build_dashboard.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469
  1. #!/usr/bin/env python3
  2. """Generate self-contained HTML dashboard for Massie 119th Congress voting analysis."""
  3. import json
  4. from collections import Counter, defaultdict
  5. votes = json.load(open("/home/user/polisci/votes.json"))
  6. # Aggregate
  7. align_counts = Counter(v["alignment"] for v in votes)
  8. massie_counts = Counter(v["massie"] for v in votes)
  9. blocked_counts = Counter(v["blocked"] for v in votes if v["blocked"])
  10. # Monthly alignment trend
  11. month_align = defaultdict(lambda: Counter())
  12. MONTHS = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"]
  13. def month_key(v):
  14. # date like "3-Jan-2025"
  15. try:
  16. d, m, y = v["date"].split("-")
  17. return f"{y}-{MONTHS.index(m)+1:02d}"
  18. except Exception:
  19. return None
  20. for v in votes:
  21. mk = month_key(v)
  22. if mk:
  23. month_align[mk][v["alignment"]] += 1
  24. months_sorted = sorted(month_align.keys())
  25. align_labels = ["Helped Republicans", "Helped Democrats", "Helped Both", "Helped Neither"]
  26. monthly_series = {lab: [month_align[m].get(lab, 0) for m in months_sorted] for lab in align_labels}
  27. # Blocked measures - list for table
  28. blocked_dem = [v for v in votes if v["blocked"] == "Democrat"]
  29. blocked_rep = [v for v in votes if v["blocked"] == "Republican"]
  30. # All vote rows (compact) for filterable table
  31. def row(v):
  32. return {
  33. "y": v["year"], "r": v["roll"], "d": v["date"],
  34. "ln": v["legis_num"], "q": v["question"],
  35. "ds": v["desc"][:90],
  36. "rs": v["result"],
  37. "m": v["massie"],
  38. "ry": v["R"]["yea"], "rn": v["R"]["nay"],
  39. "dy": v["D"]["yea"], "dn": v["D"]["nay"],
  40. "a": v["alignment"], "b": v["blocked"] or "",
  41. }
  42. rows = [row(v) for v in votes]
  43. # Summary stats
  44. total = len(votes)
  45. voting = sum(1 for v in votes if v["massie"] in ("Yea","Nay","Aye","No"))
  46. yeas = sum(1 for v in votes if v["massie"] in ("Yea","Aye"))
  47. nays = sum(1 for v in votes if v["massie"] in ("Nay","No"))
  48. nv = sum(1 for v in votes if v["massie"] == "Not Voting")
  49. present = sum(1 for v in votes if v["massie"] == "Present")
  50. # Lone-wolf: cases where Massie was on the losing side with very few co-defectors of his own party
  51. lone_wolf = 0
  52. for v in votes:
  53. if v["massie"] not in ("Yea","Nay","Aye","No"):
  54. continue
  55. m_norm = "Yea" if v["massie"] in ("Yea","Aye") else "Nay"
  56. r_pos = "Yea" if v["R"]["yea"] > v["R"]["nay"] else ("Nay" if v["R"]["nay"] > v["R"]["yea"] else "Split")
  57. if r_pos != "Split" and m_norm != r_pos:
  58. # how many Republicans defected with him?
  59. defectors = v["R"]["nay"] if r_pos == "Yea" else v["R"]["yea"]
  60. if defectors <= 5:
  61. lone_wolf += 1
  62. voted_against_gop = 0
  63. voted_against_dem = 0
  64. voted_with_gop = 0
  65. voted_with_dem = 0
  66. for v in votes:
  67. if v["massie"] not in ("Yea","Nay","Aye","No"):
  68. continue
  69. m_norm = "Yea" if v["massie"] in ("Yea","Aye") else "Nay"
  70. r_pos = "Yea" if v["R"]["yea"] > v["R"]["nay"] else ("Nay" if v["R"]["nay"] > v["R"]["yea"] else "Split")
  71. d_pos = "Yea" if v["D"]["yea"] > v["D"]["nay"] else ("Nay" if v["D"]["nay"] > v["D"]["yea"] else "Split")
  72. if r_pos != "Split":
  73. if m_norm == r_pos: voted_with_gop += 1
  74. else: voted_against_gop += 1
  75. if d_pos != "Split":
  76. if m_norm == d_pos: voted_with_dem += 1
  77. else: voted_against_dem += 1
  78. data = {
  79. "total": total, "voting": voting, "yeas": yeas, "nays": nays,
  80. "nv": nv, "present": present,
  81. "alignment": dict(align_counts),
  82. "massie": dict(massie_counts),
  83. "blocked": dict(blocked_counts),
  84. "months": months_sorted,
  85. "monthly": monthly_series,
  86. "rows": rows,
  87. "blocked_dem_count": len(blocked_dem),
  88. "blocked_rep_count": len(blocked_rep),
  89. "lone_wolf": lone_wolf,
  90. "voted_against_gop": voted_against_gop,
  91. "voted_against_dem": voted_against_dem,
  92. "voted_with_gop": voted_with_gop,
  93. "voted_with_dem": voted_with_dem,
  94. }
  95. # Write to template
  96. HTML = """<!DOCTYPE html>
  97. <html lang="en">
  98. <head>
  99. <meta charset="utf-8">
  100. <title>Thomas Massie - 119th Congress Voting Dashboard</title>
  101. <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
  102. <script src="https://cdn.jsdelivr.net/npm/sortablejs@1.15.2/Sortable.min.js"></script>
  103. <style>
  104. body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
  105. margin: 0; padding: 24px; background: #f5f5f7; color: #1d1d1f; }
  106. h1 { margin: 0 0 4px; font-size: 28px; }
  107. .sub { color: #6e6e73; margin-bottom: 24px; }
  108. .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 16px; margin-bottom: 24px; }
  109. .card { background: #fff; border-radius: 12px; padding: 20px; box-shadow: 0 1px 3px rgba(0,0,0,0.06); cursor: grab; transition: box-shadow .15s, transform .15s; }
  110. .card:active { cursor: grabbing; }
  111. .card.sortable-ghost { opacity: 0.4; }
  112. .card.sortable-chosen { box-shadow: 0 4px 16px rgba(0,0,0,0.15); transform: scale(1.02); }
  113. .card.sortable-drag { box-shadow: 0 8px 24px rgba(0,0,0,0.2); }
  114. .drag-hint { font-size: 11px; color: #6e6e73; margin-bottom: 8px; }
  115. .drag-hint button { margin-left: 8px; padding: 2px 8px; font-size: 11px; border: 1px solid #d2d2d7; border-radius: 4px; background: #fff; cursor: pointer; }
  116. .kpi { font-size: 32px; font-weight: 700; margin: 6px 0 2px; }
  117. .pct { font-size: 14px; font-weight: 500; color: #6e6e73; margin-left: 4px; }
  118. .kpi-label { color: #6e6e73; font-size: 13px; text-transform: uppercase; letter-spacing: 0.5px; }
  119. .chart-row { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-bottom: 24px; }
  120. @media (max-width: 900px) { .chart-row { grid-template-columns: 1fr; } }
  121. .chart-card { background: #fff; border-radius: 12px; padding: 20px; box-shadow: 0 1px 3px rgba(0,0,0,0.06); min-width: 0; }
  122. .chart-box { position: relative; height: 300px; width: 100%; }
  123. h2 { font-size: 18px; margin: 0 0 12px; }
  124. table { width: 100%; border-collapse: collapse; font-size: 13px; }
  125. th, td { padding: 6px 8px; text-align: left; border-bottom: 1px solid #e8e8ed; }
  126. th { background: #fafafa; position: sticky; top: 0; cursor: pointer; user-select: none; }
  127. th:hover { background: #f0f0f5; }
  128. .table-wrap { max-height: 600px; overflow: auto; border: 1px solid #e8e8ed; border-radius: 8px; }
  129. .filter-bar { display: flex; gap: 8px; margin-bottom: 12px; flex-wrap: wrap; }
  130. .filter-bar input, .filter-bar select { padding: 8px 12px; border: 1px solid #d2d2d7; border-radius: 8px; font-size: 14px; }
  131. .filter-bar input { flex: 1; min-width: 200px; }
  132. .badge { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 600; }
  133. .b-rep { background: #fde2e2; color: #a30000; }
  134. .b-dem { background: #dde7fa; color: #1644a0; }
  135. .b-both { background: #e0f0e0; color: #1e6b1e; }
  136. .b-neither { background: #f0e0f0; color: #6b1e6b; }
  137. .b-na { background: #eee; color: #666; }
  138. .v-yea { color: #1e6b1e; font-weight: 600; }
  139. .v-nay { color: #a30000; font-weight: 600; }
  140. .v-nv, .v-present { color: #888; }
  141. .note { font-size: 12px; color: #6e6e73; line-height: 1.5; margin-top: 12px; }
  142. .footer { text-align: center; color: #6e6e73; font-size: 12px; margin-top: 32px; }
  143. </style>
  144. </head>
  145. <body>
  146. <h1>Thomas Massie (R-KY) — 119th Congress Voting Analysis</h1>
  147. <div class="sub">Bioguide M001184 · House roll-call votes, Jan 3, 2025 – present · Source: clerk.house.gov</div>
  148. <div class="drag-hint">↕ Drag any card to rearrange. <button id="resetOrder">Reset order</button></div>
  149. <div class="grid" id="cardGrid">
  150. <div class="card" data-id="participation"><div class="kpi-label">Roll Calls / Participation</div><div class="kpi">__VOTING__ <span style="font-size:18px;color:#6e6e73">/ __TOTAL__</span> <span class="pct">__VOTING_PCT__%</span></div><div class="note">119th Congress, Jan 2025 – May 2026 · Yea/Aye: __YEAS__ · Nay/No: __NAYS__ · Not Voting: __NV__ · Present: __PRESENT__</div></div>
  151. <div class="card" data-id="va_gop"><div class="kpi-label">Voted Against GOP Majority</div><div class="kpi" style="color:#a30000">__VA_GOP__ <span class="pct">__VA_GOP_PCT__%</span></div><div class="note">of __PARTISAN_R__ votes where R majority took a side</div></div>
  152. <div class="card" data-id="va_dem"><div class="kpi-label">Voted Against Dem Majority</div><div class="kpi" style="color:#1644a0">__VA_DEM__ <span class="pct">__VA_DEM_PCT__%</span></div><div class="note">of __PARTISAN_D__ votes where D majority took a side</div></div>
  153. <div class="card" data-id="blk_dem"><div class="kpi-label">Blocked Dem-Backed</div><div class="kpi" style="color:#1644a0">__BLK_DEM__ <span class="pct">__BLK_DEM_PCT__%</span></div><div class="note">Massie Nay + D wanted Yea + measure failed</div></div>
  154. <div class="card" data-id="blk_rep"><div class="kpi-label">Blocked GOP-Backed</div><div class="kpi" style="color:#a30000">__BLK_REP__ <span class="pct">__BLK_REP_PCT__%</span></div><div class="note">Massie Nay + R wanted Yea + measure failed</div></div>
  155. <div class="card" data-id="lone"><div class="kpi-label">Lone Wolf Defections</div><div class="kpi">__LONE__ <span class="pct">__LONE_PCT__%</span></div><div class="note">Against R majority w/ ≤5 fellow GOP defectors</div></div>
  156. </div>
  157. <div class="chart-row">
  158. <div class="chart-card"><h2>Alignment Classification</h2><div class="chart-box"><canvas id="alignChart"></canvas></div></div>
  159. <div class="chart-card"><h2>Massie's Vote Distribution</h2><div class="chart-box"><canvas id="voteChart"></canvas></div></div>
  160. </div>
  161. <div class="chart-row">
  162. <div class="chart-card"><h2>Voted With vs. Against — by Party Majority</h2><div class="chart-box"><canvas id="partyChart"></canvas></div></div>
  163. <div class="chart-card"><h2>Blocking Wins</h2><div class="chart-box"><canvas id="blockChart"></canvas></div></div>
  164. </div>
  165. <div class="chart-row">
  166. <div class="chart-card" style="grid-column: 1 / -1"><h2>Alignment Over Time (monthly)</h2><div class="chart-box" style="height:320px"><canvas id="trendChart"></canvas></div></div>
  167. </div>
  168. <div class="chart-row">
  169. <div class="chart-card" style="grid-column: 1 / -1">
  170. <h2>All Votes (filterable)</h2>
  171. <div class="filter-bar">
  172. <input id="search" placeholder="Search bill/description…">
  173. <select id="alignFilter">
  174. <option value="">All alignments</option>
  175. <option>Helped Republicans</option>
  176. <option>Helped Democrats</option>
  177. <option>Helped Both</option>
  178. <option>Helped Neither</option>
  179. </select>
  180. <select id="blockFilter">
  181. <option value="">All votes</option>
  182. <option value="Democrat">Blocked Dem-backed</option>
  183. <option value="Republican">Blocked GOP-backed</option>
  184. </select>
  185. <select id="massieFilter">
  186. <option value="">Any Massie vote</option>
  187. <option>Yea</option><option>Aye</option><option>Nay</option><option>No</option>
  188. <option>Not Voting</option><option>Present</option>
  189. </select>
  190. </div>
  191. <div class="table-wrap">
  192. <table id="voteTable">
  193. <thead><tr>
  194. <th data-k="y">Yr</th><th data-k="r">#</th><th data-k="d">Date</th>
  195. <th data-k="ln">Bill</th><th data-k="q">Question</th><th data-k="ds">Description</th>
  196. <th data-k="rs">Result</th><th data-k="m">Massie</th>
  197. <th data-k="ry">R Yea</th><th data-k="rn">R Nay</th>
  198. <th data-k="dy">D Yea</th><th data-k="dn">D Nay</th>
  199. <th data-k="a">Alignment</th><th data-k="b">Blocked</th>
  200. </tr></thead>
  201. <tbody id="voteBody"></tbody>
  202. </table>
  203. </div>
  204. <div class="note">Click column headers to sort. Showing <span id="row-count">0</span> rows.</div>
  205. </div>
  206. </div>
  207. <div class="footer">
  208. Data: clerk.house.gov XML rollcalls. Build date: 2026-05-23. Independent analysis.
  209. </div>
  210. <script>
  211. const DATA = __DATA__;
  212. // Drag-and-drop card reordering (SortableJS + localStorage persistence)
  213. const CARD_ORDER_KEY = 'massieDashCardOrder.v1';
  214. const grid = document.getElementById('cardGrid');
  215. function applyOrder(order) {
  216. if (!order) return;
  217. const byId = {};
  218. Array.from(grid.children).forEach(c => byId[c.dataset.id] = c);
  219. order.forEach(id => { if (byId[id]) grid.appendChild(byId[id]); });
  220. }
  221. try { applyOrder(JSON.parse(localStorage.getItem(CARD_ORDER_KEY))); } catch(e){}
  222. Sortable.create(grid, {
  223. animation: 180,
  224. ghostClass: 'sortable-ghost',
  225. chosenClass: 'sortable-chosen',
  226. dragClass: 'sortable-drag',
  227. onEnd() {
  228. const order = Array.from(grid.children).map(c => c.dataset.id);
  229. localStorage.setItem(CARD_ORDER_KEY, JSON.stringify(order));
  230. }
  231. });
  232. document.getElementById('resetOrder').addEventListener('click', () => {
  233. localStorage.removeItem(CARD_ORDER_KEY);
  234. location.reload();
  235. });
  236. // KPI bar
  237. const ALIGN_COLORS = {
  238. "Helped Republicans": "#d93b3b",
  239. "Helped Democrats": "#3b6ed9",
  240. "Helped Both": "#3ba85a",
  241. "Helped Neither": "#a13bd9",
  242. "N/A: Not Voting": "#999",
  243. "N/A: Present": "#bbb",
  244. "N/A: Emmer": "#ccc"
  245. };
  246. const alignOrder = ["Helped Republicans","Helped Democrats","Helped Both","Helped Neither","N/A: Not Voting","N/A: Present","N/A: Emmer"];
  247. const alignLabels = alignOrder.filter(k => DATA.alignment[k]);
  248. const alignData = alignLabels.map(k => DATA.alignment[k]);
  249. const alignColors = alignLabels.map(k => ALIGN_COLORS[k] || "#888");
  250. const alignTotal = alignData.reduce((a,b)=>a+b,0);
  251. const alignLabelsPct = alignLabels.map((l,i) => `${l} — ${alignData[i]} (${(alignData[i]/alignTotal*100).toFixed(1)}%)`);
  252. new Chart(document.getElementById('alignChart'), {
  253. type: 'doughnut',
  254. data: {labels: alignLabelsPct, datasets:[{data: alignData, backgroundColor: alignColors}]},
  255. options: {plugins:{legend:{position:'right', labels:{boxWidth:14, font:{size:12}}},
  256. tooltip:{callbacks:{label: ctx => {
  257. const v = ctx.parsed; const pct = (v/alignTotal*100).toFixed(1);
  258. return `${alignLabels[ctx.dataIndex]}: ${v} (${pct}%)`;
  259. }}}}, responsive:true, maintainAspectRatio:false}
  260. });
  261. const voteOrder = ["Yea","Aye","Nay","No","Not Voting","Present","Emmer"];
  262. const voteLabels = voteOrder.filter(k => DATA.massie[k]);
  263. const voteData = voteLabels.map(k => DATA.massie[k]);
  264. const voteTotal = voteData.reduce((a,b)=>a+b,0);
  265. const barLabelPlugin = {
  266. id: 'barLabel',
  267. afterDatasetsDraw(chart) {
  268. const {ctx} = chart;
  269. chart.data.datasets.forEach((ds, di) => {
  270. const meta = chart.getDatasetMeta(di);
  271. meta.data.forEach((bar, i) => {
  272. const v = ds.data[i];
  273. const pct = (v/voteTotal*100).toFixed(1);
  274. ctx.fillStyle = '#333';
  275. ctx.font = '600 11px -apple-system, sans-serif';
  276. ctx.textAlign = 'center';
  277. ctx.fillText(`${v} (${pct}%)`, bar.x, bar.y - 6);
  278. });
  279. });
  280. }
  281. };
  282. new Chart(document.getElementById('voteChart'), {
  283. type: 'bar',
  284. data: {labels: voteLabels, datasets:[{label:'Count', data: voteData,
  285. backgroundColor: voteLabels.map(l => l==='Yea'||l==='Aye'?'#1e8e3e': l==='Nay'||l==='No'?'#c5221f':'#888')}]},
  286. options: {plugins:{legend:{display:false},
  287. tooltip:{callbacks:{label: ctx => `${ctx.parsed.y} (${(ctx.parsed.y/voteTotal*100).toFixed(1)}%)`}}},
  288. responsive:true, maintainAspectRatio:false,
  289. layout:{padding:{top:20}},
  290. scales:{y:{beginAtZero:true}}},
  291. plugins: [barLabelPlugin]
  292. });
  293. // Stacked: with vs against, per party
  294. const partyTotR = DATA.voted_with_gop + DATA.voted_against_gop;
  295. const partyTotD = DATA.voted_with_dem + DATA.voted_against_dem;
  296. new Chart(document.getElementById('partyChart'), {
  297. type: 'bar',
  298. data: {
  299. labels: ['GOP majority', 'Dem majority'],
  300. datasets: [
  301. {label:'Voted WITH', data:[DATA.voted_with_gop, DATA.voted_with_dem], backgroundColor:'#1e8e3e'},
  302. {label:'Voted AGAINST', data:[DATA.voted_against_gop, DATA.voted_against_dem], backgroundColor:'#c5221f'}
  303. ]
  304. },
  305. options: {responsive:true, maintainAspectRatio:false,
  306. plugins:{tooltip:{callbacks:{label: ctx => {
  307. const tot = ctx.dataIndex === 0 ? partyTotR : partyTotD;
  308. return `${ctx.dataset.label}: ${ctx.parsed.y} (${(ctx.parsed.y/tot*100).toFixed(1)}%)`;
  309. }}}},
  310. scales:{x:{stacked:true}, y:{stacked:true, beginAtZero:true}}},
  311. plugins: [{
  312. id:'stackPct',
  313. afterDatasetsDraw(chart) {
  314. const {ctx} = chart;
  315. ctx.fillStyle='#fff'; ctx.font='600 12px -apple-system, sans-serif'; ctx.textAlign='center';
  316. chart.data.datasets.forEach((ds,di) => {
  317. chart.getDatasetMeta(di).data.forEach((bar,i) => {
  318. const tot = i === 0 ? partyTotR : partyTotD;
  319. const v = ds.data[i];
  320. if (v > 0) ctx.fillText(`${v} (${(v/tot*100).toFixed(1)}%)`, bar.x, (bar.y + bar.base)/2 + 4);
  321. });
  322. });
  323. }
  324. }]
  325. });
  326. const blkTot = DATA.blocked_dem_count + DATA.blocked_rep_count;
  327. new Chart(document.getElementById('blockChart'), {
  328. type: 'bar',
  329. data: {labels:['Blocked Dem-backed','Blocked GOP-backed'],
  330. datasets:[{label:'Count', data:[DATA.blocked_dem_count, DATA.blocked_rep_count],
  331. backgroundColor:['#3b6ed9','#d93b3b']}]},
  332. options:{indexAxis:'y', responsive:true, maintainAspectRatio:false,
  333. plugins:{legend:{display:false},
  334. tooltip:{callbacks:{label: ctx => `${ctx.parsed.x} (${(ctx.parsed.x/blkTot*100).toFixed(1)}%)`}}},
  335. scales:{x:{beginAtZero:true}}},
  336. plugins:[{
  337. id:'hbarLabel',
  338. afterDatasetsDraw(chart){
  339. const {ctx} = chart;
  340. ctx.fillStyle='#333'; ctx.font='600 12px -apple-system, sans-serif'; ctx.textAlign='left'; ctx.textBaseline='middle';
  341. chart.getDatasetMeta(0).data.forEach((bar,i) => {
  342. const v = chart.data.datasets[0].data[i];
  343. ctx.fillText(` ${v} (${(v/blkTot*100).toFixed(1)}%)`, bar.x, bar.y);
  344. });
  345. }
  346. }]
  347. });
  348. const trendLabels = DATA.months;
  349. const trendSets = ["Helped Republicans","Helped Democrats","Helped Both","Helped Neither"].map(k => ({
  350. label: k, data: DATA.monthly[k], borderColor: ALIGN_COLORS[k], backgroundColor: ALIGN_COLORS[k]+'33',
  351. tension: 0.3, fill: false
  352. }));
  353. new Chart(document.getElementById('trendChart'), {
  354. type: 'line',
  355. data: {labels: trendLabels, datasets: trendSets},
  356. options: {responsive:true, maintainAspectRatio:false,
  357. scales:{y:{beginAtZero:true, title:{display:true,text:'Votes per month'}}},
  358. interaction:{mode:'index', intersect:false}}
  359. });
  360. // Table
  361. let sortKey = 'r', sortDesc = true, sortYearSecondary = true;
  362. function renderTable() {
  363. const q = document.getElementById('search').value.toLowerCase();
  364. const af = document.getElementById('alignFilter').value;
  365. const bf = document.getElementById('blockFilter').value;
  366. const mf = document.getElementById('massieFilter').value;
  367. let rows = DATA.rows.filter(r => {
  368. if (q && !((r.ln+' '+r.ds+' '+r.q).toLowerCase().includes(q))) return false;
  369. if (af && r.a !== af) return false;
  370. if (bf && r.b !== bf) return false;
  371. if (mf && r.m !== mf) return false;
  372. return true;
  373. });
  374. rows.sort((a,b)=>{
  375. const av = a[sortKey], bv = b[sortKey];
  376. let cmp;
  377. if (typeof av === 'number') cmp = av - bv;
  378. else cmp = String(av).localeCompare(String(bv));
  379. if (cmp === 0 && sortKey !== 'r') cmp = (a.y - b.y) || (a.r - b.r);
  380. return sortDesc ? -cmp : cmp;
  381. });
  382. const tb = document.getElementById('voteBody');
  383. const aClass = a => 'badge ' + ({"Helped Republicans":"b-rep","Helped Democrats":"b-dem","Helped Both":"b-both","Helped Neither":"b-neither"}[a] || "b-na");
  384. const mClass = m => 'v-' + (m==='Yea'||m==='Aye'?'yea':m==='Nay'||m==='No'?'nay':m==='Not Voting'?'nv':'present');
  385. tb.innerHTML = rows.map(r => `<tr>
  386. <td>${r.y}</td><td>${r.r}</td><td>${r.d}</td>
  387. <td>${r.ln||''}</td><td>${r.q||''}</td><td>${r.ds||''}</td>
  388. <td>${r.rs}</td>
  389. <td class="${mClass(r.m)}">${r.m||''}</td>
  390. <td>${r.ry}</td><td>${r.rn}</td><td>${r.dy}</td><td>${r.dn}</td>
  391. <td><span class="${aClass(r.a)}">${r.a}</span></td>
  392. <td>${r.b ? `<span class="badge ${r.b==='Democrat'?'b-dem':'b-rep'}">${r.b}</span>` : ''}</td>
  393. </tr>`).join('');
  394. document.getElementById('row-count').textContent = rows.length;
  395. }
  396. document.querySelectorAll('#voteTable th').forEach(th => {
  397. th.addEventListener('click', () => {
  398. const k = th.dataset.k;
  399. if (sortKey === k) sortDesc = !sortDesc;
  400. else { sortKey = k; sortDesc = true; }
  401. renderTable();
  402. });
  403. });
  404. ['search','alignFilter','blockFilter','massieFilter'].forEach(id =>
  405. document.getElementById(id).addEventListener('input', renderTable));
  406. renderTable();
  407. </script>
  408. </body>
  409. </html>
  410. """
  411. def pct(n, d): return f"{(n/d*100):.1f}" if d else "0.0"
  412. partisan_r = data["voted_with_gop"] + data["voted_against_gop"]
  413. partisan_d = data["voted_with_dem"] + data["voted_against_dem"]
  414. html = (HTML
  415. .replace("__TOTAL__", str(data["total"]))
  416. .replace("__VOTING__", str(data["voting"]))
  417. .replace("__VOTING_PCT__", pct(data["voting"], data["total"]))
  418. .replace("__YEAS__", str(data["yeas"]))
  419. .replace("__NAYS__", str(data["nays"]))
  420. .replace("__NV__", str(data["nv"]))
  421. .replace("__PRESENT__", str(data["present"]))
  422. .replace("__VA_GOP__", str(data["voted_against_gop"]))
  423. .replace("__VA_GOP_PCT__", pct(data["voted_against_gop"], partisan_r))
  424. .replace("__VA_DEM__", str(data["voted_against_dem"]))
  425. .replace("__VA_DEM_PCT__", pct(data["voted_against_dem"], partisan_d))
  426. .replace("__PARTISAN_R__", str(partisan_r))
  427. .replace("__PARTISAN_D__", str(partisan_d))
  428. .replace("__BLK_DEM__", str(data["blocked_dem_count"]))
  429. .replace("__BLK_DEM_PCT__", pct(data["blocked_dem_count"], data["total"]))
  430. .replace("__BLK_REP__", str(data["blocked_rep_count"]))
  431. .replace("__BLK_REP_PCT__", pct(data["blocked_rep_count"], data["total"]))
  432. .replace("__LONE__", str(data["lone_wolf"]))
  433. .replace("__LONE_PCT__", pct(data["lone_wolf"], data["voting"]))
  434. .replace("__DATA__", json.dumps(data))
  435. )
  436. with open("/home/user/polisci/dashboard.html", "w") as f:
  437. f.write(html)
  438. print(f"Wrote dashboard.html: {len(html)} bytes, {data['lone_wolf']} lone-wolf votes")