diff --git a/src/charts/ModelDrawsComparison.jsx b/src/charts/ModelDrawsComparison.jsx
index 090f55a..331efb3 100644
--- a/src/charts/ModelDrawsComparison.jsx
+++ b/src/charts/ModelDrawsComparison.jsx
@@ -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 (
@@ -52,124 +74,288 @@ export default function ModelDrawsComparison({ data, waves, districtName }) {
{/* Four small multiples (one per quadrant) */}
{QUADRANT_LABELS.map((q) => (
-
+
))}
+ {/* Legend */}
+
+
+
+ Observed arrests
+
+
+
+ One-year model (90% interval)
+
+
+
+ Three-year model (90% HPD)
+
+
+
{/* Note */}
- 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.
+ Red circles = 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.
)
}
/** 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
- const innerWidth = width - margin.left - margin.right
- const innerHeight = height - margin.top - margin.bottom
+ 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 (
- {/* Quadrant label */}
- {q.label}
-
-
- {/* Background */}
-
-
- {/* 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 (
-
-
- {val > 0 && (
- {Math.round(val)}
- )}
-
- )
- })}
-
- {/* Y-axis label */}
- Arrests
-
- {/* 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 (
-
- {/* Observed only for one-year models */}
-
- {Math.round(observed.arrests)}
-
- )
- }
-
- // 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
- }
-
- // 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 (
-
- {/* Observed */}
-
- {/* Predicted error bar + median point */}
-
-
-
- )
- })}
-
- {/* X-axis labels */}
- {showYears.map((year, yi) => (
- {WAVE_LABELS[year]}
- ))}
-
- {/* Legend */}
-
-
- Observed
- {q.key.includes('m3') && (
- <>
-
-
- Model (median + 90%)
- >
- )}
-
-
+
)
-}
+}
\ No newline at end of file
diff --git a/src/components/ChartPanel.jsx b/src/components/ChartPanel.jsx
index 0c22dad..b20ce95 100644
--- a/src/components/ChartPanel.jsx
+++ b/src/components/ChartPanel.jsx
@@ -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 || {} }