import { useMemo } from 'react' import { area, curveMonotoneX, curveStep } from 'd3-shape' import { scaleLinear } from 'd3-scale' import { interpolateRgb } from 'd3-interpolate' import { rateProfile } from '../utils/densityProfile.js' import { differenceRates, differenceSummary } from '../utils/groupDifference.js' import { displayDraws } from '../utils/districtGroups.js' import { niceTicks } from '../utils/niceTicks.js' /** * Chart B — "Model estimated differences". * * Port of the white paper's Fig 7 (`wp_fig_group_difference`): the posterior * distribution of Δ = rate(A) − rate(B), per 1,000 students, computed at each * draw index. * * Two deliberate departures from the R figure: * * - The fill is a **diverging** ramp centred at zero (navy below, paper at * zero, ember above) rather than the paper's sequential YlOrRd. Δ is a signed * quantity; a sequential ramp encodes "more" where the data means "which * direction", and would make a large negative difference read as a small one. * - The readout is spelled out in a sentence. `Pr(Δ > 0) = 94.4%` is the number * a reader is most likely to misread as "94.4% more arrests". */ const NEGATIVE_COLOR = '#22406A' // --cv-navy-600 const ZERO_COLOR = '#F2EDE4' // --cv-paper-2 const POSITIVE_COLOR = '#C25311' // --cv-accent const GRADIENT_STOPS = 24 const WIDTH = 760 const HEIGHT = 250 const MARGIN = { top: 18, right: 22, bottom: 46, left: 22 } export default function GroupDifference({ groups, enrollByGroup, byModel, selectedModel, status, pooled, pair, onPairChange, }) { const { counts, enroll } = useMemo( () => displayDraws(byModel?.[selectedModel]?.counts, enrollByGroup, pooled), [byModel, selectedModel, enrollByGroup, pooled], ) // Only groups with a usable draw set can be differenced at all — offering the // others in the picker would produce an empty chart with no explanation. const comparable = useMemo( () => groups.filter((g) => counts?.[g.key]?.length > 0 && enroll?.[g.key] > 0), [groups, counts, enroll], ) const [keyA, keyB] = pair || [] const groupA = comparable.find((g) => g.key === keyA) const groupB = comparable.find((g) => g.key === keyB) const deltas = useMemo( () => groupA && groupB ? differenceRates(counts[groupA.key], enroll[groupA.key], counts[groupB.key], enroll[groupB.key]) : [], [groupA, groupB, counts, enroll], ) const summary = useMemo(() => differenceSummary(deltas), [deltas]) return (

Model estimated differences

How much higher is one group’s modelled arrest rate than another’s, and how sure is the model of the direction?

{comparable.length >= 2 && ( )}
{status === 'loading' ? (

Loading posterior draws…

) : comparable.length < 2 ? ( ) : !groupA || !groupB || groupA.key === groupB.key ? ( ) : !summary ? ( ) : ( <>

Δ is computed at each of {summary.n.toLocaleString()} posterior predictive draws as{' '} {groupA.label} minus {groupB.label}, per 1,000 students. The dashed line marks no difference. Bars beneath the curve are the 80% and 95% intervals around the median Δ.

)}
) } function Readout({ summary, groupA, groupB }) { const pct = summary.prGreater * 100 const higher = summary.median >= 0 ? groupA : groupB const lower = summary.median >= 0 ? groupB : groupA const share = summary.median >= 0 ? pct : 100 - pct return (
{formatPercent(pct)} Pr(Δ > 0)

In {formatPercent(share)} of posterior predictive draws, the {higher.sentenceLabel} arrest rate exceeds the {lower.sentenceLabel} rate. The median difference is{' '} {formatDelta(summary.median)} per 1,000 students.

) } function DifferencePlot({ deltas, summary, groupA, groupB }) { const innerWidth = WIDTH - MARGIN.left - MARGIN.right const baselineY = HEIGHT - MARGIN.bottom const { ticks, min, max } = useMemo(() => symmetricDomain(summary), [summary]) const x = scaleLinear().domain([min, max]).range([MARGIN.left, MARGIN.left + innerWidth]) const profile = useMemo(() => rateProfile(deltas, { min, max, n: 80 }), [deltas, min, max]) if (!profile) return null const y = scaleLinear().domain([0, profile.maxY || 1]).range([baselineY, MARGIN.top]) const points = profile.kind === 'mass' ? padMassPoints(profile.points, profile.step, min, max) : profile.points const areaGen = area() .x((p) => x(clamp(p.x, min, max))) .y0(baselineY) .y1((p) => y(p.y)) .curve(profile.kind === 'mass' ? curveStep : curveMonotoneX) const gradientId = `delta-gradient-${groupA.key}-${groupB.key}` return (
{divergingStops(min, max).map((s) => ( ))} {ticks.map((t) => ( ))} {/* No difference — the paper's red vertical line. */} no difference {/* Median with 80% (thick) and 95% (thin) intervals. */} {ticks.map((t) => ( {formatTick(t)} ))} ← {groupB.shortLabel} higher {groupA.shortLabel} higher →
) } /** * A domain centred on zero. A signed quantity drawn on an off-centre axis makes * the eye read the *position* of the curve as the size of the difference, so * zero sits in the middle even when every draw falls on one side of it. */ function symmetricDomain(summary) { const extent = Math.max( Math.abs(summary.lower95), Math.abs(summary.upper95), Math.abs(summary.median), 1e-6, ) const { niceMax } = niceTicks(extent * 1.25, 4) const step = niceMax / 4 const ticks = [] for (let i = -4; i <= 4; i++) ticks.push(Math.round(step * i * 1e6) / 1e6) return { ticks, min: -niceMax, max: niceMax } } /** Diverging ramp, with the paper-coloured midpoint pinned to Δ = 0. */ function divergingStops(min, max) { const zeroOffset = (0 - min) / (max - min) const toNegative = interpolateRgb(NEGATIVE_COLOR, ZERO_COLOR) const toPositive = interpolateRgb(ZERO_COLOR, POSITIVE_COLOR) const stops = [] for (let i = 0; i <= GRADIENT_STOPS; i++) { const offset = i / GRADIENT_STOPS const color = offset <= zeroOffset ? toNegative(zeroOffset > 0 ? offset / zeroOffset : 1) : toPositive(zeroOffset < 1 ? (offset - zeroOffset) / (1 - zeroOffset) : 0) stops.push({ offset, color }) } return stops } function padMassPoints(points, step, min, max) { const half = Math.max(step, 1e-6) / 2 return [ { x: Math.max(points[0].x - half, min), y: 0 }, ...points, { x: Math.min(points[points.length - 1].x + half, max), y: 0 }, ] } function PairPickers({ comparable, keyA, keyB, onPairChange }) { return (
onPairChange([v, keyB])} /> against onPairChange([keyA, v])} />
) } function GroupSelect({ label, value, options, onChange }) { return ( ) } function Degraded({ reason }) { return (

{reason}

) } function clamp(v, min, max) { return Math.min(Math.max(v, min), max) } function formatPercent(pct) { return `${pct.toLocaleString(undefined, { minimumFractionDigits: 1, maximumFractionDigits: 1 })}%` } function formatDelta(v) { const sign = v > 0 ? '+' : '' return `${sign}${v.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}` } function formatTick(v) { if (v === 0) return '0' const abs = Math.abs(v) return v.toLocaleString(undefined, { minimumFractionDigits: abs < 1 ? 2 : abs < 10 ? 1 : 0, maximumFractionDigits: abs < 1 ? 2 : abs < 10 ? 1 : 0, }) } const cardTitle = { fontSize: '0.85rem', marginBottom: 'var(--space-1)', color: 'var(--cv-ink-2)' } const headerStyle = { display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', flexWrap: 'wrap', gap: 'var(--space-2)', } const subtitleStyle = { fontSize: '0.8rem', color: 'var(--cv-ink-3)', margin: 0, maxWidth: '32rem' } const emptyStyle = { color: 'var(--cv-ink-3)', fontSize: '0.85rem', padding: 'var(--space-2) 0' } const captionStyle = { fontSize: '0.74rem', color: 'var(--cv-ink-3)', margin: '0.4rem 0 0', lineHeight: 1.5 } const readoutStyle = { display: 'flex', alignItems: 'center', gap: 'var(--space-3)', flexWrap: 'wrap', padding: 'var(--space-2) 0', borderTop: '3px double var(--cv-ink)', borderBottom: '1px solid var(--cv-rule)', margin: 'var(--space-2) 0', } const readoutNumber = { fontFamily: "'Source Serif 4', Georgia, serif", fontWeight: 700, fontSize: '2.75rem', lineHeight: 1, letterSpacing: '-0.02em', color: 'var(--cv-ink)', fontVariantNumeric: 'tabular-nums', display: 'block', } const readoutLabel = { fontSize: '0.72rem', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.08em', color: 'var(--cv-ink-3)', display: 'block', marginTop: '0.3rem', } const degradedStyle = { background: 'var(--cv-paper-2)', border: '1px solid var(--cv-rule)', borderLeft: '3px solid var(--cv-ink-4)', borderRadius: 'var(--radius-md)', padding: 'var(--space-2)', marginTop: 'var(--space-2)', color: 'var(--cv-ink-2)', }