Files
crdc-demo/src/charts/RateDensityRidgeline.jsx
T
jaredandClaude Sonnet 5 cf8e65bb8e
Deploy to git-pages / deploy (push) Successful in 12s
Fix chart bugs found on deployed site, redesign Chart 2 as box-and-whisker
Chart 4 (model predictions vs. observed) was picking the first row for a
given wave/model instead of summing across selected groups, so any district
whose first-returned group had zero counts (e.g. a suppressed race group)
showed a collapsed, flat modeled marker instead of the real aggregate
interval. Also fixes the rightmost wave label ("2021-22") clipping off the
edge of the small-multiple panels by anchoring edge ticks away from the
viewBox boundary instead of centering on it.

Chart 6's y-axis group labels (e.g. "American Indian / Alaska Native
Female") were wider than their margin and clipped past the left edge of the
SVG. Adds a shared shortGroupLabel() to colors.js and uses it here, matching
the convention already used in charts 2 and 5.

Chart 2 previously drew a plain bar to the modeled median next to an
observed diamond that often sat well past the bar's end, reading as if the
bar itself were an uncertainty range when it wasn't. Replaces it with an
actual horizontal box-and-whisker: whisker = the API's reported 90%
interval, box = the fitted approximation's 25th/75th percentiles (via a new
fit.quantile() inverse-CDF, exact round-trip of fitSkewedInterval's own
construction), median tick, observed diamond overlaid.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-10 18:58:35 -04:00

164 lines
6.3 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 (
<div className="cv-card" style={{ padding: 'var(--space-2)' }}>
<h3 style={{ fontSize: '0.85rem', marginBottom: 'var(--space-1)', color: 'var(--cv-ink-2)' }}>
Predicted arrest rates by student group
</h3>
<p style={{ color: 'var(--cv-ink-3)' }}>No model data available.</p>
</div>
)
}
// 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 (
<div className="cv-card" style={{ padding: 'var(--space-2)' }}>
<h3 style={{ fontSize: '0.85rem', marginBottom: 'var(--space-1)', color: 'var(--cv-ink-2)' }}>
Predicted arrest rates by student group
</h3>
<ApproxNote />
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 'var(--space-2)', marginTop: 'var(--space-1)' }}>
{MODEL_QUADRANTS.map((q) => (
<RidgePanel key={q.model} q={q} rows={quadData[q.model] || []} maxRate={maxRate} />
))}
</div>
<ChartLegend items={[
...RACE_ORDER.map((race) => ({ shape: 'swatch', color: raceColor(race), label: RACE_LABELS[race] })),
{ shape: 'diamond', color: OBSERVED_MARK_COLOR, label: 'Observed' },
]} />
</div>
)
}
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 (
<div style={{ border: '1px solid var(--cv-rule)', borderRadius: 'var(--radius-md)', padding: 'var(--space-2)' }}>
<p style={{ fontSize: '0.65rem', color: 'var(--cv-ink-3)' }}>{q.label} — no data for the selected groups</p>
</div>
)
}
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 (
<div style={{ border: '1px solid var(--cv-rule)', borderRadius: 'var(--radius-md)', padding: 'var(--space-1)' }}>
<svg width="100%" viewBox={`0 0 ${width} ${height}`} style={{ maxWidth: '100%' }}>
<text x={width / 2} y={14} textAnchor="middle" fontSize="0.65rem" fontWeight={600} fill="var(--cv-ink-2)">
{q.label}
</text>
{[0, 0.25, 0.5, 0.75, 1].map((frac) => {
const val = frac * maxRate
const x = xScale(val)
return (
<g key={frac}>
<line x1={x} y1={margin.top} x2={x} y2={margin.top + groups.length * rowHeight} stroke="var(--cv-rule)" strokeWidth={1} />
<text x={x} y={margin.top + groups.length * rowHeight + 14} textAnchor="middle" fontSize="0.55rem" fill="var(--cv-ink-3)">
{val.toFixed(0)}
</text>
</g>
)
})}
{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 (
<g key={`${g.race}-${g.sex}`}>
<text x={margin.left - 8} y={rowTop + rowHeight / 2 + 4} textAnchor="end" fontSize="0.6rem" fill="var(--cv-ink)">
{SHORT_RACE_LABEL[g.race] || g.race} {g.sex}
</text>
<path d={areaPath} fill={color} opacity={0.6} stroke="#fff" strokeWidth={0.5} />
<rect
x={obsX - 4} y={obsY - 4} width={8} height={8}
fill={OBSERVED_MARK_COLOR} stroke="#fff" strokeWidth={1}
transform={`rotate(45 ${obsX} ${obsY})`}
/>
</g>
)
})}
<text x={width / 2} y={height - 6} textAnchor="middle" fontSize="0.55rem" fill="var(--cv-ink-3)">
Arrests per 1,000 students
</text>
</svg>
</div>
)
}