(function () { 'use strict'; // Security: validate any member id before fetch / DOM use. var ID_RE = /^[A-Z]\d{6}$|^S\d{3,4}$/; var MAX_SELECTED = 6; var PARTY_COLORS = { R: '#d9534f', D: '#337ab7', I: '#5cb85c' }; var PALETTE = ['#1f77b4', '#ff7f0e', '#2ca02c', '#d62728', '#9467bd', '#8c564b']; var ALIGN_KEYS = ['Helped Republicans', 'Helped Democrats', 'Helped Both', 'Helped Neither']; var state = { manifest: null, membersById: {}, BASE: './data/', selectedIds: [], memberCache: {}, colorById: {}, currentAlignClass: 'Helped Republicans', skipPush: false }; var els = {}; var charts = {}; document.addEventListener('DOMContentLoaded', init); function init() { var root = document.getElementById('polisci-root'); if (!root) return; state.BASE = root.dataset.base || './data/'; els.root = root; els.search = document.getElementById('member-search'); els.results = document.getElementById('member-results'); els.pills = document.getElementById('selected-pills'); els.emptyHint = document.getElementById('compare-empty-hint'); els.lastGen = document.getElementById('last-generated'); els.filterChamber = document.getElementById('filter-chamber'); els.filterParty = document.getElementById('filter-party'); els.filterState = document.getElementById('filter-state-select'); els.filterReset = document.getElementById('filter-reset'); els.sidebarToggle = document.getElementById('sidebar-toggle'); els.sidebar = document.getElementById('sidebar'); els.alignSwitcher = document.getElementById('align-class-switcher'); var inline = document.getElementById('polisci-manifest'); if (inline && inline.textContent) { try { onManifest(JSON.parse(inline.textContent)); return; } catch (e) { /* fall through */ } } fetch(state.BASE + 'manifest.json', { credentials: 'omit' }) .then(function (r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); }) .then(onManifest) .catch(function (e) { showFatal('Failed to load manifest: ' + e.message + ' — if you opened this file directly, serve it over HTTP.'); }); } function showFatal(msg) { els.root.replaceChildren(); var p = document.createElement('p'); p.style.padding = '20px'; p.style.color = '#c0392b'; p.textContent = msg; els.root.appendChild(p); } function onManifest(m) { state.manifest = m; var members = m.members || []; for (var i = 0; i < members.length; i++) { state.membersById[members[i].id] = members[i]; } els.lastGen.textContent = m.generated_at || ''; populateStateOptions(members); wireEvents(); buildCharts(); initFromURL(); window.addEventListener('popstate', onPopState); } function populateStateOptions(members) { var states = {}; for (var i = 0; i < members.length; i++) states[members[i].s] = true; var keys = Object.keys(states).sort(); els.filterState.replaceChildren(); for (var j = 0; j < keys.length; j++) { var opt = document.createElement('option'); opt.value = keys[j]; opt.textContent = keys[j]; opt.selected = true; els.filterState.appendChild(opt); } } function wireEvents() { var chamberInputs = els.filterChamber.querySelectorAll('input[type=checkbox]'); var partyInputs = els.filterParty.querySelectorAll('input[type=checkbox]'); chamberInputs.forEach(function (i) { i.addEventListener('change', refreshFilter); }); partyInputs.forEach(function (i) { i.addEventListener('change', refreshFilter); }); els.filterState.addEventListener('change', refreshFilter); els.filterReset.addEventListener('click', resetFilters); els.search.addEventListener('input', refreshFilter); els.search.addEventListener('focus', refreshFilter); document.addEventListener('click', function (e) { if (!els.results.contains(e.target) && e.target !== els.search) { els.results.classList.add('is-hidden'); els.search.setAttribute('aria-expanded', 'false'); } }); if (els.sidebarToggle) { els.sidebarToggle.addEventListener('click', function () { var open = els.sidebar.classList.toggle('is-open'); els.sidebarToggle.setAttribute('aria-expanded', open ? 'true' : 'false'); }); } els.alignSwitcher.addEventListener('change', function () { state.currentAlignClass = els.alignSwitcher.value; renderAlignmentTime(); }); } function resetFilters() { var inputs = els.filterChamber.querySelectorAll('input[type=checkbox]'); inputs.forEach(function (i) { i.checked = true; }); inputs = els.filterParty.querySelectorAll('input[type=checkbox]'); inputs.forEach(function (i) { i.checked = true; }); for (var j = 0; j < els.filterState.options.length; j++) { els.filterState.options[j].selected = true; } els.search.value = ''; refreshFilter(); } function getCheckedValues(group) { var out = []; group.querySelectorAll('input[type=checkbox]:checked').forEach(function (i) { out.push(i.value); }); return out; } function getSelectedStates() { var out = []; for (var i = 0; i < els.filterState.options.length; i++) { if (els.filterState.options[i].selected) out.push(els.filterState.options[i].value); } return out; } function initialsOf(name) { var parts = name.split(/\s+/); var out = ''; for (var i = 0; i < parts.length; i++) { if (parts[i]) out += parts[i].charAt(0).toUpperCase(); } return out; } function refreshFilter() { var chambers = getCheckedValues(els.filterChamber); var parties = getCheckedValues(els.filterParty); var states = getSelectedStates(); var q = (els.search.value || '').trim(); var qLower = q.toLowerCase(); var qUpper = q.toUpperCase(); var initialsMode = q.length > 0 && q.length <= 4 && /^[A-Za-z]+$/.test(q); var members = state.manifest.members; var matches = []; for (var i = 0; i < members.length && matches.length < 50; i++) { var m = members[i]; if (chambers.indexOf(m.c) < 0) continue; if (parties.indexOf(m.p) < 0) continue; if (states.indexOf(m.s) < 0) continue; if (q) { var nameLower = m.n.toLowerCase(); var hit = nameLower.indexOf(qLower) >= 0; if (!hit && initialsMode) { if (initialsOf(m.n) === qUpper) hit = true; } if (!hit) continue; } matches.push(m); } matches.sort(function (a, b) { return a.n.localeCompare(b.n); }); renderResults(matches, q); } function renderResults(matches, q) { els.results.replaceChildren(); if (!q && matches.length === state.manifest.members.length) { els.results.classList.add('is-hidden'); els.search.setAttribute('aria-expanded', 'false'); return; } if (!matches.length) { var li0 = document.createElement('li'); li0.textContent = 'No members match.'; li0.setAttribute('aria-disabled', 'true'); els.results.appendChild(li0); } else { for (var i = 0; i < matches.length; i++) { var m = matches[i]; var li = document.createElement('li'); li.setAttribute('role', 'option'); li.setAttribute('data-id', m.id); var alreadyIn = state.selectedIds.indexOf(m.id) >= 0; var atCap = state.selectedIds.length >= MAX_SELECTED; li.textContent = m.n + ' (' + m.p + '-' + m.s + ', ' + (m.c === 'H' ? 'House' : 'Senate') + ')' + (alreadyIn ? ' — selected' : (atCap ? ' — max 6' : '')); if (alreadyIn || atCap) { li.setAttribute('aria-disabled', 'true'); } else { li.addEventListener('click', onResultClick); } els.results.appendChild(li); } } els.results.classList.remove('is-hidden'); els.search.setAttribute('aria-expanded', 'true'); } function onResultClick(e) { var id = e.currentTarget.getAttribute('data-id'); if (!isValidId(id)) return; els.results.classList.add('is-hidden'); els.search.setAttribute('aria-expanded', 'false'); els.search.value = ''; addMember(id); } function isValidId(id) { return typeof id === 'string' && ID_RE.test(id) && Object.prototype.hasOwnProperty.call(state.membersById, id); } function pickColor() { var used = {}; for (var k in state.colorById) { if (Object.prototype.hasOwnProperty.call(state.colorById, k)) used[state.colorById[k]] = true; } for (var i = 0; i < PALETTE.length; i++) { if (!used[PALETTE[i]]) return PALETTE[i]; } return PALETTE[0]; } function addMember(id) { if (!isValidId(id)) return; if (state.selectedIds.indexOf(id) >= 0) return; if (state.selectedIds.length >= MAX_SELECTED) return; var version = state.manifest.version || ''; var url = state.BASE + 'members/' + encodeURIComponent(id) + '.json?v=' + encodeURIComponent(version); fetch(url, { credentials: 'omit' }) .then(function (r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); }) .then(function (member) { if (state.selectedIds.indexOf(id) >= 0) return; if (state.selectedIds.length >= MAX_SELECTED) return; state.memberCache[id] = member; state.colorById[id] = pickColor(); state.selectedIds.push(id); renderPills(); pushUrlState(); renderAllCharts(); }) .catch(function (e) { // Silent for one member load failure — surface via empty hint area. els.emptyHint.textContent = 'Failed to load member ' + id + ': ' + e.message; }); } function removeMember(id) { var idx = state.selectedIds.indexOf(id); if (idx < 0) return; state.selectedIds.splice(idx, 1); delete state.memberCache[id]; delete state.colorById[id]; renderPills(); pushUrlState(); renderAllCharts(); } function renderPills() { els.pills.replaceChildren(); for (var i = 0; i < state.selectedIds.length; i++) { var id = state.selectedIds[i]; var member = state.memberCache[id]; if (!member) continue; var color = state.colorById[id] || '#888'; var li = document.createElement('li'); li.className = 'compare-pill'; li.style.setProperty('--pill-color', color); li.style.borderColor = color; li.style.backgroundColor = color + '22'; var swatch = document.createElement('span'); swatch.className = 'compare-pill-swatch'; swatch.style.backgroundColor = color; swatch.setAttribute('aria-hidden', 'true'); li.appendChild(swatch); var nameBtn = document.createElement('button'); nameBtn.type = 'button'; nameBtn.className = 'compare-pill-name'; nameBtn.textContent = member.name + ' (' + member.party + '-' + member.state + ')'; nameBtn.title = 'Open ' + member.name + ' dashboard in new tab'; (function (mid) { nameBtn.addEventListener('click', function () { window.open('app.html?id=' + encodeURIComponent(mid), '_blank', 'noopener'); }); })(id); li.appendChild(nameBtn); var rm = document.createElement('button'); rm.type = 'button'; rm.className = 'compare-pill-remove'; rm.setAttribute('aria-label', 'Remove ' + member.name); rm.textContent = '×'; (function (mid) { rm.addEventListener('click', function () { removeMember(mid); }); })(id); li.appendChild(rm); els.pills.appendChild(li); } if (state.selectedIds.length === 0) { els.emptyHint.textContent = 'Pick up to 6 members to compare. Click a pill to open the member’s full dashboard in a new tab.'; } else { els.emptyHint.textContent = state.selectedIds.length + ' of ' + MAX_SELECTED + ' selected. Click a pill name to open that member’s full dashboard in a new tab.'; } } // ---- Charts ---- function buildCharts() { if (typeof Chart === 'undefined') return; var common = { responsive: true, maintainAspectRatio: false }; charts.alignmentTime = new Chart(document.getElementById('cmp-chart-alignment-time'), { type: 'line', data: { labels: [], datasets: [] }, options: Object.assign({}, common, { plugins: { legend: { position: 'bottom' } }, scales: { y: { beginAtZero: true } } }) }); charts.againstOwn = new Chart(document.getElementById('cmp-chart-against-own'), { type: 'line', data: { labels: [], datasets: [] }, options: Object.assign({}, common, { plugins: { legend: { position: 'bottom' } }, scales: { y: { beginAtZero: true } } }) }); charts.kpi = new Chart(document.getElementById('cmp-chart-kpi'), { type: 'bar', data: { labels: ['% against GOP', '% against Dem', 'Lone Wolf %', 'Participation %', 'Blocked Dem', 'Blocked GOP'], datasets: [] }, options: Object.assign({}, common, { plugins: { legend: { position: 'bottom' }, tooltip: { callbacks: { label: function (ctx) { var v = Number(ctx.parsed.y); if (isNaN(v)) v = Number(ctx.parsed) || 0; return String(ctx.dataset.label) + ': ' + v.toFixed(1); } } } }, scales: { y: { beginAtZero: true } } }) }); charts.defection = new Chart(document.getElementById('cmp-chart-defection'), { type: 'scatter', data: { datasets: [] }, options: Object.assign({}, common, { plugins: { legend: { display: false }, tooltip: { callbacks: { label: function (ctx) { var p = ctx.raw || {}; return String(ctx.dataset.label) + ': (' + (Number(p.x) || 0).toFixed(1) + '% R, ' + (Number(p.y) || 0).toFixed(1) + '% D)'; } } } }, scales: { x: { beginAtZero: true, title: { display: true, text: '% against GOP majority' } }, y: { beginAtZero: true, title: { display: true, text: '% against Dem majority' } } } }) }); charts.voteDist = new Chart(document.getElementById('cmp-chart-vote-dist'), { type: 'bar', data: { labels: ['Yea', 'Nay', 'Present', 'Not Voting'], datasets: [] }, options: Object.assign({}, common, { plugins: { legend: { position: 'bottom' } }, scales: { y: { beginAtZero: true } } }) }); } function renderAllCharts() { renderAlignmentTime(); renderAgainstOwn(); renderKpi(); renderDefection(); renderVoteDist(); } function unionMonths() { var set = {}; for (var i = 0; i < state.selectedIds.length; i++) { var m = state.memberCache[state.selectedIds[i]]; var months = m && m.metrics && Array.isArray(m.metrics.months) ? m.metrics.months : []; for (var j = 0; j < months.length; j++) set[months[j]] = true; } return Object.keys(set).sort(); } function replaceArray(target, source) { target.length = 0; for (var i = 0; i < source.length; i++) target.push(source[i]); } function monthlySeries(member, alignClass, months) { var mMonths = (member.metrics && member.metrics.months) || []; var arr = (member.metrics && member.metrics.monthly && member.metrics.monthly[alignClass]) || []; var idx = {}; for (var i = 0; i < mMonths.length; i++) idx[mMonths[i]] = i; return months.map(function (mo) { var k = idx[mo]; return (k != null && arr[k] != null) ? Number(arr[k]) : 0; }); } function renderAlignmentTime() { if (!charts.alignmentTime) return; var months = unionMonths(); replaceArray(charts.alignmentTime.data.labels, months); var datasets = []; for (var i = 0; i < state.selectedIds.length; i++) { var id = state.selectedIds[i]; var member = state.memberCache[id]; if (!member) continue; var color = state.colorById[id]; datasets.push({ label: member.name, data: monthlySeries(member, state.currentAlignClass, months), borderColor: color, backgroundColor: color + '33', tension: 0.2, fill: false }); } replaceArray(charts.alignmentTime.data.datasets, datasets); charts.alignmentTime.update('none'); } // "Voted against own party" per month is NOT directly emitted by analyze.py. // We use monthly['Helped Neither'] as a proxy; caveat noted in NOTES.md item 6. function renderAgainstOwn() { if (!charts.againstOwn) return; var months = unionMonths(); replaceArray(charts.againstOwn.data.labels, months); var datasets = []; for (var i = 0; i < state.selectedIds.length; i++) { var id = state.selectedIds[i]; var member = state.memberCache[id]; if (!member) continue; var color = state.colorById[id]; datasets.push({ label: member.name, data: monthlySeries(member, 'Helped Neither', months), borderColor: color, backgroundColor: color + '33', tension: 0.2, fill: false }); } replaceArray(charts.againstOwn.data.datasets, datasets); charts.againstOwn.update('none'); } function kpiValues(member) { var x = (member && member.metrics) || {}; var voting = Number(x.voting) || 0; var total = Number(x.total) || 0; var againstGop = Number(x.voted_against_gop) || 0; var withGop = Number(x.voted_with_gop) || 0; var againstDem = Number(x.voted_against_dem) || 0; var withDem = Number(x.voted_with_dem) || 0; var lone = Number(x.lone_wolf) || 0; var denomGop = againstGop + withGop; var denomDem = againstDem + withDem; return [ denomGop > 0 ? (100 * againstGop / denomGop) : 0, denomDem > 0 ? (100 * againstDem / denomDem) : 0, voting > 0 ? (100 * lone / voting) : 0, total > 0 ? (100 * voting / total) : 0, Number(x.blocked_dem_count) || 0, Number(x.blocked_rep_count) || 0 ]; } function renderKpi() { if (!charts.kpi) return; var datasets = []; for (var i = 0; i < state.selectedIds.length; i++) { var id = state.selectedIds[i]; var member = state.memberCache[id]; if (!member) continue; var color = state.colorById[id]; datasets.push({ label: member.name, data: kpiValues(member), backgroundColor: color, borderColor: color }); } replaceArray(charts.kpi.data.datasets, datasets); charts.kpi.update('none'); } function renderDefection() { if (!charts.defection) return; var datasets = []; for (var i = 0; i < state.selectedIds.length; i++) { var id = state.selectedIds[i]; var member = state.memberCache[id]; if (!member) continue; var vals = kpiValues(member); var partyColor = PARTY_COLORS[member.party] || '#888'; datasets.push({ label: member.name, data: [{ x: vals[0], y: vals[1] }], backgroundColor: partyColor, borderColor: partyColor, pointBackgroundColor: partyColor, pointRadius: 8, pointHoverRadius: 10 }); } replaceArray(charts.defection.data.datasets, datasets); charts.defection.update('none'); } function voteDistValues(member) { var mv = (member && member.metrics && member.metrics.member) || {}; // Some procedural votes use Aye/No instead of Yea/Nay — sum them for display. var yea = (Number(mv.Yea) || 0) + (Number(mv.Aye) || 0); var nay = (Number(mv.Nay) || 0) + (Number(mv.No) || 0); var present = Number(mv.Present) || 0; var nv = Number(mv['Not Voting']) || 0; return [yea, nay, present, nv]; } function renderVoteDist() { if (!charts.voteDist) return; var datasets = []; for (var i = 0; i < state.selectedIds.length; i++) { var id = state.selectedIds[i]; var member = state.memberCache[id]; if (!member) continue; var color = state.colorById[id]; datasets.push({ label: member.name, data: voteDistValues(member), backgroundColor: color, borderColor: color }); } replaceArray(charts.voteDist.data.datasets, datasets); charts.voteDist.update('none'); } // ---- URL state ---- function parseIdsParam() { var params = new URLSearchParams(window.location.search); var raw = params.get('ids'); if (!raw) return []; var parts = raw.split(','); var out = []; for (var i = 0; i < parts.length && out.length < MAX_SELECTED; i++) { var id = parts[i]; if (isValidId(id) && out.indexOf(id) < 0) out.push(id); } return out; } function pushUrlState() { if (state.skipPush) { state.skipPush = false; return; } var u; if (state.selectedIds.length === 0) { u = window.location.pathname; } else { u = '?ids=' + state.selectedIds.join(','); } history.pushState({ ids: state.selectedIds.slice() }, '', u); } function initFromURL() { refreshFilter(); var ids = parseIdsParam(); if (ids.length === 0) { renderPills(); renderAllCharts(); return; } // Sequentially add (each fetch independent; order preserved via URL parse). state.skipPush = true; var pending = ids.slice(); var loaded = []; function next() { if (pending.length === 0) { renderPills(); renderAllCharts(); return; } var id = pending.shift(); var version = state.manifest.version || ''; var url = state.BASE + 'members/' + encodeURIComponent(id) + '.json?v=' + encodeURIComponent(version); fetch(url, { credentials: 'omit' }) .then(function (r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); }) .then(function (member) { state.memberCache[id] = member; state.colorById[id] = pickColor(); state.selectedIds.push(id); loaded.push(id); }) .catch(function () { /* skip broken id */ }) .then(next); } next(); } function onPopState() { var ids = parseIdsParam(); // Reset selection and reload from URL. state.selectedIds = []; state.memberCache = {}; state.colorById = {}; state.skipPush = true; if (ids.length === 0) { renderPills(); renderAllCharts(); return; } var pending = ids.slice(); function next() { if (pending.length === 0) { renderPills(); renderAllCharts(); return; } var id = pending.shift(); var version = state.manifest.version || ''; var url = state.BASE + 'members/' + encodeURIComponent(id) + '.json?v=' + encodeURIComponent(version); fetch(url, { credentials: 'omit' }) .then(function (r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); }) .then(function (member) { state.memberCache[id] = member; state.colorById[id] = pickColor(); state.selectedIds.push(id); }) .catch(function () {}) .then(next); } next(); } })();