| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689 |
- (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();
- }
- })();
|