HTML 77.2%
TypeScript 10.5%
Python 9.6%
JavaScript 2.5%
1'use client';2import { extent } from 'd3-array';3import { scaleLinear, scaleLog, scaleSqrt } from 'd3-scale';4import Link from 'next/link';5import { useMemo, useState } from 'react';6import { cn } from '@/lib/cn';7import { fmtCompact } from '@/lib/format';8import { logTicks } from './charts';910export 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 };1112/**13 * Scatter / bubble chart for Pareto views (cost vs performance): linear or log axes, bubble size, highlighted set,14 * hover tooltip (touch: tap), optional frontier polyline (`frontier` = ordered points) and quadrant guide lines.15 * Theme-aware through CSS variables; every point has an accessible <title>.16 */17export function ScatterChart({18 points,19 xScale = 'linear',20 yScale = 'linear',21 xLabel,22 yLabel,23 xFormat = fmtCompact,24 yFormat = fmtCompact,25 frontier,26 highlight,27 quadrant,28 height = 360,29 className,30 color = 'var(--series-1)',31 labelTop = 6,32}: {33 points: ScatterPoint[];34 xScale?: 'linear' | 'log';35 yScale?: 'linear' | 'log';36 xLabel?: string;37 yLabel?: string;38 xFormat?: (v: number) => string;39 yFormat?: (v: number) => string;40 frontier?: { x: number; y: number }[];41 highlight?: Set<string> | string[];42 /** Guide lines at x / y (e.g. medians). */43 quadrant?: { x?: number; y?: number };44 height?: number;45 className?: string;46 color?: string;47 /** Label the n largest/highest points inline. */48 labelTop?: number;49}) {50 const [hover, setHover] = useState<string | null>(null);51 const hl = useMemo(() => (highlight instanceof Set ? highlight : new Set(highlight ?? [])), [highlight]);52 const w = 720;53 const pad = { l: 52, r: 20, t: 16, b: 36 };54 const pts = points.filter((p) => Number.isFinite(p.x) && Number.isFinite(p.y) && (xScale !== 'log' || p.x > 0) && (yScale !== 'log' || p.y > 0));55 if (pts.length < 1) return <p className={cn('text-xs text-ink-3', className)}>No data</p>;56 const [x0, x1] = extent(pts, (p) => p.x) as [number, number];57 const [y0, y1] = extent(pts, (p) => p.y) as [number, number];58 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]);59 const xd = padDom(x0, x1, xScale === 'log');60 const yd = padDom(y0, y1, yScale === 'log');61 const x = (xScale === 'log' ? scaleLog() : scaleLinear()).domain(xd).range([pad.l, w - pad.r]);62 const y = (yScale === 'log' ? scaleLog() : scaleLinear()).domain(yd).range([height - pad.b, pad.t]);63 const [r0, r1] = extent(pts, (p) => p.r ?? 1) as [number, number];64 const r = scaleSqrt().domain([r0 ?? 1, r1 === r0 ? (r0 ?? 1) + 1 : r1 ?? 1]).range([3, 12]);65 const xt = xScale === 'log' ? logTicks(xd[0], xd[1]) : (x as ReturnType<typeof scaleLinear>).ticks(6);66 const yt = yScale === 'log' ? logTicks(yd[0], yd[1]) : (y as ReturnType<typeof scaleLinear>).ticks(5);67 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));68 const active = hover ? pts.find((p) => p.id === hover) : null;69 const tipLeft = active ? (x(active.x) / w) * 100 : 0;70 const tipTop = active ? (y(active.y) / height) * 100 : 0;7172 return (73 <div className={cn('relative', className)} data-scatter>74 <svg viewBox={`0 0 ${w} ${height}`} className="block w-full" role="img" aria-label={`${yLabel ?? 'y'} vs ${xLabel ?? 'x'}`} onPointerLeave={() => setHover(null)}>75 {yt.map((t) => (76 <g key={`y${t}`}>77 <line x1={pad.l} x2={w - pad.r} y1={y(t)} y2={y(t)} stroke="var(--rule)" />78 <text x={pad.l - 6} y={y(t) + 3} textAnchor="end" fontSize={10} fill="var(--ink-3)" className="tnum">79 {yFormat(t)}80 </text>81 </g>82 ))}83 {xt.map((t) => (84 <g key={`x${t}`}>85 <line x1={x(t)} x2={x(t)} y1={pad.t} y2={height - pad.b} stroke="var(--rule)" />86 <text x={x(t)} y={height - pad.b + 14} textAnchor="middle" fontSize={10} fill="var(--ink-3)" className="tnum">87 {xFormat(t)}88 </text>89 </g>90 ))}91 {xLabel && (92 <text x={w - pad.r} y={height - 4} textAnchor="end" fontSize={10} fill="var(--ink-2)">93 {xLabel} →94 </text>95 )}96 {yLabel && (97 <text x={pad.l} y={pad.t - 4} textAnchor="start" fontSize={10} fill="var(--ink-2)">98 ↑ {yLabel}99 </text>100 )}101 {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" />}102 {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" />}103 {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} />}104 {pts.map((p) => {105 const isHl = hl.has(p.id);106 const dim = hl.size > 0 && !isHl;107 const isHover = hover === p.id;108 return (109 <g key={p.id} onPointerEnter={() => setHover(p.id)} onClick={() => setHover(p.id)} style={{ cursor: p.href ? 'pointer' : 'default' }}>110 <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}>111 <title>{`${p.label}: ${xFormat(p.x)} · ${yFormat(p.y)}`}</title>112 </circle>113 {(labelled.has(p.id) || isHl) && !dim && (114 <text x={x(p.x) + r(p.r ?? 1) + 3} y={y(p.y) + 3} fontSize={10} fill="var(--ink-2)">115 {p.label.length > 22 ? `${p.label.slice(0, 21)}…` : p.label}116 </text>117 )}118 </g>119 );120 })}121 </svg>122 {active && (123 <div role="status" className="panel pointer-events-none absolute z-10 min-w-[10rem] px-2.5 py-1.5 text-xs shadow-lg" style={{ left: `min(${tipLeft + 1.5}%, calc(100% - 11rem))`, top: `${tipTop}%`, transform: 'translateY(-110%)' }}>124 <p className="truncate font-medium text-ink">{active.label}</p>125 {active.sub && <p className="truncate text-ink-3">{active.sub}</p>}126 <p className="tnum mt-0.5 text-ink-2">127 {xLabel ?? 'x'} <span className="text-ink">{xFormat(active.x)}</span> · {yLabel ?? 'y'} <span className="text-ink">{yFormat(active.y)}</span>128 </p>129 {active.href && <p className="mt-0.5 text-accent">Open →</p>}130 </div>131 )}132 {/* accessible list + real links (SVG circles are not focusable) */}133 <ul className="sr-only">134 {pts.map((p) => (135 <li key={p.id}>{p.href ? <Link href={p.href}>{`${p.label}: ${xFormat(p.x)}, ${yFormat(p.y)}`}</Link> : `${p.label}: ${xFormat(p.x)}, ${yFormat(p.y)}`}</li>136 ))}137 </ul>138 </div>139 );140}141