From fa35d972697cc841a0d7382e03e7fe4c05c5fc30 Mon Sep 17 00:00:00 2001 From: Jared Knowles Date: Mon, 10 Aug 2026 19:37:02 -0400 Subject: [PATCH] Clean up the hero chart: remove watermark, fix label collisions, round axis ticks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/charts/ArrestsOverTime.jsx | 42 ++++++++++++++++++----------- src/charts/RateByGroupBar.jsx | 15 ++++++----- src/charts/RateDensityRidgeline.jsx | 15 ++++++----- src/components/ChartLegend.jsx | 5 +++- src/components/ChartPanel.jsx | 2 +- src/utils/niceTicks.js | 40 +++++++++++++++++++++++++++ 6 files changed, 87 insertions(+), 32 deletions(-) create mode 100644 src/utils/niceTicks.js diff --git a/src/charts/ArrestsOverTime.jsx b/src/charts/ArrestsOverTime.jsx index 15dbbd8..da2d8e3 100644 --- a/src/charts/ArrestsOverTime.jsx +++ b/src/charts/ArrestsOverTime.jsx @@ -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 (
@@ -35,16 +40,14 @@ export default function ArrestsOverTime({ data, districtName }) { - {[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 ( - + {Math.round(val).toLocaleString()} + fontSize="0.7rem" fill="var(--cv-ink-3)">{val.toLocaleString()} ) })} @@ -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 ( - {ratePerK} - per 1k ) })} - - {districtName}

diff --git a/src/charts/RateByGroupBar.jsx b/src/charts/RateByGroupBar.jsx index 6077782..9c90c1b 100644 --- a/src/charts/RateByGroupBar.jsx +++ b/src/charts/RateByGroupBar.jsx @@ -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 (

@@ -42,7 +44,7 @@ export default function RateByGroupBar({ data }) {
{SEX_PANELS.map(({ sex, label }) => ( - d.sex === sex)} maxRate={maxRate} /> + d.sex === sex)} ticks={ticks} niceMax={niceMax} /> ))}
@@ -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 (
@@ -79,14 +81,13 @@ function SexPanel({ label, rows, maxRate }) { - {[0, 0.25, 0.5, 0.75, 1].map((frac) => { - const val = frac * maxRate + {ticks.map((val) => { const x = xScale(val) return ( - + - {val.toFixed(1)} + {val} ) diff --git a/src/charts/RateDensityRidgeline.jsx b/src/charts/RateDensityRidgeline.jsx index ab5778f..54325d3 100644 --- a/src/charts/RateDensityRidgeline.jsx +++ b/src/charts/RateDensityRidgeline.jsx @@ -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 (
{SEX_COLUMNS.map(({ sex, label }) => ( - r.sex === sex)} maxRate={maxRate} /> + r.sex === sex)} ticks={ticks} maxRate={niceMax} /> ))}
) } -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 }) { ) : ( <> - {[0, 0.25, 0.5, 0.75, 1].map((frac) => { - const val = frac * maxRate + {ticks.map((val) => { const x = xScale(val) return ( - + - {val.toFixed(0)} + {val} ) diff --git a/src/components/ChartLegend.jsx b/src/components/ChartLegend.jsx index b1cdcf7..c9dbde8 100644 --- a/src/components/ChartLegend.jsx +++ b/src/components/ChartLegend.jsx @@ -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 } + if (shape === 'dot') { + return + } return } diff --git a/src/components/ChartPanel.jsx b/src/components/ChartPanel.jsx index 51429d9..8a6d047 100644 --- a/src/components/ChartPanel.jsx +++ b/src/components/ChartPanel.jsx @@ -92,7 +92,7 @@ export default function ChartPanel({ district, state }) { marginLeft: 'auto', marginRight: 'auto' }}> - + diff --git a/src/utils/niceTicks.js b/src/utils/niceTicks.js new file mode 100644 index 0000000..fe7bd48 --- /dev/null +++ b/src/utils/niceTicks.js @@ -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 } +}