Reduce demo to 3 charts, fix back-button district name bug
Deploy to git-pages / deploy (push) Successful in 26s

Fixes the deep-link/back-button bug where the district name showed
"Unknown District" on return: App.jsx was passing the LEAID as a text
search query to searchDistricts() (a name search), which never matches a
numeric ID. Resolves it instead via fetchDistrictEstimates(leaid, ...) and
reads lea_name/state directly from the returned row - correct regardless
of how the page was reached (fresh load, refresh, or browser back/forward).

Cuts the demo from 6 charts to 3, per request: arrests over time (kept),
arrest rate by student group restructured into Female/Male box-and-whisker
panels (kept), and the posterior density ridge chart restructured from a
2x2 model-quadrant grid into a single selected model (dropdown, default
three-year + referral rate) with Female/Male ridge columns. Removes
DistrictVsNational, ModelDrawsComparison, and ExceedanceProbability
entirely, along with the student-group filter (no longer needed - the
remaining charts always show the full breakdown) and the national-rates
fetch/plumbing that only those removed charts used.

Also updates LoadingAnimation's copy and dedupes its STUDENT_GROUPS/
MODEL_QUADRANTS constants against the shared ones in useApi.js.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-10 19:27:24 -04:00
co-authored by Claude Sonnet 5
parent cf8e65bb8e
commit a1ffa600b3
14 changed files with 263 additions and 874 deletions
+12 -12
View File
@@ -4,7 +4,7 @@ import DistrictSearch from './components/DistrictSearch.jsx'
import LoadingAnimation from './components/LoadingAnimation.jsx' import LoadingAnimation from './components/LoadingAnimation.jsx'
import ChartPanel from './components/ChartPanel.jsx' import ChartPanel from './components/ChartPanel.jsx'
import Footer from './components/Footer.jsx' import Footer from './components/Footer.jsx'
import { searchDistricts } from './hooks/useApi.js' import { fetchDistrictEstimates } from './hooks/useApi.js'
/** /**
* CRDC Arrests API Demo App — main router. * CRDC Arrests API Demo App — main router.
@@ -21,18 +21,18 @@ export default function App() {
const leaid = params.get('leaid') const leaid = params.get('leaid')
const stateParam = params.get('state') const stateParam = params.get('state')
if (leaid && stateParam) { if (leaid && stateParam) {
// Deep link: fetch district name first, then go to loading. // Deep link (including browser back/forward landing on this URL): resolve
// the district name via a real estimates row, keyed by LEAID. The previous
// version called searchDistricts(leaid, ...), a name/text search — passing
// an LEAID as search text never matches, so it always fell back to "Unknown
// District".
setSelectedState(stateParam) setSelectedState(stateParam)
// Fetch district name from API fetchDistrictEstimates(leaid, { model: 'unified_m2_mod', year: '21-22' }).then(rows => {
searchDistricts(leaid, stateParam).then(results => { const row = rows?.[0]
const district = results?.find(d => d.leaid === leaid) setDistrict(row
if (district) { ? { leaid: row.leaid, lea_name: row.lea_name, state: row.state }
setDistrict(district) : { leaid, lea_name: 'Unknown District', state: stateParam })
} else {
// Fallback if district not found
setDistrict({ leaid, lea_name: 'Unknown District', state: stateParam })
}
setStep('loading') setStep('loading')
}).catch(() => { }).catch(() => {
// Fallback on error // Fallback on error
-98
View File
@@ -1,98 +0,0 @@
import ChartLegend from '../components/ChartLegend.jsx'
import { raceColor, OBSERVED_MARK_COLOR, REFERENCE_GRAY } from '../utils/colors.js'
/**
* Chart 3: The district's highest-rate student group vs. the national rate
* for that same group. District bar = modeled median (race-colored) with a
* 90% interval whisker; dark diamond = observed rate on the same bar.
* National bar is a neutral gray reference, not a race/model color.
*/
export default function DistrictVsNational({ topGroup, nationalRate, rateByGroup }) {
const width = 360
const height = 300
const margin = { top: 45, right: 30, bottom: 80, left: 70 }
const innerWidth = width - margin.left - margin.right
const plotHeight = height - margin.top - margin.bottom
const districtObserved = topGroup.observedRate || 0
const districtModeled = topGroup.modeledMedian ?? districtObserved
const districtLower = topGroup.rateLower ?? districtModeled
const districtUpper = topGroup.rateUpper ?? districtModeled
const maxVal = Math.max(districtUpper, districtObserved, nationalRate, 1) * 1.3
const yFor = (val) => margin.top + plotHeight * (1 - val / maxVal)
const color = raceColor(topGroup.race)
const districtBarX = margin.left + innerWidth * 0.1
const districtBarW = innerWidth * 0.35
const nationalBarX = margin.left + innerWidth * 0.55
const nationalBarW = innerWidth * 0.35
return (
<div className="cv-card" style={{ padding: 'var(--space-2)' }}>
<h3 style={{ fontSize: '0.85rem', marginBottom: 'var(--space-1)', color: 'var(--cv-ink-2)' }}>
District vs. national rate — highest group
</h3>
<svg width="100%" viewBox={`0 0 ${width} ${height}`} style={{ maxWidth: '100%' }}>
<rect x={margin.left} y={margin.top} width={innerWidth} height={plotHeight} fill="var(--cv-paper-2)" rx={4} />
{[0, 0.25, 0.5, 0.75, 1].map((frac) => {
const val = frac * maxVal
const y = yFor(val)
return (
<g key={`y-${frac}`}>
<line x1={margin.left} y1={y} x2={margin.left + innerWidth} y2={y} stroke="var(--cv-rule)" strokeWidth={1} />
<text x={margin.left - 8} y={y + 4} textAnchor="end" fontSize="0.65rem" fill="var(--cv-ink-3)">{val.toFixed(1)}</text>
</g>
)
})}
<text x={12} y={margin.top + plotHeight / 2} textAnchor="middle" fontSize="0.7rem" fill="var(--cv-ink-3)"
transform={`rotate(-90 12 ${margin.top + plotHeight / 2})`}>
Rate per 1,000 students
</text>
{/* District bar — modeled median, race-colored, with 90% interval whisker */}
<rect x={districtBarX} y={yFor(districtModeled)} width={districtBarW} height={plotHeight * (districtModeled / maxVal)}
fill={color} rx={4} />
<line x1={districtBarX + districtBarW / 2} y1={yFor(districtLower)} x2={districtBarX + districtBarW / 2} y2={yFor(districtUpper)}
stroke="var(--cv-ink)" strokeWidth={1.5} />
{/* Observed diamond, overlaid on the district bar */}
<rect x={districtBarX + districtBarW / 2 - 5} y={yFor(districtObserved) - 5} width={10} height={10}
fill={OBSERVED_MARK_COLOR} stroke="#fff" strokeWidth={1.25}
transform={`rotate(45 ${districtBarX + districtBarW / 2} ${yFor(districtObserved)})`} />
{/* National bar — neutral reference */}
<rect x={nationalBarX} y={yFor(nationalRate)} width={nationalBarW} height={plotHeight * (nationalRate / maxVal)}
fill={REFERENCE_GRAY} rx={4} />
<text x={nationalBarX + nationalBarW / 2} y={yFor(nationalRate) - 4}
textAnchor="middle" fontSize="0.7rem" fontWeight={600} fill="var(--cv-ink)">{nationalRate.toFixed(2)}</text>
<text x={districtBarX + districtBarW / 2} y={height - margin.bottom + 15} textAnchor="middle" fontSize="0.7rem" fill="var(--cv-ink)">District</text>
<text x={nationalBarX + nationalBarW / 2} y={height - margin.bottom + 15} textAnchor="middle" fontSize="0.7rem" fill="var(--cv-ink)">National</text>
<text x={margin.left + innerWidth / 2} y={height - 5} textAnchor="middle" fontSize="0.65rem" fontWeight={600} fill="var(--cv-navy-700)">
{topGroup.label || 'Top group'}
</text>
</svg>
<ChartLegend items={[
{ shape: 'swatch', color, label: 'District (modeled + 90% interval)' },
{ shape: 'diamond', color: OBSERVED_MARK_COLOR, label: 'District (observed)' },
{ shape: 'swatch', color: REFERENCE_GRAY, label: 'National reference' },
]} />
<p style={{ fontSize: '0.72rem', color: 'var(--cv-ink-3)', marginTop: 'var(--space-1)' }}>
{topGroup.label || 'Highest-rate group'} in this district has an observed rate of{' '}
<strong>{districtObserved.toFixed(2)}</strong> per 1,000 vs. the national average of{' '}
<strong>{nationalRate.toFixed(2)}</strong>. This is{' '}
{districtObserved > nationalRate * 1.5 ? 'substantially higher than' : districtObserved > nationalRate
? 'slightly higher than' : districtObserved < nationalRate * 0.75
? 'much lower than'
: 'comparable to'} the national rate.
</p>
</div>
)
}
-127
View File
@@ -1,127 +0,0 @@
import { useState } from 'react'
import { groupKey } from '../utils/groupKey.js'
import { shortGroupLabel } from '../utils/colors.js'
import { fitSkewedInterval, probabilityExceeds } from '../utils/distributionApprox.js'
import ApproxNote from '../components/ApproxNote.jsx'
/**
* Chart 6: Probability the district exceeds the national rate for each
* student group, under a chosen model. The model dropdown now actually
* drives the computation (it was previously decorative — computed always
* read from a single ChartPanel-precomputed default model).
*/
const MODEL_OPTIONS = [
{ val: 'unified_m2_mod', label: 'Recommended (one-year + covariate)' },
{ val: 'unified_m4_mod', label: 'Three-year + covariate' },
]
export default function ExceedanceProbability({ quadData, nationalRates }) {
const [selectedModel, setSelectedModel] = useState('unified_m2_mod')
const rows = (quadData && quadData[selectedModel]) || []
const computed = rows
.map((row) => {
const natKey = groupKey(row.race, row.sex)
const nationalRate = nationalRates[natKey] || 0
const rateMedian = (row.rate_median || 0) * 1000
const rateLower = (row.rate_lower || 0) * 1000
const rateUpper = (row.rate_upper || 0) * 1000
const fit = fitSkewedInterval({ median: rateMedian, lower: rateLower, upper: rateUpper })
const probExceeds = Math.max(0.01, Math.min(0.99, probabilityExceeds(fit, nationalRate)))
return { race: row.race, sex: row.sex, label: shortGroupLabel(row.race, row.sex), nationalRate, modeledRate: rateMedian, probExceeds }
})
.sort((a, b) => b.probExceeds - a.probExceeds)
const width = 400
const height = 320
const margin = { top: 50, right: 100, bottom: 60, left: 90 }
const innerWidth = width - margin.left - margin.right
const plotHeight = height - margin.top - margin.bottom
return (
<div className="cv-card" style={{ padding: 'var(--space-2)' }}>
<h3 style={{ fontSize: '0.85rem', marginBottom: 'var(--space-1)', color: 'var(--cv-ink-2)' }}>
Probability district exceeds national rate — by student group
</h3>
<ApproxNote />
<select value={selectedModel} onChange={(e) => setSelectedModel(e.target.value)}
style={{ margin: 'var(--space-1) 0', padding: '0.25rem 0.5rem', fontFamily: 'var(--font-sans)' }}>
{MODEL_OPTIONS.map((opt) => (
<option key={opt.val} value={opt.val}>{opt.label}</option>
))}
</select>
{computed.length === 0 ? (
<p style={{ color: 'var(--cv-ink-3)' }}>No model data available for the selected groups.</p>
) : (
<svg width="100%" viewBox={`0 0 ${width} ${height}`} style={{ maxWidth: '100%' }}>
<rect x={margin.left} y={margin.top} width={innerWidth} height={plotHeight} fill="var(--cv-paper-2)" rx={4} />
{[0, 0.25, 0.5, 0.75, 1].map((frac) => {
const y = margin.top + plotHeight - frac * plotHeight
return (
<g key={`y-${frac}`}>
<line x1={margin.left} y1={y} x2={margin.left + innerWidth} y2={y} stroke="var(--cv-rule)" strokeWidth={0.5} />
<text x={margin.left - 6} y={y + 3} textAnchor="end" fontSize="0.6rem" fill="var(--cv-ink-3)">{frac.toFixed(2)}</text>
</g>
)
})}
<text x={14} y={margin.top + plotHeight / 2} textAnchor="middle" fontSize="0.7rem" fill="var(--cv-ink-3)"
transform={`rotate(-90 14 ${margin.top + plotHeight / 2})`}>
P(district rate &gt; national)
</text>
{/* 50% reference line — coin-flip threshold, not tied to any one group's national rate */}
<line x1={margin.left + innerWidth * 0.5} y1={margin.top} x2={margin.left + innerWidth * 0.5} y2={margin.top + plotHeight}
stroke="var(--cv-ink-4)" strokeWidth={1} strokeDasharray="3,2" />
{computed.map((d, i) => {
const barHeight = Math.min(22, (plotHeight * 0.9) / computed.length)
const y = margin.top + i * (barHeight + 4)
const probWidth = d.probExceeds * innerWidth
let barColor
if (d.probExceeds < 0.5) barColor = 'var(--moss-600)'
else if (d.probExceeds < 0.8) barColor = 'var(--brass-600)'
else barColor = 'var(--cv-danger)'
return (
<g key={`${d.race}-${d.sex}`}>
<rect x={margin.left} y={y + 2} width={probWidth} height={barHeight - 4} rx={2} fill={barColor} />
{d.probExceeds > 0.05 && (
<text x={margin.left + probWidth + 4} y={y + barHeight / 2 + 3} fontSize="0.65rem" fill="var(--cv-ink)">
{Math.round(d.probExceeds * 100)}%
</text>
)}
<text x={margin.left - 6} y={y + barHeight / 2 + 3} textAnchor="end" fontSize="0.65rem" fill="var(--cv-ink)">
{d.label}
</text>
</g>
)
})}
<text x={margin.left + innerWidth / 2} y={height - 8} textAnchor="middle" fontSize="0.7rem" fill="var(--cv-ink-3)">
Probability (higher = district more likely to exceed national rate)
</text>
<g transform={`translate(${margin.left}, ${margin.top - 22})`}>
<rect x={0} y={0} width={10} height={10} fill="var(--moss-600)" rx={2} />
<text x={15} y={9} fontSize="0.58rem" fill="var(--cv-ink)">&lt; 50%</text>
<rect x={65} y={0} width={10} height={10} fill="var(--brass-600)" rx={2} />
<text x={80} y={9} fontSize="0.58rem" fill="var(--cv-ink)">50–80%</text>
<rect x={145} y={0} width={10} height={10} fill="var(--cv-danger)" rx={2} />
<text x={160} y={9} fontSize="0.58rem" fill="var(--cv-ink)">&gt; 80%</text>
</g>
</svg>
)}
<p style={{ fontSize: '0.72rem', color: 'var(--cv-ink-3)', marginTop: 'var(--space-1)' }}>
Based on the {selectedModel === 'unified_m2_mod' ? 'one-year + covariate model (recommended)' : 'three-year + covariate model'}.
Green bars indicate groups where this district's rate is unlikely to exceed the national average; red indicates likely exceedance.
</p>
</div>
)
}
-143
View File
@@ -1,143 +0,0 @@
import { MODEL_QUADRANTS } from '../hooks/useApi.js'
import ChartLegend from '../components/ChartLegend.jsx'
import { OBSERVED_MARK_COLOR, MODELED_AGGREGATE_COLOR } from '../utils/colors.js'
/**
* Chart 4: Modeled vs. observed arrests, faceted by the four model-quadrant
* specifications (one-year/three-year × baseline/+referral-rate covariate).
* Plain SVG (no D3) — the quadrant label already conveys 1yr/3yr, so all
* four panels share one ink-diamond/navy-point-range convention rather than
* doubling up with a redundant per-quadrant hue.
*/
const WAVE_LABELS = { '15-16': '2015–16', '17-18': '2017–18', '21-22': '2021–22' }
const ALL_WAVES = ['15-16', '17-18', '21-22']
/** Sums count_median/lower/upper across every group row for one wave — the
* modeled counterpart to waveData's observed-arrests sum. Each row is a
* single race×sex group, so picking just one row (the original bug here)
* silently reports whichever group happens to come first from the API. */
function aggregateModeledForYear(rows, year) {
const yearRows = (rows || []).filter((r) => r.year === year)
if (yearRows.length === 0) return null
return {
median: yearRows.reduce((sum, r) => sum + (r.count_median || 0), 0),
lower: yearRows.reduce((sum, r) => sum + (r.count_lower || 0), 0),
upper: yearRows.reduce((sum, r) => sum + (r.count_upper || 0), 0),
}
}
export default function ModelDrawsComparison({ data, waves = ALL_WAVES, districtName }) {
const waveData = waves.map((year) => {
const rows = (data.waves && data.waves[year]) || []
return { year, arrests: rows.reduce((sum, r) => sum + (r.observed_arrests || 0), 0) }
})
const quadModels = (data && data.quadData) || {}
const allValues = waveData.map((d) => d.arrests)
Object.entries(quadModels).forEach(([model, rows]) => {
const isThreeYear = model.includes('m3') || model.includes('m4')
const years = isThreeYear ? ALL_WAVES : ['21-22']
years.forEach((year) => {
const agg = aggregateModeledForYear(rows, year)
if (agg) allValues.push(agg.median, agg.upper)
})
})
const maxArrests = Math.min(Math.max(...allValues, 1), 50) // cap for readability
return (
<div className="cv-card" style={{ padding: 'var(--space-2)' }}>
<h3 style={{ fontSize: '0.85rem', marginBottom: 'var(--space-1)', color: 'var(--cv-ink-2)' }}>
Model predictions vs. observed — by wave &amp; model specification
</h3>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 'var(--space-2)' }}>
{MODEL_QUADRANTS.map((q) => (
<QuadrantPanel key={q.model} q={q} waveData={waveData} quadModels={quadModels} maxArrests={maxArrests} />
))}
</div>
<ChartLegend items={[
{ shape: 'diamond', color: OBSERVED_MARK_COLOR, label: 'Observed' },
{ shape: 'line', color: MODELED_AGGREGATE_COLOR, label: 'Modeled (median + 90% interval)' },
]} />
<p style={{ fontSize: '0.72rem', color: 'var(--cv-ink-3)', marginTop: 'var(--space-1)' }}>
Three-year models span all three CRDC waves; one-year models show 2021–22 only.{' '}
{districtName ? `District: ${districtName}.` : ''}
</p>
</div>
)
}
function QuadrantPanel({ q, waveData, quadModels, maxArrests }) {
const isThreeYear = q.model.includes('m3') || q.model.includes('m4')
const showYears = isThreeYear ? ALL_WAVES : ['21-22']
const width = 260
const height = 190
const margin = { top: 26, right: 12, bottom: 26, left: 34 }
const innerWidth = width - margin.left - margin.right
const innerHeight = height - margin.top - margin.bottom
const xScale = (i) => margin.left + (showYears.length > 1 ? (i / (showYears.length - 1)) * innerWidth : innerWidth / 2)
const yScale = (val) => margin.top + innerHeight - (Math.min(val, maxArrests) / maxArrests) * innerHeight
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 * maxArrests
const y = yScale(val)
return (
<g key={frac}>
<line x1={margin.left} y1={y} x2={margin.left + innerWidth} y2={y} stroke="var(--cv-rule)" strokeWidth={1} />
<text x={margin.left - 6} y={y + 3} textAnchor="end" fontSize="0.55rem" fill="var(--cv-ink-3)">{Math.round(val)}</text>
</g>
)
})}
{showYears.map((year, i) => {
const x = xScale(i)
const obs = waveData.find((d) => d.year === year)?.arrests ?? 0
const agg = aggregateModeledForYear(quadModels[q.model], year)
const obsY = yScale(obs)
// Edge ticks anchor away from the viewBox boundary instead of centering
// on it, so the label can't be clipped by the SVG edge.
const tickAnchor = i === 0 ? 'start' : i === showYears.length - 1 ? 'end' : 'middle'
const tickX = i === 0 ? x - 4 : i === showYears.length - 1 ? x + 4 : x
return (
<g key={year}>
<text x={tickX} y={height - 6} textAnchor={tickAnchor} fontSize="0.55rem" fill="var(--cv-ink-3)">
{WAVE_LABELS[year]}
</text>
{agg && (
<>
<line
x1={x} y1={yScale(agg.upper)}
x2={x} y2={yScale(agg.lower)}
stroke={MODELED_AGGREGATE_COLOR} strokeWidth={1.5}
/>
<circle cx={x} cy={yScale(agg.median)} r={3.5} fill={MODELED_AGGREGATE_COLOR} stroke="#fff" strokeWidth={1} />
</>
)}
<rect
x={x - 4} y={obsY - 4} width={8} height={8}
fill={OBSERVED_MARK_COLOR} stroke="#fff" strokeWidth={1}
transform={`rotate(45 ${x} ${obsY})`}
/>
</g>
)
})}
</svg>
</div>
)
}
+83 -100
View File
@@ -4,20 +4,16 @@ import { raceColor, OBSERVED_MARK_COLOR, SHORT_RACE_LABEL } from '../utils/color
import { fitSkewedInterval } from '../utils/distributionApprox.js' import { fitSkewedInterval } from '../utils/distributionApprox.js'
/** /**
* Chart 2: Arrest rate per 1,000 students by student group (most recent * Arrest rate by student group, most recent year, disaggregated into two
* year). A plain bar to the modeled median (next to an observed diamond * panels (Female / Male), each a horizontal box-and-whisker across the 4
* that often sits well past the bar's end) reads as if the bar itself were * race categories. Whisker = the model's reported 90% interval, box = the
* the uncertainty range, which it wasn't. This draws an actual horizontal * fitted approximation's 25th-75th percentile, white tick = median, dark
* box-and-whisker instead: whisker = the API's reported 90% interval * diamond = observed rate.
* (rateLower/rateUpper), box = the fitted approximation's 25th/75th
* percentiles, median tick = the modeled median. Dark diamond = observed,
* overlaid at its own position. Sex is row grouping, not color.
*/ */
const RACE_ORDER = ['WH', 'BL', 'HI', 'AM'] const RACE_ORDER = ['WH', 'BL', 'HI', 'AM']
const SEX_ROWS = [{ sex: 'F', label: 'Female' }, { sex: 'M', label: 'Male' }] const SEX_PANELS = [{ sex: 'F', label: 'Female' }, { sex: 'M', label: 'Male' }]
const ROW_HEIGHT = 26 const ROW_HEIGHT = 34
const SUBHEADER_HEIGHT = 20
function buildBox(d) { function buildBox(d) {
const median = Math.max(d.modeledMedian || 0, 0) const median = Math.max(d.modeledMedian || 0, 0)
@@ -25,37 +21,17 @@ function buildBox(d) {
const upper = Math.max(d.rateUpper ?? median, median) const upper = Math.max(d.rateUpper ?? median, median)
const fit = fitSkewedInterval({ median, lower, upper }) const fit = fitSkewedInterval({ median, lower, upper })
return { return {
lower, lower, upper, median,
upper,
median,
q1: Math.max(fit.quantile(0.25), 0), q1: Math.max(fit.quantile(0.25), 0),
q3: Math.max(fit.quantile(0.75), median), q3: Math.max(fit.quantile(0.75), median),
} }
} }
export default function RateByGroupBar({ data }) { export default function RateByGroupBar({ data }) {
const width = 360
const margin = { top: 40, right: 30, bottom: 45, left: 90 }
const innerWidth = width - margin.left - margin.right
const bySex = SEX_ROWS
.map(({ sex, label }) => ({
sex,
label,
rows: RACE_ORDER.map((race) => data.find((d) => d.race === race && d.sex === sex)).filter(Boolean),
}))
.filter((g) => g.rows.length > 0)
const bodyHeight = bySex.reduce((sum, g) => sum + SUBHEADER_HEIGHT + g.rows.length * ROW_HEIGHT, 0)
const height = margin.top + bodyHeight + margin.bottom
const maxRate = Math.max( const maxRate = Math.max(
...data.map((d) => Math.max(d.observedRate, d.rateUpper ?? d.modeledMedian ?? 0)), ...data.map((d) => Math.max(d.observedRate, d.rateUpper ?? d.modeledMedian ?? 0)),
0.5 0.5
) )
const xScale = (val) => margin.left + (val / maxRate) * innerWidth
let cursorY = margin.top
return ( return (
<div className="cv-card" style={{ padding: 'var(--space-2)' }}> <div className="cv-card" style={{ padding: 'var(--space-2)' }}>
@@ -64,74 +40,11 @@ export default function RateByGroupBar({ data }) {
</h3> </h3>
<ApproxNote /> <ApproxNote />
<svg width="100%" viewBox={`0 0 ${width} ${height}`} style={{ maxWidth: '100%' }}> <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 'var(--space-3)', marginTop: 'var(--space-2)' }}>
<rect x={margin.left} y={margin.top} width={innerWidth} height={bodyHeight} fill="var(--cv-paper-2)" rx={4} /> {SEX_PANELS.map(({ sex, label }) => (
<SexPanel key={sex} label={label} rows={data.filter((d) => d.sex === sex)} maxRate={maxRate} />
{[0, 0.25, 0.5, 0.75, 1].map((frac) => { ))}
const val = frac * maxRate </div>
const x = xScale(val)
return (
<g key={`xg-${frac}`}>
<line x1={x} y1={margin.top} x2={x} y2={margin.top + bodyHeight} stroke="var(--cv-rule)" strokeWidth={1} />
<text x={x} y={margin.top + bodyHeight + 14} textAnchor="middle" fontSize="0.65rem" fill="var(--cv-ink-3)">
{val.toFixed(1)}
</text>
</g>
)
})}
{bySex.map((group) => {
const subheaderY = cursorY
cursorY += SUBHEADER_HEIGHT
const rowEls = group.rows.map((d) => {
const rowY = cursorY
cursorY += ROW_HEIGHT
const color = raceColor(d.race)
const box = buildBox(d)
const midY = rowY + ROW_HEIGHT / 2
const boxTop = midY - ROW_HEIGHT * 0.28
const boxBottom = midY + ROW_HEIGHT * 0.28
const observedX = xScale(d.observedRate)
return (
<g key={`${d.race}-${d.sex}`}>
{/* Whisker: the API's reported 90% interval */}
<line x1={xScale(box.lower)} y1={midY} x2={xScale(box.upper)} y2={midY} stroke={color} strokeWidth={1.5} />
<line x1={xScale(box.lower)} y1={boxTop} x2={xScale(box.lower)} y2={boxBottom} stroke={color} strokeWidth={1.5} />
<line x1={xScale(box.upper)} y1={boxTop} x2={xScale(box.upper)} y2={boxBottom} stroke={color} strokeWidth={1.5} />
{/* Box: fitted 25th-75th percentile */}
<rect
x={xScale(box.q1)} y={boxTop} width={Math.max(xScale(box.q3) - xScale(box.q1), 1)} height={boxBottom - boxTop}
fill={color} fillOpacity={0.55} stroke={color} strokeWidth={1} rx={1.5}
/>
{/* Median tick */}
<line x1={xScale(box.median)} y1={boxTop} x2={xScale(box.median)} y2={boxBottom} stroke="#fff" strokeWidth={1.5} />
{/* Observed diamond */}
<rect
x={observedX - 4} y={midY - 4} width={8} height={8}
fill={OBSERVED_MARK_COLOR} stroke="#fff" strokeWidth={1}
transform={`rotate(45 ${observedX} ${midY})`}
/>
<text x={margin.left - 8} y={midY + 4} textAnchor="end" fontSize="0.7rem" fill="var(--cv-ink)">
{SHORT_RACE_LABEL[d.race] || d.race}
</text>
</g>
)
})
return (
<g key={group.sex}>
<text x={margin.left} y={subheaderY + 14} fontSize="0.72rem" fontWeight={700} fill="var(--cv-ink-2)">
{group.label}
</text>
{rowEls}
</g>
)
})}
<text x={margin.left + innerWidth / 2} y={height - 8} textAnchor="middle" fontSize="0.7rem" fill="var(--cv-ink-3)">
Rate per 1,000 students
</text>
</svg>
<ChartLegend items={[ <ChartLegend items={[
...RACE_ORDER.filter((race) => data.some((d) => d.race === race)).map((race) => ({ ...RACE_ORDER.filter((race) => data.some((d) => d.race === race)).map((race) => ({
@@ -147,3 +60,73 @@ export default function RateByGroupBar({ data }) {
</div> </div>
) )
} }
function SexPanel({ label, rows, maxRate }) {
const width = 300
const margin = { top: 30, right: 16, bottom: 34, left: 66 }
const innerWidth = width - margin.left - margin.right
const byRace = RACE_ORDER.map((race) => rows.find((d) => d.race === race)).filter(Boolean)
const bodyHeight = byRace.length * ROW_HEIGHT
const height = margin.top + bodyHeight + margin.bottom
const xScale = (val) => margin.left + (val / maxRate) * innerWidth
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={16} textAnchor="middle" fontSize="0.78rem" fontWeight={700} fill="var(--cv-ink-2)">
{label}
</text>
<rect x={margin.left} y={margin.top} width={innerWidth} height={bodyHeight} fill="var(--cv-paper-2)" rx={4} />
{[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 + bodyHeight} stroke="var(--cv-rule)" strokeWidth={1} />
<text x={x} y={margin.top + bodyHeight + 14} textAnchor="middle" fontSize="0.6rem" fill="var(--cv-ink-3)">
{val.toFixed(1)}
</text>
</g>
)
})}
{byRace.map((d, i) => {
const rowY = margin.top + i * ROW_HEIGHT
const midY = rowY + ROW_HEIGHT / 2
const boxTop = midY - ROW_HEIGHT * 0.26
const boxBottom = midY + ROW_HEIGHT * 0.26
const color = raceColor(d.race)
const box = buildBox(d)
const observedX = xScale(d.observedRate)
return (
<g key={d.race}>
<line x1={xScale(box.lower)} y1={midY} x2={xScale(box.upper)} y2={midY} stroke={color} strokeWidth={1.5} />
<line x1={xScale(box.lower)} y1={boxTop} x2={xScale(box.lower)} y2={boxBottom} stroke={color} strokeWidth={1.5} />
<line x1={xScale(box.upper)} y1={boxTop} x2={xScale(box.upper)} y2={boxBottom} stroke={color} strokeWidth={1.5} />
<rect
x={xScale(box.q1)} y={boxTop} width={Math.max(xScale(box.q3) - xScale(box.q1), 1)} height={boxBottom - boxTop}
fill={color} fillOpacity={0.55} stroke={color} strokeWidth={1} rx={1.5}
/>
<line x1={xScale(box.median)} y1={boxTop} x2={xScale(box.median)} y2={boxBottom} stroke="#fff" strokeWidth={1.5} />
<rect
x={observedX - 4} y={midY - 4} width={8} height={8}
fill={OBSERVED_MARK_COLOR} stroke="#fff" strokeWidth={1}
transform={`rotate(45 ${observedX} ${midY})`}
/>
<text x={margin.left - 8} y={midY + 4} textAnchor="end" fontSize="0.72rem" fill="var(--cv-ink)">
{SHORT_RACE_LABEL[d.race] || d.race}
</text>
</g>
)
})}
<text x={margin.left + innerWidth / 2} y={height - 6} textAnchor="middle" fontSize="0.62rem" fill="var(--cv-ink-3)">
Rate per 1,000 students
</text>
</svg>
</div>
)
}
+112 -103
View File
@@ -1,3 +1,4 @@
import { useState } from 'react'
import { MODEL_QUADRANTS } from '../hooks/useApi.js' import { MODEL_QUADRANTS } from '../hooks/useApi.js'
import { raceColor, OBSERVED_MARK_COLOR, RACE_LABELS, SHORT_RACE_LABEL } from '../utils/colors.js' import { raceColor, OBSERVED_MARK_COLOR, RACE_LABELS, SHORT_RACE_LABEL } from '../utils/colors.js'
import { fitSkewedInterval, densityCurve } from '../utils/distributionApprox.js' import { fitSkewedInterval, densityCurve } from '../utils/distributionApprox.js'
@@ -5,15 +6,16 @@ import ChartLegend from '../components/ChartLegend.jsx'
import ApproxNote from '../components/ApproxNote.jsx' import ApproxNote from '../components/ApproxNote.jsx'
/** /**
* Chart 5: Modeled posterior density per race×sex group, faceted 2×2 by * Modeled posterior density per race×sex group, for one selected model
* model quadrant (matches whitepaper-fig-clark-density-1.png). Plain SVG — * (dropdown, default three-year + referral rate), split into Female/Male
* no D3, no fake normal draws: each ridge is drawn as 60 analytic points * columns — matches whitepaper-fig-clark-density-1.png's ridge style. Plain
* from fitSkewedInterval/densityCurve, which is already smooth without a * SVG — each ridge is 60 analytic points from fitSkewedInterval/densityCurve,
* binning/curveBasis smoothing pass. * already smooth without a binning/curveBasis smoothing pass.
*/ */
const RACE_ORDER = ['WH', 'BL', 'HI', 'AM'] const RACE_ORDER = ['WH', 'BL', 'HI', 'AM']
const SEX_ORDER = ['F', 'M'] 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) {
const enroll = row.stu_enroll || 0 const enroll = row.stu_enroll || 0
@@ -30,133 +32,140 @@ function buildGroupRow(row) {
} }
export default function RateDensityRidgeline({ quadData }) { export default function RateDensityRidgeline({ quadData }) {
const hasData = quadData && Object.values(quadData).some((rows) => (rows || []).length > 0) const [selectedModel, setSelectedModel] = useState(DEFAULT_MODEL)
const rows = (quadData && quadData[selectedModel]) || []
if (!hasData) { const modelSelect = (
return ( <select
<div className="cv-card" style={{ padding: 'var(--space-2)' }}> value={selectedModel}
<h3 style={{ fontSize: '0.85rem', marginBottom: 'var(--space-1)', color: 'var(--cv-ink-2)' }}> onChange={(e) => setSelectedModel(e.target.value)}
Predicted arrest rates by student group style={{ padding: '0.25rem 0.5rem', fontFamily: 'var(--font-sans)', fontSize: '0.8rem' }}
</h3> >
<p style={{ color: 'var(--cv-ink-3)' }}>No model data available.</p> {MODEL_QUADRANTS.map((q) => (
</div> <option key={q.model} value={q.model}>{q.label}</option>
) ))}
} </select>
)
// 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 ( return (
<div className="cv-card" style={{ padding: 'var(--space-2)' }}> <div className="cv-card" style={{ padding: 'var(--space-2)' }}>
<h3 style={{ fontSize: '0.85rem', marginBottom: 'var(--space-1)', color: 'var(--cv-ink-2)' }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', flexWrap: 'wrap', gap: 'var(--space-2)' }}>
Predicted arrest rates by student group <div>
</h3> <h3 style={{ fontSize: '0.85rem', marginBottom: 'var(--space-1)', color: 'var(--cv-ink-2)' }}>
<ApproxNote /> Predicted arrest rates by student group
</h3>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 'var(--space-2)', marginTop: 'var(--space-1)' }}> <ApproxNote />
{MODEL_QUADRANTS.map((q) => ( </div>
<RidgePanel key={q.model} q={q} rows={quadData[q.model] || []} maxRate={maxRate} /> {modelSelect}
))}
</div> </div>
<ChartLegend items={[ {rows.length === 0 ? (
...RACE_ORDER.map((race) => ({ shape: 'swatch', color: raceColor(race), label: RACE_LABELS[race] })), <p style={{ color: 'var(--cv-ink-3)', marginTop: 'var(--space-2)' }}>No model data available.</p>
{ shape: 'diamond', color: OBSERVED_MARK_COLOR, label: 'Observed' }, ) : (
]} /> <>
<RidgeColumns rows={rows} />
<ChartLegend items={[
...RACE_ORDER.map((race) => ({ shape: 'swatch', color: raceColor(race), label: RACE_LABELS[race] })),
{ shape: 'diamond', color: OBSERVED_MARK_COLOR, label: 'Observed' },
]} />
</>
)}
</div> </div>
) )
} }
function RidgePanel({ q, rows, maxRate }) { function RidgeColumns({ rows }) {
const groups = [] // Shared x-domain across both columns, so Female/Male are directly comparable.
SEX_ORDER.forEach((sex) => { const allUpper = rows.map((r) => (r.rate_upper || 0) * 1000)
RACE_ORDER.forEach((race) => { const allObserved = rows
const row = rows.find((r) => r.race === race && r.sex === sex) .filter((r) => (r.stu_enroll || 0) > 0)
if (row) groups.push(buildGroupRow(row)) .map((r) => ((r.observed_arrests || 0) / r.stu_enroll) * 1000)
}) const maxRate = Math.min(Math.max(...allUpper, ...allObserved, 1) * 1.15, 30)
})
if (groups.length === 0) { return (
return ( <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 'var(--space-3)', marginTop: 'var(--space-2)' }}>
<div style={{ border: '1px solid var(--cv-rule)', borderRadius: 'var(--radius-md)', padding: 'var(--space-2)' }}> {SEX_COLUMNS.map(({ sex, label }) => (
<p style={{ fontSize: '0.65rem', color: 'var(--cv-ink-3)' }}>{q.label} — no data for the selected groups</p> <SexRidgeColumn key={sex} label={label} rows={rows.filter((r) => r.sex === sex)} maxRate={maxRate} />
</div> ))}
) </div>
} )
}
function SexRidgeColumn({ label, rows, maxRate }) {
const groups = RACE_ORDER.map((race) => rows.find((r) => r.race === race)).filter(Boolean).map(buildGroupRow)
const width = 300 const width = 300
const rowHeight = 46 const rowHeight = 58
const margin = { top: 24, right: 16, bottom: 26, left: 56 } const margin = { top: 26, right: 16, bottom: 26, left: 56 }
const innerWidth = width - margin.left - margin.right const innerWidth = width - margin.left - margin.right
const height = margin.top + groups.length * rowHeight + margin.bottom const height = margin.top + Math.max(groups.length, 1) * rowHeight + margin.bottom
const xScale = (val) => margin.left + (val / maxRate) * innerWidth 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) => densityCurve(g.fit, { min: 0, max: maxRate, n: 60 }))
const maxPdf = Math.max(...curves.flatMap((c) => c.map((p) => p.y)), 1e-9) const maxPdf = Math.max(...curves.flatMap((c) => c.map((p) => p.y)), 1e-9)
const peakHeight = rowHeight * 0.85 const peakHeight = rowHeight * 0.82
return ( return (
<div style={{ border: '1px solid var(--cv-rule)', borderRadius: 'var(--radius-md)', padding: 'var(--space-1)' }}> <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%' }}> <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)"> <text x={width / 2} y={16} textAnchor="middle" fontSize="0.78rem" fontWeight={700} fill="var(--cv-ink-2)">
{q.label} {label}
</text> </text>
{[0, 0.25, 0.5, 0.75, 1].map((frac) => { {groups.length === 0 ? (
const val = frac * maxRate <text x={width / 2} y={height / 2} textAnchor="middle" fontSize="0.65rem" fill="var(--cv-ink-3)">
const x = xScale(val) No data for this group
return ( </text>
<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)"> {[0, 0.25, 0.5, 0.75, 1].map((frac) => {
{val.toFixed(0)} const val = frac * maxRate
</text> const x = xScale(val)
</g> 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.58rem" fill="var(--cv-ink-3)">
{val.toFixed(0)}
</text>
</g>
)
})}
{groups.map((g, i) => { {groups.map((g, i) => {
const rowTop = margin.top + i * rowHeight const rowTop = margin.top + i * rowHeight
const baselineY = rowTop + rowHeight * 0.9 const baselineY = rowTop + rowHeight * 0.9
const curve = curves[i] const curve = curves[i]
const color = raceColor(g.race) const color = raceColor(g.race)
const topPath = curve const topPath = curve
.map((p, j) => `${j === 0 ? 'M' : 'L'}${xScale(p.x)},${baselineY - (p.y / maxPdf) * peakHeight}`) .map((p, j) => `${j === 0 ? 'M' : 'L'}${xScale(p.x)},${baselineY - (p.y / maxPdf) * peakHeight}`)
.join(' ') .join(' ')
const areaPath = `${topPath} L${xScale(maxRate)},${baselineY} L${xScale(0)},${baselineY} Z` const areaPath = `${topPath} L${xScale(maxRate)},${baselineY} L${xScale(0)},${baselineY} Z`
const obsX = xScale(Math.min(g.observedRate, maxRate)) const obsX = xScale(Math.min(g.observedRate, maxRate))
const obsY = baselineY - peakHeight * 0.15 const obsY = baselineY - peakHeight * 0.15
return ( return (
<g key={`${g.race}-${g.sex}`}> <g key={g.race}>
<text x={margin.left - 8} y={rowTop + rowHeight / 2 + 4} textAnchor="end" fontSize="0.6rem" fill="var(--cv-ink)"> <text x={margin.left - 8} y={rowTop + rowHeight / 2 + 4} textAnchor="end" fontSize="0.68rem" fill="var(--cv-ink)">
{SHORT_RACE_LABEL[g.race] || g.race} {g.sex} {SHORT_RACE_LABEL[g.race] || g.race}
</text> </text>
<path d={areaPath} fill={color} opacity={0.6} stroke="#fff" strokeWidth={0.5} /> <path d={areaPath} fill={color} opacity={0.6} stroke="#fff" strokeWidth={0.5} />
<rect <rect
x={obsX - 4} y={obsY - 4} width={8} height={8} x={obsX - 4} y={obsY - 4} width={8} height={8}
fill={OBSERVED_MARK_COLOR} stroke="#fff" strokeWidth={1} fill={OBSERVED_MARK_COLOR} stroke="#fff" strokeWidth={1}
transform={`rotate(45 ${obsX} ${obsY})`} transform={`rotate(45 ${obsX} ${obsY})`}
/> />
</g> </g>
) )
})} })}
<text x={width / 2} y={height - 6} textAnchor="middle" fontSize="0.55rem" fill="var(--cv-ink-3)"> <text x={width / 2} y={height - 6} textAnchor="middle" fontSize="0.58rem" fill="var(--cv-ink-3)">
Arrests per 1,000 students Arrests per 1,000 students
</text> </text>
</>
)}
</svg> </svg>
</div> </div>
) )
+38 -87
View File
@@ -1,49 +1,34 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import * as api from '../hooks/useApi.js' import * as api from '../hooks/useApi.js'
import useGroupFilter, { filterRowsByGroup } from '../hooks/useGroupFilter.js'
import GroupFilterBar from './GroupFilterBar.jsx'
import { groupLabel } from '../utils/colors.js' import { groupLabel } from '../utils/colors.js'
import ArrestsOverTime from '../charts/ArrestsOverTime.jsx' import ArrestsOverTime from '../charts/ArrestsOverTime.jsx'
import RateByGroupBar from '../charts/RateByGroupBar.jsx' import RateByGroupBar from '../charts/RateByGroupBar.jsx'
import DistrictVsNational from '../charts/DistrictVsNational.jsx'
import ModelDrawsComparison from '../charts/ModelDrawsComparison.jsx'
import RateDensityRidgeline from '../charts/RateDensityRidgeline.jsx' import RateDensityRidgeline from '../charts/RateDensityRidgeline.jsx'
import ExceedanceProbability from '../charts/ExceedanceProbability.jsx'
const ALL_WAVES = ['15-16', '17-18', '21-22'] const ALL_WAVES = ['15-16', '17-18', '21-22']
const QUADRANT_MODELS = ['unified_m1_mod', 'unified_m2_mod', 'unified_m3_mod', 'unified_m4_mod']
/** /**
* ChartPanel — 6 charts in a single-column layout, all scoped by one shared * ChartPanel — 3 charts: arrests over time (observed vs. modeled), arrest
* student-group filter (defaults to all 8 groups selected). * rate by student group (Female/Male panels), and the model-selectable
* posterior density ridge chart.
*/ */
export default function ChartPanel({ district, state }) { export default function ChartPanel({ district, state }) {
const [data, setData] = useState(null) // all fetched estimate rows keyed by year/model/group const [data, setData] = useState(null) // all fetched estimate rows keyed by year/model
const [nationalRates, setNationalRates] = useState(null)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const filter = useGroupFilter()
useEffect(() => { useEffect(() => {
async function fetchData() { async function fetchData() {
try { try {
// National rates from static fixture (or cache from loading step) // Chart 1 + 2: all 3 waves × three-year model (unified_m3_mod)
let nat = window.__NATIONAL_RATES__
if (!nat) {
const res = await fetch(import.meta.env.BASE_URL + 'data/national_rates.json')
nat = await res.json()
}
setNationalRates(nat)
// Fetch all 3 waves × three-year model (unified_m3_mod) for Chart 1 + Charts 2-3
const waves = ['21-22', '17-18', '15-16']
const waveData = {} const waveData = {}
await Promise.all(waves.map(async (year) => { await Promise.all(ALL_WAVES.map(async (year) => {
try { waveData[year] = await api.fetchDistrictEstimates(district.leaid, { model: 'unified_m3_mod', year }) } catch (e) {} try { waveData[year] = await api.fetchDistrictEstimates(district.leaid, { model: 'unified_m3_mod', year }) } catch (e) {}
})) }))
// Fetch all 4 quadrant models for Charts 4-6 (8 groups × 4 models, batched by model) // Chart 3: all 4 quadrant models, so the dropdown can switch between them
const quadrants = ['unified_m1_mod', 'unified_m2_mod', 'unified_m3_mod', 'unified_m4_mod']
const quadData = {} const quadData = {}
await Promise.all(quadrants.map(async (model) => { await Promise.all(QUADRANT_MODELS.map(async (model) => {
try { quadData[model] = await api.fetchDistrictEstimates(district.leaid, { model, year: '21-22' }) } catch (e) {} try { quadData[model] = await api.fetchDistrictEstimates(district.leaid, { model, year: '21-22' }) } catch (e) {}
})) }))
@@ -62,12 +47,11 @@ export default function ChartPanel({ district, state }) {
return <LoadingCharts /> return <LoadingCharts />
} }
// ——— Every derived dataset below is scoped to the selected student groups ——— const mostRecent = data.waveData['21-22'] || []
const mostRecent = filterRowsByGroup(data.waveData['21-22'] || [], filter)
// Chart 1: Arrests over time by wave — observed total + modeled (three-year model) point-range // Chart 1: Arrests over time by wave — observed total + modeled (three-year model) point-range
const timeSeriesData = ALL_WAVES.map((year) => { const timeSeriesData = ALL_WAVES.map((year) => {
const yearRows = filterRowsByGroup(data.waveData[year] || [], filter) const yearRows = data.waveData[year] || []
return { return {
year, year,
label: `20${year.replace('-', '-')}`, label: `20${year.replace('-', '-')}`,
@@ -79,83 +63,50 @@ export default function ChartPanel({ district, state }) {
} }
}) })
// Charts 2-3: rate by student group (most recent wave, per 1k), with modeled + observed + interval // Chart 2: rate by student group (most recent wave, per 1k), with modeled + observed + interval
const rateByGroup = mostRecent const rateByGroup = mostRecent.map((r) => ({
.map((r) => ({ race: r.race, sex: r.sex, label: groupLabel(r.race, r.sex),
race: r.race, sex: r.sex, label: groupLabel(r.race, r.sex), observedRate: (r.observed_arrests || 0) / ((r.stu_enroll || 1) / 1000),
observedRate: (r.observed_arrests || 0) / ((r.stu_enroll || 1) / 1000), modeledMedian: (r.count_median || 0) / ((r.stu_enroll || 1) / 1000),
modeledMedian: (r.count_median || 0) / ((r.stu_enroll || 1) / 1000), rateLower: (r.rate_lower || 0) * 1000,
rateLower: (r.rate_lower || 0) * 1000, rateUpper: (r.rate_upper || 0) * 1000,
rateUpper: (r.rate_upper || 0) * 1000, observedArrests: r.observed_arrests || 0,
observedArrests: r.observed_arrests || 0, enrollment: r.stu_enroll || 0,
enrollment: r.stu_enroll || 0, }))
}))
.sort((a, b) => b.observedRate - a.observedRate)
const topGroup = rateByGroup[0] || null
const natKey = topGroup ? `${topGroup.race}_${topGroup.sex}` : null
const nationalTopRate = (natKey && nationalRates?.rates_per_1000?.[natKey]) || 0
// Charts 4-6: all 4 quadrant models, each filtered to the selected groups
const quadDataFiltered = Object.fromEntries(
Object.entries(data.quadData || {}).map(([model, rows]) => [model, filterRowsByGroup(rows, filter)])
)
const waveDataFiltered = Object.fromEntries(
ALL_WAVES.map((year) => [year, filterRowsByGroup(data.waveData[year] || [], filter)])
)
return ( return (
<div style={{ padding: 'var(--space-3) 0 var(--space-7)' }}> <div style={{ padding: 'var(--space-3) 0 var(--space-7)' }}>
{/* Chart panel header */} {/* Chart panel header */}
<div style={{ marginBottom: 'var(--space-3)' }}> <div style={{ marginBottom: 'var(--space-4)' }}>
<span className="eyebrow">District estimates — Bayesian model comparison</span> <span className="eyebrow">District estimates — Bayesian model comparison</span>
<h2 style={{ marginTop: 'var(--space-1)', marginBottom: 0 }}> <h2 style={{ marginTop: 'var(--space-1)', marginBottom: 0 }}>
{district.lea_name} ({state}) — School-based arrest rates, 2021–22 CRDC {district.lea_name} ({state}) — School-based arrest rates, 2021–22 CRDC
</h2> </h2>
</div> </div>
<div style={{ maxWidth: '70rem', marginLeft: 'auto', marginRight: 'auto' }}> <div style={{
<GroupFilterBar filter={filter} /> display: 'flex',
flexDirection: 'column',
gap: 'var(--space-5)',
maxWidth: '70rem',
marginLeft: 'auto',
marginRight: 'auto'
}}>
<ArrestsOverTime data={timeSeriesData} districtName={district.lea_name} />
<RateByGroupBar data={rateByGroup} />
<RateDensityRidgeline quadData={data.quadData} />
</div> </div>
{filter.selected.size === 0 ? (
<p style={{ color: 'var(--cv-ink-3)', maxWidth: '70rem', margin: '0 auto' }}>
Select at least one student group above to see charts.
</p>
) : (
<div style={{
display: 'flex',
flexDirection: 'column',
gap: 'var(--space-5)',
maxWidth: '70rem',
marginLeft: 'auto',
marginRight: 'auto'
}}>
{/* Charts 1–3: Observed & descriptive */}
<ArrestsOverTime data={timeSeriesData} districtName={district.lea_name} />
<RateByGroupBar data={rateByGroup} />
{topGroup && (
<DistrictVsNational topGroup={topGroup} nationalRate={nationalTopRate} rateByGroup={rateByGroup} />
)}
{/* Charts 4–6: Bayesian model distributions */}
<ModelDrawsComparison data={{ waves: waveDataFiltered, quadData: quadDataFiltered }} districtName={district.lea_name} />
<RateDensityRidgeline quadData={quadDataFiltered} />
<ExceedanceProbability quadData={quadDataFiltered} nationalRates={nationalRates?.rates_per_1000 || {}} />
</div>
)}
{/* Methodology footer */} {/* Methodology footer */}
<div style={{ marginTop: 'var(--space-6)', padding: 'var(--space-3) 0', borderTop: '1px solid var(--cv-rule)' }}> <div style={{ marginTop: 'var(--space-6)', padding: 'var(--space-3) 0', borderTop: '1px solid var(--cv-rule)' }}>
<span className="eyebrow">Methodology</span> <span className="eyebrow">Methodology</span>
<p style={{ fontSize: '0.85rem', color: 'var(--cv-ink-2)', marginTop: 'var(--space-1)' }}> <p style={{ fontSize: '0.85rem', color: 'var(--cv-ink-2)', marginTop: 'var(--space-1)' }}>
Estimates are from the CRDC School Arrest Rate API (Knowles & Miller 2025). Data shown spans three waves of Estimates are from the CRDC School Arrest Rate API (Knowles & Miller 2025). Data shown spans three waves of
the Civil Rights Data Collection (2015–16, 2017–18, 2021–22) and compares four Bayesian model specifications: the Civil Rights Data Collection (2015–16, 2017–18, 2021–22) and lets you explore four Bayesian model
one-year vs. three-year models with and without referral-rate covariates. All rates are per 1,000 students. specifications: one-year vs. three-year models with and without referral-rate covariates. All rates are
per 1,000 students.
</p> </p>
</div> </div>
</div> </div>
-66
View File
@@ -1,66 +0,0 @@
import { raceColor, RACE_LABELS } from '../utils/colors.js'
const RACE_ORDER = ['WH', 'BL', 'HI', 'AM']
const SEX_ROWS = [
{ sex: 'F', label: 'Female' },
{ sex: 'M', label: 'Male' },
]
/**
* Student-group filter: two rows (Female/Male) of 4 race-colored toggle
* chips. Sex is row position, not color — race owns the color channel, and
* a chip's color never changes when siblings are toggled.
*/
export default function GroupFilterBar({ filter }) {
return (
<div style={{ margin: '0 0 var(--space-4)' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 'var(--space-1)' }}>
<span className="eyebrow" style={{ marginBottom: 0 }}>Student groups</span>
<div style={{ display: 'flex', gap: '0.5rem' }}>
<button type="button" className="btn-outline" onClick={filter.selectAll} disabled={filter.allSelected}>
Select all
</button>
<button type="button" className="btn-outline" onClick={filter.selectNone} disabled={filter.selected.size === 0}>
Clear
</button>
</div>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem', marginTop: 'var(--space-1)' }}>
{SEX_ROWS.map(({ sex, label }) => (
<div key={sex} style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', flexWrap: 'wrap' }}>
<span style={{ fontSize: '0.8rem', color: 'var(--cv-ink-3)', width: '3.5rem', flexShrink: 0 }}>{label}</span>
{RACE_ORDER.map((race) => {
const active = filter.isSelected(race, sex)
const color = raceColor(race)
return (
<button
key={race}
type="button"
onClick={() => filter.toggle(race, sex)}
aria-pressed={active}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '0.4rem',
padding: '0.35rem 0.75rem',
borderRadius: 'var(--radius-md)',
border: `1.5px solid ${color}`,
background: active ? color : 'transparent',
color: active ? '#fff' : 'var(--cv-ink-2)',
fontSize: '0.8rem',
fontWeight: 600,
cursor: 'pointer',
transition: 'background 120ms ease, color 120ms ease',
}}
>
{RACE_LABELS[race]}
</button>
)
})}
</div>
))}
</div>
</div>
)
}
+15 -37
View File
@@ -1,26 +1,8 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import ChartPanel from './ChartPanel.jsx' import ChartPanel from './ChartPanel.jsx'
import * as api from '../hooks/useApi.js' import * as api from '../hooks/useApi.js'
import { STUDENT_GROUPS, MODEL_QUADRANTS } from '../hooks/useApi.js'
// The 8 student groups we fetch for (race×sex combinations) import { shortGroupLabel } from '../utils/colors.js'
const STUDENT_GROUPS = [
{ race: 'WH', sex: 'F', label: 'White F' },
{ race: 'WH', sex: 'M', label: 'White M' },
{ race: 'BL', sex: 'F', label: 'Black F' },
{ race: 'BL', sex: 'M', label: 'Black M' },
{ race: 'HI', sex: 'F', label: 'Hispanic F' },
{ race: 'HI', sex: 'M', label: 'Hispanic M' },
{ race: 'AM', sex: 'F', label: 'AI/AN F' },
{ race: 'AM', sex: 'M', label: 'AI/AN M' },
]
// The "four quadrants" of models for distribution charts (Charts 4–6)
const MODEL_QUADRANTS = [
{ model: 'unified_m1_mod', label: 'One-year, baseline', col: 0, row: 0 },
{ model: 'unified_m2_mod', label: 'One-year + covariate', col: 0, row: 1 },
{ model: 'unified_m3_mod', label: 'Three-year, baseline', col: 1, row: 0 },
{ model: 'unified_m4_mod', label: 'Three-year + covariate', col: 1, row: 1 },
]
// CRDC waves to fetch (for Chart 1 — time series) // CRDC waves to fetch (for Chart 1 — time series)
const WAVES = ['21-22', '17-18', '15-16'] const WAVES = ['21-22', '17-18', '15-16']
@@ -35,11 +17,9 @@ export default function LoadingAnimation({ district, state }) {
const [totalCalls, setTotalCalls] = useState(0) const [totalCalls, setTotalCalls] = useState(0)
const [error, setError] = useState(null) const [error, setError] = useState(null)
// Build the full list of API calls needed for all 6 charts // Build the full list of API calls needed for all 3 charts
useEffect(() => { useEffect(() => {
let cancelled = false let cancelled = false
let count = 0
let total = 0
async function loadData() { async function loadData() {
try { try {
@@ -50,14 +30,14 @@ export default function LoadingAnimation({ district, state }) {
.catch(() => { /* individual failure doesn't block */ }) .catch(() => { /* individual failure doesn't block */ })
) )
// ——— Charts 2 & 3: Rate by group (8 groups × default model, most recent wave) ——— // ——— Chart 2: Rate by group (8 groups × default model, most recent wave) ———
const groupPromises = STUDENT_GROUPS.map((sg) => const groupPromises = STUDENT_GROUPS.map((sg) =>
fetchDistrictEstimatesBatch(district.leaid, '21-22', sg.race, sg.sex) fetchDistrictEstimatesBatch(district.leaid, '21-22', sg.race, sg.sex)
.then(() => !cancelled && setLoadedCount(c => c + 1)) .then(() => !cancelled && setLoadedCount(c => c + 1))
.catch(() => {}) .catch(() => {})
) )
// ——— Charts 4–6: Model comparison (8 groups × 4 quadrant models = 32 calls) ——— // ——— Chart 3: posterior density, all 4 quadrant models so the dropdown can switch (8 groups × 4 = 32 calls) ———
const modelPromises = MODEL_QUADRANTS.flatMap((quad) => const modelPromises = MODEL_QUADRANTS.flatMap((quad) =>
STUDENT_GROUPS.map((sg) => STUDENT_GROUPS.map((sg) =>
fetchDistrictEstimatesBatch(district.leaid, '21-22', sg.race, sg.sex, quad.model) fetchDistrictEstimatesBatch(district.leaid, '21-22', sg.race, sg.sex, quad.model)
@@ -66,22 +46,16 @@ export default function LoadingAnimation({ district, state }) {
) )
) )
// ——— National rates (from static fixture, instant) ———
const nationalPromise = fetch(import.meta.env.BASE_URL + 'data/national_rates.json')
.then(r => r.json())
.then(data => { /* cache for ChartPanel */ window.__NATIONAL_RATES__ = data })
.catch(() => {})
// Set total before starting (for progress bar) // Set total before starting (for progress bar)
const allPromises = [...wavePromises, ...groupPromises, ...modelPromises] const allPromises = [...wavePromises, ...groupPromises, ...modelPromises]
if (!cancelled) setTotalCalls(allPromises.length + 1 /* national */) if (!cancelled) setTotalCalls(allPromises.length)
await Promise.all([...allPromises, nationalPromise]) await Promise.all(allPromises)
if (!cancelled) { if (!cancelled) {
// All data loaded — ChartPanel renders in place of this component // All data loaded — ChartPanel renders in place of this component
// We use a render prop pattern: return <ChartPanel /> when done // We use a render prop pattern: return <ChartPanel /> when done
setLoadedCount(allPromises.length + 1) setLoadedCount(allPromises.length)
} }
} catch (err) { } catch (err) {
if (!cancelled) setError(err.message || 'Failed to load data') if (!cancelled) setError(err.message || 'Failed to load data')
@@ -110,10 +84,14 @@ export default function LoadingAnimation({ district, state }) {
// Build bar metadata for rendering // Build bar metadata for rendering
const allBars = Array.from({ length: TOTAL_BARS }, (_, i) => { const allBars = Array.from({ length: TOTAL_BARS }, (_, i) => {
if (i < WAVES.length) return { subLabel: 'Time series', group: 'waves' } if (i < WAVES.length) return { subLabel: 'Time series', group: 'waves' }
if (i < WAVES.length + STUDENT_GROUPS.length) return { subLabel: STUDENT_GROUPS[i - WAVES.length].label, group: 'groups' } if (i < WAVES.length + STUDENT_GROUPS.length) {
const sg = STUDENT_GROUPS[i - WAVES.length]
return { subLabel: shortGroupLabel(sg.race, sg.sex), group: 'groups' }
}
const quadIdx = Math.floor((i - WAVES.length - STUDENT_GROUPS.length) / STUDENT_GROUPS.length) const quadIdx = Math.floor((i - WAVES.length - STUDENT_GROUPS.length) / STUDENT_GROUPS.length)
const sgIdx = (i - WAVES.length - STUDENT_GROUPS.length) % STUDENT_GROUPS.length const sgIdx = (i - WAVES.length - STUDENT_GROUPS.length) % STUDENT_GROUPS.length
return { subLabel: STUDENT_GROUPS[sgIdx].label, group: 'models', modelIdx: quadIdx } const sg = STUDENT_GROUPS[sgIdx]
return { subLabel: shortGroupLabel(sg.race, sg.sex), group: 'models', modelIdx: quadIdx }
}) })
return ( return (
@@ -142,7 +120,7 @@ export default function LoadingAnimation({ district, state }) {
marginTop: 'var(--space-4)' marginTop: 'var(--space-4)'
}}> }}>
{/* Column headers */} {/* Column headers */}
{['Time series (Chart 1)', 'Observed rates (Charts 2–3)', 'Model comparisons (Charts 4–6)'].map((h, i) => ( {['Time series (Chart 1)', 'By student group (Chart 2)', 'Posterior density (Chart 3)'].map((h, i) => (
<div key={i} style={{ textAlign: 'center', paddingBottom: 'var(--space-1)' }}> <div key={i} style={{ textAlign: 'center', paddingBottom: 'var(--space-1)' }}>
<span className="stat-label" style={{ display: 'block' }}>{h}</span> <span className="stat-label" style={{ display: 'block' }}>{h}</span>
</div> </div>
-44
View File
@@ -1,44 +0,0 @@
{
"metadata": {
"source": "CRDC School Arrest Rate API / Knowles & Miller 2025, White Paper §3 Data",
"year": "21-22",
"description": "National arrest rates per 1,000 students for each race×sex group in the 2021-22 CRDC. Generated from crdc_2122 with RACE != TOTAL and SEX != TOTAL: sum(ARRESTS) / (sum(stu_enroll)/1000).",
"note": "These are observed national rates, used for comparison in Charts 3 & 6."
},
"rates_per_1000": {
"WH_F": 0.34,
"WH_M": 0.67,
"BL_F": 1.25,
"BL_M": 1.98,
"HI_F": 0.42,
"HI_M": 0.88,
"AM_F": 0.91,
"AM_M": 1.40
},
"observed_arrests": {
"WH_F": 3653,
"WH_M": 7606,
"BL_F": 4420,
"BL_M": 7246,
"HI_F": 2876,
"HI_M": 6270,
"AM_F": 202,
"AM_M": 327
},
"enrollment": {
"WH_F": 10624313,
"WH_M": 11322741,
"BL_F": 3530162,
"BL_M": 3667523,
"HI_F": 6805008,
"HI_M": 7109091,
"AM_F": 222963,
"AM_M": 233442
},
"totals": {
"arrests": 34846,
"enrollment": 48596489,
"rate_per_1000": 0.72,
"districts_reporting_arrests_pct": 11.6
}
}
-36
View File
@@ -1,36 +0,0 @@
import { useState, useCallback, useMemo } from 'react'
import { STUDENT_GROUPS } from './useApi.js'
import { groupKey } from '../utils/groupKey.js'
/**
* Shared student-group filter — defaults to all 8 groups selected. Lives
* once in ChartPanel and threads down to every chart, so "select which
* groups to focus on" behaves consistently everywhere instead of being
* reimplemented per chart.
*/
export default function useGroupFilter(groups = STUDENT_GROUPS) {
const allKeys = useMemo(() => groups.map((g) => groupKey(g.race, g.sex)), [groups])
const [selected, setSelected] = useState(() => new Set(allKeys))
const isSelected = useCallback((race, sex) => selected.has(groupKey(race, sex)), [selected])
const toggle = useCallback((race, sex) => {
setSelected((prev) => {
const next = new Set(prev)
const key = groupKey(race, sex)
if (next.has(key)) next.delete(key)
else next.add(key)
return next
})
}, [])
const selectAll = useCallback(() => setSelected(new Set(allKeys)), [allKeys])
const selectNone = useCallback(() => setSelected(new Set()), [])
return { groups, selected, isSelected, toggle, selectAll, selectNone, allSelected: selected.size === allKeys.length }
}
/** Filters a rows array (each row has .race/.sex) down to the selected groups. */
export function filterRowsByGroup(rows, filter) {
return (rows || []).filter((r) => filter.isSelected(r.race, r.sex))
}
+3 -3
View File
@@ -20,11 +20,11 @@ export const RACE_LABELS = { WH: 'White', BL: 'Black', HI: 'Hispanic', AM: 'Amer
// SVG viewBox. // SVG viewBox.
export const SHORT_RACE_LABEL = { WH: 'White', BL: 'Black', HI: 'Hispanic', AM: 'AI/AN' } export const SHORT_RACE_LABEL = { WH: 'White', BL: 'Black', HI: 'Hispanic', AM: 'AI/AN' }
// Mark-type colors for the observed-vs-modeled convention. These are used // Mark-type colors for the observed-vs-modeled convention. Used where a
// only where a chart has no race facet of its own (aggregate totals) or // chart has no race facet of its own (aggregate totals).
// needs a neutral third "reference/benchmark" role distinct from both.
export const OBSERVED_MARK_COLOR = 'var(--cv-ink)' export const OBSERVED_MARK_COLOR = 'var(--cv-ink)'
export const MODELED_AGGREGATE_COLOR = 'var(--cv-navy-600)' export const MODELED_AGGREGATE_COLOR = 'var(--cv-navy-600)'
// Fallback for an unrecognized race key — kept neutral rather than defaulting to a real race hue.
export const REFERENCE_GRAY = 'var(--cv-ink-4)' export const REFERENCE_GRAY = 'var(--cv-ink-4)'
export function raceColor(race) { export function raceColor(race) {
-5
View File
@@ -119,10 +119,5 @@ export function densityCurve(fit, { min = 0, max, n = 60 } = {}) {
return points return points
} }
/** P(X > threshold) under the fitted approximation. */
export function probabilityExceeds(fit, threshold) {
return 1 - fit.cdf(threshold)
}
export const DISTRIBUTION_APPROX_NOTE = export const DISTRIBUTION_APPROX_NOTE =
'Distribution shape estimated from interval bounds — not raw posterior draws.' 'Distribution shape estimated from interval bounds — not raw posterior draws.'
-13
View File
@@ -1,13 +0,0 @@
/**
* Single source of truth for the race/sex group key format (matches the
* `RACE_SEX` keys used in national_rates.json), so charts and filters agree.
*/
export function groupKey(race, sex) {
return `${race}_${sex}`
}
export function parseGroupKey(key) {
const [race, sex] = key.split('_')
return { race, sex }
}