From 466ebdb9a674c76e986e0e79a6463f0637d00442 Mon Sep 17 00:00:00 2001 From: Jared Knowles Date: Mon, 10 Aug 2026 19:44:28 -0400 Subject: [PATCH] Align modeled intervals with their observed points, use diamonds, name the model The modeled point-range was dodged 18px to the side of its observed point, which read as misaligned rather than paired. Removes the dodge so both sit on the same x column - directly comparable at a glance, matching the reference whitepaper figures' own convention. Observed points were plain circles, inconsistent with every other chart in the app where a diamond means "observed." Switches them to diamonds, drawn last so they stay on top of the modeled marks sharing their column. Adds a caption naming the model the modeled interval is drawn from (three- year, no covariate / unified_m3_mod), sourced from ChartPanel's existing WAVE_MODEL constant via the shared MODEL_QUADRANT_LABEL lookup rather than hardcoded, so it can't drift if the model choice changes. Also gives the rate-per-1k labels a paper-colored text halo (paintOrder: stroke) so they stay legible now that they can sit directly over the modeled whisker line instead of needing to dodge around it. Co-Authored-By: Claude Sonnet 5 --- src/charts/ArrestsOverTime.jsx | 74 +++++++++++++++++++--------------- src/components/ChartPanel.jsx | 5 ++- 2 files changed, 44 insertions(+), 35 deletions(-) diff --git a/src/charts/ArrestsOverTime.jsx b/src/charts/ArrestsOverTime.jsx index da2d8e3..3c9b90b 100644 --- a/src/charts/ArrestsOverTime.jsx +++ b/src/charts/ArrestsOverTime.jsx @@ -1,23 +1,26 @@ import ChartLegend from '../components/ChartLegend.jsx' import { OBSERVED_MARK_COLOR, MODELED_AGGREGATE_COLOR } from '../utils/colors.js' import { niceTicks } from '../utils/niceTicks.js' +import { MODEL_QUADRANT_LABEL } from '../utils/labels.js' /** - * Chart 1: Observed arrests by CRDC wave (line), overlaid with the - * three-year model's predicted total (point-range) per wave — the - * observed-vs-modeled comparison this app is built around. + * Chart 1: Observed arrests by CRDC wave (line, diamond markers — the same + * "diamond = observed" convention as every other chart), overlaid with one + * model's predicted total (point-range) per wave, aligned to the same x + * position as its observed point rather than dodged aside, so the two are + * read as a pair. */ const WAVE_LABELS = { '15-16': '2015–16', '17-18': '2017–18', '21-22': '2021–22' } -const DODGE = 18 // px offset so the modeled point-range doesn't sit on top of the observed point -const LABEL_CLEARANCE = 32 // px reserved above the taller of (observed, modeled upper) for its rate label +const HEADROOM = 26 // px reserved at the top of the plot so a point's rate label has room to sit above it -export default function ArrestsOverTime({ data }) { +export default function ArrestsOverTime({ data, modelId }) { const maxArrests = Math.max( ...data.map((d) => Math.max(d.arrests, d.modeledUpper ?? 0)), 1 ) const { ticks, niceMax } = niceTicks(maxArrests) + const modelLabel = MODEL_QUADRANT_LABEL[modelId] || modelId const width = 360 const height = 280 @@ -26,17 +29,20 @@ export default function ArrestsOverTime({ data }) { const innerHeight = height - margin.top - margin.bottom const xScale = (i) => (i / Math.max(data.length - 1, 1)) * innerWidth - // Reserves LABEL_CLEARANCE px at the top of the plot so a point's own rate - // label never has to compete for space with the modeled whisker above it. - const yScale = (val) => LABEL_CLEARANCE + (innerHeight - LABEL_CLEARANCE) * (1 - val / niceMax) + const yScale = (val) => HEADROOM + (innerHeight - HEADROOM) * (1 - val / niceMax) return (
-

+

Arrests over time — observed vs. modeled

+ {modelLabel && ( +

+ Modeled intervals use the {modelLabel.toLowerCase()} model. +

+ )} - + @@ -67,19 +73,10 @@ export default function ArrestsOverTime({ data }) { CRDC wave - {/* Observed line */} - {data.length > 1 && ( - `${margin.left + xScale(i)},${margin.top + yScale(d.arrests)}`).join(' ')} - fill="none" stroke={OBSERVED_MARK_COLOR} strokeWidth={2.5} - strokeLinejoin="round" strokeLinecap="round" - /> - )} - - {/* Modeled point-range per wave */} + {/* Modeled point-range per wave — drawn first, directly under the observed marks it pairs with */} {data.map((d, i) => { if (d.modeledMedian == null) return null - const cx = margin.left + xScale(i) + DODGE + const cx = margin.left + xScale(i) const cyMedian = margin.top + yScale(d.modeledMedian) const cyLower = margin.top + yScale(d.modeledLower ?? d.modeledMedian) const cyUpper = margin.top + yScale(d.modeledUpper ?? d.modeledMedian) @@ -91,13 +88,20 @@ export default function ArrestsOverTime({ data }) { ) })} - {/* Observed points + rate-per-1k labels, cleared above whichever of - the observed point or the modeled whisker reaches higher */} + {/* Observed line */} + {data.length > 1 && ( + `${margin.left + xScale(i)},${margin.top + yScale(d.arrests)}`).join(' ')} + fill="none" stroke={OBSERVED_MARK_COLOR} strokeWidth={2.5} + strokeLinejoin="round" strokeLinecap="round" + /> + )} + + {/* Observed diamonds + rate-per-1k labels (halo behind the text keeps + it legible over the modeled whisker sharing the same column) */} {data.map((d, i) => { const cx = margin.left + xScale(i) const cy = margin.top + yScale(d.arrests) - const modeledUpperY = d.modeledUpper != null ? margin.top + yScale(d.modeledUpper) : cy - const topY = Math.min(cy, modeledUpperY) const ratePerK = d.enroll > 0 ? (d.arrests / (d.enroll / 1000)).toFixed(2) : '0.0' // The first/last points sit flush on the plot's left/right edge, so a // center-anchored label above them would overflow into the y-axis @@ -105,22 +109,26 @@ export default function ArrestsOverTime({ data }) { const isFirst = i === 0 const isLast = i === data.length - 1 const anchor = isFirst ? 'start' : isLast ? 'end' : 'middle' - const labelX = isFirst ? cx + 6 : isLast ? cx - 6 : cx + const labelX = isFirst ? cx + 7 : isLast ? cx - 7 : cx return ( - - {ratePerK} - per 1k + + {ratePerK} + per 1k ) })} diff --git a/src/components/ChartPanel.jsx b/src/components/ChartPanel.jsx index 8a6d047..6fad293 100644 --- a/src/components/ChartPanel.jsx +++ b/src/components/ChartPanel.jsx @@ -7,6 +7,7 @@ import RateDensityRidgeline from '../charts/RateDensityRidgeline.jsx' const ALL_WAVES = ['15-16', '17-18', '21-22'] const QUADRANT_MODELS = ['unified_m1_mod', 'unified_m2_mod', 'unified_m3_mod', 'unified_m4_mod'] +const WAVE_MODEL = 'unified_m3_mod' // three-year, no covariate — powers Charts 1 & 2 /** * ChartPanel — 3 charts: arrests over time (observed vs. modeled), arrest @@ -23,7 +24,7 @@ export default function ChartPanel({ district, state }) { // Chart 1 + 2: all 3 waves × three-year model (unified_m3_mod) const waveData = {} await Promise.all(ALL_WAVES.map(async (year) => { - try { waveData[year] = await api.fetchDistrictEstimates(district.leaid, { model: 'unified_m3_mod', year }) } catch (e) {} + try { waveData[year] = await api.fetchDistrictEstimates(district.leaid, { model: WAVE_MODEL, year }) } catch (e) {} })) // Chart 3: all 4 quadrant models, so the dropdown can switch between them @@ -92,7 +93,7 @@ export default function ChartPanel({ district, state }) { marginLeft: 'auto', marginRight: 'auto' }}> - +