Fix chart sizing, model predictions, 90% intervals, and deep link district name
Deploy to git-pages / deploy (push) Successful in 17s

- Increase SVG dimensions in all 6 charts to prevent label truncation
- Fix ModelDrawsComparison: pass quadData so predictions render for three-year models
- Change error bars from 95% to 90% (legend text + SD calculation)
- Widen chart viewport: max-width 70rem vs default 60rem text width
- Fix deep link: fetch district name from API instead of showing 'Loading...'
This commit is contained in:
2026-08-10 12:50:19 -04:00
parent eb396abce7
commit b135f480c0
8 changed files with 49 additions and 32 deletions
+18 -4
View File
@@ -4,6 +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'
/** /**
* CRDC Arrests API Demo App — main router. * CRDC Arrests API Demo App — main router.
@@ -20,11 +21,24 @@ 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: skip straight to loading → results. // Deep link: fetch district name first, then go to loading.
// We need to fetch the district name since we only have LEAID from URL.
setSelectedState(stateParam) setSelectedState(stateParam)
setDistrict({ leaid, lea_name: 'Loading…', state: stateParam })
setStep('loading') // Fetch district name from API
searchDistricts(leaid, stateParam).then(results => {
const district = results?.find(d => d.leaid === leaid)
if (district) {
setDistrict(district)
} else {
// Fallback if district not found
setDistrict({ leaid, lea_name: 'Unknown District', state: stateParam })
}
setStep('loading')
}).catch(() => {
// Fallback on error
setDistrict({ leaid, lea_name: 'Unknown District', state: stateParam })
setStep('loading')
})
} }
}, []) }, [])
+3 -3
View File
@@ -15,9 +15,9 @@ export default function ArrestsOverTime({ data, districtName }) {
) )
// Chart dimensions (SVG-based, no D3 dependency) // Chart dimensions (SVG-based, no D3 dependency)
const width = 260 const width = 360
const height = 220 const height = 280
const margin = { top: 40, right: 80, bottom: 50, left: 55 } const margin = { top: 50, right: 90, bottom: 60, left: 65 }
const innerWidth = width - margin.left - margin.right const innerWidth = width - margin.left - margin.right
const innerHeight = height - margin.top - margin.bottom const innerHeight = height - margin.top - margin.bottom
+3 -3
View File
@@ -4,9 +4,9 @@
*/ */
export default function DistrictVsNational({ topGroup, nationalRate, rateByGroup }) { export default function DistrictVsNational({ topGroup, nationalRate, rateByGroup }) {
const width = 280 const width = 360
const height = 240 const height = 300
const margin = { top: 35, right: 20, bottom: 60, left: 55 } const margin = { top: 45, right: 30, bottom: 80, left: 70 }
const innerWidth = width - margin.left - margin.right const innerWidth = width - margin.left - margin.right
// Compute max rate for scaling (district + national) // Compute max rate for scaling (district + national)
+6 -6
View File
@@ -11,7 +11,7 @@ export default function ExceedanceProbability({ rateByGroup, nationalRates }) {
// Compute exceedance probability for each group using normal approximation: // Compute exceedance probability for each group using normal approximation:
// P(district_rate > national) ≈ Φ((modeled_median - national) / modeled_sd) // P(district_rate > national) ≈ Φ((modeled_median - national) / modeled_sd)
// where sd is derived from the interval width (upper - lower) / (2 * z_{0.975}) // where sd is derived from the interval width (upper - lower) / (2 * z_{0.95})
const computed = rateByGroup.map(d => { const computed = rateByGroup.map(d => {
const natKey = `${d.race}_${d.sex}` const natKey = `${d.race}_${d.sex}`
const nationalRate = nationalRates[natKey] || 0 const nationalRate = nationalRates[natKey] || 0
@@ -25,8 +25,8 @@ export default function ExceedanceProbability({ rateByGroup, nationalRates }) {
// Derive SD from interval width if available, else use observed as proxy // Derive SD from interval width if available, else use observed as proxy
const lowerPerK = ((d.rate_lower || 0) * 1000) || (modeledRatePerK * 0.7) const lowerPerK = ((d.rate_lower || 0) * 1000) || (modeledRatePerK * 0.7)
const upperPerK = ((d.rate_upper || 0) * 1000) || (modeledRatePerK * 1.3) const upperPerK = ((d.rate_upper || 0) * 1000) || (modeledRatePerK * 1.3)
// sd ≈ (upper - lower) / (2 * 1.96) for a 95% interval under normality // sd ≈ (upper - lower) / (2 * 1.645) for a 90% interval under normality
const modeledSd = Math.max((upperPerK - lowerPerK) / 3.92, 0.05) const modeledSd = Math.max((upperPerK - lowerPerK) / 3.29, 0.05)
// P(district > national) using normal CDF approximation // P(district > national) using normal CDF approximation
const z = (modeledRatePerK - nationalRate) / modeledSd const z = (modeledRatePerK - nationalRate) / modeledSd
@@ -42,9 +42,9 @@ export default function ExceedanceProbability({ rateByGroup, nationalRates }) {
} }
}).sort((a, b) => b.probExceeds - a.probExceeds) }).sort((a, b) => b.probExceeds - a.probExceeds)
const width = 300 const width = 400
const height = 260 const height = 320
const margin = { top: 40, right: 80, bottom: 70, left: 55 } const margin = { top: 50, right: 100, bottom: 90, left: 70 }
const innerWidth = width - margin.left - margin.right const innerWidth = width - margin.left - margin.right
return ( return (
+5 -5
View File
@@ -58,7 +58,7 @@ export default function ModelDrawsComparison({ data, waves, districtName }) {
{/* Note */} {/* Note */}
<p style={{ fontSize: '0.72rem', color: 'var(--cv-ink-3)', marginTop: 'var(--space-1)' }}> <p style={{ fontSize: '0.72rem', color: 'var(--cv-ink-3)', marginTop: 'var(--space-1)' }}>
Red circles = observed arrests. Teal points + error bars (three-year models only) show Bayesian model median and 95% interval. One-year models can only compare against the most recent wave. Red circles = observed arrests. Teal points + error bars (three-year models only) show Bayesian model median and 90% interval. One-year models can only compare against the most recent wave.
</p> </p>
</div> </div>
) )
@@ -66,9 +66,9 @@ export default function ModelDrawsComparison({ data, waves, districtName }) {
/** Single quadrant chart: predicted vs observed for one model specification */ /** Single quadrant chart: predicted vs observed for one model specification */
function QuadrantChart({ q, waveData, quadModels, maxArrests }) { function QuadrantChart({ q, waveData, quadModels, maxArrests }) {
const width = 280 const width = 360
const height = 240 const height = 280
const margin = { top: 35, right: 20, bottom: 60, left: 45 } const margin = { top: 45, right: 25, bottom: 70, left: 55 }
// For three-year models show all 3 waves; for one-year only the most recent // For three-year models show all 3 waves; for one-year only the most recent
const showYears = (q.key.includes('m3') || q.key.includes('m4')) ? ['15-16', '17-18', '21-22'] : ['21-22'] const showYears = (q.key.includes('m3') || q.key.includes('m4')) ? ['15-16', '17-18', '21-22'] : ['21-22']
@@ -165,7 +165,7 @@ function QuadrantChart({ q, waveData, quadModels, maxArrests }) {
<> <>
<line x1={70} y1={7} x2={86} y2={7} stroke="var(--teal-600)" strokeWidth={1.5} /> <line x1={70} y1={7} x2={86} y2={7} stroke="var(--teal-600)" strokeWidth={1.5} />
<circle cx={78} cy={7} r={3} fill="var(--teal-600)" /> <circle cx={78} cy={7} r={3} fill="var(--teal-600)" />
<text x={92} y={11} fontSize="0.6rem" fill="var(--cv-ink)">Model (median + 95%)</text> <text x={92} y={11} fontSize="0.6rem" fill="var(--cv-ink)">Model (median + 90%)</text>
</> </>
)} )}
</g> </g>
+6 -6
View File
@@ -51,9 +51,9 @@ export default function ObservedRateDensity({ quadData }) {
// Max value for scaling the ridgeline chart // Max value for scaling the ridgeline chart
const maxRate = Math.max(...modelResults.map(r => r.modeledUpper), ...modelResults.map(r => r.observedRate), 0.5) const maxRate = Math.max(...modelResults.map(r => r.modeledUpper), ...modelResults.map(r => r.observedRate), 0.5)
const width = 280 const width = 380
const height = 220 const height = 280
const margin = { top: 30, right: 20, bottom: 60, left: 45 } const margin = { top: 40, right: 30, bottom: 80, left: 60 }
return ( return (
<div className="cv-card" style={{ padding: 'var(--space-2)' }}> <div className="cv-card" style={{ padding: 'var(--space-2)' }}>
@@ -89,7 +89,7 @@ export default function ObservedRateDensity({ quadData }) {
{/* Observed rate bar (solid) */} {/* Observed rate bar (solid) */}
<rect x={margin.left} y={rowY + 4} width={obsWidth} height={barHeight - 8} fill="var(--cv-navy-600)" rx={2} opacity={0.9} /> <rect x={margin.left} y={rowY + 4} width={obsWidth} height={barHeight - 8} fill="var(--cv-navy-600)" rx={2} opacity={0.9} />
{/* Model interval (dashed outline showing 95% range) */} {/* Model interval (dashed outline showing 90% range) */}
<rect x={lowerX} y={rowY + 4} width={upperWidth} height={barHeight - 8} fill="none" stroke="var(--teal-600)" strokeWidth={1.5} rx={2} /> <rect x={lowerX} y={rowY + 4} width={upperWidth} height={barHeight - 8} fill="none" stroke="var(--teal-600)" strokeWidth={1.5} rx={2} />
{/* Model median marker */} {/* Model median marker */}
@@ -125,13 +125,13 @@ export default function ObservedRateDensity({ quadData }) {
<text x={18} y={10}>Observed rate</text> <text x={18} y={10}>Observed rate</text>
<line x1={140} y1={6} x2={156} y2={6} stroke="var(--teal-600)" strokeWidth={2} /> <line x1={140} y1={6} x2={156} y2={6} stroke="var(--teal-600)" strokeWidth={2} />
<circle cx={148} cy={6} r={3} fill="var(--teal-600)" /> <circle cx={148} cy={6} r={3} fill="var(--teal-600)" />
<text x={162} y={10}>Model (median + 95% interval)</text> <text x={162} y={10}>Model (median + 90% interval)</text>
</g> </g>
</svg> </svg>
{/* Note */} {/* Note */}
<p style={{ fontSize: '0.72rem', color: 'var(--cv-ink-3)', marginTop: 'var(--space-1)' }}> <p style={{ fontSize: '0.72rem', color: 'var(--cv-ink-3)', marginTop: 'var(--space-1)' }}>
Solid blue = observed rate per 1,000 students. Teal outline + median line shows each model's posterior distribution (95% interval). Dashed lines indicate the range of plausible values according to each Bayesian specification. Solid blue = observed rate per 1,000 students. Teal outline + median line shows each model's posterior distribution (90% interval). Dashed lines indicate the range of plausible values according to each Bayesian specification.
</p> </p>
</div> </div>
) )
+3 -3
View File
@@ -15,9 +15,9 @@ const GROUP_COLORS = {
} }
export default function RateByGroupBar({ data }) { export default function RateByGroupBar({ data }) {
const width = 280 const width = 360
const height = 240 const height = 300
const margin = { top: 35, right: 20, bottom: 80, left: 45 } const margin = { top: 45, right: 30, bottom: 100, left: 60 }
const innerWidth = width - margin.left - margin.right const innerWidth = width - margin.left - margin.right
const barHeight = Math.min(16, (innerWidth * 0.7) / data.length) const barHeight = Math.min(16, (innerWidth * 0.7) / data.length)
+5 -2
View File
@@ -102,7 +102,10 @@ export default function ChartPanel({ district, state }) {
<div style={{ <div style={{
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
gap: 'var(--space-5)' gap: 'var(--space-5)',
maxWidth: '70rem',
marginLeft: 'auto',
marginRight: 'auto'
}}> }}>
{/* Charts 1–3: Observed & descriptive */} {/* Charts 1–3: Observed & descriptive */}
<ArrestsOverTime data={timeSeriesData} districtName={district.lea_name} /> <ArrestsOverTime data={timeSeriesData} districtName={district.lea_name} />
@@ -112,7 +115,7 @@ export default function ChartPanel({ district, state }) {
<DistrictVsNational topGroup={topGroup} nationalRate={nationalTopRate} rateByGroup={rateByGroup} /> <DistrictVsNational topGroup={topGroup} nationalRate={nationalTopRate} rateByGroup={rateByGroup} />
{/* Charts 4–6: Bayesian model distributions */} {/* Charts 4–6: Bayesian model distributions */}
<ModelDrawsComparison data={chart4Data} waves={['15-16', '17-18', '21-22']} districtName={district.lea_name} /> <ModelDrawsComparison data={{ ...chart4Data, quadData: chart5Data }} waves={['15-16', '17-18', '21-22']} districtName={district.lea_name} />
<ObservedRateDensity quadData={chart5Data} /> <ObservedRateDensity quadData={chart5Data} />