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 || {} }