Deploy to git-pages / deploy (push) Successful in 18s
Rebuilds all 6 charts around a validated categorical race palette, row-based sex encoding, and a consistent observed-vs-modeled mark convention (diamond vs. filled bar/density) instead of ad hoc per-chart color schemes. Adds a shared student-group filter (defaults to all 8 groups) that scopes every chart's data from one place in ChartPanel. Drops the D3 dependency entirely in favor of plain SVG, removing the imperative-DOM bug class behind this app's repeated "fix the fix" commits. Replaces the fake symmetric-normal posterior approximation with a skewed, median-preserving fit to the API's interval bounds, clearly labeled as an approximation. Fixes two broken SVG fill attributes, a decorative model dropdown that never affected its chart, dead code, an orphaned component, and a broken CSS token reference. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
123 lines
5.0 KiB
React
123 lines
5.0 KiB
React
import { MODEL_QUADRANTS } from '../hooks/useApi.js'
|
||
import ChartLegend from '../components/ChartLegend.jsx'
|
||
import { OBSERVED_MARK_COLOR, MODELED_AGGREGATE_COLOR } from '../utils/colors.js'
|
||
|
||
/**
|
||
* Chart 4: Modeled vs. observed arrests, faceted by the four model-quadrant
|
||
* specifications (one-year/three-year × baseline/+referral-rate covariate).
|
||
* Plain SVG (no D3) — the quadrant label already conveys 1yr/3yr, so all
|
||
* four panels share one ink-diamond/navy-point-range convention rather than
|
||
* doubling up with a redundant per-quadrant hue.
|
||
*/
|
||
|
||
const WAVE_LABELS = { '15-16': '2015–16', '17-18': '2017–18', '21-22': '2021–22' }
|
||
const ALL_WAVES = ['15-16', '17-18', '21-22']
|
||
|
||
export default function ModelDrawsComparison({ data, waves = ALL_WAVES, districtName }) {
|
||
const waveData = waves.map((year) => {
|
||
const rows = (data.waves && data.waves[year]) || []
|
||
return { year, arrests: rows.reduce((sum, r) => sum + (r.observed_arrests || 0), 0) }
|
||
})
|
||
|
||
const quadModels = (data && data.quadData) || {}
|
||
|
||
const allValues = waveData.map((d) => d.arrests)
|
||
Object.values(quadModels).forEach((rows) => {
|
||
;(rows || []).forEach((r) => {
|
||
allValues.push(r.count_median || 0, r.count_upper || 0)
|
||
})
|
||
})
|
||
const maxArrests = Math.min(Math.max(...allValues, 1), 50) // cap for readability
|
||
|
||
return (
|
||
<div className="cv-card" style={{ padding: 'var(--space-2)' }}>
|
||
<h3 style={{ fontSize: '0.85rem', marginBottom: 'var(--space-1)', color: 'var(--cv-ink-2)' }}>
|
||
Model predictions vs. observed — by wave & model specification
|
||
</h3>
|
||
|
||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 'var(--space-2)' }}>
|
||
{MODEL_QUADRANTS.map((q) => (
|
||
<QuadrantPanel key={q.model} q={q} waveData={waveData} quadModels={quadModels} maxArrests={maxArrests} />
|
||
))}
|
||
</div>
|
||
|
||
<ChartLegend items={[
|
||
{ shape: 'diamond', color: OBSERVED_MARK_COLOR, label: 'Observed' },
|
||
{ shape: 'line', color: MODELED_AGGREGATE_COLOR, label: 'Modeled (median + 90% interval)' },
|
||
]} />
|
||
|
||
<p style={{ fontSize: '0.72rem', color: 'var(--cv-ink-3)', marginTop: 'var(--space-1)' }}>
|
||
Three-year models span all three CRDC waves; one-year models show 2021–22 only.{' '}
|
||
{districtName ? `District: ${districtName}.` : ''}
|
||
</p>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function QuadrantPanel({ q, waveData, quadModels, maxArrests }) {
|
||
const isThreeYear = q.model.includes('m3') || q.model.includes('m4')
|
||
const showYears = isThreeYear ? ALL_WAVES : ['21-22']
|
||
|
||
const width = 260
|
||
const height = 190
|
||
const margin = { top: 26, right: 12, bottom: 26, left: 34 }
|
||
const innerWidth = width - margin.left - margin.right
|
||
const innerHeight = height - margin.top - margin.bottom
|
||
|
||
const xScale = (i) => margin.left + (showYears.length > 1 ? (i / (showYears.length - 1)) * innerWidth : innerWidth / 2)
|
||
const yScale = (val) => margin.top + innerHeight - (Math.min(val, maxArrests) / maxArrests) * innerHeight
|
||
|
||
return (
|
||
<div style={{ border: '1px solid var(--cv-rule)', borderRadius: 'var(--radius-md)', padding: 'var(--space-1)' }}>
|
||
<svg width="100%" viewBox={`0 0 ${width} ${height}`} style={{ maxWidth: '100%' }}>
|
||
<text x={width / 2} y={14} textAnchor="middle" fontSize="0.65rem" fontWeight={600} fill="var(--cv-ink-2)">
|
||
{q.label}
|
||
</text>
|
||
|
||
{[0, 0.25, 0.5, 0.75, 1].map((frac) => {
|
||
const val = frac * maxArrests
|
||
const y = yScale(val)
|
||
return (
|
||
<g key={frac}>
|
||
<line x1={margin.left} y1={y} x2={margin.left + innerWidth} y2={y} stroke="var(--cv-rule)" strokeWidth={1} />
|
||
<text x={margin.left - 6} y={y + 3} textAnchor="end" fontSize="0.55rem" fill="var(--cv-ink-3)">{Math.round(val)}</text>
|
||
</g>
|
||
)
|
||
})}
|
||
|
||
{showYears.map((year, i) => {
|
||
const x = xScale(i)
|
||
const obs = waveData.find((d) => d.year === year)?.arrests ?? 0
|
||
const yearRow = (quadModels[q.model] || []).find((r) => r.year === year)
|
||
const obsY = yScale(obs)
|
||
|
||
return (
|
||
<g key={year}>
|
||
<text x={x} y={height - 6} textAnchor="middle" fontSize="0.55rem" fill="var(--cv-ink-3)">
|
||
{WAVE_LABELS[year]}
|
||
</text>
|
||
|
||
{yearRow && (
|
||
<>
|
||
<line
|
||
x1={x} y1={yScale(yearRow.count_upper ?? yearRow.count_median ?? 0)}
|
||
x2={x} y2={yScale(yearRow.count_lower ?? yearRow.count_median ?? 0)}
|
||
stroke={MODELED_AGGREGATE_COLOR} strokeWidth={1.5}
|
||
/>
|
||
<circle cx={x} cy={yScale(yearRow.count_median ?? 0)} r={3.5} fill={MODELED_AGGREGATE_COLOR} stroke="#fff" strokeWidth={1} />
|
||
</>
|
||
)}
|
||
|
||
<rect
|
||
x={x - 4} y={obsY - 4} width={8} height={8}
|
||
fill={OBSERVED_MARK_COLOR} stroke="#fff" strokeWidth={1}
|
||
transform={`rotate(45 ${x} ${obsY})`}
|
||
/>
|
||
</g>
|
||
)
|
||
})}
|
||
</svg>
|
||
</div>
|
||
)
|
||
}
|