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
+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')