Hook up raw posterior draws from API to ModelDrawsComparison
Deploy to git-pages / deploy (push) Successful in 11s
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:
+291
-105
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 || {} }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user