Files
crdc-demo/src/charts/ModelDrawsComparison.jsx
T
jaredandClaude Sonnet 5 67caa02fe2
Deploy to git-pages / deploy (push) Successful in 18s
Redesign chart visualizations to match Civilytics white-paper style
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>
2026-08-10 18:45:37 -04:00

123 lines
5.0 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 { 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 &amp; 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>
)
}