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 }) {

)} - - - + {ticks.map((val) => { const y = margin.top + yScale(val) return ( - {val.toLocaleString()} + {val.toLocaleString()} ) })} - - Total arrests (count) + {/* Baseline, so the wave labels read as sitting on an axis */} + + + + Total arrests {data.map((d, i) => ( - + {WAVE_LABELS[d.year] || d.label} ))} - CRDC wave + CRDC wave - {/* 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 ( - - + + ) })} @@ -91,37 +113,31 @@ export default function ArrestsOverTime({ data, modelId }) { {/* Observed line */} {data.length > 1 && ( `${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 ( - - {ratePerK} - per 1k + + {ratePerK} + per 1,000 + ) })} diff --git a/src/charts/RateDensityPanel.jsx b/src/charts/RateDensityPanel.jsx index c45692b..2cebe66 100644 --- a/src/charts/RateDensityPanel.jsx +++ b/src/charts/RateDensityPanel.jsx @@ -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({ /> - {anyApproximated && } + {/* 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 && } {selected.length === 0 ? (

@@ -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 (

{label &&
{label}
} @@ -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) => ( e.profile) return ( @@ -302,7 +349,28 @@ function ModelRow({ row, compareAll, x, top, rowHeight, acByKey, niceMax }) { )} - {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) => ( + + {label.text} + + ))} + + {drawable.map((entry) => ( ))} @@ -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 ( @@ -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. */} - 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} - ) } diff --git a/src/utils/labelLayout.js b/src/utils/labelLayout.js new file mode 100644 index 0000000..8e99815 --- /dev/null +++ b/src/utils/labelLayout.js @@ -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 } +} diff --git a/src/utils/labelLayout.test.js b/src/utils/labelLayout.test.js new file mode 100644 index 0000000..e07bf1e --- /dev/null +++ b/src/utils/labelLayout.test.js @@ -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) +})