fix: pack density labels into lanes and match the line chart's scale

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.
This commit is contained in:
2026-08-12 09:00:22 -04:00
parent 4396a56a4a
commit c8757cd786
4 changed files with 336 additions and 76 deletions
+115
View File
@@ -0,0 +1,115 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { estimateTextWidth, layoutPeakLabels } from './labelLayout.js'
const BOUNDS = { min: 0, max: 400, fontPx: 10 }
test('estimateTextWidth: grows with text length and font size', () => {
assert.ok(estimateTextWidth('AB', 10) > estimateTextWidth('A', 10))
assert.ok(estimateTextWidth('ABC', 20) > estimateTextWidth('ABC', 10))
assert.ok(estimateTextWidth('', 10) >= 0)
})
test('layoutPeakLabels: well-separated labels all sit on lane 0', () => {
const out = layoutPeakLabels(
[{ key: 'a', x: 20, text: 'A' }, { key: 'b', x: 200, text: 'B' }, { key: 'c', x: 380, text: 'C' }],
BOUNDS,
)
assert.deepEqual(out.labels.map((l) => l.lane), [0, 0, 0])
assert.equal(out.lanes, 1)
})
test('layoutPeakLabels: overlapping labels are pushed to separate lanes', () => {
// This is the failing case from the live site: three densities peaking at
// nearly the same rate rendered as "HiWhite:F: Black F".
const out = layoutPeakLabels(
[
{ key: 'hi', x: 100, text: 'Hispanic F' },
{ key: 'wh', x: 104, text: 'White F' },
{ key: 'bl', x: 108, text: 'Black F' },
],
BOUNDS,
)
const lanes = out.labels.map((l) => l.lane).sort()
assert.deepEqual(lanes, [0, 1, 2])
assert.equal(out.lanes, 3)
})
test('layoutPeakLabels: identical positions never share a lane', () => {
const out = layoutPeakLabels(
[
{ key: 'a', x: 200, text: 'Hispanic F' },
{ key: 'b', x: 200, text: 'Hispanic M' },
],
BOUNDS,
)
assert.notEqual(out.labels[0].lane, out.labels[1].lane)
})
test('layoutPeakLabels: a lane is reused once there is horizontal room', () => {
const out = layoutPeakLabels(
[
{ key: 'a', x: 20, text: 'A' },
{ key: 'b', x: 24, text: 'B' },
{ key: 'c', x: 380, text: 'C' },
],
BOUNDS,
)
const byKey = Object.fromEntries(out.labels.map((l) => [l.key, l.lane]))
assert.equal(byKey.a, 0)
assert.equal(byKey.b, 1)
// 'c' is far away, so it drops back to the first lane rather than stacking.
assert.equal(byKey.c, 0)
})
test('layoutPeakLabels: anchors outward at the edges so text stays in frame', () => {
const out = layoutPeakLabels(
[{ key: 'l', x: 0, text: 'Hispanic F' }, { key: 'r', x: 400, text: 'Hispanic F' }],
BOUNDS,
)
const byKey = Object.fromEntries(out.labels.map((l) => [l.key, l]))
assert.equal(byKey.l.anchor, 'start')
assert.equal(byKey.r.anchor, 'end')
})
test('layoutPeakLabels: a mid-plot label stays centred on its peak', () => {
const out = layoutPeakLabels([{ key: 'm', x: 200, text: 'Black F' }], BOUNDS)
assert.equal(out.labels[0].anchor, 'middle')
assert.equal(out.labels[0].x, 200)
})
test('layoutPeakLabels: no label extends outside the plot bounds', () => {
const out = layoutPeakLabels(
[
{ key: 'l', x: -50, text: 'American Indian / Alaska Native' },
{ key: 'r', x: 900, text: 'American Indian / Alaska Native' },
],
BOUNDS,
)
for (const l of out.labels) {
assert.ok(l.left >= BOUNDS.min - 0.01, `${l.key} left ${l.left} < ${BOUNDS.min}`)
assert.ok(l.right <= BOUNDS.max + 0.01, `${l.key} right ${l.right} > ${BOUNDS.max}`)
}
})
test('layoutPeakLabels: preserves input order in the output', () => {
// Lane assignment sorts internally; callers still key off their own order.
const out = layoutPeakLabels(
[{ key: 'z', x: 300, text: 'Z' }, { key: 'a', x: 10, text: 'A' }],
BOUNDS,
)
assert.deepEqual(out.labels.map((l) => l.key), ['z', 'a'])
})
test('layoutPeakLabels: empty input yields zero lanes', () => {
const out = layoutPeakLabels([], BOUNDS)
assert.deepEqual(out.labels, [])
assert.equal(out.lanes, 0)
})
test('layoutPeakLabels: wider gap forces more lanes', () => {
const items = [{ key: 'a', x: 100, text: 'A' }, { key: 'b', x: 130, text: 'B' }]
const tight = layoutPeakLabels(items, { ...BOUNDS, gap: 0 })
const loose = layoutPeakLabels(items, { ...BOUNDS, gap: 40 })
assert.ok(loose.lanes > tight.lanes)
})