'use client';
import { extent } from 'd3-array';
import { scaleLinear, scaleLog, scaleSqrt } from 'd3-scale';
import Link from 'next/link';
import { useMemo, useState } from 'react';
import { cn } from '@/lib/cn';
import { fmtCompact } from '@/lib/format';
import { logTicks } from './charts';
export type ScatterPoint = { id: string; x: number; y: number; /** bubble size (raw; scaled with sqrt) */ r?: number; label: string; sub?: string; href?: string; color?: string; group?: string };
/**
* Scatter / bubble chart for Pareto views (cost vs performance): linear or log axes, bubble size, highlighted set,
* hover tooltip (touch: tap), optional frontier polyline (`frontier` = ordered points) and quadrant guide lines.
* Theme-aware through CSS variables; every point has an accessible
.
*/
export function ScatterChart({
points,
xScale = 'linear',
yScale = 'linear',
xLabel,
yLabel,
xFormat = fmtCompact,
yFormat = fmtCompact,
frontier,
highlight,
quadrant,
height = 360,
className,
color = 'var(--series-1)',
labelTop = 6,
}: {
points: ScatterPoint[];
xScale?: 'linear' | 'log';
yScale?: 'linear' | 'log';
xLabel?: string;
yLabel?: string;
xFormat?: (v: number) => string;
yFormat?: (v: number) => string;
frontier?: { x: number; y: number }[];
highlight?: Set | string[];
/** Guide lines at x / y (e.g. medians). */
quadrant?: { x?: number; y?: number };
height?: number;
className?: string;
color?: string;
/** Label the n largest/highest points inline. */
labelTop?: number;
}) {
const [hover, setHover] = useState(null);
const hl = useMemo(() => (highlight instanceof Set ? highlight : new Set(highlight ?? [])), [highlight]);
const w = 720;
const pad = { l: 52, r: 20, t: 16, b: 36 };
const pts = points.filter((p) => Number.isFinite(p.x) && Number.isFinite(p.y) && (xScale !== 'log' || p.x > 0) && (yScale !== 'log' || p.y > 0));
if (pts.length < 1) return No data
;
const [x0, x1] = extent(pts, (p) => p.x) as [number, number];
const [y0, y1] = extent(pts, (p) => p.y) as [number, number];
const padDom = (lo: number, hi: number, log: boolean): [number, number] => (log ? [lo / 1.4, hi * 1.4] : lo === hi ? [lo - 1, hi + 1] : [lo - (hi - lo) * 0.06, hi + (hi - lo) * 0.08]);
const xd = padDom(x0, x1, xScale === 'log');
const yd = padDom(y0, y1, yScale === 'log');
const x = (xScale === 'log' ? scaleLog() : scaleLinear()).domain(xd).range([pad.l, w - pad.r]);
const y = (yScale === 'log' ? scaleLog() : scaleLinear()).domain(yd).range([height - pad.b, pad.t]);
const [r0, r1] = extent(pts, (p) => p.r ?? 1) as [number, number];
const r = scaleSqrt().domain([r0 ?? 1, r1 === r0 ? (r0 ?? 1) + 1 : r1 ?? 1]).range([3, 12]);
const xt = xScale === 'log' ? logTicks(xd[0], xd[1]) : (x as ReturnType).ticks(6);
const yt = yScale === 'log' ? logTicks(yd[0], yd[1]) : (y as ReturnType).ticks(5);
const labelled = new Set([...pts].sort((a, b) => (b.r ?? 0) - (a.r ?? 0) || b.y - a.y).slice(0, labelTop).map((p) => p.id));
const active = hover ? pts.find((p) => p.id === hover) : null;
const tipLeft = active ? (x(active.x) / w) * 100 : 0;
const tipTop = active ? (y(active.y) / height) * 100 : 0;
return (
{active && (
{active.label}
{active.sub &&
{active.sub}
}
{xLabel ?? 'x'} {xFormat(active.x)} · {yLabel ?? 'y'} {yFormat(active.y)}
{active.href &&
Open →
}
)}
{/* accessible list + real links (SVG circles are not focusable) */}
{pts.map((p) => (
- {p.href ? {`${p.label}: ${xFormat(p.x)}, ${yFormat(p.y)}`} : `${p.label}: ${xFormat(p.x)}, ${yFormat(p.y)}`}
))}
);
}