Redesign chart visualizations to match Civilytics white-paper style
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>
This commit is contained in:
2026-08-10 18:45:37 -04:00
co-authored by Claude Sonnet 5
parent 6c8e1e8409
commit 67caa02fe2
19 changed files with 910 additions and 1580 deletions
+88 -330
View File
@@ -1,364 +1,122 @@
import { useMemo, useRef, useEffect } from 'react'
import * as d3 from 'd3'
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: Predicted draws by year vs. observed — with raw posterior draws from API.
* Shows model's predicted distribution alongside the observed value,
* faceted by four quadrants (1yr baseline / 1yr covariate / 3yr baseline / 3yr covariate).
* 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 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' }
const ALL_WAVES = ['15-16', '17-18', '21-22']
const COLORS = {
observed: '#c92d0e', // Red for observed
model1yr: '#0791b6', // Teal for one-year models
model3yr: '#000a9b', // Navy for three-year models
}
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])
// Handle both cases: when quadData exists as object or is null/undefined
const quadModels = (data && data.quadData) ? data.quadData : {}
// modelDraws may be null if API unavailable, handle gracefully
const modelDraws = (data && data.modelDraws) ? data.modelDraws : null
// Compute max arrests for Y-axis scaling across all quadrants and years
const allArrestValues = []
waveData.forEach(d => allArrestValues.push(d.arrests))
// Also include predicted values
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(yearRow.count_median || 0)
allArrestValues.push(yearRow.count_upper || 0)
}
})
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) }
})
// Add draw values if available (when modelDraws is populated)
const drawValues = modelDraws || {}
Object.values(drawValues).forEach(groups => {
Object.values(groups).forEach(draws => {
if (draws && draws.length > 0) {
allArrestValues.push(...draws)
}
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(...allArrestValues, 1), 50) // Cap at 50 for readability
const maxArrests = Math.min(Math.max(...allValues, 1), 50) // cap for readability
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)' }}>
Model predictions vs. observed — by wave & model type
Model predictions vs. observed — by wave &amp; model specification
</h3>
{/* Four small multiples (one per quadrant) */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 'var(--space-2)' }}>
{QUADRANT_LABELS.map((q) => (
<QuadrantChart
key={q.key}
q={q}
waveData={waveData}
quadModels={quadModels}
modelDraws={modelDraws ? modelDraws[q.key] : null}
maxArrests={maxArrests}
/>
{MODEL_QUADRANTS.map((q) => (
<QuadrantPanel key={q.model} q={q} waveData={waveData} quadModels={quadModels} maxArrests={maxArrests} />
))}
</div>
{/* Legend */}
<div style={{ display: 'flex', gap: 'var(--space-3)', marginTop: 'var(--space-2)', flexWrap: 'wrap' }}>
<span style={{ display: 'flex', alignItems: 'center', gap: '0.3rem', fontSize: '0.7rem' }}>
<span style={{ width: 8, height: 8, borderRadius: '50%', background: COLORS.observed }} />
Observed arrests
</span>
<span style={{ display: 'flex', alignItems: 'center', gap: '0.3rem', fontSize: '0.7rem' }}>
<span style={{ width: 8, height: 8, borderRadius: '50%', background: COLORS.model1yr }} />
One-year model (90% interval)
</span>
<span style={{ display: 'flex', alignItems: 'center', gap: '0.3rem', fontSize: '0.7rem' }}>
<span style={{ width: 8, height: 3, background: COLORS.model3yr }} />
Three-year model (90% HPD)
</span>
</div>
<ChartLegend items={[
{ shape: 'diamond', color: OBSERVED_MARK_COLOR, label: 'Observed' },
{ shape: 'line', color: MODELED_AGGREGATE_COLOR, label: 'Modeled (median + 90% interval)' },
]} />
{/* Note */}
<p style={{ fontSize: '0.72rem', color: 'var(--cv-ink-3)', marginTop: 'var(--space-1)' }}>
<strong>Red circles</strong> = observed arrests. Points with error bars show Bayesian model median and 90% interval.
Three-year models span multiple waves; one-year models only show most recent 2021–22.
Three-year models span all three CRDC waves; one-year models show 2021–22 only.{' '}
{districtName ? `District: ${districtName}.` : ''}
</p>
</div>
)
}
/** Single quadrant chart: predicted vs observed for one model specification */
function QuadrantChart({ q, waveData, quadModels, modelDraws, maxArrests }) {
const svgRef = useRef(null)
// For three-year models show all 3 waves; for one-year only the most recent
const isThreeYear = q.key.includes('m3') || q.key.includes('m4')
const showYears = isThreeYear ? ['15-16', '17-18', '21-22'] : ['21-22']
function QuadrantPanel({ q, waveData, quadModels, maxArrests }) {
const isThreeYear = q.model.includes('m3') || q.model.includes('m4')
const showYears = isThreeYear ? ALL_WAVES : ['21-22']
useEffect(() => {
const container = svgRef.current?.parentElement
const width = Math.min(320, container?.clientWidth || 320)
const height = 220
const margin = { top: 35, right: 15, bottom: 45, left: 40 }
const innerWidth = width - margin.left - margin.right
const innerHeight = height - margin.top - margin.bottom
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
// Clear previous
d3.select(svgRef.current).selectAll('*').remove()
const svg = d3.select(svgRef.current)
.attr('width', width)
.attr('height', height)
.attr('viewBox', `0 0 ${width} ${height}`)
// Scales
const xScale = d3.scaleLinear()
.domain([0, showYears.length - 1])
.range([margin.left, margin.left + innerWidth])
const yScale = d3.scaleLinear()
.domain([0, maxArrests])
.range([margin.top + innerHeight, margin.top])
// Gridlines
svg.append('g')
.attr('class', 'grid')
.selectAll('line')
.data(yScale.ticks(5))
.join('line')
.attr('x1', margin.left)
.attr('x2', margin.left + innerWidth)
.attr('y1', d => yScale(d))
.attr('y2', d => yScale(d))
.attr('stroke', '#f0f0f0')
.attr('stroke-width', 1)
// X-axis (wave labels)
svg.append('g')
.attr('transform', `translate(0,${margin.top + innerHeight})`)
.call(d3.axisBottom(xScale).tickFormat(i => WAVE_LABELS[showYears[i]] || '').ticks(showYears.length))
.call(g => g.select('.domain').attr('stroke', '#ddd'))
.call(g => g.selectAll('.tick line').remove())
.call(g => g.selectAll('.tick text')
.attr('font-size', '0.6rem')
.attr('fill', '#666'))
// Y-axis
svg.append('g')
.attr('transform', `translate(${margin.left},0)`)
.call(d3.axisLeft(yScale).ticks(5))
.call(g => g.select('.domain').remove())
.call(g => g.selectAll('.tick line').attr('stroke', '#eee'))
.call(g => g.selectAll('.tick text').attr('font-size', '0.6rem').attr('fill', '#666'))
// Y-axis label
svg.append('text')
.attr('x', 10)
.attr('y', margin.top + innerHeight / 2)
.attr('transform', `rotate(-90 10 ${margin.top + innerHeight / 2})`)
.attr('text-anchor', 'middle')
.attr('font-size', '0.55rem')
.attr('fill', '#999')
.text('Arrests')
// Colors
const baseColor = isThreeYear ? COLORS.model3yr : COLORS.model1yr
// Process and render each year
showYears.forEach((year, i) => {
const x = xScale(i)
// Observed data
const obsData = waveData.find(d => d.year === year) || { arrests: 0 }
const obsY = yScale(Math.min(obsData.arrests, maxArrests))
// Model predictions (from summary stats)
const yearRow = (quadModels[q.key] || []).find(r => r.year === year)
const predMedian = yearRow?.count_median || 0
const predLower = yearRow?.count_lower || 0
const predUpper = yearRow?.count_upper || 0
// Raw draws (from API) - sum across all demographic groups for this year
const rawDraws = modelDraws ? Object.values(modelDraws).flat() : []
if (rawDraws && rawDraws.length > 0) {
// Use actual posterior draws for visualization
// Create histogram bins from raw draws
const binGenerator = d3.bin()
.domain([0, maxArrests])
.thresholds(20)
const bins = binGenerator(rawDraws.filter(d => d <= maxArrests * 1.5))
const maxBin = d3.max(bins, b => b.length) || 1
// Normalize heights - shift histogram up above x-axis
const histHeight = innerHeight * 0.25 // 25% of chart height for distribution
const histY = margin.top + innerHeight
// Draw density shape
if (bins.length > 0) {
const area = d3.area()
.x(b => xScale(i) + ((b.x0 + b.x1) / 2 - i) * innerWidth / (showYears.length - 1 || 1) * 0.7)
.y0(histY)
.y1(b => histY - (b.length / maxBin) * histHeight)
.curve(d3.curveBasis)
svg.append('path')
.datum(bins.filter(b => b.length > 0))
.attr('d', area)
.attr('fill', baseColor)
.attr('opacity', 0.5)
}
// Add median line from draws
const drawMedian = d3.median(rawDraws)
svg.append('line')
.attr('x1', x - 4)
.attr('x2', x + 4)
.attr('y1', yScale(drawMedian))
.attr('y2', yScale(drawMedian))
.attr('stroke', baseColor)
.attr('stroke-width', 2)
// Add 90% interval from draws
const drawLower = d3.quantile(rawDraws.sort(d3.ascending), 0.05)
const drawUpper = d3.quantile(rawDraws.sort(d3.ascending), 0.95)
svg.append('line')
.attr('x1', x)
.attr('x2', x)
.attr('y1', yScale(drawUpper))
.attr('y2', yScale(drawLower))
.attr('stroke', baseColor)
.attr('stroke-width', 1.5)
svg.append('line')
.attr('x1', x - 3)
.attr('x2', x + 3)
.attr('y1', yScale(drawUpper))
.attr('y2', yScale(drawUpper))
.attr('stroke', baseColor)
.attr('stroke-width', 1.5)
svg.append('line')
.attr('x1', x - 3)
.attr('x2', x + 3)
.attr('y1', yScale(drawLower))
.attr('y2', yScale(drawLower))
.attr('stroke', baseColor)
.attr('stroke-width', 1.5)
// Add observed on top (diamond)
svg.append('path')
.attr('d', `M${x},${obsY - 5} L${x + 4},${obsY} L${x},${obsY + 5} L${x - 4},${obsY} Z`)
.attr('fill', COLORS.observed)
// Label observed value
if (obsData.arrests > 0) {
svg.append('text')
.attr('x', x)
.attr('y', obsY - 8)
.attr('text-anchor', 'middle')
.attr('font-size', '0.55rem')
.attr('fill', COLORS.observed)
.text(Math.round(obsData.arrests))
}
} else if (yearRow) {
// Fall back to interval-based rendering
const medianY = yScale(Math.min(predMedian, maxArrests))
// Error bar
svg.append('line')
.attr('x1', x)
.attr('x2', x)
.attr('y1', yScale(Math.min(predUpper, maxArrests)))
.attr('y2', yScale(Math.min(predLower, maxArrests)))
.attr('stroke', baseColor)
.attr('stroke-width', 1.5)
// Median point
svg.append('circle')
.attr('cx', x)
.attr('cy', medianY)
.attr('r', 3)
.attr('fill', baseColor)
// Observed (diamond)
svg.append('path')
.attr('d', `M${x},${obsY - 5} L${x + 4},${obsY} L${x},${obsY + 5} L${x - 4},${obsY} Z`)
.attr('fill', COLORS.observed)
// Label
if (obsData.arrests > 0) {
svg.append('text')
.attr('x', x)
.attr('y', obsY - 8)
.attr('text-anchor', 'middle')
.attr('font-size', '0.55rem')
.attr('fill', COLORS.observed)
.text(Math.round(obsData.arrests))
}
} else {
// No model data - just show observed
svg.append('path')
.attr('d', `M${x},${obsY - 5} L${x + 4},${obsY} L${x},${obsY + 5} L${x - 4},${obsY} Z`)
.attr('fill', COLORS.observed)
if (obsData.arrests > 0) {
svg.append('text')
.attr('x', x)
.attr('y', obsY - 8)
.attr('text-anchor', 'middle')
.attr('font-size', '0.55rem')
.attr('fill', COLORS.observed)
.text(Math.round(obsData.arrests))
}
}
})
// Quadrant label at top
svg.append('text')
.attr('x', margin.left + innerWidth / 2)
.attr('y', 14)
.attr('text-anchor', 'middle')
.attr('font-size', '0.65rem')
.attr('fill', '#444')
.attr('font-weight', 500)
.text(q.label)
}, [waveData, quadModels, modelDraws, q, maxArrests])
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 ref={svgRef} style={{ width: '100%', height: 'auto' }} />
<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>
)
}
}