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