Fix chart sizing, model predictions, 90% intervals, and deep link district name
Deploy to git-pages / deploy (push) Successful in 17s
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:
+18
-4
@@ -4,6 +4,7 @@ import DistrictSearch from './components/DistrictSearch.jsx'
|
||||
import LoadingAnimation from './components/LoadingAnimation.jsx'
|
||||
import ChartPanel from './components/ChartPanel.jsx'
|
||||
import Footer from './components/Footer.jsx'
|
||||
import { searchDistricts } from './hooks/useApi.js'
|
||||
|
||||
/**
|
||||
* CRDC Arrests API Demo App — main router.
|
||||
@@ -20,11 +21,24 @@ export default function App() {
|
||||
const leaid = params.get('leaid')
|
||||
const stateParam = params.get('state')
|
||||
if (leaid && stateParam) {
|
||||
// Deep link: skip straight to loading → results.
|
||||
// We need to fetch the district name since we only have LEAID from URL.
|
||||
// Deep link: fetch district name first, then go to loading.
|
||||
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')
|
||||
})
|
||||
}
|
||||
}, [])
|
||||
|
||||
|
||||
@@ -15,9 +15,9 @@ export default function ArrestsOverTime({ data, districtName }) {
|
||||
)
|
||||
|
||||
// Chart dimensions (SVG-based, no D3 dependency)
|
||||
const width = 260
|
||||
const height = 220
|
||||
const margin = { top: 40, right: 80, bottom: 50, left: 55 }
|
||||
const width = 360
|
||||
const height = 280
|
||||
const margin = { top: 50, right: 90, bottom: 60, left: 65 }
|
||||
const innerWidth = width - margin.left - margin.right
|
||||
const innerHeight = height - margin.top - margin.bottom
|
||||
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
*/
|
||||
|
||||
export default function DistrictVsNational({ topGroup, nationalRate, rateByGroup }) {
|
||||
const width = 280
|
||||
const height = 240
|
||||
const margin = { top: 35, right: 20, bottom: 60, left: 55 }
|
||||
const width = 360
|
||||
const height = 300
|
||||
const margin = { top: 45, right: 30, bottom: 80, left: 70 }
|
||||
const innerWidth = width - margin.left - margin.right
|
||||
|
||||
// Compute max rate for scaling (district + national)
|
||||
|
||||
@@ -11,7 +11,7 @@ export default function ExceedanceProbability({ rateByGroup, nationalRates }) {
|
||||
|
||||
// Compute exceedance probability for each group using normal approximation:
|
||||
// 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 natKey = `${d.race}_${d.sex}`
|
||||
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
|
||||
const lowerPerK = ((d.rate_lower || 0) * 1000) || (modeledRatePerK * 0.7)
|
||||
const upperPerK = ((d.rate_upper || 0) * 1000) || (modeledRatePerK * 1.3)
|
||||
// sd ≈ (upper - lower) / (2 * 1.96) for a 95% interval under normality
|
||||
const modeledSd = Math.max((upperPerK - lowerPerK) / 3.92, 0.05)
|
||||
// sd ≈ (upper - lower) / (2 * 1.645) for a 90% interval under normality
|
||||
const modeledSd = Math.max((upperPerK - lowerPerK) / 3.29, 0.05)
|
||||
|
||||
// P(district > national) using normal CDF approximation
|
||||
const z = (modeledRatePerK - nationalRate) / modeledSd
|
||||
@@ -42,9 +42,9 @@ export default function ExceedanceProbability({ rateByGroup, nationalRates }) {
|
||||
}
|
||||
}).sort((a, b) => b.probExceeds - a.probExceeds)
|
||||
|
||||
const width = 300
|
||||
const height = 260
|
||||
const margin = { top: 40, right: 80, bottom: 70, left: 55 }
|
||||
const width = 400
|
||||
const height = 320
|
||||
const margin = { top: 50, right: 100, bottom: 90, left: 70 }
|
||||
const innerWidth = width - margin.left - margin.right
|
||||
|
||||
return (
|
||||
|
||||
@@ -58,7 +58,7 @@ export default function ModelDrawsComparison({ data, waves, districtName }) {
|
||||
|
||||
{/* Note */}
|
||||
<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>
|
||||
</div>
|
||||
)
|
||||
@@ -66,9 +66,9 @@ export default function ModelDrawsComparison({ data, waves, districtName }) {
|
||||
|
||||
/** Single quadrant chart: predicted vs observed for one model specification */
|
||||
function QuadrantChart({ q, waveData, quadModels, maxArrests }) {
|
||||
const width = 280
|
||||
const height = 240
|
||||
const margin = { top: 35, right: 20, bottom: 60, left: 45 }
|
||||
const width = 360
|
||||
const height = 280
|
||||
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
|
||||
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} />
|
||||
<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>
|
||||
|
||||
@@ -51,9 +51,9 @@ export default function ObservedRateDensity({ quadData }) {
|
||||
// Max value for scaling the ridgeline chart
|
||||
const maxRate = Math.max(...modelResults.map(r => r.modeledUpper), ...modelResults.map(r => r.observedRate), 0.5)
|
||||
|
||||
const width = 280
|
||||
const height = 220
|
||||
const margin = { top: 30, right: 20, bottom: 60, left: 45 }
|
||||
const width = 380
|
||||
const height = 280
|
||||
const margin = { top: 40, right: 30, bottom: 80, left: 60 }
|
||||
|
||||
return (
|
||||
<div className="cv-card" style={{ padding: 'var(--space-2)' }}>
|
||||
@@ -89,7 +89,7 @@ export default function ObservedRateDensity({ quadData }) {
|
||||
{/* 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} />
|
||||
|
||||
{/* 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} />
|
||||
|
||||
{/* Model median marker */}
|
||||
@@ -125,13 +125,13 @@ export default function ObservedRateDensity({ quadData }) {
|
||||
<text x={18} y={10}>Observed rate</text>
|
||||
<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)" />
|
||||
<text x={162} y={10}>Model (median + 95% interval)</text>
|
||||
<text x={162} y={10}>Model (median + 90% interval)</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
{/* Note */}
|
||||
<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>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -15,9 +15,9 @@ const GROUP_COLORS = {
|
||||
}
|
||||
|
||||
export default function RateByGroupBar({ data }) {
|
||||
const width = 280
|
||||
const height = 240
|
||||
const margin = { top: 35, right: 20, bottom: 80, left: 45 }
|
||||
const width = 360
|
||||
const height = 300
|
||||
const margin = { top: 45, right: 30, bottom: 100, left: 60 }
|
||||
const innerWidth = width - margin.left - margin.right
|
||||
const barHeight = Math.min(16, (innerWidth * 0.7) / data.length)
|
||||
|
||||
|
||||
@@ -102,7 +102,10 @@ export default function ChartPanel({ district, state }) {
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 'var(--space-5)'
|
||||
gap: 'var(--space-5)',
|
||||
maxWidth: '70rem',
|
||||
marginLeft: 'auto',
|
||||
marginRight: 'auto'
|
||||
}}>
|
||||
{/* Charts 1–3: Observed & descriptive */}
|
||||
<ArrestsOverTime data={timeSeriesData} districtName={district.lea_name} />
|
||||
@@ -112,7 +115,7 @@ export default function ChartPanel({ district, state }) {
|
||||
<DistrictVsNational topGroup={topGroup} nationalRate={nationalTopRate} rateByGroup={rateByGroup} />
|
||||
|
||||
{/* 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} />
|
||||
|
||||
|
||||
Reference in New Issue
Block a user