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 (
)
}