From c8757cd786e099b02299a91841b0a799f1b55799 Mon Sep 17 00:00:00 2001
From: Jared Knowles
Date: Wed, 12 Aug 2026 09:00:22 -0400
Subject: [PATCH] fix: pack density labels into lanes and match the line
chart's scale
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Two visual defects reported against the deployed site.
Direct labels collided. Each density is normalized to its own peak so the
curves stay comparable in shape, which means every curve peaks at the *same*
height — so labelling "at the apex" put every label on one line, and the
alternating two-row offset only ever separated two of them. Three groups with
similar rates rendered as "HiWhite:F: Black F".
Labels now live in a reserved band above each row and are packed into lanes by
utils/labelLayout.js: first-fit by x, dropping to a new lane only where the
previous one is occupied, so well-separated groups still share a lane and the
common case stays compact. Rows size themselves from the lane count. Widths are
estimated from character count — SVG text can't be measured before render — and
the estimate is deliberately generous so packing errs toward separation.
Verified by measuring rendered getBBox rects in the browser: zero overlaps for
Clark County unpooled, pooled with all four races checked, and compare-all-four
(16 labels per panel), with nothing outside the viewBox.
The line chart looked like it came from a different app because it did: its
viewBox was 360 wide where the other charts are 760. Both render at width="100%"
in the same card, so its 0.7rem text was scaled up roughly twice as far. Now on
the same 760 grid with matching type sizes (ticks 0.62rem, axis titles 0.64rem),
the beige plot fill dropped to match the other cards, and a baseline under the
waves.
Series ends had no room: the first and last waves sat flush against the plot
edges, clipping half of each end diamond and forcing their labels to be anchored
outward to avoid overflowing. X_PAD insets the scale so every marker has 88px of
clearance and every label centres over its own point. The rate label is one line
("1.71 per 1,000") instead of a stacked number and unit, and the modeled
point-range is thinner and slightly transparent so the observed series reads as
primary.
Also fixes a note that fired too early: ApproxNote rendered while the draws
fetch was still in flight, because with no draws yet every group counts as
approximated — claiming a fallback that hadn't happened, directly above a
spinner saying the real draws were still coming.
---
src/charts/ArrestsOverTime.jsx | 100 +++++++++++++++------------
src/charts/RateDensityPanel.jsx | 116 ++++++++++++++++++++++----------
src/utils/labelLayout.js | 81 ++++++++++++++++++++++
src/utils/labelLayout.test.js | 115 +++++++++++++++++++++++++++++++
4 files changed, 336 insertions(+), 76 deletions(-)
create mode 100644 src/utils/labelLayout.js
create mode 100644 src/utils/labelLayout.test.js
diff --git a/src/charts/ArrestsOverTime.jsx b/src/charts/ArrestsOverTime.jsx
index 95cb522..f01d43c 100644
--- a/src/charts/ArrestsOverTime.jsx
+++ b/src/charts/ArrestsOverTime.jsx
@@ -14,6 +14,19 @@ import { MODEL_QUADRANT_LABEL } from '../utils/labels.js'
const WAVE_LABELS = { '15-16': '2015–16', '17-18': '2017–18', '21-22': '2021–22' }
const HEADROOM = 26 // px reserved at the top of the plot so a point's rate label has room to sit above it
+// Matches RateDensityPanel/GroupDifference. These SVGs are rendered at
+// width="100%" inside the same card, so the viewBox width sets the scale factor
+// for everything in it: at 360 this chart's 0.7rem text came out roughly twice
+// the size of the other charts' 0.62rem, which is what made it look like a
+// different chart from a different app.
+const WIDTH = 760
+const HEIGHT = 300
+const MARGIN = { top: 20, right: 24, bottom: 52, left: 56 }
+// Keeps the first and last wave off the plot edges. Without it the end markers
+// sit flush against the y-axis and the right border, so half of each diamond's
+// glyph is visually clipped and its label has nowhere to go.
+const X_PAD = 64
+
export default function ArrestsOverTime({ data, modelId }) {
const maxArrests = Math.max(
...data.map((d) => Math.max(d.arrests, d.modeledUpper ?? 0)),
@@ -22,13 +35,15 @@ export default function ArrestsOverTime({ data, modelId }) {
const { ticks, niceMax } = niceTicks(maxArrests)
const modelLabel = MODEL_QUADRANT_LABEL[modelId] || modelId
- const width = 360
- const height = 280
- const margin = { top: 40, right: 40, bottom: 60, left: 65 }
+ const width = WIDTH
+ const height = HEIGHT
+ const margin = MARGIN
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 plotLeft = margin.left + X_PAD
+ const plotRight = margin.left + innerWidth - X_PAD
+ const xScale = (i) => plotLeft + (i / Math.max(data.length - 1, 1)) * (plotRight - plotLeft)
const yScale = (val) => HEADROOM + (innerHeight - HEADROOM) * (1 - val / niceMax)
return (
@@ -42,48 +57,55 @@ export default function ArrestsOverTime({ data, modelId }) {