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,175 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { searchDistricts, fetchStateDistricts } from '../hooks/useApi.js'
|
||||
|
||||
/**
|
||||
* District search screen with:
|
||||
* 1. "Interesting" suggestions — districts with the most arrests in the selected state (fetched once)
|
||||
* 2. Live-search as you type → /api/v1/districts?q=...&state=XX
|
||||
*/
|
||||
export default function DistrictSearch({ state, onSelect, onBack }) {
|
||||
const [query, setQuery] = useState('')
|
||||
const [searchResults, setSearchResults] = useState([])
|
||||
const [suggestions, setSuggestions] = useState([]) // "interesting" districts sorted by arrests desc
|
||||
const [loadingSugg, setLoadingSugg] = useState(true)
|
||||
const [loadingSearch, setLoadingSearch] = useState(false)
|
||||
|
||||
// ——— Fetch interesting suggestions (top arrests in this state) once on mount ———
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
async function loadSuggestions() {
|
||||
try {
|
||||
// /estimates?state=XX&year=21-22 returns all districts; sort by observed_arrests desc client-side
|
||||
const data = await fetchStateDistricts(state, '21-22', 500)
|
||||
|
||||
// Aggregate arrests per district (sum across race×sex groups), then sort
|
||||
const byLeaid = {}
|
||||
for (const row of data) {
|
||||
if (!byLeaid[row.leaid]) {
|
||||
byLeaid[row.leaid] = { leaid: row.leaid, lea_name: row.lea_name, state: row.state, observed_arrests: 0 }
|
||||
}
|
||||
byLeaid[row.leaid].observed_arrests += (row.observed_arrests || 0)
|
||||
}
|
||||
|
||||
const sorted = Object.values(byLeaid).sort((a, b) => b.observed_arrests - a.observed_arrests)
|
||||
|
||||
if (!cancelled) {
|
||||
// Take top 8 for suggestions; filter to those with >0 arrests
|
||||
setSuggestions(sorted.filter(d => d.observed_arrests > 0).slice(0, 8))
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load interesting districts:', err)
|
||||
if (!cancelled) setSuggestions([]) // degrade gracefully — just show search box
|
||||
} finally {
|
||||
if (!cancelled) setLoadingSugg(false)
|
||||
}
|
||||
}
|
||||
|
||||
loadSuggestions()
|
||||
return () => { cancelled = true }
|
||||
}, [state])
|
||||
|
||||
// ——— Live search as user types (debounced by React's natural event batching) ———
|
||||
useEffect(() => {
|
||||
if (!query.trim()) { setSearchResults([]); return }
|
||||
|
||||
const timer = setTimeout(async () => {
|
||||
setLoadingSearch(true)
|
||||
try {
|
||||
const data = await searchDistricts(query, state)
|
||||
setSearchResults(data || [])
|
||||
} catch (err) {
|
||||
console.error('District search failed:', err)
|
||||
setSearchResults([])
|
||||
} finally {
|
||||
setLoadingSearch(false)
|
||||
}
|
||||
}, 300)
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
}, [query, state])
|
||||
|
||||
// ——— Render a district card (used for both suggestions and search results) ———
|
||||
const renderDistrictCard = (dist) => (
|
||||
<button
|
||||
key={dist.leaid}
|
||||
className="cv-card"
|
||||
style={{ textAlign: 'left', padding: 'var(--space-2)' }}
|
||||
onClick={() => onSelect(dist)}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}>
|
||||
<span style={{ fontWeight: 600, color: 'var(--cv-navy-700)', fontSize: '0.95rem' }}>
|
||||
{dist.lea_name || dist.name}
|
||||
</span>
|
||||
{dist.observed_arrests !== undefined && (
|
||||
<span className="stat-label accent" style={{ marginTop: 0 }}>
|
||||
{dist.observed_arrests.toLocaleString()} arrests in 2021–22
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
|
||||
return (
|
||||
<div style={{ padding: 'var(--space-5) 0 var(--space-7)' }}>
|
||||
{/* Back button */}
|
||||
<button className="btn-outline" onClick={onBack} style={{ marginBottom: 'var(--space-3)' }}>
|
||||
← Change state
|
||||
</button>
|
||||
|
||||
<h2 style={{ marginTop: 0, marginBottom: 'var(--space-1)' }}>
|
||||
{state === 'DC' ? "District of Columbia" : `Find a district in ${getStateName(state)}`}
|
||||
</h2>
|
||||
<p style={{ color: 'var(--cv-ink-2)', marginBottom: 'var(--space-3)' }}>
|
||||
Search by district name, or try one of the suggested districts with the highest arrest counts.
|
||||
</p>
|
||||
|
||||
{/* "Interesting" suggestions */}
|
||||
{!query && (
|
||||
<div style={{ marginTop: 'var(--space-4)' }}>
|
||||
<span className="eyebrow">Suggested districts — most arrests in 2021–22</span>
|
||||
{loadingSugg ? (
|
||||
<p style={{ color: 'var(--cv-ink-3)', padding: 'var(--space-2) 0' }}>Loading suggestions…</p>
|
||||
) : suggestions.length > 0 ? (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: 'var(--space-1)' }}>
|
||||
{suggestions.map(renderDistrictCard)}
|
||||
</div>
|
||||
) : (
|
||||
<p style={{ color: 'var(--cv-ink-3)', padding: 'var(--space-2) 0' }}>
|
||||
No districts with reported arrests found in this state. Try searching by name below.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Live search */}
|
||||
<div style={{ marginTop: query ? 0 : 'var(--space-5)' }}>
|
||||
<label htmlFor="district-search" className="stat-label">Search district name:</label>
|
||||
<input
|
||||
id="district-search"
|
||||
type="text"
|
||||
placeholder="Type a district name… (e.g., Madison, Derby, Paterson)"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
style={{
|
||||
width: '100%', padding: 'var(--space-2)', fontSize: '1rem',
|
||||
border: '1px solid var(--cv-rule)', borderRadius: 'var(--radius-md)',
|
||||
fontFamily: 'var(--font-sans)'
|
||||
}}
|
||||
/>
|
||||
|
||||
{query && (
|
||||
<div style={{ marginTop: 'var(--space-2)' }}>
|
||||
{loadingSearch ? (
|
||||
<p style={{ color: 'var(--cv-ink-3)' }}>Searching…</p>
|
||||
) : searchResults.length > 0 ? (
|
||||
<div style={{ display: 'grid', gap: 'var(--space-1)' }}>
|
||||
{searchResults.slice(0, 20).map(renderDistrictCard)}
|
||||
</div>
|
||||
) : (
|
||||
<p style={{ color: 'var(--cv-ink-3)' }}>No districts found matching "{query}".</p>
|
||||
)}
|
||||
|
||||
{/* Back to suggestions */}
|
||||
{searchResults.length > 0 && (
|
||||
<button className="btn-outline" onClick={() => setQuery('')} style={{ marginTop: 'var(--space-2)' }}>
|
||||
← Show suggested districts again
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Hint */}
|
||||
<p style={{ fontSize: '0.85rem', color: 'var(--cv-ink-3)', marginTop: 'var(--space-4)' }}>
|
||||
Tip: Try districts like Derby (KS), Paterson (NJ), or Mobile County (AL) — they have notable arrest rates.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user