SPB Git forge

spb/ai-atlas

Public
41commits 1branches 0releases
4.6 MBsize
maindefault branch
12 days agolast push
HTML 77.2% TypeScript 10.5% Python 9.6% JavaScript 2.5%
7.1 KB · 141 lines tsx
Raw Blame History
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