/** * 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 } }