'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> | 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<string | null>(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 <p className={cn('text-xs text-ink-3', className)}>No data</p>; 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<typeof scaleLinear>).ticks(6); const yt = yScale === 'log' ? logTicks(yd[0], yd[1]) : (y as ReturnType<typeof scaleLinear>).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 ( <div className={cn('relative', className)} data-scatter> <svg viewBox={`0 0 ${w} ${height}`} className="block w-full" role="img" aria-label={`${yLabel ?? 'y'} vs ${xLabel ?? 'x'}`} onPointerLeave={() => setHover(null)}> {yt.map((t) => ( <g key={`y${t}`}> <line x1={pad.l} x2={w - pad.r} y1={y(t)} y2={y(t)} stroke="var(--rule)" /> <text x={pad.l - 6} y={y(t) + 3} textAnchor="end" fontSize={10} fill="var(--ink-3)" className="tnum"> {yFormat(t)} </text> </g> ))} {xt.map((t) => ( <g key={`x${t}`}> <line x1={x(t)} x2={x(t)} y1={pad.t} y2={height - pad.b} stroke="var(--rule)" /> <text x={x(t)} y={height - pad.b + 14} textAnchor="middle" fontSize={10} fill="var(--ink-3)" className="tnum"> {xFormat(t)} </text> </g> ))} {xLabel && ( <text x={w - pad.r} y={height - 4} textAnchor="end" fontSize={10} fill="var(--ink-2)"> {xLabel} → </text> )} {yLabel && ( <text x={pad.l} y={pad.t - 4} textAnchor="start" fontSize={10} fill="var(--ink-2)"> ↑ {yLabel} </text> )} {quadrant?.x !== undefined && Number.isFinite(quadrant.x) && <line x1={x(quadrant.x)} x2={x(quadrant.x)} y1={pad.t} y2={height - pad.b} stroke="var(--rule-strong)" strokeDasharray="4 4" />} {quadrant?.y !== undefined && Number.isFinite(quadrant.y) && <line x1={pad.l} x2={w - pad.r} y1={y(quadrant.y)} y2={y(quadrant.y)} stroke="var(--rule-strong)" strokeDasharray="4 4" />} {frontier && frontier.length > 1 && <polyline points={frontier.map((p) => `${x(p.x)},${y(p.y)}`).join(' ')} fill="none" stroke="var(--accent-2)" strokeWidth={1.5} strokeDasharray="6 3" opacity={0.9} />} {pts.map((p) => { const isHl = hl.has(p.id); const dim = hl.size > 0 && !isHl; const isHover = hover === p.id; return ( <g key={p.id} onPointerEnter={() => setHover(p.id)} onClick={() => setHover(p.id)} style={{ cursor: p.href ? 'pointer' : 'default' }}> <circle cx={x(p.x)} cy={y(p.y)} r={r(p.r ?? 1) + (isHover ? 2 : 0)} fill={p.color ?? color} fillOpacity={dim ? 0.18 : isHl || isHover ? 0.95 : 0.6} stroke={isHl || isHover ? 'var(--ink)' : 'var(--canvas)'} strokeWidth={isHl || isHover ? 1.5 : 0.8}> <title>{`${p.label}: ${xFormat(p.x)} · ${yFormat(p.y)}`} {(labelled.has(p.id) || isHl) && !dim && ( {p.label.length > 22 ? `${p.label.slice(0, 21)}…` : p.label} )} ); })} {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) */} ); }