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