// Public-site Layer 2 Indices page — per-index methodology + state ranking. // Loads state_indices.json on first visit, caches in window.PL_INDICES. { const { STATES, STATE_POPULATION } = window.PL_DATA; // Indices where higher = bad (rust palette). Low = teal palette. const RESTRICTIVE = new Set(["fascism", "greed", "religious_nationalism", "punishment"]); // Canon files we expect to load lazily (must match rubrics/index_canons/{id}.md). const KNOWN_INDICES = [ "fascism", "greed", "religious_nationalism", "wellbeing", "punishment", "equity", "climate_resilience", "aging" ]; // Hex helpers — diverging palette matching the atlas styling. function indexColor(score, restrictive) { if (score === null || score === undefined) return "#9ca29a"; const polarity = restrictive ? score : (100 - score); const distance = (polarity - 50) / 50; // -1..1 const t = Math.min(Math.abs(distance), 1); if (distance >= 0) { if (t < 0.5) { return `rgb(${Math.round(125 + 65 * t * 2)}, ${Math.round(105 - 15 * t * 2)}, ${Math.round(95 - 25 * t * 2)})`; } return `rgb(${Math.round(190 - 80 * (t - 0.5) * 2)}, ${Math.round(90 - 30 * (t - 0.5) * 2)}, ${Math.round(70 - 20 * (t - 0.5) * 2)})`; } if (t < 0.5) { return `rgb(${Math.round(125 - 30 * t * 2)}, ${Math.round(150 + 5 * t * 2)}, ${Math.round(140 + 8 * t * 2)})`; } return `rgb(${Math.round(95 - 30 * (t - 0.5) * 2)}, ${Math.round(155 - 35 * (t - 0.5) * 2)}, ${Math.round(148 - 30 * (t - 0.5) * 2)})`; } const MODES = [ { id: "all", label: "All activity" }, { id: "passed", label: "Passed (became law)" }, { id: "pressure", label: "Pressure (introduced)" }, ]; // Tiny markdown renderer — handles the subset we use in canon files // (headings, paragraphs, bullets, simple bold/italic, code spans). No fenced // code blocks — we don't need them here. function renderMarkdown(md) { if (!md) return null; const lines = md.split("\n"); const out = []; let i = 0; let key = 0; while (i < lines.length) { const line = lines[i]; if (/^#\s/.test(line)) { out.push(

{line.replace(/^#\s/, "")}

); } else if (/^##\s/.test(line)) { out.push(

{line.replace(/^##\s/, "")}

); } else if (/^###\s/.test(line)) { out.push(

{line.replace(/^###\s/, "")}

); } else if (/^\|/.test(line)) { // Table — gobble all subsequent table lines const tableLines = []; while (i < lines.length && /^\|/.test(lines[i])) { tableLines.push(lines[i]); i++; } out.push(renderTable(tableLines, key++)); continue; } else if (/^-\s/.test(line)) { // List — gobble bullets const items = []; while (i < lines.length && /^-\s/.test(lines[i])) { items.push(lines[i].replace(/^-\s/, "")); i++; } out.push( ); continue; } else if (line.trim() === "") { // blank line — paragraph break, no DOM } else { out.push(

{renderInline(line)}

); } i++; } return out; } function renderInline(text) { // Handle **bold**, *italic*, `code` minimally const parts = []; let key = 0; let cursor = 0; const re = /(\*\*[^*]+\*\*|\*[^*]+\*|`[^`]+`)/g; let m; while ((m = re.exec(text)) !== null) { if (m.index > cursor) parts.push(text.slice(cursor, m.index)); const tok = m[0]; if (tok.startsWith("**")) parts.push({tok.slice(2, -2)}); else if (tok.startsWith("`")) parts.push({tok.slice(1, -1)}); else parts.push({tok.slice(1, -1)}); cursor = m.index + tok.length; } if (cursor < text.length) parts.push(text.slice(cursor)); return parts; } function renderTable(lines, k) { // Skip separator row (--- | --- | ...) const rows = lines.filter(l => !/^\|\s*[-:]+/.test(l)).map(l => l.split("|").slice(1, -1).map(cell => cell.trim()) ); if (!rows.length) return null; const [header, ...body] = rows; return ( {header.map((h, i) => )} {body.map((row, i) => ( {row.map((cell, j) => )} ))}
{renderInline(h)}
{renderInline(cell)}
); } function IndicesPage({ go, indexId, setIndexId }) { const [indicesData, setIndicesData] = React.useState(window.PL_INDICES || null); const [externalData, setExternalData] = React.useState(window.PL_INDICES_EXTERNAL || null); const [canonText, setCanonText] = React.useState(""); const [mode, setMode] = React.useState("all"); const [latestOnly, setLatestOnly] = React.useState(true); const [loadError, setLoadError] = React.useState(null); // Lazy-load state_indices.json (~2.4 MB) on first mount. React.useEffect(() => { if (indicesData) return; fetch("./state_indices.json") .then(r => r.ok ? r.json() : Promise.reject(`HTTP ${r.status}`)) .then(data => { window.PL_INDICES = data; setIndicesData(data); }) .catch(err => setLoadError(String(err))); }, [indicesData]); // Lazy-load indices_external.json (small, cheap). Used for the triangulation // panel — when populated, surfaces side-by-side comparison with established // external indices (V-Dem, MAP, AARP LTSS, Commonwealth Fund, etc.). // Schema doc: rubrics/index_canons/EXTERNAL_DATA_HOWTO.md React.useEffect(() => { if (externalData) return; fetch("./indices_external.json") .then(r => r.ok ? r.json() : Promise.reject(`HTTP ${r.status}`)) .then(data => { window.PL_INDICES_EXTERNAL = data; setExternalData(data); }) .catch(() => setExternalData({})); // silent fail — panel just hides }, [externalData]); // Default to first known index when nothing selected React.useEffect(() => { if (!indexId && KNOWN_INDICES.length) setIndexId(KNOWN_INDICES[0]); }, [indexId, setIndexId]); // Load canon markdown lazily as user switches indices. React.useEffect(() => { if (!indexId) return; fetch(`./index_canons/${indexId}.md`) .then(r => r.ok ? r.text() : Promise.reject(`HTTP ${r.status}`)) .then(setCanonText) .catch(() => setCanonText("Canon file not loaded.")); }, [indexId]); if (loadError) { return (

Indices

Failed to load indices data: {loadError}

); } if (!indicesData || !indexId) { return (
Loading indices…
); } const restrictive = RESTRICTIVE.has(indexId); // Build ranking for the current index + mode const rows = []; Object.entries(indicesData).forEach(([ss, byIdx]) => { const cell = byIdx[indexId]?.modes?.[mode]; if (!cell || cell.score === undefined) return; const [state, sessionId] = ss.split("_"); rows.push({ state_session: ss, state, session_id: sessionId, score: cell.score, coverage: cell.coverage, contributing: cell.contributing, }); }); // Dedupe to latest session per state let displayRows = rows; if (latestOnly) { const byState = {}; rows.forEach(r => { const sid = parseInt(r.session_id) || 0; const ex = byState[r.state]; if (!ex || sid > (parseInt(ex.session_id) || 0)) byState[r.state] = r; }); displayRows = Object.values(byState); } displayRows.sort((a, b) => b.score - a.score); // Index meta from the first state-session (all states carry the same meta) const firstSS = Object.keys(indicesData)[0]; const meta = indicesData[firstSS]?.[indexId] || {}; return (
{/* Page header */}
Layer 2 — Editorial Indices

{meta.name || indexId}

{meta.description}

{/* Index tabs */}
{KNOWN_INDICES.map(id => ( ))}
{/* Controls */}
{MODES.map(m => ( ))}
{/* Two-column: ranking + breakdown */}
{/* Ranked list */}
Ranked states · {displayRows.length}
{displayRows.map((row, i) => (
{row.state} {!latestOnly && {row.session_id}} {row.score.toFixed(1)}
))}
{/* Breakdown for top state */}
Top contributors · {displayRows[0]?.state || "—"}
{displayRows[0] ? ( <>
{displayRows[0].state} → {displayRows[0].score.toFixed(1)} coverage {(displayRows[0].coverage * 100).toFixed(0)}%
{displayRows[0].contributing.map(c => ( ))}
Lens Wt Raw Used
{c.lens.replace(/_/g, " ")} {(c.weight * 100).toFixed(0)}% {c.raw} {c.used} {c.invert ? "⇅" : ""}
⇅ means inverted: we use (100 − lens_score) so the contribution aligns with the index's editorial direction.
) : (
No data.
)}
{/* External comparison / triangulation */} {/* Full canon */}
Source canon · methodology · limitations
{canonText ? renderMarkdown(canonText) : Loading canon…}
{/* Navigation back */}
go("landing")} style={{ color: "var(--accent)", cursor: "pointer", fontSize: 13, textDecoration: "underline" }}> ← Back to atlas
); } // ── External comparison panel ─────────────────────────────────────────────── // Shows side-by-side comparison with established external indices (V-Dem, // MAP, AARP LTSS, etc.). Renders gracefully whether or not the operator has // populated values in indices_external.json — empty sources show a "not yet // compiled" note so readers know we plan to triangulate. // Schema + how-to: rubrics/index_canons/EXTERNAL_DATA_HOWTO.md function ExternalComparison({ indexId, externalData, displayRows, restrictive }) { if (!externalData) return null; const indexBlock = externalData[indexId]; if (!indexBlock || !indexBlock.sources) return null; const sources = Object.entries(indexBlock.sources); if (sources.length === 0) return null; // Build state → our-score map for joining const ourByState = {}; displayRows.forEach(r => { ourByState[r.state] = r.score; }); return (
External comparison · triangulation
Established indices that measure adjacent constructs. When our score sharply disagrees with one of these for a state, that's a signal worth investigating — could be a methodological difference, a filter gap, or a real divergence.
{sources.map(([sourceId, source]) => ( ))}
{indexBlock._note && (
{indexBlock._note}
)}
); } function ExternalSourceCard({ source, ourByState, restrictive }) { const values = source.values || {}; const stateEntries = Object.entries(values); const hasData = stateEntries.length > 0; // Polarity alignment: if both indices "high = same thing" then we say "aligned", // else "inverted." Used to interpret divergences. const polarityAligns = source.polarity_aligns_with_our_index; const polarityLabel = polarityAligns === true ? "polarity aligned" : polarityAligns === false ? "polarity inverted" : "polarity unspecified"; return (
{/* Header */}
{source.name}
{source.as_of && (
as of {source.as_of}
)}
{/* Metadata strip */}
{source.url && ( <> source ↗ · )} {source.higher_is && ( <> higher = {source.higher_is} · )} {polarityLabel}
{/* Body */}
{hasData ? ( ) : (
Not yet compiled. State-level values are published in the source report; comparison will appear here once values are populated in indices_external.json.
)}
); } function ComparisonTable({ stateEntries, ourByState, polarityAligns, restrictive }) { // Build joined rows where BOTH our score and their value exist. // Sort by our score descending (matches the main ranking). const joined = stateEntries .filter(([state]) => ourByState[state] !== undefined) .map(([state, theirValue]) => ({ state, ours: ourByState[state], theirs: theirValue, })) .sort((a, b) => b.ours - a.ours); if (joined.length === 0) { return (
No state overlap between this source and our scored states.
); } // Show top 5 + bottom 5 when there's enough data; else show all. const showSplit = joined.length > 12; const topN = showSplit ? joined.slice(0, 5) : joined; const botN = showSplit ? joined.slice(-5) : []; // Compute correlation-ish divergence flag for each row. // If polarityAligns: rows where ours is HIGH and theirs is LOW = divergence. // If !polarityAligns: rows where ours is HIGH and theirs is HIGH = divergence. const theirValues = joined.map(j => j.theirs).filter(v => typeof v === "number").sort((a, b) => a - b); const theirMedian = theirValues.length ? theirValues[Math.floor(theirValues.length / 2)] : null; const renderRow = (row) => { const ours = row.ours; const theirs = row.theirs; const ourHigh = ours >= 50; let divergent = false; if (theirMedian !== null && typeof theirs === "number") { const theirHigh = theirs >= theirMedian; // If polarity aligned: expect ourHigh == theirHigh. Divergent when opposite. // If inverted: expect ourHigh != theirHigh. Divergent when same. divergent = polarityAligns ? (ourHigh !== theirHigh) : (ourHigh === theirHigh); } return ( {row.state} {ours.toFixed(1)} {typeof theirs === "number" ? theirs.toLocaleString() : String(theirs)} {divergent ? "⚠" : "✓"} ); }; return ( <> {topN.map(renderRow)} {showSplit && ( )} {botN.map(renderRow)}
State Ours Theirs
··· {joined.length - 10} STATES ···
✓ = directional agreement (both above/below median per their polarity). ⚠ = divergence worth investigating.
); } window.PL_IndicesPage = IndicesPage; window.PL_KNOWN_INDICES = KNOWN_INDICES; }