import { fmtInt, fmtNum } from '@/lib/format'; export interface ScatterPoint { id: string; label: string; /** Every point is a link (to the cancer page). */ href: string; x: number; y: number; /** Bubble area is proportional to this value (e.g. active trials). Omit for uniform dots. */ size?: number | null; /** Full tooltip text (``); defaults to label + coordinates. */ tooltip?: string; /** Rendered in gray (e.g. ineligible for the index). */ muted?: boolean; } export interface ReferenceLine { /** y = slope · x (a straight line on log-log axes). */ slope: number; label: string; } const fmtTick = (v: number): string => (v >= 1000 ? fmtInt(v) : v >= 1 ? fmtNum(v, 0) : fmtNum(v, 2)); /** Ticks at 1·10^k (and 2·, 5· when the axis spans few decades). */ function logTicks(min: number, max: number): number[] { const lo = Math.floor(Math.log10(min)); const hi = Math.ceil(Math.log10(max)); const decades = hi - lo; const mults = decades <= 2 ? [1, 2, 5] : decades <= 4 ? [1, 3] : [1]; const out: number[] = []; for (let k = lo; k <= hi; k++) for (const m of mults) { const v = m * 10 ** k; if (v >= min && v <= max) out.push(v); } return out; } /** Round to 1 significant digit in {1, 2, 5} × 10^k, not above v. */ function niceBelow(v: number): number { if (v <= 0) return 0; const k = 10 ** Math.floor(Math.log10(v)); const m = v / k; return (m >= 5 ? 5 : m >= 2 ? 2 : 1) * k; } interface Box { x1: number; x2: number; y1: number; y2: number; } const overlaps = (a: Box, b: Box) => a.x1 < b.x2 && a.x2 > b.x1 && a.y1 < b.y2 && a.y2 > b.y1; /** * Log–log scatter with area-proportional bubbles, one proportional reference line and per-point * labels — pure server-rendered SVG (no client JS). Every point is an `<a>` with a `<title>` tooltip * and an enlarged transparent hit circle; identity is carried by the label, never by colour alone. * Labels are placed to the right of each bubble and nudged vertically (then flipped to the left) * when they would overlap an already placed label — a cheap greedy pass that is enough for ≤ 40 points. */ export function ScatterChart({ points, xLabel, yLabel, sizeLabel, ariaLabel, reference, width = 720, height = 400, labelAll, labelTop = 12, }: { points: ScatterPoint[]; xLabel: string; yLabel: string; sizeLabel?: string; ariaLabel: string; reference?: ReferenceLine | null; width?: number; height?: number; /** Label every point (default: when ≤ 40 points); otherwise only the `labelTop` largest bubbles. */ labelAll?: boolean; labelTop?: number; }) { const pts = points.filter((p) => Number.isFinite(p.x) && Number.isFinite(p.y) && p.x > 0 && p.y > 0); if (pts.length === 0) return null; const dropped = points.length - pts.length; const pad = { l: 64, r: 24, t: 14, b: 48 }; const iw = width - pad.l - pad.r; const ih = height - pad.t - pad.b; const xs = pts.map((p) => p.x); const ys = pts.map((p) => p.y); // Domain padded by ~15% in log space so the largest bubbles and labels stay inside the plot. const padLog = (min: number, max: number): [number, number] => { const a = Math.log10(min); const b = Math.log10(max); const span = Math.max(b - a, 0.5); return [10 ** (a - span * 0.08), 10 ** (b + span * 0.15)]; }; const [xMin, xMax] = padLog(Math.min(...xs), Math.max(...xs)); const [yMin, yMax] = padLog(Math.min(...ys), Math.max(...ys)); const sx = (x: number) => pad.l + ((Math.log10(x) - Math.log10(xMin)) / (Math.log10(xMax) - Math.log10(xMin))) * iw; const sy = (y: number) => pad.t + ih - ((Math.log10(y) - Math.log10(yMin)) / (Math.log10(yMax) - Math.log10(yMin))) * ih; const sizes = pts.map((p) => Math.max(0, Number(p.size ?? 0))); const maxSize = Math.max(...sizes, 0); const hasSize = maxSize > 0; const rMin = 3.5; const rMax = Math.min(18, Math.max(10, Math.sqrt(iw * ih) / 22)); const radius = (s: number | null | undefined) => (hasSize ? rMin + (rMax - rMin) * Math.sqrt(Math.max(0, Number(s ?? 0)) / maxSize) : 5); // Label placement (greedy). const font = 10; const showAll = labelAll ?? pts.length <= 40; const labelIds = new Set(showAll ? pts.map((p) => p.id) : [...pts].sort((a, b) => Number(b.size ?? 0) - Number(a.size ?? 0)).slice(0, labelTop).map((p) => p.id)); const placed: Box[] = pts.map((p) => { const r = radius(p.size); return { x1: sx(p.x) - r, x2: sx(p.x) + r, y1: sy(p.y) - r, y2: sy(p.y) + r }; }); const labels = new Map<string, { x: number; y: number; anchor: 'start' | 'end'; off: number }>(); const order = [...pts].sort((a, b) => sy(a.y) - sy(b.y) || sx(a.x) - sx(b.x)); const dy = font + 1; for (const p of order) { if (!labelIds.has(p.id)) continue; const r = radius(p.size); const cx = sx(p.x); const cy = sy(p.y); const w = p.label.length * font * 0.6 + 2; // Right of the bubble first, then left, then growing vertical nudges on either side. const tries: Array<{ anchor: 'start' | 'end'; off: number }> = []; for (const off of [0, -dy, dy, -2 * dy, 2 * dy, -3 * dy, 3 * dy]) { tries.push({ anchor: 'start', off }); tries.push({ anchor: 'end', off }); } let chosen: { x: number; y: number; anchor: 'start' | 'end'; box: Box; off: number } | null = null; for (const t of tries) { const x = t.anchor === 'start' ? cx + r + 3 : cx - r - 3; const y = cy + font / 2 - 1 + t.off; const box: Box = t.anchor === 'start' ? { x1: x, x2: x + w, y1: y - font, y2: y + 2 } : { x1: x - w, x2: x, y1: y - font, y2: y + 2 }; const inside = box.x1 >= pad.l - 2 && box.x2 <= width - 2 && box.y1 >= 0 && box.y2 <= height - pad.b + 8; if (inside && !placed.some((b) => overlaps(b, box))) { chosen = { x, y, anchor: t.anchor, box, off: t.off }; break; } } if (!chosen) { // Last resort: keep the first candidate so no point is left unnamed (may touch a neighbour). const x = cx + r + 3; const y = cy + font / 2 - 1 + 4 * dy; chosen = { x, y, anchor: 'start', box: { x1: x, x2: x + w, y1: y - font, y2: y + 2 }, off: 4 * dy }; } placed.push(chosen.box); labels.set(p.id, { x: chosen.x, y: chosen.y, anchor: chosen.anchor, off: chosen.off }); } // Reference line y = slope·x clipped to the domain. let ref: { x1: number; y1: number; x2: number; y2: number } | null = null; if (reference && reference.slope > 0) { const cands = [ { x: xMin, y: reference.slope * xMin }, { x: xMax, y: reference.slope * xMax }, { x: yMin / reference.slope, y: yMin }, { x: yMax / reference.slope, y: yMax }, ].filter((c) => c.x >= xMin * 0.999 && c.x <= xMax * 1.001 && c.y >= yMin * 0.999 && c.y <= yMax * 1.001); if (cands.length >= 2) { cands.sort((a, b) => a.x - b.x); const a = cands[0]!; const b = cands[cands.length - 1]!; ref = { x1: sx(a.x), y1: sy(a.y), x2: sx(b.x), y2: sy(b.y) }; } } const xTicks = logTicks(xMin, xMax); const yTicks = logTicks(yMin, yMax); const legendSizes = hasSize ? [...new Set([niceBelow(maxSize), niceBelow(maxSize / 4), niceBelow(maxSize / 16)].filter((v) => v > 0))] : []; return ( <figure className="w-full"> {/* Narrow screens scroll horizontally (like .ci-table-wrap) instead of shrinking labels below legibility. */} <div className="overflow-x-auto" style={{ WebkitOverflowScrolling: 'touch' }}> <svg viewBox={`0 0 ${width} ${height}`} width="100%" role="img" aria-label={ariaLabel} className="block" style={{ fontFamily: 'var(--font-sans)', minWidth: Math.min(width, 640) }}> <title>{ariaLabel} {/* grid */} {yTicks.map((v) => ( {fmtTick(v)} ))} {xTicks.map((v) => ( {fmtTick(v)} ))} {/* axis titles */} {xLabel} (log scale) {yLabel} (log scale) {/* reference line */} {ref && reference ? ( {reference.label} ) : null} {/* points (largest first so small bubbles stay clickable on top) */} {[...pts] .sort((a, b) => Number(b.size ?? 0) - Number(a.size ?? 0)) .map((p) => { const r = radius(p.size); const cx = sx(p.x); const cy = sy(p.y); const lab = labels.get(p.id); const tip = p.tooltip ?? `${p.label} — ${xLabel}: ${fmtTick(p.x)} · ${yLabel}: ${fmtTick(p.y)}${hasSize && p.size != null ? ` · ${sizeLabel ?? 'size'}: ${fmtInt(p.size)}` : ''}`; const fill = p.muted ? 'var(--color-ink-4)' : 'var(--color-accent)'; return ( {tip} {lab && Math.abs(lab.off) >= dy ? ( // Leader from the bubble edge to a label that had to be nudged away from its point. ) : null} {lab ? ( {p.label} ) : null} ); })}
{reference ? ( {reference.label} ) : null} {legendSizes.length ? ( {sizeLabel ?? 'Bubble area'}: {legendSizes.map((v) => ( {fmtInt(v)} ))} ) : null} {dropped > 0 ? {dropped} point(s) with a zero value cannot be shown on log axes. : null}
); }