| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433 |
- #!/usr/bin/env python3
- """Parameterized Senate dashboard builder. Parses cached senate.gov XMLs
- for a given senator (LIS ID) and emits a self-contained HTML dashboard.
- Usage:
- python3 build_senator.py <lis_id> "<Full Name>" <party R|D|I> <output_filename>
- python3 build_senator.py # builds the default ROSTER
- """
- import sys, os, json, glob, re
- import xml.etree.ElementTree as ET
- from collections import Counter, defaultdict
- CACHE_DIR = "/home/user/polisci/senate_vote_cache"
- OUT_DIR = "/home/user/polisci/results"
- MONTHS_FULL = ["January","February","March","April","May","June",
- "July","August","September","October","November","December"]
- # Independents who caucus with Democrats — treat as D for party-majority math.
- DEM_CAUCUSING_INDEPENDENTS = {"S313"} # Sanders (VT); add King (S354) etc as needed.
- def parse_date(s):
- """'January 9, 2025, 02:54 PM' -> ('2025-01', '2025-01-09')."""
- if not s: return None, None
- m = re.match(r"\s*(\w+)\s+(\d+),\s+(\d+)", s)
- if not m: return None, None
- mon, day, yr = m.groups()
- try:
- mi = MONTHS_FULL.index(mon) + 1
- return f"{yr}-{mi:02d}", f"{yr}-{mi:02d}-{int(day):02d}"
- except ValueError:
- return None, None
- def parse_xml(path, session, vnum, lis_id):
- try:
- with open(path, "rb") as f:
- root = ET.fromstring(f.read())
- except Exception:
- return None
- def t(tag):
- el = root.find(tag)
- return (el.text or "").strip() if el is not None and el.text else ""
- # Build party totals from members
- R = {"yea":0,"nay":0,"present":0,"nv":0}
- D = {"yea":0,"nay":0,"present":0,"nv":0}
- member_vote = None
- for mem in root.iter("member"):
- party = (mem.findtext("party","") or "").strip()
- vc = (mem.findtext("vote_cast","") or "").strip()
- lid = (mem.findtext("lis_member_id","") or "").strip()
- if lid == lis_id:
- member_vote = vc
- bucket = ("yea" if vc == "Yea"
- else "nay" if vc == "Nay"
- else "present" if vc == "Present"
- else "nv")
- if party == "R":
- R[bucket] += 1
- elif party == "D" or lid in DEM_CAUCUSING_INDEPENDENTS:
- D[bucket] += 1
- # other independents (none currently caucus with R) ignored for party math
- # Compose bill id
- doc_type = root.findtext("document/document_type","") or ""
- doc_num = root.findtext("document/document_number","") or ""
- legis = (doc_type + " " + doc_num).strip() if (doc_type or doc_num) else (root.findtext("vote_title","") or "")
- date_raw = t("vote_date")
- return {
- "session": session, "vnum": vnum,
- "date_raw": date_raw,
- "month_key": parse_date(date_raw)[0],
- "date_iso": parse_date(date_raw)[1],
- "question": t("question") or t("vote_question_text"),
- "result": t("vote_result"),
- "legis_num": legis,
- "desc": t("vote_title") or t("vote_document_text"),
- "R": R, "D": D,
- "member": member_vote,
- }
- def classify(v):
- r_yea, r_nay = v["R"]["yea"], v["R"]["nay"]
- d_yea, d_nay = v["D"]["yea"], v["D"]["nay"]
- r_pos = "Yea" if r_yea > r_nay else ("Nay" if r_nay > r_yea else "Split")
- d_pos = "Yea" if d_yea > d_nay else ("Nay" if d_nay > d_yea else "Split")
- m = v["member"]
- if m not in ("Yea","Nay"):
- return ("N/A: " + (m or "absent"), None, r_pos, d_pos)
- helped_r = (r_pos != "Split" and m == r_pos)
- helped_d = (d_pos != "Split" and m == d_pos)
- if helped_r and helped_d: align = "Helped Both"
- elif helped_r: align = "Helped Republicans"
- elif helped_d: align = "Helped Democrats"
- else: align = "Helped Neither"
- result = v["result"].lower()
- # Senate results often say "agreed to" / "rejected" / "passed" / "failed" / "not agreed to"
- measure_failed = ("rejected" in result or "not agreed" in result
- or "failed" in result or "not passed" in result
- or "not invoked" in result)
- blocked = None
- if d_pos == "Yea" and measure_failed and m == "Nay" and r_pos != "Yea":
- blocked = "Democrat"
- if r_pos == "Yea" and measure_failed and m == "Nay" and d_pos != "Yea":
- blocked = "Republican"
- return (align, blocked, r_pos, d_pos)
- def gather(lis_id):
- votes = []
- for path in sorted(glob.glob(f"{CACHE_DIR}/*.xml")):
- base = os.path.basename(path).replace(".xml","")
- try:
- session, vnum = base.split("_")
- session, vnum = int(session), int(vnum)
- except Exception:
- continue
- v = parse_xml(path, session, vnum, lis_id)
- if not v: continue
- align, blocked, r_pos, d_pos = classify(v)
- v["alignment"] = align
- v["blocked"] = blocked
- v["r_pos"] = r_pos
- v["d_pos"] = d_pos
- votes.append(v)
- return votes
- def aggregate(votes, member_party):
- align_counts = Counter(v["alignment"] for v in votes)
- member_counts = Counter(v["member"] for v in votes)
- blocked_counts = Counter(v["blocked"] for v in votes if v["blocked"])
- month_align = defaultdict(Counter)
- for v in votes:
- if v["month_key"]:
- month_align[v["month_key"]][v["alignment"]] += 1
- months_sorted = sorted(month_align.keys())
- align_labels = ["Helped Republicans","Helped Democrats","Helped Both","Helped Neither"]
- monthly = {l: [month_align[m].get(l,0) for m in months_sorted] for l in align_labels}
- def row(v):
- return {"y": (v["date_iso"] or "")[:4] or "?", "r": v["vnum"],
- "d": v["date_raw"] or "", "ln": v["legis_num"],
- "q": v["question"], "ds": (v["desc"] or "")[:90],
- "rs": v["result"], "m": v["member"],
- "ry": v["R"]["yea"], "rn": v["R"]["nay"],
- "dy": v["D"]["yea"], "dn": v["D"]["nay"],
- "a": v["alignment"], "b": v["blocked"] or ""}
- rows = [row(v) for v in votes]
- total = len(votes)
- voting = sum(1 for v in votes if v["member"] in ("Yea","Nay"))
- yeas = sum(1 for v in votes if v["member"] == "Yea")
- nays = sum(1 for v in votes if v["member"] == "Nay")
- nv = sum(1 for v in votes if v["member"] == "Not Voting")
- present = sum(1 for v in votes if v["member"] == "Present")
- va_gop = vw_gop = va_dem = vw_dem = 0
- for v in votes:
- if v["member"] not in ("Yea","Nay"): continue
- r_pos = v["r_pos"]; d_pos = v["d_pos"]
- if r_pos != "Split":
- if v["member"] == r_pos: vw_gop += 1
- else: va_gop += 1
- if d_pos != "Split":
- if v["member"] == d_pos: vw_dem += 1
- else: va_dem += 1
- own = member_party
- lone_wolf = 0
- for v in votes:
- if v["member"] not in ("Yea","Nay"): continue
- own_pos = v["r_pos"] if own == "R" else v["d_pos"]
- if own_pos == "Split" or v["member"] == own_pos: continue
- own_totals = v["R"] if own == "R" else v["D"]
- defectors = own_totals["nay"] if own_pos == "Yea" else own_totals["yea"]
- if defectors <= 3: # Senate threshold tighter (smaller chamber)
- lone_wolf += 1
- return {
- "total":total,"voting":voting,"yeas":yeas,"nays":nays,"nv":nv,"present":present,
- "alignment":dict(align_counts),"member":dict(member_counts),
- "blocked":dict(blocked_counts),"months":months_sorted,"monthly":monthly,
- "rows":rows,
- "blocked_dem_count":sum(1 for v in votes if v["blocked"] == "Democrat"),
- "blocked_rep_count":sum(1 for v in votes if v["blocked"] == "Republican"),
- "lone_wolf":lone_wolf,
- "voted_against_gop":va_gop,"voted_against_dem":va_dem,
- "voted_with_gop":vw_gop,"voted_with_dem":vw_dem,
- }
- HTML_TEMPLATE = r"""<!DOCTYPE html>
- <html lang="en">
- <head>
- <meta charset="utf-8">
- <title>__NAME__ - 119th Congress Voting Dashboard</title>
- <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
- <script src="https://cdn.jsdelivr.net/npm/sortablejs@1.15.2/Sortable.min.js"></script>
- <style>
- body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif;margin:0;padding:24px;background:#f5f5f7;color:#1d1d1f;}
- h1{margin:0 0 4px;font-size:28px;} .sub{color:#6e6e73;margin-bottom:24px;}
- .grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:16px;margin-bottom:24px;}
- .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;}
- .card:active{cursor:grabbing;} .card.sortable-ghost{opacity:.4;}
- .card.sortable-chosen{box-shadow:0 4px 16px rgba(0,0,0,.15);transform:scale(1.02);}
- .card.sortable-drag{box-shadow:0 8px 24px rgba(0,0,0,.2);}
- .kpi{font-size:32px;font-weight:700;margin:6px 0 2px;} .pct{font-size:14px;font-weight:500;color:#6e6e73;margin-left:4px;}
- .kpi-label{color:#6e6e73;font-size:13px;text-transform:uppercase;letter-spacing:.5px;}
- .chart-row{display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-bottom:24px;}
- @media (max-width:900px){.chart-row{grid-template-columns:1fr;}}
- .chart-card{background:#fff;border-radius:12px;padding:20px;box-shadow:0 1px 3px rgba(0,0,0,.06);min-width:0;}
- .chart-box{position:relative;height:300px;width:100%;}
- h2{font-size:18px;margin:0 0 12px;}
- table{width:100%;border-collapse:collapse;font-size:13px;}
- th,td{padding:6px 8px;text-align:left;border-bottom:1px solid #e8e8ed;}
- th{background:#fafafa;position:sticky;top:0;cursor:pointer;user-select:none;}
- th:hover{background:#f0f0f5;}
- .table-wrap{max-height:600px;overflow:auto;border:1px solid #e8e8ed;border-radius:8px;}
- .filter-bar{display:flex;gap:8px;margin-bottom:12px;flex-wrap:wrap;}
- .filter-bar input,.filter-bar select{padding:8px 12px;border:1px solid #d2d2d7;border-radius:8px;font-size:14px;}
- .filter-bar input{flex:1;min-width:200px;}
- .badge{display:inline-block;padding:2px 8px;border-radius:4px;font-size:11px;font-weight:600;}
- .b-rep{background:#fde2e2;color:#a30000;} .b-dem{background:#dde7fa;color:#1644a0;}
- .b-both{background:#e0f0e0;color:#1e6b1e;} .b-neither{background:#f0e0f0;color:#6b1e6b;}
- .b-na{background:#eee;color:#666;}
- .v-yea{color:#1e6b1e;font-weight:600;} .v-nay{color:#a30000;font-weight:600;}
- .v-nv,.v-present{color:#888;}
- .note{font-size:12px;color:#6e6e73;line-height:1.5;margin-top:12px;}
- .drag-hint{font-size:11px;color:#6e6e73;margin-bottom:8px;}
- .drag-hint button{margin-left:8px;padding:2px 8px;font-size:11px;border:1px solid #d2d2d7;border-radius:4px;background:#fff;cursor:pointer;}
- .party-pill{display:inline-block;padding:2px 8px;border-radius:10px;font-size:12px;font-weight:600;margin-left:8px;color:#fff;}
- .chamber-pill{display:inline-block;padding:2px 8px;border-radius:10px;font-size:12px;font-weight:600;margin-left:6px;background:#5a3d8a;color:#fff;}
- .footer{text-align:center;color:#6e6e73;font-size:12px;margin-top:32px;}
- .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;}
- .member-note strong{color:#3d2f00;}
- </style>
- </head>
- <body>
- <h1>__NAME__<span class="party-pill" style="background:__PARTY_COLOR__">__PARTY__</span><span class="chamber-pill">SENATE</span></h1>
- <div class="sub">LIS __LIS__ · Senate roll-call votes, 119th Congress (Jan 2025 – present) · Source: senate.gov</div>
- __MEMBER_NOTE__
- <div class="drag-hint">↕ Drag any card to rearrange. <button id="resetOrder">Reset order</button></div>
- <div class="grid" id="cardGrid">
- <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>
- <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>
- <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>
- <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>
- <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>
- <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>
- </div>
- <div class="chart-row">
- <div class="chart-card"><h2>Alignment Classification</h2><div class="chart-box"><canvas id="alignChart"></canvas></div></div>
- <div class="chart-card"><h2>Vote Distribution</h2><div class="chart-box"><canvas id="voteChart"></canvas></div></div>
- </div>
- <div class="chart-row">
- <div class="chart-card"><h2>Voted With vs. Against — by Party Majority</h2><div class="chart-box"><canvas id="partyChart"></canvas></div></div>
- <div class="chart-card"><h2>Blocking Wins</h2><div class="chart-box"><canvas id="blockChart"></canvas></div></div>
- </div>
- <div class="chart-row">
- <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>
- </div>
- <div class="chart-row">
- <div class="chart-card" style="grid-column:1 / -1">
- <h2>All Votes (filterable)</h2>
- <div class="filter-bar">
- <input id="search" placeholder="Search bill/description…">
- <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>
- <select id="blockFilter"><option value="">All votes</option><option value="Democrat">Blocked Dem-backed</option><option value="Republican">Blocked GOP-backed</option></select>
- <select id="memberFilter"><option value="">Any vote</option><option>Yea</option><option>Nay</option><option>Not Voting</option><option>Present</option></select>
- </div>
- <div class="table-wrap"><table id="voteTable">
- <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>
- <tbody id="voteBody"></tbody></table></div>
- <div class="note">Click headers to sort. Showing <span id="row-count">0</span> rows.</div>
- </div>
- </div>
- <div class="footer">Source: senate.gov LIS XML rollcalls · Built 2026-05-24 · See DOCUMENTATION.md §12 for Senate methodology</div>
- <script>
- const DATA = __DATA__;
- const CARD_ORDER_KEY = 'dashCardOrder.__LIS__';
- const grid = document.getElementById('cardGrid');
- 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]);});}
- try{applyOrder(JSON.parse(localStorage.getItem(CARD_ORDER_KEY)));}catch(e){}
- Sortable.create(grid,{animation:180,ghostClass:'sortable-ghost',chosenClass:'sortable-chosen',dragClass:'sortable-drag',
- onEnd(){localStorage.setItem(CARD_ORDER_KEY,JSON.stringify(Array.from(grid.children).map(c=>c.dataset.id)));}});
- document.getElementById('resetOrder').addEventListener('click',()=>{localStorage.removeItem(CARD_ORDER_KEY);location.reload();});
- const ALIGN_COLORS={"Helped Republicans":"#d93b3b","Helped Democrats":"#3b6ed9","Helped Both":"#3ba85a","Helped Neither":"#a13bd9","N/A: Not Voting":"#999","N/A: Present":"#bbb"};
- const alignOrder=["Helped Republicans","Helped Democrats","Helped Both","Helped Neither","N/A: Not Voting","N/A: Present"];
- const alignLabels=alignOrder.filter(k=>DATA.alignment[k]);
- const alignData=alignLabels.map(k=>DATA.alignment[k]);
- const alignColors=alignLabels.map(k=>ALIGN_COLORS[k]||"#888");
- const alignTotal=alignData.reduce((a,b)=>a+b,0);
- const alignLabelsPct=alignLabels.map((l,i)=>`${l} — ${alignData[i]} (${(alignData[i]/alignTotal*100).toFixed(1)}%)`);
- new Chart(document.getElementById('alignChart'),{type:'doughnut',data:{labels:alignLabelsPct,datasets:[{data:alignData,backgroundColor:alignColors}]},
- options:{plugins:{legend:{position:'right',labels:{boxWidth:14,font:{size:12}}},
- tooltip:{callbacks:{label:ctx=>`${alignLabels[ctx.dataIndex]}: ${ctx.parsed} (${(ctx.parsed/alignTotal*100).toFixed(1)}%)`}}},
- responsive:true,maintainAspectRatio:false}});
- const voteOrder=["Yea","Nay","Not Voting","Present"];
- const voteLabels=voteOrder.filter(k=>DATA.member[k]);
- const voteData=voteLabels.map(k=>DATA.member[k]);
- const voteTotal=voteData.reduce((a,b)=>a+b,0);
- const barLabelPlugin={id:'barLabel',afterDatasetsDraw(chart){const {ctx}=chart;
- chart.data.datasets.forEach((ds,di)=>{chart.getDatasetMeta(di).data.forEach((bar,i)=>{
- const v=ds.data[i],pct=(v/voteTotal*100).toFixed(1);
- ctx.fillStyle='#333';ctx.font='600 11px -apple-system, sans-serif';ctx.textAlign='center';
- ctx.fillText(`${v} (${pct}%)`,bar.x,bar.y-6);});});}};
- new Chart(document.getElementById('voteChart'),{type:'bar',
- data:{labels:voteLabels,datasets:[{label:'Count',data:voteData,
- backgroundColor:voteLabels.map(l=>l==='Yea'?'#1e8e3e':l==='Nay'?'#c5221f':'#888')}]},
- options:{plugins:{legend:{display:false},tooltip:{callbacks:{label:ctx=>`${ctx.parsed.y} (${(ctx.parsed.y/voteTotal*100).toFixed(1)}%)`}}},
- responsive:true,maintainAspectRatio:false,layout:{padding:{top:20}},scales:{y:{beginAtZero:true}}},
- plugins:[barLabelPlugin]});
- const partyTotR=DATA.voted_with_gop+DATA.voted_against_gop;
- const partyTotD=DATA.voted_with_dem+DATA.voted_against_dem;
- new Chart(document.getElementById('partyChart'),{type:'bar',
- data:{labels:['GOP majority','Dem majority'],datasets:[
- {label:'Voted WITH',data:[DATA.voted_with_gop,DATA.voted_with_dem],backgroundColor:'#1e8e3e'},
- {label:'Voted AGAINST',data:[DATA.voted_against_gop,DATA.voted_against_dem],backgroundColor:'#c5221f'}]},
- options:{responsive:true,maintainAspectRatio:false,
- 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'}%)`;}}}},
- scales:{x:{stacked:true},y:{stacked:true,beginAtZero:true}}},
- plugins:[{id:'stackPct',afterDatasetsDraw(chart){const {ctx}=chart;
- ctx.fillStyle='#fff';ctx.font='600 12px -apple-system, sans-serif';ctx.textAlign='center';
- chart.data.datasets.forEach((ds,di)=>{chart.getDatasetMeta(di).data.forEach((bar,i)=>{
- const tot=i===0?partyTotR:partyTotD,v=ds.data[i];
- if(v>0&&tot)ctx.fillText(`${v} (${(v/tot*100).toFixed(1)}%)`,bar.x,(bar.y+bar.base)/2+4);});});}}]});
- const blkTot=DATA.blocked_dem_count+DATA.blocked_rep_count;
- new Chart(document.getElementById('blockChart'),{type:'bar',
- data:{labels:['Blocked Dem-backed','Blocked GOP-backed'],
- datasets:[{label:'Count',data:[DATA.blocked_dem_count,DATA.blocked_rep_count],backgroundColor:['#3b6ed9','#d93b3b']}]},
- options:{indexAxis:'y',responsive:true,maintainAspectRatio:false,
- plugins:{legend:{display:false},tooltip:{callbacks:{label:ctx=>`${ctx.parsed.x} (${blkTot?(ctx.parsed.x/blkTot*100).toFixed(1):'0.0'}%)`}}},
- scales:{x:{beginAtZero:true}}},
- plugins:[{id:'hbarLabel',afterDatasetsDraw(chart){const {ctx}=chart;
- ctx.fillStyle='#333';ctx.font='600 12px -apple-system, sans-serif';ctx.textAlign='left';ctx.textBaseline='middle';
- chart.getDatasetMeta(0).data.forEach((bar,i)=>{const v=chart.data.datasets[0].data[i];
- ctx.fillText(` ${v} (${blkTot?(v/blkTot*100).toFixed(1):'0.0'}%)`,bar.x,bar.y);});}}]});
- const trendLabels=DATA.months;
- const trendSets=["Helped Republicans","Helped Democrats","Helped Both","Helped Neither"].map(k=>({
- label:k,data:DATA.monthly[k],borderColor:ALIGN_COLORS[k],backgroundColor:ALIGN_COLORS[k]+'33',tension:.3,fill:false}));
- new Chart(document.getElementById('trendChart'),{type:'line',data:{labels:trendLabels,datasets:trendSets},
- options:{responsive:true,maintainAspectRatio:false,
- scales:{y:{beginAtZero:true,title:{display:true,text:'Votes per month'}}},interaction:{mode:'index',intersect:false}}});
- let sortKey='r',sortDesc=true;
- function renderTable(){
- const q=document.getElementById('search').value.toLowerCase();
- const af=document.getElementById('alignFilter').value;
- const bf=document.getElementById('blockFilter').value;
- const mf=document.getElementById('memberFilter').value;
- let rows=DATA.rows.filter(r=>{
- if(q&&!((r.ln+' '+r.ds+' '+r.q).toLowerCase().includes(q)))return false;
- if(af&&r.a!==af)return false; if(bf&&r.b!==bf)return false; if(mf&&r.m!==mf)return false; return true;});
- rows.sort((a,b)=>{const av=a[sortKey],bv=b[sortKey];let cmp;
- if(typeof av==='number')cmp=av-bv;else cmp=String(av).localeCompare(String(bv));
- if(cmp===0&&sortKey!=='r')cmp=(String(a.y).localeCompare(String(b.y)))||(a.r-b.r);return sortDesc?-cmp:cmp;});
- const tb=document.getElementById('voteBody');
- const aClass=a=>'badge '+({"Helped Republicans":"b-rep","Helped Democrats":"b-dem","Helped Both":"b-both","Helped Neither":"b-neither"}[a]||"b-na");
- const mClass=m=>'v-'+(m==='Yea'?'yea':m==='Nay'?'nay':m==='Not Voting'?'nv':'present');
- 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('');
- document.getElementById('row-count').textContent=rows.length;
- }
- 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();});});
- ['search','alignFilter','blockFilter','memberFilter'].forEach(id=>document.getElementById(id).addEventListener('input',renderTable));
- renderTable();
- </script>
- </body></html>
- """
- def render(lis_id, name, party, out_name, note=None):
- votes = gather(lis_id)
- if not votes:
- print(f" WARN: no votes parsed for {name}", file=sys.stderr)
- data = aggregate(votes, party)
- def pct(n, d): return f"{(n/d*100):.1f}" if d else "0.0"
- partisan_r = data["voted_with_gop"] + data["voted_against_gop"]
- partisan_d = data["voted_with_dem"] + data["voted_against_dem"]
- color = "#d93b3b" if party == "R" else ("#3b6ed9" if party == "D" else "#888")
- note_html = f'<div class="member-note"><strong>Note:</strong> {note}</div>' if note else ''
- html = (HTML_TEMPLATE
- .replace("__MEMBER_NOTE__", note_html)
- .replace("__NAME__", name)
- .replace("__LIS__", lis_id)
- .replace("__PARTY__", party)
- .replace("__PARTY_COLOR__", color)
- .replace("__TOTAL__", str(data["total"]))
- .replace("__VOTING__", str(data["voting"]))
- .replace("__VOTING_PCT__", pct(data["voting"], data["total"]))
- .replace("__YEAS__", str(data["yeas"]))
- .replace("__NAYS__", str(data["nays"]))
- .replace("__NV__", str(data["nv"]))
- .replace("__PRESENT__", str(data["present"]))
- .replace("__VA_GOP__", str(data["voted_against_gop"]))
- .replace("__VA_GOP_PCT__", pct(data["voted_against_gop"], partisan_r))
- .replace("__VA_DEM__", str(data["voted_against_dem"]))
- .replace("__VA_DEM_PCT__", pct(data["voted_against_dem"], partisan_d))
- .replace("__PARTISAN_R__", str(partisan_r))
- .replace("__PARTISAN_D__", str(partisan_d))
- .replace("__BLK_DEM__", str(data["blocked_dem_count"]))
- .replace("__BLK_DEM_PCT__", pct(data["blocked_dem_count"], data["total"]))
- .replace("__BLK_REP__", str(data["blocked_rep_count"]))
- .replace("__BLK_REP_PCT__", pct(data["blocked_rep_count"], data["total"]))
- .replace("__LONE__", str(data["lone_wolf"]))
- .replace("__LONE_PCT__", pct(data["lone_wolf"], data["voting"]))
- .replace("__DATA__", json.dumps(data))
- )
- os.makedirs(OUT_DIR, exist_ok=True)
- out = os.path.join(OUT_DIR, out_name)
- with open(out, "w") as f: f.write(html)
- print(f" {name}: {data['voting']}/{data['total']} voted, "
- f"R-aligned:{data['alignment'].get('Helped Republicans',0)} "
- f"D-aligned:{data['alignment'].get('Helped Democrats',0)} "
- f"Both:{data['alignment'].get('Helped Both',0)} "
- f"Neither:{data['alignment'].get('Helped Neither',0)} → {out}")
- if __name__ == "__main__":
- if len(sys.argv) >= 5:
- render(sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4])
- else:
- ROSTER = [
- ("S293", "Lindsey Graham", "R", "LindseyGraham119.html"),
- ]
- for entry in ROSTER:
- lis, nm, pt, fn = entry[:4]
- note = entry[4] if len(entry) > 4 else None
- render(lis, nm, pt, fn, note)
|