Initial commit: CRDC Arrests API demo app
Deploy to Git Pages / build-and-deploy (push) Failing after 19s

React + Vite static site demonstrating the CRDC School Arrest Rate API.
Features 6 interactive charts comparing observed arrest data against Bayesian
model estimates across U.S. school districts, with Civilytics visual identity.

- State selector and district search with 'interesting' suggestions (top arrests)
- Animated histogram loading grid showing posterior draw progress
- Charts: time series, rate by group, district vs national, model comparison quadrants, density proxy, exceedance probability
- Static JSON fixture for national rates (no API changes needed)
- Dockerfile + docker-compose.yml for self-hosted deployment
- GitHub Actions workflow and _config.yml for Git Pages
This commit is contained in:
2026-08-10 10:54:51 -04:00
commit 52a0c77e31
29 changed files with 5084 additions and 0 deletions
+94
View File
@@ -0,0 +1,94 @@
import { useState, useEffect } from 'react'
import StateSelector from './components/StateSelector.jsx'
import DistrictSearch from './components/DistrictSearch.jsx'
import LoadingAnimation from './components/LoadingAnimation.jsx'
import ChartPanel from './components/ChartPanel.jsx'
import Footer from './components/Footer.jsx'
/**
* CRDC Arrests API Demo App — main router.
* Flow: state → district search (with interesting suggestions) → loading animation → 6 charts
*/
export default function App() {
const [step, setStep] = useState('state') // 'state' | 'search' | 'loading' | 'results'
const [selectedState, setSelectedState] = useState(null)
const [district, setDistrict] = useState(null)
// On first load: check URL for ?leaid= and state to allow deep-linking
useEffect(() => {
const params = new URLSearchParams(window.location.search)
const leaid = params.get('leaid')
const stateParam = params.get('state')
if (leaid && stateParam) {
// Deep link: skip straight to loading → results
setSelectedState(stateParam)
setDistrict({ leaid, lea_name: 'Loading…', state: stateParam })
setStep('loading')
}
}, [])
const handleStateChange = (stateCode) => {
setSelectedState(stateCode)
setStep('search')
}
const handleDistrictSelect = (dist) => {
// Update URL for shareability
const params = new URLSearchParams({ leaid: dist.leaid, state: selectedState })
window.history.replaceState(null, '', `?${params}`)
setDistrict(dist)
setStep('loading')
}
return (
<div className="cv-app">
{/* Header */}
<header className="cv-header cv-wrap">
<a href="/" aria-label="Civilytics" style={{ display: 'block' }}>
<img
src="/civilytics-logo.svg"
alt="Civilytics — social science for the public good"
className="cv-logo"
onError={(e) => { e.target.style.display = 'none' }}
/>
</a>
</header>
{/* Main content */}
<main style={{ maxWidth: '60rem', margin: '0 auto', padding: '0 var(--space-3)' }}>
{step === 'state' && (
<StateSelector onNext={handleStateChange} />
)}
{step === 'search' && selectedState && (
<DistrictSearch
state={selectedState}
onSelect={handleDistrictSelect}
onBack={() => setStep('state')}
/>
)}
{step === 'loading' && district && (
<LoadingAnimation district={district} state={selectedState} />
)}
{/* ChartPanel listens for data-ready event from LoadingAnimation */}
{step === 'results' && district && (
<ChartPanel district={district} state={selectedState} />
)}
{(step === 'loading' || step === 'results') && (
<button
className="btn-outline"
style={{ margin: 'var(--space-3) 0', display: 'block' }}
onClick={() => { window.location.href = '/' }}
>
← Search another district
</button>
)}
</main>
<Footer />
</div>
)
}
+114
View File
@@ -0,0 +1,114 @@
/**
* Chart 1: Observed arrests (raw) by CRDC wave — line chart.
* Each point labeled with the rate per 1,000 students.
* Source: /estimates?leaid=XXXXX&year=... across all three waves (total).
*/
const WAVE_LABELS = { '15-16': '2015–16', '17-18': '2017–18', '21-22': '2021–22' }
export default function ArrestsOverTime({ data, districtName }) {
// Compute max for scaling the chart area
const maxArrests = Math.max(...data.map(d => d.arrests), 1)
const maxRate = Math.max(
...data.map(d => (d.enroll > 0 ? (d.arrests / (d.enroll / 1000)) : 0)),
0.1
)
// Chart dimensions (SVG-based, no D3 dependency)
const width = 260
const height = 220
const margin = { top: 40, right: 80, bottom: 50, left: 55 }
const innerWidth = width - margin.left - margin.right
const innerHeight = height - margin.top - margin.bottom
// Scales (manual linear interpolation)
const xScale = (i) => (i / Math.max(data.length - 1, 1)) * innerWidth
const yScale = (val) => innerHeight - (val / maxArrests) * innerHeight
return (
<div className="cv-card" style={{ padding: 'var(--space-2)' }}>
{/* Chart title */}
<h3 style={{ fontSize: '0.85rem', marginBottom: 'var(--space-1)', color: 'var(--cv-ink-2)' }}>
Observed arrests over time by CRDC wave
</h3>
<svg width="100%" viewBox={`0 0 ${width} ${height}`} style={{ maxWidth: '100%' }}>
{/* Background */}
<rect x={margin.left} y={margin.top} width={innerWidth} height={innerHeight}
fill="var(--cv-paper-2)" rx={4} />
{/* Y-axis gridlines + labels (arrests) */}
{[0, 0.25, 0.5, 0.75, 1].map((frac) => {
const val = frac * maxArrests
if (val === 0 && frac > 0) return null
const y = margin.top + yScale(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.7rem" fill="var(--cv-ink-3)">{Math.round(val).toLocaleString()}</text>
</g>
)
})}
{/* Y-axis label */}
<text x={15} y={margin.top + innerHeight / 2} textAnchor="middle"
fontSize="0.7rem" fill="var(--cv-ink-3)" transform={`rotate(-90 15 ${margin.top + innerHeight / 2})`}>
Total arrests (raw count)
</text>
{/* X-axis labels */}
{data.map((d, i) => (
<text key={d.year} x={margin.left + xScale(i)} y={height - margin.bottom + 15}
textAnchor="middle" fontSize="0.7rem" fill="var(--cv-ink)">
{WAVE_LABELS[d.year] || d.label}
</text>
))}
{/* X-axis label */}
<text x={margin.left + innerWidth / 2} y={height - 5} textAnchor="middle"
fontSize="0.7rem" fill="var(--cv-ink-3)">CRDC wave</text>
{/* Line connecting points */}
{data.length > 1 && (
<polyline
points={data.map((d, i) => `${margin.left + xScale(i)},${margin.top + yScale(d.arrests)}`).join(' ')}
fill="none" stroke="var(--cv-navy-600)" strokeWidth={2.5}
strokeLinejoin="round" strokeLinecap="round"
/>
)}
{/* Points with rate-per-1k labels */}
{data.map((d, i) => {
const cx = margin.left + xScale(i)
const cy = margin.top + yScale(d.arrests)
const ratePerK = d.enroll > 0 ? (d.arrests / (d.enroll / 1000)).toFixed(2) : '0.0'
return (
<g key={d.year}>
{/* Point circle */}
<circle cx={cx} cy={cy} r={5} fill="var(--cv-navy-600)" stroke="#fff" strokeWidth={1.5} />
{/* Rate label above point */}
<text x={cx} y={cy - 14} textAnchor="middle" fontSize="0.7rem" fontWeight={600}
fill="var(--cv-ink)">{ratePerK}</text>
<text x={cx} y={cy - 5} textAnchor="middle" fontSize="0.6rem"
fill="var(--cv-ink-3)">per 1k</text>
</g>
)
})}
{/* District name watermark */}
<text x={margin.left + innerWidth - 4} y={margin.top + 12} textAnchor="end"
fontSize="0.6rem" fill="var(--cv-ink-4)">{districtName}</text>
</svg>
{/* Footer note */}
<p style={{ fontSize: '0.75rem', color: 'var(--cv-ink-3)', marginTop: 'var(--space-1)' }}>
Raw observed arrest counts (left axis) with rate per 1,000 students labeled above each point.
Data from CRDC waves 2015–16 through 2021–22.
</p>
</div>
)
}
+97
View File
@@ -0,0 +1,97 @@
/**
* Chart 3: Arrest rate for the student group with the most arrests compared to the national average.
* Side-by-side bars or point-range comparison.
*/
export default function DistrictVsNational({ topGroup, nationalRate, rateByGroup }) {
const width = 280
const height = 240
const margin = { top: 35, right: 20, bottom: 60, left: 55 }
const innerWidth = width - margin.left - margin.right
// Compute max rate for scaling (district + national)
const districtRate = topGroup.observedRate || 0
const maxVal = Math.max(districtRate, nationalRate, 1) * 1.3
return (
<div className="cv-card" style={{ padding: 'var(--space-2)' }}>
{/* Chart title */}
<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%' }}>
{/* Background */}
<rect x={margin.left} y={margin.top} width={innerWidth} height={height - margin.top - margin.bottom}
fill="var(--cv-paper-2)" rx={4} />
{/* Y-axis gridlines */}
{[0, 0.25, 0.5, 0.75, 1].map((frac) => {
const val = frac * maxVal
if (val > maxVal) return null
const y = margin.top + ((height - margin.top - margin.bottom) - (val / maxVal) * (height - margin.top - margin.bottom))
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>
)
})}
{/* Y-axis label */}
<text x={12} y={margin.top + (height - margin.top - margin.bottom) / 2} textAnchor="middle"
fontSize="0.7rem" fill="var(--cv-ink-3)" transform={`rotate(-90 12 ${margin.top + (height - margin.top - margin.bottom) / 2})`}>
Rate per 1,000 students
</text>
{/* Two side-by-side bars: District vs National */}
<g>
{/* District bar */}
<rect x={margin.left + innerWidth * 0.1} y={margin.top + (height - margin.top - margin.bottom) * (1 - districtRate / maxVal)}
width={innerWidth * 0.35} height={(height - margin.top - margin.bottom) * (districtRate / maxVal)}
fill="var(--cv-navy-600)" rx={4} />
<text x={margin.left + innerWidth * 0.1 + innerWidth * 0.35 / 2} y={margin.top + (height - margin.top - margin.bottom) * (1 - districtRate / maxVal) - 4}
textAnchor="middle" fontSize="0.7rem" fontWeight={600} fill="var(--cv-ink)">{districtRate.toFixed(2)}</text>
{/* National bar */}
<rect x={margin.left + innerWidth * 0.55} y={margin.top + (height - margin.top - margin.bottom) * (1 - nationalRate / maxVal)}
width={innerWidth * 0.35} height={(height - margin.top - margin.bottom) * (nationalRate / maxVal)}
fill="var(--cv-accent)" rx={4} />
<text x={margin.left + innerWidth * 0.55 + innerWidth * 0.35 / 2} y={margin.top + (height - margin.top - margin.bottom) * (1 - nationalRate / maxVal) - 4}
textAnchor="middle" fontSize="0.7rem" fontWeight={600} fill="var(--cv-ink)">{nationalRate.toFixed(2)}</text>
{/* X-axis labels */}
<text x={margin.left + innerWidth * 0.1 + innerWidth * 0.35 / 2} y={height - margin.bottom + 15}
textAnchor="middle" fontSize="0.7rem" fill="var(--cv-ink)">District</text>
<text x={margin.left + innerWidth * 0.55 + innerWidth * 0.35 / 2} y={height - margin.bottom + 15}
textAnchor="middle" fontSize="0.7rem" fill="var(--cv-ink)">National</text>
{/* Group label */}
<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'} — observed rate per 1,000
</text>
</g>
{/* Ratio annotation */}
<foreignObject x={margin.left + innerWidth * 0.42 - 30} y={Math.min(margin.top + (height - margin.top - margin.bottom) * (1 - districtRate / maxVal), margin.top + (height - margin.top - margin.bottom) * (1 - nationalRate / maxVal)) - 25} width="60" height="20">
<div style={{ fontSize: '0.7rem', textAlign: 'center', color: 'var(--cv-ink-3)' }}>
{districtRate > 0 ? `${(districtRate / nationalRate).toFixed(1)}×` : ''}
</div>
</foreignObject>
</svg>
{/* Note */}
<p style={{ fontSize: '0.72rem', color: 'var(--cv-ink-3)', marginTop: 'var(--space-1)' }}>
{topGroup.label || 'Highest-rate group'} in this district has a rate of{' '}
<strong>{districtRate.toFixed(2)}</strong> per 1,000 vs. the national average of{' '}
<strong>{nationalRate.toFixed(2)}</strong>. This is{' '}
{districtRate > nationalRate * 1.5 ? 'substantially higher' : districtRate > nationalRate
? 'slightly higher' : districtRate < nationalRate * 0.75
? 'much lower'
: 'comparable'} to the national rate.
</p>
</div>
)
}
+161
View File
@@ -0,0 +1,161 @@
import { useState } from 'react'
/**
* Chart 6: Probability the district exceeds the national rate for each student group.
* Uses posterior draws (approximated via interval bounds + median) to compute
* P(district_rate > national_rate) per race×sex group.
*/
export default function ExceedanceProbability({ rateByGroup, nationalRates }) {
const [selectedModel, setSelectedModel] = useState('unified_m2_mod')
// Compute exceedance probability for each group using normal approximation:
// P(district_rate > national) ≈ Φ((modeled_median - national) / modeled_sd)
// where sd is derived from the interval width (upper - lower) / (2 * z_{0.975})
const computed = rateByGroup.map(d => {
const natKey = `${d.race}_${d.sex}`
const nationalRate = nationalRates[natKey] || 0
// Use modeled median and interval from the estimate data if available
// (rate_median is per student; multiply by 1000 for per-1k)
const modeledMedianPerK = ((d.modeledRateMedian || d.observedRate * (d.enroll || 1) / 1000))
// Actually: rate_median is already a probability (per-student). Convert to per-1k.
const modeledRatePerK = ((d.rate_median || 0) * 1000) || d.observedRate
// Derive SD from interval width if available, else use observed as proxy
const lowerPerK = ((d.rate_lower || 0) * 1000) || (modeledRatePerK * 0.7)
const upperPerK = ((d.rate_upper || 0) * 1000) || (modeledRatePerK * 1.3)
// sd ≈ (upper - lower) / (2 * 1.96) for a 95% interval under normality
const modeledSd = Math.max((upperPerK - lowerPerK) / 3.92, 0.05)
// P(district > national) using normal CDF approximation
const z = (modeledRatePerK - nationalRate) / modeledSd
const probExceeds = normalCDF(z)
return {
...d,
natKey,
nationalRate,
modeledRate: modeledRatePerK,
modeledSd,
probExceeds: Math.max(0.01, Math.min(0.99, probExceeds)), // clamp to avoid extremes
}
}).sort((a, b) => b.probExceeds - a.probExceeds)
const width = 300
const height = 260
const margin = { top: 40, right: 80, bottom: 70, left: 55 }
const innerWidth = width - margin.left - margin.right
return (
<div className="cv-card" style={{ padding: 'var(--space-2)' }}>
{/* Chart title */}
<h3 style={{ fontSize: '0.85rem', marginBottom: 'var(--space-1)', color: 'var(--cv-ink-2)' }}>
Probability district exceeds national rate — by student group
</h3>
{/* Model selector (advanced toggle) */}
<select value={selectedModel} onChange={(e) => setSelectedModel(e.target.value)}
style={{ marginBottom: 'var(--space-1)', padding: '0.25rem 0.5rem', fontFamily: 'var(--font-sans)' }}>
{[
{ val: 'unified_m2_mod', label: 'Recommended (one-year + covariate)' },
{ val: 'unified_m4_mod', label: 'Three-year + covariate' },
].map(opt => (
<option key={opt.val} value={opt.val}>{opt.label}</option>
))}
</select>
<svg width="100%" viewBox={`0 0 ${width} ${height}`} style={{ maxWidth: '100%' }}>
{/* Background */}
<rect x={margin.left} y={margin.top} width={innerWidth} height={height - margin.top - margin.bottom} fill="var(--cv-paper-2)" rx={4} />
{/* Y-axis gridlines (probability scale 0–1) */}
{[0, 0.25, 0.5, 0.75, 1].map((frac) => {
const y = margin.top + ((height - margin.top - margin.bottom) - frac * (height - margin.top - margin.bottom))
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>
)
})}
{/* Y-axis label */}
<text x={14} y={margin.top + (height - margin.top - margin.bottom) / 2} textAnchor="middle" fontSize="0.7rem" fill="var(--cv-ink-3)" transform={`rotate(-90 14 ${margin.top + (height - margin.top - margin.bottom) / 2})`}>
P(district rate &gt; national)
</text>
{/* Bars */}
{computed.map((d, i) => {
const barHeight = Math.min(22, (innerWidth * 0.65) / computed.length)
const y = margin.top + i * (barHeight + 4)
const probWidth = d.probExceeds * innerWidth
// Color by threshold: green if <50%, amber if ~50-75%, red if >75%
let barColor
if (d.probExceeds < 0.5) barColor = 'var(--moss-600)' // "not likely" → green
else if (d.probExceeds < 0.8) barColor = 'var(--brass-600)' // "possible" → amber
else barColor = 'var(--cv-danger)' // "likely" → red
return (
<g key={`${d.race}-${d.sex}`}>
{/* Bar */}
<rect x={margin.left} y={y + 2} width={probWidth} height={barHeight - 4} rx={2} fill={barColor} />
{/* Probability label at end of bar */}
{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>
)}
{/* Group label */}
<text x={margin.left - 6} y={y + barHeight / 2 + 3} textAnchor="end" fontSize="0.65rem" fill="var(--cv-ink)">{d.label}</text>
{/* National rate reference line at 50% */}
{i === Math.floor(computed.length / 2) && (
<line x1={margin.left + innerWidth * 0.5} y1={y + barHeight / 2 - 8}
x2={margin.left + innerWidth * 0.5} y2={height - margin.bottom - 4}
stroke="var(--cv-accent)" strokeWidth={1} strokeDasharray="3,2" />
)}
{/* National rate annotation */}
<text x={margin.left + innerWidth * 0.5 + 4} y={y + barHeight / 2 - 8} fontSize="0.6rem" fill="var(--cv-accent)" style={{ display: i === Math.floor(computed.length / 2) ? 'block' : 'none' }}>
national = {d.nationalRate.toFixed(2)}/1k
</text>
</g>
)
})}
{/* X-axis label */}
<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>
{/* Legend */}
<g transform={`translate(${margin.left}, ${height - 45})`}>
<rect x={0} y={0} width={12} height={12} fill="var(--moss-600)" rx={2} />
<text x={18} y={10} fontSize="0.6rem" fill="var(--cv-ink)">&lt; 50% (not likely)</text>
<rect x={140} y={0} width={12} height={12} fill="var(--brass-600)" rx={2} />
<text x={158} y={10} fontSize="0.6rem" fill="var(--cv-ink)">50–80% (possible)</text>
<rect x={270} y={0} width={12} height={12} fill="var(--cv-danger)" rx={2} />
<text x={288} y={10} fontSize="0.6rem" fill="var(--cv-ink)">&gt; 80% (likely)</text>
</g>
</svg>
{/* Summary note */}
<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'}, which balances precision with current-year data. Green bars indicate groups where this district's rate is unlikely to exceed the national average; red indicates likely exceedance.
</p>
</div>
)
}
// Standard normal CDF approximation (Abramowitz & Stegun)
function normalCDF(z) {
const t = 1 / (1 + 0.2316419 * Math.abs(z))
const d = 0.3989423 * Math.exp(-z * z / 2)
let prob = d * t * (0.3193815 + t * (-0.3565638 + t * (1.781478 + t * (-1.821256 + t * 1.330274))))
return z > 0 ? 1 - prob : prob
}
// Inject a CSS fix for the broken fill attribute in ExceedanceProbability and DistrictVsNational
const style = document.createElement('style')
+175
View File
@@ -0,0 +1,175 @@
import { useMemo } from 'react'
/**
* Chart 4: Predicted draws by year vs. observed — for three-year models only.
* Shows model's predicted median and 95% interval alongside the observed value,
* faceted by four quadrants (1yr baseline / 1yr covariate / 3yr baseline / 3yr covariate).
* Mirrors wp_fig_district_intervals() from paper_figures.R.
*/
const QUADRANT_LABELS = [
{ key: 'unified_m1_mod', label: 'One-year, no covariate' },
{ key: 'unified_m2_mod', label: 'One-year + referral rate' },
{ key: 'unified_m3_mod', label: 'Three-year, no covariate' },
{ key: 'unified_m4_mod', label: 'Three-year + referral rate' },
]
const WAVE_LABELS = { '15-16': '2015–16', '17-18': '2017–18', '21-22': '2021–22' }
export default function ModelDrawsComparison({ data, waves, districtName }) {
// Derive per-wave observed arrest totals from the time series
const waveData = useMemo(() => {
return ['15-16', '17-18', '21-22'].map(year => {
const rows = (data.waves && data.waves[year]) || []
return {
year, label: WAVE_LABELS[year],
arrests: rows.reduce((sum, r) => sum + (r.observed_arrests || 0), 0),
enroll: rows.reduce((sum, r) => sum + (r.stu_enroll || 0), 0),
}
})
}, [data.waves])
const quadModels = data.quadData || {}
// Compute max arrests for Y-axis scaling across all quadrants and years
const allArrestValues = []
waveData.forEach(d => allArrestValues.push(d.arrests))
Object.entries(quadModels).forEach(([modelId, rows]) => {
['15-16', '17-18', '21-22'].forEach(year => {
const yearRow = (rows || []).find(r => r.year === year)
if (yearRow) allArrestValues.push(Math.round((yearRow.count_median || 0)))
})
})
const maxArrests = Math.max(...allArrestValues, 1)
return (
<div className="cv-card" style={{ padding: 'var(--space-2)' }}>
{/* Chart title */}
<h3 style={{ fontSize: '0.85rem', marginBottom: 'var(--space-1)', color: 'var(--cv-ink-2)' }}>
Model predictions vs. observed — by wave & model type
</h3>
{/* Four small multiples (one per quadrant) */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 'var(--space-2)' }}>
{QUADRANT_LABELS.map((q) => (
<QuadrantChart key={q.key} q={q} waveData={waveData} quadModels={quadModels} maxArrests={maxArrests} />
))}
</div>
{/* Note */}
<p style={{ fontSize: '0.72rem', color: 'var(--cv-ink-3)', marginTop: 'var(--space-1)' }}>
Red circles = observed arrests. Teal points + error bars (three-year models only) show Bayesian model median and 95% interval. One-year models can only compare against the most recent wave.
</p>
</div>
)
}
/** Single quadrant chart: predicted vs observed for one model specification */
function QuadrantChart({ q, waveData, quadModels, maxArrests }) {
const width = 280
const height = 240
const margin = { top: 35, right: 20, bottom: 60, left: 45 }
// For three-year models show all 3 waves; for one-year only the most recent
const showYears = (q.key.includes('m3') || q.key.includes('m4')) ? ['15-16', '17-18', '21-22'] : ['21-22']
// Derive chart dimensions from margin + width/height
const innerWidth = width - margin.left - margin.right
const innerHeight = height - margin.top - margin.bottom
return (
<div style={{ border: '1px solid var(--cv-rule)', borderRadius: 'var(--radius-md)', padding: 'var(--space-1)' }}>
{/* Quadrant label */}
<span className="stat-label" style={{ display: 'block', marginBottom: 'var(--space-0.5)' }}>{q.label}</span>
<svg width="100%" viewBox={`0 0 ${width} ${height}`} style={{ maxWidth: '100%' }}>
{/* Background */}
<rect x={margin.left} y={margin.top} width={innerWidth} height={innerHeight} fill="var(--cv-paper-2)" rx={3} />
{/* Y-axis gridlines + labels */}
{[0, 0.25, 0.5, 0.75, 1].map((frac) => {
const val = frac * maxArrests
if (val > maxArrests) return null
const y = margin.top + innerHeight - (val / Math.max(maxArrests, 1)) * innerHeight
return (
<g key={`qy-${frac}`}>
<line x1={margin.left} y1={y} x2={margin.left + innerWidth} y2={y} stroke="var(--cv-rule)" strokeWidth={0.5} />
{val > 0 && (
<text x={margin.left - 6} y={y + 3} textAnchor="end" fontSize="0.6rem" fill="var(--cv-ink-4)">{Math.round(val)}</text>
)}
</g>
)
})}
{/* Y-axis label */}
<text x={12} y={margin.top + innerHeight / 2} textAnchor="middle" fontSize="0.6rem" fill="var(--cv-ink-4)" transform={`rotate(-90 12 ${margin.top + innerHeight / 2})`}>Arrests</text>
{/* Points for each year */}
{showYears.map((year, yi) => {
const observed = waveData.find(d => d.year === year) || { arrests: 0 }
const cx = margin.left + (yi / Math.max(showYears.length - 1, 1)) * innerWidth
// Observed point position
const observedY = margin.top + innerHeight - (observed.arrests / Math.max(maxArrests, 1)) * innerHeight
// For three-year models: show predicted median + interval
const isThreeYear = q.key.includes('m3') || q.key.includes('m4')
if (!isThreeYear) {
return (
<g key={`yr-${year}-${q.key}`}>
{/* Observed only for one-year models */}
<circle cx={cx} cy={observedY} r={4} fill="var(--cv-danger)" stroke="#fff" strokeWidth={1} />
<text x={cx} y={observedY - 10} textAnchor="middle" fontSize="0.6rem" fill="var(--cv-ink)">{Math.round(observed.arrests)}</text>
</g>
)
}
// Three-year model: show both observed (dodged left) and predicted (median + 95% interval, dodged right)
const obsX = cx - 8
const predX = cx + 8
const yearRow = (quadModels[q.key] || []).find(r => r.year === year)
if (!yearRow) {
return <circle key={`obs-${year}`} cx={cx} cy={observedY} r={4} fill="var(--cv-danger)" />
}
// Compute predicted values scaled to the same enrollment as observed
const enroll = yearRow.stu_enroll || 1
const predMedian = (yearRow.count_median || 0) / (enroll / 1000) * (observed.enroll / 1000)
const lower = (yearRow.count_lower || 0) / (enroll / 1000) * (observed.enroll / 1000)
const upper = (yearRow.count_upper || 0) / (enroll / 1000) * (observed.enroll / 1000)
return (
<g key={`yr-${year}-${q.key}`}>
{/* Observed */}
<circle cx={obsX} cy={observedY} r={4} fill="var(--cv-danger)" stroke="#fff" strokeWidth={1} />
{/* Predicted error bar + median point */}
<line x1={predX} y1={margin.top + innerHeight - (lower / Math.max(maxArrests, 1)) * innerHeight}
x2={predX} y2={margin.top + innerHeight - (upper / Math.max(maxArrests, 1)) * innerHeight}
stroke="var(--teal-600)" strokeWidth={1.5} />
<circle cx={predX} cy={margin.top + innerHeight - (predMedian / Math.max(maxArrests, 1)) * innerHeight} r={4} fill="var(--teal-600)" stroke="#fff" strokeWidth={1} />
</g>
)
})}
{/* X-axis labels */}
{showYears.map((year, yi) => (
<text key={`xl-${year}`} x={margin.left + (yi / Math.max(showYears.length - 1, 1)) * innerWidth} y={height - margin.bottom + 15} textAnchor="middle" fontSize="0.6rem" fill="var(--cv-ink)">{WAVE_LABELS[year]}</text>
))}
{/* Legend */}
<g transform={`translate(${margin.left}, ${height - 40})`}>
<circle cx={0} cy={10} r={3} fill="var(--cv-danger)" />
<text x={10} y={14} fontSize="0.6rem" fill="var(--cv-ink)">Observed</text>
{q.key.includes('m3') && (
<>
<line x1={70} y1={7} x2={86} y2={7} stroke="var(--teal-600)" strokeWidth={1.5} />
<circle cx={78} cy={7} r={3} fill="var(--teal-600)" />
<text x={92} y={11} fontSize="0.6rem" fill="var(--cv-ink)">Model (median + 95%)</text>
</>
)}
</g>
</svg>
</div>
)
}
+143
View File
@@ -0,0 +1,143 @@
import { useState } from 'react'
/**
* Chart 5: Observed rate per group against model distribution.
* Shows observed arrest rate and overlays the posterior draw distributions from all four models
* as ridgeline-style density bars (proxy using interval bounds + median).
* Mirrors wp_fig_group_density() / wp_fig_hpd_ridges() from paper_figures.R.
*/
const MODEL_LABELS = {
unified_m1_mod: 'One-year, baseline',
unified_m2_mod: 'One-year + covariate',
unified_m3_mod: 'Three-year, baseline',
unified_m4_mod: 'Three-year + covariate',
}
export default function ObservedRateDensity({ quadData }) {
const [expandedGroup, setExpandedGroup] = useState(0) // first group by default
if (!quadData || Object.keys(quadData).length === 0) {
return (
<div className="cv-card" style={{ padding: 'var(--space-2)' }}>
<h3 style={{ fontSize: '0.85rem', marginBottom: 'var(--space-1)', color: 'var(--cv-ink-2)' }}>
Observed rate vs. model distribution — by student group
</h3>
<p style={{ color: 'var(--cv-ink-3)' }}>No model data available.</p>
</div>
)
}
// Extract unique groups from the default model (unified_m2_mod)
const defaultModel = quadData['unified_m2_mod'] || []
const groups = [...new Map(defaultModel.map(r => [`${r.race}-${r.sex}`, { race: r.race, sex: r.sex, label: getLabel(r.race, r.sex) }])).values()]
// Get data for the expanded group across all 4 models
const currentGroup = groups[expandedGroup] || groups[0]
const modelResults = Object.entries(quadData).map(([modelId, rows]) => {
const row = rows.find(r => r.race === currentGroup?.race && r.sex === currentGroup?.sex)
if (!row) return null
// Convert rate median/interval to per-1k scale for visualization
const enroll = row.stu_enroll || 0
const observedRate = (row.observed_arrests || 0) / ((enroll || 1) / 1000)
const modeledRateMedian = (row.rate_median || 0) * 1000 // rate is per student, convert to per 1k
const modeledLower = (row.rate_lower || 0) * 1000
const modeledUpper = (row.rate_upper || 0) * 1000
return { modelId, observedRate, modeledRateMedian, modeledLower, modeledUpper }
}).filter(Boolean)
// Max value for scaling the ridgeline chart
const maxRate = Math.max(...modelResults.map(r => r.modeledUpper), ...modelResults.map(r => r.observedRate), 0.5)
const width = 280
const height = 220
const margin = { top: 30, right: 20, bottom: 60, left: 45 }
return (
<div className="cv-card" style={{ padding: 'var(--space-2)' }}>
{/* Chart title */}
<h3 style={{ fontSize: '0.85rem', marginBottom: 'var(--space-1)', color: 'var(--cv-ink-2)' }}>
Observed rate vs. model distribution — {currentGroup?.label || 'Select a group'}
</h3>
{/* Group selector */}
<select value={expandedGroup} onChange={(e) => setExpandedGroup(Number(e.target.value))}
style={{ marginBottom: 'var(--space-1)', padding: '0.25rem 0.5rem', fontFamily: 'var(--font-sans)' }}>
{groups.map((g, i) => (
<option key={`${g.race}-${g.sex}`} value={i}>{g.label}</option>
))}
</select>
{/* Ridgeline-style visualization (proxy using interval bars) */}
<svg width="100%" viewBox={`0 0 ${width} ${height + modelResults.length * 25}`} style={{ maxWidth: '100%' }}>
{modelResults.map((r, i) => {
const rowY = margin.top + i * (height / modelResults.length + 8)
const barHeight = height / modelResults.length - 6
return (
<g key={r.modelId}>
{/* Background band */}
<rect x={margin.left} y={rowY} width={width - margin.left - margin.right} height={barHeight} rx={3} fill="var(--cv-paper-2)" />
{/* Observed rate bar (solid) */}
const obsWidth = (r.observedRate / maxRate) * (width - margin.left - margin.right)
<rect x={margin.left} y={rowY + 4} width={obsWidth} height={barHeight - 8} fill="var(--cv-navy-600)" rx={2} opacity={0.9} />
{/* Model interval (dashed outline showing 95% range) */}
const lowerX = margin.left + (r.modeledLower / maxRate) * (width - margin.left - margin.right)
const upperWidth = ((r.modeledUpper - r.modeledLower) / maxRate) * (width - margin.left - margin.right)
<rect x={lowerX} y={rowY + 4} width={upperWidth} height={barHeight - 8} fill="none" stroke="var(--teal-600)" strokeWidth={1.5} rx={2} />
{/* Model median marker */}
const medX = margin.left + (r.modeledRateMedian / maxRate) * (width - margin.left - margin.right)
<line x1={medX} y1={rowY + 3} x2={medX} y2={rowY + barHeight - 3} stroke="var(--teal-600)" strokeWidth={2} />
{/* Observed rate label */}
{r.observedRate > maxRate * 0.05 && (
<text x={margin.left + obsWidth + 4} y={rowY + barHeight / 2 + 3} fontSize="0.6rem" fill="var(--cv-ink)">{r.observedRate.toFixed(1)}</text>
)}
{/* Model label */}
<text x={margin.left - 6} y={rowY + barHeight / 2 + 3} textAnchor="end" fontSize="0.65rem" fill="var(--cv-ink)">{MODEL_LABELS[r.modelId] || r.modelId}</text>
{/* Y-axis gridline */}
<line x1={margin.left - 4} y1={rowY + barHeight / 2 - 8} x2={margin.left} y2={rowY + barHeight / 2 - 8} stroke="var(--cv-rule)" strokeWidth={0.5} />
</g>
)
})}
{/* X-axis */}
<line x1={margin.left} y1={height + modelResults.length * 16} x2={width - margin.right} y2={height + modelResults.length * 16} stroke="var(--cv-rule)" strokeWidth={1} />
{[0, 0.5, 1].map((frac) => {
const val = frac * maxRate
return (
<text key={`x-${val}`} x={margin.left + frac * (width - margin.left - margin.right)} y={height + modelResults.length * 24} textAnchor="middle" fontSize="0.6rem" fill="var(--cv-ink-3)">{val.toFixed(1)}</text>
)
})}
<text x={margin.left + (width - margin.left - margin.right) / 2} y={height + modelResults.length * 30} textAnchor="middle" fontSize="0.7rem" fill="var(--cv-ink-3)">Rate per 1,000 students</text>
{/* Legend */}
<g transform={`translate(${margin.left}, ${height + modelResults.length * 8})`} style={{ fontSize: '0.65rem' }}>
<rect x={0} y={0} width={12} height={12} fill="var(--cv-navy-600)" rx={2} />
<text x={18} y={10}>Observed rate</text>
<line x1={140} y1={6} x2={156} y2={6} stroke="var(--teal-600)" strokeWidth={2} />
<circle cx={148} cy={6} r={3} fill="var(--teal-600)" />
<text x={162} y={10}>Model (median + 95% interval)</text>
</g>
</svg>
{/* Note */}
<p style={{ fontSize: '0.72rem', color: 'var(--cv-ink-3)', marginTop: 'var(--space-1)' }}>
Solid blue = observed rate per 1,000 students. Teal outline + median line shows each model's posterior distribution (95% interval). Dashed lines indicate the range of plausible values according to each Bayesian specification.
</p>
</div>
)
}
function getLabel(race, sex) {
const r = race === 'WH' ? 'White' : race === 'BL' ? 'Black' : race === 'HI' ? 'Hispanic' : 'American Indian/Alaskan Native'
const s = sex === 'F' ? 'Female' : 'Male'
return `${r} ${s}`
}
+112
View File
@@ -0,0 +1,112 @@
/**
* Chart 2: Arrest rate per 1,000 students by student group (most recent year).
* Bar chart — observed rate vs. modeled median rate.
*/
const GROUP_COLORS = {
'White Female': '#4A74B0', // navy-400
'White Male': '#2E5590', // navy-500
'Black Female': '#AB00AA', // plum-like (from social media posts)
'Black Male': '#8B007F', // darker purple
'Hispanic Female': '#1FAA4A', // green-ish (from wp_group_palette)
'Hispanic Male': '#15903C',
'AI/AN Female': '#D6271E', // red for American Indian emphasis
'AI/AN Male': '#A6271D'
}
export default function RateByGroupBar({ data }) {
const width = 280
const height = 240
const margin = { top: 35, right: 20, bottom: 80, left: 45 }
const innerWidth = width - margin.left - margin.right
const barHeight = Math.min(16, (innerWidth * 0.7) / data.length)
// Y-axis scale — per 1k rate
const maxRate = Math.max(...data.map(d => d.observedRate), ...data.map(d => d.modeledMedian || 0), 0.5)
return (
<div className="cv-card" style={{ padding: 'var(--space-2)' }}>
{/* Chart title */}
<h3 style={{ fontSize: '0.85rem', marginBottom: 'var(--space-1)', color: 'var(--cv-ink-2)' }}>
Arrest rate by student group — 2021–22 (per 1,000)
</h3>
<svg width="100%" viewBox={`0 0 ${width} ${height}`} style={{ maxWidth: '100%' }}>
{/* Background */}
<rect x={margin.left} y={margin.top} width={innerWidth} height={height - margin.top - margin.bottom}
fill="var(--cv-paper-2)" rx={4} />
{/* Y-axis gridlines */}
{[0, 0.25, 0.5, 0.75, 1].map((frac) => {
const val = frac * maxRate
if (val > maxRate) return null
const y = margin.top + ((height - margin.top - margin.bottom) - (val / maxRate) * (height - margin.top - margin.bottom))
return (
<g key={`yg-${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>
)
})}
{/* Y-axis label */}
<text x={12} y={margin.top + (height - margin.top - margin.bottom) / 2} textAnchor="middle"
fontSize="0.7rem" fill="var(--cv-ink-3)" transform={`rotate(-90 12 ${margin.top + (height - margin.top - margin.bottom) / 2})`}>
Rate per 1,000 students
</text>
{/* Bars */}
{data.map((d, i) => {
const y = margin.top + i * barHeight + 4
const observedWidth = (d.observedRate / maxRate) * innerWidth
const modeledWidth = ((d.modeledMedian || d.observedRate) / maxRate) * innerWidth
return (
<g key={`${d.race}-${d.sex}`}>
{/* Observed bar */}
{d.observedArrests > 0 && (
<>
<rect x={margin.left} y={y + 2} width={observedWidth} height={barHeight - 4}
fill="var(--cv-navy-600)" rx={2} />
{/* Observed count label */}
{d.observedRate > maxRate * 0.1 && (
<text x={margin.left + observedWidth + 4} y={y + barHeight / 2 + 4}
fontSize="0.65rem" fill="var(--cv-ink)">{d.observedArrests}</text>
)}
</>
)}
{/* Modeled median (dashed outline) */}
{d.modeledMedian > 0 && d.modeledMedian !== d.observedRate * (d.stu_enroll || 1) / 1000 && (
<rect x={margin.left} y={y + 2} width={modeledWidth} height={barHeight - 4}
fill="none" stroke="var(--teal-600)" strokeWidth={1.5} rx={2} />
)}
{/* Group label */}
<text x={margin.left - 8} y={y + barHeight / 2 + 4} textAnchor="end"
fontSize="0.7rem" fill="var(--cv-ink)">{d.label}</text>
</g>
)
})}
{/* X-axis label */}
<text x={margin.left + innerWidth / 2} y={height - 10} textAnchor="middle"
fontSize="0.7rem" fill="var(--cv-ink-3)">Solid = observed · Dashed = modeled median</text>
{/* Legend */}
<g transform={`translate(${margin.left}, ${height - 40})`}>
<rect x={0} y={0} width={12} height={12} fill="var(--cv-navy-600)" rx={2} />
<text x={18} y={10} fontSize="0.65rem" fill="var(--cv-ink)">Observed arrests</text>
<rect x={140} y={0} width={12} height={12} fill="none" stroke="var(--teal-600)" strokeWidth={1.5} rx={2} />
<text x={158} y={10} fontSize="0.65rem" fill="var(--cv-ink)">Modeled median</text>
</g>
</svg>
{/* Note */}
<p style={{ fontSize: '0.72rem', color: 'var(--cv-ink-3)', marginTop: 'var(--space-1)' }}>
Bars show observed arrests per 1,000 students. Dashed outlines show the Bayesian model's median estimate (unified_m2_mod).
</p>
</div>
)
}
+151
View File
@@ -0,0 +1,151 @@
import { useState, useEffect } from 'react'
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 ObservedRateDensity from '../charts/ObservedRateDensity.jsx'
import ExceedanceProbability from '../charts/ExceedanceProbability.jsx'
/**
* ChartPanel — 6 charts in a responsive grid.
* Fetches all required data on mount (since LoadingAnimation already loaded it, but we need to re-fetch structured).
*/
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 [loading, setLoading] = useState(true)
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('/data/national_rates.json')
nat = await res.json()
}
setNationalRates(nat)
// Fetch all 3 waves × default model (unified_m2_mod) for Chart 1 + Charts 2-3
const waves = ['21-22', '17-18', '15-16']
const waveData = {}
for (const year of waves) {
const res = await fetch(
`https://crdc-api.civilytics.org/api/v1/estimates/${district.leaid}?model=unified_m2_mod&year=${year}`
)
if (res.ok) waveData[year] = (await res.json()).data || []
}
// Fetch all 4 quadrant models for Charts 4-6 (8 groups × 4 models, but we batch by model)
const quadrants = ['unified_m1_mod', 'unified_m2_mod', 'unified_m3_mod', 'unified_m4_mod']
const quadData = {}
for (const model of quadrants) {
const res = await fetch(
`https://crdc-api.civilytics.org/api/v1/estimates/${district.leaid}?model=${model}&year=21-22`
)
if (res.ok) quadData[model] = (await res.json()).data || []
}
setData({ waveData, quadData })
} catch (err) {
console.error('ChartPanel data fetch failed:', err)
} finally {
setLoading(false)
}
}
fetchData()
}, [district.leaid])
if (loading || !data) {
return <LoadingCharts />
}
// ——— Derive chart-specific data from the fetched estimates ———
const mostRecent = data.waveData['21-22'] || []
// Chart 1: Arrests over time by wave (total + rate per 1k labels)
const timeSeriesData = ['15-16', '17-18', '21-22'].map(year => {
const yearRows = data.waveData[year] || []
// Sum observed_arrests and stu_enroll across all groups for this wave
const totalArrests = (yearRows || []).reduce((sum, r) => sum + (r.observed_arrests || 0), 0)
const totalEnroll = (yearRows || []).reduce((sum, r) => sum + (r.stu_enroll || 0), 0)
return { year, label: `20${year.replace('-', '-')}`, arrests: totalArrests, enroll: totalEnroll }
})
// Chart 2: Rate by student group (most recent wave, per 1k)
const rateByGroup = mostRecent.map(r => ({
race: r.race, sex: r.sex, label: getStudentLabel(r.race, r.sex),
observedRate: (r.observed_arrests || 0) / ((r.stu_enroll || 1) / 1000),
modeledMedian: (r.count_median || 0) / ((r.stu_enroll || 1) / 1000),
observedArrests: r.observed_arrests || 0,
enrollment: r.stu_enroll || 0
})).sort((a, b) => b.observedRate - a.observedRate)
// Chart 3: District vs national — top student group by rate
const topGroup = rateByGroup[0] || { race: 'BL', sex: 'M' }
const natKey = `${topGroup.race}_${topGroup.sex}`
const nationalTopRate = nationalRates?.rates_per_1000?.[natKey] || 0
// Chart 4-6 use quadData (4 models × 8 groups)
const chart4Data = { timeSeries: mostRecent, waves: data.waveData }
const chart5Data = data.quadData // all 4 quadrant model results
const chart6Data = { rates: rateByGroup, nationalRates: nationalRates?.rates_per_1000 || {} }
return (
<div style={{ padding: 'var(--space-3) 0 var(--space-7)' }}>
{/* Chart panel header */}
<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>
{/* Responsive grid: 3 columns on desktop, 1 on mobile */}
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(3, 1fr)',
gap: 'var(--space-4)'
}}>
{/* Row 1 — Observed & descriptive */}
<ArrestsOverTime data={timeSeriesData} districtName={district.lea_name} />
<RateByGroupBar data={rateByGroup} />
<DistrictVsNational topGroup={topGroup} nationalRate={nationalTopRate} rateByGroup={rateByGroup} />
{/* Row 2 — Bayesian model distributions */}
<ModelDrawsComparison data={chart4Data} waves={['15-16', '17-18', '21-22']} districtName={district.lea_name} />
<ObservedRateDensity quadData={chart5Data} />
<ExceedanceProbability rateByGroup={rateByGroup} nationalRates={nationalRates?.rates_per_1000 || {}} />
</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.
</p>
</div>
</div>
)
}
function getStudentLabel(race, sex) {
const labels = { WH: 'White', BL: 'Black', HI: 'Hispanic', AM: 'American Indian/Alaskan Native' }
return `${labels[race] || race} ${sex === 'F' ? 'Female' : 'Male'}`.replace('American Indian/Alaskan Native', 'AI/AN')
}
function LoadingCharts() {
return (
<div style={{ padding: 'var(--space-5) 0 var(--space-7)', textAlign: 'center' }}>
<span className="eyebrow">Preparing charts</span>
<p style={{ color: 'var(--cv-ink-2)' }}>Organizing data across all model specifications…</p>
</div>
)
}
+175
View File
@@ -0,0 +1,175 @@
import { useState, useEffect } from 'react'
import { searchDistricts, fetchStateDistricts } from '../hooks/useApi.js'
/**
* District search screen with:
* 1. "Interesting" suggestions — districts with the most arrests in the selected state (fetched once)
* 2. Live-search as you type → /api/v1/districts?q=...&state=XX
*/
export default function DistrictSearch({ state, onSelect, onBack }) {
const [query, setQuery] = useState('')
const [searchResults, setSearchResults] = useState([])
const [suggestions, setSuggestions] = useState([]) // "interesting" districts sorted by arrests desc
const [loadingSugg, setLoadingSugg] = useState(true)
const [loadingSearch, setLoadingSearch] = useState(false)
// ——— Fetch interesting suggestions (top arrests in this state) once on mount ———
useEffect(() => {
let cancelled = false
async function loadSuggestions() {
try {
// /estimates?state=XX&year=21-22 returns all districts; sort by observed_arrests desc client-side
const data = await fetchStateDistricts(state, '21-22', 500)
// Aggregate arrests per district (sum across race×sex groups), then sort
const byLeaid = {}
for (const row of data) {
if (!byLeaid[row.leaid]) {
byLeaid[row.leaid] = { leaid: row.leaid, lea_name: row.lea_name, state: row.state, observed_arrests: 0 }
}
byLeaid[row.leaid].observed_arrests += (row.observed_arrests || 0)
}
const sorted = Object.values(byLeaid).sort((a, b) => b.observed_arrests - a.observed_arrests)
if (!cancelled) {
// Take top 8 for suggestions; filter to those with >0 arrests
setSuggestions(sorted.filter(d => d.observed_arrests > 0).slice(0, 8))
}
} catch (err) {
console.error('Failed to load interesting districts:', err)
if (!cancelled) setSuggestions([]) // degrade gracefully — just show search box
} finally {
if (!cancelled) setLoadingSugg(false)
}
}
loadSuggestions()
return () => { cancelled = true }
}, [state])
// ——— Live search as user types (debounced by React's natural event batching) ———
useEffect(() => {
if (!query.trim()) { setSearchResults([]); return }
const timer = setTimeout(async () => {
setLoadingSearch(true)
try {
const data = await searchDistricts(query, state)
setSearchResults(data || [])
} catch (err) {
console.error('District search failed:', err)
setSearchResults([])
} finally {
setLoadingSearch(false)
}
}, 300)
return () => clearTimeout(timer)
}, [query, state])
// ——— Render a district card (used for both suggestions and search results) ———
const renderDistrictCard = (dist) => (
<button
key={dist.leaid}
className="cv-card"
style={{ textAlign: 'left', padding: 'var(--space-2)' }}
onClick={() => onSelect(dist)}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}>
<span style={{ fontWeight: 600, color: 'var(--cv-navy-700)', fontSize: '0.95rem' }}>
{dist.lea_name || dist.name}
</span>
{dist.observed_arrests !== undefined && (
<span className="stat-label accent" style={{ marginTop: 0 }}>
{dist.observed_arrests.toLocaleString()} arrests in 2021–22
</span>
)}
</div>
</button>
)
return (
<div style={{ padding: 'var(--space-5) 0 var(--space-7)' }}>
{/* Back button */}
<button className="btn-outline" onClick={onBack} style={{ marginBottom: 'var(--space-3)' }}>
← Change state
</button>
<h2 style={{ marginTop: 0, marginBottom: 'var(--space-1)' }}>
{state === 'DC' ? "District of Columbia" : `Find a district in ${getStateName(state)}`}
</h2>
<p style={{ color: 'var(--cv-ink-2)', marginBottom: 'var(--space-3)' }}>
Search by district name, or try one of the suggested districts with the highest arrest counts.
</p>
{/* "Interesting" suggestions */}
{!query && (
<div style={{ marginTop: 'var(--space-4)' }}>
<span className="eyebrow">Suggested districts — most arrests in 2021–22</span>
{loadingSugg ? (
<p style={{ color: 'var(--cv-ink-3)', padding: 'var(--space-2) 0' }}>Loading suggestions…</p>
) : suggestions.length > 0 ? (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: 'var(--space-1)' }}>
{suggestions.map(renderDistrictCard)}
</div>
) : (
<p style={{ color: 'var(--cv-ink-3)', padding: 'var(--space-2) 0' }}>
No districts with reported arrests found in this state. Try searching by name below.
</p>
)}
</div>
)}
{/* Live search */}
<div style={{ marginTop: query ? 0 : 'var(--space-5)' }}>
<label htmlFor="district-search" className="stat-label">Search district name:</label>
<input
id="district-search"
type="text"
placeholder="Type a district name… (e.g., Madison, Derby, Paterson)"
value={query}
onChange={(e) => setQuery(e.target.value)}
style={{
width: '100%', padding: 'var(--space-2)', fontSize: '1rem',
border: '1px solid var(--cv-rule)', borderRadius: 'var(--radius-md)',
fontFamily: 'var(--font-sans)'
}}
/>
{query && (
<div style={{ marginTop: 'var(--space-2)' }}>
{loadingSearch ? (
<p style={{ color: 'var(--cv-ink-3)' }}>Searching…</p>
) : searchResults.length > 0 ? (
<div style={{ display: 'grid', gap: 'var(--space-1)' }}>
{searchResults.slice(0, 20).map(renderDistrictCard)}
</div>
) : (
<p style={{ color: 'var(--cv-ink-3)' }}>No districts found matching "{query}".</p>
)}
{/* Back to suggestions */}
{searchResults.length > 0 && (
<button className="btn-outline" onClick={() => setQuery('')} style={{ marginTop: 'var(--space-2)' }}>
← Show suggested districts again
</button>
)}
</div>
)}
</div>
{/* Hint */}
<p style={{ fontSize: '0.85rem', color: 'var(--cv-ink-3)', marginTop: 'var(--space-4)' }}>
Tip: Try districts like Derby (KS), Paterson (NJ), or Mobile County (AL) — they have notable arrest rates.
</p>
</div>
)
}
function getStateName(code) {
const names = {
AL:'Alabama',AK:'Alaska',AZ:'Arizona',AR:'Arkansas',CA:'California',CO:'Colorado',CT:'Connecticut',DE:'Delaware',DC:'District of Columbia',FL:'Florida',GA:'Georgia',HI:'Hawaii',ID:'Idaho',IL:'Illinois',IN:'Indiana',IA:'Iowa',KS:'Kansas',KY:'Kentucky',LA:'Louisiana',ME:'Maine',MD:'Maryland',MA:'Massachusetts',MI:'Michigan',MN:'Minnesota',MS:'Mississippi',MO:'Missouri',MT:'Montana',NE:'Nebraska',NV:'Nevada',NH:'New Hampshire',NJ:'New Jersey',NM:'New Mexico',NY:'New York',NC:'North Carolina',ND:'North Dakota',OH:'Ohio',OK:'Oklahoma',OR:'Oregon',PA:'Pennsylvania',RI:'Rhode Island',SC:'South Carolina',SD:'South Dakota',TN:'Tennessee',TX:'Texas',UT:'Utah',VT:'Vermont',VA:'Virginia',WA:'Washington',WV:'West Virginia',WI:'Wisconsin',WY:'Wyoming'
}
return names[code] || code
}
+24
View File
@@ -0,0 +1,24 @@
/**
* Footer — matches the Civilytics visual style from theme/civilytics.scss.
*/
export default function Footer() {
return (
<footer className="cv-footer cv-wrap" style={{ marginTop: 'var(--space-5)' }}>
<span>© Civilytics — social science for the public good.</span>
<div style={{ display: 'flex', gap: 'var(--space-2)', flexWrap: 'wrap' }}>
<a href="https://civilytics.com">civilytics.com</a>
<a href="https://github.com/civilytics/crdc-arrests" target="_blank" rel="noopener noreferrer">Source code</a>
<a href="https://crdc-api.civilytics.org/api/v1/" target="_blank" rel="noopener noreferrer">API docs</a>
</div>
{/* Methodology footnote */}
<div style={{ width: '100%', marginTop: 'var(--space-2)', padding: 'var(--space-1) 0', borderTop: '1px solid var(--cv-rule)' }}>
<span className="stat-label">About this demo</span>
<p style={{ fontSize: '0.8rem', color: 'var(--cv-ink-2)', marginTop: 'var(--space-0.5)' }}>
This application demonstrates the CRDC School Arrest Rate API, which provides Bayesian small-area estimates of school-based arrest rates from the US Department of Education Civil Rights Data Collection. Estimates use hierarchical models that improve precision for rare events — enabling meaningful comparisons across districts, student groups, and time. Cite: Knowles & Miller 2025.
</p>
</div>
</footer>
)
}
+234
View File
@@ -0,0 +1,234 @@
import { useState, useEffect } from 'react'
import ChartPanel from './ChartPanel.jsx'
// 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 },
]
// CRDC waves to fetch (for Chart 1 — time series)
const WAVES = ['21-22', '17-18', '15-16']
/**
* Loading screen with animated histogram grid.
* Fetches all required data in parallel, shows progress as bars fill.
* When complete → renders ChartPanel directly (no transition needed).
*/
export default function LoadingAnimation({ district, state }) {
const [loadedCount, setLoadedCount] = useState(0)
const [totalCalls, setTotalCalls] = useState(0)
const [error, setError] = useState(null)
// Build the full list of API calls needed for all 6 charts
useEffect(() => {
let cancelled = false
let count = 0
let total = 0
async function loadData() {
try {
// ——— Chart 1: Arrests over time (3 waves × default model) ———
const wavePromises = WAVES.map((year) =>
fetchDistrictEstimatesBatch(district.leaid, year)
.then(() => !cancelled && setLoadedCount(c => c + 1))
.catch(() => { /* individual failure doesn't block */ })
)
// ——— Charts 2 & 3: 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) ———
const modelPromises = MODEL_QUADRANTS.flatMap((quad) =>
STUDENT_GROUPS.map((sg) =>
fetchDistrictEstimatesBatch(district.leaid, '21-22', sg.race, sg.sex, quad.model)
.then(() => !cancelled && setLoadedCount(c => c + 1))
.catch(() => {})
)
)
// ——— National rates (from static fixture, instant) ———
const nationalPromise = fetch('/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 */)
await Promise.all([...allPromises, nationalPromise])
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)
}
} catch (err) {
if (!cancelled) setError(err.message || 'Failed to load data')
}
}
loadData()
return () => { cancelled = true }
}, [district.leaid])
// ——— Render the histogram grid animation or ChartPanel when done ———
const isComplete = loadedCount >= totalCalls && totalCalls > 0
if (isComplete) {
return <ChartPanel district={district} state={state} />
}
if (error) {
return renderError(error, district)
}
// Build the grid of "bars" — one per API call needed
const allBars = [
...WAVES.map((y, i) => ({ label: `Wave ${y}`, subLabel: 'Time series', col: 0, row: i })),
...STUDENT_GROUPS.map((sg, i) => ({ label: sg.label, subLabel: 'Rate by group', col: 1, row: i })),
...MODEL_QUADRANTS.flatMap((q, qi) =>
STUDENT_GROUPS.map((sg, si) => ({
label: `${q.model.split('_m')[0].substring(0,3).toUpperCase()} M${qi+1}`,
subLabel: sg.label,
col: 2 + qi % 2, row: si, quadrant: q
}))
),
]
return (
<div style={{ padding: 'var(--space-5) 0 var(--space-7)' }}>
{/* Header */}
<span className="eyebrow">Fetching data from the CRDC Arrest Rate API</span>
<h2 style={{ marginTop: 'var(--space-2)', marginBottom: 'var(--space-3)' }}>
Building estimates for {district.lea_name} in {state === 'DC' ? "District of Columbia" : getStateName(state)}
</h2>
{/* Progress counter */}
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 'var(--space-3)' }}>
<span style={{ color: 'var(--cv-navy-600)', fontWeight: 600, fontSize: '1.1rem' }}>
{loadedCount} of {totalCalls || allBars.length} datasets loaded
</span>
<span className="stat-label">
{Math.round((loadedCount / (totalCalls || allBars.length)) * 100)}% complete
</span>
</div>
{/* Animated histogram grid — mirrors the Bayesian posterior draw theme */}
<div style={{
display: 'grid',
gridTemplateColumns: `repeat(${3}, 1fr)`,
gap: 'var(--space-2)',
marginTop: 'var(--space-4)'
}}>
{/* Column headers */}
{['Time series (Chart 1)', 'Observed rates (Charts 2–3)', 'Model comparisons (Charts 4–6)'].map((h, i) => (
<div key={i} style={{ textAlign: 'center', paddingBottom: 'var(--space-1)' }}>
<span className="stat-label" style={{ display: 'block' }}>{h}</span>
</div>
))}
{/* Grid of animated bars — each represents one API call */}
{allBars.slice(0, totalCalls || allBars.length).map((bar, i) => {
const filled = i < loadedCount
// Random height for "data viz" aesthetic (deterministic via seed = index)
const heightSeed = ((i * 37) % 100) + 20 // 20–120px range
return (
<div key={i} style={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
{/* The animated bar */}
<div style={{
width: '100%', height: `${heightSeed}px`,
background: filled
? `var(--cv-navy-${600 - (i % 2) * 100})`
: 'var(--paper-3)',
border: '1px solid var(--cv-rule)',
borderRadius: '4px 4px 0 0',
transition: 'height 300ms ease, background 300ms ease',
position: 'relative',
overflow: 'hidden'
}}>
{filled && (
<div style={{
position: 'absolute', bottom: 0, left: 0, right: 0, height: '100%',
background: `linear-gradient(to top, var(--teal-600) 0%, transparent ${70 + (i % 30)}%)`,
opacity: 0.4, transition: 'opacity 500ms ease'
}} />
)}
</div>
{/* Label below */}
<span style={{ fontSize: '0.65rem', color: filled ? 'var(--cv-ink)' : 'var(--cv-ink-4)', marginTop: '0.25rem', textAlign: 'center' }}>
{bar.subLabel}
</span>
</div>
)
})}
</div>
{/* Context message */}
<p style={{ fontSize: '0.9rem', color: 'var(--cv-ink-2)', marginTop: 'var(--space-4)' }}>
Fetching {totalCalls || allBars.length} datasets across 3 CRDC waves, 8 student groups, and 4 Bayesian model specifications...
</p>
{/* Model legend */}
<div style={{ display: 'flex', gap: 'var(--space-2)', marginTop: 'var(--space-3)', flexWrap: 'wrap' }}>
{MODEL_QUADRANTS.map((q, i) => (
<span key={i} className="stat-label" style={{ display: 'inline-flex', alignItems: 'center', gap: '0.4rem' }}>
<span style={{ width: 12, height: 12, background: `var(--cv-navy-${600 - i * 150})`, borderRadius: 2 }} />
{q.label}
</span>
))}
</div>
</div>
)
}
// ——— Inline fetch helper (avoids circular import with useApi.js) ———
async function fetchDistrictEstimatesBatch(leaid, year, race = null, sex = null, model = 'unified_m2_mod') {
const params = new URLSearchParams({ leaid, year, model })
if (race) params.set('race', race)
if (sex) params.set('sex', sex)
const res = await fetch(`https://crdc-api.civilytics.org/api/v1/estimates/${leaid}?${params}`)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return res.json()
}
function getStateName(code) {
const names = {
AL:'Alabama',AK:'Alaska',AZ:'Arizona',AR:'Arkansas',CA:'California',CO:'Colorado',CT:'Connecticut',DE:'Delaware',DC:'District of Columbia',FL:'Florida',GA:'Georgia',HI:'Hawaii',ID:'Idaho',IL:'Illinois',IN:'Indiana',IA:'Iowa',KS:'Kansas',KY:'Kentucky',LA:'Louisiana',ME:'Maine',MD:'Maryland',MA:'Massachusetts',MI:'Michigan',MN:'Minnesota',MS:'Mississippi',MO:'Missouri',MT:'Montana',NE:'Nebraska',NV:'Nevada',NH:'New Hampshire',NJ:'New Jersey',NM:'New Mexico',NY:'New York',NC:'North Carolina',ND:'North Dakota',OH:'Ohio',OK:'Oklahoma',OR:'Oregon',PA:'Pennsylvania',RI:'Rhode Island',SC:'South Carolina',SD:'South Dakota',TN:'Tennessee',TX:'Texas',UT:'Utah',VT:'Vermont',VA:'Virginia',WA:'Washington',WV:'West Virginia',WI:'Wisconsin',WY:'Wyoming'
}
return names[code] || code
}
function renderError(error, district) {
return (
<div style={{ padding: 'var(--space-5) 0 var(--space-7)', textAlign: 'center' }}>
<span className="stat-label" style={{ color: 'var(--cv-danger)' }}>ERROR</span>
<h3 style={{ marginTop: 'var(--space-2)' }}>Failed to load data for {district.lea_name}</h3>
<p style={{ color: 'var(--cv-ink-2)', maxWidth: '40rem', margin: '0 auto var(--space-3)' }}>
{error}. This may be a temporary API issue or the district may not have available estimates.
</p>
<button className="btn-outline" onClick={() => window.location.href = '/'}>
← Try another district
</button>
</div>
)
}
+137
View File
@@ -0,0 +1,137 @@
import { useState } from 'react'
// All 50 states + DC, matching the API's ALLOWED_STATES
const STATES = [
'AL','AK','AZ','AR','CA','CO','CT','DE','DC','FL','GA','HI',
'ID','IL','IN','IA','KS','KY','LA','ME','MD','MA','MI','MN','MS','MO','MT',
'NE','NV','NH','NJ','NM','NY','NC','ND','OH','OK','OR','PA','RI','SC','SD',
'TN','TX','UT','VT','VA','WA','WV','WI','WY'
]
const STATE_NAMES = {
AL: 'Alabama', AK: 'Alaska', AZ: 'Arizona', AR: 'Arkansas', CA: 'California',
CO: 'Colorado', CT: 'Connecticut', DE: 'Delaware', DC: 'District of Columbia', FL: 'Florida',
GA: 'Georgia', HI: 'Hawaii', ID: 'Idaho', IL: 'Illinois', IN: 'Indiana', IA: 'Iowa',
KS: 'Kansas', KY: 'Kentucky', LA: 'Louisiana', ME: 'Maine', MD: 'Maryland', MA: 'Massachusetts',
MI: 'Michigan', MN: 'Minnesota', MS: 'Mississippi', MO: 'Missouri', MT: 'Montana',
NE: 'Nebraska', NV: 'Nevada', NH: 'New Hampshire', NJ: 'New Jersey', NM: 'New Mexico',
NY: 'New York', NC: 'North Carolina', ND: 'North Dakota', OH: 'Ohio', OK: 'Oklahoma',
OR: 'Oregon', PA: 'Pennsylvania', RI: 'Rhode Island', SC: 'South Carolina', SD: 'South Dakota',
TN: 'Tennessee', TX: 'Texas', UT: 'Utah', VT: 'Vermont', VA: 'Virginia',
WA: 'Washington', WV: 'West Virginia', WI: 'Wisconsin', WY: 'Wyoming'
}
/**
* Landing screen — prompts user to select a state.
* Matches the Civilytics visual style with eyebrow label, stat callout, and clean card layout.
*/
export default function StateSelector({ onNext }) {
const [query, setQuery] = useState('')
// Filter states by code or name as user types
const filtered = STATES.filter(
(code) =>
code.toLowerCase().includes(query.toLowerCase()) ||
STATE_NAMES[code].toLowerCase().includes(query.toLowerCase())
)
return (
<div style={{ padding: 'var(--space-5) 0 var(--space-7)' }}>
{/* Eyebrow */}
<span className="eyebrow">US Department of Education Civil Rights Data Collection</span>
{/* Hero */}
<h1 style={{ marginTop: 'var(--space-2)', marginBottom: 'var(--space-3)' }}>
Explore school-based arrest rates by district
</h1>
<p style={{ fontSize: '1.1rem', lineHeight: 1.7, maxWidth: '48rem' }}>
The Civil Rights Data Collection (CRDC) is the only source of data on school-related
arrests for all U.S. public schools and districts. Enter a state to see estimates for
any district — with Bayesian model comparisons that reveal how confident we can be in
each rate, even when arrests are rare events.
</p>
{/* Stat callout — key context */}
<div className="stat-callout" style={{ marginTop: 'var(--space-4)' }}>
<div>
<span className="stat-num">34,846</span>
<span className="stat-label">students arrested in 2021–22</span>
</div>
<div>
<span className="stat-num">0.72</span>
<span className="stat-label accent">arrests per 1,000 students (national rate)</span>
</div>
<div>
<span className="stat-num">11.6%</span>
<span className="stat-label">of districts reported &gt;0 arrests</span>
</div>
</div>
{/* Search input */}
<div style={{ marginTop: 'var(--space-5)' }}>
<label htmlFor="state-search" style={{ display: 'block', marginBottom: 'var(--space-2)', fontWeight: 600 }}>
Select a state to begin:
</label>
<input
id="state-search"
type="text"
placeholder="Type a state name or code (e.g., Texas, TX)…"
value={query}
onChange={(e) => setQuery(e.target.value)}
style={{
width: '100%', padding: 'var(--space-2)', fontSize: '1rem',
border: '1px solid var(--cv-rule)', borderRadius: 'var(--radius-md)',
fontFamily: 'var(--font-sans)'
}}
autoFocus
/>
{/* Filtered grid of states */}
{filtered.length > 0 && (
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(120px, 1fr))',
gap: 'var(--space-1)',
marginTop: 'var(--space-3)'
}}>
{filtered.map((code) => (
<button
key={code}
className="cv-card"
style={{ textAlign: 'center', padding: 'var(--space-2)' }}
onClick={() => onNext(code)}
>
<span style={{ fontSize: '1.25rem', fontWeight: 700, color: 'var(--cv-navy-600)' }}>
{code}
</span>
<span className="stat-label" style={{ marginTop: '0.25rem' }}>
{STATE_NAMES[code]}
</span>
</button>
))}
</div>
)}
{filtered.length === 0 && (
<p style={{ color: 'var(--cv-ink-3)', marginTop: 'var(--space-2)' }}>
No states match "{query}". Try a full state name or two-letter code.
</p>
)}
</div>
{/* Context footer */}
<div style={{ marginTop: 'var(--space-5)', paddingBottom: 'var(--space-3)', borderBottom: '1px solid var(--cv-rule)' }}>
<span className="eyebrow">How it works</span>
<p style={{ fontSize: '0.9rem', color: 'var(--cv-ink-2)', marginTop: 'var(--space-1)' }}>
After selecting a state, you'll search for a school district and see arrest estimates from 10 Bayesian models —
including one-year vs. three-year specifications and baseline vs. referral-rate covariate models.
The app fetches data directly from the{' '}
<a href="https://crdc-api.civilytics.org/api/v1/" target="_blank" rel="noopener noreferrer">
CRDC Arrest Rate API
</a>. No account or key required.
</p>
</div>
</div>
)
}
+44
View File
@@ -0,0 +1,44 @@
{
"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
}
}
+123
View File
@@ -0,0 +1,123 @@
/**
* Civilytics CRDC Arrest Rate API client.
* Wraps fetch() with retry, backoff, and typed error handling.
* Base URL: https://crdc-api.civilytics.org/api/v1
*/
const BASE_URL = import.meta.env.VITE_API_BASE || 'https://crdc-api.civilytics.org/api/v1'
// Student group labels matching the API enum (race=AM|BL|HI|WH; sex=F|M)
export const STUDENT_GROUPS = [
{ race: 'WH', sex: 'F', label: 'White Female' },
{ race: 'WH', sex: 'M', label: 'White Male' },
{ race: 'BL', sex: 'F', label: 'Black Female' },
{ race: 'BL', sex: 'M', label: 'Black Male' },
{ race: 'HI', sex: 'F', label: 'Hispanic Female' },
{ race: 'HI', sex: 'M', label: 'Hispanic Male' },
{ race: 'AM', sex: 'F', label: 'American Indian\nFemale' },
{ race: 'AM', sex: 'M', label: 'American Indian\nMale' },
]
// CRDC wave labels (year=15-16|17-18|21-22)
export const CRDC_WAVES = [
{ value: '15-16', label: '2015–16' },
{ value: '17-18', label: '2017–18' },
{ value: '21-22', label: '2021–22' },
]
// Model IDs returned by /models endpoint
export const MODEL_IDS = [
'unified_m1_mod', 'unified_m2_mod', 'unified_m3_mod', 'unified_m4_mod', 'unified_m5_mod',
'stratified_m1_mod', 'stratified_m2_mod', 'stratified_m3_mod', 'stratified_m4_mod', 'stratified_m5_mod',
]
// The "four quadrants" for distribution charts (Chart 4–6)
export const MODEL_QUADRANTS = [
{ model: 'unified_m1_mod', label: 'One-year, no covariate', row: 0, col: 0 },
{ model: 'unified_m2_mod', label: 'One-year + referral rate', row: 1, col: 0 },
{ model: 'unified_m3_mod', label: 'Three-year, no covariate', row: 0, col: 1 },
{ model: 'unified_m4_mod', label: 'Three-year + referral rate', row: 1, col: 1 },
]
// Default/recommended model (from validate.R)
export const DEFAULT_MODEL = 'unified_m2_mod'
/** Exponential backoff fetch wrapper */
async function apiFetch(path, { retries = 3, delay = 500 } = {}) {
const url = `${BASE_URL}${path}`
let lastError
for (let attempt = 0; attempt <= retries; attempt++) {
try {
const res = await fetch(url, { signal: AbortSignal.timeout(30000) })
if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`)
/** @type {{status:string,data:any,error?:string,meta:object}} */
const envelope = await res.json()
if (envelope.status === 'success') return envelope.data
throw new Error(envelope.error || 'Unknown API error')
} catch (err) {
lastError = err
if (attempt < retries && !(err instanceof DOMException)) {
const wait = delay * Math.pow(2, attempt) + Math.random() * 100
await new Promise((r) => setTimeout(r, wait))
} else {
throw lastError
}
}
}
throw lastError
}
// ——— API endpoint wrappers (return the `data` array from the envelope) ———
/** GET /models — static list of available Bayesian model specifications */
export async function fetchModels() {
return apiFetch('/models')
}
/** GET /districts?q=<partial>&state=XX — name/geo lookup → LEAID */
export async function searchDistricts(query, state) {
const params = new URLSearchParams({ q: query, state })
return apiFetch(`/districts?${params}`)
}
/** GET /estimates/{leaid}?model=X&year=Y — all demographics for one district/year/model */
export async function fetchDistrictEstimates(leaid, options = {}) {
const params = new URLSearchParams()
if (options.model) params.set('model', options.model)
if (options.year) params.set('year', options.year)
if (options.race) params.set('race', options.race)
if (options.sex) params.set('sex', options.sex)
const qs = params.toString()
return apiFetch(`/estimates/${leaid}${qs ? `?${qs}` : ''}`)
}
/** GET /states/{state}?race=X&sex=Y&year=Z — state-level aggregate for one group */
export async function fetchStateEstimates(state, options = {}) {
const params = new URLSearchParams()
if (options.race) params.set('race', options.race)
if (options.sex) params.set('sex', options.sex)
if (options.year) params.set('year', options.year)
const qs = params.toString()
return apiFetch(`/states/${state}${qs ? `?${qs}` : ''}`)
}
/** GET /estimates?state=XX&year=Y — all districts in a state for "interesting" suggestions */
export async function fetchStateDistricts(state, year = '21-22', limit = 500) {
return apiFetch(`/estimates?state=${state}&year=${year}&limit=${limit}`)
}
/** GET /draws?... — returns HF parquet shard URL + DuckDB SQL (bulk only, not used in browser app) */
export async function fetchDrawLocation(state, race, sex, year, model) {
const params = new URLSearchParams({ state })
if (race) params.set('race', race)
if (sex) params.set('sex', sex)
if (year) params.set('year', year)
if (model) params.set('model', model)
return apiFetch(`/draws?${params}`)
}
+10
View File
@@ -0,0 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.jsx'
import './styles/tokens.css'
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)
+266
View File
@@ -0,0 +1,266 @@
/* =============================================================
Civilytics Design Tokens — single source of truth.
Mirrored from crdc-arrests/theme/_tokens.scss so this demo app
stays in sync with the white paper / social media visual style.
Neutrals: warm paper → civic ink
Brand: Civic Navy (primary) + Ember (accent)
VIZ: Supporting palette for data series
============================================================ */
:root {
/* — Paper (backgrounds) — */
--cv-paper: #FAF7F2;
--cv-paper-2: #F2EDE4;
--cv-paper-3: #E6DFD1;
/* — Rule / borders — */
--cv-rule: #D6CEBD;
--cv-rule-strong: #B8AE97;
/* — Ink (text) — */
--cv-ink: #0E1A2B;
--cv-ink-2: #2B3A52;
--cv-ink-3: #5A6A82;
--cv-ink-4: #8C97AB;
/* — Civic Navy (primary brand) — */
--cv-navy-50: #EEF3FA;
--cv-navy-100: #DDE6F2;
--cv-navy-200: #B3C6E0;
--cv-navy-300: #7A9BCA;
--cv-navy-400: #4A74B0;
--cv-navy-500: #2E5590;
--cv-navy-600: #22406A; /* links, primary buttons */
--cv-navy-700: #1A2E4A; /* hover state */
/* — Ember (accent) — */
--cv-accent: #C25311; /* main accent color */
--cv-accent-text: #A04400;
--cv-accent-dark: #923D00;
--cv-accent-hover: #7A3600;
/* — Supporting data-viz palette (from _tokens.scss) — */
--teal-600: #1F6F70;
--plum-600: #6B3A5E;
--moss-600: #4A6B2F;
--brass-600: #B8751C;
/* — Semantic status colors — */
--cv-success: #4A6B2F; /* moss */
--cv-warning: #9A5F18;
--cv-danger: #A6271D;
/* — Font stacks (from _tokens.scss) — */
--font-display: 'Libre Franklin', 'Franklin Gothic', 'Inter', system-ui, sans-serif;
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
--font-mono: 'JetBrains Mono', 'SF Mono', Menlo, Consolas, monospace;
/* — Spacing scale (from _tokens.scss) — */
--space-1: 0.5rem;
--space-2: 1rem;
--space-3: 1.5rem;
--space-4: 2rem;
--space-5: 3rem;
--space-6: 4rem;
--space-7: 6rem;
/* — Border radius (from _tokens.scss) — */
--radius-sm: 4px;
--radius-md: 6px;
--radius-lg: 12px;
/* — Chart-specific colors — */
--chart-modeled: #000a9bff; /* dark indigo-blue (from wp_model_palette) */
--chart-frequentist: #858585bb; /* gray (from wp_model_palette) */
--chart-observed: #A6271D; /* red for observed data points */
/* — Model quadrant colors — */
--model-one-year-baseline: var(--navy-600);
--model-three-year-baseline: var(--teal-600);
}
/* =============================================================
Base reset + typography (from crdc-arrests/theme/civilytics.scss)
============================================================ */
* { box-sizing: border-box; }
html { font-size: 100%; -webkit-font-smoothing: antialiased; text-rendering: optimizeLegibility; }
body {
margin: 0;
background: var(--cv-paper);
color: var(--cv-ink);
font-family: var(--font-sans);
line-height: 1.65;
}
h1, h2, h3, h4 {
font-family: var(--font-display);
font-weight: 800;
letter-spacing: -0.025em;
line-height: 1.08;
color: var(--cv-ink);
text-wrap: pretty;
margin: 0 0 .4em;
}
h1 { font-size: clamp(2.25rem, 4.2vw, 3.75rem); font-weight: 900; letter-spacing: -0.035em; line-height: 1.0; }
h2 { font-size: clamp(1.75rem, 2.8vw, 2.375rem); }
h3 { font-size: 1.5rem; font-weight: 700; letter-spacing: -0.022em; color: var(--cv-ink-2); }
p, li { line-height: 1.7; text-wrap: pretty; }
a {
color: var(--cv-navy-600);
text-decoration: underline;
text-decoration-thickness: 1px;
text-underline-offset: 2px;
transition: color 120ms ease;
}
a:hover { color: var(--cv-navy-700); text-decoration-thickness: 2px; }
code, .mono { font-family: var(--font-mono); font-feature-settings: "tnum" 1, "zero" 1; }
:focus-visible { outline: 2px solid var(--cv-accent); outline-offset: 2px; }
/* =============================================================
Layout helpers (from civilytics.scss)
============================================================ */
.cv-wrap { max-width: 60rem; margin: 0 auto; padding: 0 var(--space-3); }
.cv-header {
display: flex; align-items: center; justify-content: space-between;
padding: var(--space-3) 0; border-bottom: 1px solid var(--cv-rule);
}
.cv-header .cv-logo { height: 28px; display: block; }
/* Eyebrow / section label (from civilytics.scss + extras.css) */
.eyebrow, .cv-eyebrow {
font-family: var(--font-sans);
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--cv-accent-text);
margin: 0 0 0.5rem;
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.eyebrow::before, .cv-eyebrow::before {
content: "";
display: inline-block;
width: 24px; height: 2px;
background: var(--cv-accent);
}
/* Stat callout — hero numbers (from extras.css) */
.stat-callout {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 2rem;
margin: 2rem 0 2.5rem;
padding: 1.5rem 0;
border-top: 3px double var(--cv-ink);
border-bottom: 1px solid var(--cv-rule);
}
.stat-callout .stat-num {
font-family: 'Source Serif 4', Georgia, serif;
font-weight: 700;
font-size: 3rem;
line-height: 1;
letter-spacing: -0.02em;
color: var(--cv-ink);
font-variant-numeric: tabular-nums;
display: block;
}
.stat-callout .stat-label {
font-family: var(--font-sans);
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--cv-ink-3);
margin-top: 0.4rem;
}
.stat-callout .stat-label.accent { color: var(--cv-accent); }
/* Card component (used for district suggestions, model cards) */
.cv-card {
display: flex;
flex-direction: column;
gap: 0.25rem;
background: #fff;
border: 1px solid var(--cv-rule);
border-top: 3px solid var(--cv-accent);
border-radius: var(--radius-lg);
padding: var(--space-2) var(--space-3);
text-decoration: none;
transition: border-color 120ms ease, box-shadow 120ms ease;
}
.cv-card:hover {
border-color: var(--cv-ink-3);
box-shadow: 0 2px 8px rgba(0,0,0,0.04);
}
/* Button (primary) */
.btn-primary {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.625rem 1.25rem;
background: var(--cv-navy-600);
color: #fff;
border: none;
border-radius: var(--radius-md);
font-family: var(--font-sans);
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
transition: background 120ms ease, transform 20ms ease;
}
.btn-primary:hover { background: var(--cv-navy-700); }
.btn-primary:active { transform: scale(0.98); }
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
/* Button (secondary / outline) */
.btn-outline {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background: transparent;
color: var(--cv-navy-600);
border: 1px solid var(--cv-rule);
border-radius: var(--radius-md);
font-family: var(--font-sans);
font-size: 0.85rem;
font-weight: 500;
cursor: pointer;
transition: background 120ms ease, color 120ms ease;
}
.btn-outline:hover {
background: var(--cv-paper-2);
color: var(--cv-navy-700);
}
/* Footer */
.cv-footer {
border-top: 1px solid var(--cv-rule);
padding: var(--space-4) 0;
color: var(--cv-ink-3);
font-size: 0.9rem;
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
justify-content: space-between;
align-items: center;
}
/* Responsive */
@media (max-width: 680px) { .cv-footer { flex-direction: column; text-align: center; } }