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,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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user