ranking.js 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269
  1. (function () {
  2. 'use strict';
  3. var ID_RE = /^[A-Z]\d{6}$|^S\d{3,4}$/;
  4. var METRIC_LABELS = {
  5. total: 'Total Votes',
  6. yeas: 'Yeas',
  7. nays: 'Nays',
  8. participation_pct: 'Participation %',
  9. voted_with_gop: 'Voted With GOP',
  10. voted_with_gop_pct: 'Voted With GOP %',
  11. voted_with_dem: 'Voted With Dem',
  12. voted_with_dem_pct: 'Voted With Dem %',
  13. voted_against_gop: 'Voted Against GOP',
  14. voted_against_gop_pct: 'Voted Against GOP %',
  15. voted_against_dem: 'Voted Against Dem',
  16. voted_against_dem_pct: 'Voted Against Dem %',
  17. lone_wolf: 'Lone Wolf Votes',
  18. lone_wolf_pct: 'Lone Wolf %'
  19. };
  20. var state = {
  21. BASE: './data/',
  22. manifest: null,
  23. chamber: 'H',
  24. parties: { R: true, D: true, I: true },
  25. metric: 'total',
  26. order: 'desc'
  27. };
  28. var els = {};
  29. document.addEventListener('DOMContentLoaded', init);
  30. function init() {
  31. els.root = document.getElementById('polisci-root');
  32. state.BASE = els.root.dataset.base || './data/';
  33. els.tbody = document.querySelector('#rank-table tbody');
  34. els.thead = document.querySelector('#rank-table thead');
  35. els.metricHeader = document.getElementById('rank-metric-header');
  36. els.summary = document.getElementById('rank-summary');
  37. els.metricSelect = document.getElementById('rank-metric');
  38. els.lastGenerated = document.getElementById('last-generated');
  39. var inline = document.getElementById('polisci-manifest');
  40. if (inline && inline.textContent) {
  41. try { onManifest(JSON.parse(inline.textContent)); return; }
  42. catch (e) { /* fall through */ }
  43. }
  44. fetch(state.BASE + 'manifest.json', { credentials: 'omit' })
  45. .then(function (r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); })
  46. .then(onManifest)
  47. .catch(function (e) { showFatal('Failed to load manifest: ' + e.message + ' — if you opened this file directly, serve it over HTTP.'); });
  48. }
  49. function showFatal(msg) {
  50. els.root.replaceChildren();
  51. var p = document.createElement('p');
  52. p.style.padding = '20px';
  53. p.style.color = '#c0392b';
  54. p.textContent = msg;
  55. els.root.appendChild(p);
  56. }
  57. function onManifest(m) {
  58. state.manifest = m;
  59. if (els.lastGenerated) els.lastGenerated.textContent = m.generated_at || '';
  60. var chamberInputs = document.querySelectorAll('input[name="chamber"]');
  61. for (var i = 0; i < chamberInputs.length; i++) {
  62. chamberInputs[i].addEventListener('change', onChamberChange);
  63. }
  64. var partyInputs = document.querySelectorAll('input[name="party"]');
  65. for (var j = 0; j < partyInputs.length; j++) {
  66. partyInputs[j].addEventListener('change', onPartyChange);
  67. }
  68. els.metricSelect.addEventListener('change', function () {
  69. state.metric = els.metricSelect.value;
  70. syncURL();
  71. render();
  72. });
  73. var orderInputs = document.querySelectorAll('input[name="order"]');
  74. for (var k = 0; k < orderInputs.length; k++) {
  75. orderInputs[k].addEventListener('change', onOrderChange);
  76. }
  77. document.getElementById('rank-reset').addEventListener('click', resetControls);
  78. var toggle = document.getElementById('sidebar-toggle');
  79. if (toggle) toggle.addEventListener('click', function () {
  80. var body = document.getElementById('sidebar-body');
  81. var open = body.classList.toggle('is-collapsed') ? 'false' : 'true';
  82. toggle.setAttribute('aria-expanded', open);
  83. });
  84. window.addEventListener('popstate', function () { restoreFromURL(); render(); });
  85. restoreFromURL();
  86. render();
  87. }
  88. function onChamberChange(e) {
  89. state.chamber = e.target.value === 'S' ? 'S' : 'H';
  90. syncURL();
  91. render();
  92. }
  93. function onPartyChange(e) {
  94. state.parties[e.target.value] = e.target.checked;
  95. syncURL();
  96. render();
  97. }
  98. function onOrderChange(e) {
  99. state.order = e.target.value === 'asc' ? 'asc' : 'desc';
  100. syncURL();
  101. render();
  102. }
  103. function resetControls() {
  104. state.chamber = 'H';
  105. state.parties = { R: true, D: true, I: true };
  106. state.metric = 'total';
  107. state.order = 'desc';
  108. document.querySelector('input[name="chamber"][value="H"]').checked = true;
  109. document.querySelector('input[name="party"][value="R"]').checked = true;
  110. document.querySelector('input[name="party"][value="D"]').checked = true;
  111. document.querySelector('input[name="party"][value="I"]').checked = true;
  112. els.metricSelect.value = 'total';
  113. document.querySelector('input[name="order"][value="desc"]').checked = true;
  114. syncURL();
  115. render();
  116. }
  117. function computeMetric(k, key) {
  118. var voting = k.voting || 0;
  119. switch (key) {
  120. case 'total': return k.total || 0;
  121. case 'yeas': return k.yeas || 0;
  122. case 'nays': return k.nays || 0;
  123. case 'participation_pct': return k.total ? (100 * voting / k.total) : null;
  124. case 'voted_with_gop': return k.voted_with_gop || 0;
  125. case 'voted_with_gop_pct': return voting ? (100 * (k.voted_with_gop || 0) / voting) : null;
  126. case 'voted_with_dem': return k.voted_with_dem || 0;
  127. case 'voted_with_dem_pct': return voting ? (100 * (k.voted_with_dem || 0) / voting) : null;
  128. case 'voted_against_gop': return k.voted_against_gop || 0;
  129. case 'voted_against_gop_pct': return voting ? (100 * (k.voted_against_gop || 0) / voting) : null;
  130. case 'voted_against_dem': return k.voted_against_dem || 0;
  131. case 'voted_against_dem_pct': return voting ? (100 * (k.voted_against_dem || 0) / voting) : null;
  132. case 'lone_wolf': return k.lone_wolf || 0;
  133. case 'lone_wolf_pct': return voting ? (100 * (k.lone_wolf || 0) / voting) : null;
  134. }
  135. return null;
  136. }
  137. function isPct(key) { return /_pct$/.test(key); }
  138. function render() {
  139. var members = (state.manifest && state.manifest.members) || [];
  140. var rows = [];
  141. for (var i = 0; i < members.length; i++) {
  142. var m = members[i];
  143. if (m.c !== state.chamber) continue;
  144. if (!state.parties[m.p]) continue;
  145. if (!m.k) continue;
  146. var v = computeMetric(m.k, state.metric);
  147. if (v === null) continue;
  148. rows.push({ m: m, v: v });
  149. }
  150. rows.sort(function (a, b) {
  151. if (a.v === b.v) return (a.m.n || '').localeCompare(b.m.n || '');
  152. return state.order === 'asc' ? a.v - b.v : b.v - a.v;
  153. });
  154. var label = METRIC_LABELS[state.metric] || state.metric;
  155. els.metricHeader.textContent = label;
  156. els.summary.replaceChildren();
  157. var p = document.createElement('p');
  158. p.className = 'rank-summary-line';
  159. var chamberName = state.chamber === 'H' ? 'House' : 'Senate';
  160. var partyList = ['R', 'D', 'I'].filter(function (x) { return state.parties[x]; }).join('/') || 'none';
  161. p.textContent = rows.length + ' ' + chamberName + ' members (' + partyList + ') ranked by ' + label
  162. + ' (' + (state.order === 'desc' ? 'highest first' : 'lowest first') + ')';
  163. els.summary.appendChild(p);
  164. els.tbody.replaceChildren();
  165. var pct = isPct(state.metric);
  166. for (var r = 0; r < rows.length; r++) {
  167. var row = rows[r];
  168. var tr = document.createElement('tr');
  169. tr.className = 'rank-row';
  170. tr.tabIndex = 0;
  171. tr.dataset.id = row.m.id;
  172. tr.addEventListener('click', openMember);
  173. tr.addEventListener('keydown', function (e) {
  174. if (e.key === 'Enter') openMember.call(e.currentTarget, e);
  175. });
  176. var rank = document.createElement('td');
  177. rank.textContent = String(r + 1);
  178. tr.appendChild(rank);
  179. var name = document.createElement('td');
  180. name.textContent = row.m.n || row.m.id;
  181. tr.appendChild(name);
  182. var party = document.createElement('td');
  183. party.textContent = row.m.p || '';
  184. party.className = 'party-cell party-' + (row.m.p || 'X');
  185. tr.appendChild(party);
  186. var st = document.createElement('td');
  187. st.textContent = row.m.s || '';
  188. tr.appendChild(st);
  189. var val = document.createElement('td');
  190. val.className = 'rank-value';
  191. val.textContent = pct ? row.v.toFixed(1) + '%' : String(row.v);
  192. tr.appendChild(val);
  193. els.tbody.appendChild(tr);
  194. }
  195. }
  196. function openMember(e) {
  197. var tr = e.currentTarget;
  198. var id = tr.dataset.id;
  199. if (!id || !ID_RE.test(id)) return;
  200. window.open('app.html?id=' + encodeURIComponent(id), '_blank', 'noopener');
  201. }
  202. function syncURL() {
  203. var params = new URLSearchParams();
  204. if (state.chamber !== 'H') params.set('c', state.chamber);
  205. if (state.metric !== 'total') params.set('m', state.metric);
  206. if (state.order !== 'desc') params.set('o', state.order);
  207. var pset = ['R', 'D', 'I'].filter(function (x) { return state.parties[x]; });
  208. if (pset.length !== 3) params.set('p', pset.join(''));
  209. var q = params.toString();
  210. var url = q ? ('?' + q) : location.pathname;
  211. history.replaceState(null, '', url);
  212. }
  213. function restoreFromURL() {
  214. var params = new URLSearchParams(location.search);
  215. var c = params.get('c');
  216. if (c === 'S' || c === 'H') state.chamber = c;
  217. var m = params.get('m');
  218. if (m && METRIC_LABELS[m]) state.metric = m;
  219. var o = params.get('o');
  220. if (o === 'asc' || o === 'desc') state.order = o;
  221. var p = params.get('p');
  222. if (p) {
  223. state.parties = { R: false, D: false, I: false };
  224. for (var i = 0; i < p.length; i++) {
  225. var ch = p.charAt(i);
  226. if (ch === 'R' || ch === 'D' || ch === 'I') state.parties[ch] = true;
  227. }
  228. }
  229. // Reflect in controls
  230. var ci = document.querySelector('input[name="chamber"][value="' + state.chamber + '"]');
  231. if (ci) ci.checked = true;
  232. var pInputs = document.querySelectorAll('input[name="party"]');
  233. for (var j = 0; j < pInputs.length; j++) {
  234. pInputs[j].checked = !!state.parties[pInputs[j].value];
  235. }
  236. els.metricSelect.value = state.metric;
  237. var oi = document.querySelector('input[name="order"][value="' + state.order + '"]');
  238. if (oi) oi.checked = true;
  239. }
  240. })();