Files
crdc-demo/src/utils/labelLayout.js
T
jared c8757cd786 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.
2026-08-12 09:00:22 -04:00

82 lines
3.4 KiB
JavaScript

/**
* Collision-free placement for direct labels on a shared axis.
*
* The density panel labels each curve at its own peak rather than shipping a
* legend, which only works if the labels don't collide — and in this app they
* collide constantly, because the interesting districts are exactly the ones
* where several groups have similar rates. Worse, each density is normalized to
* its own peak height, so every curve peaks at the *same* y and a naive
* placement puts every label on one line: three overlapping groups rendered as
* "HiWhite:F: Black F".
*
* So labels are packed into horizontal lanes: first-fit by x, dropping to a new
* lane only when the previous one is occupied at that position. Groups that are
* far apart still share a lane, which keeps the common case compact.
*
* Pure geometry — no DOM, no measurement. SVG text can't be measured before
* render, so widths are estimated from character count; the estimate is
* deliberately generous so labels err toward extra separation rather than
* overlap.
*/
// Mean advance width of a glyph as a fraction of font size, for the app's sans
// stack at the weights these labels use. Slightly over the true average so the
// packing errs toward separation.
const CHAR_WIDTH_RATIO = 0.62
/**
* @param {string} text
* @param {number} fontPx
* @returns {number} approximate rendered width in px
*/
export function estimateTextWidth(text, fontPx) {
return (text || '').length * fontPx * CHAR_WIDTH_RATIO
}
/**
* @param {Array<{key: string, x: number, text: string}>} items - one per label,
* `x` being the point it wants to sit above (a curve's peak).
* @param {{min: number, max: number, fontPx: number, gap?: number}} options -
* `min`/`max` are the plot's horizontal bounds; labels are kept inside them.
* @returns {{labels: Array<{key: string, text: string, x: number,
* anchor: 'start'|'middle'|'end', width: number, left: number, right: number,
* lane: number}>, lanes: number}}
* `labels` is in input order; `lanes` is how many rows the caller must
* reserve above the plot.
*/
export function layoutPeakLabels(items, { min, max, fontPx, gap = 6 } = {}) {
if (!items?.length) return { labels: [], lanes: 0 }
const placed = items.map((item) => {
const width = estimateTextWidth(item.text, fontPx)
const half = width / 2
// Anchor outward near the edges so a centred label can't overflow the plot.
let anchor = 'middle'
if (item.x - half < min) anchor = 'start'
else if (item.x + half > max) anchor = 'end'
// Clamp the anchor point itself, so a peak clipped to the axis edge still
// yields a label fully inside the frame.
let x = item.x
if (anchor === 'start') x = Math.max(min, Math.min(x, max - width))
else if (anchor === 'end') x = Math.min(max, Math.max(x, min + width))
else x = Math.min(Math.max(x, min + half), max - half)
const left = anchor === 'start' ? x : anchor === 'end' ? x - width : x - half
return { ...item, width, anchor, x, left, right: left + width, lane: 0 }
})
// First-fit by left edge. Sorting only decides lane order; the returned array
// keeps the caller's original order.
const laneRightEdge = []
for (const label of [...placed].sort((a, b) => a.left - b.left)) {
let lane = 0
while (lane < laneRightEdge.length && laneRightEdge[lane] + gap > label.left) lane++
laneRightEdge[lane] = label.right
label.lane = lane
}
return { labels: placed, lanes: laneRightEdge.length }
}