"use client";
import * as React from "react";
export interface ScatterDatum {
/** Stable key (defaults to the index). */
id?: string;
x: number;
y: number;
/** Optional third measure, encoded as the point's area. */
size?: number;
/** Optional group, encoded as colour (with a legend toggle per category). */
category?: string;
/** Name shown in the tooltip, the read-out and the data table. */
label?: string;
}
export type ScatterBrushMode = "lasso" | "rect" | "none";
export interface ScatterPlotProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "children"> {
data: ScatterDatum[];
/** Accessible name of the chart (also the data table caption). */
label: string;
xLabel?: string;
yLabel?: string;
sizeLabel?: string;
/** Plot height in px; the width follows the container. */
height?: number;
/** Largest point radius in px (points without a size use about half of it). */
pointSize?: number;
/** Fill opacity of the points, 0.1–1. */
opacity?: number;
/** Drag gesture used to select points. */
brushMode?: ScatterBrushMode;
/** Wheel and +/− keys zoom around the cursor or the active point. */
zoomable?: boolean;
showGrid?: boolean;
/** Category legend with show/hide toggles (only drawn when there is more than one category). */
showLegend?: boolean;
/** Colour of the first category; the others use a categorical palette. */
accent?: string;
formatX?: (value: number) => string;
formatY?: (value: number) => string;
formatSize?: (value: number) => string;
/** Called with the selected points whenever the selection changes. */
onSelectionChange?: (selected: ScatterDatum[]) => void;
}
/* --------------------------------- Scales --------------------------------- */
type Domain = [number, number];
interface View {
x: Domain;
y: Domain;
}
/** 1, 2 or 5 × 10^k: the step a person would choose for about `count` intervals. */
function niceStep(span: number, count: number) {
const raw = Math.max(1e-12, span) / Math.max(1, count);
const pow = 10 ** Math.floor(Math.log10(raw));
const f = raw / pow;
return (f >= 7.5 ? 10 : f >= 3.5 ? 5 : f >= 1.5 ? 2 : 1) * pow;
}
function niceDomain(min: number, max: number, count: number): Domain {
if (min === max) {
min -= 1;
max += 1;
}
const pad = (max - min) * 0.04;
const step = niceStep(max - min + pad * 2, count);
return [Math.floor((min - pad) / step) * step, Math.ceil((max + pad) / step) * step];
}
function ticks([min, max]: Domain, count: number) {
const step = niceStep(max - min, count);
const out: number[] = [];
const decimals = Math.max(0, -Math.floor(Math.log10(step)));
for (let v = Math.ceil(min / step - 1e-9) * step; v <= max + step * 1e-9; v += step) {
out.push(Number(v.toFixed(decimals)));
}
return { values: out, step };
}
const r2 = (n: number) => Math.round(n * 100) / 100;
function defaultFormat(step: number) {
const digits = step >= 1 ? 0 : Math.min(4, Math.ceil(-Math.log10(step)));
const nf = new Intl.NumberFormat("en-US", { notation: Math.abs(step) >= 1000 ? "compact" : "standard", maximumFractionDigits: digits });
return (v: number) => nf.format(v);
}
const plain = new Intl.NumberFormat("en-US", { maximumFractionDigits: 2 });
/** Ray-casting point-in-polygon test. */
function inPolygon(x: number, y: number, poly: [number, number][]) {
let inside = false;
for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
const [xi, yi] = poly[i];
const [xj, yj] = poly[j];
if (yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) inside = !inside;
}
return inside;
}
/* --------------------------------- Palette --------------------------------- */
const PALETTE_VARS =
"[--sp-2:#3d8bea] [--sp-3:#22b07d] [--sp-4:#e0a21a] [--sp-5:#e87ba4] [--sp-6:#8b7cf0] [--sp-7:#4fb9c9] [--sp-8:#a3a3a3]";
const categoryColor = (i: number) => (i === 0 ? "var(--sp-accent)" : `var(--sp-${Math.min(i, 7) + 1})`);
const MARGIN = { top: 14, right: 16, bottom: 34, left: 48 };
const HOVER_RADIUS = 28;
/* -------------------------------- Component -------------------------------- */
/**
* An SVG scatter plot with x/y position, area (size) and colour (category) encodings and nice
* axis ticks. Hover, tap or focus shows the nearest point; drag a lasso or rectangle to select a
* subset (others dim) and clear it again; legend buttons hide or show categories; the wheel
* zooms around the cursor. The plot is keyboard navigable — arrows jump to the nearest point in
* that direction — and a visually hidden table carries every value for screen readers.
*/
export function ScatterPlot({
data,
label,
xLabel = "x",
yLabel = "y",
sizeLabel = "Size",
height = 380,
pointSize = 12,
opacity = 0.75,
brushMode = "lasso",
zoomable = false,
showGrid = true,
showLegend = true,
accent = "#FF7448",
formatX,
formatY,
formatSize,
onSelectionChange,
className = "",
style,
...props
}: ScatterPlotProps) {
const uid = React.useId();
const clipId = `${uid.replace(/[^a-zA-Z0-9_-]/g, "")}-clip`;
const hintId = `${uid}-hint`;
const boxRef = React.useRef<HTMLDivElement>(null);
const svgRef = React.useRef<SVGSVGElement>(null);
const lassoRef = React.useRef<SVGPathElement>(null);
const brushRef = React.useRef<{ pts: [number, number][]; id: number; moved: boolean } | null>(null);
const [width, setWidth] = React.useState(720);
const [hidden, setHidden] = React.useState<ReadonlySet<string>>(() => new Set());
const [selected, setSelected] = React.useState<ReadonlySet<number>>(() => new Set());
const [active, setActive] = React.useState<number | null>(null);
const [view, setView] = React.useState<View | null>(null);
const [announcement, setAnnouncement] = React.useState("");
/* ------------------------------- Derived data ------------------------------ */
const categories = React.useMemo(() => {
const list: string[] = [];
for (const d of data) {
const c = d.category ?? "Series";
if (!list.includes(c)) list.push(c);
}
return list;
}, [data]);
const extent = React.useMemo(() => {
let x0 = Infinity;
let x1 = -Infinity;
let y0 = Infinity;
let y1 = -Infinity;
let s0 = Infinity;
let s1 = -Infinity;
for (const d of data) {
x0 = Math.min(x0, d.x);
x1 = Math.max(x1, d.x);
y0 = Math.min(y0, d.y);
y1 = Math.max(y1, d.y);
if (d.size != null) {
s0 = Math.min(s0, d.size);
s1 = Math.max(s1, d.size);
}
}
if (!data.length) return { x: [0, 1] as Domain, y: [0, 1] as Domain, size: null };
return { x: niceDomain(x0, x1, 6), y: niceDomain(y0, y1, 5), size: Number.isFinite(s0) ? ([s0, s1] as Domain) : null };
}, [data]);
const domain = view ?? { x: extent.x, y: extent.y };
const W = Math.max(120, width);
const H = Math.max(160, height);
const iw = W - MARGIN.left - MARGIN.right;
const ih = H - MARGIN.top - MARGIN.bottom;
const sx = (v: number) => MARGIN.left + ((v - domain.x[0]) / (domain.x[1] - domain.x[0])) * iw;
const sy = (v: number) => MARGIN.top + ih - ((v - domain.y[0]) / (domain.y[1] - domain.y[0])) * ih;
const xTicks = ticks(domain.x, Math.max(2, Math.round(iw / 110)));
const yTicks = ticks(domain.y, Math.max(2, Math.round(ih / 64)));
const fx = formatX ?? defaultFormat(xTicks.step);
const fy = formatY ?? defaultFormat(yTicks.step);
const fs = formatSize ?? ((v: number) => plain.format(v));
const maxR = Math.max(2, pointSize);
const minR = Math.min(maxR, 2.5);
/** Area-true size encoding: radius ∝ √size. */
const radius = (d: ScatterDatum) => {
if (d.size == null || !extent.size) return Math.max(minR, maxR * 0.5);
const [a, b] = extent.size;
const t = b > a ? (d.size - a) / (b - a) : 1;
return minR + (maxR - minR) * Math.sqrt(Math.max(0, t));
};
const points = data.map((d, i) => {
const cat = d.category ?? "Series";
return {
d,
i,
cat,
ci: categories.indexOf(cat),
px: r2(sx(d.x)),
py: r2(sy(d.y)),
r: r2(radius(d)),
name: d.label ?? `Point ${i + 1}`,
};
});
type Point = (typeof points)[number];
const inView = (p: Point) => p.px >= MARGIN.left - 0.5 && p.px <= W - MARGIN.right + 0.5 && p.py >= MARGIN.top - 0.5 && p.py <= H - MARGIN.bottom + 0.5;
const visible = points.filter((p) => !hidden.has(p.cat) && inView(p));
const hasSelection = selected.size > 0;
const activePoint = active != null ? (visible.find((p) => p.i === active) ?? null) : null;
/* ------------------------------- Formatting ------------------------------- */
const describe = (p: Point) => {
const parts = [`${p.name}`];
if (categories.length > 1) parts.push(p.cat);
parts.push(`${xLabel} ${fx(p.d.x)}`, `${yLabel} ${fy(p.d.y)}`);
if (p.d.size != null) parts.push(`${sizeLabel} ${fs(p.d.size)}`);
if (selected.has(p.i)) parts.push("selected");
return parts.join(", ");
};
/* -------------------------------- Selection -------------------------------- */
const commitSelection = (next: ReadonlySet<number>, message: string) => {
setSelected(next);
setAnnouncement(message);
onSelectionChange?.(data.filter((_, i) => next.has(i)));
};
const clearSelection = () => commitSelection(new Set(), "Selection cleared.");
const toggleCategory = (cat: string) => {
const next = new Set(hidden);
if (next.has(cat)) next.delete(cat);
else next.add(cat);
setHidden(next);
setAnnouncement(`${cat} ${next.has(cat) ? "hidden" : "shown"}.`);
if (active != null && points[active]?.cat === cat && next.has(cat)) setActive(null);
};
/* --------------------------------- Pointer --------------------------------- */
const local = (e: { clientX: number; clientY: number }): [number, number] => {
const rect = svgRef.current?.getBoundingClientRect();
return rect ? [e.clientX - rect.left, e.clientY - rect.top] : [0, 0];
};
/** Linear nearest-point search in pixel space (fast enough for a few thousand points). */
const nearest = (x: number, y: number, within: number) => {
let best: Point | null = null;
let bestD = within * within;
for (const p of visible) {
const dx = p.px - x;
const dy = p.py - y;
const d2 = dx * dx + dy * dy - p.r * p.r * 0.5;
if (d2 < bestD) {
bestD = d2;
best = p;
}
}
return best;
};
const drawBrush = (pts: [number, number][]) => {
const path = lassoRef.current;
if (!path) return;
if (pts.length < 2) {
path.setAttribute("d", "");
return;
}
if (brushMode === "rect") {
const [a, b] = [pts[0], pts[pts.length - 1]];
path.setAttribute("d", `M${a[0]} ${a[1]}H${b[0]}V${b[1]}H${a[0]}Z`);
} else {
path.setAttribute("d", `M${pts.map(([x, y]) => `${r2(x)} ${r2(y)}`).join("L")}Z`);
}
};
const onPointerDown = (e: React.PointerEvent<SVGSVGElement>) => {
if (e.button !== 0) return;
// Touch keeps native scrolling: a tap shows the nearest point, dragging scrolls the page.
if (brushMode === "none" || e.pointerType === "touch") {
brushRef.current = { pts: [local(e)], id: e.pointerId, moved: false };
return;
}
e.currentTarget.setPointerCapture(e.pointerId);
brushRef.current = { pts: [local(e)], id: e.pointerId, moved: false };
// The tooltip would sit on top of the lasso; it returns on the next hover.
if (active !== null) setActive(null);
};
const onPointerMove = (e: React.PointerEvent<SVGSVGElement>) => {
const [x, y] = local(e);
const brush = brushRef.current;
if (brush && brush.id === e.pointerId && brushMode !== "none" && e.pointerType !== "touch") {
const [lx, ly] = brush.pts[brush.pts.length - 1];
const [x0, y0] = brush.pts[0];
if (!brush.moved && Math.hypot(x - x0, y - y0) > 4) brush.moved = true;
if (Math.hypot(x - lx, y - ly) > 2) {
if (brushMode === "rect") brush.pts = [brush.pts[0], [x, y]];
else brush.pts.push([x, y]);
drawBrush(brush.pts);
}
return;
}
if (e.pointerType !== "mouse") return;
// Only re-render when the nearest point changes, never on every move.
const hit = nearest(x, y, HOVER_RADIUS);
const next = hit ? hit.i : null;
if (next !== active) setActive(next);
};
const onPointerUp = (e: React.PointerEvent<SVGSVGElement>) => {
const brush = brushRef.current;
brushRef.current = null;
drawBrush([]);
if (!brush || brush.id !== e.pointerId) return;
const [x, y] = local(e);
const moved = brush.moved || Math.hypot(x - brush.pts[0][0], y - brush.pts[0][1]) > 4;
if (!moved) {
// A click or tap: show (and on click, toggle) the nearest point; empty space clears the selection.
const hit = nearest(x, y, e.pointerType === "touch" ? 36 : HOVER_RADIUS);
if (hit) {
setActive(hit.i);
if (e.pointerType !== "touch" && brushMode !== "none") toggle(hit);
} else if (hasSelection) clearSelection();
else setActive(null);
return;
}
if (brushMode === "none" || e.pointerType === "touch") return;
const pts = brush.pts;
let picked: Point[];
if (brushMode === "rect") {
const [[ax, ay], [bx, by]] = [pts[0], pts[pts.length - 1]];
const [x0, x1] = [Math.min(ax, bx), Math.max(ax, bx)];
const [y0, y1] = [Math.min(ay, by), Math.max(ay, by)];
picked = visible.filter((p) => p.px >= x0 && p.px <= x1 && p.py >= y0 && p.py <= y1);
} else {
picked = pts.length > 2 ? visible.filter((p) => inPolygon(p.px, p.py, pts)) : [];
}
const next = new Set(e.shiftKey ? selected : []);
for (const p of picked) next.add(p.i);
commitSelection(next, next.size ? `${next.size} ${next.size === 1 ? "point" : "points"} selected.` : "No points in the selection.");
};
const onPointerCancel = () => {
brushRef.current = null;
drawBrush([]);
};
function toggle(p: Point) {
const next = new Set(selected);
if (next.has(p.i)) next.delete(p.i);
else next.add(p.i);
commitSelection(next, `${p.name} ${next.has(p.i) ? "added to" : "removed from"} the selection. ${next.size} selected.`);
}
/* ---------------------------------- Zoom ---------------------------------- */
/** Zooms by `factor` (< 1 zooms in) around a pixel position. Zooming out past the data resets. */
const zoomAt = (px: number, py: number, factor: number) => {
const cur = view ?? { x: extent.x, y: extent.y };
const fxp = Math.min(1, Math.max(0, (px - MARGIN.left) / iw));
const fyp = Math.min(1, Math.max(0, 1 - (py - MARGIN.top) / ih));
const cx = cur.x[0] + (cur.x[1] - cur.x[0]) * fxp;
const cy = cur.y[0] + (cur.y[1] - cur.y[0]) * fyp;
const spanX = (cur.x[1] - cur.x[0]) * factor;
const spanY = (cur.y[1] - cur.y[0]) * factor;
const fullX = extent.x[1] - extent.x[0];
if (spanX >= fullX * 0.999) {
setView(null);
return;
}
if (spanX < fullX / 40) return;
setView({
x: [cx - spanX * fxp, cx + spanX * (1 - fxp)],
y: [cy - spanY * fyp, cy + spanY * (1 - fyp)],
});
};
const onWheel = React.useEffectEvent((e: WheelEvent) => {
e.preventDefault();
const [x, y] = local(e);
zoomAt(x, y, Math.exp(Math.max(-60, Math.min(60, e.deltaY)) * 0.004));
});
React.useEffect(() => {
const svg = svgRef.current;
if (!svg || !zoomable) return;
// Non-passive so the page doesn't scroll while zooming the plot.
const handler = (e: WheelEvent) => onWheel(e);
svg.addEventListener("wheel", handler, { passive: false });
return () => svg.removeEventListener("wheel", handler);
}, [zoomable]);
React.useEffect(() => {
const box = boxRef.current;
if (!box) return;
const ro = new ResizeObserver((entries) => setWidth(Math.max(1, Math.round(entries[0].contentRect.width))));
ro.observe(box);
return () => ro.disconnect();
}, []);
/* -------------------------------- Keyboard -------------------------------- */
/** Nearest visible point in a direction, by distance along it with a penalty for drifting sideways. */
const neighbour = (from: Point, dx: number, dy: number) => {
let best: Point | null = null;
let bestScore = Infinity;
for (const p of visible) {
if (p === from) continue;
const tx = p.px - from.px;
const ty = p.py - from.py;
const along = tx * dx + ty * dy;
if (along <= 0.5) continue;
const across = Math.abs(tx * dy - ty * dx);
const score = along + across * 2;
if (score < bestScore) {
bestScore = score;
best = p;
}
}
return best;
};
const byX = () => [...visible].sort((a, b) => a.d.x - b.d.x || a.d.y - b.d.y);
const move = (p: Point | null) => {
if (!p) return;
setActive(p.i);
setAnnouncement(describe(p));
};
const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
const dirs: Record<string, [number, number]> = { ArrowRight: [1, 0], ArrowLeft: [-1, 0], ArrowDown: [0, 1], ArrowUp: [0, -1] };
const dir = dirs[e.key];
if (dir) {
e.preventDefault();
if (!activePoint) move(byX()[0] ?? null);
else move(neighbour(activePoint, dir[0], dir[1]));
} else if (e.key === "Home" || e.key === "End") {
e.preventDefault();
const sorted = byX();
move((e.key === "Home" ? sorted[0] : sorted[sorted.length - 1]) ?? null);
} else if ((e.key === " " || e.key === "Enter") && activePoint) {
e.preventDefault();
toggle(activePoint);
} else if (e.key === "Escape") {
if (hasSelection) clearSelection();
else setActive(null);
} else if (zoomable && (e.key === "+" || e.key === "=" || e.key === "-" || e.key === "_")) {
e.preventDefault();
const cx = activePoint ? activePoint.px : MARGIN.left + iw / 2;
const cy = activePoint ? activePoint.py : MARGIN.top + ih / 2;
zoomAt(cx, cy, e.key === "+" || e.key === "=" ? 0.7 : 1 / 0.7);
} else if (zoomable && e.key === "0") {
setView(null);
}
};
/* --------------------------------- Render --------------------------------- */
const counts = categories.map((c) => data.filter((d) => (d.category ?? "Series") === c).length);
const tipLeft = activePoint ? Math.min(Math.max(activePoint.px, 90), W - 90) : 0;
const tipAbove = activePoint ? activePoint.py > 110 : true;
return (
<div
className={`flex w-full min-w-0 flex-col gap-3 text-zinc-950 dark:text-zinc-50 ${PALETTE_VARS} ${className}`}
style={{ ["--sp-accent" as string]: accent, ...style }}
{...props}
>
<style>{`@keyframes sp-in{from{opacity:0;transform:scale(.2)}}`}</style>
<div className="flex min-w-0 flex-wrap items-center justify-between gap-x-4 gap-y-2">
{showLegend && categories.length > 1 ? (
<div role="group" aria-label="Categories" className="flex min-w-0 flex-wrap gap-1.5">
{categories.map((c, i) => {
const on = !hidden.has(c);
return (
<button
key={c}
type="button"
aria-pressed={on}
onClick={() => toggleCategory(c)}
className={`inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-[12px] transition-[background-color,color,border-color] outline-hidden focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-[color:var(--sp-accent)] ${
on
? "border-zinc-900/15 bg-white/70 text-zinc-800 hover:bg-white dark:border-white/15 dark:bg-white/[0.06] dark:text-zinc-100 dark:hover:bg-white/10"
: "border-dashed border-zinc-900/20 text-zinc-400 hover:text-zinc-600 dark:border-white/15 dark:text-zinc-500 dark:hover:text-zinc-300"
}`}
>
<span
aria-hidden="true"
className="size-2 rounded-full transition-opacity"
style={{ backgroundColor: categoryColor(i), opacity: on ? 1 : 0.35 }}
/>
{c}
<span className="font-mono text-[10px] tabular-nums opacity-60">{counts[i]}</span>
</button>
);
})}
</div>
) : (
<span />
)}
<div className="flex items-center gap-1.5">
{view && (
<button
type="button"
onClick={() => {
setView(null);
setAnnouncement("Zoom reset.");
}}
className="rounded-md border border-zinc-900/10 px-2 py-1 font-mono text-[10px] uppercase tracking-[0.12em] text-zinc-600 transition-[background-color,color,border-color] outline-hidden hover:bg-zinc-900/5 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-[color:var(--sp-accent)] dark:border-white/10 dark:text-zinc-300 dark:hover:bg-white/5"
>
Reset zoom
</button>
)}
{hasSelection && (
<button
type="button"
onClick={clearSelection}
className="rounded-md border border-zinc-900/10 px-2 py-1 font-mono text-[10px] uppercase tracking-[0.12em] text-zinc-600 transition-[background-color,color,border-color] outline-hidden hover:bg-zinc-900/5 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-[color:var(--sp-accent)] dark:border-white/10 dark:text-zinc-300 dark:hover:bg-white/5"
>
Clear selection · {selected.size}
</button>
)}
</div>
</div>
<div
ref={boxRef}
role="group"
aria-roledescription="scatter plot"
aria-label={label}
aria-describedby={hintId}
tabIndex={0}
onKeyDown={onKeyDown}
onBlur={(e) => {
if (!e.currentTarget.contains(e.relatedTarget)) setActive(null);
}}
className="relative w-full rounded-md outline-hidden focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-solid focus-visible:outline-[color:var(--sp-accent)]"
style={{ height: H }}
>
<svg
ref={svgRef}
aria-hidden="true"
width={W}
height={H}
className={`block select-none ${brushMode === "none" ? "cursor-default" : "cursor-crosshair"}`}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerCancel}
onPointerLeave={(e) => {
if (e.pointerType === "mouse" && !brushRef.current && document.activeElement !== boxRef.current) setActive(null);
}}
>
<defs>
<clipPath id={clipId}>
<rect x={MARGIN.left} y={MARGIN.top} width={iw} height={ih} />
</clipPath>
</defs>
{/* Grid and axes */}
<g className="text-zinc-900/[0.07] dark:text-white/[0.07]">
{showGrid &&
yTicks.values.map((v) => (
<line key={`gy${v}`} x1={MARGIN.left} x2={W - MARGIN.right} y1={r2(sy(v))} y2={r2(sy(v))} stroke="currentColor" />
))}
{showGrid &&
xTicks.values.map((v) => (
<line key={`gx${v}`} y1={MARGIN.top} y2={H - MARGIN.bottom} x1={r2(sx(v))} x2={r2(sx(v))} stroke="currentColor" />
))}
</g>
<line
x1={MARGIN.left}
x2={W - MARGIN.right}
y1={H - MARGIN.bottom}
y2={H - MARGIN.bottom}
className="stroke-zinc-900/25 dark:stroke-white/25"
/>
<line x1={MARGIN.left} x2={MARGIN.left} y1={MARGIN.top} y2={H - MARGIN.bottom} className="stroke-zinc-900/25 dark:stroke-white/25" />
<g className="fill-zinc-500 font-mono text-[10px] tabular-nums dark:fill-zinc-400">
{xTicks.values.map((v) => (
<text key={`tx${v}`} x={r2(sx(v))} y={H - MARGIN.bottom + 15} textAnchor="middle">
{fx(v)}
</text>
))}
{yTicks.values.map((v) => (
<text key={`ty${v}`} x={MARGIN.left - 8} y={r2(sy(v)) + 3} textAnchor="end">
{fy(v)}
</text>
))}
</g>
<g className="fill-zinc-600 font-mono text-[10px] uppercase tracking-[0.12em] dark:fill-zinc-300">
<text x={W - MARGIN.right} y={H - 4} textAnchor="end">
{xLabel} →
</text>
<text x={MARGIN.left + 6} y={MARGIN.top + 10}>
↑ {yLabel}
</text>
</g>
{/* Points */}
<g clipPath={`url(#${clipId})`}>
{points.map((p) => {
if (hidden.has(p.cat)) return null;
const isSel = selected.has(p.i);
const dim = hasSelection && !isSel;
const color = categoryColor(p.ci);
return (
<circle
key={p.d.id ?? p.i}
cx={p.px}
cy={p.py}
r={p.r}
className="transition-[opacity,stroke-width] duration-200 [transform-box:fill-box] [transform-origin:center] [animation:sp-in_520ms_cubic-bezier(0.2,0.8,0.2,1)_both] motion-reduce:[animation:none]"
style={{
fill: color,
fillOpacity: dim ? 0.12 : Math.min(1, Math.max(0.1, opacity)),
stroke: color,
strokeOpacity: dim ? 0.25 : 1,
strokeWidth: isSel ? 2 : 1,
animationDelay: `${Math.min(600, p.i * 3)}ms`,
}}
/>
);
})}
{activePoint && (
<circle
cx={activePoint.px}
cy={activePoint.py}
r={activePoint.r + 4}
fill="none"
strokeWidth={1.5}
className="pointer-events-none stroke-zinc-950 dark:stroke-white"
/>
)}
<path
ref={lassoRef}
d=""
className="pointer-events-none fill-[color:var(--sp-accent)]/10 stroke-[color:var(--sp-accent)]"
strokeWidth={1.25}
strokeDasharray="4 3"
/>
</g>
</svg>
{activePoint && (
<div
aria-hidden="true"
className="pointer-events-none absolute z-10 w-max max-w-[220px] -translate-x-1/2 rounded-md border border-zinc-900/10 bg-white/95 px-2.5 py-2 text-left shadow-[0_10px_30px_-12px_rgb(0_0_0/0.35)] dark:border-white/10 dark:bg-zinc-900/95"
style={{
left: tipLeft,
...(tipAbove ? { bottom: H - activePoint.py + activePoint.r + 10 } : { top: activePoint.py + activePoint.r + 10 }),
}}
>
{categories.length > 1 && (
<span className="flex items-center gap-1.5 font-mono text-[10px] uppercase tracking-[0.12em] text-zinc-500 dark:text-zinc-400">
<span className="size-1.5 rounded-full" style={{ backgroundColor: categoryColor(activePoint.ci) }} />
{activePoint.cat}
</span>
)}
<span className="block text-sm font-semibold tracking-[-0.01em]">{activePoint.name}</span>
<span className="mt-1 grid grid-cols-[auto_auto] gap-x-3 font-mono text-[11px] tabular-nums text-zinc-600 dark:text-zinc-300">
<span className="text-zinc-400 dark:text-zinc-500">{xLabel}</span>
<span className="text-right">{fx(activePoint.d.x)}</span>
<span className="text-zinc-400 dark:text-zinc-500">{yLabel}</span>
<span className="text-right">{fy(activePoint.d.y)}</span>
{activePoint.d.size != null && (
<>
<span className="text-zinc-400 dark:text-zinc-500">{sizeLabel}</span>
<span className="text-right">{fs(activePoint.d.size)}</span>
</>
)}
</span>
</div>
)}
</div>
<p aria-hidden="true" className="font-mono text-[10px] uppercase tracking-[0.12em] text-zinc-500 dark:text-zinc-400">
{visible.length} of {data.length} points{hasSelection ? ` · ${selected.size} selected` : ""}
{brushMode !== "none" ? ` · drag to ${brushMode === "lasso" ? "lasso" : "box"}-select` : ""}
{zoomable ? " · scroll to zoom" : ""}
</p>
<p id={hintId} className="sr-only">
Arrow keys move to the nearest point in that direction; Home and End jump to the first and last point along the x axis.
Space or Enter adds the point to the selection or removes it; Escape clears the selection.
{zoomable ? " Plus and minus zoom around the current point; 0 resets the zoom." : ""}
</p>
<p className="sr-only" aria-live="polite">
{announcement}
</p>
<table className="sr-only">
<caption>{label}</caption>
<thead>
<tr>
<th scope="col">Point</th>
{categories.length > 1 && <th scope="col">Category</th>}
<th scope="col">{xLabel}</th>
<th scope="col">{yLabel}</th>
{extent.size && <th scope="col">{sizeLabel}</th>}
</tr>
</thead>
<tbody>
{points.map((p) => (
<tr key={p.d.id ?? p.i}>
<th scope="row">{p.name}</th>
{categories.length > 1 && <td>{p.cat}</td>}
<td>{fx(p.d.x)}</td>
<td>{fy(p.d.y)}</td>
{extent.size && <td>{p.d.size != null ? fs(p.d.size) : "—"}</td>}
</tr>
))}
</tbody>
</table>
</div>
);
}