Align modeled intervals with their observed points, use diamonds, name the model
Deploy to git-pages / deploy (push) Successful in 11s

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 <noreply@anthropic.com>
This commit is contained in:
2026-08-10 19:44:28 -04:00
co-authored by Claude Sonnet 5
parent fa35d97269
commit 466ebdb9a6
2 changed files with 44 additions and 35 deletions
+41 -33
View File
@@ -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 (
<div className="cv-card" style={{ padding: 'var(--space-2)' }}>
<h3 style={{ fontSize: '0.85rem', marginBottom: 'var(--space-1)', color: 'var(--cv-ink-2)' }}>
<h3 style={{ fontSize: '0.85rem', marginBottom: 0, color: 'var(--cv-ink-2)' }}>
Arrests over time — observed vs. modeled
</h3>
{modelLabel && (
<p style={{ fontSize: '0.78rem', fontStyle: 'italic', color: 'var(--cv-ink-3)', margin: '0.15rem 0 0' }}>
Modeled intervals use the {modelLabel.toLowerCase()} model.
</p>
)}
<svg width="100%" viewBox={`0 0 ${width} ${height}`} style={{ maxWidth: '100%' }}>
<svg width="100%" viewBox={`0 0 ${width} ${height}`} style={{ maxWidth: '100%', marginTop: 'var(--space-1)' }}>
<rect x={margin.left} y={margin.top} width={innerWidth} height={innerHeight}
fill="var(--cv-paper-2)" rx={4} />
@@ -67,19 +73,10 @@ export default function ArrestsOverTime({ data }) {
<text x={margin.left + innerWidth / 2} y={height - 5} textAnchor="middle"
fontSize="0.7rem" fill="var(--cv-ink-3)">CRDC wave</text>
{/* Observed line */}
{data.length > 1 && (
<polyline
points={data.map((d, i) => `${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 && (
<polyline
points={data.map((d, i) => `${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 (
<g key={d.year}>
<circle cx={cx} cy={cy} r={5} fill={OBSERVED_MARK_COLOR} stroke="#fff" strokeWidth={1.5} />
<text x={labelX} y={topY - 15} textAnchor={anchor} fontSize="0.7rem" fontWeight={600}
fill="var(--cv-ink)">{ratePerK}</text>
<text x={labelX} y={topY - 6} textAnchor={anchor} fontSize="0.6rem"
fill="var(--cv-ink-3)">per 1k</text>
<rect x={cx - 5} y={cy - 5} width={10} height={10}
fill={OBSERVED_MARK_COLOR} stroke="#fff" strokeWidth={1.5}
transform={`rotate(45 ${cx} ${cy})`} />
<text x={labelX} y={cy - 16} textAnchor={anchor} fontSize="0.7rem" fontWeight={600}
fill="var(--cv-ink)" stroke="var(--cv-paper-2)" strokeWidth={3}
strokeLinejoin="round" paintOrder="stroke">{ratePerK}</text>
<text x={labelX} y={cy - 7} textAnchor={anchor} fontSize="0.6rem"
fill="var(--cv-ink-3)" stroke="var(--cv-paper-2)" strokeWidth={3}
strokeLinejoin="round" paintOrder="stroke">per 1k</text>
</g>
)
})}
</svg>
<ChartLegend items={[
{ shape: 'line', color: OBSERVED_MARK_COLOR, label: 'Observed' },
{ shape: 'diamond', color: OBSERVED_MARK_COLOR, label: 'Observed' },
{ shape: 'dot', color: MODELED_AGGREGATE_COLOR, label: 'Modeled (median + 90% interval)' },
]} />
+3 -2
View File
@@ -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'
}}>
<ArrestsOverTime data={timeSeriesData} />
<ArrestsOverTime data={timeSeriesData} modelId={WAVE_MODEL} />
<RateByGroupBar data={rateByGroup} />