Initial commit: CRDC Arrests API demo app
Deploy to Git Pages / build-and-deploy (push) Failing after 19s
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:
+94
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 > 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)">< 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)">> 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')
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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}`
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 >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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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}`)
|
||||
}
|
||||
@@ -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>,
|
||||
)
|
||||
@@ -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; } }
|
||||
Reference in New Issue
Block a user