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 } +}