Redesign chart visualizations to match Civilytics white-paper style
Deploy to git-pages / deploy (push) Successful in 18s
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:
@@ -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 & 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>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user