/** * Shared lightweight SVG chart components used across the new visual pages: * HeatmapGrid (ODD coverage, regression map), Donut (composition), DeltaBars * (paired baseline/candidate deltas), and SeriesChart (multi-line with * reference boundaries — the base of the sequential-evidence chart). * No chart library: everything is plain SVG in theme colors. */ import { useMemo, useState, type ReactNode } from 'react'; import Box from '@mui/material/Box'; import Tooltip from '@mui/material/Tooltip'; import Typography from '@mui/material/Typography'; import { tokens } from '../../theme'; // ---------------------------------------------------------------- HeatmapGrid export interface HeatCell { row: string; col: string; value: number | null; /** Fully resolved fill color; the page owns the color semantics. */ color: string; label?: string; tooltip?: ReactNode; } interface HeatmapGridProps { rows: string[]; cols: string[]; cells: HeatCell[]; onCellClick?: (cell: HeatCell) => void; selectedKey?: string | null; // `${row}|${col}` cellH?: number; minCellW?: number; rowLabelW?: number; } export function HeatmapGrid({ rows, cols, cells, onCellClick, selectedKey, cellH = 34, minCellW = 56, rowLabelW = 120, }: HeatmapGridProps) { const byKey = useMemo(() => { const m = new Map(); cells.forEach((c) => m.set(`${c.row}|${c.col}`, c)); return m; }, [cells]); return ( {cols.map((c) => ( {c} ))} {rows.map((r) => ( {r} {cols.map((c) => { const cell = byKey.get(`${r}|${c}`); const selected = selectedKey === `${r}|${c}`; const body = ( onCellClick(cell) : undefined} onKeyDown={cell && onCellClick ? (e) => { if (e.key === 'Enter' || e.key === ' ') onCellClick(cell); } : undefined} sx={{ height: cellH, borderRadius: '3px', bgcolor: cell ? cell.color : tokens.color.surfaceSunken, border: selected ? `2px solid #fff` : `1px solid rgba(0,0,0,0.35)`, display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: cell && onCellClick ? 'pointer' : 'default', transition: `transform ${tokens.motion.fast}, border-color ${tokens.motion.fast}`, '&:hover': cell && onCellClick ? { transform: 'scale(1.06)', zIndex: 1 } : undefined, '&:focus-visible': { outline: `2px solid ${tokens.color.info}`, outlineOffset: 1 }, }} > {cell?.label ?? ''} ); return cell?.tooltip ? ( {body} ) : ( body ); })} ))} ); } // ---------------------------------------------------------------- Donut export interface DonutSegment { label: string; value: number; color: string; } export function Donut({ segments, size = 150, thickness = 22, centerLabel, centerSub }: { segments: DonutSegment[]; size?: number; thickness?: number; centerLabel?: string; centerSub?: string }) { const total = segments.reduce((s, x) => s + x.value, 0); const r = (size - thickness) / 2; const cx = size / 2; const circumference = 2 * Math.PI * r; let acc = 0; return ( {total > 0 ? segments.map((s) => { const frac = s.value / total; const dash = frac * circumference; const el = ( ); acc += frac; return el; }) : null} {centerLabel ? ( {centerLabel} ) : null} {centerSub ? ( {centerSub} ) : null} {segments.map((s) => ( {s.label} {s.value.toLocaleString()} {total > 0 ? ` (${((s.value / total) * 100).toFixed(1)}%)` : ''} ))} ); } // ---------------------------------------------------------------- DeltaBars export interface DeltaBarRow { label: string; baseline: number; candidate: number; delta: number; n?: number; improved: boolean; tooltip?: ReactNode; } /** Paired horizontal bars (baseline vs candidate) with a signed delta chip. */ export function DeltaBars({ rows, max = 1, fmt = (v: number) => v.toFixed(3) }: { rows: DeltaBarRow[]; max?: number; fmt?: (v: number) => string }) { return ( {rows.map((r) => { const body = ( {r.label} {r.n !== undefined ? · n={r.n.toLocaleString()} : null} {r.delta >= 0 ? '+' : ''} {fmt(r.delta)} ); return r.tooltip ? ( {body} ) : ( body ); })} ); } // ---------------------------------------------------------------- SeriesChart export interface Series { id: string; color: string; points: Array<{ x: number; y: number; meta?: unknown }>; width?: number; dashed?: boolean; } interface SeriesChartProps { series: Series[]; height?: number; xLabel: string; yLabel: string; refLinesY?: Array<{ y: number; label: string; color?: string; dashed?: boolean }>; /** Vertical marker, e.g. "stopped here". */ markerX?: { x: number; label: string; color?: string } | null; onHoverPoint?: (seriesId: string | null, point: { x: number; y: number; meta?: unknown } | null) => void; yDomain?: [number, number]; xLog?: boolean; } const SPAD = { l: 52, r: 14, t: 12, b: 34 }; const SVIEW_W = 720; export function SeriesChart({ series, height = 300, xLabel, yLabel, refLinesY = [], markerX = null, onHoverPoint, yDomain }: SeriesChartProps) { const [hover, setHover] = useState<{ sid: string; x: number; y: number } | null>(null); const allX = series.flatMap((s) => s.points.map((p) => p.x)); const allY = [...series.flatMap((s) => s.points.map((p) => p.y)), ...refLinesY.map((r) => r.y)]; const xLo = allX.length ? Math.min(...allX) : 0; const xHi = allX.length ? Math.max(...allX, xLo + 1) : 1; let yLo: number; let yHi: number; if (yDomain) { [yLo, yHi] = yDomain; } else { yLo = allY.length ? Math.min(...allY) : 0; yHi = allY.length ? Math.max(...allY, yLo + 1) : 1; const pad = (yHi - yLo) * 0.1 || 1; yLo -= pad; yHi += pad; } const sx = (x: number) => SPAD.l + ((x - xLo) / (xHi - xLo || 1)) * (SVIEW_W - SPAD.l - SPAD.r); const sy = (y: number) => height - SPAD.b - ((y - yLo) / (yHi - yLo || 1)) * (height - SPAD.t - SPAD.b); const xTicks = Array.from({ length: 6 }, (_, i) => xLo + ((xHi - xLo) * i) / 5); const yTicks = Array.from({ length: 5 }, (_, i) => yLo + ((yHi - yLo) * i) / 4); return ( {xTicks.map((t) => ( {Math.round(t).toLocaleString()} ))} {yTicks.map((t) => ( {Math.abs(t) >= 100 ? t.toFixed(0) : t.toFixed(1)} ))} {xLabel} {yLabel} {refLinesY.map((r) => ( {r.label} ))} {markerX ? ( {markerX.label} ) : null} {series.map((s) => ( `${sx(p.x)},${sy(p.y)}`).join(' ')} fill="none" stroke={s.color} strokeWidth={s.width ?? 1.8} strokeDasharray={s.dashed ? '5 4' : undefined} opacity={hover && hover.sid !== s.id ? 0.25 : 1} style={{ transition: `opacity ${tokens.motion.fast}` }} /> {s.points.map((p, i) => ( { setHover({ sid: s.id, x: p.x, y: p.y }); onHoverPoint?.(s.id, p); }} onPointerLeave={() => { setHover(null); onHoverPoint?.(null, null); }} style={{ cursor: 'pointer', transition: `r ${tokens.motion.fast}` }} /> ))} ))} ); }