import { MODEL_QUADRANTS } from '../hooks/useApi.js' import { raceColor, OBSERVED_MARK_COLOR, RACE_LABELS, SHORT_RACE_LABEL } from '../utils/colors.js' import { fitSkewedInterval, densityCurve } from '../utils/distributionApprox.js' import ChartLegend from '../components/ChartLegend.jsx' import ApproxNote from '../components/ApproxNote.jsx' /** * Chart 5: Modeled posterior density per race×sex group, faceted 2×2 by * model quadrant (matches whitepaper-fig-clark-density-1.png). Plain SVG — * no D3, no fake normal draws: each ridge is drawn as 60 analytic points * from fitSkewedInterval/densityCurve, which is already smooth without a * binning/curveBasis smoothing pass. */ const RACE_ORDER = ['WH', 'BL', 'HI', 'AM'] const SEX_ORDER = ['F', 'M'] function buildGroupRow(row) { const enroll = row.stu_enroll || 0 const observedRate = enroll > 0 ? ((row.observed_arrests || 0) / enroll) * 1000 : 0 const rateMedian = (row.rate_median || 0) * 1000 const rateLower = (row.rate_lower || 0) * 1000 const rateUpper = (row.rate_upper || 0) * 1000 return { race: row.race, sex: row.sex, observedRate, fit: fitSkewedInterval({ median: rateMedian, lower: rateLower, upper: rateUpper }), } } export default function RateDensityRidgeline({ quadData }) { const hasData = quadData && Object.values(quadData).some((rows) => (rows || []).length > 0) if (!hasData) { return (

Predicted arrest rates by student group

No model data available.

) } // Shared x-domain across all four quadrant panels, so they're directly comparable. const allUpper = [] const allObserved = [] MODEL_QUADRANTS.forEach((q) => { (quadData[q.model] || []).forEach((row) => { allUpper.push((row.rate_upper || 0) * 1000) const enroll = row.stu_enroll || 0 if (enroll > 0) allObserved.push(((row.observed_arrests || 0) / enroll) * 1000) }) }) const maxRate = Math.min(Math.max(...allUpper, ...allObserved, 1) * 1.15, 30) return (

Predicted arrest rates by student group

{MODEL_QUADRANTS.map((q) => ( ))}
({ shape: 'swatch', color: raceColor(race), label: RACE_LABELS[race] })), { shape: 'diamond', color: OBSERVED_MARK_COLOR, label: 'Observed' }, ]} />
) } function RidgePanel({ q, rows, maxRate }) { const groups = [] SEX_ORDER.forEach((sex) => { RACE_ORDER.forEach((race) => { const row = rows.find((r) => r.race === race && r.sex === sex) if (row) groups.push(buildGroupRow(row)) }) }) if (groups.length === 0) { return (

{q.label} — no data for the selected groups

) } const width = 300 const rowHeight = 46 const margin = { top: 24, right: 16, bottom: 26, left: 56 } const innerWidth = width - margin.left - margin.right const height = margin.top + groups.length * rowHeight + margin.bottom const xScale = (val) => margin.left + (val / maxRate) * innerWidth const curves = groups.map((g) => densityCurve(g.fit, { min: 0, max: maxRate, n: 60 })) const maxPdf = Math.max(...curves.flatMap((c) => c.map((p) => p.y)), 1e-9) const peakHeight = rowHeight * 0.85 return (
{q.label} {[0, 0.25, 0.5, 0.75, 1].map((frac) => { const val = frac * maxRate const x = xScale(val) return ( {val.toFixed(0)} ) })} {groups.map((g, i) => { const rowTop = margin.top + i * rowHeight const baselineY = rowTop + rowHeight * 0.9 const curve = curves[i] const color = raceColor(g.race) const topPath = curve .map((p, j) => `${j === 0 ? 'M' : 'L'}${xScale(p.x)},${baselineY - (p.y / maxPdf) * peakHeight}`) .join(' ') const areaPath = `${topPath} L${xScale(maxRate)},${baselineY} L${xScale(0)},${baselineY} Z` const obsX = xScale(Math.min(g.observedRate, maxRate)) const obsY = baselineY - peakHeight * 0.15 return ( {SHORT_RACE_LABEL[g.race] || g.race} {g.sex} ) })} Arrests per 1,000 students
) }