app.js 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634
  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 renderNote(m) {
  273. var partial = m.served_partial === true;
  274. var noVotes = !m.metrics || m.metrics.total === 0;
  275. var isDelegate = m.is_delegate === true;
  276. if (isDelegate) {
  277. var terr = TERRITORY_NAMES[m.state] || m.state;
  278. els.note.textContent = 'Note: This member is the non-voting delegate from ' + terr +
  279. '. House delegates may vote in committees and on amendments in the Committee of the Whole, ' +
  280. 'but cannot vote on final passage on the House floor. Their low participation rate is structural, not absenteeism.';
  281. els.note.classList.remove('is-hidden');
  282. } else if (partial || noVotes) {
  283. var endDate = m.served_to || 'present';
  284. els.note.textContent = 'This member did not cast roll-call votes during the period analyzed (served ' +
  285. (m.served_from || '?') + ' – ' + endDate + '). The dashboards below reflect that absence.';
  286. els.note.classList.remove('is-hidden');
  287. } else {
  288. els.note.textContent = '';
  289. els.note.classList.add('is-hidden');
  290. }
  291. }
  292. function pct(n, d) {
  293. if (!d) return '0%';
  294. return ((n / d) * 100).toFixed(1) + '%';
  295. }
  296. function renderKPIs(m) {
  297. var x = m.metrics || {};
  298. var voting = x.voting || 0;
  299. var kpis = [
  300. ['Total Votes', String(x.total || 0)],
  301. ['Yea / Nay', (x.yeas || 0) + ' / ' + (x.nays || 0)],
  302. ['Participation', pct(voting, x.total || 0)],
  303. ['Voted With GOP', (x.voted_with_gop || 0) + ' (' + pct(x.voted_with_gop || 0, voting) + ')'],
  304. ['Voted With Dem', (x.voted_with_dem || 0) + ' (' + pct(x.voted_with_dem || 0, voting) + ')'],
  305. ['Voted Against GOP', (x.voted_against_gop || 0) + ' (' + pct(x.voted_against_gop || 0, voting) + ')'],
  306. ['Voted Against Dem', (x.voted_against_dem || 0) + ' (' + pct(x.voted_against_dem || 0, voting) + ')'],
  307. ['Lone Wolf Votes', String(x.lone_wolf || 0)]
  308. ];
  309. var cards = els.kpis.querySelectorAll('.kpi-card');
  310. for (var i = 0; i < cards.length; i++) {
  311. var label = cards[i].querySelector('.kpi-label');
  312. var value = cards[i].querySelector('.kpi-value');
  313. if (i < kpis.length) {
  314. label.textContent = kpis[i][0];
  315. value.textContent = kpis[i][1];
  316. } else {
  317. label.textContent = '';
  318. value.textContent = '';
  319. }
  320. }
  321. }
  322. function buildCharts() {
  323. if (typeof Chart === 'undefined') return;
  324. var common = { responsive: true, maintainAspectRatio: false };
  325. charts.voteDist = new Chart(document.getElementById('chart-vote-dist'), {
  326. type: 'bar',
  327. data: { labels: [], datasets: [{ label: 'Member vote', data: [], backgroundColor: ['#27ae60', '#c0392b', '#f39c12', '#7f8c8d'] }] },
  328. options: Object.assign({}, common, { plugins: { legend: { display: false } }, scales: { y: { beginAtZero: true } } })
  329. });
  330. charts.alignment = new Chart(document.getElementById('chart-alignment'), {
  331. type: 'doughnut',
  332. data: { labels: ALIGN_KEYS.slice(), datasets: [{ data: [0, 0, 0, 0], backgroundColor: ALIGN_COLORS.slice() }] },
  333. options: Object.assign({}, common, {
  334. plugins: {
  335. legend: { position: 'bottom' },
  336. tooltip: {
  337. callbacks: {
  338. label: function (ctx) {
  339. var dataArr = ctx.dataset.data || [];
  340. var sum = 0;
  341. for (var i = 0; i < dataArr.length; i++) sum += Number(dataArr[i]) || 0;
  342. var v = Number(ctx.parsed) || 0;
  343. var p = sum > 0 ? ((v / sum) * 100).toFixed(1) + '%' : '0%';
  344. return String(ctx.label) + ': ' + v + ' (' + p + ')';
  345. }
  346. }
  347. }
  348. }
  349. })
  350. });
  351. charts.blocked = new Chart(document.getElementById('chart-blocked'), {
  352. type: 'bar',
  353. data: {
  354. labels: ['Dem-Backed', 'GOP-Backed'],
  355. datasets: [{ label: 'Blocked', data: [0, 0], backgroundColor: [COLORS.helpedD, COLORS.helpedR] }]
  356. },
  357. options: Object.assign({}, common, { indexAxis: 'y', plugins: { legend: { display: false } }, scales: { x: { beginAtZero: true } } })
  358. });
  359. charts.alignmentTime = new Chart(document.getElementById('chart-alignment-time'), {
  360. type: 'line',
  361. data: {
  362. labels: [],
  363. datasets: ALIGN_KEYS.map(function (k, i) {
  364. return {
  365. label: k, data: [], borderColor: ALIGN_COLORS[i], backgroundColor: ALIGN_COLORS[i],
  366. tension: 0.2, fill: false
  367. };
  368. })
  369. },
  370. options: Object.assign({}, common, { plugins: { legend: { position: 'bottom' } }, scales: { y: { beginAtZero: true } } })
  371. });
  372. charts.withAgainst = new Chart(document.getElementById('chart-with-against'), {
  373. type: 'bar',
  374. data: {
  375. labels: ['GOP majority', 'Dem majority'],
  376. datasets: [
  377. { label: 'Voted With', data: [0, 0], backgroundColor: COLORS.green },
  378. { label: 'Voted Against', data: [0, 0], backgroundColor: COLORS.red }
  379. ]
  380. },
  381. options: Object.assign({}, common, { plugins: { legend: { position: 'bottom' } }, scales: { y: { beginAtZero: true } } })
  382. });
  383. }
  384. function updateCharts(member) {
  385. if (typeof Chart === 'undefined') return;
  386. var x = member.metrics || {};
  387. var mv = x.member || {};
  388. var mvKeys = ['Yea', 'Nay', 'Present', 'Not Voting'];
  389. charts.voteDist.data.labels = mvKeys;
  390. charts.voteDist.data.datasets[0].data = mvKeys.map(function (k) { return Number(mv[k]) || 0; });
  391. charts.voteDist.update('none');
  392. var al = x.alignment || {};
  393. charts.alignment.data.datasets[0].data = ALIGN_KEYS.map(function (k) { return Number(al[k]) || 0; });
  394. charts.alignment.update('none');
  395. charts.blocked.data.datasets[0].data = [Number(x.blocked_dem_count) || 0, Number(x.blocked_rep_count) || 0];
  396. charts.blocked.update('none');
  397. var months = Array.isArray(x.months) ? x.months : [];
  398. var monthly = x.monthly || {};
  399. charts.alignmentTime.data.labels = months.slice();
  400. for (var i = 0; i < ALIGN_KEYS.length; i++) {
  401. var arr = monthly[ALIGN_KEYS[i]];
  402. charts.alignmentTime.data.datasets[i].data = Array.isArray(arr) ? arr.slice() : months.map(function () { return 0; });
  403. }
  404. charts.alignmentTime.update('none');
  405. charts.withAgainst.data.datasets[0].data = [Number(x.voted_with_gop) || 0, Number(x.voted_with_dem) || 0];
  406. charts.withAgainst.data.datasets[1].data = [Number(x.voted_against_gop) || 0, Number(x.voted_against_dem) || 0];
  407. charts.withAgainst.update('none');
  408. }
  409. // ---- Table ----
  410. var COL_DEFS = [
  411. { key: 'y', numeric: true },
  412. { key: 'r', numeric: true },
  413. { key: 'd', numeric: false },
  414. { key: 'ln', numeric: false },
  415. { key: 'q', numeric: false },
  416. { key: 'ds', numeric: false },
  417. { key: 'rs', numeric: false },
  418. { key: 'm', numeric: false },
  419. { key: 'ry_rn', numeric: false },
  420. { key: 'dy_dn', numeric: false },
  421. { key: 'a', numeric: false },
  422. { key: 'b', numeric: false }
  423. ];
  424. function initSortHeaders() {
  425. var ths = els.tableHead.querySelectorAll('th');
  426. for (var i = 0; i < ths.length; i++) {
  427. ths[i].style.cursor = 'pointer';
  428. ths[i].setAttribute('data-col-index', String(i));
  429. ths[i].addEventListener('click', onSortClick);
  430. }
  431. }
  432. function onSortClick(e) {
  433. var idx = Number(e.currentTarget.getAttribute('data-col-index'));
  434. if (isNaN(idx)) return;
  435. if (state.sortCol === idx) {
  436. state.sortAsc = !state.sortAsc;
  437. } else {
  438. state.sortCol = idx;
  439. state.sortAsc = true;
  440. }
  441. updateSortIndicators();
  442. if (state.currentMember) renderTable(state.currentMember.metrics.rows || []);
  443. }
  444. function updateSortIndicators() {
  445. var ths = els.tableHead.querySelectorAll('th');
  446. for (var i = 0; i < ths.length; i++) {
  447. var base = ths[i].textContent.replace(/[▲▼]\s*$/, '').trim();
  448. ths[i].textContent = base + (i === state.sortCol ? (state.sortAsc ? ' ▲' : ' ▼') : '');
  449. }
  450. }
  451. function renderTable(rows) {
  452. var sorted = rows.slice();
  453. if (state.sortCol != null && state.sortCol >= 0 && state.sortCol < COL_DEFS.length) {
  454. var col = COL_DEFS[state.sortCol];
  455. var dir = state.sortAsc ? 1 : -1;
  456. sorted.sort(function (a, b) {
  457. var va, vb;
  458. if (col.key === 'ry_rn') { va = (a.ry || 0) - (a.rn || 0); vb = (b.ry || 0) - (b.rn || 0); }
  459. else if (col.key === 'dy_dn') { va = (a.dy || 0) - (a.dn || 0); vb = (b.dy || 0) - (b.dn || 0); }
  460. else { va = a[col.key]; vb = b[col.key]; }
  461. if (col.numeric) { va = Number(va) || 0; vb = Number(vb) || 0; return (va - vb) * dir; }
  462. va = String(va == null ? '' : va);
  463. vb = String(vb == null ? '' : vb);
  464. return va.localeCompare(vb) * dir;
  465. });
  466. }
  467. els.tableBody.replaceChildren();
  468. var frag = document.createDocumentFragment();
  469. for (var i = 0; i < sorted.length; i++) {
  470. var r = sorted[i];
  471. var tr = document.createElement('tr');
  472. appendCell(tr, r.y);
  473. appendCell(tr, r.r);
  474. appendCell(tr, r.d);
  475. appendCell(tr, r.ln);
  476. appendCell(tr, r.q);
  477. appendCell(tr, r.ds);
  478. appendCell(tr, r.rs);
  479. appendCell(tr, r.m);
  480. appendCell(tr, (r.ry || 0) + ' / ' + (r.rn || 0));
  481. appendCell(tr, (r.dy || 0) + ' / ' + (r.dn || 0));
  482. appendCell(tr, r.a);
  483. appendCell(tr, r.b);
  484. frag.appendChild(tr);
  485. }
  486. els.tableBody.appendChild(frag);
  487. }
  488. function appendCell(tr, val) {
  489. var td = document.createElement('td');
  490. td.textContent = val == null ? '' : String(val);
  491. tr.appendChild(td);
  492. }
  493. // ---- URL / history ----
  494. function buildShareUrl(id) {
  495. var params = new URLSearchParams();
  496. if (id) params.set('id', id);
  497. var q = (els.search.value || '').trim();
  498. if (q) params.set('q', q);
  499. var parties = getCheckedValues(els.filterParty);
  500. if (parties.length > 0 && parties.length < 3) params.set('p', parties.join(','));
  501. var chambers = getCheckedValues(els.filterChamber);
  502. if (chambers.length > 0 && chambers.length < 2) params.set('c', chambers.join(','));
  503. var statesSel = getSelectedStates();
  504. if (statesSel.length > 0 && statesSel.length < els.filterState.options.length) {
  505. params.set('s', statesSel.join(','));
  506. }
  507. var s = params.toString();
  508. return s ? ('?' + s) : window.location.pathname;
  509. }
  510. function debounceUrlReplace() {
  511. if (state.urlReplaceTimer) clearTimeout(state.urlReplaceTimer);
  512. // Debounce to avoid hammering history during keystrokes.
  513. state.urlReplaceTimer = setTimeout(function () {
  514. var u = buildShareUrl(state.currentId);
  515. history.replaceState({ id: state.currentId }, '', u);
  516. }, 250);
  517. }
  518. function restoreFiltersFromURL() {
  519. var params = new URLSearchParams(window.location.search);
  520. var p = params.get('p');
  521. var c = params.get('c');
  522. var s = params.get('s');
  523. var q = params.get('q');
  524. if (p) {
  525. var allowed = p.split(',');
  526. els.filterParty.querySelectorAll('input[type=checkbox]').forEach(function (i) {
  527. i.checked = allowed.indexOf(i.value) >= 0;
  528. });
  529. }
  530. if (c) {
  531. var allowedC = c.split(',');
  532. els.filterChamber.querySelectorAll('input[type=checkbox]').forEach(function (i) {
  533. i.checked = allowedC.indexOf(i.value) >= 0;
  534. });
  535. }
  536. if (s) {
  537. var allowedS = s.split(',');
  538. for (var i = 0; i < els.filterState.options.length; i++) {
  539. els.filterState.options[i].selected = allowedS.indexOf(els.filterState.options[i].value) >= 0;
  540. }
  541. }
  542. if (q) els.search.value = q;
  543. }
  544. function onPopState(e) {
  545. var params = new URLSearchParams(window.location.search);
  546. var id = params.get('id');
  547. if (id && isValidId(id)) {
  548. state.skipPush = true;
  549. selectMember(id);
  550. }
  551. }
  552. function initialSelect() {
  553. var params = new URLSearchParams(window.location.search);
  554. var urlId = params.get('id');
  555. if (urlId && isValidId(urlId)) {
  556. state.skipPush = true;
  557. selectMember(urlId);
  558. return;
  559. }
  560. var stored = null;
  561. try { stored = localStorage.getItem(LS_KEY); } catch (_) {}
  562. if (stored && isValidId(stored)) {
  563. state.skipPush = true;
  564. selectMember(stored);
  565. return;
  566. }
  567. // No selection — leave summary empty with a hint.
  568. els.summary.replaceChildren();
  569. var p = document.createElement('p');
  570. p.style.color = 'var(--ps-text-muted)';
  571. p.textContent = 'Pick a member to begin — type a name, set of initials (e.g., AOC), or use the filters at left.';
  572. els.summary.appendChild(p);
  573. }
  574. })();