Clean up the hero chart: remove watermark, fix label collisions, round axis ticks
Deploy to git-pages / deploy (push) Successful in 12s

The district-name watermark inside the arrests-over-time chart was
redundant with the page's own heading and was the direct cause of a label
collision (a high point's rate label rendered on top of it). Removes it.

Two more collisions, both edge cases the screenshot happened to hit: the
first wave's label sat close enough to the plot's top that it could
overlap the topmost gridline text, and a point sitting flush on the plot's
left edge had its center-anchored label bleed into the y-axis tick labels.
Fixes both with more vertical clearance and edge-aware text anchoring
(first point anchors right, last point anchors left, matching the same
fix already applied to chart 4's wave ticks before that chart was cut).

Adds a shared niceTicks() utility (Heckbert's nice-numbers algorithm) so
axis ticks read as round numbers (0/100/200) instead of arbitrary
fractions of the data max (0/113/226/339) — applied to all three charts
for consistency. Also fixes a legend/mark mismatch in the arrests-over-time
chart (the modeled series legend showed a diamond; the actual mark is a
dot) by adding a proper 'dot' shape to ChartLegend.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-10 19:37:02 -04:00
co-authored by Claude Sonnet 5
parent a1ffa600b3
commit fa35d97269
6 changed files with 87 additions and 32 deletions
+26 -16
View File
@@ -1,5 +1,6 @@
import ChartLegend from '../components/ChartLegend.jsx'
import { OBSERVED_MARK_COLOR, MODELED_AGGREGATE_COLOR } from '../utils/colors.js'
import { niceTicks } from '../utils/niceTicks.js'
/**
* Chart 1: Observed arrests by CRDC wave (line), overlaid with the
@@ -8,22 +9,26 @@ import { OBSERVED_MARK_COLOR, MODELED_AGGREGATE_COLOR } from '../utils/colors.js
*/
const WAVE_LABELS = { '15-16': '2015–16', '17-18': '2017–18', '21-22': '2021–22' }
const DODGE = 14 // px offset so the modeled point-range doesn't sit on top of the observed point
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
export default function ArrestsOverTime({ data, districtName }) {
export default function ArrestsOverTime({ data }) {
const maxArrests = Math.max(
...data.map((d) => Math.max(d.arrests, d.modeledUpper ?? 0)),
1
)
const { ticks, niceMax } = niceTicks(maxArrests)
const width = 360
const height = 280
const margin = { top: 50, right: 90, bottom: 60, left: 65 }
const margin = { top: 40, right: 40, bottom: 60, left: 65 }
const innerWidth = width - margin.left - margin.right
const innerHeight = height - margin.top - margin.bottom
const xScale = (i) => (i / Math.max(data.length - 1, 1)) * innerWidth
const yScale = (val) => innerHeight - (val / maxArrests) * innerHeight
// 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)
return (
<div className="cv-card" style={{ padding: 'var(--space-2)' }}>
@@ -35,16 +40,14 @@ export default function ArrestsOverTime({ data, districtName }) {
<rect x={margin.left} y={margin.top} width={innerWidth} height={innerHeight}
fill="var(--cv-paper-2)" rx={4} />
{[0, 0.25, 0.5, 0.75, 1].map((frac) => {
const val = frac * maxArrests
if (val === 0 && frac > 0) return null
{ticks.map((val) => {
const y = margin.top + yScale(val)
return (
<g key={`y-${frac}`}>
<g key={`y-${val}`}>
<line x1={margin.left} y1={y} x2={margin.left + innerWidth} y2={y}
stroke="var(--cv-rule)" strokeWidth={1} />
<text x={margin.left - 8} y={y + 4} textAnchor="end"
fontSize="0.7rem" fill="var(--cv-ink-3)">{Math.round(val).toLocaleString()}</text>
fontSize="0.7rem" fill="var(--cv-ink-3)">{val.toLocaleString()}</text>
</g>
)
})}
@@ -88,30 +91,37 @@ export default function ArrestsOverTime({ data, districtName }) {
)
})}
{/* Observed points + rate-per-1k labels */}
{/* Observed points + rate-per-1k labels, cleared above whichever of
the observed point or the modeled whisker reaches higher */}
{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
// ticks or off the right edge — anchor those two outward instead.
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
return (
<g key={d.year}>
<circle cx={cx} cy={cy} r={5} fill={OBSERVED_MARK_COLOR} stroke="#fff" strokeWidth={1.5} />
<text x={cx} y={cy - 14} textAnchor="middle" fontSize="0.7rem" fontWeight={600}
<text x={labelX} y={topY - 15} textAnchor={anchor} fontSize="0.7rem" fontWeight={600}
fill="var(--cv-ink)">{ratePerK}</text>
<text x={cx} y={cy - 5} textAnchor="middle" fontSize="0.6rem"
<text x={labelX} y={topY - 6} textAnchor={anchor} fontSize="0.6rem"
fill="var(--cv-ink-3)">per 1k</text>
</g>
)
})}
<text x={margin.left + innerWidth - 4} y={margin.top + 12} textAnchor="end"
fontSize="0.6rem" fill="var(--cv-ink-4)">{districtName}</text>
</svg>
<ChartLegend items={[
{ shape: 'line', color: OBSERVED_MARK_COLOR, label: 'Observed' },
{ shape: 'diamond', color: MODELED_AGGREGATE_COLOR, label: 'Modeled (median + 90% interval)' },
{ shape: 'dot', color: MODELED_AGGREGATE_COLOR, label: 'Modeled (median + 90% interval)' },
]} />
<p style={{ fontSize: '0.75rem', color: 'var(--cv-ink-3)', marginTop: 'var(--space-1)' }}>
+8 -7
View File
@@ -2,6 +2,7 @@ import ChartLegend from '../components/ChartLegend.jsx'
import ApproxNote from '../components/ApproxNote.jsx'
import { raceColor, OBSERVED_MARK_COLOR, SHORT_RACE_LABEL } from '../utils/colors.js'
import { fitSkewedInterval } from '../utils/distributionApprox.js'
import { niceTicks } from '../utils/niceTicks.js'
/**
* Arrest rate by student group, most recent year, disaggregated into two
@@ -32,6 +33,7 @@ export default function RateByGroupBar({ data }) {
...data.map((d) => Math.max(d.observedRate, d.rateUpper ?? d.modeledMedian ?? 0)),
0.5
)
const { ticks, niceMax } = niceTicks(maxRate, 4)
return (
<div className="cv-card" style={{ padding: 'var(--space-2)' }}>
@@ -42,7 +44,7 @@ export default function RateByGroupBar({ data }) {
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 'var(--space-3)', marginTop: 'var(--space-2)' }}>
{SEX_PANELS.map(({ sex, label }) => (
<SexPanel key={sex} label={label} rows={data.filter((d) => d.sex === sex)} maxRate={maxRate} />
<SexPanel key={sex} label={label} rows={data.filter((d) => d.sex === sex)} ticks={ticks} niceMax={niceMax} />
))}
</div>
@@ -61,14 +63,14 @@ export default function RateByGroupBar({ data }) {
)
}
function SexPanel({ label, rows, maxRate }) {
function SexPanel({ label, rows, ticks, niceMax }) {
const width = 300
const margin = { top: 30, right: 16, bottom: 34, left: 66 }
const innerWidth = width - margin.left - margin.right
const byRace = RACE_ORDER.map((race) => rows.find((d) => d.race === race)).filter(Boolean)
const bodyHeight = byRace.length * ROW_HEIGHT
const height = margin.top + bodyHeight + margin.bottom
const xScale = (val) => margin.left + (val / maxRate) * innerWidth
const xScale = (val) => margin.left + (val / niceMax) * innerWidth
return (
<div style={{ border: '1px solid var(--cv-rule)', borderRadius: 'var(--radius-md)', padding: 'var(--space-1)' }}>
@@ -79,14 +81,13 @@ function SexPanel({ label, rows, maxRate }) {
<rect x={margin.left} y={margin.top} width={innerWidth} height={bodyHeight} fill="var(--cv-paper-2)" rx={4} />
{[0, 0.25, 0.5, 0.75, 1].map((frac) => {
const val = frac * maxRate
{ticks.map((val) => {
const x = xScale(val)
return (
<g key={frac}>
<g key={val}>
<line x1={x} y1={margin.top} x2={x} y2={margin.top + bodyHeight} stroke="var(--cv-rule)" strokeWidth={1} />
<text x={x} y={margin.top + bodyHeight + 14} textAnchor="middle" fontSize="0.6rem" fill="var(--cv-ink-3)">
{val.toFixed(1)}
{val}
</text>
</g>
)
+8 -7
View File
@@ -2,6 +2,7 @@ import { useState } from 'react'
import { MODEL_QUADRANTS } from '../hooks/useApi.js'
import { raceColor, OBSERVED_MARK_COLOR, RACE_LABELS, SHORT_RACE_LABEL } from '../utils/colors.js'
import { fitSkewedInterval, densityCurve } from '../utils/distributionApprox.js'
import { niceTicks } from '../utils/niceTicks.js'
import ChartLegend from '../components/ChartLegend.jsx'
import ApproxNote from '../components/ApproxNote.jsx'
@@ -80,18 +81,19 @@ function RidgeColumns({ rows }) {
const allObserved = rows
.filter((r) => (r.stu_enroll || 0) > 0)
.map((r) => ((r.observed_arrests || 0) / r.stu_enroll) * 1000)
const maxRate = Math.min(Math.max(...allUpper, ...allObserved, 1) * 1.15, 30)
const rawMax = Math.min(Math.max(...allUpper, ...allObserved, 1) * 1.15, 30)
const { ticks, niceMax } = niceTicks(rawMax, 5)
return (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 'var(--space-3)', marginTop: 'var(--space-2)' }}>
{SEX_COLUMNS.map(({ sex, label }) => (
<SexRidgeColumn key={sex} label={label} rows={rows.filter((r) => r.sex === sex)} maxRate={maxRate} />
<SexRidgeColumn key={sex} label={label} rows={rows.filter((r) => r.sex === sex)} ticks={ticks} maxRate={niceMax} />
))}
</div>
)
}
function SexRidgeColumn({ label, rows, maxRate }) {
function SexRidgeColumn({ label, rows, ticks, maxRate }) {
const groups = RACE_ORDER.map((race) => rows.find((r) => r.race === race)).filter(Boolean).map(buildGroupRow)
const width = 300
@@ -119,14 +121,13 @@ function SexRidgeColumn({ label, rows, maxRate }) {
</text>
) : (
<>
{[0, 0.25, 0.5, 0.75, 1].map((frac) => {
const val = frac * maxRate
{ticks.map((val) => {
const x = xScale(val)
return (
<g key={frac}>
<g key={val}>
<line x1={x} y1={margin.top} x2={x} y2={margin.top + groups.length * rowHeight} stroke="var(--cv-rule)" strokeWidth={1} />
<text x={x} y={margin.top + groups.length * rowHeight + 14} textAnchor="middle" fontSize="0.58rem" fill="var(--cv-ink-3)">
{val.toFixed(0)}
{val}
</text>
</g>
)
+4 -1
View File
@@ -3,7 +3,7 @@
* Both of the app's stray-`}` CSS bugs lived inside hand-written SVG legend
* `fill="..."` strings; keeping legends out of SVG removes that bug class.
*
* items: [{ color, label, shape: 'swatch' | 'diamond' | 'line' }]
* items: [{ color, label, shape: 'swatch' | 'diamond' | 'line' | 'dot' }]
*/
export default function ChartLegend({ items }) {
return (
@@ -36,5 +36,8 @@ function LegendMark({ shape, color }) {
if (shape === 'line') {
return <span style={{ display: 'inline-block', width: '16px', height: '3px', background: color, borderRadius: '2px', flexShrink: 0 }} />
}
if (shape === 'dot') {
return <span style={{ display: 'inline-block', width: '10px', height: '10px', background: color, borderRadius: '50%', flexShrink: 0 }} />
}
return <span style={{ display: 'inline-block', width: '11px', height: '11px', background: color, borderRadius: '3px', flexShrink: 0 }} />
}
+1 -1
View File
@@ -92,7 +92,7 @@ export default function ChartPanel({ district, state }) {
marginLeft: 'auto',
marginRight: 'auto'
}}>
<ArrestsOverTime data={timeSeriesData} districtName={district.lea_name} />
<ArrestsOverTime data={timeSeriesData} />
<RateByGroupBar data={rateByGroup} />
+40
View File
@@ -0,0 +1,40 @@
/**
* "Nice numbers" axis tick generator (Heckbert 1990) — produces clean,
* human-friendly tick values (0 / 50 / 100 / 150 rather than 0 / 37 / 74 /
* 111, an artifact of slicing frac*max into equal fractions) for any linear
* axis, from just the data's max value.
*/
function niceNumber(range, round) {
const exponent = Math.floor(Math.log10(range))
const fraction = range / 10 ** exponent
let niceFraction
if (round) {
if (fraction < 1.5) niceFraction = 1
else if (fraction < 3) niceFraction = 2
else if (fraction < 7) niceFraction = 5
else niceFraction = 10
} else {
if (fraction <= 1) niceFraction = 1
else if (fraction <= 2) niceFraction = 2
else if (fraction <= 5) niceFraction = 5
else niceFraction = 10
}
return niceFraction * 10 ** exponent
}
/**
* @param {number} maxValue - the largest value the axis must cover
* @param {number} tickCount - approximate desired number of ticks
* @returns {{ ticks: number[], niceMax: number }}
*/
export function niceTicks(maxValue, tickCount = 5) {
if (!(maxValue > 0)) return { ticks: [0], niceMax: 1 }
const niceRange = niceNumber(maxValue, false)
const niceStep = niceNumber(niceRange / (tickCount - 1), true)
const niceMax = Math.ceil(maxValue / niceStep) * niceStep
const ticks = []
for (let v = 0; v <= niceMax + niceStep / 2; v += niceStep) {
ticks.push(Math.round(v * 1e6) / 1e6)
}
return { ticks, niceMax }
}