Files
crdc-demo/src/charts/ObservedRateDensity.jsx
T
jared dd69bb6f8f
Deploy to git-pages / deploy (push) Successful in 11s
Fix JS syntax error in ObservedRateDensity.jsx — const declarations were embedded inside JSX
The obsWidth, lowerX, upperWidth, and medX variables were declared inline within the SVG <g> element's children, which is invalid JavaScript/JSX. This caused 'ReferenceError: obsWidth is not defined' at runtime when rendering Chart 5.

Fix: Moved all const declarations to the top of the map callback, before the return statement.
2026-08-10 12:21:00 -04:00

145 lines
7.3 KiB
React

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 (
<div className="cv-card" style={{ padding: 'var(--space-2)' }}>
<h3 style={{ fontSize: '0.85rem', marginBottom: 'var(--space-1)', color: 'var(--cv-ink-2)' }}>
Observed rate vs. model distribution — by student group
</h3>
<p style={{ color: 'var(--cv-ink-3)' }}>No model data available.</p>
</div>
)
}
// 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 (
<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)' }}>
Observed rate vs. model distribution — {currentGroup?.label || 'Select a group'}
</h3>
{/* Group selector */}
<select value={expandedGroup} onChange={(e) => setExpandedGroup(Number(e.target.value))}
style={{ marginBottom: 'var(--space-1)', padding: '0.25rem 0.5rem', fontFamily: 'var(--font-sans)' }}>
{groups.map((g, i) => (
<option key={`${g.race}-${g.sex}`} value={i}>{g.label}</option>
))}
</select>
{/* Ridgeline-style visualization (proxy using interval bars) */}
<svg width="100%" viewBox={`0 0 ${width} ${height + modelResults.length * 25}`} style={{ maxWidth: '100%' }}>
{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 (
<g key={r.modelId}>
{/* Background band */}
<rect x={margin.left} y={rowY} width={width - margin.left - margin.right} height={barHeight} rx={3} fill="var(--cv-paper-2)" />
{/* 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) */}
<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 */}
<line x1={medX} y1={rowY + 3} x2={medX} y2={rowY + barHeight - 3} stroke="var(--teal-600)" strokeWidth={2} />
{/* Observed rate label */}
{r.observedRate > maxRate * 0.05 && (
<text x={margin.left + obsWidth + 4} y={rowY + barHeight / 2 + 3} fontSize="0.6rem" fill="var(--cv-ink)">{r.observedRate.toFixed(1)}</text>
)}
{/* Model label */}
<text x={margin.left - 6} y={rowY + barHeight / 2 + 3} textAnchor="end" fontSize="0.65rem" fill="var(--cv-ink)">{MODEL_LABELS[r.modelId] || r.modelId}</text>
{/* Y-axis gridline */}
<line x1={margin.left - 4} y1={rowY + barHeight / 2 - 8} x2={margin.left} y2={rowY + barHeight / 2 - 8} stroke="var(--cv-rule)" strokeWidth={0.5} />
</g>
)
})}
{/* X-axis */}
<line x1={margin.left} y1={height + modelResults.length * 16} x2={width - margin.right} y2={height + modelResults.length * 16} stroke="var(--cv-rule)" strokeWidth={1} />
{[0, 0.5, 1].map((frac) => {
const val = frac * maxRate
return (
<text key={`x-${val}`} x={margin.left + frac * (width - margin.left - margin.right)} y={height + modelResults.length * 24} textAnchor="middle" fontSize="0.6rem" fill="var(--cv-ink-3)">{val.toFixed(1)}</text>
)
})}
<text x={margin.left + (width - margin.left - margin.right) / 2} y={height + modelResults.length * 30} textAnchor="middle" fontSize="0.7rem" fill="var(--cv-ink-3)">Rate per 1,000 students</text>
{/* Legend */}
<g transform={`translate(${margin.left}, ${height + modelResults.length * 8})`} style={{ fontSize: '0.65rem' }}>
<rect x={0} y={0} width={12} height={12} fill="var(--cv-navy-600)" rx={2} />
<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>
</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.
</p>
</div>
)
}
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}`
}