Reduce demo to 3 charts, fix back-button district name bug
Deploy to git-pages / deploy (push) Successful in 26s
Deploy to git-pages / deploy (push) Successful in 26s
Fixes the deep-link/back-button bug where the district name showed "Unknown District" on return: App.jsx was passing the LEAID as a text search query to searchDistricts() (a name search), which never matches a numeric ID. Resolves it instead via fetchDistrictEstimates(leaid, ...) and reads lea_name/state directly from the returned row - correct regardless of how the page was reached (fresh load, refresh, or browser back/forward). Cuts the demo from 6 charts to 3, per request: arrests over time (kept), arrest rate by student group restructured into Female/Male box-and-whisker panels (kept), and the posterior density ridge chart restructured from a 2x2 model-quadrant grid into a single selected model (dropdown, default three-year + referral rate) with Female/Male ridge columns. Removes DistrictVsNational, ModelDrawsComparison, and ExceedanceProbability entirely, along with the student-group filter (no longer needed - the remaining charts always show the full breakdown) and the national-rates fetch/plumbing that only those removed charts used. Also updates LoadingAnimation's copy and dedupes its STUDENT_GROUPS/ MODEL_QUADRANTS constants against the shared ones in useApi.js. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
+12
-12
@@ -4,7 +4,7 @@ import DistrictSearch from './components/DistrictSearch.jsx'
|
|||||||
import LoadingAnimation from './components/LoadingAnimation.jsx'
|
import LoadingAnimation from './components/LoadingAnimation.jsx'
|
||||||
import ChartPanel from './components/ChartPanel.jsx'
|
import ChartPanel from './components/ChartPanel.jsx'
|
||||||
import Footer from './components/Footer.jsx'
|
import Footer from './components/Footer.jsx'
|
||||||
import { searchDistricts } from './hooks/useApi.js'
|
import { fetchDistrictEstimates } from './hooks/useApi.js'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* CRDC Arrests API Demo App — main router.
|
* CRDC Arrests API Demo App — main router.
|
||||||
@@ -21,18 +21,18 @@ export default function App() {
|
|||||||
const leaid = params.get('leaid')
|
const leaid = params.get('leaid')
|
||||||
const stateParam = params.get('state')
|
const stateParam = params.get('state')
|
||||||
if (leaid && stateParam) {
|
if (leaid && stateParam) {
|
||||||
// Deep link: fetch district name first, then go to loading.
|
// Deep link (including browser back/forward landing on this URL): resolve
|
||||||
|
// the district name via a real estimates row, keyed by LEAID. The previous
|
||||||
|
// version called searchDistricts(leaid, ...), a name/text search — passing
|
||||||
|
// an LEAID as search text never matches, so it always fell back to "Unknown
|
||||||
|
// District".
|
||||||
setSelectedState(stateParam)
|
setSelectedState(stateParam)
|
||||||
|
|
||||||
// Fetch district name from API
|
fetchDistrictEstimates(leaid, { model: 'unified_m2_mod', year: '21-22' }).then(rows => {
|
||||||
searchDistricts(leaid, stateParam).then(results => {
|
const row = rows?.[0]
|
||||||
const district = results?.find(d => d.leaid === leaid)
|
setDistrict(row
|
||||||
if (district) {
|
? { leaid: row.leaid, lea_name: row.lea_name, state: row.state }
|
||||||
setDistrict(district)
|
: { leaid, lea_name: 'Unknown District', state: stateParam })
|
||||||
} else {
|
|
||||||
// Fallback if district not found
|
|
||||||
setDistrict({ leaid, lea_name: 'Unknown District', state: stateParam })
|
|
||||||
}
|
|
||||||
setStep('loading')
|
setStep('loading')
|
||||||
}).catch(() => {
|
}).catch(() => {
|
||||||
// Fallback on error
|
// Fallback on error
|
||||||
|
|||||||
@@ -1,98 +0,0 @@
|
|||||||
import ChartLegend from '../components/ChartLegend.jsx'
|
|
||||||
import { raceColor, OBSERVED_MARK_COLOR, REFERENCE_GRAY } from '../utils/colors.js'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Chart 3: The district's highest-rate student group vs. the national rate
|
|
||||||
* for that same group. District bar = modeled median (race-colored) with a
|
|
||||||
* 90% interval whisker; dark diamond = observed rate on the same bar.
|
|
||||||
* National bar is a neutral gray reference, not a race/model color.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export default function DistrictVsNational({ topGroup, nationalRate, rateByGroup }) {
|
|
||||||
const width = 360
|
|
||||||
const height = 300
|
|
||||||
const margin = { top: 45, right: 30, bottom: 80, left: 70 }
|
|
||||||
const innerWidth = width - margin.left - margin.right
|
|
||||||
const plotHeight = height - margin.top - margin.bottom
|
|
||||||
|
|
||||||
const districtObserved = topGroup.observedRate || 0
|
|
||||||
const districtModeled = topGroup.modeledMedian ?? districtObserved
|
|
||||||
const districtLower = topGroup.rateLower ?? districtModeled
|
|
||||||
const districtUpper = topGroup.rateUpper ?? districtModeled
|
|
||||||
const maxVal = Math.max(districtUpper, districtObserved, nationalRate, 1) * 1.3
|
|
||||||
|
|
||||||
const yFor = (val) => margin.top + plotHeight * (1 - val / maxVal)
|
|
||||||
const color = raceColor(topGroup.race)
|
|
||||||
|
|
||||||
const districtBarX = margin.left + innerWidth * 0.1
|
|
||||||
const districtBarW = innerWidth * 0.35
|
|
||||||
const nationalBarX = margin.left + innerWidth * 0.55
|
|
||||||
const nationalBarW = innerWidth * 0.35
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="cv-card" style={{ padding: 'var(--space-2)' }}>
|
|
||||||
<h3 style={{ fontSize: '0.85rem', marginBottom: 'var(--space-1)', color: 'var(--cv-ink-2)' }}>
|
|
||||||
District vs. national rate — highest group
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<svg width="100%" viewBox={`0 0 ${width} ${height}`} style={{ maxWidth: '100%' }}>
|
|
||||||
<rect x={margin.left} y={margin.top} width={innerWidth} height={plotHeight} fill="var(--cv-paper-2)" rx={4} />
|
|
||||||
|
|
||||||
{[0, 0.25, 0.5, 0.75, 1].map((frac) => {
|
|
||||||
const val = frac * maxVal
|
|
||||||
const y = yFor(val)
|
|
||||||
return (
|
|
||||||
<g key={`y-${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>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
|
|
||||||
<text x={12} y={margin.top + plotHeight / 2} textAnchor="middle" fontSize="0.7rem" fill="var(--cv-ink-3)"
|
|
||||||
transform={`rotate(-90 12 ${margin.top + plotHeight / 2})`}>
|
|
||||||
Rate per 1,000 students
|
|
||||||
</text>
|
|
||||||
|
|
||||||
{/* District bar — modeled median, race-colored, with 90% interval whisker */}
|
|
||||||
<rect x={districtBarX} y={yFor(districtModeled)} width={districtBarW} height={plotHeight * (districtModeled / maxVal)}
|
|
||||||
fill={color} rx={4} />
|
|
||||||
<line x1={districtBarX + districtBarW / 2} y1={yFor(districtLower)} x2={districtBarX + districtBarW / 2} y2={yFor(districtUpper)}
|
|
||||||
stroke="var(--cv-ink)" strokeWidth={1.5} />
|
|
||||||
{/* Observed diamond, overlaid on the district bar */}
|
|
||||||
<rect x={districtBarX + districtBarW / 2 - 5} y={yFor(districtObserved) - 5} width={10} height={10}
|
|
||||||
fill={OBSERVED_MARK_COLOR} stroke="#fff" strokeWidth={1.25}
|
|
||||||
transform={`rotate(45 ${districtBarX + districtBarW / 2} ${yFor(districtObserved)})`} />
|
|
||||||
|
|
||||||
{/* National bar — neutral reference */}
|
|
||||||
<rect x={nationalBarX} y={yFor(nationalRate)} width={nationalBarW} height={plotHeight * (nationalRate / maxVal)}
|
|
||||||
fill={REFERENCE_GRAY} rx={4} />
|
|
||||||
<text x={nationalBarX + nationalBarW / 2} y={yFor(nationalRate) - 4}
|
|
||||||
textAnchor="middle" fontSize="0.7rem" fontWeight={600} fill="var(--cv-ink)">{nationalRate.toFixed(2)}</text>
|
|
||||||
|
|
||||||
<text x={districtBarX + districtBarW / 2} y={height - margin.bottom + 15} textAnchor="middle" fontSize="0.7rem" fill="var(--cv-ink)">District</text>
|
|
||||||
<text x={nationalBarX + nationalBarW / 2} y={height - margin.bottom + 15} textAnchor="middle" fontSize="0.7rem" fill="var(--cv-ink)">National</text>
|
|
||||||
|
|
||||||
<text x={margin.left + innerWidth / 2} y={height - 5} textAnchor="middle" fontSize="0.65rem" fontWeight={600} fill="var(--cv-navy-700)">
|
|
||||||
{topGroup.label || 'Top group'}
|
|
||||||
</text>
|
|
||||||
</svg>
|
|
||||||
|
|
||||||
<ChartLegend items={[
|
|
||||||
{ shape: 'swatch', color, label: 'District (modeled + 90% interval)' },
|
|
||||||
{ shape: 'diamond', color: OBSERVED_MARK_COLOR, label: 'District (observed)' },
|
|
||||||
{ shape: 'swatch', color: REFERENCE_GRAY, label: 'National reference' },
|
|
||||||
]} />
|
|
||||||
|
|
||||||
<p style={{ fontSize: '0.72rem', color: 'var(--cv-ink-3)', marginTop: 'var(--space-1)' }}>
|
|
||||||
{topGroup.label || 'Highest-rate group'} in this district has an observed rate of{' '}
|
|
||||||
<strong>{districtObserved.toFixed(2)}</strong> per 1,000 vs. the national average of{' '}
|
|
||||||
<strong>{nationalRate.toFixed(2)}</strong>. This is{' '}
|
|
||||||
{districtObserved > nationalRate * 1.5 ? 'substantially higher than' : districtObserved > nationalRate
|
|
||||||
? 'slightly higher than' : districtObserved < nationalRate * 0.75
|
|
||||||
? 'much lower than'
|
|
||||||
: 'comparable to'} the national rate.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,127 +0,0 @@
|
|||||||
import { useState } from 'react'
|
|
||||||
import { groupKey } from '../utils/groupKey.js'
|
|
||||||
import { shortGroupLabel } from '../utils/colors.js'
|
|
||||||
import { fitSkewedInterval, probabilityExceeds } from '../utils/distributionApprox.js'
|
|
||||||
import ApproxNote from '../components/ApproxNote.jsx'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Chart 6: Probability the district exceeds the national rate for each
|
|
||||||
* student group, under a chosen model. The model dropdown now actually
|
|
||||||
* drives the computation (it was previously decorative — computed always
|
|
||||||
* read from a single ChartPanel-precomputed default model).
|
|
||||||
*/
|
|
||||||
|
|
||||||
const MODEL_OPTIONS = [
|
|
||||||
{ val: 'unified_m2_mod', label: 'Recommended (one-year + covariate)' },
|
|
||||||
{ val: 'unified_m4_mod', label: 'Three-year + covariate' },
|
|
||||||
]
|
|
||||||
|
|
||||||
export default function ExceedanceProbability({ quadData, nationalRates }) {
|
|
||||||
const [selectedModel, setSelectedModel] = useState('unified_m2_mod')
|
|
||||||
|
|
||||||
const rows = (quadData && quadData[selectedModel]) || []
|
|
||||||
const computed = rows
|
|
||||||
.map((row) => {
|
|
||||||
const natKey = groupKey(row.race, row.sex)
|
|
||||||
const nationalRate = nationalRates[natKey] || 0
|
|
||||||
const rateMedian = (row.rate_median || 0) * 1000
|
|
||||||
const rateLower = (row.rate_lower || 0) * 1000
|
|
||||||
const rateUpper = (row.rate_upper || 0) * 1000
|
|
||||||
const fit = fitSkewedInterval({ median: rateMedian, lower: rateLower, upper: rateUpper })
|
|
||||||
const probExceeds = Math.max(0.01, Math.min(0.99, probabilityExceeds(fit, nationalRate)))
|
|
||||||
return { race: row.race, sex: row.sex, label: shortGroupLabel(row.race, row.sex), nationalRate, modeledRate: rateMedian, probExceeds }
|
|
||||||
})
|
|
||||||
.sort((a, b) => b.probExceeds - a.probExceeds)
|
|
||||||
|
|
||||||
const width = 400
|
|
||||||
const height = 320
|
|
||||||
const margin = { top: 50, right: 100, bottom: 60, left: 90 }
|
|
||||||
const innerWidth = width - margin.left - margin.right
|
|
||||||
const plotHeight = height - margin.top - margin.bottom
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="cv-card" style={{ padding: 'var(--space-2)' }}>
|
|
||||||
<h3 style={{ fontSize: '0.85rem', marginBottom: 'var(--space-1)', color: 'var(--cv-ink-2)' }}>
|
|
||||||
Probability district exceeds national rate — by student group
|
|
||||||
</h3>
|
|
||||||
<ApproxNote />
|
|
||||||
|
|
||||||
<select value={selectedModel} onChange={(e) => setSelectedModel(e.target.value)}
|
|
||||||
style={{ margin: 'var(--space-1) 0', padding: '0.25rem 0.5rem', fontFamily: 'var(--font-sans)' }}>
|
|
||||||
{MODEL_OPTIONS.map((opt) => (
|
|
||||||
<option key={opt.val} value={opt.val}>{opt.label}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
|
|
||||||
{computed.length === 0 ? (
|
|
||||||
<p style={{ color: 'var(--cv-ink-3)' }}>No model data available for the selected groups.</p>
|
|
||||||
) : (
|
|
||||||
<svg width="100%" viewBox={`0 0 ${width} ${height}`} style={{ maxWidth: '100%' }}>
|
|
||||||
<rect x={margin.left} y={margin.top} width={innerWidth} height={plotHeight} fill="var(--cv-paper-2)" rx={4} />
|
|
||||||
|
|
||||||
{[0, 0.25, 0.5, 0.75, 1].map((frac) => {
|
|
||||||
const y = margin.top + plotHeight - frac * plotHeight
|
|
||||||
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>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
|
|
||||||
<text x={14} y={margin.top + plotHeight / 2} textAnchor="middle" fontSize="0.7rem" fill="var(--cv-ink-3)"
|
|
||||||
transform={`rotate(-90 14 ${margin.top + plotHeight / 2})`}>
|
|
||||||
P(district rate > national)
|
|
||||||
</text>
|
|
||||||
|
|
||||||
{/* 50% reference line — coin-flip threshold, not tied to any one group's national rate */}
|
|
||||||
<line x1={margin.left + innerWidth * 0.5} y1={margin.top} x2={margin.left + innerWidth * 0.5} y2={margin.top + plotHeight}
|
|
||||||
stroke="var(--cv-ink-4)" strokeWidth={1} strokeDasharray="3,2" />
|
|
||||||
|
|
||||||
{computed.map((d, i) => {
|
|
||||||
const barHeight = Math.min(22, (plotHeight * 0.9) / computed.length)
|
|
||||||
const y = margin.top + i * (barHeight + 4)
|
|
||||||
const probWidth = d.probExceeds * innerWidth
|
|
||||||
|
|
||||||
let barColor
|
|
||||||
if (d.probExceeds < 0.5) barColor = 'var(--moss-600)'
|
|
||||||
else if (d.probExceeds < 0.8) barColor = 'var(--brass-600)'
|
|
||||||
else barColor = 'var(--cv-danger)'
|
|
||||||
|
|
||||||
return (
|
|
||||||
<g key={`${d.race}-${d.sex}`}>
|
|
||||||
<rect x={margin.left} y={y + 2} width={probWidth} height={barHeight - 4} rx={2} fill={barColor} />
|
|
||||||
{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>
|
|
||||||
)}
|
|
||||||
<text x={margin.left - 6} y={y + barHeight / 2 + 3} textAnchor="end" fontSize="0.65rem" fill="var(--cv-ink)">
|
|
||||||
{d.label}
|
|
||||||
</text>
|
|
||||||
</g>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<g transform={`translate(${margin.left}, ${margin.top - 22})`}>
|
|
||||||
<rect x={0} y={0} width={10} height={10} fill="var(--moss-600)" rx={2} />
|
|
||||||
<text x={15} y={9} fontSize="0.58rem" fill="var(--cv-ink)">< 50%</text>
|
|
||||||
<rect x={65} y={0} width={10} height={10} fill="var(--brass-600)" rx={2} />
|
|
||||||
<text x={80} y={9} fontSize="0.58rem" fill="var(--cv-ink)">50–80%</text>
|
|
||||||
<rect x={145} y={0} width={10} height={10} fill="var(--cv-danger)" rx={2} />
|
|
||||||
<text x={160} y={9} fontSize="0.58rem" fill="var(--cv-ink)">> 80%</text>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<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'}.
|
|
||||||
Green bars indicate groups where this district's rate is unlikely to exceed the national average; red indicates likely exceedance.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,143 +0,0 @@
|
|||||||
import { MODEL_QUADRANTS } from '../hooks/useApi.js'
|
|
||||||
import ChartLegend from '../components/ChartLegend.jsx'
|
|
||||||
import { OBSERVED_MARK_COLOR, MODELED_AGGREGATE_COLOR } from '../utils/colors.js'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Chart 4: Modeled vs. observed arrests, faceted by the four model-quadrant
|
|
||||||
* specifications (one-year/three-year × baseline/+referral-rate covariate).
|
|
||||||
* Plain SVG (no D3) — the quadrant label already conveys 1yr/3yr, so all
|
|
||||||
* four panels share one ink-diamond/navy-point-range convention rather than
|
|
||||||
* doubling up with a redundant per-quadrant hue.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const WAVE_LABELS = { '15-16': '2015–16', '17-18': '2017–18', '21-22': '2021–22' }
|
|
||||||
const ALL_WAVES = ['15-16', '17-18', '21-22']
|
|
||||||
|
|
||||||
/** Sums count_median/lower/upper across every group row for one wave — the
|
|
||||||
* modeled counterpart to waveData's observed-arrests sum. Each row is a
|
|
||||||
* single race×sex group, so picking just one row (the original bug here)
|
|
||||||
* silently reports whichever group happens to come first from the API. */
|
|
||||||
function aggregateModeledForYear(rows, year) {
|
|
||||||
const yearRows = (rows || []).filter((r) => r.year === year)
|
|
||||||
if (yearRows.length === 0) return null
|
|
||||||
return {
|
|
||||||
median: yearRows.reduce((sum, r) => sum + (r.count_median || 0), 0),
|
|
||||||
lower: yearRows.reduce((sum, r) => sum + (r.count_lower || 0), 0),
|
|
||||||
upper: yearRows.reduce((sum, r) => sum + (r.count_upper || 0), 0),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ModelDrawsComparison({ data, waves = ALL_WAVES, districtName }) {
|
|
||||||
const waveData = waves.map((year) => {
|
|
||||||
const rows = (data.waves && data.waves[year]) || []
|
|
||||||
return { year, arrests: rows.reduce((sum, r) => sum + (r.observed_arrests || 0), 0) }
|
|
||||||
})
|
|
||||||
|
|
||||||
const quadModels = (data && data.quadData) || {}
|
|
||||||
|
|
||||||
const allValues = waveData.map((d) => d.arrests)
|
|
||||||
Object.entries(quadModels).forEach(([model, rows]) => {
|
|
||||||
const isThreeYear = model.includes('m3') || model.includes('m4')
|
|
||||||
const years = isThreeYear ? ALL_WAVES : ['21-22']
|
|
||||||
years.forEach((year) => {
|
|
||||||
const agg = aggregateModeledForYear(rows, year)
|
|
||||||
if (agg) allValues.push(agg.median, agg.upper)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
const maxArrests = Math.min(Math.max(...allValues, 1), 50) // cap for readability
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="cv-card" style={{ padding: 'var(--space-2)' }}>
|
|
||||||
<h3 style={{ fontSize: '0.85rem', marginBottom: 'var(--space-1)', color: 'var(--cv-ink-2)' }}>
|
|
||||||
Model predictions vs. observed — by wave & model specification
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 'var(--space-2)' }}>
|
|
||||||
{MODEL_QUADRANTS.map((q) => (
|
|
||||||
<QuadrantPanel key={q.model} q={q} waveData={waveData} quadModels={quadModels} maxArrests={maxArrests} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ChartLegend items={[
|
|
||||||
{ shape: 'diamond', color: OBSERVED_MARK_COLOR, label: 'Observed' },
|
|
||||||
{ shape: 'line', color: MODELED_AGGREGATE_COLOR, label: 'Modeled (median + 90% interval)' },
|
|
||||||
]} />
|
|
||||||
|
|
||||||
<p style={{ fontSize: '0.72rem', color: 'var(--cv-ink-3)', marginTop: 'var(--space-1)' }}>
|
|
||||||
Three-year models span all three CRDC waves; one-year models show 2021–22 only.{' '}
|
|
||||||
{districtName ? `District: ${districtName}.` : ''}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function QuadrantPanel({ q, waveData, quadModels, maxArrests }) {
|
|
||||||
const isThreeYear = q.model.includes('m3') || q.model.includes('m4')
|
|
||||||
const showYears = isThreeYear ? ALL_WAVES : ['21-22']
|
|
||||||
|
|
||||||
const width = 260
|
|
||||||
const height = 190
|
|
||||||
const margin = { top: 26, right: 12, bottom: 26, left: 34 }
|
|
||||||
const innerWidth = width - margin.left - margin.right
|
|
||||||
const innerHeight = height - margin.top - margin.bottom
|
|
||||||
|
|
||||||
const xScale = (i) => margin.left + (showYears.length > 1 ? (i / (showYears.length - 1)) * innerWidth : innerWidth / 2)
|
|
||||||
const yScale = (val) => margin.top + innerHeight - (Math.min(val, maxArrests) / maxArrests) * innerHeight
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{ border: '1px solid var(--cv-rule)', borderRadius: 'var(--radius-md)', padding: 'var(--space-1)' }}>
|
|
||||||
<svg width="100%" viewBox={`0 0 ${width} ${height}`} style={{ maxWidth: '100%' }}>
|
|
||||||
<text x={width / 2} y={14} textAnchor="middle" fontSize="0.65rem" fontWeight={600} fill="var(--cv-ink-2)">
|
|
||||||
{q.label}
|
|
||||||
</text>
|
|
||||||
|
|
||||||
{[0, 0.25, 0.5, 0.75, 1].map((frac) => {
|
|
||||||
const val = frac * maxArrests
|
|
||||||
const y = yScale(val)
|
|
||||||
return (
|
|
||||||
<g key={frac}>
|
|
||||||
<line x1={margin.left} y1={y} x2={margin.left + innerWidth} y2={y} stroke="var(--cv-rule)" strokeWidth={1} />
|
|
||||||
<text x={margin.left - 6} y={y + 3} textAnchor="end" fontSize="0.55rem" fill="var(--cv-ink-3)">{Math.round(val)}</text>
|
|
||||||
</g>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
|
|
||||||
{showYears.map((year, i) => {
|
|
||||||
const x = xScale(i)
|
|
||||||
const obs = waveData.find((d) => d.year === year)?.arrests ?? 0
|
|
||||||
const agg = aggregateModeledForYear(quadModels[q.model], year)
|
|
||||||
const obsY = yScale(obs)
|
|
||||||
// Edge ticks anchor away from the viewBox boundary instead of centering
|
|
||||||
// on it, so the label can't be clipped by the SVG edge.
|
|
||||||
const tickAnchor = i === 0 ? 'start' : i === showYears.length - 1 ? 'end' : 'middle'
|
|
||||||
const tickX = i === 0 ? x - 4 : i === showYears.length - 1 ? x + 4 : x
|
|
||||||
|
|
||||||
return (
|
|
||||||
<g key={year}>
|
|
||||||
<text x={tickX} y={height - 6} textAnchor={tickAnchor} fontSize="0.55rem" fill="var(--cv-ink-3)">
|
|
||||||
{WAVE_LABELS[year]}
|
|
||||||
</text>
|
|
||||||
|
|
||||||
{agg && (
|
|
||||||
<>
|
|
||||||
<line
|
|
||||||
x1={x} y1={yScale(agg.upper)}
|
|
||||||
x2={x} y2={yScale(agg.lower)}
|
|
||||||
stroke={MODELED_AGGREGATE_COLOR} strokeWidth={1.5}
|
|
||||||
/>
|
|
||||||
<circle cx={x} cy={yScale(agg.median)} r={3.5} fill={MODELED_AGGREGATE_COLOR} stroke="#fff" strokeWidth={1} />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<rect
|
|
||||||
x={x - 4} y={obsY - 4} width={8} height={8}
|
|
||||||
fill={OBSERVED_MARK_COLOR} stroke="#fff" strokeWidth={1}
|
|
||||||
transform={`rotate(45 ${x} ${obsY})`}
|
|
||||||
/>
|
|
||||||
</g>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
+83
-100
@@ -4,20 +4,16 @@ import { raceColor, OBSERVED_MARK_COLOR, SHORT_RACE_LABEL } from '../utils/color
|
|||||||
import { fitSkewedInterval } from '../utils/distributionApprox.js'
|
import { fitSkewedInterval } from '../utils/distributionApprox.js'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Chart 2: Arrest rate per 1,000 students by student group (most recent
|
* Arrest rate by student group, most recent year, disaggregated into two
|
||||||
* year). A plain bar to the modeled median (next to an observed diamond
|
* panels (Female / Male), each a horizontal box-and-whisker across the 4
|
||||||
* that often sits well past the bar's end) reads as if the bar itself were
|
* race categories. Whisker = the model's reported 90% interval, box = the
|
||||||
* the uncertainty range, which it wasn't. This draws an actual horizontal
|
* fitted approximation's 25th-75th percentile, white tick = median, dark
|
||||||
* box-and-whisker instead: whisker = the API's reported 90% interval
|
* diamond = observed rate.
|
||||||
* (rateLower/rateUpper), box = the fitted approximation's 25th/75th
|
|
||||||
* percentiles, median tick = the modeled median. Dark diamond = observed,
|
|
||||||
* overlaid at its own position. Sex is row grouping, not color.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const RACE_ORDER = ['WH', 'BL', 'HI', 'AM']
|
const RACE_ORDER = ['WH', 'BL', 'HI', 'AM']
|
||||||
const SEX_ROWS = [{ sex: 'F', label: 'Female' }, { sex: 'M', label: 'Male' }]
|
const SEX_PANELS = [{ sex: 'F', label: 'Female' }, { sex: 'M', label: 'Male' }]
|
||||||
const ROW_HEIGHT = 26
|
const ROW_HEIGHT = 34
|
||||||
const SUBHEADER_HEIGHT = 20
|
|
||||||
|
|
||||||
function buildBox(d) {
|
function buildBox(d) {
|
||||||
const median = Math.max(d.modeledMedian || 0, 0)
|
const median = Math.max(d.modeledMedian || 0, 0)
|
||||||
@@ -25,37 +21,17 @@ function buildBox(d) {
|
|||||||
const upper = Math.max(d.rateUpper ?? median, median)
|
const upper = Math.max(d.rateUpper ?? median, median)
|
||||||
const fit = fitSkewedInterval({ median, lower, upper })
|
const fit = fitSkewedInterval({ median, lower, upper })
|
||||||
return {
|
return {
|
||||||
lower,
|
lower, upper, median,
|
||||||
upper,
|
|
||||||
median,
|
|
||||||
q1: Math.max(fit.quantile(0.25), 0),
|
q1: Math.max(fit.quantile(0.25), 0),
|
||||||
q3: Math.max(fit.quantile(0.75), median),
|
q3: Math.max(fit.quantile(0.75), median),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function RateByGroupBar({ data }) {
|
export default function RateByGroupBar({ data }) {
|
||||||
const width = 360
|
|
||||||
const margin = { top: 40, right: 30, bottom: 45, left: 90 }
|
|
||||||
const innerWidth = width - margin.left - margin.right
|
|
||||||
|
|
||||||
const bySex = SEX_ROWS
|
|
||||||
.map(({ sex, label }) => ({
|
|
||||||
sex,
|
|
||||||
label,
|
|
||||||
rows: RACE_ORDER.map((race) => data.find((d) => d.race === race && d.sex === sex)).filter(Boolean),
|
|
||||||
}))
|
|
||||||
.filter((g) => g.rows.length > 0)
|
|
||||||
|
|
||||||
const bodyHeight = bySex.reduce((sum, g) => sum + SUBHEADER_HEIGHT + g.rows.length * ROW_HEIGHT, 0)
|
|
||||||
const height = margin.top + bodyHeight + margin.bottom
|
|
||||||
|
|
||||||
const maxRate = Math.max(
|
const maxRate = Math.max(
|
||||||
...data.map((d) => Math.max(d.observedRate, d.rateUpper ?? d.modeledMedian ?? 0)),
|
...data.map((d) => Math.max(d.observedRate, d.rateUpper ?? d.modeledMedian ?? 0)),
|
||||||
0.5
|
0.5
|
||||||
)
|
)
|
||||||
const xScale = (val) => margin.left + (val / maxRate) * innerWidth
|
|
||||||
|
|
||||||
let cursorY = margin.top
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="cv-card" style={{ padding: 'var(--space-2)' }}>
|
<div className="cv-card" style={{ padding: 'var(--space-2)' }}>
|
||||||
@@ -64,74 +40,11 @@ export default function RateByGroupBar({ data }) {
|
|||||||
</h3>
|
</h3>
|
||||||
<ApproxNote />
|
<ApproxNote />
|
||||||
|
|
||||||
<svg width="100%" viewBox={`0 0 ${width} ${height}`} style={{ maxWidth: '100%' }}>
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 'var(--space-3)', marginTop: 'var(--space-2)' }}>
|
||||||
<rect x={margin.left} y={margin.top} width={innerWidth} height={bodyHeight} fill="var(--cv-paper-2)" rx={4} />
|
{SEX_PANELS.map(({ sex, label }) => (
|
||||||
|
<SexPanel key={sex} label={label} rows={data.filter((d) => d.sex === sex)} maxRate={maxRate} />
|
||||||
{[0, 0.25, 0.5, 0.75, 1].map((frac) => {
|
))}
|
||||||
const val = frac * maxRate
|
</div>
|
||||||
const x = xScale(val)
|
|
||||||
return (
|
|
||||||
<g key={`xg-${frac}`}>
|
|
||||||
<line x1={x} y1={margin.top} x2={x} y2={margin.top + bodyHeight} stroke="var(--cv-rule)" strokeWidth={1} />
|
|
||||||
<text x={x} y={margin.top + bodyHeight + 14} textAnchor="middle" fontSize="0.65rem" fill="var(--cv-ink-3)">
|
|
||||||
{val.toFixed(1)}
|
|
||||||
</text>
|
|
||||||
</g>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
|
|
||||||
{bySex.map((group) => {
|
|
||||||
const subheaderY = cursorY
|
|
||||||
cursorY += SUBHEADER_HEIGHT
|
|
||||||
const rowEls = group.rows.map((d) => {
|
|
||||||
const rowY = cursorY
|
|
||||||
cursorY += ROW_HEIGHT
|
|
||||||
const color = raceColor(d.race)
|
|
||||||
const box = buildBox(d)
|
|
||||||
const midY = rowY + ROW_HEIGHT / 2
|
|
||||||
const boxTop = midY - ROW_HEIGHT * 0.28
|
|
||||||
const boxBottom = midY + ROW_HEIGHT * 0.28
|
|
||||||
const observedX = xScale(d.observedRate)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<g key={`${d.race}-${d.sex}`}>
|
|
||||||
{/* Whisker: the API's reported 90% interval */}
|
|
||||||
<line x1={xScale(box.lower)} y1={midY} x2={xScale(box.upper)} y2={midY} stroke={color} strokeWidth={1.5} />
|
|
||||||
<line x1={xScale(box.lower)} y1={boxTop} x2={xScale(box.lower)} y2={boxBottom} stroke={color} strokeWidth={1.5} />
|
|
||||||
<line x1={xScale(box.upper)} y1={boxTop} x2={xScale(box.upper)} y2={boxBottom} stroke={color} strokeWidth={1.5} />
|
|
||||||
{/* Box: fitted 25th-75th percentile */}
|
|
||||||
<rect
|
|
||||||
x={xScale(box.q1)} y={boxTop} width={Math.max(xScale(box.q3) - xScale(box.q1), 1)} height={boxBottom - boxTop}
|
|
||||||
fill={color} fillOpacity={0.55} stroke={color} strokeWidth={1} rx={1.5}
|
|
||||||
/>
|
|
||||||
{/* Median tick */}
|
|
||||||
<line x1={xScale(box.median)} y1={boxTop} x2={xScale(box.median)} y2={boxBottom} stroke="#fff" strokeWidth={1.5} />
|
|
||||||
{/* Observed diamond */}
|
|
||||||
<rect
|
|
||||||
x={observedX - 4} y={midY - 4} width={8} height={8}
|
|
||||||
fill={OBSERVED_MARK_COLOR} stroke="#fff" strokeWidth={1}
|
|
||||||
transform={`rotate(45 ${observedX} ${midY})`}
|
|
||||||
/>
|
|
||||||
<text x={margin.left - 8} y={midY + 4} textAnchor="end" fontSize="0.7rem" fill="var(--cv-ink)">
|
|
||||||
{SHORT_RACE_LABEL[d.race] || d.race}
|
|
||||||
</text>
|
|
||||||
</g>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
return (
|
|
||||||
<g key={group.sex}>
|
|
||||||
<text x={margin.left} y={subheaderY + 14} fontSize="0.72rem" fontWeight={700} fill="var(--cv-ink-2)">
|
|
||||||
{group.label}
|
|
||||||
</text>
|
|
||||||
{rowEls}
|
|
||||||
</g>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
|
|
||||||
<text x={margin.left + innerWidth / 2} y={height - 8} textAnchor="middle" fontSize="0.7rem" fill="var(--cv-ink-3)">
|
|
||||||
Rate per 1,000 students
|
|
||||||
</text>
|
|
||||||
</svg>
|
|
||||||
|
|
||||||
<ChartLegend items={[
|
<ChartLegend items={[
|
||||||
...RACE_ORDER.filter((race) => data.some((d) => d.race === race)).map((race) => ({
|
...RACE_ORDER.filter((race) => data.some((d) => d.race === race)).map((race) => ({
|
||||||
@@ -147,3 +60,73 @@ export default function RateByGroupBar({ data }) {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function SexPanel({ label, rows, maxRate }) {
|
||||||
|
const width = 300
|
||||||
|
const margin = { top: 30, right: 16, bottom: 34, left: 66 }
|
||||||
|
const innerWidth = width - margin.left - margin.right
|
||||||
|
const byRace = RACE_ORDER.map((race) => rows.find((d) => d.race === race)).filter(Boolean)
|
||||||
|
const bodyHeight = byRace.length * ROW_HEIGHT
|
||||||
|
const height = margin.top + bodyHeight + margin.bottom
|
||||||
|
const xScale = (val) => margin.left + (val / maxRate) * innerWidth
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ border: '1px solid var(--cv-rule)', borderRadius: 'var(--radius-md)', padding: 'var(--space-1)' }}>
|
||||||
|
<svg width="100%" viewBox={`0 0 ${width} ${height}`} style={{ maxWidth: '100%' }}>
|
||||||
|
<text x={width / 2} y={16} textAnchor="middle" fontSize="0.78rem" fontWeight={700} fill="var(--cv-ink-2)">
|
||||||
|
{label}
|
||||||
|
</text>
|
||||||
|
|
||||||
|
<rect x={margin.left} y={margin.top} width={innerWidth} height={bodyHeight} fill="var(--cv-paper-2)" rx={4} />
|
||||||
|
|
||||||
|
{[0, 0.25, 0.5, 0.75, 1].map((frac) => {
|
||||||
|
const val = frac * maxRate
|
||||||
|
const x = xScale(val)
|
||||||
|
return (
|
||||||
|
<g key={frac}>
|
||||||
|
<line x1={x} y1={margin.top} x2={x} y2={margin.top + bodyHeight} stroke="var(--cv-rule)" strokeWidth={1} />
|
||||||
|
<text x={x} y={margin.top + bodyHeight + 14} textAnchor="middle" fontSize="0.6rem" fill="var(--cv-ink-3)">
|
||||||
|
{val.toFixed(1)}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
{byRace.map((d, i) => {
|
||||||
|
const rowY = margin.top + i * ROW_HEIGHT
|
||||||
|
const midY = rowY + ROW_HEIGHT / 2
|
||||||
|
const boxTop = midY - ROW_HEIGHT * 0.26
|
||||||
|
const boxBottom = midY + ROW_HEIGHT * 0.26
|
||||||
|
const color = raceColor(d.race)
|
||||||
|
const box = buildBox(d)
|
||||||
|
const observedX = xScale(d.observedRate)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<g key={d.race}>
|
||||||
|
<line x1={xScale(box.lower)} y1={midY} x2={xScale(box.upper)} y2={midY} stroke={color} strokeWidth={1.5} />
|
||||||
|
<line x1={xScale(box.lower)} y1={boxTop} x2={xScale(box.lower)} y2={boxBottom} stroke={color} strokeWidth={1.5} />
|
||||||
|
<line x1={xScale(box.upper)} y1={boxTop} x2={xScale(box.upper)} y2={boxBottom} stroke={color} strokeWidth={1.5} />
|
||||||
|
<rect
|
||||||
|
x={xScale(box.q1)} y={boxTop} width={Math.max(xScale(box.q3) - xScale(box.q1), 1)} height={boxBottom - boxTop}
|
||||||
|
fill={color} fillOpacity={0.55} stroke={color} strokeWidth={1} rx={1.5}
|
||||||
|
/>
|
||||||
|
<line x1={xScale(box.median)} y1={boxTop} x2={xScale(box.median)} y2={boxBottom} stroke="#fff" strokeWidth={1.5} />
|
||||||
|
<rect
|
||||||
|
x={observedX - 4} y={midY - 4} width={8} height={8}
|
||||||
|
fill={OBSERVED_MARK_COLOR} stroke="#fff" strokeWidth={1}
|
||||||
|
transform={`rotate(45 ${observedX} ${midY})`}
|
||||||
|
/>
|
||||||
|
<text x={margin.left - 8} y={midY + 4} textAnchor="end" fontSize="0.72rem" fill="var(--cv-ink)">
|
||||||
|
{SHORT_RACE_LABEL[d.race] || d.race}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
<text x={margin.left + innerWidth / 2} y={height - 6} textAnchor="middle" fontSize="0.62rem" fill="var(--cv-ink-3)">
|
||||||
|
Rate per 1,000 students
|
||||||
|
</text>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
+112
-103
@@ -1,3 +1,4 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
import { MODEL_QUADRANTS } from '../hooks/useApi.js'
|
import { MODEL_QUADRANTS } from '../hooks/useApi.js'
|
||||||
import { raceColor, OBSERVED_MARK_COLOR, RACE_LABELS, SHORT_RACE_LABEL } from '../utils/colors.js'
|
import { raceColor, OBSERVED_MARK_COLOR, RACE_LABELS, SHORT_RACE_LABEL } from '../utils/colors.js'
|
||||||
import { fitSkewedInterval, densityCurve } from '../utils/distributionApprox.js'
|
import { fitSkewedInterval, densityCurve } from '../utils/distributionApprox.js'
|
||||||
@@ -5,15 +6,16 @@ import ChartLegend from '../components/ChartLegend.jsx'
|
|||||||
import ApproxNote from '../components/ApproxNote.jsx'
|
import ApproxNote from '../components/ApproxNote.jsx'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Chart 5: Modeled posterior density per race×sex group, faceted 2×2 by
|
* Modeled posterior density per race×sex group, for one selected model
|
||||||
* model quadrant (matches whitepaper-fig-clark-density-1.png). Plain SVG —
|
* (dropdown, default three-year + referral rate), split into Female/Male
|
||||||
* no D3, no fake normal draws: each ridge is drawn as 60 analytic points
|
* columns — matches whitepaper-fig-clark-density-1.png's ridge style. Plain
|
||||||
* from fitSkewedInterval/densityCurve, which is already smooth without a
|
* SVG — each ridge is 60 analytic points from fitSkewedInterval/densityCurve,
|
||||||
* binning/curveBasis smoothing pass.
|
* already smooth without a binning/curveBasis smoothing pass.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const RACE_ORDER = ['WH', 'BL', 'HI', 'AM']
|
const RACE_ORDER = ['WH', 'BL', 'HI', 'AM']
|
||||||
const SEX_ORDER = ['F', 'M']
|
const SEX_COLUMNS = [{ sex: 'F', label: 'Female' }, { sex: 'M', label: 'Male' }]
|
||||||
|
const DEFAULT_MODEL = 'unified_m4_mod' // Three-year + referral rate
|
||||||
|
|
||||||
function buildGroupRow(row) {
|
function buildGroupRow(row) {
|
||||||
const enroll = row.stu_enroll || 0
|
const enroll = row.stu_enroll || 0
|
||||||
@@ -30,133 +32,140 @@ function buildGroupRow(row) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function RateDensityRidgeline({ quadData }) {
|
export default function RateDensityRidgeline({ quadData }) {
|
||||||
const hasData = quadData && Object.values(quadData).some((rows) => (rows || []).length > 0)
|
const [selectedModel, setSelectedModel] = useState(DEFAULT_MODEL)
|
||||||
|
const rows = (quadData && quadData[selectedModel]) || []
|
||||||
|
|
||||||
if (!hasData) {
|
const modelSelect = (
|
||||||
return (
|
<select
|
||||||
<div className="cv-card" style={{ padding: 'var(--space-2)' }}>
|
value={selectedModel}
|
||||||
<h3 style={{ fontSize: '0.85rem', marginBottom: 'var(--space-1)', color: 'var(--cv-ink-2)' }}>
|
onChange={(e) => setSelectedModel(e.target.value)}
|
||||||
Predicted arrest rates by student group
|
style={{ padding: '0.25rem 0.5rem', fontFamily: 'var(--font-sans)', fontSize: '0.8rem' }}
|
||||||
</h3>
|
>
|
||||||
<p style={{ color: 'var(--cv-ink-3)' }}>No model data available.</p>
|
{MODEL_QUADRANTS.map((q) => (
|
||||||
</div>
|
<option key={q.model} value={q.model}>{q.label}</option>
|
||||||
)
|
))}
|
||||||
}
|
</select>
|
||||||
|
)
|
||||||
// Shared x-domain across all four quadrant panels, so they're directly comparable.
|
|
||||||
const allUpper = []
|
|
||||||
const allObserved = []
|
|
||||||
MODEL_QUADRANTS.forEach((q) => {
|
|
||||||
(quadData[q.model] || []).forEach((row) => {
|
|
||||||
allUpper.push((row.rate_upper || 0) * 1000)
|
|
||||||
const enroll = row.stu_enroll || 0
|
|
||||||
if (enroll > 0) allObserved.push(((row.observed_arrests || 0) / enroll) * 1000)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
const maxRate = Math.min(Math.max(...allUpper, ...allObserved, 1) * 1.15, 30)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="cv-card" style={{ padding: 'var(--space-2)' }}>
|
<div className="cv-card" style={{ padding: 'var(--space-2)' }}>
|
||||||
<h3 style={{ fontSize: '0.85rem', marginBottom: 'var(--space-1)', color: 'var(--cv-ink-2)' }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', flexWrap: 'wrap', gap: 'var(--space-2)' }}>
|
||||||
Predicted arrest rates by student group
|
<div>
|
||||||
</h3>
|
<h3 style={{ fontSize: '0.85rem', marginBottom: 'var(--space-1)', color: 'var(--cv-ink-2)' }}>
|
||||||
<ApproxNote />
|
Predicted arrest rates by student group
|
||||||
|
</h3>
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 'var(--space-2)', marginTop: 'var(--space-1)' }}>
|
<ApproxNote />
|
||||||
{MODEL_QUADRANTS.map((q) => (
|
</div>
|
||||||
<RidgePanel key={q.model} q={q} rows={quadData[q.model] || []} maxRate={maxRate} />
|
{modelSelect}
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ChartLegend items={[
|
{rows.length === 0 ? (
|
||||||
...RACE_ORDER.map((race) => ({ shape: 'swatch', color: raceColor(race), label: RACE_LABELS[race] })),
|
<p style={{ color: 'var(--cv-ink-3)', marginTop: 'var(--space-2)' }}>No model data available.</p>
|
||||||
{ shape: 'diamond', color: OBSERVED_MARK_COLOR, label: 'Observed' },
|
) : (
|
||||||
]} />
|
<>
|
||||||
|
<RidgeColumns rows={rows} />
|
||||||
|
<ChartLegend items={[
|
||||||
|
...RACE_ORDER.map((race) => ({ shape: 'swatch', color: raceColor(race), label: RACE_LABELS[race] })),
|
||||||
|
{ shape: 'diamond', color: OBSERVED_MARK_COLOR, label: 'Observed' },
|
||||||
|
]} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function RidgePanel({ q, rows, maxRate }) {
|
function RidgeColumns({ rows }) {
|
||||||
const groups = []
|
// Shared x-domain across both columns, so Female/Male are directly comparable.
|
||||||
SEX_ORDER.forEach((sex) => {
|
const allUpper = rows.map((r) => (r.rate_upper || 0) * 1000)
|
||||||
RACE_ORDER.forEach((race) => {
|
const allObserved = rows
|
||||||
const row = rows.find((r) => r.race === race && r.sex === sex)
|
.filter((r) => (r.stu_enroll || 0) > 0)
|
||||||
if (row) groups.push(buildGroupRow(row))
|
.map((r) => ((r.observed_arrests || 0) / r.stu_enroll) * 1000)
|
||||||
})
|
const maxRate = Math.min(Math.max(...allUpper, ...allObserved, 1) * 1.15, 30)
|
||||||
})
|
|
||||||
|
|
||||||
if (groups.length === 0) {
|
return (
|
||||||
return (
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 'var(--space-3)', marginTop: 'var(--space-2)' }}>
|
||||||
<div style={{ border: '1px solid var(--cv-rule)', borderRadius: 'var(--radius-md)', padding: 'var(--space-2)' }}>
|
{SEX_COLUMNS.map(({ sex, label }) => (
|
||||||
<p style={{ fontSize: '0.65rem', color: 'var(--cv-ink-3)' }}>{q.label} — no data for the selected groups</p>
|
<SexRidgeColumn key={sex} label={label} rows={rows.filter((r) => r.sex === sex)} maxRate={maxRate} />
|
||||||
</div>
|
))}
|
||||||
)
|
</div>
|
||||||
}
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SexRidgeColumn({ label, rows, maxRate }) {
|
||||||
|
const groups = RACE_ORDER.map((race) => rows.find((r) => r.race === race)).filter(Boolean).map(buildGroupRow)
|
||||||
|
|
||||||
const width = 300
|
const width = 300
|
||||||
const rowHeight = 46
|
const rowHeight = 58
|
||||||
const margin = { top: 24, right: 16, bottom: 26, left: 56 }
|
const margin = { top: 26, right: 16, bottom: 26, left: 56 }
|
||||||
const innerWidth = width - margin.left - margin.right
|
const innerWidth = width - margin.left - margin.right
|
||||||
const height = margin.top + groups.length * rowHeight + margin.bottom
|
const height = margin.top + Math.max(groups.length, 1) * rowHeight + margin.bottom
|
||||||
|
|
||||||
const xScale = (val) => margin.left + (val / maxRate) * innerWidth
|
const xScale = (val) => margin.left + (val / maxRate) * innerWidth
|
||||||
|
|
||||||
const curves = groups.map((g) => densityCurve(g.fit, { min: 0, max: maxRate, n: 60 }))
|
const curves = groups.map((g) => densityCurve(g.fit, { min: 0, max: maxRate, n: 60 }))
|
||||||
const maxPdf = Math.max(...curves.flatMap((c) => c.map((p) => p.y)), 1e-9)
|
const maxPdf = Math.max(...curves.flatMap((c) => c.map((p) => p.y)), 1e-9)
|
||||||
const peakHeight = rowHeight * 0.85
|
const peakHeight = rowHeight * 0.82
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ border: '1px solid var(--cv-rule)', borderRadius: 'var(--radius-md)', padding: 'var(--space-1)' }}>
|
<div style={{ border: '1px solid var(--cv-rule)', borderRadius: 'var(--radius-md)', padding: 'var(--space-1)' }}>
|
||||||
<svg width="100%" viewBox={`0 0 ${width} ${height}`} style={{ maxWidth: '100%' }}>
|
<svg width="100%" viewBox={`0 0 ${width} ${height}`} style={{ maxWidth: '100%' }}>
|
||||||
<text x={width / 2} y={14} textAnchor="middle" fontSize="0.65rem" fontWeight={600} fill="var(--cv-ink-2)">
|
<text x={width / 2} y={16} textAnchor="middle" fontSize="0.78rem" fontWeight={700} fill="var(--cv-ink-2)">
|
||||||
{q.label}
|
{label}
|
||||||
</text>
|
</text>
|
||||||
|
|
||||||
{[0, 0.25, 0.5, 0.75, 1].map((frac) => {
|
{groups.length === 0 ? (
|
||||||
const val = frac * maxRate
|
<text x={width / 2} y={height / 2} textAnchor="middle" fontSize="0.65rem" fill="var(--cv-ink-3)">
|
||||||
const x = xScale(val)
|
No data for this group
|
||||||
return (
|
</text>
|
||||||
<g key={frac}>
|
) : (
|
||||||
<line x1={x} y1={margin.top} x2={x} y2={margin.top + groups.length * rowHeight} stroke="var(--cv-rule)" strokeWidth={1} />
|
<>
|
||||||
<text x={x} y={margin.top + groups.length * rowHeight + 14} textAnchor="middle" fontSize="0.55rem" fill="var(--cv-ink-3)">
|
{[0, 0.25, 0.5, 0.75, 1].map((frac) => {
|
||||||
{val.toFixed(0)}
|
const val = frac * maxRate
|
||||||
</text>
|
const x = xScale(val)
|
||||||
</g>
|
return (
|
||||||
)
|
<g key={frac}>
|
||||||
})}
|
<line x1={x} y1={margin.top} x2={x} y2={margin.top + groups.length * rowHeight} stroke="var(--cv-rule)" strokeWidth={1} />
|
||||||
|
<text x={x} y={margin.top + groups.length * rowHeight + 14} textAnchor="middle" fontSize="0.58rem" fill="var(--cv-ink-3)">
|
||||||
|
{val.toFixed(0)}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
{groups.map((g, i) => {
|
{groups.map((g, i) => {
|
||||||
const rowTop = margin.top + i * rowHeight
|
const rowTop = margin.top + i * rowHeight
|
||||||
const baselineY = rowTop + rowHeight * 0.9
|
const baselineY = rowTop + rowHeight * 0.9
|
||||||
const curve = curves[i]
|
const curve = curves[i]
|
||||||
const color = raceColor(g.race)
|
const color = raceColor(g.race)
|
||||||
|
|
||||||
const topPath = curve
|
const topPath = curve
|
||||||
.map((p, j) => `${j === 0 ? 'M' : 'L'}${xScale(p.x)},${baselineY - (p.y / maxPdf) * peakHeight}`)
|
.map((p, j) => `${j === 0 ? 'M' : 'L'}${xScale(p.x)},${baselineY - (p.y / maxPdf) * peakHeight}`)
|
||||||
.join(' ')
|
.join(' ')
|
||||||
const areaPath = `${topPath} L${xScale(maxRate)},${baselineY} L${xScale(0)},${baselineY} Z`
|
const areaPath = `${topPath} L${xScale(maxRate)},${baselineY} L${xScale(0)},${baselineY} Z`
|
||||||
|
|
||||||
const obsX = xScale(Math.min(g.observedRate, maxRate))
|
const obsX = xScale(Math.min(g.observedRate, maxRate))
|
||||||
const obsY = baselineY - peakHeight * 0.15
|
const obsY = baselineY - peakHeight * 0.15
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<g key={`${g.race}-${g.sex}`}>
|
<g key={g.race}>
|
||||||
<text x={margin.left - 8} y={rowTop + rowHeight / 2 + 4} textAnchor="end" fontSize="0.6rem" fill="var(--cv-ink)">
|
<text x={margin.left - 8} y={rowTop + rowHeight / 2 + 4} textAnchor="end" fontSize="0.68rem" fill="var(--cv-ink)">
|
||||||
{SHORT_RACE_LABEL[g.race] || g.race} {g.sex}
|
{SHORT_RACE_LABEL[g.race] || g.race}
|
||||||
</text>
|
</text>
|
||||||
<path d={areaPath} fill={color} opacity={0.6} stroke="#fff" strokeWidth={0.5} />
|
<path d={areaPath} fill={color} opacity={0.6} stroke="#fff" strokeWidth={0.5} />
|
||||||
<rect
|
<rect
|
||||||
x={obsX - 4} y={obsY - 4} width={8} height={8}
|
x={obsX - 4} y={obsY - 4} width={8} height={8}
|
||||||
fill={OBSERVED_MARK_COLOR} stroke="#fff" strokeWidth={1}
|
fill={OBSERVED_MARK_COLOR} stroke="#fff" strokeWidth={1}
|
||||||
transform={`rotate(45 ${obsX} ${obsY})`}
|
transform={`rotate(45 ${obsX} ${obsY})`}
|
||||||
/>
|
/>
|
||||||
</g>
|
</g>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|
||||||
<text x={width / 2} y={height - 6} textAnchor="middle" fontSize="0.55rem" fill="var(--cv-ink-3)">
|
<text x={width / 2} y={height - 6} textAnchor="middle" fontSize="0.58rem" fill="var(--cv-ink-3)">
|
||||||
Arrests per 1,000 students
|
Arrests per 1,000 students
|
||||||
</text>
|
</text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,49 +1,34 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import * as api from '../hooks/useApi.js'
|
import * as api from '../hooks/useApi.js'
|
||||||
import useGroupFilter, { filterRowsByGroup } from '../hooks/useGroupFilter.js'
|
|
||||||
import GroupFilterBar from './GroupFilterBar.jsx'
|
|
||||||
import { groupLabel } from '../utils/colors.js'
|
import { groupLabel } from '../utils/colors.js'
|
||||||
import ArrestsOverTime from '../charts/ArrestsOverTime.jsx'
|
import ArrestsOverTime from '../charts/ArrestsOverTime.jsx'
|
||||||
import RateByGroupBar from '../charts/RateByGroupBar.jsx'
|
import RateByGroupBar from '../charts/RateByGroupBar.jsx'
|
||||||
import DistrictVsNational from '../charts/DistrictVsNational.jsx'
|
|
||||||
import ModelDrawsComparison from '../charts/ModelDrawsComparison.jsx'
|
|
||||||
import RateDensityRidgeline from '../charts/RateDensityRidgeline.jsx'
|
import RateDensityRidgeline from '../charts/RateDensityRidgeline.jsx'
|
||||||
import ExceedanceProbability from '../charts/ExceedanceProbability.jsx'
|
|
||||||
|
|
||||||
const ALL_WAVES = ['15-16', '17-18', '21-22']
|
const ALL_WAVES = ['15-16', '17-18', '21-22']
|
||||||
|
const QUADRANT_MODELS = ['unified_m1_mod', 'unified_m2_mod', 'unified_m3_mod', 'unified_m4_mod']
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ChartPanel — 6 charts in a single-column layout, all scoped by one shared
|
* ChartPanel — 3 charts: arrests over time (observed vs. modeled), arrest
|
||||||
* student-group filter (defaults to all 8 groups selected).
|
* rate by student group (Female/Male panels), and the model-selectable
|
||||||
|
* posterior density ridge chart.
|
||||||
*/
|
*/
|
||||||
export default function ChartPanel({ district, state }) {
|
export default function ChartPanel({ district, state }) {
|
||||||
const [data, setData] = useState(null) // all fetched estimate rows keyed by year/model/group
|
const [data, setData] = useState(null) // all fetched estimate rows keyed by year/model
|
||||||
const [nationalRates, setNationalRates] = useState(null)
|
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const filter = useGroupFilter()
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function fetchData() {
|
async function fetchData() {
|
||||||
try {
|
try {
|
||||||
// National rates from static fixture (or cache from loading step)
|
// Chart 1 + 2: all 3 waves × three-year model (unified_m3_mod)
|
||||||
let nat = window.__NATIONAL_RATES__
|
|
||||||
if (!nat) {
|
|
||||||
const res = await fetch(import.meta.env.BASE_URL + 'data/national_rates.json')
|
|
||||||
nat = await res.json()
|
|
||||||
}
|
|
||||||
setNationalRates(nat)
|
|
||||||
|
|
||||||
// Fetch all 3 waves × three-year model (unified_m3_mod) for Chart 1 + Charts 2-3
|
|
||||||
const waves = ['21-22', '17-18', '15-16']
|
|
||||||
const waveData = {}
|
const waveData = {}
|
||||||
await Promise.all(waves.map(async (year) => {
|
await Promise.all(ALL_WAVES.map(async (year) => {
|
||||||
try { waveData[year] = await api.fetchDistrictEstimates(district.leaid, { model: 'unified_m3_mod', year }) } catch (e) {}
|
try { waveData[year] = await api.fetchDistrictEstimates(district.leaid, { model: 'unified_m3_mod', year }) } catch (e) {}
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// Fetch all 4 quadrant models for Charts 4-6 (8 groups × 4 models, batched by model)
|
// Chart 3: all 4 quadrant models, so the dropdown can switch between them
|
||||||
const quadrants = ['unified_m1_mod', 'unified_m2_mod', 'unified_m3_mod', 'unified_m4_mod']
|
|
||||||
const quadData = {}
|
const quadData = {}
|
||||||
await Promise.all(quadrants.map(async (model) => {
|
await Promise.all(QUADRANT_MODELS.map(async (model) => {
|
||||||
try { quadData[model] = await api.fetchDistrictEstimates(district.leaid, { model, year: '21-22' }) } catch (e) {}
|
try { quadData[model] = await api.fetchDistrictEstimates(district.leaid, { model, year: '21-22' }) } catch (e) {}
|
||||||
}))
|
}))
|
||||||
|
|
||||||
@@ -62,12 +47,11 @@ export default function ChartPanel({ district, state }) {
|
|||||||
return <LoadingCharts />
|
return <LoadingCharts />
|
||||||
}
|
}
|
||||||
|
|
||||||
// ——— Every derived dataset below is scoped to the selected student groups ———
|
const mostRecent = data.waveData['21-22'] || []
|
||||||
const mostRecent = filterRowsByGroup(data.waveData['21-22'] || [], filter)
|
|
||||||
|
|
||||||
// Chart 1: Arrests over time by wave — observed total + modeled (three-year model) point-range
|
// Chart 1: Arrests over time by wave — observed total + modeled (three-year model) point-range
|
||||||
const timeSeriesData = ALL_WAVES.map((year) => {
|
const timeSeriesData = ALL_WAVES.map((year) => {
|
||||||
const yearRows = filterRowsByGroup(data.waveData[year] || [], filter)
|
const yearRows = data.waveData[year] || []
|
||||||
return {
|
return {
|
||||||
year,
|
year,
|
||||||
label: `20${year.replace('-', '-')}`,
|
label: `20${year.replace('-', '-')}`,
|
||||||
@@ -79,83 +63,50 @@ export default function ChartPanel({ district, state }) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Charts 2-3: rate by student group (most recent wave, per 1k), with modeled + observed + interval
|
// Chart 2: rate by student group (most recent wave, per 1k), with modeled + observed + interval
|
||||||
const rateByGroup = mostRecent
|
const rateByGroup = mostRecent.map((r) => ({
|
||||||
.map((r) => ({
|
race: r.race, sex: r.sex, label: groupLabel(r.race, r.sex),
|
||||||
race: r.race, sex: r.sex, label: groupLabel(r.race, r.sex),
|
observedRate: (r.observed_arrests || 0) / ((r.stu_enroll || 1) / 1000),
|
||||||
observedRate: (r.observed_arrests || 0) / ((r.stu_enroll || 1) / 1000),
|
modeledMedian: (r.count_median || 0) / ((r.stu_enroll || 1) / 1000),
|
||||||
modeledMedian: (r.count_median || 0) / ((r.stu_enroll || 1) / 1000),
|
rateLower: (r.rate_lower || 0) * 1000,
|
||||||
rateLower: (r.rate_lower || 0) * 1000,
|
rateUpper: (r.rate_upper || 0) * 1000,
|
||||||
rateUpper: (r.rate_upper || 0) * 1000,
|
observedArrests: r.observed_arrests || 0,
|
||||||
observedArrests: r.observed_arrests || 0,
|
enrollment: r.stu_enroll || 0,
|
||||||
enrollment: r.stu_enroll || 0,
|
}))
|
||||||
}))
|
|
||||||
.sort((a, b) => b.observedRate - a.observedRate)
|
|
||||||
|
|
||||||
const topGroup = rateByGroup[0] || null
|
|
||||||
const natKey = topGroup ? `${topGroup.race}_${topGroup.sex}` : null
|
|
||||||
const nationalTopRate = (natKey && nationalRates?.rates_per_1000?.[natKey]) || 0
|
|
||||||
|
|
||||||
// Charts 4-6: all 4 quadrant models, each filtered to the selected groups
|
|
||||||
const quadDataFiltered = Object.fromEntries(
|
|
||||||
Object.entries(data.quadData || {}).map(([model, rows]) => [model, filterRowsByGroup(rows, filter)])
|
|
||||||
)
|
|
||||||
const waveDataFiltered = Object.fromEntries(
|
|
||||||
ALL_WAVES.map((year) => [year, filterRowsByGroup(data.waveData[year] || [], filter)])
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: 'var(--space-3) 0 var(--space-7)' }}>
|
<div style={{ padding: 'var(--space-3) 0 var(--space-7)' }}>
|
||||||
{/* Chart panel header */}
|
{/* Chart panel header */}
|
||||||
<div style={{ marginBottom: 'var(--space-3)' }}>
|
<div style={{ marginBottom: 'var(--space-4)' }}>
|
||||||
<span className="eyebrow">District estimates — Bayesian model comparison</span>
|
<span className="eyebrow">District estimates — Bayesian model comparison</span>
|
||||||
<h2 style={{ marginTop: 'var(--space-1)', marginBottom: 0 }}>
|
<h2 style={{ marginTop: 'var(--space-1)', marginBottom: 0 }}>
|
||||||
{district.lea_name} ({state}) — School-based arrest rates, 2021–22 CRDC
|
{district.lea_name} ({state}) — School-based arrest rates, 2021–22 CRDC
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ maxWidth: '70rem', marginLeft: 'auto', marginRight: 'auto' }}>
|
<div style={{
|
||||||
<GroupFilterBar filter={filter} />
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 'var(--space-5)',
|
||||||
|
maxWidth: '70rem',
|
||||||
|
marginLeft: 'auto',
|
||||||
|
marginRight: 'auto'
|
||||||
|
}}>
|
||||||
|
<ArrestsOverTime data={timeSeriesData} districtName={district.lea_name} />
|
||||||
|
|
||||||
|
<RateByGroupBar data={rateByGroup} />
|
||||||
|
|
||||||
|
<RateDensityRidgeline quadData={data.quadData} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{filter.selected.size === 0 ? (
|
|
||||||
<p style={{ color: 'var(--cv-ink-3)', maxWidth: '70rem', margin: '0 auto' }}>
|
|
||||||
Select at least one student group above to see charts.
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<div style={{
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
gap: 'var(--space-5)',
|
|
||||||
maxWidth: '70rem',
|
|
||||||
marginLeft: 'auto',
|
|
||||||
marginRight: 'auto'
|
|
||||||
}}>
|
|
||||||
{/* Charts 1–3: Observed & descriptive */}
|
|
||||||
<ArrestsOverTime data={timeSeriesData} districtName={district.lea_name} />
|
|
||||||
|
|
||||||
<RateByGroupBar data={rateByGroup} />
|
|
||||||
|
|
||||||
{topGroup && (
|
|
||||||
<DistrictVsNational topGroup={topGroup} nationalRate={nationalTopRate} rateByGroup={rateByGroup} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Charts 4–6: Bayesian model distributions */}
|
|
||||||
<ModelDrawsComparison data={{ waves: waveDataFiltered, quadData: quadDataFiltered }} districtName={district.lea_name} />
|
|
||||||
|
|
||||||
<RateDensityRidgeline quadData={quadDataFiltered} />
|
|
||||||
|
|
||||||
<ExceedanceProbability quadData={quadDataFiltered} nationalRates={nationalRates?.rates_per_1000 || {}} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Methodology footer */}
|
{/* Methodology footer */}
|
||||||
<div style={{ marginTop: 'var(--space-6)', padding: 'var(--space-3) 0', borderTop: '1px solid var(--cv-rule)' }}>
|
<div style={{ marginTop: 'var(--space-6)', padding: 'var(--space-3) 0', borderTop: '1px solid var(--cv-rule)' }}>
|
||||||
<span className="eyebrow">Methodology</span>
|
<span className="eyebrow">Methodology</span>
|
||||||
<p style={{ fontSize: '0.85rem', color: 'var(--cv-ink-2)', marginTop: 'var(--space-1)' }}>
|
<p style={{ fontSize: '0.85rem', color: 'var(--cv-ink-2)', marginTop: 'var(--space-1)' }}>
|
||||||
Estimates are from the CRDC School Arrest Rate API (Knowles & Miller 2025). Data shown spans three waves of
|
Estimates are from the CRDC School Arrest Rate API (Knowles & Miller 2025). Data shown spans three waves of
|
||||||
the Civil Rights Data Collection (2015–16, 2017–18, 2021–22) and compares four Bayesian model specifications:
|
the Civil Rights Data Collection (2015–16, 2017–18, 2021–22) and lets you explore four Bayesian model
|
||||||
one-year vs. three-year models with and without referral-rate covariates. All rates are per 1,000 students.
|
specifications: one-year vs. three-year models with and without referral-rate covariates. All rates are
|
||||||
|
per 1,000 students.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,66 +0,0 @@
|
|||||||
import { raceColor, RACE_LABELS } from '../utils/colors.js'
|
|
||||||
|
|
||||||
const RACE_ORDER = ['WH', 'BL', 'HI', 'AM']
|
|
||||||
const SEX_ROWS = [
|
|
||||||
{ sex: 'F', label: 'Female' },
|
|
||||||
{ sex: 'M', label: 'Male' },
|
|
||||||
]
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Student-group filter: two rows (Female/Male) of 4 race-colored toggle
|
|
||||||
* chips. Sex is row position, not color — race owns the color channel, and
|
|
||||||
* a chip's color never changes when siblings are toggled.
|
|
||||||
*/
|
|
||||||
export default function GroupFilterBar({ filter }) {
|
|
||||||
return (
|
|
||||||
<div style={{ margin: '0 0 var(--space-4)' }}>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 'var(--space-1)' }}>
|
|
||||||
<span className="eyebrow" style={{ marginBottom: 0 }}>Student groups</span>
|
|
||||||
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
|
||||||
<button type="button" className="btn-outline" onClick={filter.selectAll} disabled={filter.allSelected}>
|
|
||||||
Select all
|
|
||||||
</button>
|
|
||||||
<button type="button" className="btn-outline" onClick={filter.selectNone} disabled={filter.selected.size === 0}>
|
|
||||||
Clear
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem', marginTop: 'var(--space-1)' }}>
|
|
||||||
{SEX_ROWS.map(({ sex, label }) => (
|
|
||||||
<div key={sex} style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', flexWrap: 'wrap' }}>
|
|
||||||
<span style={{ fontSize: '0.8rem', color: 'var(--cv-ink-3)', width: '3.5rem', flexShrink: 0 }}>{label}</span>
|
|
||||||
{RACE_ORDER.map((race) => {
|
|
||||||
const active = filter.isSelected(race, sex)
|
|
||||||
const color = raceColor(race)
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={race}
|
|
||||||
type="button"
|
|
||||||
onClick={() => filter.toggle(race, sex)}
|
|
||||||
aria-pressed={active}
|
|
||||||
style={{
|
|
||||||
display: 'inline-flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: '0.4rem',
|
|
||||||
padding: '0.35rem 0.75rem',
|
|
||||||
borderRadius: 'var(--radius-md)',
|
|
||||||
border: `1.5px solid ${color}`,
|
|
||||||
background: active ? color : 'transparent',
|
|
||||||
color: active ? '#fff' : 'var(--cv-ink-2)',
|
|
||||||
fontSize: '0.8rem',
|
|
||||||
fontWeight: 600,
|
|
||||||
cursor: 'pointer',
|
|
||||||
transition: 'background 120ms ease, color 120ms ease',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{RACE_LABELS[race]}
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,26 +1,8 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import ChartPanel from './ChartPanel.jsx'
|
import ChartPanel from './ChartPanel.jsx'
|
||||||
import * as api from '../hooks/useApi.js'
|
import * as api from '../hooks/useApi.js'
|
||||||
|
import { STUDENT_GROUPS, MODEL_QUADRANTS } from '../hooks/useApi.js'
|
||||||
// The 8 student groups we fetch for (race×sex combinations)
|
import { shortGroupLabel } from '../utils/colors.js'
|
||||||
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)
|
// CRDC waves to fetch (for Chart 1 — time series)
|
||||||
const WAVES = ['21-22', '17-18', '15-16']
|
const WAVES = ['21-22', '17-18', '15-16']
|
||||||
@@ -35,11 +17,9 @@ export default function LoadingAnimation({ district, state }) {
|
|||||||
const [totalCalls, setTotalCalls] = useState(0)
|
const [totalCalls, setTotalCalls] = useState(0)
|
||||||
const [error, setError] = useState(null)
|
const [error, setError] = useState(null)
|
||||||
|
|
||||||
// Build the full list of API calls needed for all 6 charts
|
// Build the full list of API calls needed for all 3 charts
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
let count = 0
|
|
||||||
let total = 0
|
|
||||||
|
|
||||||
async function loadData() {
|
async function loadData() {
|
||||||
try {
|
try {
|
||||||
@@ -50,14 +30,14 @@ export default function LoadingAnimation({ district, state }) {
|
|||||||
.catch(() => { /* individual failure doesn't block */ })
|
.catch(() => { /* individual failure doesn't block */ })
|
||||||
)
|
)
|
||||||
|
|
||||||
// ——— Charts 2 & 3: Rate by group (8 groups × default model, most recent wave) ———
|
// ——— Chart 2: Rate by group (8 groups × default model, most recent wave) ———
|
||||||
const groupPromises = STUDENT_GROUPS.map((sg) =>
|
const groupPromises = STUDENT_GROUPS.map((sg) =>
|
||||||
fetchDistrictEstimatesBatch(district.leaid, '21-22', sg.race, sg.sex)
|
fetchDistrictEstimatesBatch(district.leaid, '21-22', sg.race, sg.sex)
|
||||||
.then(() => !cancelled && setLoadedCount(c => c + 1))
|
.then(() => !cancelled && setLoadedCount(c => c + 1))
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
)
|
)
|
||||||
|
|
||||||
// ——— Charts 4–6: Model comparison (8 groups × 4 quadrant models = 32 calls) ———
|
// ——— Chart 3: posterior density, all 4 quadrant models so the dropdown can switch (8 groups × 4 = 32 calls) ———
|
||||||
const modelPromises = MODEL_QUADRANTS.flatMap((quad) =>
|
const modelPromises = MODEL_QUADRANTS.flatMap((quad) =>
|
||||||
STUDENT_GROUPS.map((sg) =>
|
STUDENT_GROUPS.map((sg) =>
|
||||||
fetchDistrictEstimatesBatch(district.leaid, '21-22', sg.race, sg.sex, quad.model)
|
fetchDistrictEstimatesBatch(district.leaid, '21-22', sg.race, sg.sex, quad.model)
|
||||||
@@ -66,22 +46,16 @@ export default function LoadingAnimation({ district, state }) {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
// ——— National rates (from static fixture, instant) ———
|
|
||||||
const nationalPromise = fetch(import.meta.env.BASE_URL + '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)
|
// Set total before starting (for progress bar)
|
||||||
const allPromises = [...wavePromises, ...groupPromises, ...modelPromises]
|
const allPromises = [...wavePromises, ...groupPromises, ...modelPromises]
|
||||||
if (!cancelled) setTotalCalls(allPromises.length + 1 /* national */)
|
if (!cancelled) setTotalCalls(allPromises.length)
|
||||||
|
|
||||||
await Promise.all([...allPromises, nationalPromise])
|
await Promise.all(allPromises)
|
||||||
|
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
// All data loaded — ChartPanel renders in place of this component
|
// All data loaded — ChartPanel renders in place of this component
|
||||||
// We use a render prop pattern: return <ChartPanel /> when done
|
// We use a render prop pattern: return <ChartPanel /> when done
|
||||||
setLoadedCount(allPromises.length + 1)
|
setLoadedCount(allPromises.length)
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!cancelled) setError(err.message || 'Failed to load data')
|
if (!cancelled) setError(err.message || 'Failed to load data')
|
||||||
@@ -110,10 +84,14 @@ export default function LoadingAnimation({ district, state }) {
|
|||||||
// Build bar metadata for rendering
|
// Build bar metadata for rendering
|
||||||
const allBars = Array.from({ length: TOTAL_BARS }, (_, i) => {
|
const allBars = Array.from({ length: TOTAL_BARS }, (_, i) => {
|
||||||
if (i < WAVES.length) return { subLabel: 'Time series', group: 'waves' }
|
if (i < WAVES.length) return { subLabel: 'Time series', group: 'waves' }
|
||||||
if (i < WAVES.length + STUDENT_GROUPS.length) return { subLabel: STUDENT_GROUPS[i - WAVES.length].label, group: 'groups' }
|
if (i < WAVES.length + STUDENT_GROUPS.length) {
|
||||||
|
const sg = STUDENT_GROUPS[i - WAVES.length]
|
||||||
|
return { subLabel: shortGroupLabel(sg.race, sg.sex), group: 'groups' }
|
||||||
|
}
|
||||||
const quadIdx = Math.floor((i - WAVES.length - STUDENT_GROUPS.length) / STUDENT_GROUPS.length)
|
const quadIdx = Math.floor((i - WAVES.length - STUDENT_GROUPS.length) / STUDENT_GROUPS.length)
|
||||||
const sgIdx = (i - WAVES.length - STUDENT_GROUPS.length) % STUDENT_GROUPS.length
|
const sgIdx = (i - WAVES.length - STUDENT_GROUPS.length) % STUDENT_GROUPS.length
|
||||||
return { subLabel: STUDENT_GROUPS[sgIdx].label, group: 'models', modelIdx: quadIdx }
|
const sg = STUDENT_GROUPS[sgIdx]
|
||||||
|
return { subLabel: shortGroupLabel(sg.race, sg.sex), group: 'models', modelIdx: quadIdx }
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -142,7 +120,7 @@ export default function LoadingAnimation({ district, state }) {
|
|||||||
marginTop: 'var(--space-4)'
|
marginTop: 'var(--space-4)'
|
||||||
}}>
|
}}>
|
||||||
{/* Column headers */}
|
{/* Column headers */}
|
||||||
{['Time series (Chart 1)', 'Observed rates (Charts 2–3)', 'Model comparisons (Charts 4–6)'].map((h, i) => (
|
{['Time series (Chart 1)', 'By student group (Chart 2)', 'Posterior density (Chart 3)'].map((h, i) => (
|
||||||
<div key={i} style={{ textAlign: 'center', paddingBottom: 'var(--space-1)' }}>
|
<div key={i} style={{ textAlign: 'center', paddingBottom: 'var(--space-1)' }}>
|
||||||
<span className="stat-label" style={{ display: 'block' }}>{h}</span>
|
<span className="stat-label" style={{ display: 'block' }}>{h}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,44 +0,0 @@
|
|||||||
{
|
|
||||||
"metadata": {
|
|
||||||
"source": "CRDC School Arrest Rate API / Knowles & Miller 2025, White Paper §3 Data",
|
|
||||||
"year": "21-22",
|
|
||||||
"description": "National arrest rates per 1,000 students for each race×sex group in the 2021-22 CRDC. Generated from crdc_2122 with RACE != TOTAL and SEX != TOTAL: sum(ARRESTS) / (sum(stu_enroll)/1000).",
|
|
||||||
"note": "These are observed national rates, used for comparison in Charts 3 & 6."
|
|
||||||
},
|
|
||||||
"rates_per_1000": {
|
|
||||||
"WH_F": 0.34,
|
|
||||||
"WH_M": 0.67,
|
|
||||||
"BL_F": 1.25,
|
|
||||||
"BL_M": 1.98,
|
|
||||||
"HI_F": 0.42,
|
|
||||||
"HI_M": 0.88,
|
|
||||||
"AM_F": 0.91,
|
|
||||||
"AM_M": 1.40
|
|
||||||
},
|
|
||||||
"observed_arrests": {
|
|
||||||
"WH_F": 3653,
|
|
||||||
"WH_M": 7606,
|
|
||||||
"BL_F": 4420,
|
|
||||||
"BL_M": 7246,
|
|
||||||
"HI_F": 2876,
|
|
||||||
"HI_M": 6270,
|
|
||||||
"AM_F": 202,
|
|
||||||
"AM_M": 327
|
|
||||||
},
|
|
||||||
"enrollment": {
|
|
||||||
"WH_F": 10624313,
|
|
||||||
"WH_M": 11322741,
|
|
||||||
"BL_F": 3530162,
|
|
||||||
"BL_M": 3667523,
|
|
||||||
"HI_F": 6805008,
|
|
||||||
"HI_M": 7109091,
|
|
||||||
"AM_F": 222963,
|
|
||||||
"AM_M": 233442
|
|
||||||
},
|
|
||||||
"totals": {
|
|
||||||
"arrests": 34846,
|
|
||||||
"enrollment": 48596489,
|
|
||||||
"rate_per_1000": 0.72,
|
|
||||||
"districts_reporting_arrests_pct": 11.6
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
import { useState, useCallback, useMemo } from 'react'
|
|
||||||
import { STUDENT_GROUPS } from './useApi.js'
|
|
||||||
import { groupKey } from '../utils/groupKey.js'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Shared student-group filter — defaults to all 8 groups selected. Lives
|
|
||||||
* once in ChartPanel and threads down to every chart, so "select which
|
|
||||||
* groups to focus on" behaves consistently everywhere instead of being
|
|
||||||
* reimplemented per chart.
|
|
||||||
*/
|
|
||||||
export default function useGroupFilter(groups = STUDENT_GROUPS) {
|
|
||||||
const allKeys = useMemo(() => groups.map((g) => groupKey(g.race, g.sex)), [groups])
|
|
||||||
const [selected, setSelected] = useState(() => new Set(allKeys))
|
|
||||||
|
|
||||||
const isSelected = useCallback((race, sex) => selected.has(groupKey(race, sex)), [selected])
|
|
||||||
|
|
||||||
const toggle = useCallback((race, sex) => {
|
|
||||||
setSelected((prev) => {
|
|
||||||
const next = new Set(prev)
|
|
||||||
const key = groupKey(race, sex)
|
|
||||||
if (next.has(key)) next.delete(key)
|
|
||||||
else next.add(key)
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const selectAll = useCallback(() => setSelected(new Set(allKeys)), [allKeys])
|
|
||||||
const selectNone = useCallback(() => setSelected(new Set()), [])
|
|
||||||
|
|
||||||
return { groups, selected, isSelected, toggle, selectAll, selectNone, allSelected: selected.size === allKeys.length }
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Filters a rows array (each row has .race/.sex) down to the selected groups. */
|
|
||||||
export function filterRowsByGroup(rows, filter) {
|
|
||||||
return (rows || []).filter((r) => filter.isSelected(r.race, r.sex))
|
|
||||||
}
|
|
||||||
+3
-3
@@ -20,11 +20,11 @@ export const RACE_LABELS = { WH: 'White', BL: 'Black', HI: 'Hispanic', AM: 'Amer
|
|||||||
// SVG viewBox.
|
// SVG viewBox.
|
||||||
export const SHORT_RACE_LABEL = { WH: 'White', BL: 'Black', HI: 'Hispanic', AM: 'AI/AN' }
|
export const SHORT_RACE_LABEL = { WH: 'White', BL: 'Black', HI: 'Hispanic', AM: 'AI/AN' }
|
||||||
|
|
||||||
// Mark-type colors for the observed-vs-modeled convention. These are used
|
// Mark-type colors for the observed-vs-modeled convention. Used where a
|
||||||
// only where a chart has no race facet of its own (aggregate totals) or
|
// chart has no race facet of its own (aggregate totals).
|
||||||
// needs a neutral third "reference/benchmark" role distinct from both.
|
|
||||||
export const OBSERVED_MARK_COLOR = 'var(--cv-ink)'
|
export const OBSERVED_MARK_COLOR = 'var(--cv-ink)'
|
||||||
export const MODELED_AGGREGATE_COLOR = 'var(--cv-navy-600)'
|
export const MODELED_AGGREGATE_COLOR = 'var(--cv-navy-600)'
|
||||||
|
// Fallback for an unrecognized race key — kept neutral rather than defaulting to a real race hue.
|
||||||
export const REFERENCE_GRAY = 'var(--cv-ink-4)'
|
export const REFERENCE_GRAY = 'var(--cv-ink-4)'
|
||||||
|
|
||||||
export function raceColor(race) {
|
export function raceColor(race) {
|
||||||
|
|||||||
@@ -119,10 +119,5 @@ export function densityCurve(fit, { min = 0, max, n = 60 } = {}) {
|
|||||||
return points
|
return points
|
||||||
}
|
}
|
||||||
|
|
||||||
/** P(X > threshold) under the fitted approximation. */
|
|
||||||
export function probabilityExceeds(fit, threshold) {
|
|
||||||
return 1 - fit.cdf(threshold)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const DISTRIBUTION_APPROX_NOTE =
|
export const DISTRIBUTION_APPROX_NOTE =
|
||||||
'Distribution shape estimated from interval bounds — not raw posterior draws.'
|
'Distribution shape estimated from interval bounds — not raw posterior draws.'
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
/**
|
|
||||||
* Single source of truth for the race/sex group key format (matches the
|
|
||||||
* `RACE_SEX` keys used in national_rates.json), so charts and filters agree.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export function groupKey(race, sex) {
|
|
||||||
return `${race}_${sex}`
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parseGroupKey(key) {
|
|
||||||
const [race, sex] = key.split('_')
|
|
||||||
return { race, sex }
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user