Align modeled intervals with their observed points, use diamonds, name the model
Deploy to git-pages / deploy (push) Successful in 11s
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:
@@ -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)' },
|
||||
]} />
|
||||
|
||||
|
||||
@@ -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} />
|
||||
|
||||
|
||||
Reference in New Issue
Block a user