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:
+17
-3
@@ -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 })
|
|
||||||
|
// 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')
|
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)
|
// 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
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user