import { useMemo } from 'react' /** * Chart 4: Predicted draws by year vs. observed — for three-year models only. * Shows model's predicted median and 95% interval alongside the observed value, * faceted by four quadrants (1yr baseline / 1yr covariate / 3yr baseline / 3yr covariate). * Mirrors wp_fig_district_intervals() from paper_figures.R. */ const QUADRANT_LABELS = [ { key: 'unified_m1_mod', label: 'One-year, no covariate' }, { key: 'unified_m2_mod', label: 'One-year + referral rate' }, { key: 'unified_m3_mod', label: 'Three-year, no covariate' }, { key: 'unified_m4_mod', label: 'Three-year + referral rate' }, ] const WAVE_LABELS = { '15-16': '2015–16', '17-18': '2017–18', '21-22': '2021–22' } export default function ModelDrawsComparison({ data, waves, districtName }) { // Derive per-wave observed arrest totals from the time series const waveData = useMemo(() => { return ['15-16', '17-18', '21-22'].map(year => { const rows = (data.waves && data.waves[year]) || [] return { year, label: WAVE_LABELS[year], arrests: rows.reduce((sum, r) => sum + (r.observed_arrests || 0), 0), enroll: rows.reduce((sum, r) => sum + (r.stu_enroll || 0), 0), } }) }, [data.waves]) const quadModels = data.quadData || {} // Compute max arrests for Y-axis scaling across all quadrants and years const allArrestValues = [] waveData.forEach(d => allArrestValues.push(d.arrests)) Object.entries(quadModels).forEach(([modelId, rows]) => { ['15-16', '17-18', '21-22'].forEach(year => { const yearRow = (rows || []).find(r => r.year === year) if (yearRow) allArrestValues.push(Math.round((yearRow.count_median || 0))) }) }) const maxArrests = Math.max(...allArrestValues, 1) return (
{/* Chart title */}

Model predictions vs. observed — by wave & model type

{/* Four small multiples (one per quadrant) */}
{QUADRANT_LABELS.map((q) => ( ))}
{/* Note */}

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.

) } /** Single quadrant chart: predicted vs observed for one model specification */ function QuadrantChart({ q, waveData, quadModels, maxArrests }) { 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'] // Derive chart dimensions from margin + width/height const innerWidth = width - margin.left - margin.right const innerHeight = height - margin.top - margin.bottom return (
{/* Quadrant label */} {q.label} {/* Background */} {/* Y-axis gridlines + labels */} {[0, 0.25, 0.5, 0.75, 1].map((frac) => { const val = frac * maxArrests if (val > maxArrests) return null const y = margin.top + innerHeight - (val / Math.max(maxArrests, 1)) * innerHeight return ( {val > 0 && ( {Math.round(val)} )} ) })} {/* Y-axis label */} Arrests {/* Points for each year */} {showYears.map((year, yi) => { const observed = waveData.find(d => d.year === year) || { arrests: 0 } const cx = margin.left + (yi / Math.max(showYears.length - 1, 1)) * innerWidth // Observed point position const observedY = margin.top + innerHeight - (observed.arrests / Math.max(maxArrests, 1)) * innerHeight // For three-year models: show predicted median + interval const isThreeYear = q.key.includes('m3') || q.key.includes('m4') if (!isThreeYear) { return ( {/* Observed only for one-year models */} {Math.round(observed.arrests)} ) } // Three-year model: show both observed (dodged left) and predicted (median + 95% interval, dodged right) const obsX = cx - 8 const predX = cx + 8 const yearRow = (quadModels[q.key] || []).find(r => r.year === year) if (!yearRow) { return } // Compute predicted values scaled to the same enrollment as observed const enroll = yearRow.stu_enroll || 1 const predMedian = (yearRow.count_median || 0) / (enroll / 1000) * (observed.enroll / 1000) const lower = (yearRow.count_lower || 0) / (enroll / 1000) * (observed.enroll / 1000) const upper = (yearRow.count_upper || 0) / (enroll / 1000) * (observed.enroll / 1000) return ( {/* Observed */} {/* Predicted error bar + median point */} ) })} {/* X-axis labels */} {showYears.map((year, yi) => ( {WAVE_LABELS[year]} ))} {/* Legend */} Observed {q.key.includes('m3') && ( <> Model (median + 90%) )}
) }