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:
@@ -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')
|
||||
Reference in New Issue
Block a user