import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; export interface Series { x: ArrayLike; y: ArrayLike; color?: string; label?: string; width?: number; dash?: number[]; } export type XRange = [number, number]; export interface LinePlotProps { series: Series[]; title?: string; xLabel?: string; yLabel?: string; /** Display multipliers (e.g. 1000 to plot seconds as milliseconds) */ xScale?: number; yScale?: number; logY?: boolean; /** Visible x window in data units; null or undefined shows `defaultXRange` or everything */ xRange?: XRange | null; /** Window shown when not zoomed (data units); zooming out past it up to the data extent is allowed */ defaultXRange?: XRange; /** Enables zooming: drag to select, wheel to zoom about the pointer, double-click to reset. */ onXRangeChange?: (r: XRange | null) => void; yRange?: [number, number]; /** Vertical marker at this x (data units), e.g. the animation time */ cursorX?: number; /** Crosshair with value readout at this x (data units), typically shared between plots */ hoverX?: number | null; onHover?: (x: number | null) => void; onClick?: (x: number) => void; height?: number; /** Unit shown in the readout */ yUnit?: string; } const DEFAULT_COLORS = ["#1f5fa8", "#d1495b", "#2e8b57", "#8a5cbf", "#e08a1e", "#3aa7b8"]; const MARGIN = { left: 60, right: 12, top: 8, bottom: 30 }; interface Geometry { width: number; height: number; xmin: number; xmax: number; ymin: number; ymax: number; } /** * Canvas line plot. The data layer redraws only when the data or the window * changes; the cursor, crosshair and selection live on a separate overlay so * hovering costs nothing. Draws the solver's numbers; computes nothing about them. */ export function LinePlot(props: LinePlotProps) { const { series, title, xLabel, yLabel, xScale = 1, yScale = 1, logY, defaultXRange, onXRangeChange, yRange, cursorX, hoverX, onHover, onClick, height = 200, yUnit } = props; const xRange = props.xRange ?? defaultXRange ?? null; const zoomed = !!props.xRange; const baseRef = useRef(null); const overRef = useRef(null); const boxRef = useRef(null); const geom = useRef({ width: 400, height, xmin: 0, xmax: 1, ymin: 0, ymax: 1 }); const [width, setWidth] = useState(400); const [drag, setDrag] = useState<{ x0: number; x1: number } | null>(null); const dragRef = useRef<{ x0: number; x1: number } | null>(null); // Track the container width useLayoutEffect(() => { const box = boxRef.current; if (!box) return; const update = () => setWidth(Math.max(120, box.clientWidth)); update(); const ro = new ResizeObserver(update); ro.observe(box); return () => ro.disconnect(); }, []); const pw = width - MARGIN.left - MARGIN.right; const ph = height - MARGIN.top - MARGIN.bottom; // Data extents in display units const fullX = useRef([0, 1]); // ---- Data layer useEffect(() => { const canvas = baseRef.current; if (!canvas) return; const dpr = Math.min(2, window.devicePixelRatio || 1); canvas.width = Math.round(width * dpr); canvas.height = Math.round(height * dpr); const ctx = canvas.getContext("2d"); if (!ctx) return; ctx.save(); ctx.scale(dpr, dpr); ctx.clearRect(0, 0, width, height); let dxmin = Infinity, dxmax = -Infinity; for (const s of series) { for (let i = 0; i < s.x.length; i++) { const x = s.x[i] * xScale; if (!Number.isFinite(x)) continue; if (x < dxmin) dxmin = x; if (x > dxmax) dxmax = x; } } if (!Number.isFinite(dxmin) || dxmin === dxmax) [dxmin, dxmax] = [0, 1]; fullX.current = [dxmin, dxmax]; let xmin = dxmin, xmax = dxmax; if (xRange) [xmin, xmax] = [xRange[0] * xScale, xRange[1] * xScale]; if (xmin === xmax) xmax = xmin + 1; // y extents over the visible window let ymin = Infinity, ymax = -Infinity; for (const s of series) { for (let i = 0; i < s.x.length; i++) { const x = s.x[i] * xScale; if (x < xmin || x > xmax) continue; let y = s.y[i] * yScale; if (!Number.isFinite(y)) continue; if (logY) { if (y <= 0) continue; y = Math.log10(y); } if (y < ymin) ymin = y; if (y > ymax) ymax = y; } } if (yRange) [ymin, ymax] = logY ? [Math.log10(yRange[0] * yScale), Math.log10(yRange[1] * yScale)] : [yRange[0] * yScale, yRange[1] * yScale]; if (!Number.isFinite(ymin)) [ymin, ymax] = [0, 1]; if (ymin === ymax) [ymin, ymax] = [ymin - 1, ymax + 1]; if (!yRange && !logY) { const pad = 0.05 * (ymax - ymin); ymin -= pad; ymax += pad; } geom.current = { width, height, xmin, xmax, ymin, ymax }; const X = (x: number) => MARGIN.left + ((x - xmin) / (xmax - xmin)) * pw; const Y = (y: number) => MARGIN.top + (1 - (y - ymin) / (ymax - ymin)) * ph; ctx.font = "10px system-ui, sans-serif"; ctx.fillStyle = "#5b6573"; ctx.strokeStyle = "#e3e6ea"; ctx.lineWidth = 1; for (const t of ticks(xmin, xmax, Math.max(3, Math.floor(pw / 70)))) { const px = X(t); ctx.beginPath(); ctx.moveTo(px, MARGIN.top); ctx.lineTo(px, MARGIN.top + ph); ctx.stroke(); ctx.textAlign = "center"; ctx.fillText(fmtTick(t), px, MARGIN.top + ph + 12); } const yt = logY ? logTicks(ymin, ymax) : ticks(ymin, ymax, 5); for (const t of yt) { const py = Y(t); ctx.beginPath(); ctx.moveTo(MARGIN.left, py); ctx.lineTo(MARGIN.left + pw, py); ctx.stroke(); ctx.textAlign = "right"; ctx.fillText(logY ? fmtTick(Math.pow(10, t)) : fmtTick(t), MARGIN.left - 4, py + 3); } ctx.strokeStyle = "#8a93a0"; ctx.strokeRect(MARGIN.left + 0.5, MARGIN.top + 0.5, pw - 1, ph - 1); if (xLabel) { ctx.textAlign = "center"; ctx.fillText(xLabel, MARGIN.left + pw / 2, height - 4); } if (yLabel) { ctx.save(); ctx.translate(10, MARGIN.top + ph / 2); ctx.rotate(-Math.PI / 2); ctx.textAlign = "center"; ctx.fillText(yLabel, 0, 0); ctx.restore(); } ctx.save(); ctx.beginPath(); ctx.rect(MARGIN.left, MARGIN.top, pw, ph); ctx.clip(); series.forEach((s, si) => { ctx.strokeStyle = s.color ?? DEFAULT_COLORS[si % DEFAULT_COLORS.length]; ctx.lineWidth = s.width ?? 1.2; ctx.setLineDash(s.dash ?? []); ctx.beginPath(); let pen = false; const n = s.x.length; // Visible index window (x assumed increasing), decimated to about two points per pixel const i0 = Math.max(0, lowerBound(s.x, xmin / xScale) - 1); const i1 = Math.min(n - 1, lowerBound(s.x, xmax / xScale) + 1); const stride = Math.max(1, Math.floor((i1 - i0) / (pw * 2))); for (let i = i0; i <= i1; i += stride) { const x = s.x[i] * xScale; let y = s.y[i] * yScale; if (logY) y = y > 0 ? Math.log10(y) : NaN; if (!Number.isFinite(x) || !Number.isFinite(y)) { pen = false; continue; } const px = X(x), py = Y(y); if (pen) ctx.lineTo(px, py); else { ctx.moveTo(px, py); pen = true; } } ctx.stroke(); }); ctx.setLineDash([]); ctx.restore(); ctx.restore(); }, [series, xScale, yScale, logY, xRange, yRange, height, width, xLabel, yLabel, pw, ph]); // ---- Overlay layer: cursor, crosshair with readout, drag selection useEffect(() => { const canvas = overRef.current; if (!canvas) return; const dpr = Math.min(2, window.devicePixelRatio || 1); canvas.width = Math.round(width * dpr); canvas.height = Math.round(height * dpr); const ctx = canvas.getContext("2d"); if (!ctx) return; ctx.save(); ctx.scale(dpr, dpr); ctx.clearRect(0, 0, width, height); const g = geom.current; const X = (x: number) => MARGIN.left + ((x - g.xmin) / (g.xmax - g.xmin)) * pw; const Y = (y: number) => MARGIN.top + (1 - (y - g.ymin) / (g.ymax - g.ymin)) * ph; ctx.beginPath(); ctx.rect(MARGIN.left, MARGIN.top, pw, ph); ctx.clip(); if (cursorX !== undefined && Number.isFinite(cursorX)) { const px = X(cursorX * xScale); ctx.strokeStyle = "#333"; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(px, MARGIN.top); ctx.lineTo(px, MARGIN.top + ph); ctx.stroke(); } if (hoverX !== undefined && hoverX !== null && Number.isFinite(hoverX)) { const hx = hoverX * xScale; if (hx >= g.xmin && hx <= g.xmax) { const px = X(hx); ctx.strokeStyle = "rgba(0,0,0,0.35)"; ctx.lineWidth = 1; ctx.setLineDash([3, 3]); ctx.beginPath(); ctx.moveTo(px, MARGIN.top); ctx.lineTo(px, MARGIN.top + ph); ctx.stroke(); ctx.setLineDash([]); ctx.font = "10px system-ui, sans-serif"; const lines: { text: string; color: string; py: number }[] = []; series.forEach((s, si) => { const i = nearestIndex(s.x, hoverX); if (i < 0) return; const y = s.y[i] * yScale; if (!Number.isFinite(y)) return; const color = s.color ?? DEFAULT_COLORS[si % DEFAULT_COLORS.length]; const py = Y(logY ? (y > 0 ? Math.log10(y) : g.ymin) : y); ctx.fillStyle = color; ctx.beginPath(); ctx.arc(px, py, 3, 0, 2 * Math.PI); ctx.fill(); lines.push({ text: `${s.label ? s.label + ": " : ""}${fmtTick(y)}${yUnit ? " " + yUnit : ""}`, color, py }); }); const boxW = Math.max(60, ...lines.map((l) => ctx.measureText(l.text).width + 10)); const right = px + 8 + boxW > MARGIN.left + pw; const bx = right ? px - 8 - boxW : px + 8; const by = MARGIN.top + 4; const boxH = 14 * (lines.length + 1) + 4; ctx.fillStyle = "rgba(255,255,255,0.92)"; ctx.fillRect(bx, by, boxW, boxH); ctx.strokeStyle = "#c9ced5"; ctx.strokeRect(bx + 0.5, by + 0.5, boxW - 1, boxH - 1); ctx.textAlign = "left"; ctx.fillStyle = "#5b6573"; ctx.fillText(`${xLabel ? xLabel.replace(/\s*\[.*\]\s*$/, "") + " " : ""}${fmtTick(hx)}${unitOf(xLabel)}`, bx + 5, by + 12); lines.forEach((l, k) => { ctx.fillStyle = l.color; ctx.fillText(l.text, bx + 5, by + 12 + 14 * (k + 1)); }); } } if (drag) { const a = Math.min(drag.x0, drag.x1), b = Math.max(drag.x0, drag.x1); ctx.fillStyle = "rgba(31,95,168,0.15)"; ctx.fillRect(a, MARGIN.top, b - a, ph); } ctx.restore(); }, [cursorX, hoverX, drag, series, xScale, yScale, logY, width, height, pw, ph, xLabel, yUnit, xRange]); // ---- Interaction const toDataX = useCallback( (clientX: number, el: HTMLElement) => { const rect = el.getBoundingClientRect(); const px = clientX - rect.left; const g = geom.current; const f = (px - MARGIN.left) / pw; return { px, x: (g.xmin + f * (g.xmax - g.xmin)) / xScale, inside: f >= 0 && f <= 1 }; }, [pw, xScale], ); const clampRange = useCallback( (a: number, b: number): XRange | null => { const [fmin, fmax] = fullX.current.map((v) => v / xScale) as XRange; let lo = Math.max(fmin, Math.min(a, b)); let hi = Math.min(fmax, Math.max(a, b)); if (hi - lo < (fmax - fmin) * 1e-4) return null; if (lo <= fmin && hi >= fmax) return null; return [lo, hi]; }, [xScale], ); // Wheel zoom needs a non-passive listener to prevent page scroll useEffect(() => { const el = overRef.current; if (!el || !onXRangeChange) return; const onWheel = (e: WheelEvent) => { const { x, inside } = toDataX(e.clientX, el); if (!inside) return; e.preventDefault(); const g = geom.current; const factor = Math.exp(e.deltaY * 0.0015); const lo = x - (x - g.xmin / xScale) * factor; const hi = x + (g.xmax / xScale - x) * factor; onXRangeChange(clampRange(lo, hi)); }; el.addEventListener("wheel", onWheel, { passive: false }); return () => el.removeEventListener("wheel", onWheel); }, [onXRangeChange, toDataX, clampRange, xScale]); return (
{(title || zoomed) && (
{title} {zoomed && onXRangeChange && ( )}
)}
{ const { px, x, inside } = toDataX(e.clientX, e.currentTarget); if (dragRef.current) { dragRef.current = { ...dragRef.current, x1: px }; setDrag(dragRef.current); } onHover?.(inside ? x : null); }} onMouseLeave={() => onHover?.(null)} onMouseDown={(e) => { if (!onXRangeChange || e.button !== 0) return; const { px, inside } = toDataX(e.clientX, e.currentTarget); if (!inside) return; dragRef.current = { x0: px, x1: px }; setDrag(dragRef.current); }} onMouseUp={(e) => { const d = dragRef.current; dragRef.current = null; setDrag(null); const { x } = toDataX(e.clientX, e.currentTarget); if (d && Math.abs(d.x1 - d.x0) > 4 && onXRangeChange) { const g = geom.current; const toX = (px: number) => (g.xmin + ((px - MARGIN.left) / pw) * (g.xmax - g.xmin)) / xScale; onXRangeChange(clampRange(toX(d.x0), toX(d.x1))); } else if (onClick) { onClick(x); } }} onDoubleClick={() => onXRangeChange?.(null)} />
{series.some((s) => s.label) && (
{series.map((s, i) => ( {s.label} ))} {onXRangeChange && drag or scroll to zoom, double-click to reset}
)}
); } function unitOf(label?: string): string { const m = label?.match(/\[(.*)\]\s*$/); return m ? ` ${m[1]}` : ""; } /** First index with x[i] >= v (x increasing). */ function lowerBound(x: ArrayLike, v: number): number { let lo = 0, hi = x.length; while (lo < hi) { const mid = (lo + hi) >> 1; if (x[mid] < v) lo = mid + 1; else hi = mid; } return lo; } function nearestIndex(x: ArrayLike, v: number): number { const n = x.length; if (n === 0) return -1; const i = lowerBound(x, v); if (i <= 0) return 0; if (i >= n) return n - 1; return Math.abs(x[i] - v) < Math.abs(x[i - 1] - v) ? i : i - 1; } export function ticks(min: number, max: number, n: number): number[] { const span = max - min; if (!(span > 0)) return [min]; const raw = span / n; const mag = Math.pow(10, Math.floor(Math.log10(raw))); const norm = raw / mag; const step = (norm < 1.5 ? 1 : norm < 3.5 ? 2 : norm < 7.5 ? 5 : 10) * mag; const out: number[] = []; for (let t = Math.ceil(min / step) * step; t <= max + 1e-9 * span; t += step) out.push(Number(t.toPrecision(12))); return out; } function logTicks(lmin: number, lmax: number): number[] { const lo = Math.ceil(lmin), hi = Math.floor(lmax); if (hi < lo) return [lmin, lmax]; // at most about six labelled decades, so wide dynamic ranges stay legible const step = Math.max(1, Math.ceil((hi - lo + 1) / 6)); const out: number[] = []; for (let e = lo; e <= hi; e += step) out.push(e); return out; } export function fmtTick(x: number): string { if (x === 0) return "0"; const a = Math.abs(x); if (a >= 1e5 || a < 1e-3) return x.toExponential(2).replace("e+", "e"); return String(Number(x.toPrecision(4))); }