Board: add a partisan-lean lens

Fourth lens on the Board, coloring each county by the D/R balance of the
state + federal representatives who cover it — the offices that are
actually partisan (99% D/R coverage vs ~0% for nonpartisan local seats).

Pipeline (build_viz.py):
- Carry each district rep's party alongside their name through the
  county<->district edge mapping.
- Per county, tally reps by party over districts covering >=5% of its
  area (slivers excluded), and store lean = (R-D)/(R+D) in [-1,+1] plus
  repD/repR counts. Head-count, not area-weighted, so the number matches
  the readout and isn't skewed toward large rural districts. 3130/3131
  counties resolve.

Board (board.html):
- "Partisan lean" lens with a diverging blue<->grey<->red ramp (centered
  at 0, skipped in the percentile-rank machinery the other lenses use).
- Diverging legend (More Democratic / More Republican).
- Readout gains a Representation row (e.g. "8 D · 3 R  D+45").
- Drill-down district reps get a D/R party badge.

Verified in preview: no console errors; Manhattan -1.0 (21D/0R), LA -0.46,
Palm Beach even (3D/3R), Loving TX +1.0; readout, color, and badges agree.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Fabio
2026-07-06 08:59:51 -04:00
parent 77e7b4282e
commit 802c49a89d
4 changed files with 57 additions and 11 deletions
+29 -3
View File
@@ -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}")
+26 -6
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long