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
+58 -42
View File
@@ -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 }) {
</p>
)}
<svg width="100%" viewBox={`0 0 ${width} ${height}`} style={{ maxWidth: '100%', marginTop: 'var(--space-1)' }}>
<rect x={margin.left} y={margin.top} width={innerWidth} height={innerHeight}
fill="var(--cv-paper-2)" rx={4} />
<svg width="100%" viewBox={`0 0 ${width} ${height}`}
style={{ maxWidth: '100%', minWidth: '320px', marginTop: 'var(--space-1)' }}
role="img" aria-label="Total arrests by CRDC wave, observed against the modeled total">
{ticks.map((val) => {
const y = margin.top + yScale(val)
return (
<g key={`y-${val}`}>
<line x1={margin.left} y1={y} x2={margin.left + innerWidth} y2={y}
stroke="var(--cv-rule)" strokeWidth={1} />
<text x={margin.left - 8} y={y + 4} textAnchor="end"
fontSize="0.7rem" fill="var(--cv-ink-3)">{val.toLocaleString()}</text>
<text x={margin.left - 8} y={y + 3} textAnchor="end"
fontSize="0.62rem" fill="var(--cv-ink-3)">{val.toLocaleString()}</text>
</g>
)
})}
<text x={15} y={margin.top + innerHeight / 2} textAnchor="middle"
fontSize="0.7rem" fill="var(--cv-ink-3)" transform={`rotate(-90 15 ${margin.top + innerHeight / 2})`}>
Total arrests (count)
{/* Baseline, so the wave labels read as sitting on an axis */}
<line x1={margin.left} y1={margin.top + yScale(0)} x2={margin.left + innerWidth}
y2={margin.top + yScale(0)} stroke="var(--cv-rule-strong)" strokeWidth={1} />
<text x={14} y={margin.top + innerHeight / 2} textAnchor="middle"
fontSize="0.64rem" fill="var(--cv-ink-3)" transform={`rotate(-90 14 ${margin.top + innerHeight / 2})`}>
Total arrests
</text>
{data.map((d, i) => (
<text key={d.year} x={margin.left + xScale(i)} y={height - margin.bottom + 15}
textAnchor="middle" fontSize="0.7rem" fill="var(--cv-ink)">
<text key={d.year} x={xScale(i)} y={margin.top + yScale(0) + 18}
textAnchor="middle" fontSize="0.62rem" fill="var(--cv-ink-2)">
{WAVE_LABELS[d.year] || d.label}
</text>
))}
<text x={margin.left + innerWidth / 2} y={height - 5} textAnchor="middle"
fontSize="0.7rem" fill="var(--cv-ink-3)">CRDC wave</text>
<text x={margin.left + innerWidth / 2} y={height - 6} textAnchor="middle"
fontSize="0.64rem" fill="var(--cv-ink-3)">CRDC wave</text>
{/* Modeled point-range per wave — drawn first, directly under the observed marks it pairs with */}
{/* Modeled point-range per wave — drawn first, directly under the
observed marks it pairs with, and kept visually subordinate (thinner,
no halo) so the observed series reads as the primary line. */}
{data.map((d, i) => {
if (d.modeledMedian == null) return null
const cx = margin.left + xScale(i)
const cx = xScale(i)
const cyMedian = margin.top + yScale(d.modeledMedian)
const cyLower = margin.top + yScale(d.modeledLower ?? d.modeledMedian)
const cyUpper = margin.top + yScale(d.modeledUpper ?? d.modeledMedian)
return (
<g key={`modeled-${d.year}`}>
<line x1={cx} y1={cyLower} x2={cx} y2={cyUpper} stroke={MODELED_AGGREGATE_COLOR} strokeWidth={2} />
<circle cx={cx} cy={cyMedian} r={4.5} fill={MODELED_AGGREGATE_COLOR} stroke="#fff" strokeWidth={1.25} />
<line x1={cx} y1={cyLower} x2={cx} y2={cyUpper}
stroke={MODELED_AGGREGATE_COLOR} strokeWidth={1.5} strokeLinecap="round" opacity={0.75} />
<circle cx={cx} cy={cyMedian} r={3.5} fill={MODELED_AGGREGATE_COLOR}
stroke="var(--cv-paper)" strokeWidth={1.25} />
</g>
)
})}
@@ -91,37 +113,31 @@ export default function ArrestsOverTime({ data, modelId }) {
{/* Observed line */}
{data.length > 1 && (
<polyline
points={data.map((d, i) => `${margin.left + xScale(i)},${margin.top + yScale(d.arrests)}`).join(' ')}
fill="none" stroke={OBSERVED_MARK_COLOR} strokeWidth={2.5}
points={data.map((d, i) => `${xScale(i)},${margin.top + yScale(d.arrests)}`).join(' ')}
fill="none" stroke={OBSERVED_MARK_COLOR} strokeWidth={2}
strokeLinejoin="round" strokeLinecap="round"
/>
)}
{/* Observed diamonds + rate-per-1k labels (halo behind the text keeps
it legible over the modeled whisker sharing the same column) */}
{/* Observed diamonds + rate-per-1k labels. X_PAD keeps the end markers
clear of the plot edges, so every label can be centred over its own
point instead of being anchored outward to avoid an overflow. */}
{data.map((d, i) => {
const cx = margin.left + xScale(i)
const cx = xScale(i)
const cy = margin.top + yScale(d.arrests)
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 + 7 : isLast ? cx - 7 : cx
const ratePerK = d.enroll > 0 ? (d.arrests / (d.enroll / 1000)).toFixed(2) : '0.00'
return (
<g key={d.year}>
<rect x={cx - 5} y={cy - 5} width={10} height={10}
fill={OBSERVED_MARK_COLOR} stroke="#fff" strokeWidth={1.5}
<rect x={cx - 4.5} y={cy - 4.5} width={9} height={9}
fill={OBSERVED_MARK_COLOR} stroke="var(--cv-paper)" strokeWidth={1.5}
transform={`rotate(45 ${cx} ${cy})`} />
<text x={labelX} y={cy - 16} textAnchor={anchor} fontSize="0.7rem" fontWeight={600}
fill="var(--cv-ink)" stroke="var(--cv-paper-2)" strokeWidth={3}
strokeLinejoin="round" paintOrder="stroke">{ratePerK}</text>
<text x={labelX} y={cy - 7} textAnchor={anchor} fontSize="0.6rem"
fill="var(--cv-ink-3)" stroke="var(--cv-paper-2)" strokeWidth={3}
strokeLinejoin="round" paintOrder="stroke">per 1k</text>
<text x={cx} y={cy - 13} textAnchor="middle" fontSize="0.66rem" fontWeight={700}
fill="var(--cv-ink)" stroke="var(--cv-paper)" strokeWidth={3.5}
strokeLinejoin="round" paintOrder="stroke">
{ratePerK}
<tspan fontSize="0.58rem" fontWeight={500} fill="var(--cv-ink-3)"> per 1,000</tspan>
</text>
</g>
)
})}
+82 -34
View File
@@ -7,6 +7,7 @@ import { agrestiCoull } from '../utils/agrestiCoull.js'
import { densityProfile } from '../utils/densityProfile.js'
import { displayDraws } from '../utils/districtGroups.js'
import { computeRateDomain } from '../utils/rateDomain.js'
import { layoutPeakLabels } from '../utils/labelLayout.js'
import { toRates } from '../utils/pooling.js'
import { densityCurve, fitSkewedInterval } from '../utils/distributionApprox.js'
import ApproxNote from '../components/ApproxNote.jsx'
@@ -39,6 +40,19 @@ const ROW_HEIGHT = 118
const COMPARE_ROW_HEIGHT = 74
const RAIL_HEIGHT = 26
// Direct labels live in a reserved band above each row rather than floating at
// each curve's apex. Every density is normalized to its own peak, so all the
// apexes sit at the same height — placing labels there stacks them on one line
// and they overprint into gibberish as soon as two groups have similar rates.
const LABEL_FONT_PX = 10.4 // 0.65rem
const COMPACT_LABEL_FONT_PX = 9.3 // 0.58rem
// Comfortably above the rendered line box (a 10.4px label measures ~12.1px tall
// with descenders), so adjacent lanes clear each other instead of just touching.
const LABEL_LINE_HEIGHT = 14
const COMPACT_LABEL_LINE_HEIGHT = 12
const LABEL_GAP = 8
const SPEC_LABEL_HEIGHT = 13
export default function RateDensityPanel({
groups,
selectedKeys,
@@ -142,7 +156,11 @@ export default function RateDensityPanel({
/>
</div>
{anyApproximated && <ApproxNote />}
{/* Only once the fetch has settled. While it is in flight every group is
nominally "approximated" (there are no draws yet), so showing the note
then would claim a fallback that hasn't happened — next to a spinner
saying the real draws are still coming. */}
{!loading && anyApproximated && <ApproxNote />}
{selected.length === 0 ? (
<p style={emptyStyle}>
@@ -207,6 +225,12 @@ function profileFor(group, counts, enroll, niceMax) {
}
}
/** x of a profile's tallest point, clamped into the plotted domain. */
function peakX(profile, x, niceMax) {
const peak = profile.points.reduce((best, p) => (p.y > best.y ? p : best), profile.points[0])
return x(Math.min(peak.x, niceMax))
}
function SexPanel({ label, sex, domain, rowsByModel, activeModels, acByKey, compareAll }) {
const rows = activeModels.map((model) => ({
model,
@@ -217,11 +241,35 @@ function SexPanel({ label, sex, domain, rowsByModel, activeModels, acByKey, comp
const hasAnything = rows.some((r) => r.entries.some((e) => e.profile))
const rowHeight = compareAll ? COMPARE_ROW_HEIGHT : ROW_HEIGHT
const innerWidth = CHART_WIDTH - MARGIN.left - MARGIN.right
const bandHeight = rowHeight + RAIL_HEIGHT
const height = MARGIN.top + rows.length * bandHeight + MARGIN.bottom
const fontPx = compareAll ? COMPACT_LABEL_FONT_PX : LABEL_FONT_PX
const lineHeight = compareAll ? COMPACT_LABEL_LINE_HEIGHT : LABEL_LINE_HEIGHT
const x = scaleLinear().domain([0, domain.niceMax]).range([MARGIN.left, MARGIN.left + innerWidth])
// Lay the labels out before sizing the SVG: how many lanes they need decides
// how much room each row has to reserve above its curves.
let cursor = MARGIN.top
const placedRows = rows.map((row) => {
const drawable = row.entries.filter((e) => e.profile)
const { labels, lanes } = layoutPeakLabels(
drawable.map((e) => ({
key: e.group.key,
x: peakX(e.profile, x, domain.niceMax),
text: e.group.shortLabel,
color: raceColor(e.group.race),
})),
{ min: MARGIN.left, max: MARGIN.left + innerWidth, fontPx, gap: LABEL_GAP },
)
const specLabel = compareAll ? SPEC_LABEL_HEIGHT : 0
const band = specLabel + (lanes > 0 ? lanes * lineHeight + 3 : 0)
const top = cursor
cursor += band + rowHeight + RAIL_HEIGHT
return { row, drawable, labels, band, specLabel, top, lineHeight, fontPx }
})
const plotBottom = cursor
const height = plotBottom + MARGIN.bottom
return (
<figure style={{ margin: '0 0 var(--space-2)' }}>
{label && <figcaption style={panelLabelStyle}>{label}</figcaption>}
@@ -242,19 +290,18 @@ function SexPanel({ label, sex, domain, rowsByModel, activeModels, acByKey, comp
x1={x(t)}
y1={MARGIN.top}
x2={x(t)}
y2={MARGIN.top + rows.length * bandHeight}
y2={plotBottom}
stroke="var(--cv-rule)"
strokeWidth={1}
/>
))}
{rows.map((row, i) => (
{placedRows.map((placed) => (
<ModelRow
key={row.model}
row={row}
key={placed.row.model}
placed={placed}
compareAll={compareAll}
x={x}
top={MARGIN.top + i * bandHeight}
rowHeight={rowHeight}
acByKey={acByKey}
niceMax={domain.niceMax}
@@ -265,7 +312,7 @@ function SexPanel({ label, sex, domain, rowsByModel, activeModels, acByKey, comp
<text
key={t}
x={x(t)}
y={MARGIN.top + rows.length * bandHeight + 15}
y={plotBottom + 15}
textAnchor="middle"
fontSize="0.62rem"
fill="var(--cv-ink-3)"
@@ -289,10 +336,10 @@ function SexPanel({ label, sex, domain, rowsByModel, activeModels, acByKey, comp
)
}
function ModelRow({ row, compareAll, x, top, rowHeight, acByKey, niceMax }) {
const baselineY = top + rowHeight
function ModelRow({ placed, compareAll, x, rowHeight, acByKey, niceMax }) {
const { row, drawable, labels, band, specLabel, top, lineHeight, fontPx } = placed
const baselineY = top + band + rowHeight
const peakHeight = rowHeight * 0.86
const drawable = row.entries.filter((e) => e.profile)
return (
<g>
@@ -302,7 +349,28 @@ function ModelRow({ row, compareAll, x, top, rowHeight, acByKey, niceMax }) {
</text>
)}
{drawable.map((entry, i) => (
{/* Direct labels, packed into lanes so overlapping densities stay legible.
Colour is what ties each one to its curve — the same hue the table's
swatch uses — so no leader lines are needed. */}
{labels.map((label) => (
<text
key={label.key}
x={label.x}
y={top + specLabel + label.lane * lineHeight + fontPx}
textAnchor={label.anchor}
fontSize={`${fontPx}px`}
fontWeight={700}
fill={label.color}
stroke="var(--cv-paper)"
strokeWidth={3}
strokeLinejoin="round"
paintOrder="stroke"
>
{label.text}
</text>
))}
{drawable.map((entry) => (
<GroupArea
key={entry.group.key}
entry={entry}
@@ -314,8 +382,6 @@ function ModelRow({ row, compareAll, x, top, rowHeight, acByKey, niceMax }) {
// what the reader is comparing here is location and spread, not peak
// height.
peakHeight={peakHeight}
labelIndex={i}
compact={compareAll}
niceMax={niceMax}
/>
))}
@@ -347,7 +413,7 @@ function ModelRow({ row, compareAll, x, top, rowHeight, acByKey, niceMax }) {
)
}
function GroupArea({ entry, x, baselineY, peakHeight, labelIndex, compact, niceMax }) {
function GroupArea({ entry, x, baselineY, peakHeight, niceMax }) {
const { profile, group } = entry
const color = raceColor(group.race)
const y = scaleLinear().domain([0, profile.maxY || 1]).range([baselineY, baselineY - peakHeight])
@@ -366,10 +432,6 @@ function GroupArea({ entry, x, baselineY, peakHeight, labelIndex, compact, niceM
// still visually rhyming with the filled areas beside it.
.curve(profile.kind === 'mass' ? curveStep : curveMonotoneX)
const peak = profile.points.reduce((best, p) => (p.y > best.y ? p : best), profile.points[0])
const peakX = x(Math.min(peak.x, niceMax))
const labelY = Math.max(baselineY - peakHeight - 2, y(peak.y) - 4 - (labelIndex % 2) * 11)
return (
<g>
<path d={areaGen(points)} fill={color} opacity={FILL_OPACITY} />
@@ -380,20 +442,6 @@ function GroupArea({ entry, x, baselineY, peakHeight, labelIndex, compact, niceM
strokeWidth={STROKE_WIDTH}
strokeLinejoin="round"
/>
{/* Direct-labelled at the peak so there is no legend to hunt through. */}
<text
x={peakX}
y={labelY}
textAnchor={peakX > x(niceMax) * 0.8 ? 'end' : 'middle'}
fontSize={compact ? '0.58rem' : '0.65rem'}
fontWeight={700}
fill={color}
stroke="var(--cv-paper)"
strokeWidth={3}
paintOrder="stroke"
>
{group.shortLabel}
</text>
</g>
)
}
+81
View File
@@ -0,0 +1,81 @@
/**
* 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 }
}
+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)
})