// USA Choropleth — real state geography via us-atlas TopoJSON + d3-geo. // Continuous diverging palette + distinct "no data" treatment. { const { STATES, scoreFor, lensMetaFor } = window.PL_DATA; // Five-stop continuous diverging palette: rust → terracotta → slate → jade → teal. // Continuous (not bucketed) so subtle differences between states are visible. const PALETTE_STOPS = [ { at: 0, c: "#5a2a1f" }, // rust — most restrictive { at: 25, c: "#b85a4a" }, // terracotta { at: 50, c: "#475569" }, // slate — neutral { at: 75, c: "#6b9b94" }, // jade { at: 100, c: "#2a6b66" }, // teal — most protective ]; const _palette = window.d3.scaleLinear() .domain(PALETTE_STOPS.map(s => s.at)) .range(PALETTE_STOPS.map(s => s.c)) .interpolate(window.d3.interpolateRgb) .clamp(true); function colorForScore(s) { return { color: _palette(s), text: "#fff" }; } // A state has "real data" for a lens only if at least one bill was scored // against that lens for that state. Without this distinction, unscored states // would look identical to scored-as-neutral states. function stateHasData(code, lensId) { if (!lensMetaFor) return true; const meta = lensMetaFor(code, lensId); return !!(meta && meta.bill_count > 0); } window.PL_COLOR = colorForScore; window.PL_HAS_DATA = stateHasData; // Map state-name (as used in us-atlas) → 2-letter code we score with. const NAME_TO_CODE = { "Alabama":"AL","Alaska":"AK","Arizona":"AZ","Arkansas":"AR","California":"CA", "Colorado":"CO","Connecticut":"CT","Delaware":"DE","Florida":"FL","Georgia":"GA", "Hawaii":"HI","Idaho":"ID","Illinois":"IL","Indiana":"IN","Iowa":"IA", "Kansas":"KS","Kentucky":"KY","Louisiana":"LA","Maine":"ME","Maryland":"MD", "Massachusetts":"MA","Michigan":"MI","Minnesota":"MN","Mississippi":"MS","Missouri":"MO", "Montana":"MT","Nebraska":"NE","Nevada":"NV","New Hampshire":"NH","New Jersey":"NJ", "New Mexico":"NM","New York":"NY","North Carolina":"NC","North Dakota":"ND","Ohio":"OH", "Oklahoma":"OK","Oregon":"OR","Pennsylvania":"PA","Rhode Island":"RI","South Carolina":"SC", "South Dakota":"SD","Tennessee":"TN","Texas":"TX","Utah":"UT","Vermont":"VT", "Virginia":"VA","Washington":"WA","West Virginia":"WV","Wisconsin":"WI","Wyoming":"WY", "District of Columbia":"DC", }; // Approx label centroids for state-abbr overlays (lon,lat) — used in projected space at draw time. // We compute centroids from the geometry instead of hard-coding. // Single shared promise for the topojson fetch. let _atlasPromise = null; function loadAtlas() { if (!_atlasPromise) { _atlasPromise = fetch("https://cdn.jsdelivr.net/npm/us-atlas@3/states-10m.json") .then(r => r.json()); } return _atlasPromise; } function Choropleth({ lensId, mode = "passed", onSelect, activeState, showLabels = "always" }) { const [atlas, setAtlas] = React.useState(null); const [hover, setHover] = React.useState(null); // {code, name, score, x, y} const [transform, setTransform] = React.useState({ k: 1, x: 0, y: 0 }); const wrapRef = React.useRef(null); const svgRef = React.useRef(null); const zoomBehaviorRef = React.useRef(null); React.useEffect(() => { loadAtlas().then(setAtlas).catch(err => { console.warn("atlas load failed", err); }); }, []); // Wire d3 zoom + pan once the atlas + svg are ready React.useEffect(() => { if (!atlas || !svgRef.current) return; const svg = window.d3.select(svgRef.current); const z = window.d3.zoom() .scaleExtent([1, 6]) .on("zoom", (e) => setTransform({ k: e.transform.k, x: e.transform.x, y: e.transform.y })); zoomBehaviorRef.current = z; svg.call(z); return () => svg.on(".zoom", null); }, [atlas]); const resetZoom = () => { if (svgRef.current && zoomBehaviorRef.current) { window.d3.select(svgRef.current).transition().duration(280) .call(zoomBehaviorRef.current.transform, window.d3.zoomIdentity); } }; if (!atlas) { return (
Loading geography…
); } const W = 975, H = 610; const fc = window.topojson.feature(atlas, atlas.objects.states); const states = fc.features; const projection = window.d3.geoAlbersUsa().fitSize([W, H], fc); const path = window.d3.geoPath(projection); // Compute label centroids for non-tiny states. const centroidByCode = {}; states.forEach(f => { const code = NAME_TO_CODE[f.properties.name]; if (!code) return; const c = path.centroid(f); const a = path.area(f); centroidByCode[code] = { x: c[0], y: c[1], area: a }; }); return (
{/* subtle paper grain noise */} {/* Hatched fill for "no data" states — visually distinct from any scored color. */} {/* zoom transform group — wraps everything so zoom/pan affects all layers together */} {/* base fills */} {states.map(f => { const code = NAME_TO_CODE[f.properties.name]; if (!code) return null; const hasData = window.PL_HAS_DATA ? window.PL_HAS_DATA(code, lensId) : true; const score = scoreFor(code, lensId, mode); const pal = colorForScore(score); const isActive = activeState === code; const isHover = hover && hover.code === code; const fillColor = hasData ? pal.color : "url(#pl-nodata)"; return ( { const rect = wrapRef.current.getBoundingClientRect(); setHover({ code, name: f.properties.name, score, hasData, x: e.clientX - rect.left, y: e.clientY - rect.top, }); }} onMouseMove={(e) => { const rect = wrapRef.current.getBoundingClientRect(); setHover(h => h ? { ...h, x: e.clientX - rect.left, y: e.clientY - rect.top } : h); }} onMouseLeave={() => setHover(null)} onClick={() => hasData && onSelect && onSelect(code)} > {f.properties.name} · {hasData ? Math.round(score) : "no data yet"} ); })} {/* paper grain layer over fills */} {states.map(f => { const code = NAME_TO_CODE[f.properties.name]; if (!code) return null; return ( ); })} {/* state-abbr labels for non-tiny states */} {showLabels !== "never" && ( {Object.entries(centroidByCode).map(([code, c]) => { if (c.area < 320) return null; // skip RI/DE/CT — hover only const showThis = showLabels === "always" || (showLabels === "hover" && hover && hover.code === code) || (activeState === code); if (!showThis) return null; return ( {code} ); })} )} {/* end zoom transform group */} {/* Zoom controls */}
{/* Tooltip */} {hover && (
{hover.name}
{hover.code}
{hover.hasData ? ( <>
{mode === "passed" ? "became law" : mode === "pressure" ? "introduced" : "all activity"} {hover.score.toFixed(1)}
CLICK → STATE DETAIL
) : (
No bills scored yet for this lens.
)}
)}
); } function MapLegend({ mode = "passed" }) { // Continuous gradient using the same 5 palette stops const gradient = `linear-gradient(to right, ${PALETTE_STOPS.map(s => `${s.c} ${s.at}%`).join(", ")})`; const modeLabel = mode === "passed" ? "Became law" : mode === "pressure" ? "Pressure to change" : "All activity"; return (
{modeLabel}
RESTRICTIVE NEUTRAL PROTECTIVE
{/* No-data swatch */}
NOT YET SCORED
); } window.PL_TileMap = Choropleth; // legacy name kept for callers window.PL_MapLegend = MapLegend; }