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:
+29
-3
@@ -104,22 +104,46 @@ def main():
|
|||||||
|
|
||||||
# ---- district representation per county (fips -> {cd, ss, sh}) ----
|
# ---- district representation per county (fips -> {cd, ss, sh}) ----
|
||||||
rep_by_node = defaultdict(list) # district node id -> current rep name(s)
|
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:
|
for rec in officeholders:
|
||||||
if not rec.get("is_current"):
|
if not rec.get("is_current"):
|
||||||
continue
|
continue
|
||||||
nid = build.person_district_node(rec)
|
nid = build.person_district_node(rec)
|
||||||
if nid:
|
if nid:
|
||||||
rep_by_node[nid].append(rec.get("full_name") or "?")
|
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"}
|
KEYMAP = {"CD": "cd", "SS": "ss", "SH": "sh"}
|
||||||
districts_by_fips = defaultdict(lambda: {"cd": [], "ss": [], "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"),
|
for e in sorted(build.load("county_district_edges.jsonl"),
|
||||||
key=lambda x: (x["county_geoid"], build.DTYPE_RANK[x["type"]], -x["area_weight"])):
|
key=lambda x: (x["county_geoid"], build.DTYPE_RANK[x["type"]], -x["area_weight"])):
|
||||||
dt = e["type"]
|
dt = e["type"]
|
||||||
ident = build.cd_ident(e["district_label"]) if dt == "CD" else build.leg_ident(e["district_label"])
|
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), [])))
|
node = build.edge_node_id(e)
|
||||||
districts_by_fips[build.canonical_fips(e["county_geoid"])][KEYMAP[dt]].append(
|
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),
|
{"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) ----
|
# ---- county_detail.json (any county with officials OR districts) ----
|
||||||
fips_to_key = {cf: key for key, cf in slug_to_fips.items()}
|
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)")
|
f"({len(districts_by_fips)} with district representation)")
|
||||||
print(f"officials mapped: {total_oh - unresolved}/{total_oh} "
|
print(f"officials mapped: {total_oh - unresolved}/{total_oh} "
|
||||||
f"(county-unresolvable, tree-only: {unresolved})")
|
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}")
|
print(f"board.html injected: totals={totals}")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+26
-6
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user