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 `` with a `` 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();
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 (
{/* Narrow screens scroll horizontally (like .ci-table-wrap) instead of shrinking labels below legibility. */}
{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}
);
}