'use client'; import { scaleSqrt } from 'd3-scale'; import { useMemo, useState } from 'react'; import { t } from '@/i18n'; import { formatTick, formatValue } from '@/lib/format'; import type { FormatSpec as Spec } from '@/lib/types'; import { ChartFrame } from './chart-frame'; import { CHART, MARK } from './palette'; import { DEFAULT_MARGIN, extent, yScale } from './scales'; import { ChartTooltip, TooltipRow } from './tooltip'; import { useMeasure } from './use-measure'; export interface ScatterPoint { id: string; label: string; x: number | null; y: number | null; size?: number | null; href?: string; highlight?: boolean; } /** * Scatter / bubble. One colour for all points (nominal, no series), highlighted points in the accent; * labels only for highlighted points and the extremes (never every point). Nearest-point hover with a * 24 px hit radius. */ export function Scatter({ points, xSpec, ySpec, logX, logY, height = 300, title, className, labelCount = 4 }: { points: ScatterPoint[]; xSpec: Spec; ySpec: Spec; logX?: boolean; logY?: boolean; height?: number; title?: React.ReactNode; className?: string; labelCount?: number }) { const { ref, width } = useMeasure(560); const [hover, setHover] = useState(null); const m = { ...DEFAULT_MARGIN, left: 52, bottom: 32 }; const model = useMemo(() => { const clean = points.filter((p) => p.x != null && p.y != null) as Array; const innerW = Math.max(10, width - m.left - m.right); const innerH = Math.max(10, height - m.top - m.bottom); const xd = extent(clean.map((p) => p.x)) ?? [0, 1]; const yd = extent(clean.map((p) => p.y)) ?? [0, 1]; const x = yScale(xd, [0, innerW], { log: logX, includeZero: !logX }); const y = yScale(yd, [innerH, 0], { log: logY, includeZero: !logY }); const sizes = clean.map((p) => p.size ?? null).filter((v): v is number => v != null); const r = sizes.length ? scaleSqrt().domain([0, Math.max(...sizes)]).range([3, 18]) : null; const labelled = new Set(); for (const p of clean) if (p.highlight) labelled.add(p.id); const byY = [...clean].sort((a, b) => b.y - a.y); const byX = [...clean].sort((a, b) => b.x - a.x); for (const p of [byY[0], byY[byY.length - 1], byX[0], byX[byX.length - 1]]) if (p && labelled.size < labelCount + 2) labelled.add(p.id); return { clean, x, y, r, innerW, innerH, labelled }; }, [points, width, height, logX, logY, labelCount, m.left, m.right, m.top, m.bottom]); const summary = t('chart.summary.scatter', { x: xSpec.name ?? 'x', y: ySpec.name ?? 'y', n: model.clean.length }); const hp = hover ? model.clean.find((p) => p.id === hover) : null; const onMove = (e: React.PointerEvent) => { const rect = e.currentTarget.getBoundingClientRect(); const px = e.clientX - rect.left; const py = e.clientY - rect.top; let best: string | null = null; let bd = 24 * 24; for (const p of model.clean) { const dx = model.x(p.x) - px; const dy = model.y(p.y) - py; const d = dx * dx + dy * dy; if (d < bd) { bd = d; best = p.id; } } setHover(best); }; return ( ({ label: p.label, x: formatValue(p.x, xSpec), y: formatValue(p.y, ySpec) })) }}>
{model.y.ticks(4).map((tk) => ( ))} {model.x.ticks(5).map((tk) => ( {formatTick(tk, xSpec)} ))} {model.y.ticks(4).map((tk) => ( {formatTick(tk, ySpec)} ))} {xSpec.name} {model.clean.map((p) => ( ))} {model.clean .filter((p) => model.labelled.has(p.id)) .map((p) => ( {p.label} ))} setHover(null)} style={{ touchAction: 'pan-y' }} /> {hp ? (
{hp.label}
) : null}
); }