app.js 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718
  1. (function () {
  2. 'use strict';
  3. // Security: validate any member id before fetch / DOM use.
  4. var ID_RE = /^[A-Z]\d{6}$|^S\d{3,4}$/;
  5. var LS_KEY = 'polisci:v119:lastMember';
  6. var COLORS = {
  7. R: '#d9534f', D: '#337ab7', I: '#5cb85c',
  8. helpedR: '#d9534f', helpedD: '#337ab7',
  9. helpedBoth: '#8e44ad', helpedNeither: '#7f8c8d',
  10. green: '#27ae60', red: '#c0392b'
  11. };
  12. var ALIGN_KEYS = ['Helped Republicans', 'Helped Democrats', 'Helped Both', 'Helped Neither'];
  13. var ALIGN_COLORS = [COLORS.helpedR, COLORS.helpedD, COLORS.helpedBoth, COLORS.helpedNeither];
  14. var state = {
  15. manifest: null,
  16. membersById: {}, // id -> manifest entry
  17. BASE: './data/',
  18. currentId: null,
  19. currentMember: null,
  20. sortCol: null,
  21. sortAsc: true,
  22. selectedStates: null, // null => all
  23. skipPush: false,
  24. urlReplaceTimer: 0
  25. };
  26. var els = {};
  27. var charts = {};
  28. document.addEventListener('DOMContentLoaded', init);
  29. function init() {
  30. var root = document.getElementById('polisci-root');
  31. if (!root) return;
  32. state.BASE = root.dataset.base || './data/';
  33. els.root = root;
  34. els.note = document.getElementById('member-note');
  35. els.summary = document.getElementById('member-summary');
  36. els.kpis = document.getElementById('kpi-cards');
  37. els.search = document.getElementById('member-search');
  38. els.results = document.getElementById('member-results');
  39. els.lastGen = document.getElementById('last-generated');
  40. els.filterChamber = document.getElementById('filter-chamber');
  41. els.filterParty = document.getElementById('filter-party');
  42. els.filterState = document.getElementById('filter-state-select');
  43. els.filterReset = document.getElementById('filter-reset');
  44. els.sidebarToggle = document.getElementById('sidebar-toggle');
  45. els.sidebar = document.getElementById('sidebar');
  46. els.tableBody = document.querySelector('#vote-table tbody');
  47. els.tableHead = document.querySelector('#vote-table thead');
  48. var inline = document.getElementById('polisci-manifest');
  49. if (inline && inline.textContent) {
  50. try { onManifest(JSON.parse(inline.textContent)); return; }
  51. catch (e) { /* fall through to fetch */ }
  52. }
  53. var url = state.BASE + 'manifest.json';
  54. fetch(url, { credentials: 'omit' })
  55. .then(function (r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); })
  56. .then(onManifest)
  57. .catch(function (e) { showFatal('Failed to load manifest: ' + e.message + ' — if you opened this file directly, serve it over HTTP (e.g., `cd results/119 && python3 -m http.server`).'); });
  58. }
  59. function showFatal(msg) {
  60. els.root.replaceChildren();
  61. var p = document.createElement('p');
  62. p.style.padding = '20px';
  63. p.style.color = '#c0392b';
  64. p.textContent = msg;
  65. els.root.appendChild(p);
  66. }
  67. function onManifest(m) {
  68. state.manifest = m;
  69. var members = m.members || [];
  70. for (var i = 0; i < members.length; i++) {
  71. state.membersById[members[i].id] = members[i];
  72. }
  73. els.lastGen.textContent = m.generated_at || '';
  74. populateStateOptions(members);
  75. wireEvents();
  76. buildCharts();
  77. initSortHeaders();
  78. restoreFiltersFromURL();
  79. initialSelect();
  80. window.addEventListener('popstate', onPopState);
  81. }
  82. function populateStateOptions(members) {
  83. var states = {};
  84. for (var i = 0; i < members.length; i++) states[members[i].s] = true;
  85. var keys = Object.keys(states).sort();
  86. els.filterState.replaceChildren();
  87. for (var j = 0; j < keys.length; j++) {
  88. var opt = document.createElement('option');
  89. opt.value = keys[j];
  90. opt.textContent = keys[j];
  91. opt.selected = true;
  92. els.filterState.appendChild(opt);
  93. }
  94. }
  95. function wireEvents() {
  96. var chamberInputs = els.filterChamber.querySelectorAll('input[type=checkbox]');
  97. var partyInputs = els.filterParty.querySelectorAll('input[type=checkbox]');
  98. chamberInputs.forEach(function (i) { i.addEventListener('change', onFilterChange); });
  99. partyInputs.forEach(function (i) { i.addEventListener('change', onFilterChange); });
  100. els.filterState.addEventListener('change', onFilterChange);
  101. els.filterReset.addEventListener('click', resetFilters);
  102. els.search.addEventListener('input', onFilterChange);
  103. els.search.addEventListener('focus', refreshFilter);
  104. document.addEventListener('click', function (e) {
  105. if (!els.results.contains(e.target) && e.target !== els.search) {
  106. els.results.classList.add('is-hidden');
  107. els.search.setAttribute('aria-expanded', 'false');
  108. }
  109. });
  110. if (els.sidebarToggle) {
  111. els.sidebarToggle.addEventListener('click', function () {
  112. var open = els.sidebar.classList.toggle('is-open');
  113. els.sidebarToggle.setAttribute('aria-expanded', open ? 'true' : 'false');
  114. });
  115. }
  116. }
  117. function onFilterChange() {
  118. refreshFilter();
  119. debounceUrlReplace();
  120. }
  121. function resetFilters() {
  122. var inputs = els.filterChamber.querySelectorAll('input[type=checkbox]');
  123. inputs.forEach(function (i) { i.checked = true; });
  124. inputs = els.filterParty.querySelectorAll('input[type=checkbox]');
  125. inputs.forEach(function (i) { i.checked = true; });
  126. for (var j = 0; j < els.filterState.options.length; j++) {
  127. els.filterState.options[j].selected = true;
  128. }
  129. els.search.value = '';
  130. refreshFilter();
  131. debounceUrlReplace();
  132. }
  133. function getCheckedValues(group) {
  134. var out = [];
  135. var inputs = group.querySelectorAll('input[type=checkbox]:checked');
  136. inputs.forEach(function (i) { out.push(i.value); });
  137. return out;
  138. }
  139. function getSelectedStates() {
  140. var out = [];
  141. for (var i = 0; i < els.filterState.options.length; i++) {
  142. if (els.filterState.options[i].selected) out.push(els.filterState.options[i].value);
  143. }
  144. return out;
  145. }
  146. function initialsOf(name) {
  147. var parts = name.split(/\s+/);
  148. var out = '';
  149. for (var i = 0; i < parts.length; i++) {
  150. if (parts[i]) out += parts[i].charAt(0).toUpperCase();
  151. }
  152. return out;
  153. }
  154. function refreshFilter() {
  155. var chambers = getCheckedValues(els.filterChamber);
  156. var parties = getCheckedValues(els.filterParty);
  157. var states = getSelectedStates();
  158. var q = (els.search.value || '').trim();
  159. var qLower = q.toLowerCase();
  160. var qUpper = q.toUpperCase();
  161. var initialsMode = q.length > 0 && q.length <= 4 && /^[A-Za-z]+$/.test(q);
  162. var members = state.manifest.members;
  163. var matches = [];
  164. for (var i = 0; i < members.length && matches.length < 50; i++) {
  165. var m = members[i];
  166. if (chambers.indexOf(m.c) < 0) continue;
  167. if (parties.indexOf(m.p) < 0) continue;
  168. if (states.indexOf(m.s) < 0) continue;
  169. if (q) {
  170. var nameLower = m.n.toLowerCase();
  171. var hit = nameLower.indexOf(qLower) >= 0;
  172. if (!hit && initialsMode) {
  173. if (initialsOf(m.n) === qUpper) hit = true;
  174. }
  175. if (!hit) continue;
  176. }
  177. matches.push(m);
  178. }
  179. matches.sort(function (a, b) { return a.n.localeCompare(b.n); });
  180. renderResults(matches, q);
  181. }
  182. function renderResults(matches, q) {
  183. els.results.replaceChildren();
  184. if (!q && matches.length === state.manifest.members.length) {
  185. els.results.classList.add('is-hidden');
  186. els.search.setAttribute('aria-expanded', 'false');
  187. return;
  188. }
  189. if (!matches.length) {
  190. var li0 = document.createElement('li');
  191. li0.textContent = 'No members match.';
  192. li0.setAttribute('aria-disabled', 'true');
  193. els.results.appendChild(li0);
  194. } else {
  195. for (var i = 0; i < matches.length; i++) {
  196. var m = matches[i];
  197. var li = document.createElement('li');
  198. li.setAttribute('role', 'option');
  199. li.setAttribute('data-id', m.id);
  200. li.textContent = m.n + ' (' + m.p + '-' + m.s + ', ' + (m.c === 'H' ? 'House' : 'Senate') + ')';
  201. li.addEventListener('click', onResultClick);
  202. els.results.appendChild(li);
  203. }
  204. }
  205. els.results.classList.remove('is-hidden');
  206. els.search.setAttribute('aria-expanded', 'true');
  207. }
  208. function onResultClick(e) {
  209. var id = e.currentTarget.getAttribute('data-id');
  210. if (!isValidId(id)) return;
  211. els.results.classList.add('is-hidden');
  212. els.search.setAttribute('aria-expanded', 'false');
  213. selectMember(id);
  214. }
  215. function isValidId(id) {
  216. return typeof id === 'string' && ID_RE.test(id) && Object.prototype.hasOwnProperty.call(state.membersById, id);
  217. }
  218. function selectMember(id) {
  219. if (!isValidId(id)) return;
  220. var version = state.manifest.version || '';
  221. var url = state.BASE + 'members/' + encodeURIComponent(id) + '.json?v=' + encodeURIComponent(version);
  222. fetch(url, { credentials: 'omit' })
  223. .then(function (r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); })
  224. .then(function (member) {
  225. state.currentId = id;
  226. state.currentMember = member;
  227. renderMember(member);
  228. try { localStorage.setItem(LS_KEY, id); } catch (_) {}
  229. if (!state.skipPush) {
  230. var u = buildShareUrl(id);
  231. history.pushState({ id: id }, '', u);
  232. }
  233. state.skipPush = false;
  234. document.title = member.name + ' — 119th Congress Voting Dashboard';
  235. })
  236. .catch(function (e) { showError('Failed to load member ' + id + ': ' + e.message); });
  237. }
  238. function showError(msg) {
  239. els.summary.replaceChildren();
  240. var p = document.createElement('p');
  241. p.style.color = '#c0392b';
  242. p.textContent = msg;
  243. els.summary.appendChild(p);
  244. }
  245. function renderMember(member) {
  246. renderSummary(member);
  247. renderNote(member);
  248. renderKPIs(member);
  249. updateCharts(member);
  250. renderTable(member.metrics.rows || []);
  251. }
  252. function renderSummary(m) {
  253. els.summary.replaceChildren();
  254. var h = document.createElement('h2');
  255. h.textContent = m.name;
  256. h.style.margin = '0 0 6px';
  257. els.summary.appendChild(h);
  258. var sub = document.createElement('div');
  259. sub.style.color = 'var(--ps-text-muted)';
  260. sub.style.fontSize = '0.92rem';
  261. var chamberLabel = m.chamber === 'house' ? 'House' : 'Senate';
  262. var distSuffix = (m.district && m.chamber === 'house') ? '-' + m.district : '';
  263. var endDate = m.served_to || 'present';
  264. sub.textContent = chamberLabel + ' · ' + m.party + '-' + m.state + distSuffix +
  265. ' · served ' + (m.served_from || '?') + ' – ' + endDate;
  266. els.summary.appendChild(sub);
  267. }
  268. var TERRITORY_NAMES = {
  269. AS: 'American Samoa', DC: 'the District of Columbia', GU: 'Guam',
  270. MP: 'the Northern Mariana Islands', PR: 'Puerto Rico', VI: 'the U.S. Virgin Islands'
  271. };
  272. function memberLink(bg) {
  273. // Returns a span (or anchor if the bg is in the manifest) — caller appends to a node.
  274. var entry = state.membersById[bg];
  275. if (!entry) {
  276. var span = document.createElement('span');
  277. span.textContent = bg;
  278. return span;
  279. }
  280. var a = document.createElement('a');
  281. a.textContent = entry.n + ' (' + entry.p + '-' + entry.s + ')';
  282. a.href = 'app.html?id=' + encodeURIComponent(bg);
  283. return a;
  284. }
  285. function appendText(node, text) {
  286. node.appendChild(document.createTextNode(text));
  287. }
  288. function renderNote(m) {
  289. els.note.replaceChildren();
  290. els.note.classList.add('is-hidden');
  291. var voting = (m.metrics && m.metrics.voting) || 0;
  292. var total = (m.metrics && m.metrics.total) || 0;
  293. var isDelegate = m.is_delegate === true;
  294. var partial = m.served_partial === true;
  295. var term = m.congress_term || {};
  296. var startYear = term.startYear;
  297. var endYear = term.endYear;
  298. var died = !!m.death_year;
  299. // Never-seated: appears in vote data (total > 0) but cast no Yea/Nay AND
  300. // was not a delegate, did not die, and was not in the directory as a
  301. // currently-serving member with prior tenure. Examples: Gaetz (119th).
  302. var unseated = (
  303. !isDelegate && !partial && voting === 0 && total > 0 && !died
  304. );
  305. if (isDelegate) {
  306. var terr = TERRITORY_NAMES[m.state] || m.state;
  307. appendText(els.note,
  308. 'Note: This member is the non-voting delegate from ' + terr +
  309. '. House delegates may vote in committees and on amendments in the ' +
  310. 'Committee of the Whole, but cannot vote on final passage on the ' +
  311. 'House floor. Their low participation rate is structural, not absenteeism.');
  312. els.note.classList.remove('is-hidden');
  313. return;
  314. }
  315. if (unseated) {
  316. appendText(els.note,
  317. 'Note: This member appears once in the 119th Congress roll-call data ' +
  318. '(typically on the opening-day quorum call) but cast no recorded ' +
  319. 'votes — likely a member-elect who resigned, declined the seat, or ' +
  320. 'was otherwise never seated. The Total Votes denominator (' + total +
  321. ') reflects all House roll calls, not their attendance.');
  322. if (m.replaced_by) {
  323. appendText(els.note, ' The seat was subsequently filled by ');
  324. els.note.appendChild(memberLink(m.replaced_by));
  325. appendText(els.note, '.');
  326. }
  327. els.note.classList.remove('is-hidden');
  328. return;
  329. }
  330. if (died) {
  331. appendText(els.note, 'Note: This member died in office in ' + m.death_year + '. ');
  332. if (startYear) appendText(els.note, 'Their 119th-Congress service ran from ' + startYear + ' until their death. ');
  333. appendText(els.note, 'KPIs reflect only the votes they cast before then');
  334. if (m.replaced_by) {
  335. appendText(els.note, '. The seat was subsequently filled by ');
  336. els.note.appendChild(memberLink(m.replaced_by));
  337. }
  338. appendText(els.note, '.');
  339. els.note.classList.remove('is-hidden');
  340. return;
  341. }
  342. if (m.replaced_by) {
  343. appendText(els.note, 'Note: This member left office during the 119th Congress');
  344. if (startYear && endYear) appendText(els.note, ' (served ' + startYear + '–' + endYear + ')');
  345. appendText(els.note, '. They were succeeded by ');
  346. els.note.appendChild(memberLink(m.replaced_by));
  347. appendText(els.note, '. KPIs reflect only the portion of the term they served.');
  348. els.note.classList.remove('is-hidden');
  349. return;
  350. }
  351. if (m.replaces) {
  352. appendText(els.note, 'Note: This member entered the 119th Congress mid-term, succeeding ');
  353. els.note.appendChild(memberLink(m.replaces));
  354. appendText(els.note, '. KPIs reflect only the portion of the term they have served so far.');
  355. els.note.classList.remove('is-hidden');
  356. return;
  357. }
  358. if (partial || total === 0) {
  359. var endDate = m.served_to || 'present';
  360. appendText(els.note,
  361. 'This member did not cast roll-call votes during the period analyzed ' +
  362. '(served ' + (m.served_from || '?') + ' – ' + endDate + '). ' +
  363. 'The dashboards below reflect that absence.');
  364. els.note.classList.remove('is-hidden');
  365. }
  366. }
  367. function pct(n, d) {
  368. if (!d) return '0%';
  369. return ((n / d) * 100).toFixed(1) + '%';
  370. }
  371. function renderKPIs(m) {
  372. var x = m.metrics || {};
  373. var voting = x.voting || 0;
  374. var kpis = [
  375. ['Total Votes', String(x.total || 0)],
  376. ['Yea / Nay', (x.yeas || 0) + ' / ' + (x.nays || 0)],
  377. ['Participation', pct(voting, x.total || 0)],
  378. ['Voted With GOP', (x.voted_with_gop || 0) + ' (' + pct(x.voted_with_gop || 0, voting) + ')'],
  379. ['Voted With Dem', (x.voted_with_dem || 0) + ' (' + pct(x.voted_with_dem || 0, voting) + ')'],
  380. ['Voted Against GOP', (x.voted_against_gop || 0) + ' (' + pct(x.voted_against_gop || 0, voting) + ')'],
  381. ['Voted Against Dem', (x.voted_against_dem || 0) + ' (' + pct(x.voted_against_dem || 0, voting) + ')'],
  382. ['Lone Wolf Votes', String(x.lone_wolf || 0)]
  383. ];
  384. var cards = els.kpis.querySelectorAll('.kpi-card');
  385. for (var i = 0; i < cards.length; i++) {
  386. var label = cards[i].querySelector('.kpi-label');
  387. var value = cards[i].querySelector('.kpi-value');
  388. if (i < kpis.length) {
  389. label.textContent = kpis[i][0];
  390. value.textContent = kpis[i][1];
  391. } else {
  392. label.textContent = '';
  393. value.textContent = '';
  394. }
  395. }
  396. }
  397. function buildCharts() {
  398. if (typeof Chart === 'undefined') return;
  399. var common = { responsive: true, maintainAspectRatio: false };
  400. charts.voteDist = new Chart(document.getElementById('chart-vote-dist'), {
  401. type: 'bar',
  402. data: { labels: [], datasets: [{ label: 'Member vote', data: [], backgroundColor: ['#27ae60', '#c0392b', '#f39c12', '#7f8c8d'] }] },
  403. options: Object.assign({}, common, { plugins: { legend: { display: false } }, scales: { y: { beginAtZero: true } } })
  404. });
  405. charts.alignment = new Chart(document.getElementById('chart-alignment'), {
  406. type: 'doughnut',
  407. data: { labels: ALIGN_KEYS.slice(), datasets: [{ data: [0, 0, 0, 0], backgroundColor: ALIGN_COLORS.slice() }] },
  408. options: Object.assign({}, common, {
  409. plugins: {
  410. legend: { position: 'bottom' },
  411. tooltip: {
  412. callbacks: {
  413. label: function (ctx) {
  414. var dataArr = ctx.dataset.data || [];
  415. var sum = 0;
  416. for (var i = 0; i < dataArr.length; i++) sum += Number(dataArr[i]) || 0;
  417. var v = Number(ctx.parsed) || 0;
  418. var p = sum > 0 ? ((v / sum) * 100).toFixed(1) + '%' : '0%';
  419. return String(ctx.label) + ': ' + v + ' (' + p + ')';
  420. }
  421. }
  422. }
  423. }
  424. })
  425. });
  426. charts.blocked = new Chart(document.getElementById('chart-blocked'), {
  427. type: 'bar',
  428. data: {
  429. labels: ['Dem-Backed', 'GOP-Backed'],
  430. datasets: [{ label: 'Blocked', data: [0, 0], backgroundColor: [COLORS.helpedD, COLORS.helpedR] }]
  431. },
  432. options: Object.assign({}, common, { indexAxis: 'y', plugins: { legend: { display: false } }, scales: { x: { beginAtZero: true } } })
  433. });
  434. charts.alignmentTime = new Chart(document.getElementById('chart-alignment-time'), {
  435. type: 'line',
  436. data: {
  437. labels: [],
  438. datasets: ALIGN_KEYS.map(function (k, i) {
  439. return {
  440. label: k, data: [], borderColor: ALIGN_COLORS[i], backgroundColor: ALIGN_COLORS[i],
  441. tension: 0.2, fill: false
  442. };
  443. })
  444. },
  445. options: Object.assign({}, common, { plugins: { legend: { position: 'bottom' } }, scales: { y: { beginAtZero: true } } })
  446. });
  447. charts.withAgainst = new Chart(document.getElementById('chart-with-against'), {
  448. type: 'bar',
  449. data: {
  450. labels: ['GOP majority', 'Dem majority'],
  451. datasets: [
  452. { label: 'Voted With', data: [0, 0], backgroundColor: COLORS.green },
  453. { label: 'Voted Against', data: [0, 0], backgroundColor: COLORS.red }
  454. ]
  455. },
  456. options: Object.assign({}, common, { plugins: { legend: { position: 'bottom' } }, scales: { y: { beginAtZero: true } } })
  457. });
  458. }
  459. function updateCharts(member) {
  460. if (typeof Chart === 'undefined') return;
  461. var x = member.metrics || {};
  462. var mv = x.member || {};
  463. var mvKeys = ['Yea', 'Nay', 'Present', 'Not Voting'];
  464. charts.voteDist.data.labels = mvKeys;
  465. charts.voteDist.data.datasets[0].data = mvKeys.map(function (k) { return Number(mv[k]) || 0; });
  466. charts.voteDist.update('none');
  467. var al = x.alignment || {};
  468. charts.alignment.data.datasets[0].data = ALIGN_KEYS.map(function (k) { return Number(al[k]) || 0; });
  469. charts.alignment.update('none');
  470. charts.blocked.data.datasets[0].data = [Number(x.blocked_dem_count) || 0, Number(x.blocked_rep_count) || 0];
  471. charts.blocked.update('none');
  472. var months = Array.isArray(x.months) ? x.months : [];
  473. var monthly = x.monthly || {};
  474. charts.alignmentTime.data.labels = months.slice();
  475. for (var i = 0; i < ALIGN_KEYS.length; i++) {
  476. var arr = monthly[ALIGN_KEYS[i]];
  477. charts.alignmentTime.data.datasets[i].data = Array.isArray(arr) ? arr.slice() : months.map(function () { return 0; });
  478. }
  479. charts.alignmentTime.update('none');
  480. charts.withAgainst.data.datasets[0].data = [Number(x.voted_with_gop) || 0, Number(x.voted_with_dem) || 0];
  481. charts.withAgainst.data.datasets[1].data = [Number(x.voted_against_gop) || 0, Number(x.voted_against_dem) || 0];
  482. charts.withAgainst.update('none');
  483. }
  484. // ---- Table ----
  485. var COL_DEFS = [
  486. { key: 'y', numeric: true },
  487. { key: 'r', numeric: true },
  488. { key: 'd', numeric: false },
  489. { key: 'ln', numeric: false },
  490. { key: 'q', numeric: false },
  491. { key: 'ds', numeric: false },
  492. { key: 'rs', numeric: false },
  493. { key: 'm', numeric: false },
  494. { key: 'ry_rn', numeric: false },
  495. { key: 'dy_dn', numeric: false },
  496. { key: 'a', numeric: false },
  497. { key: 'b', numeric: false }
  498. ];
  499. function initSortHeaders() {
  500. var ths = els.tableHead.querySelectorAll('th');
  501. for (var i = 0; i < ths.length; i++) {
  502. ths[i].style.cursor = 'pointer';
  503. ths[i].setAttribute('data-col-index', String(i));
  504. ths[i].addEventListener('click', onSortClick);
  505. }
  506. }
  507. function onSortClick(e) {
  508. var idx = Number(e.currentTarget.getAttribute('data-col-index'));
  509. if (isNaN(idx)) return;
  510. if (state.sortCol === idx) {
  511. state.sortAsc = !state.sortAsc;
  512. } else {
  513. state.sortCol = idx;
  514. state.sortAsc = true;
  515. }
  516. updateSortIndicators();
  517. if (state.currentMember) renderTable(state.currentMember.metrics.rows || []);
  518. }
  519. function updateSortIndicators() {
  520. var ths = els.tableHead.querySelectorAll('th');
  521. for (var i = 0; i < ths.length; i++) {
  522. var base = ths[i].textContent.replace(/[▲▼]\s*$/, '').trim();
  523. ths[i].textContent = base + (i === state.sortCol ? (state.sortAsc ? ' ▲' : ' ▼') : '');
  524. }
  525. }
  526. function renderTable(rows) {
  527. var sorted = rows.slice();
  528. if (state.sortCol != null && state.sortCol >= 0 && state.sortCol < COL_DEFS.length) {
  529. var col = COL_DEFS[state.sortCol];
  530. var dir = state.sortAsc ? 1 : -1;
  531. sorted.sort(function (a, b) {
  532. var va, vb;
  533. if (col.key === 'ry_rn') { va = (a.ry || 0) - (a.rn || 0); vb = (b.ry || 0) - (b.rn || 0); }
  534. else if (col.key === 'dy_dn') { va = (a.dy || 0) - (a.dn || 0); vb = (b.dy || 0) - (b.dn || 0); }
  535. else { va = a[col.key]; vb = b[col.key]; }
  536. if (col.numeric) { va = Number(va) || 0; vb = Number(vb) || 0; return (va - vb) * dir; }
  537. va = String(va == null ? '' : va);
  538. vb = String(vb == null ? '' : vb);
  539. return va.localeCompare(vb) * dir;
  540. });
  541. }
  542. els.tableBody.replaceChildren();
  543. var frag = document.createDocumentFragment();
  544. for (var i = 0; i < sorted.length; i++) {
  545. var r = sorted[i];
  546. var tr = document.createElement('tr');
  547. appendCell(tr, r.y);
  548. appendCell(tr, r.r);
  549. appendCell(tr, r.d);
  550. appendCell(tr, r.ln);
  551. appendCell(tr, r.q);
  552. appendCell(tr, r.ds);
  553. appendCell(tr, r.rs);
  554. appendCell(tr, r.m);
  555. appendCell(tr, (r.ry || 0) + ' / ' + (r.rn || 0));
  556. appendCell(tr, (r.dy || 0) + ' / ' + (r.dn || 0));
  557. appendCell(tr, r.a);
  558. appendCell(tr, r.b);
  559. frag.appendChild(tr);
  560. }
  561. els.tableBody.appendChild(frag);
  562. }
  563. function appendCell(tr, val) {
  564. var td = document.createElement('td');
  565. td.textContent = val == null ? '' : String(val);
  566. tr.appendChild(td);
  567. }
  568. // ---- URL / history ----
  569. function buildShareUrl(id) {
  570. var params = new URLSearchParams();
  571. if (id) params.set('id', id);
  572. var q = (els.search.value || '').trim();
  573. if (q) params.set('q', q);
  574. var parties = getCheckedValues(els.filterParty);
  575. if (parties.length > 0 && parties.length < 3) params.set('p', parties.join(','));
  576. var chambers = getCheckedValues(els.filterChamber);
  577. if (chambers.length > 0 && chambers.length < 2) params.set('c', chambers.join(','));
  578. var statesSel = getSelectedStates();
  579. if (statesSel.length > 0 && statesSel.length < els.filterState.options.length) {
  580. params.set('s', statesSel.join(','));
  581. }
  582. var s = params.toString();
  583. return s ? ('?' + s) : window.location.pathname;
  584. }
  585. function debounceUrlReplace() {
  586. if (state.urlReplaceTimer) clearTimeout(state.urlReplaceTimer);
  587. // Debounce to avoid hammering history during keystrokes.
  588. state.urlReplaceTimer = setTimeout(function () {
  589. var u = buildShareUrl(state.currentId);
  590. history.replaceState({ id: state.currentId }, '', u);
  591. }, 250);
  592. }
  593. function restoreFiltersFromURL() {
  594. var params = new URLSearchParams(window.location.search);
  595. var p = params.get('p');
  596. var c = params.get('c');
  597. var s = params.get('s');
  598. var q = params.get('q');
  599. if (p) {
  600. var allowed = p.split(',');
  601. els.filterParty.querySelectorAll('input[type=checkbox]').forEach(function (i) {
  602. i.checked = allowed.indexOf(i.value) >= 0;
  603. });
  604. }
  605. if (c) {
  606. var allowedC = c.split(',');
  607. els.filterChamber.querySelectorAll('input[type=checkbox]').forEach(function (i) {
  608. i.checked = allowedC.indexOf(i.value) >= 0;
  609. });
  610. }
  611. if (s) {
  612. var allowedS = s.split(',');
  613. for (var i = 0; i < els.filterState.options.length; i++) {
  614. els.filterState.options[i].selected = allowedS.indexOf(els.filterState.options[i].value) >= 0;
  615. }
  616. }
  617. if (q) els.search.value = q;
  618. }
  619. function onPopState(e) {
  620. var params = new URLSearchParams(window.location.search);
  621. var id = params.get('id');
  622. if (id && isValidId(id)) {
  623. state.skipPush = true;
  624. selectMember(id);
  625. }
  626. }
  627. function initialSelect() {
  628. var params = new URLSearchParams(window.location.search);
  629. var urlId = params.get('id');
  630. if (urlId && isValidId(urlId)) {
  631. state.skipPush = true;
  632. selectMember(urlId);
  633. return;
  634. }
  635. var stored = null;
  636. try { stored = localStorage.getItem(LS_KEY); } catch (_) {}
  637. if (stored && isValidId(stored)) {
  638. state.skipPush = true;
  639. selectMember(stored);
  640. return;
  641. }
  642. // No selection — leave summary empty with a hint.
  643. els.summary.replaceChildren();
  644. var p = document.createElement('p');
  645. p.style.color = 'var(--ps-text-muted)';
  646. p.textContent = 'Pick a member to begin — type a name, set of initials (e.g., AOC), or use the filters at left.';
  647. els.summary.appendChild(p);
  648. }
  649. })();