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