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
+11 -11
View File
@@ -4,7 +4,7 @@ import DistrictSearch from './components/DistrictSearch.jsx'
import LoadingAnimation from './components/LoadingAnimation.jsx'
import ChartPanel from './components/ChartPanel.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.
@@ -21,18 +21,18 @@ export default function App() {
const leaid = params.get('leaid')
const stateParam = params.get('state')
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)
// Fetch district name from API
searchDistricts(leaid, stateParam).then(results => {
const district = results?.find(d => d.leaid === leaid)
if (district) {
setDistrict(district)
} else {
// Fallback if district not found
setDistrict({ leaid, lea_name: 'Unknown District', state: stateParam })
}
fetchDistrictEstimates(leaid, { model: 'unified_m2_mod', year: '21-22' }).then(rows => {
const row = rows?.[0]
setDistrict(row
? { leaid: row.leaid, lea_name: row.lea_name, state: row.state }
: { leaid, lea_name: 'Unknown District', state: stateParam })
setStep('loading')
}).catch(() => {
// 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'
/**
* Chart 2: Arrest rate per 1,000 students by student group (most recent
* year). A plain bar to the modeled median (next to an observed diamond
* that often sits well past the bar's end) reads as if the bar itself were
* the uncertainty range, which it wasn't. This draws an actual horizontal
* box-and-whisker instead: whisker = the API's reported 90% interval
* (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.
* Arrest rate by student group, most recent year, disaggregated into two
* panels (Female / Male), each a horizontal box-and-whisker across the 4
* race categories. Whisker = the model's reported 90% interval, box = the
* fitted approximation's 25th-75th percentile, white tick = median, dark
* diamond = observed rate.
*/
const RACE_ORDER = ['WH', 'BL', 'HI', 'AM']
const SEX_ROWS = [{ sex: 'F', label: 'Female' }, { sex: 'M', label: 'Male' }]
const ROW_HEIGHT = 26
const SUBHEADER_HEIGHT = 20
const SEX_PANELS = [{ sex: 'F', label: 'Female' }, { sex: 'M', label: 'Male' }]
const ROW_HEIGHT = 34
function buildBox(d) {
const median = Math.max(d.modeledMedian || 0, 0)
@@ -25,37 +21,17 @@ function buildBox(d) {
const upper = Math.max(d.rateUpper ?? median, median)
const fit = fitSkewedInterval({ median, lower, upper })
return {
lower,
upper,
median,
lower, upper, median,
q1: Math.max(fit.quantile(0.25), 0),
q3: Math.max(fit.quantile(0.75), median),
}
}
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(
...data.map((d) => Math.max(d.observedRate, d.rateUpper ?? d.modeledMedian ?? 0)),
0.5
)
const xScale = (val) => margin.left + (val / maxRate) * innerWidth
let cursorY = margin.top
return (
<div className="cv-card" style={{ padding: 'var(--space-2)' }}>
@@ -64,74 +40,11 @@ export default function RateByGroupBar({ data }) {
</h3>
<ApproxNote />
<svg width="100%" viewBox={`0 0 ${width} ${height}`} style={{ maxWidth: '100%' }}>
<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={`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>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 'var(--space-3)', marginTop: 'var(--space-2)' }}>
{SEX_PANELS.map(({ sex, label }) => (
<SexPanel key={sex} label={label} rows={data.filter((d) => d.sex === sex)} maxRate={maxRate} />
))}
</div>
<ChartLegend items={[
...RACE_ORDER.filter((race) => data.some((d) => d.race === race)).map((race) => ({
@@ -147,3 +60,73 @@ export default function RateByGroupBar({ data }) {
</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>
)
}
+65 -56
View File
@@ -1,3 +1,4 @@
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'
@@ -5,15 +6,16 @@ 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.
* 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.
*/
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) {
const enroll = row.stu_enroll || 0
@@ -30,95 +32,100 @@ function buildGroupRow(row) {
}
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) {
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>
const modelSelect = (
<select
value={selectedModel}
onChange={(e) => setSelectedModel(e.target.value)}
style={{ padding: '0.25rem 0.5rem', fontFamily: 'var(--font-sans)', fontSize: '0.8rem' }}
>
{MODEL_QUADRANTS.map((q) => (
<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 (
<div className="cv-card" style={{ padding: 'var(--space-2)' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', flexWrap: 'wrap', gap: 'var(--space-2)' }}>
<div>
<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>
{modelSelect}
</div>
{rows.length === 0 ? (
<p style={{ color: 'var(--cv-ink-3)', marginTop: 'var(--space-2)' }}>No model data available.</p>
) : (
<>
<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>
)
}
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))
})
})
function RidgeColumns({ rows }) {
// 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
.filter((r) => (r.stu_enroll || 0) > 0)
.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 (
<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 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)} maxRate={maxRate} />
))}
</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 rowHeight = 46
const margin = { top: 24, right: 16, bottom: 26, left: 56 }
const rowHeight = 58
const margin = { top: 26, right: 16, bottom: 26, left: 56 }
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 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
const peakHeight = rowHeight * 0.82
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 x={width / 2} y={16} textAnchor="middle" fontSize="0.78rem" fontWeight={700} fill="var(--cv-ink-2)">
{label}
</text>
{groups.length === 0 ? (
<text x={width / 2} y={height / 2} textAnchor="middle" fontSize="0.65rem" fill="var(--cv-ink-3)">
No data for this group
</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)">
<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>
@@ -140,9 +147,9 @@ function RidgePanel({ q, rows, 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}
<g key={g.race}>
<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}
</text>
<path d={areaPath} fill={color} opacity={0.6} stroke="#fff" strokeWidth={0.5} />
<rect
@@ -154,9 +161,11 @@ function RidgePanel({ q, rows, maxRate }) {
)
})}
<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
</text>
</>
)}
</svg>
</div>
)
+18 -67
View File
@@ -1,49 +1,34 @@
import { useState, useEffect } from 'react'
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 ArrestsOverTime from '../charts/ArrestsOverTime.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 ExceedanceProbability from '../charts/ExceedanceProbability.jsx'
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
* student-group filter (defaults to all 8 groups selected).
* ChartPanel — 3 charts: arrests over time (observed vs. modeled), arrest
* rate by student group (Female/Male panels), and the model-selectable
* posterior density ridge chart.
*/
export default function ChartPanel({ district, state }) {
const [data, setData] = useState(null) // all fetched estimate rows keyed by year/model/group
const [nationalRates, setNationalRates] = useState(null)
const [data, setData] = useState(null) // all fetched estimate rows keyed by year/model
const [loading, setLoading] = useState(true)
const filter = useGroupFilter()
useEffect(() => {
async function fetchData() {
try {
// National rates from static fixture (or cache from loading step)
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']
// Chart 1 + 2: all 3 waves × three-year model (unified_m3_mod)
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) {}
}))
// Fetch all 4 quadrant models for Charts 4-6 (8 groups × 4 models, batched by model)
const quadrants = ['unified_m1_mod', 'unified_m2_mod', 'unified_m3_mod', 'unified_m4_mod']
// Chart 3: all 4 quadrant models, so the dropdown can switch between them
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) {}
}))
@@ -62,12 +47,11 @@ export default function ChartPanel({ district, state }) {
return <LoadingCharts />
}
// ——— Every derived dataset below is scoped to the selected student groups ———
const mostRecent = filterRowsByGroup(data.waveData['21-22'] || [], filter)
const mostRecent = data.waveData['21-22'] || []
// Chart 1: Arrests over time by wave — observed total + modeled (three-year model) point-range
const timeSeriesData = ALL_WAVES.map((year) => {
const yearRows = filterRowsByGroup(data.waveData[year] || [], filter)
const yearRows = data.waveData[year] || []
return {
year,
label: `20${year.replace('-', '-')}`,
@@ -79,9 +63,8 @@ export default function ChartPanel({ district, state }) {
}
})
// Charts 2-3: rate by student group (most recent wave, per 1k), with modeled + observed + interval
const rateByGroup = mostRecent
.map((r) => ({
// Chart 2: rate by student group (most recent wave, per 1k), with modeled + observed + interval
const rateByGroup = mostRecent.map((r) => ({
race: r.race, sex: r.sex, label: groupLabel(r.race, r.sex),
observedRate: (r.observed_arrests || 0) / ((r.stu_enroll || 1) / 1000),
modeledMedian: (r.count_median || 0) / ((r.stu_enroll || 1) / 1000),
@@ -90,39 +73,17 @@ export default function ChartPanel({ district, state }) {
observedArrests: r.observed_arrests || 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 (
<div style={{ padding: 'var(--space-3) 0 var(--space-7)' }}>
{/* Chart panel header */}
<div style={{ marginBottom: 'var(--space-3)' }}>
<div style={{ marginBottom: 'var(--space-4)' }}>
<span className="eyebrow">District estimates — Bayesian model comparison</span>
<h2 style={{ marginTop: 'var(--space-1)', marginBottom: 0 }}>
{district.lea_name} ({state}) — School-based arrest rates, 2021–22 CRDC
</h2>
</div>
<div style={{ maxWidth: '70rem', marginLeft: 'auto', marginRight: 'auto' }}>
<GroupFilterBar filter={filter} />
</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',
@@ -131,31 +92,21 @@ export default function ChartPanel({ district, state }) {
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 || {}} />
<RateDensityRidgeline quadData={data.quadData} />
</div>
)}
{/* Methodology footer */}
<div style={{ marginTop: 'var(--space-6)', padding: 'var(--space-3) 0', borderTop: '1px solid var(--cv-rule)' }}>
<span className="eyebrow">Methodology</span>
<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
the Civil Rights Data Collection (2015–16, 2017–18, 2021–22) and compares four Bayesian model specifications:
one-year vs. three-year models with and without referral-rate covariates. All rates are per 1,000 students.
the Civil Rights Data Collection (2015–16, 2017–18, 2021–22) and lets you explore four Bayesian model
specifications: one-year vs. three-year models with and without referral-rate covariates. All rates are
per 1,000 students.
</p>
</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 ChartPanel from './ChartPanel.jsx'
import * as api from '../hooks/useApi.js'
// The 8 student groups we fetch for (race×sex combinations)
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 },
]
import { STUDENT_GROUPS, MODEL_QUADRANTS } from '../hooks/useApi.js'
import { shortGroupLabel } from '../utils/colors.js'
// CRDC waves to fetch (for Chart 1 — time series)
const WAVES = ['21-22', '17-18', '15-16']
@@ -35,11 +17,9 @@ export default function LoadingAnimation({ district, state }) {
const [totalCalls, setTotalCalls] = useState(0)
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(() => {
let cancelled = false
let count = 0
let total = 0
async function loadData() {
try {
@@ -50,14 +30,14 @@ export default function LoadingAnimation({ district, state }) {
.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) =>
fetchDistrictEstimatesBatch(district.leaid, '21-22', sg.race, sg.sex)
.then(() => !cancelled && setLoadedCount(c => c + 1))
.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) =>
STUDENT_GROUPS.map((sg) =>
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)
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) {
// All data loaded — ChartPanel renders in place of this component
// We use a render prop pattern: return <ChartPanel /> when done
setLoadedCount(allPromises.length + 1)
setLoadedCount(allPromises.length)
}
} catch (err) {
if (!cancelled) setError(err.message || 'Failed to load data')
@@ -110,10 +84,14 @@ export default function LoadingAnimation({ district, state }) {
// Build bar metadata for rendering
const allBars = Array.from({ length: TOTAL_BARS }, (_, i) => {
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 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 (
@@ -142,7 +120,7 @@ export default function LoadingAnimation({ district, state }) {
marginTop: 'var(--space-4)'
}}>
{/* 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)' }}>
<span className="stat-label" style={{ display: 'block' }}>{h}</span>
</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.
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
// only where a chart has no race facet of its own (aggregate totals) or
// needs a neutral third "reference/benchmark" role distinct from both.
// Mark-type colors for the observed-vs-modeled convention. Used where a
// chart has no race facet of its own (aggregate totals).
export const OBSERVED_MARK_COLOR = 'var(--cv-ink)'
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 function raceColor(race) {
-5
View File
@@ -119,10 +119,5 @@ export function densityCurve(fit, { min = 0, max, n = 60 } = {}) {
return points
}
/** P(X > threshold) under the fitted approximation. */
export function probabilityExceeds(fit, threshold) {
return 1 - fit.cdf(threshold)
}
export const DISTRIBUTION_APPROX_NOTE =
'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 }
}