/** * Brush-selectable scatter chart (direct-manipulation primitive). * * Drag a rectangle over the plot to select points — the selection is * reported via `onBrush` and highlighted in place. Click empty space to * clear. Used by the calibration residual scatter and the visual query * builder preview. */ import { useMemo, useRef, useState } from 'react'; import Box from '@mui/material/Box'; import Typography from '@mui/material/Typography'; import { tokens } from '../../theme'; export interface BrushPoint { id: string; x: number; y: number; color?: string; label?: string; r?: number; } interface BrushChartProps { points: BrushPoint[]; width?: number | string; height?: number; xLabel: string; yLabel: string; onBrush?: (selectedIds: string[] | null) => void; /** Horizontal reference lines in y units, e.g. thresholds. */ refLinesY?: Array<{ y: number; label: string; color?: string }>; /** Vertical reference lines in x units. */ refLinesX?: Array<{ x: number; label: string; color?: string }>; xDomain?: [number, number]; yDomain?: [number, number]; } const PAD = { l: 46, r: 12, t: 10, b: 32 }; const VIEW_W = 640; function niceDomain(vals: number[], fixed?: [number, number]): [number, number] { if (fixed) return fixed; if (!vals.length) return [0, 1]; let lo = Math.min(...vals); let hi = Math.max(...vals); if (lo === hi) { lo -= 1; hi += 1; } const pad = (hi - lo) * 0.08; return [lo - pad, hi + pad]; } export default function BrushChart({ points, width = '100%', height = 260, xLabel, yLabel, onBrush, refLinesY = [], refLinesX = [], xDomain, yDomain, }: BrushChartProps) { const svgRef = useRef(null); const [brush, setBrush] = useState<{ x0: number; y0: number; x1: number; y1: number } | null>(null); const [selected, setSelected] = useState | null>(null); const [hover, setHover] = useState(null); const viewH = height; const [xLo, xHi] = useMemo( () => niceDomain([...points.map((p) => p.x), ...refLinesX.map((r) => r.x)], xDomain), [points, refLinesX, xDomain] ); const [yLo, yHi] = useMemo( () => niceDomain([...points.map((p) => p.y), ...refLinesY.map((r) => r.y)], yDomain), [points, refLinesY, yDomain] ); const sx = (x: number) => PAD.l + ((x - xLo) / (xHi - xLo)) * (VIEW_W - PAD.l - PAD.r); const sy = (y: number) => viewH - PAD.b - ((y - yLo) / (yHi - yLo)) * (viewH - PAD.t - PAD.b); const toLocal = (e: React.PointerEvent) => { const svg = svgRef.current; if (!svg) return { x: 0, y: 0 }; const rect = svg.getBoundingClientRect(); return { x: ((e.clientX - rect.left) / rect.width) * VIEW_W, y: ((e.clientY - rect.top) / rect.height) * viewH, }; }; const finishBrush = (b: { x0: number; y0: number; x1: number; y1: number }) => { const [bx0, bx1] = [Math.min(b.x0, b.x1), Math.max(b.x0, b.x1)]; const [by0, by1] = [Math.min(b.y0, b.y1), Math.max(b.y0, b.y1)]; if (bx1 - bx0 < 4 && by1 - by0 < 4) { setSelected(null); onBrush?.(null); return; } const ids = points.filter((p) => { const px = sx(p.x); const py = sy(p.y); return px >= bx0 && px <= bx1 && py >= by0 && py <= by1; }).map((p) => p.id); setSelected(new Set(ids)); onBrush?.(ids); }; const xTicks = useMemo(() => { const n = 5; return Array.from({ length: n + 1 }, (_, i) => xLo + ((xHi - xLo) * i) / n); }, [xLo, xHi]); const yTicks = useMemo(() => { const n = 4; return Array.from({ length: n + 1 }, (_, i) => yLo + ((yHi - yLo) * i) / n); }, [yLo, yHi]); return ( { const p = toLocal(e); (e.target as Element).setPointerCapture(e.pointerId); setBrush({ x0: p.x, y0: p.y, x1: p.x, y1: p.y }); }} onPointerMove={(e) => { if (!brush || !(e.target as Element).hasPointerCapture?.(e.pointerId)) return; const p = toLocal(e); setBrush({ ...brush, x1: p.x, y1: p.y }); }} onPointerUp={(e) => { (e.target as Element).releasePointerCapture(e.pointerId); if (brush) finishBrush(brush); setBrush(null); }} > {/* axes */} {xTicks.map((t) => ( {Math.abs(t) >= 100 ? t.toFixed(0) : t.toFixed(2)} ))} {yTicks.map((t) => ( {Math.abs(t) >= 100 ? t.toFixed(0) : t.toFixed(2)} ))} {xLabel} {yLabel} {/* reference lines */} {refLinesY.map((r) => ( {r.label} ))} {refLinesX.map((r) => ( {r.label} ))} {/* points */} {points.map((p) => { const dim = selected !== null && !selected.has(p.id); return ( setHover(p)} onPointerLeave={() => setHover((h) => (h?.id === p.id ? null : h))} style={{ transition: `opacity ${tokens.motion.fast}` }} /> ); })} {/* live brush rect */} {brush ? ( ) : null} {hover ? ( {hover.label ?? hover.id}: ({hover.x.toFixed(2)}, {hover.y.toFixed(2)}) ) : null} ); }