Hook up raw posterior draws from API to ModelDrawsComparison
Deploy to git-pages / deploy (push) Successful in 11s

- Add fetchDistrictDraws call in ChartPanel for all 4 quadrant models
- Pass modelDraws to ModelDrawsComparison component
- Rewrite Chart 4 with D3 showing:
  - Density histogram from raw draws when available (per-year distribution)
  - Fall back to interval-based rendering otherwise
  - 90% credible intervals from quantile calculation
  - Legend explaining colors (red=observed, teal=one-year, navy=three-year)
This commit is contained in:
2026-08-10 16:27:47 -04:00
parent f65a70fc3e
commit 54814564a9
2 changed files with 318 additions and 111 deletions
+291 -105
View File
@@ -1,10 +1,10 @@
import { useMemo } from 'react'
import { useMemo, useRef, useEffect } from 'react'
import * as d3 from 'd3'
/**
* Chart 4: Predicted draws by year vs. observed — for three-year models only.
* Shows model's predicted median and 95% interval alongside the observed value,
* 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).
* Mirrors wp_fig_district_intervals() from paper_figures.R.
*/
const QUADRANT_LABELS = [
@@ -16,6 +16,12 @@ const QUADRANT_LABELS = [
const WAVE_LABELS = { '15-16': '2015–16', '17-18': '2017–18', '21-22': '2021–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(() => {
@@ -30,17 +36,33 @@ export default function ModelDrawsComparison({ data, waves, districtName }) {
}, [data.waves])
const quadModels = data.quadData || {}
const modelDraws = data.modelDraws || {}
// 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(Math.round((yearRow.count_median || 0)))
if (yearRow) {
allArrestValues.push(yearRow.count_median || 0)
allArrestValues.push(yearRow.count_upper || 0)
}
})
})
const maxArrests = Math.max(...allArrestValues, 1)
// Add draw values if available
Object.values(modelDraws).forEach(groups => {
Object.values(groups).forEach(draws => {
if (draws && draws.length > 0) {
allArrestValues.push(...draws)
}
})
})
const maxArrests = Math.min(Math.max(...allArrestValues, 1), 50) // Cap at 50 for readability
return (
<div className="cv-card" style={{ padding: 'var(--space-2)' }}>
@@ -52,124 +74,288 @@ export default function ModelDrawsComparison({ data, waves, districtName }) {
{/* 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} maxArrests={maxArrests} />
<QuadrantChart
key={q.key}
q={q}
waveData={waveData}
quadModels={quadModels}
modelDraws={modelDraws[q.key]}
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>
{/* Note */}
<p style={{ fontSize: '0.72rem', color: 'var(--cv-ink-3)', marginTop: 'var(--space-1)' }}>
Red circles = observed arrests. Teal points + error bars (three-year models only) show Bayesian model median and 90% interval. One-year models can only compare against the most recent wave.
<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.
</p>
</div>
)
}
/** Single quadrant chart: predicted vs observed for one model specification */
function QuadrantChart({ q, waveData, quadModels, maxArrests }) {
const width = 360
const height = 280
const margin = { top: 45, right: 25, bottom: 70, left: 55 }
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 showYears = (q.key.includes('m3') || q.key.includes('m4')) ? ['15-16', '17-18', '21-22'] : ['21-22']
const isThreeYear = q.key.includes('m3') || q.key.includes('m4')
const showYears = isThreeYear ? ['15-16', '17-18', '21-22'] : ['21-22']
// Derive chart dimensions from margin + width/height
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
// 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])
return (
<div style={{ border: '1px solid var(--cv-rule)', borderRadius: 'var(--radius-md)', padding: 'var(--space-1)' }}>
{/* Quadrant label */}
<span className="stat-label" style={{ display: 'block', marginBottom: 'var(--space-0.5)' }}>{q.label}</span>
<svg width="100%" viewBox={`0 0 ${width} ${height}`} style={{ maxWidth: '100%' }}>
{/* Background */}
<rect x={margin.left} y={margin.top} width={innerWidth} height={innerHeight} fill="var(--cv-paper-2)" rx={3} />
{/* Y-axis gridlines + labels */}
{[0, 0.25, 0.5, 0.75, 1].map((frac) => {
const val = frac * maxArrests
if (val > maxArrests) return null
const y = margin.top + innerHeight - (val / Math.max(maxArrests, 1)) * innerHeight
return (
<g key={`qy-${frac}`}>
<line x1={margin.left} y1={y} x2={margin.left + innerWidth} y2={y} stroke="var(--cv-rule)" strokeWidth={0.5} />
{val > 0 && (
<text x={margin.left - 6} y={y + 3} textAnchor="end" fontSize="0.6rem" fill="var(--cv-ink-4)">{Math.round(val)}</text>
)}
</g>
)
})}
{/* Y-axis label */}
<text x={12} y={margin.top + innerHeight / 2} textAnchor="middle" fontSize="0.6rem" fill="var(--cv-ink-4)" transform={`rotate(-90 12 ${margin.top + innerHeight / 2})`}>Arrests</text>
{/* Points for each year */}
{showYears.map((year, yi) => {
const observed = waveData.find(d => d.year === year) || { arrests: 0 }
const cx = margin.left + (yi / Math.max(showYears.length - 1, 1)) * innerWidth
// Observed point position
const observedY = margin.top + innerHeight - (observed.arrests / Math.max(maxArrests, 1)) * innerHeight
// For three-year models: show predicted median + interval
const isThreeYear = q.key.includes('m3') || q.key.includes('m4')
if (!isThreeYear) {
return (
<g key={`yr-${year}-${q.key}`}>
{/* Observed only for one-year models */}
<circle cx={cx} cy={observedY} r={4} fill="var(--cv-danger)" stroke="#fff" strokeWidth={1} />
<text x={cx} y={observedY - 10} textAnchor="middle" fontSize="0.6rem" fill="var(--cv-ink)">{Math.round(observed.arrests)}</text>
</g>
)
}
// Three-year model: show both observed (dodged left) and predicted (median + 95% interval, dodged right)
const obsX = cx - 8
const predX = cx + 8
const yearRow = (quadModels[q.key] || []).find(r => r.year === year)
if (!yearRow) {
return <circle key={`obs-${year}`} cx={cx} cy={observedY} r={4} fill="var(--cv-danger)" />
}
// Compute predicted values scaled to the same enrollment as observed
const enroll = yearRow.stu_enroll || 1
const predMedian = (yearRow.count_median || 0) / (enroll / 1000) * (observed.enroll / 1000)
const lower = (yearRow.count_lower || 0) / (enroll / 1000) * (observed.enroll / 1000)
const upper = (yearRow.count_upper || 0) / (enroll / 1000) * (observed.enroll / 1000)
return (
<g key={`yr-${year}-${q.key}`}>
{/* Observed */}
<circle cx={obsX} cy={observedY} r={4} fill="var(--cv-danger)" stroke="#fff" strokeWidth={1} />
{/* Predicted error bar + median point */}
<line x1={predX} y1={margin.top + innerHeight - (lower / Math.max(maxArrests, 1)) * innerHeight}
x2={predX} y2={margin.top + innerHeight - (upper / Math.max(maxArrests, 1)) * innerHeight}
stroke="var(--teal-600)" strokeWidth={1.5} />
<circle cx={predX} cy={margin.top + innerHeight - (predMedian / Math.max(maxArrests, 1)) * innerHeight} r={4} fill="var(--teal-600)" stroke="#fff" strokeWidth={1} />
</g>
)
})}
{/* X-axis labels */}
{showYears.map((year, yi) => (
<text key={`xl-${year}`} x={margin.left + (yi / Math.max(showYears.length - 1, 1)) * innerWidth} y={height - margin.bottom + 15} textAnchor="middle" fontSize="0.6rem" fill="var(--cv-ink)">{WAVE_LABELS[year]}</text>
))}
{/* Legend */}
<g transform={`translate(${margin.left}, ${height - 40})`}>
<circle cx={0} cy={10} r={3} fill="var(--cv-danger)" />
<text x={10} y={14} fontSize="0.6rem" fill="var(--cv-ink)">Observed</text>
{q.key.includes('m3') && (
<>
<line x1={70} y1={7} x2={86} y2={7} stroke="var(--teal-600)" strokeWidth={1.5} />
<circle cx={78} cy={7} r={3} fill="var(--teal-600)" />
<text x={92} y={11} fontSize="0.6rem" fill="var(--cv-ink)">Model (median + 90%)</text>
</>
)}
</g>
</svg>
<svg ref={svgRef} style={{ width: '100%', height: 'auto' }} />
</div>
)
}
+23 -2
View File
@@ -43,7 +43,28 @@ export default function ChartPanel({ district, state }) {
try { quadData[model] = await api.fetchDistrictEstimates(district.leaid, { model, year: '21-22' }) } catch(e) {}
}))
setData({ waveData, quadData })
// Fetch raw posterior draws for Chart 4 (ModelDrawsComparison)
const modelDraws = {}
await Promise.all(quadrants.map(async (model) => {
try {
const result = await api.fetchDistrictDraws(district.leaid, { model, year: '21-22' })
// API returns draws array grouped by race/sex - process into usable format
if (result?.data) {
// Group draws by model for easier access
const drawsByGroup = {}
result.data.forEach(d => {
const key = `${d.race}_${d.sex}`
if (!drawsByGroup[key]) drawsByGroup[key] = []
drawsByGroup[key].push(d.pred)
})
modelDraws[model] = drawsByGroup
}
} catch(e) {
console.error('Failed to fetch draws for', model, e)
}
}))
setData({ waveData, quadData, modelDraws })
} catch (err) {
console.error('ChartPanel data fetch failed:', err)
} finally {
@@ -85,7 +106,7 @@ export default function ChartPanel({ district, state }) {
const nationalTopRate = nationalRates?.rates_per_1000?.[natKey] || 0
// Chart 4-6 use quadData (4 models × 8 groups)
const chart4Data = { timeSeries: mostRecent, waves: data.waveData }
const chart4Data = { timeSeries: mostRecent, waves: data.waveData, modelDraws: data.modelDraws }
const chart5Data = data.quadData // all 4 quadrant model results
const chart6Data = { rates: rateByGroup, nationalRates: nationalRates?.rates_per_1000 || {} }