import { useState } from 'react' /** * Chart 5: Observed rate per group against model distribution. * Shows observed arrest rate and overlays the posterior draw distributions from all four models * as ridgeline-style density bars (proxy using interval bounds + median). * Mirrors wp_fig_group_density() / wp_fig_hpd_ridges() from paper_figures.R. */ const MODEL_LABELS = { unified_m1_mod: 'One-year, baseline', unified_m2_mod: 'One-year + covariate', unified_m3_mod: 'Three-year, baseline', unified_m4_mod: 'Three-year + covariate', } export default function ObservedRateDensity({ quadData }) { const [expandedGroup, setExpandedGroup] = useState(0) // first group by default if (!quadData || Object.keys(quadData).length === 0) { return (

Observed rate vs. model distribution — by student group

No model data available.

) } // Extract unique groups from the default model (unified_m2_mod) const defaultModel = quadData['unified_m2_mod'] || [] const groups = [...new Map(defaultModel.map(r => [`${r.race}-${r.sex}`, { race: r.race, sex: r.sex, label: getLabel(r.race, r.sex) }])).values()] // Get data for the expanded group across all 4 models const currentGroup = groups[expandedGroup] || groups[0] const modelResults = Object.entries(quadData).map(([modelId, rows]) => { const row = rows.find(r => r.race === currentGroup?.race && r.sex === currentGroup?.sex) if (!row) return null // Convert rate median/interval to per-1k scale for visualization const enroll = row.stu_enroll || 0 const observedRate = (row.observed_arrests || 0) / ((enroll || 1) / 1000) const modeledRateMedian = (row.rate_median || 0) * 1000 // rate is per student, convert to per 1k const modeledLower = (row.rate_lower || 0) * 1000 const modeledUpper = (row.rate_upper || 0) * 1000 return { modelId, observedRate, modeledRateMedian, modeledLower, modeledUpper } }).filter(Boolean) // 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 } return (
{/* Chart title */}

Observed rate vs. model distribution — {currentGroup?.label || 'Select a group'}

{/* Group selector */} {/* Ridgeline-style visualization (proxy using interval bars) */} {modelResults.map((r, i) => { const rowY = margin.top + i * (height / modelResults.length + 8) const barHeight = height / modelResults.length - 6 // Pre-compute coordinates outside JSX to avoid embedding statements in render output const obsWidth = (r.observedRate / maxRate) * (width - margin.left - margin.right) const lowerX = margin.left + (r.modeledLower / maxRate) * (width - margin.left - margin.right) const upperWidth = ((r.modeledUpper - r.modeledLower) / maxRate) * (width - margin.left - margin.right) const medX = margin.left + (r.modeledRateMedian / maxRate) * (width - margin.left - margin.right) return ( {/* Background band */} {/* Observed rate bar (solid) */} {/* Model interval (dashed outline showing 95% range) */} {/* Model median marker */} {/* Observed rate label */} {r.observedRate > maxRate * 0.05 && ( {r.observedRate.toFixed(1)} )} {/* Model label */} {MODEL_LABELS[r.modelId] || r.modelId} {/* Y-axis gridline */} ) })} {/* X-axis */} {[0, 0.5, 1].map((frac) => { const val = frac * maxRate return ( {val.toFixed(1)} ) })} Rate per 1,000 students {/* Legend */} Observed rate Model (median + 95% interval) {/* Note */}

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.

) } function getLabel(race, sex) { const r = race === 'WH' ? 'White' : race === 'BL' ? 'Black' : race === 'HI' ? 'Hispanic' : 'American Indian/Alaskan Native' const s = sex === 'F' ? 'Female' : 'Male' return `${r} ${s}` }