Deploy to git-pages / deploy (push) Successful in 12s
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>
164 lines
6.3 KiB
React
164 lines
6.3 KiB
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 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>
|
||
)
|
||
}
|