build_senator.py 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433
  1. #!/usr/bin/env python3
  2. """Parameterized Senate dashboard builder. Parses cached senate.gov XMLs
  3. for a given senator (LIS ID) and emits a self-contained HTML dashboard.
  4. Usage:
  5. python3 build_senator.py <lis_id> "<Full Name>" <party R|D|I> <output_filename>
  6. python3 build_senator.py # builds the default ROSTER
  7. """
  8. import sys, os, json, glob, re
  9. import xml.etree.ElementTree as ET
  10. from collections import Counter, defaultdict
  11. CACHE_DIR = "/home/user/polisci/senate_vote_cache"
  12. OUT_DIR = "/home/user/polisci/results"
  13. MONTHS_FULL = ["January","February","March","April","May","June",
  14. "July","August","September","October","November","December"]
  15. # Independents who caucus with Democrats — treat as D for party-majority math.
  16. DEM_CAUCUSING_INDEPENDENTS = {"S313"} # Sanders (VT); add King (S354) etc as needed.
  17. def parse_date(s):
  18. """'January 9, 2025, 02:54 PM' -> ('2025-01', '2025-01-09')."""
  19. if not s: return None, None
  20. m = re.match(r"\s*(\w+)\s+(\d+),\s+(\d+)", s)
  21. if not m: return None, None
  22. mon, day, yr = m.groups()
  23. try:
  24. mi = MONTHS_FULL.index(mon) + 1
  25. return f"{yr}-{mi:02d}", f"{yr}-{mi:02d}-{int(day):02d}"
  26. except ValueError:
  27. return None, None
  28. def parse_xml(path, session, vnum, lis_id):
  29. try:
  30. with open(path, "rb") as f:
  31. root = ET.fromstring(f.read())
  32. except Exception:
  33. return None
  34. def t(tag):
  35. el = root.find(tag)
  36. return (el.text or "").strip() if el is not None and el.text else ""
  37. # Build party totals from members
  38. R = {"yea":0,"nay":0,"present":0,"nv":0}
  39. D = {"yea":0,"nay":0,"present":0,"nv":0}
  40. member_vote = None
  41. for mem in root.iter("member"):
  42. party = (mem.findtext("party","") or "").strip()
  43. vc = (mem.findtext("vote_cast","") or "").strip()
  44. lid = (mem.findtext("lis_member_id","") or "").strip()
  45. if lid == lis_id:
  46. member_vote = vc
  47. bucket = ("yea" if vc == "Yea"
  48. else "nay" if vc == "Nay"
  49. else "present" if vc == "Present"
  50. else "nv")
  51. if party == "R":
  52. R[bucket] += 1
  53. elif party == "D" or lid in DEM_CAUCUSING_INDEPENDENTS:
  54. D[bucket] += 1
  55. # other independents (none currently caucus with R) ignored for party math
  56. # Compose bill id
  57. doc_type = root.findtext("document/document_type","") or ""
  58. doc_num = root.findtext("document/document_number","") or ""
  59. legis = (doc_type + " " + doc_num).strip() if (doc_type or doc_num) else (root.findtext("vote_title","") or "")
  60. date_raw = t("vote_date")
  61. return {
  62. "session": session, "vnum": vnum,
  63. "date_raw": date_raw,
  64. "month_key": parse_date(date_raw)[0],
  65. "date_iso": parse_date(date_raw)[1],
  66. "question": t("question") or t("vote_question_text"),
  67. "result": t("vote_result"),
  68. "legis_num": legis,
  69. "desc": t("vote_title") or t("vote_document_text"),
  70. "R": R, "D": D,
  71. "member": member_vote,
  72. }
  73. def classify(v):
  74. r_yea, r_nay = v["R"]["yea"], v["R"]["nay"]
  75. d_yea, d_nay = v["D"]["yea"], v["D"]["nay"]
  76. r_pos = "Yea" if r_yea > r_nay else ("Nay" if r_nay > r_yea else "Split")
  77. d_pos = "Yea" if d_yea > d_nay else ("Nay" if d_nay > d_yea else "Split")
  78. m = v["member"]
  79. if m not in ("Yea","Nay"):
  80. return ("N/A: " + (m or "absent"), None, r_pos, d_pos)
  81. helped_r = (r_pos != "Split" and m == r_pos)
  82. helped_d = (d_pos != "Split" and m == d_pos)
  83. if helped_r and helped_d: align = "Helped Both"
  84. elif helped_r: align = "Helped Republicans"
  85. elif helped_d: align = "Helped Democrats"
  86. else: align = "Helped Neither"
  87. result = v["result"].lower()
  88. # Senate results often say "agreed to" / "rejected" / "passed" / "failed" / "not agreed to"
  89. measure_failed = ("rejected" in result or "not agreed" in result
  90. or "failed" in result or "not passed" in result
  91. or "not invoked" in result)
  92. blocked = None
  93. if d_pos == "Yea" and measure_failed and m == "Nay" and r_pos != "Yea":
  94. blocked = "Democrat"
  95. if r_pos == "Yea" and measure_failed and m == "Nay" and d_pos != "Yea":
  96. blocked = "Republican"
  97. return (align, blocked, r_pos, d_pos)
  98. def gather(lis_id):
  99. votes = []
  100. for path in sorted(glob.glob(f"{CACHE_DIR}/*.xml")):
  101. base = os.path.basename(path).replace(".xml","")
  102. try:
  103. session, vnum = base.split("_")
  104. session, vnum = int(session), int(vnum)
  105. except Exception:
  106. continue
  107. v = parse_xml(path, session, vnum, lis_id)
  108. if not v: continue
  109. align, blocked, r_pos, d_pos = classify(v)
  110. v["alignment"] = align
  111. v["blocked"] = blocked
  112. v["r_pos"] = r_pos
  113. v["d_pos"] = d_pos
  114. votes.append(v)
  115. return votes
  116. def aggregate(votes, member_party):
  117. align_counts = Counter(v["alignment"] for v in votes)
  118. member_counts = Counter(v["member"] for v in votes)
  119. blocked_counts = Counter(v["blocked"] for v in votes if v["blocked"])
  120. month_align = defaultdict(Counter)
  121. for v in votes:
  122. if v["month_key"]:
  123. month_align[v["month_key"]][v["alignment"]] += 1
  124. months_sorted = sorted(month_align.keys())
  125. align_labels = ["Helped Republicans","Helped Democrats","Helped Both","Helped Neither"]
  126. monthly = {l: [month_align[m].get(l,0) for m in months_sorted] for l in align_labels}
  127. def row(v):
  128. return {"y": (v["date_iso"] or "")[:4] or "?", "r": v["vnum"],
  129. "d": v["date_raw"] or "", "ln": v["legis_num"],
  130. "q": v["question"], "ds": (v["desc"] or "")[:90],
  131. "rs": v["result"], "m": v["member"],
  132. "ry": v["R"]["yea"], "rn": v["R"]["nay"],
  133. "dy": v["D"]["yea"], "dn": v["D"]["nay"],
  134. "a": v["alignment"], "b": v["blocked"] or ""}
  135. rows = [row(v) for v in votes]
  136. total = len(votes)
  137. voting = sum(1 for v in votes if v["member"] in ("Yea","Nay"))
  138. yeas = sum(1 for v in votes if v["member"] == "Yea")
  139. nays = sum(1 for v in votes if v["member"] == "Nay")
  140. nv = sum(1 for v in votes if v["member"] == "Not Voting")
  141. present = sum(1 for v in votes if v["member"] == "Present")
  142. va_gop = vw_gop = va_dem = vw_dem = 0
  143. for v in votes:
  144. if v["member"] not in ("Yea","Nay"): continue
  145. r_pos = v["r_pos"]; d_pos = v["d_pos"]
  146. if r_pos != "Split":
  147. if v["member"] == r_pos: vw_gop += 1
  148. else: va_gop += 1
  149. if d_pos != "Split":
  150. if v["member"] == d_pos: vw_dem += 1
  151. else: va_dem += 1
  152. own = member_party
  153. lone_wolf = 0
  154. for v in votes:
  155. if v["member"] not in ("Yea","Nay"): continue
  156. own_pos = v["r_pos"] if own == "R" else v["d_pos"]
  157. if own_pos == "Split" or v["member"] == own_pos: continue
  158. own_totals = v["R"] if own == "R" else v["D"]
  159. defectors = own_totals["nay"] if own_pos == "Yea" else own_totals["yea"]
  160. if defectors <= 3: # Senate threshold tighter (smaller chamber)
  161. lone_wolf += 1
  162. return {
  163. "total":total,"voting":voting,"yeas":yeas,"nays":nays,"nv":nv,"present":present,
  164. "alignment":dict(align_counts),"member":dict(member_counts),
  165. "blocked":dict(blocked_counts),"months":months_sorted,"monthly":monthly,
  166. "rows":rows,
  167. "blocked_dem_count":sum(1 for v in votes if v["blocked"] == "Democrat"),
  168. "blocked_rep_count":sum(1 for v in votes if v["blocked"] == "Republican"),
  169. "lone_wolf":lone_wolf,
  170. "voted_against_gop":va_gop,"voted_against_dem":va_dem,
  171. "voted_with_gop":vw_gop,"voted_with_dem":vw_dem,
  172. }
  173. HTML_TEMPLATE = r"""<!DOCTYPE html>
  174. <html lang="en">
  175. <head>
  176. <meta charset="utf-8">
  177. <title>__NAME__ - 119th Congress Voting Dashboard</title>
  178. <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
  179. <script src="https://cdn.jsdelivr.net/npm/sortablejs@1.15.2/Sortable.min.js"></script>
  180. <style>
  181. body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif;margin:0;padding:24px;background:#f5f5f7;color:#1d1d1f;}
  182. h1{margin:0 0 4px;font-size:28px;} .sub{color:#6e6e73;margin-bottom:24px;}
  183. .grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:16px;margin-bottom:24px;}
  184. .card{background:#fff;border-radius:12px;padding:20px;box-shadow:0 1px 3px rgba(0,0,0,.06);cursor:grab;transition:box-shadow .15s,transform .15s;}
  185. .card:active{cursor:grabbing;} .card.sortable-ghost{opacity:.4;}
  186. .card.sortable-chosen{box-shadow:0 4px 16px rgba(0,0,0,.15);transform:scale(1.02);}
  187. .card.sortable-drag{box-shadow:0 8px 24px rgba(0,0,0,.2);}
  188. .kpi{font-size:32px;font-weight:700;margin:6px 0 2px;} .pct{font-size:14px;font-weight:500;color:#6e6e73;margin-left:4px;}
  189. .kpi-label{color:#6e6e73;font-size:13px;text-transform:uppercase;letter-spacing:.5px;}
  190. .chart-row{display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-bottom:24px;}
  191. @media (max-width:900px){.chart-row{grid-template-columns:1fr;}}
  192. .chart-card{background:#fff;border-radius:12px;padding:20px;box-shadow:0 1px 3px rgba(0,0,0,.06);min-width:0;}
  193. .chart-box{position:relative;height:300px;width:100%;}
  194. h2{font-size:18px;margin:0 0 12px;}
  195. table{width:100%;border-collapse:collapse;font-size:13px;}
  196. th,td{padding:6px 8px;text-align:left;border-bottom:1px solid #e8e8ed;}
  197. th{background:#fafafa;position:sticky;top:0;cursor:pointer;user-select:none;}
  198. th:hover{background:#f0f0f5;}
  199. .table-wrap{max-height:600px;overflow:auto;border:1px solid #e8e8ed;border-radius:8px;}
  200. .filter-bar{display:flex;gap:8px;margin-bottom:12px;flex-wrap:wrap;}
  201. .filter-bar input,.filter-bar select{padding:8px 12px;border:1px solid #d2d2d7;border-radius:8px;font-size:14px;}
  202. .filter-bar input{flex:1;min-width:200px;}
  203. .badge{display:inline-block;padding:2px 8px;border-radius:4px;font-size:11px;font-weight:600;}
  204. .b-rep{background:#fde2e2;color:#a30000;} .b-dem{background:#dde7fa;color:#1644a0;}
  205. .b-both{background:#e0f0e0;color:#1e6b1e;} .b-neither{background:#f0e0f0;color:#6b1e6b;}
  206. .b-na{background:#eee;color:#666;}
  207. .v-yea{color:#1e6b1e;font-weight:600;} .v-nay{color:#a30000;font-weight:600;}
  208. .v-nv,.v-present{color:#888;}
  209. .note{font-size:12px;color:#6e6e73;line-height:1.5;margin-top:12px;}
  210. .drag-hint{font-size:11px;color:#6e6e73;margin-bottom:8px;}
  211. .drag-hint button{margin-left:8px;padding:2px 8px;font-size:11px;border:1px solid #d2d2d7;border-radius:4px;background:#fff;cursor:pointer;}
  212. .party-pill{display:inline-block;padding:2px 8px;border-radius:10px;font-size:12px;font-weight:600;margin-left:8px;color:#fff;}
  213. .chamber-pill{display:inline-block;padding:2px 8px;border-radius:10px;font-size:12px;font-weight:600;margin-left:6px;background:#5a3d8a;color:#fff;}
  214. .footer{text-align:center;color:#6e6e73;font-size:12px;margin-top:32px;}
  215. .member-note{background:#fff8e1;border-left:4px solid #f5b400;padding:12px 16px;border-radius:6px;margin:0 0 20px;font-size:14px;line-height:1.5;color:#5a4500;}
  216. .member-note strong{color:#3d2f00;}
  217. </style>
  218. </head>
  219. <body>
  220. <h1>__NAME__<span class="party-pill" style="background:__PARTY_COLOR__">__PARTY__</span><span class="chamber-pill">SENATE</span></h1>
  221. <div class="sub">LIS __LIS__ · Senate roll-call votes, 119th Congress (Jan 2025 – present) · Source: senate.gov</div>
  222. __MEMBER_NOTE__
  223. <div class="drag-hint">↕ Drag any card to rearrange. <button id="resetOrder">Reset order</button></div>
  224. <div class="grid" id="cardGrid">
  225. <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 · Yea: __YEAS__ · Nay: __NAYS__ · Not Voting: __NV__ · Present: __PRESENT__</div></div>
  226. <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>
  227. <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>
  228. <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">Senator Nay + D wanted Yea + measure failed</div></div>
  229. <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">Senator Nay + R wanted Yea + measure failed</div></div>
  230. <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">Defected from own (__PARTY__) party majority w/ ≤3 fellow defectors</div></div>
  231. </div>
  232. <div class="chart-row">
  233. <div class="chart-card"><h2>Alignment Classification</h2><div class="chart-box"><canvas id="alignChart"></canvas></div></div>
  234. <div class="chart-card"><h2>Vote Distribution</h2><div class="chart-box"><canvas id="voteChart"></canvas></div></div>
  235. </div>
  236. <div class="chart-row">
  237. <div class="chart-card"><h2>Voted With vs. Against — by Party Majority</h2><div class="chart-box"><canvas id="partyChart"></canvas></div></div>
  238. <div class="chart-card"><h2>Blocking Wins</h2><div class="chart-box"><canvas id="blockChart"></canvas></div></div>
  239. </div>
  240. <div class="chart-row">
  241. <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>
  242. </div>
  243. <div class="chart-row">
  244. <div class="chart-card" style="grid-column:1 / -1">
  245. <h2>All Votes (filterable)</h2>
  246. <div class="filter-bar">
  247. <input id="search" placeholder="Search bill/description…">
  248. <select id="alignFilter"><option value="">All alignments</option><option>Helped Republicans</option><option>Helped Democrats</option><option>Helped Both</option><option>Helped Neither</option></select>
  249. <select id="blockFilter"><option value="">All votes</option><option value="Democrat">Blocked Dem-backed</option><option value="Republican">Blocked GOP-backed</option></select>
  250. <select id="memberFilter"><option value="">Any vote</option><option>Yea</option><option>Nay</option><option>Not Voting</option><option>Present</option></select>
  251. </div>
  252. <div class="table-wrap"><table id="voteTable">
  253. <thead><tr><th data-k="y">Yr</th><th data-k="r">#</th><th data-k="d">Date</th><th data-k="ln">Bill</th><th data-k="q">Question</th><th data-k="ds">Description</th><th data-k="rs">Result</th><th data-k="m">Vote</th><th data-k="ry">R Yea</th><th data-k="rn">R Nay</th><th data-k="dy">D Yea</th><th data-k="dn">D Nay</th><th data-k="a">Alignment</th><th data-k="b">Blocked</th></tr></thead>
  254. <tbody id="voteBody"></tbody></table></div>
  255. <div class="note">Click headers to sort. Showing <span id="row-count">0</span> rows.</div>
  256. </div>
  257. </div>
  258. <div class="footer">Source: senate.gov LIS XML rollcalls · Built 2026-05-24 · See DOCUMENTATION.md §12 for Senate methodology</div>
  259. <script>
  260. const DATA = __DATA__;
  261. const CARD_ORDER_KEY = 'dashCardOrder.__LIS__';
  262. const grid = document.getElementById('cardGrid');
  263. function applyOrder(order){if(!order)return;const byId={};Array.from(grid.children).forEach(c=>byId[c.dataset.id]=c);order.forEach(id=>{if(byId[id])grid.appendChild(byId[id]);});}
  264. try{applyOrder(JSON.parse(localStorage.getItem(CARD_ORDER_KEY)));}catch(e){}
  265. Sortable.create(grid,{animation:180,ghostClass:'sortable-ghost',chosenClass:'sortable-chosen',dragClass:'sortable-drag',
  266. onEnd(){localStorage.setItem(CARD_ORDER_KEY,JSON.stringify(Array.from(grid.children).map(c=>c.dataset.id)));}});
  267. document.getElementById('resetOrder').addEventListener('click',()=>{localStorage.removeItem(CARD_ORDER_KEY);location.reload();});
  268. const ALIGN_COLORS={"Helped Republicans":"#d93b3b","Helped Democrats":"#3b6ed9","Helped Both":"#3ba85a","Helped Neither":"#a13bd9","N/A: Not Voting":"#999","N/A: Present":"#bbb"};
  269. const alignOrder=["Helped Republicans","Helped Democrats","Helped Both","Helped Neither","N/A: Not Voting","N/A: Present"];
  270. const alignLabels=alignOrder.filter(k=>DATA.alignment[k]);
  271. const alignData=alignLabels.map(k=>DATA.alignment[k]);
  272. const alignColors=alignLabels.map(k=>ALIGN_COLORS[k]||"#888");
  273. const alignTotal=alignData.reduce((a,b)=>a+b,0);
  274. const alignLabelsPct=alignLabels.map((l,i)=>`${l} — ${alignData[i]} (${(alignData[i]/alignTotal*100).toFixed(1)}%)`);
  275. new Chart(document.getElementById('alignChart'),{type:'doughnut',data:{labels:alignLabelsPct,datasets:[{data:alignData,backgroundColor:alignColors}]},
  276. options:{plugins:{legend:{position:'right',labels:{boxWidth:14,font:{size:12}}},
  277. tooltip:{callbacks:{label:ctx=>`${alignLabels[ctx.dataIndex]}: ${ctx.parsed} (${(ctx.parsed/alignTotal*100).toFixed(1)}%)`}}},
  278. responsive:true,maintainAspectRatio:false}});
  279. const voteOrder=["Yea","Nay","Not Voting","Present"];
  280. const voteLabels=voteOrder.filter(k=>DATA.member[k]);
  281. const voteData=voteLabels.map(k=>DATA.member[k]);
  282. const voteTotal=voteData.reduce((a,b)=>a+b,0);
  283. const barLabelPlugin={id:'barLabel',afterDatasetsDraw(chart){const {ctx}=chart;
  284. chart.data.datasets.forEach((ds,di)=>{chart.getDatasetMeta(di).data.forEach((bar,i)=>{
  285. const v=ds.data[i],pct=(v/voteTotal*100).toFixed(1);
  286. ctx.fillStyle='#333';ctx.font='600 11px -apple-system, sans-serif';ctx.textAlign='center';
  287. ctx.fillText(`${v} (${pct}%)`,bar.x,bar.y-6);});});}};
  288. new Chart(document.getElementById('voteChart'),{type:'bar',
  289. data:{labels:voteLabels,datasets:[{label:'Count',data:voteData,
  290. backgroundColor:voteLabels.map(l=>l==='Yea'?'#1e8e3e':l==='Nay'?'#c5221f':'#888')}]},
  291. options:{plugins:{legend:{display:false},tooltip:{callbacks:{label:ctx=>`${ctx.parsed.y} (${(ctx.parsed.y/voteTotal*100).toFixed(1)}%)`}}},
  292. responsive:true,maintainAspectRatio:false,layout:{padding:{top:20}},scales:{y:{beginAtZero:true}}},
  293. plugins:[barLabelPlugin]});
  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'),{type:'bar',
  297. data:{labels:['GOP majority','Dem majority'],datasets:[
  298. {label:'Voted WITH',data:[DATA.voted_with_gop,DATA.voted_with_dem],backgroundColor:'#1e8e3e'},
  299. {label:'Voted AGAINST',data:[DATA.voted_against_gop,DATA.voted_against_dem],backgroundColor:'#c5221f'}]},
  300. options:{responsive:true,maintainAspectRatio:false,
  301. plugins:{tooltip:{callbacks:{label:ctx=>{const tot=ctx.dataIndex===0?partyTotR:partyTotD;return `${ctx.dataset.label}: ${ctx.parsed.y} (${tot?(ctx.parsed.y/tot*100).toFixed(1):'0.0'}%)`;}}}},
  302. scales:{x:{stacked:true},y:{stacked:true,beginAtZero:true}}},
  303. plugins:[{id:'stackPct',afterDatasetsDraw(chart){const {ctx}=chart;
  304. ctx.fillStyle='#fff';ctx.font='600 12px -apple-system, sans-serif';ctx.textAlign='center';
  305. chart.data.datasets.forEach((ds,di)=>{chart.getDatasetMeta(di).data.forEach((bar,i)=>{
  306. const tot=i===0?partyTotR:partyTotD,v=ds.data[i];
  307. if(v>0&&tot)ctx.fillText(`${v} (${(v/tot*100).toFixed(1)}%)`,bar.x,(bar.y+bar.base)/2+4);});});}}]});
  308. const blkTot=DATA.blocked_dem_count+DATA.blocked_rep_count;
  309. new Chart(document.getElementById('blockChart'),{type:'bar',
  310. data:{labels:['Blocked Dem-backed','Blocked GOP-backed'],
  311. datasets:[{label:'Count',data:[DATA.blocked_dem_count,DATA.blocked_rep_count],backgroundColor:['#3b6ed9','#d93b3b']}]},
  312. options:{indexAxis:'y',responsive:true,maintainAspectRatio:false,
  313. plugins:{legend:{display:false},tooltip:{callbacks:{label:ctx=>`${ctx.parsed.x} (${blkTot?(ctx.parsed.x/blkTot*100).toFixed(1):'0.0'}%)`}}},
  314. scales:{x:{beginAtZero:true}}},
  315. plugins:[{id:'hbarLabel',afterDatasetsDraw(chart){const {ctx}=chart;
  316. ctx.fillStyle='#333';ctx.font='600 12px -apple-system, sans-serif';ctx.textAlign='left';ctx.textBaseline='middle';
  317. chart.getDatasetMeta(0).data.forEach((bar,i)=>{const v=chart.data.datasets[0].data[i];
  318. ctx.fillText(` ${v} (${blkTot?(v/blkTot*100).toFixed(1):'0.0'}%)`,bar.x,bar.y);});}}]});
  319. const trendLabels=DATA.months;
  320. const trendSets=["Helped Republicans","Helped Democrats","Helped Both","Helped Neither"].map(k=>({
  321. label:k,data:DATA.monthly[k],borderColor:ALIGN_COLORS[k],backgroundColor:ALIGN_COLORS[k]+'33',tension:.3,fill:false}));
  322. new Chart(document.getElementById('trendChart'),{type:'line',data:{labels:trendLabels,datasets:trendSets},
  323. options:{responsive:true,maintainAspectRatio:false,
  324. scales:{y:{beginAtZero:true,title:{display:true,text:'Votes per month'}}},interaction:{mode:'index',intersect:false}}});
  325. let sortKey='r',sortDesc=true;
  326. function renderTable(){
  327. const q=document.getElementById('search').value.toLowerCase();
  328. const af=document.getElementById('alignFilter').value;
  329. const bf=document.getElementById('blockFilter').value;
  330. const mf=document.getElementById('memberFilter').value;
  331. let rows=DATA.rows.filter(r=>{
  332. if(q&&!((r.ln+' '+r.ds+' '+r.q).toLowerCase().includes(q)))return false;
  333. if(af&&r.a!==af)return false; if(bf&&r.b!==bf)return false; if(mf&&r.m!==mf)return false; return true;});
  334. rows.sort((a,b)=>{const av=a[sortKey],bv=b[sortKey];let cmp;
  335. if(typeof av==='number')cmp=av-bv;else cmp=String(av).localeCompare(String(bv));
  336. if(cmp===0&&sortKey!=='r')cmp=(String(a.y).localeCompare(String(b.y)))||(a.r-b.r);return sortDesc?-cmp:cmp;});
  337. const tb=document.getElementById('voteBody');
  338. const aClass=a=>'badge '+({"Helped Republicans":"b-rep","Helped Democrats":"b-dem","Helped Both":"b-both","Helped Neither":"b-neither"}[a]||"b-na");
  339. const mClass=m=>'v-'+(m==='Yea'?'yea':m==='Nay'?'nay':m==='Not Voting'?'nv':'present');
  340. tb.innerHTML=rows.map(r=>`<tr><td>${r.y}</td><td>${r.r}</td><td>${r.d}</td><td>${r.ln||''}</td><td>${r.q||''}</td><td>${r.ds||''}</td><td>${r.rs}</td><td class="${mClass(r.m)}">${r.m||''}</td><td>${r.ry}</td><td>${r.rn}</td><td>${r.dy}</td><td>${r.dn}</td><td><span class="${aClass(r.a)}">${r.a}</span></td><td>${r.b?`<span class="badge ${r.b==='Democrat'?'b-dem':'b-rep'}">${r.b}</span>`:''}</td></tr>`).join('');
  341. document.getElementById('row-count').textContent=rows.length;
  342. }
  343. document.querySelectorAll('#voteTable th').forEach(th=>{th.addEventListener('click',()=>{const k=th.dataset.k;if(sortKey===k)sortDesc=!sortDesc;else{sortKey=k;sortDesc=true;}renderTable();});});
  344. ['search','alignFilter','blockFilter','memberFilter'].forEach(id=>document.getElementById(id).addEventListener('input',renderTable));
  345. renderTable();
  346. </script>
  347. </body></html>
  348. """
  349. def render(lis_id, name, party, out_name, note=None):
  350. votes = gather(lis_id)
  351. if not votes:
  352. print(f" WARN: no votes parsed for {name}", file=sys.stderr)
  353. data = aggregate(votes, party)
  354. def pct(n, d): return f"{(n/d*100):.1f}" if d else "0.0"
  355. partisan_r = data["voted_with_gop"] + data["voted_against_gop"]
  356. partisan_d = data["voted_with_dem"] + data["voted_against_dem"]
  357. color = "#d93b3b" if party == "R" else ("#3b6ed9" if party == "D" else "#888")
  358. note_html = f'<div class="member-note"><strong>Note:</strong> {note}</div>' if note else ''
  359. html = (HTML_TEMPLATE
  360. .replace("__MEMBER_NOTE__", note_html)
  361. .replace("__NAME__", name)
  362. .replace("__LIS__", lis_id)
  363. .replace("__PARTY__", party)
  364. .replace("__PARTY_COLOR__", color)
  365. .replace("__TOTAL__", str(data["total"]))
  366. .replace("__VOTING__", str(data["voting"]))
  367. .replace("__VOTING_PCT__", pct(data["voting"], data["total"]))
  368. .replace("__YEAS__", str(data["yeas"]))
  369. .replace("__NAYS__", str(data["nays"]))
  370. .replace("__NV__", str(data["nv"]))
  371. .replace("__PRESENT__", str(data["present"]))
  372. .replace("__VA_GOP__", str(data["voted_against_gop"]))
  373. .replace("__VA_GOP_PCT__", pct(data["voted_against_gop"], partisan_r))
  374. .replace("__VA_DEM__", str(data["voted_against_dem"]))
  375. .replace("__VA_DEM_PCT__", pct(data["voted_against_dem"], partisan_d))
  376. .replace("__PARTISAN_R__", str(partisan_r))
  377. .replace("__PARTISAN_D__", str(partisan_d))
  378. .replace("__BLK_DEM__", str(data["blocked_dem_count"]))
  379. .replace("__BLK_DEM_PCT__", pct(data["blocked_dem_count"], data["total"]))
  380. .replace("__BLK_REP__", str(data["blocked_rep_count"]))
  381. .replace("__BLK_REP_PCT__", pct(data["blocked_rep_count"], data["total"]))
  382. .replace("__LONE__", str(data["lone_wolf"]))
  383. .replace("__LONE_PCT__", pct(data["lone_wolf"], data["voting"]))
  384. .replace("__DATA__", json.dumps(data))
  385. )
  386. os.makedirs(OUT_DIR, exist_ok=True)
  387. out = os.path.join(OUT_DIR, out_name)
  388. with open(out, "w") as f: f.write(html)
  389. print(f" {name}: {data['voting']}/{data['total']} voted, "
  390. f"R-aligned:{data['alignment'].get('Helped Republicans',0)} "
  391. f"D-aligned:{data['alignment'].get('Helped Democrats',0)} "
  392. f"Both:{data['alignment'].get('Helped Both',0)} "
  393. f"Neither:{data['alignment'].get('Helped Neither',0)} → {out}")
  394. if __name__ == "__main__":
  395. if len(sys.argv) >= 5:
  396. render(sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4])
  397. else:
  398. ROSTER = [
  399. ("S293", "Lindsey Graham", "R", "LindseyGraham119.html"),
  400. ]
  401. for entry in ROSTER:
  402. lis, nm, pt, fn = entry[:4]
  403. note = entry[4] if len(entry) > 4 else None
  404. render(lis, nm, pt, fn, note)