SPB Git forge

spb/cancerindex

Public
37commits 1branches 0releases
2.9 MBsize
maindefault branch
10 days agolast push
TypeScript 97.2% SQL 1.5% CSS 0.6% JavaScript 0.5%
12.2 KB · 273 lines tsx
Raw Blame History
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