spb/cancerindex
Public
TypeScript 97.2%
SQL 1.5%
CSS 0.6%
JavaScript 0.5%
1import { fmtInt, fmtNum } from '@/lib/format';23export interface ScatterPoint {4 id: string;5 label: string;6 /** Every point is a link (to the cancer page). */7 href: string;8 x: number;9 y: number;10 /** Bubble area is proportional to this value (e.g. active trials). Omit for uniform dots. */11 size?: number | null;12 /** Full tooltip text (`<title>`); defaults to label + coordinates. */13 tooltip?: string;14 /** Rendered in gray (e.g. ineligible for the index). */15 muted?: boolean;16}1718export interface ReferenceLine {19 /** y = slope · x (a straight line on log-log axes). */20 slope: number;21 label: string;22}2324const fmtTick = (v: number): string => (v >= 1000 ? fmtInt(v) : v >= 1 ? fmtNum(v, 0) : fmtNum(v, 2));2526/** Ticks at 1·10^k (and 2·, 5· when the axis spans few decades). */27function logTicks(min: number, max: number): number[] {28 const lo = Math.floor(Math.log10(min));29 const hi = Math.ceil(Math.log10(max));30 const decades = hi - lo;31 const mults = decades <= 2 ? [1, 2, 5] : decades <= 4 ? [1, 3] : [1];32 const out: number[] = [];33 for (let k = lo; k <= hi; k++) for (const m of mults) {34 const v = m * 10 ** k;35 if (v >= min && v <= max) out.push(v);36 }37 return out;38}3940/** Round to 1 significant digit in {1, 2, 5} × 10^k, not above v. */41function niceBelow(v: number): number {42 if (v <= 0) return 0;43 const k = 10 ** Math.floor(Math.log10(v));44 const m = v / k;45 return (m >= 5 ? 5 : m >= 2 ? 2 : 1) * k;46}4748interface Box {49 x1: number;50 x2: number;51 y1: number;52 y2: number;53}54const overlaps = (a: Box, b: Box) => a.x1 < b.x2 && a.x2 > b.x1 && a.y1 < b.y2 && a.y2 > b.y1;5556/**57 * Log–log scatter with area-proportional bubbles, one proportional reference line and per-point58 * labels — pure server-rendered SVG (no client JS). Every point is an `<a>` with a `<title>` tooltip59 * and an enlarged transparent hit circle; identity is carried by the label, never by colour alone.60 * Labels are placed to the right of each bubble and nudged vertically (then flipped to the left)61 * when they would overlap an already placed label — a cheap greedy pass that is enough for ≤ 40 points.62 */63export function ScatterChart({64 points,65 xLabel,66 yLabel,67 sizeLabel,68 ariaLabel,69 reference,70 width = 720,71 height = 400,72 labelAll,73 labelTop = 12,74}: {75 points: ScatterPoint[];76 xLabel: string;77 yLabel: string;78 sizeLabel?: string;79 ariaLabel: string;80 reference?: ReferenceLine | null;81 width?: number;82 height?: number;83 /** Label every point (default: when ≤ 40 points); otherwise only the `labelTop` largest bubbles. */84 labelAll?: boolean;85 labelTop?: number;86}) {87 const pts = points.filter((p) => Number.isFinite(p.x) && Number.isFinite(p.y) && p.x > 0 && p.y > 0);88 if (pts.length === 0) return null;89 const dropped = points.length - pts.length;90 const pad = { l: 64, r: 24, t: 14, b: 48 };91 const iw = width - pad.l - pad.r;92 const ih = height - pad.t - pad.b;93 const xs = pts.map((p) => p.x);94 const ys = pts.map((p) => p.y);95 // Domain padded by ~15% in log space so the largest bubbles and labels stay inside the plot.96 const padLog = (min: number, max: number): [number, number] => {97 const a = Math.log10(min);98 const b = Math.log10(max);99 const span = Math.max(b - a, 0.5);100 return [10 ** (a - span * 0.08), 10 ** (b + span * 0.15)];101 };102 const [xMin, xMax] = padLog(Math.min(...xs), Math.max(...xs));103 const [yMin, yMax] = padLog(Math.min(...ys), Math.max(...ys));104 const sx = (x: number) => pad.l + ((Math.log10(x) - Math.log10(xMin)) / (Math.log10(xMax) - Math.log10(xMin))) * iw;105 const sy = (y: number) => pad.t + ih - ((Math.log10(y) - Math.log10(yMin)) / (Math.log10(yMax) - Math.log10(yMin))) * ih;106107 const sizes = pts.map((p) => Math.max(0, Number(p.size ?? 0)));108 const maxSize = Math.max(...sizes, 0);109 const hasSize = maxSize > 0;110 const rMin = 3.5;111 const rMax = Math.min(18, Math.max(10, Math.sqrt(iw * ih) / 22));112 const radius = (s: number | null | undefined) => (hasSize ? rMin + (rMax - rMin) * Math.sqrt(Math.max(0, Number(s ?? 0)) / maxSize) : 5);113114 // Label placement (greedy).115 const font = 10;116 const showAll = labelAll ?? pts.length <= 40;117 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));118 const placed: Box[] = pts.map((p) => {119 const r = radius(p.size);120 return { x1: sx(p.x) - r, x2: sx(p.x) + r, y1: sy(p.y) - r, y2: sy(p.y) + r };121 });122 const labels = new Map<string, { x: number; y: number; anchor: 'start' | 'end'; off: number }>();123 const order = [...pts].sort((a, b) => sy(a.y) - sy(b.y) || sx(a.x) - sx(b.x));124 const dy = font + 1;125 for (const p of order) {126 if (!labelIds.has(p.id)) continue;127 const r = radius(p.size);128 const cx = sx(p.x);129 const cy = sy(p.y);130 const w = p.label.length * font * 0.6 + 2;131 // Right of the bubble first, then left, then growing vertical nudges on either side.132 const tries: Array<{ anchor: 'start' | 'end'; off: number }> = [];133 for (const off of [0, -dy, dy, -2 * dy, 2 * dy, -3 * dy, 3 * dy]) {134 tries.push({ anchor: 'start', off });135 tries.push({ anchor: 'end', off });136 }137 let chosen: { x: number; y: number; anchor: 'start' | 'end'; box: Box; off: number } | null = null;138 for (const t of tries) {139 const x = t.anchor === 'start' ? cx + r + 3 : cx - r - 3;140 const y = cy + font / 2 - 1 + t.off;141 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 };142 const inside = box.x1 >= pad.l - 2 && box.x2 <= width - 2 && box.y1 >= 0 && box.y2 <= height - pad.b + 8;143 if (inside && !placed.some((b) => overlaps(b, box))) {144 chosen = { x, y, anchor: t.anchor, box, off: t.off };145 break;146 }147 }148 if (!chosen) {149 // Last resort: keep the first candidate so no point is left unnamed (may touch a neighbour).150 const x = cx + r + 3;151 const y = cy + font / 2 - 1 + 4 * dy;152 chosen = { x, y, anchor: 'start', box: { x1: x, x2: x + w, y1: y - font, y2: y + 2 }, off: 4 * dy };153 }154 placed.push(chosen.box);155 labels.set(p.id, { x: chosen.x, y: chosen.y, anchor: chosen.anchor, off: chosen.off });156 }157158 // Reference line y = slope·x clipped to the domain.159 let ref: { x1: number; y1: number; x2: number; y2: number } | null = null;160 if (reference && reference.slope > 0) {161 const cands = [162 { x: xMin, y: reference.slope * xMin },163 { x: xMax, y: reference.slope * xMax },164 { x: yMin / reference.slope, y: yMin },165 { x: yMax / reference.slope, y: yMax },166 ].filter((c) => c.x >= xMin * 0.999 && c.x <= xMax * 1.001 && c.y >= yMin * 0.999 && c.y <= yMax * 1.001);167 if (cands.length >= 2) {168 cands.sort((a, b) => a.x - b.x);169 const a = cands[0]!;170 const b = cands[cands.length - 1]!;171 ref = { x1: sx(a.x), y1: sy(a.y), x2: sx(b.x), y2: sy(b.y) };172 }173 }174175 const xTicks = logTicks(xMin, xMax);176 const yTicks = logTicks(yMin, yMax);177 const legendSizes = hasSize ? [...new Set([niceBelow(maxSize), niceBelow(maxSize / 4), niceBelow(maxSize / 16)].filter((v) => v > 0))] : [];178179 return (180 <figure className="w-full">181 {/* Narrow screens scroll horizontally (like .ci-table-wrap) instead of shrinking labels below legibility. */}182 <div className="overflow-x-auto" style={{ WebkitOverflowScrolling: 'touch' }}>183 <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) }}>184 <title>{ariaLabel}</title>185 {/* grid */}186 {yTicks.map((v) => (187 <g key={`y${v}`}>188 <line x1={pad.l} x2={width - pad.r} y1={sy(v)} y2={sy(v)} stroke="var(--color-rule)" strokeWidth="1" />189 <text x={pad.l - 8} y={sy(v) + 3.5} textAnchor="end" fontSize="10.5" fill="var(--color-ink-3)" style={{ fontVariantNumeric: 'tabular-nums' }}>190 {fmtTick(v)}191 </text>192 </g>193 ))}194 {xTicks.map((v) => (195 <g key={`x${v}`}>196 <line y1={pad.t} y2={height - pad.b} x1={sx(v)} x2={sx(v)} stroke="var(--color-rule)" strokeWidth="1" />197 <text x={sx(v)} y={height - pad.b + 14} textAnchor="middle" fontSize="10.5" fill="var(--color-ink-3)" style={{ fontVariantNumeric: 'tabular-nums' }}>198 {fmtTick(v)}199 </text>200 </g>201 ))}202 <line x1={pad.l} x2={width - pad.r} y1={height - pad.b} y2={height - pad.b} stroke="var(--color-rule-strong)" strokeWidth="1" />203 <line x1={pad.l} x2={pad.l} y1={pad.t} y2={height - pad.b} stroke="var(--color-rule-strong)" strokeWidth="1" />204 {/* axis titles */}205 <text x={pad.l + iw / 2} y={height - 10} textAnchor="middle" fontSize="11" fill="var(--color-ink-2)">206 {xLabel} (log scale)207 </text>208 <text x={14} y={pad.t + ih / 2} textAnchor="middle" fontSize="11" fill="var(--color-ink-2)" transform={`rotate(-90 14 ${pad.t + ih / 2})`}>209 {yLabel} (log scale)210 </text>211 {/* reference line */}212 {ref && reference ? (213 <g>214 <line {...ref} stroke="var(--color-ink-3)" strokeWidth="1.25" strokeDasharray="5 4" />215 <title>{reference.label}</title>216 </g>217 ) : null}218 {/* points (largest first so small bubbles stay clickable on top) */}219 {[...pts]220 .sort((a, b) => Number(b.size ?? 0) - Number(a.size ?? 0))221 .map((p) => {222 const r = radius(p.size);223 const cx = sx(p.x);224 const cy = sy(p.y);225 const lab = labels.get(p.id);226 const tip = p.tooltip ?? `${p.label} — ${xLabel}: ${fmtTick(p.x)} · ${yLabel}: ${fmtTick(p.y)}${hasSize && p.size != null ? ` · ${sizeLabel ?? 'size'}: ${fmtInt(p.size)}` : ''}`;227 const fill = p.muted ? 'var(--color-ink-4)' : 'var(--color-accent)';228 return (229 <a key={p.id} href={p.href} aria-label={tip}>230 <title>{tip}</title>231 <circle cx={cx} cy={cy} r={Math.max(r + 8, 12)} fill="transparent" />232 <circle cx={cx} cy={cy} r={r} fill={fill} fillOpacity={p.muted ? 0.45 : 0.6} stroke="var(--color-paper)" strokeWidth="2" />233 <circle cx={cx} cy={cy} r={Math.min(1.5, r / 2)} fill={fill} />234 {lab && Math.abs(lab.off) >= dy ? (235 // Leader from the bubble edge to a label that had to be nudged away from its point.236 <line x1={lab.anchor === 'start' ? cx + r : cx - r} y1={cy} x2={lab.anchor === 'start' ? lab.x - 1 : lab.x + 1} y2={lab.y - font / 2 + 1} stroke="var(--color-ink-4)" strokeWidth="0.75" />237 ) : null}238 {lab ? (239 <text x={lab.x} y={lab.y} textAnchor={lab.anchor} fontSize={font} fill={p.muted ? 'var(--color-ink-3)' : 'var(--color-ink-2)'} paintOrder="stroke" stroke="var(--color-paper)" strokeWidth="2.5" strokeLinejoin="round">240 {p.label}241 </text>242 ) : null}243 </a>244 );245 })}246 </svg>247 </div>248 <figcaption className="mt-1 flex flex-wrap items-center gap-x-5 gap-y-1 text-[12px] text-ink-2">249 {reference ? (250 <span className="inline-flex items-center gap-1.5">251 <span aria-hidden className="inline-block h-0 w-5 border-t border-dashed border-ink-3" />252 {reference.label}253 </span>254 ) : null}255 {legendSizes.length ? (256 <span className="inline-flex items-center gap-2">257 <span>{sizeLabel ?? 'Bubble area'}:</span>258 {legendSizes.map((v) => (259 <span key={v} className="inline-flex items-center gap-1">260 <svg width={rMax * 2 + 2} height={rMax * 2 + 2} aria-hidden className="shrink-0">261 <circle cx={rMax + 1} cy={rMax + 1} r={radius(v)} fill="var(--color-accent)" fillOpacity="0.6" stroke="var(--color-paper)" strokeWidth="2" />262 </svg>263 <span className="ci-num">{fmtInt(v)}</span>264 </span>265 ))}266 </span>267 ) : null}268 {dropped > 0 ? <span className="text-ink-3">{dropped} point(s) with a zero value cannot be shown on log axes.</span> : null}269 </figcaption>270 </figure>271 );272}273