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
+112
View File
@@ -0,0 +1,112 @@
/**
* Chart 2: Arrest rate per 1,000 students by student group (most recent year).
* Bar chart — observed rate vs. modeled median rate.
*/
const GROUP_COLORS = {
'White Female': '#4A74B0', // navy-400
'White Male': '#2E5590', // navy-500
'Black Female': '#AB00AA', // plum-like (from social media posts)
'Black Male': '#8B007F', // darker purple
'Hispanic Female': '#1FAA4A', // green-ish (from wp_group_palette)
'Hispanic Male': '#15903C',
'AI/AN Female': '#D6271E', // red for American Indian emphasis
'AI/AN Male': '#A6271D'
}
export default function RateByGroupBar({ data }) {
const width = 280
const height = 240
const margin = { top: 35, right: 20, bottom: 80, left: 45 }
const innerWidth = width - margin.left - margin.right
const barHeight = Math.min(16, (innerWidth * 0.7) / data.length)
// Y-axis scale — per 1k rate
const maxRate = Math.max(...data.map(d => d.observedRate), ...data.map(d => d.modeledMedian || 0), 0.5)
return (
<div className="cv-card" style={{ padding: 'var(--space-2)' }}>
{/* Chart title */}
<h3 style={{ fontSize: '0.85rem', marginBottom: 'var(--space-1)', color: 'var(--cv-ink-2)' }}>
Arrest rate by student group — 2021–22 (per 1,000)
</h3>
<svg width="100%" viewBox={`0 0 ${width} ${height}`} style={{ maxWidth: '100%' }}>
{/* Background */}
<rect x={margin.left} y={margin.top} width={innerWidth} height={height - margin.top - margin.bottom}
fill="var(--cv-paper-2)" rx={4} />
{/* Y-axis gridlines */}
{[0, 0.25, 0.5, 0.75, 1].map((frac) => {
const val = frac * maxRate
if (val > maxRate) return null
const y = margin.top + ((height - margin.top - margin.bottom) - (val / maxRate) * (height - margin.top - margin.bottom))
return (
<g key={`yg-${frac}`}>
<line x1={margin.left} y1={y} x2={margin.left + innerWidth} y2={y}
stroke="var(--cv-rule)" strokeWidth={1} />
<text x={margin.left - 8} y={y + 4} textAnchor="end" fontSize="0.65rem" fill="var(--cv-ink-3)">{val.toFixed(1)}</text>
</g>
)
})}
{/* Y-axis label */}
<text x={12} y={margin.top + (height - margin.top - margin.bottom) / 2} textAnchor="middle"
fontSize="0.7rem" fill="var(--cv-ink-3)" transform={`rotate(-90 12 ${margin.top + (height - margin.top - margin.bottom) / 2})`}>
Rate per 1,000 students
</text>
{/* Bars */}
{data.map((d, i) => {
const y = margin.top + i * barHeight + 4
const observedWidth = (d.observedRate / maxRate) * innerWidth
const modeledWidth = ((d.modeledMedian || d.observedRate) / maxRate) * innerWidth
return (
<g key={`${d.race}-${d.sex}`}>
{/* Observed bar */}
{d.observedArrests > 0 && (
<>
<rect x={margin.left} y={y + 2} width={observedWidth} height={barHeight - 4}
fill="var(--cv-navy-600)" rx={2} />
{/* Observed count label */}
{d.observedRate > maxRate * 0.1 && (
<text x={margin.left + observedWidth + 4} y={y + barHeight / 2 + 4}
fontSize="0.65rem" fill="var(--cv-ink)">{d.observedArrests}</text>
)}
</>
)}
{/* Modeled median (dashed outline) */}
{d.modeledMedian > 0 && d.modeledMedian !== d.observedRate * (d.stu_enroll || 1) / 1000 && (
<rect x={margin.left} y={y + 2} width={modeledWidth} height={barHeight - 4}
fill="none" stroke="var(--teal-600)" strokeWidth={1.5} rx={2} />
)}
{/* Group label */}
<text x={margin.left - 8} y={y + barHeight / 2 + 4} textAnchor="end"
fontSize="0.7rem" fill="var(--cv-ink)">{d.label}</text>
</g>
)
})}
{/* X-axis label */}
<text x={margin.left + innerWidth / 2} y={height - 10} textAnchor="middle"
fontSize="0.7rem" fill="var(--cv-ink-3)">Solid = observed · Dashed = modeled median</text>
{/* Legend */}
<g transform={`translate(${margin.left}, ${height - 40})`}>
<rect x={0} y={0} width={12} height={12} fill="var(--cv-navy-600)" rx={2} />
<text x={18} y={10} fontSize="0.65rem" fill="var(--cv-ink)">Observed arrests</text>
<rect x={140} y={0} width={12} height={12} fill="none" stroke="var(--teal-600)" strokeWidth={1.5} rx={2} />
<text x={158} y={10} fontSize="0.65rem" fill="var(--cv-ink)">Modeled median</text>
</g>
</svg>
{/* Note */}
<p style={{ fontSize: '0.72rem', color: 'var(--cv-ink-3)', marginTop: 'var(--space-1)' }}>
Bars show observed arrests per 1,000 students. Dashed outlines show the Bayesian model's median estimate (unified_m2_mod).
</p>
</div>
)
}