diff --git a/scripts/build_viz.py b/scripts/build_viz.py index 629ded3531..fec57cc9c6 100644 --- a/scripts/build_viz.py +++ b/scripts/build_viz.py @@ -104,22 +104,46 @@ def main(): # ---- district representation per county (fips -> {cd, ss, sh}) ---- rep_by_node = defaultdict(list) # district node id -> current rep name(s) + rep_party_by_node = defaultdict(list) # district node id -> current rep party code(s) for rec in officeholders: if not rec.get("is_current"): continue nid = build.person_district_node(rec) if nid: rep_by_node[nid].append(rec.get("full_name") or "?") + rep_party_by_node[nid].append(rec.get("party")) KEYMAP = {"CD": "cd", "SS": "ss", "SH": "sh"} districts_by_fips = defaultdict(lambda: {"cd": [], "ss": [], "sh": []}) + party_ct = defaultdict(lambda: {"D": 0, "R": 0}) # fips -> rep head-count by party (>=5% of county) for e in sorted(build.load("county_district_edges.jsonl"), key=lambda x: (x["county_geoid"], build.DTYPE_RANK[x["type"]], -x["area_weight"])): dt = e["type"] ident = build.cd_ident(e["district_label"]) if dt == "CD" else build.leg_ident(e["district_label"]) - reps = sorted(set(rep_by_node.get(build.edge_node_id(e), []))) - districts_by_fips[build.canonical_fips(e["county_geoid"])][KEYMAP[dt]].append( + node = build.edge_node_id(e) + reps = sorted(set(rep_by_node.get(node, []))) + cf = build.canonical_fips(e["county_geoid"]) + w = e["area_weight"] + dparties = set() + for p in rep_party_by_node.get(node, []): + if p in ("D", "R"): + dparties.add(p) + if w >= 0.05: # ignore sliver overlaps in the county tally + party_ct[cf][p] += 1 + districts_by_fips[cf][KEYMAP[dt]].append( {"d": build.district_title(dt, e["district_state"], ident), - "w": round(e["area_weight"], 3), "rep": ", ".join(reps) or None}) + "w": round(e["area_weight"], 3), "rep": ", ".join(reps) or None, + "p": (next(iter(dparties)) if len(dparties) == 1 else None)}) + + # ---- partisan representation lean per county (from state + federal reps) ---- + # lean = (R - D) / (R + D) by rep head-count; -1 all-D .. +1 all-R. Head-count (not + # area) so it matches the "N D · M R" readout and isn't skewed by large rural + # districts; districts covering <5% of the county are treated as slivers, excluded. + for cf, entry in county_data.items(): + ct = party_ct.get(cf, {"D": 0, "R": 0}) + tot = ct["D"] + ct["R"] + entry["lean"] = round((ct["R"] - ct["D"]) / tot, 3) if tot > 0 else None + entry["repD"] = ct["D"] + entry["repR"] = ct["R"] # ---- county_detail.json (any county with officials OR districts) ---- fips_to_key = {cf: key for key, cf in slug_to_fips.items()} @@ -165,6 +189,8 @@ def main(): f"({len(districts_by_fips)} with district representation)") print(f"officials mapped: {total_oh - unresolved}/{total_oh} " f"(county-unresolvable, tree-only: {unresolved})") + leaned = sum(1 for v in county_data.values() if v.get("lean") is not None) + print(f"partisan lean: {leaned}/{len(county_data)} counties have D/R representation") print(f"board.html injected: totals={totals}") diff --git a/viz/board.html b/viz/board.html index 816369b63c..040457ee7c 100644 --- a/viz/board.html +++ b/viz/board.html @@ -101,6 +101,10 @@ .off{display:flex; justify-content:space-between; gap:14px; font-size:12.5px; padding:4px 0; border-bottom:1px dotted var(--line-soft)} .off .on{color:var(--ink)} .off .or{color:var(--muted); text-align:right; flex:none; max-width:55%} + .pty{display:inline-block; font-size:9px; font-weight:700; line-height:1; padding:2px 4px; border-radius:3px; + vertical-align:middle; letter-spacing:.02em} + .pty.D{color:#bcd2ff; background:rgba(74,124,255,.22)} .pty.R{color:#ffc4c4; background:rgba(240,72,72,.22)} + .pty.E{color:var(--muted); background:rgba(122,120,140,.22)} .empty{margin-top:16px; font-size:12.5px; color:var(--muted); line-height:1.6} .ghlink{display:inline-block; margin-top:20px; font-family:var(--mono); font-size:11px; letter-spacing:.06em; color:var(--teal); text-decoration:none; border:1px solid #1c5f59; padding:8px 12px} @@ -146,14 +150,15 @@