feat: drive Chart 3's ridgelines from real posterior draws, with fallback

Wire useDrawDistribution into RateDensityRidgeline so each race×sex ridge
uses a Gaussian KDE over that group's 500 real posterior draws when the
selected model's shard has loaded, falling back per-group to the analytic
fitSkewedInterval approximation otherwise. ApproxNote now only shows when
status !== 'ready'. Pass leaid/state through from ChartPanel.
This commit is contained in:
2026-08-11 09:30:46 -04:00
parent ea6d554e3d
commit b5941a78d1
2 changed files with 32 additions and 13 deletions
+31 -12
View File
@@ -2,23 +2,25 @@ import { useState } from 'react'
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 { kdeCurve } from '../utils/kde.js'
import { niceTicks } from '../utils/niceTicks.js'
import { useDrawDistribution } from '../hooks/useDrawDistribution.js'
import ChartLegend from '../components/ChartLegend.jsx'
import ApproxNote from '../components/ApproxNote.jsx'
/**
* Modeled posterior density per race×sex group, for one selected model
* (dropdown, default three-year + referral rate), split into Female/Male
* columns — matches whitepaper-fig-clark-density-1.png's ridge style. Plain
* SVG — each ridge is 60 analytic points from fitSkewedInterval/densityCurve,
* already smooth without a binning/curveBasis smoothing pass.
* columns. Each ridge is drawn from that group's 500 real posterior draws
* (Gaussian KDE) when available, falling back per-group to the analytic
* fitSkewedInterval/densityCurve approximation otherwise.
*/
const RACE_ORDER = ['WH', 'BL', 'HI', 'AM']
const SEX_COLUMNS = [{ sex: 'F', label: 'Female' }, { sex: 'M', label: 'Male' }]
const DEFAULT_MODEL = 'unified_m4_mod' // Three-year + referral rate
function buildGroupRow(row) {
function buildGroupRow(row, draws) {
const enroll = row.stu_enroll || 0
const observedRate = enroll > 0 ? ((row.observed_arrests || 0) / enroll) * 1000 : 0
const rateMedian = (row.rate_median || 0) * 1000
@@ -28,13 +30,16 @@ function buildGroupRow(row) {
race: row.race,
sex: row.sex,
observedRate,
draws,
fit: fitSkewedInterval({ median: rateMedian, lower: rateLower, upper: rateUpper }),
}
}
export default function RateDensityRidgeline({ quadData }) {
export default function RateDensityRidgeline({ quadData, leaid, state }) {
const [selectedModel, setSelectedModel] = useState(DEFAULT_MODEL)
const rows = (quadData && quadData[selectedModel]) || []
const groups = rows.map((r) => ({ race: r.race, sex: r.sex, stuEnroll: r.stu_enroll || 0 }))
const { status, drawsByGroup } = useDrawDistribution({ leaid, state, model: selectedModel, year: '21-22', groups })
const modelSelect = (
<select
@@ -55,7 +60,7 @@ export default function RateDensityRidgeline({ quadData }) {
<h3 style={{ fontSize: '0.85rem', marginBottom: 'var(--space-1)', color: 'var(--cv-ink-2)' }}>
Predicted arrest rates by student group
</h3>
<ApproxNote />
{status !== 'ready' && <ApproxNote />}
</div>
{modelSelect}
</div>
@@ -64,7 +69,7 @@ export default function RateDensityRidgeline({ quadData }) {
<p style={{ color: 'var(--cv-ink-3)', marginTop: 'var(--space-2)' }}>No model data available.</p>
) : (
<>
<RidgeColumns rows={rows} />
<RidgeColumns rows={rows} drawsByGroup={drawsByGroup} />
<ChartLegend items={[
...RACE_ORDER.map((race) => ({ shape: 'swatch', color: raceColor(race), label: RACE_LABELS[race] })),
{ shape: 'diamond', color: OBSERVED_MARK_COLOR, label: 'Observed' },
@@ -75,7 +80,7 @@ export default function RateDensityRidgeline({ quadData }) {
)
}
function RidgeColumns({ rows }) {
function RidgeColumns({ rows, drawsByGroup }) {
// Shared x-domain across both columns, so Female/Male are directly comparable.
const allUpper = rows.map((r) => (r.rate_upper || 0) * 1000)
const allObserved = rows
@@ -87,14 +92,24 @@ function RidgeColumns({ rows }) {
return (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 'var(--space-3)', marginTop: 'var(--space-2)' }}>
{SEX_COLUMNS.map(({ sex, label }) => (
<SexRidgeColumn key={sex} label={label} rows={rows.filter((r) => r.sex === sex)} ticks={ticks} maxRate={niceMax} />
<SexRidgeColumn
key={sex}
label={label}
rows={rows.filter((r) => r.sex === sex)}
drawsByGroup={drawsByGroup}
ticks={ticks}
maxRate={niceMax}
/>
))}
</div>
)
}
function SexRidgeColumn({ label, rows, ticks, maxRate }) {
const groups = RACE_ORDER.map((race) => rows.find((r) => r.race === race)).filter(Boolean).map(buildGroupRow)
function SexRidgeColumn({ label, rows, drawsByGroup, ticks, maxRate }) {
const groups = RACE_ORDER
.map((race) => rows.find((r) => r.race === race))
.filter(Boolean)
.map((row) => buildGroupRow(row, drawsByGroup?.[`${row.race}_${row.sex}`]))
const width = 300
const rowHeight = 58
@@ -104,7 +119,11 @@ function SexRidgeColumn({ label, rows, ticks, maxRate }) {
const xScale = (val) => margin.left + (val / maxRate) * innerWidth
const curves = groups.map((g) => densityCurve(g.fit, { min: 0, max: maxRate, n: 60 }))
const curves = groups.map((g) =>
g.draws && g.draws.length > 0
? kdeCurve(g.draws, { min: 0, max: maxRate, n: 60 })
: 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.82
+1 -1
View File
@@ -97,7 +97,7 @@ export default function ChartPanel({ district, state }) {
<RateByGroupBar data={rateByGroup} leaid={district.leaid} state={state} />
<RateDensityRidgeline quadData={data.quadData} />
<RateDensityRidgeline quadData={data.quadData} leaid={district.leaid} state={state} />
</div>
{/* Methodology footer */}