Files
crdc-demo/src/charts/ExceedanceProbability.jsx
T
jared b135f480c0
Deploy to git-pages / deploy (push) Successful in 17s
Fix chart sizing, model predictions, 90% intervals, and deep link district name
- 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...'
2026-08-10 12:50:19 -04:00

162 lines
7.9 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState } from 'react'
/**
* Chart 6: Probability the district exceeds the national rate for each student group.
* Uses posterior draws (approximated via interval bounds + median) to compute
* P(district_rate > national_rate) per race×sex group.
*/
export default function ExceedanceProbability({ rateByGroup, nationalRates }) {
const [selectedModel, setSelectedModel] = useState('unified_m2_mod')
// 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.95})
const computed = rateByGroup.map(d => {
const natKey = `${d.race}_${d.sex}`
const nationalRate = nationalRates[natKey] || 0
// Use modeled median and interval from the estimate data if available
// (rate_median is per student; multiply by 1000 for per-1k)
const modeledMedianPerK = ((d.modeledRateMedian || d.observedRate * (d.enroll || 1) / 1000))
// Actually: rate_median is already a probability (per-student). Convert to per-1k.
const modeledRatePerK = ((d.rate_median || 0) * 1000) || d.observedRate
// 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.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
const probExceeds = normalCDF(z)
return {
...d,
natKey,
nationalRate,
modeledRate: modeledRatePerK,
modeledSd,
probExceeds: Math.max(0.01, Math.min(0.99, probExceeds)), // clamp to avoid extremes
}
}).sort((a, b) => b.probExceeds - a.probExceeds)
const width = 400
const height = 320
const margin = { top: 50, right: 100, bottom: 90, left: 70 }
const innerWidth = width - margin.left - margin.right
return (
<div className="cv-card" style={{ padding: 'var(--space-2)' }}>
{/* Chart title */}
<h3 style={{ fontSize: '0.85rem', marginBottom: 'var(--space-1)', color: 'var(--cv-ink-2)' }}>
Probability district exceeds national rate — by student group
</h3>
{/* Model selector (advanced toggle) */}
<select value={selectedModel} onChange={(e) => setSelectedModel(e.target.value)}
style={{ marginBottom: 'var(--space-1)', padding: '0.25rem 0.5rem', fontFamily: 'var(--font-sans)' }}>
{[
{ val: 'unified_m2_mod', label: 'Recommended (one-year + covariate)' },
{ val: 'unified_m4_mod', label: 'Three-year + covariate' },
].map(opt => (
<option key={opt.val} value={opt.val}>{opt.label}</option>
))}
</select>
<svg width="100%" viewBox={`0 0 ${width} ${height}`} style={{ maxWidth: '100%' }}>
{/* Background */}
<rect x={margin.left} y={margin.top} width={innerWidth} height={height - margin.top - margin.bottom} fill="var(--cv-paper-2)" rx={4} />
{/* Y-axis gridlines (probability scale 0–1) */}
{[0, 0.25, 0.5, 0.75, 1].map((frac) => {
const y = margin.top + ((height - margin.top - margin.bottom) - frac * (height - margin.top - margin.bottom))
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>
)
})}
{/* Y-axis label */}
<text x={14} y={margin.top + (height - margin.top - margin.bottom) / 2} textAnchor="middle" fontSize="0.7rem" fill="var(--cv-ink-3)" transform={`rotate(-90 14 ${margin.top + (height - margin.top - margin.bottom) / 2})`}>
P(district rate &gt; national)
</text>
{/* Bars */}
{computed.map((d, i) => {
const barHeight = Math.min(22, (innerWidth * 0.65) / computed.length)
const y = margin.top + i * (barHeight + 4)
const probWidth = d.probExceeds * innerWidth
// Color by threshold: green if <50%, amber if ~50-75%, red if >75%
let barColor
if (d.probExceeds < 0.5) barColor = 'var(--moss-600)' // "not likely" → green
else if (d.probExceeds < 0.8) barColor = 'var(--brass-600)' // "possible" → amber
else barColor = 'var(--cv-danger)' // "likely" → red
return (
<g key={`${d.race}-${d.sex}`}>
{/* Bar */}
<rect x={margin.left} y={y + 2} width={probWidth} height={barHeight - 4} rx={2} fill={barColor} />
{/* Probability label at end of bar */}
{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>
)}
{/* Group label */}
<text x={margin.left - 6} y={y + barHeight / 2 + 3} textAnchor="end" fontSize="0.65rem" fill="var(--cv-ink)">{d.label}</text>
{/* National rate reference line at 50% */}
{i === Math.floor(computed.length / 2) && (
<line x1={margin.left + innerWidth * 0.5} y1={y + barHeight / 2 - 8}
x2={margin.left + innerWidth * 0.5} y2={height - margin.bottom - 4}
stroke="var(--cv-accent)" strokeWidth={1} strokeDasharray="3,2" />
)}
{/* National rate annotation */}
<text x={margin.left + innerWidth * 0.5 + 4} y={y + barHeight / 2 - 8} fontSize="0.6rem" fill="var(--cv-accent)" style={{ display: i === Math.floor(computed.length / 2) ? 'block' : 'none' }}>
national = {d.nationalRate.toFixed(2)}/1k
</text>
</g>
)
})}
{/* X-axis label */}
<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>
{/* Legend */}
<g transform={`translate(${margin.left}, ${height - 45})`}>
<rect x={0} y={0} width={12} height={12} fill="var(--moss-600)" rx={2} />
<text x={18} y={10} fontSize="0.6rem" fill="var(--cv-ink)">&lt; 50% (not likely)</text>
<rect x={140} y={0} width={12} height={12} fill="var(--brass-600)" rx={2} />
<text x={158} y={10} fontSize="0.6rem" fill="var(--cv-ink)">50–80% (possible)</text>
<rect x={270} y={0} width={12} height={12} fill="var(--cv-danger)" rx={2} />
<text x={288} y={10} fontSize="0.6rem" fill="var(--cv-ink)">&gt; 80% (likely)</text>
</g>
</svg>
{/* Summary note */}
<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'}, which balances precision with current-year data. Green bars indicate groups where this district's rate is unlikely to exceed the national average; red indicates likely exceedance.
</p>
</div>
)
}
// Standard normal CDF approximation (Abramowitz & Stegun)
function normalCDF(z) {
const t = 1 / (1 + 0.2316419 * Math.abs(z))
const d = 0.3989423 * Math.exp(-z * z / 2)
let prob = d * t * (0.3193815 + t * (-0.3565638 + t * (1.781478 + t * (-1.821256 + t * 1.330274))))
return z > 0 ? 1 - prob : prob
}
// Inject a CSS fix for the broken fill attribute in ExceedanceProbability and DistrictVsNational
const style = document.createElement('style')