1import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
3export interface Series {
4 x: ArrayLike<number>;
5 y: ArrayLike<number>;
6 color?: string;
7 label?: string;
8 width?: number;
9 dash?: number[];
10}
12export type XRange = [number, number];
14export interface LinePlotProps {
15 series: Series[];
16 title?: string;
17 xLabel?: string;
18 yLabel?: string;
19 /** Display multipliers (e.g. 1000 to plot seconds as milliseconds) */
20 xScale?: number;
21 yScale?: number;
22 logY?: boolean;
23 /** Visible x window in data units; null or undefined shows `defaultXRange` or everything */
24 xRange?: XRange | null;
25 /** Window shown when not zoomed (data units); zooming out past it up to the data extent is allowed */
26 defaultXRange?: XRange;
27 /** Enables zooming: drag to select, wheel to zoom about the pointer, double-click to reset. */
28 onXRangeChange?: (r: XRange | null) => void;
29 yRange?: [number, number];
30 /** Vertical marker at this x (data units), e.g. the animation time */
31 cursorX?: number;
32 /** Crosshair with value readout at this x (data units), typically shared between plots */
33 hoverX?: number | null;
34 onHover?: (x: number | null) => void;
35 onClick?: (x: number) => void;
36 height?: number;
37 /** Unit shown in the readout */
38 yUnit?: string;
39}
41const DEFAULT_COLORS = ["#1f5fa8", "#d1495b", "#2e8b57", "#8a5cbf", "#e08a1e", "#3aa7b8"];
42const MARGIN = { left: 60, right: 12, top: 8, bottom: 30 };
44interface Geometry {
45 width: number;
46 height: number;
47 xmin: number;
48 xmax: number;
49 ymin: number;
50 ymax: number;
51}
53/**
54 * Canvas line plot. The data layer redraws only when the data or the window
55 * changes; the cursor, crosshair and selection live on a separate overlay so
56 * hovering costs nothing. Draws the solver's numbers; computes nothing about them.
57 */
58export function LinePlot(props: LinePlotProps) {
59 const { series, title, xLabel, yLabel, xScale = 1, yScale = 1, logY, defaultXRange, onXRangeChange, yRange, cursorX, hoverX, onHover, onClick, height = 200, yUnit } = props;
60 const xRange = props.xRange ?? defaultXRange ?? null;
61 const zoomed = !!props.xRange;
62 const baseRef = useRef<HTMLCanvasElement>(null);
63 const overRef = useRef<HTMLCanvasElement>(null);
64 const boxRef = useRef<HTMLDivElement>(null);
65 const geom = useRef<Geometry>({ width: 400, height, xmin: 0, xmax: 1, ymin: 0, ymax: 1 });
66 const [width, setWidth] = useState(400);
67 const [drag, setDrag] = useState<{ x0: number; x1: number } | null>(null);
68 const dragRef = useRef<{ x0: number; x1: number } | null>(null);
70 // Track the container width
71 useLayoutEffect(() => {
72 const box = boxRef.current;
73 if (!box) return;
74 const update = () => setWidth(Math.max(120, box.clientWidth));
75 update();
76 const ro = new ResizeObserver(update);
77 ro.observe(box);
78 return () => ro.disconnect();
79 }, []);
81 const pw = width - MARGIN.left - MARGIN.right;
82 const ph = height - MARGIN.top - MARGIN.bottom;
84 // Data extents in display units
85 const fullX = useRef<XRange>([0, 1]);
87 // ---- Data layer
88 useEffect(() => {
89 const canvas = baseRef.current;
90 if (!canvas) return;
91 const dpr = Math.min(2, window.devicePixelRatio || 1);
92 canvas.width = Math.round(width * dpr);
93 canvas.height = Math.round(height * dpr);
94 const ctx = canvas.getContext("2d");
95 if (!ctx) return;
96 ctx.save();
97 ctx.scale(dpr, dpr);
98 ctx.clearRect(0, 0, width, height);
100 let dxmin = Infinity, dxmax = -Infinity;
101 for (const s of series) {
102 for (let i = 0; i < s.x.length; i++) {
103 const x = s.x[i] * xScale;
104 if (!Number.isFinite(x)) continue;
105 if (x < dxmin) dxmin = x;
106 if (x > dxmax) dxmax = x;
107 }
108 }
109 if (!Number.isFinite(dxmin) || dxmin === dxmax) [dxmin, dxmax] = [0, 1];
110 fullX.current = [dxmin, dxmax];
111 let xmin = dxmin, xmax = dxmax;
112 if (xRange) [xmin, xmax] = [xRange[0] * xScale, xRange[1] * xScale];
113 if (xmin === xmax) xmax = xmin + 1;
115 // y extents over the visible window
116 let ymin = Infinity, ymax = -Infinity;
117 for (const s of series) {
118 for (let i = 0; i < s.x.length; i++) {
119 const x = s.x[i] * xScale;
120 if (x < xmin || x > xmax) continue;
121 let y = s.y[i] * yScale;
122 if (!Number.isFinite(y)) continue;
123 if (logY) {
124 if (y <= 0) continue;
125 y = Math.log10(y);
126 }
127 if (y < ymin) ymin = y;
128 if (y > ymax) ymax = y;
129 }
130 }
131 if (yRange) [ymin, ymax] = logY ? [Math.log10(yRange[0] * yScale), Math.log10(yRange[1] * yScale)] : [yRange[0] * yScale, yRange[1] * yScale];
132 if (!Number.isFinite(ymin)) [ymin, ymax] = [0, 1];
133 if (ymin === ymax) [ymin, ymax] = [ymin - 1, ymax + 1];
134 if (!yRange && !logY) {
135 const pad = 0.05 * (ymax - ymin);
136 ymin -= pad;
137 ymax += pad;
138 }
139 geom.current = { width, height, xmin, xmax, ymin, ymax };
140 const X = (x: number) => MARGIN.left + ((x - xmin) / (xmax - xmin)) * pw;
141 const Y = (y: number) => MARGIN.top + (1 - (y - ymin) / (ymax - ymin)) * ph;
143 ctx.font = "10px system-ui, sans-serif";
144 ctx.fillStyle = "#5b6573";
145 ctx.strokeStyle = "#e3e6ea";
146 ctx.lineWidth = 1;
147 for (const t of ticks(xmin, xmax, Math.max(3, Math.floor(pw / 70)))) {
148 const px = X(t);
149 ctx.beginPath(); ctx.moveTo(px, MARGIN.top); ctx.lineTo(px, MARGIN.top + ph); ctx.stroke();
150 ctx.textAlign = "center";
151 ctx.fillText(fmtTick(t), px, MARGIN.top + ph + 12);
152 }
153 const yt = logY ? logTicks(ymin, ymax) : ticks(ymin, ymax, 5);
154 for (const t of yt) {
155 const py = Y(t);
156 ctx.beginPath(); ctx.moveTo(MARGIN.left, py); ctx.lineTo(MARGIN.left + pw, py); ctx.stroke();
157 ctx.textAlign = "right";
158 ctx.fillText(logY ? fmtTick(Math.pow(10, t)) : fmtTick(t), MARGIN.left - 4, py + 3);
159 }
160 ctx.strokeStyle = "#8a93a0";
161 ctx.strokeRect(MARGIN.left + 0.5, MARGIN.top + 0.5, pw - 1, ph - 1);
162 if (xLabel) { ctx.textAlign = "center"; ctx.fillText(xLabel, MARGIN.left + pw / 2, height - 4); }
163 if (yLabel) {
164 ctx.save(); ctx.translate(10, MARGIN.top + ph / 2); ctx.rotate(-Math.PI / 2); ctx.textAlign = "center"; ctx.fillText(yLabel, 0, 0); ctx.restore();
165 }
167 ctx.save();
168 ctx.beginPath(); ctx.rect(MARGIN.left, MARGIN.top, pw, ph); ctx.clip();
169 series.forEach((s, si) => {
170 ctx.strokeStyle = s.color ?? DEFAULT_COLORS[si % DEFAULT_COLORS.length];
171 ctx.lineWidth = s.width ?? 1.2;
172 ctx.setLineDash(s.dash ?? []);
173 ctx.beginPath();
174 let pen = false;
175 const n = s.x.length;
176 // Visible index window (x assumed increasing), decimated to about two points per pixel
177 const i0 = Math.max(0, lowerBound(s.x, xmin / xScale) - 1);
178 const i1 = Math.min(n - 1, lowerBound(s.x, xmax / xScale) + 1);
179 const stride = Math.max(1, Math.floor((i1 - i0) / (pw * 2)));
180 for (let i = i0; i <= i1; i += stride) {
181 const x = s.x[i] * xScale;
182 let y = s.y[i] * yScale;
183 if (logY) y = y > 0 ? Math.log10(y) : NaN;
184 if (!Number.isFinite(x) || !Number.isFinite(y)) { pen = false; continue; }
185 const px = X(x), py = Y(y);
186 if (pen) ctx.lineTo(px, py); else { ctx.moveTo(px, py); pen = true; }
187 }
188 ctx.stroke();
189 });
190 ctx.setLineDash([]);
191 ctx.restore();
192 ctx.restore();
193 }, [series, xScale, yScale, logY, xRange, yRange, height, width, xLabel, yLabel, pw, ph]);
195 // ---- Overlay layer: cursor, crosshair with readout, drag selection
196 useEffect(() => {
197 const canvas = overRef.current;
198 if (!canvas) return;
199 const dpr = Math.min(2, window.devicePixelRatio || 1);
200 canvas.width = Math.round(width * dpr);
201 canvas.height = Math.round(height * dpr);
202 const ctx = canvas.getContext("2d");
203 if (!ctx) return;
204 ctx.save();
205 ctx.scale(dpr, dpr);
206 ctx.clearRect(0, 0, width, height);
207 const g = geom.current;
208 const X = (x: number) => MARGIN.left + ((x - g.xmin) / (g.xmax - g.xmin)) * pw;
209 const Y = (y: number) => MARGIN.top + (1 - (y - g.ymin) / (g.ymax - g.ymin)) * ph;
210 ctx.beginPath(); ctx.rect(MARGIN.left, MARGIN.top, pw, ph); ctx.clip();
211 if (cursorX !== undefined && Number.isFinite(cursorX)) {
212 const px = X(cursorX * xScale);
213 ctx.strokeStyle = "#333"; ctx.lineWidth = 1;
214 ctx.beginPath(); ctx.moveTo(px, MARGIN.top); ctx.lineTo(px, MARGIN.top + ph); ctx.stroke();
215 }
216 if (hoverX !== undefined && hoverX !== null && Number.isFinite(hoverX)) {
217 const hx = hoverX * xScale;
218 if (hx >= g.xmin && hx <= g.xmax) {
219 const px = X(hx);
220 ctx.strokeStyle = "rgba(0,0,0,0.35)"; ctx.lineWidth = 1; ctx.setLineDash([3, 3]);
221 ctx.beginPath(); ctx.moveTo(px, MARGIN.top); ctx.lineTo(px, MARGIN.top + ph); ctx.stroke();
222 ctx.setLineDash([]);
223 ctx.font = "10px system-ui, sans-serif";
224 const lines: { text: string; color: string; py: number }[] = [];
225 series.forEach((s, si) => {
226 const i = nearestIndex(s.x, hoverX);
227 if (i < 0) return;
228 const y = s.y[i] * yScale;
229 if (!Number.isFinite(y)) return;
230 const color = s.color ?? DEFAULT_COLORS[si % DEFAULT_COLORS.length];
231 const py = Y(logY ? (y > 0 ? Math.log10(y) : g.ymin) : y);
232 ctx.fillStyle = color;
233 ctx.beginPath(); ctx.arc(px, py, 3, 0, 2 * Math.PI); ctx.fill();
234 lines.push({ text: `${s.label ? s.label + ": " : ""}${fmtTick(y)}${yUnit ? " " + yUnit : ""}`, color, py });
235 });
236 const boxW = Math.max(60, ...lines.map((l) => ctx.measureText(l.text).width + 10));
237 const right = px + 8 + boxW > MARGIN.left + pw;
238 const bx = right ? px - 8 - boxW : px + 8;
239 const by = MARGIN.top + 4;
240 const boxH = 14 * (lines.length + 1) + 4;
241 ctx.fillStyle = "rgba(255,255,255,0.92)";
242 ctx.fillRect(bx, by, boxW, boxH);
243 ctx.strokeStyle = "#c9ced5"; ctx.strokeRect(bx + 0.5, by + 0.5, boxW - 1, boxH - 1);
244 ctx.textAlign = "left";
245 ctx.fillStyle = "#5b6573";
246 ctx.fillText(`${xLabel ? xLabel.replace(/\s*\[.*\]\s*$/, "") + " " : ""}${fmtTick(hx)}${unitOf(xLabel)}`, bx + 5, by + 12);
247 lines.forEach((l, k) => { ctx.fillStyle = l.color; ctx.fillText(l.text, bx + 5, by + 12 + 14 * (k + 1)); });
248 }
249 }
250 if (drag) {
251 const a = Math.min(drag.x0, drag.x1), b = Math.max(drag.x0, drag.x1);
252 ctx.fillStyle = "rgba(31,95,168,0.15)";
253 ctx.fillRect(a, MARGIN.top, b - a, ph);
254 }
255 ctx.restore();
256 }, [cursorX, hoverX, drag, series, xScale, yScale, logY, width, height, pw, ph, xLabel, yUnit, xRange]);
258 // ---- Interaction
259 const toDataX = useCallback(
260 (clientX: number, el: HTMLElement) => {
261 const rect = el.getBoundingClientRect();
262 const px = clientX - rect.left;
263 const g = geom.current;
264 const f = (px - MARGIN.left) / pw;
265 return { px, x: (g.xmin + f * (g.xmax - g.xmin)) / xScale, inside: f >= 0 && f <= 1 };
266 },
267 [pw, xScale],
268 );
270 const clampRange = useCallback(
271 (a: number, b: number): XRange | null => {
272 const [fmin, fmax] = fullX.current.map((v) => v / xScale) as XRange;
273 let lo = Math.max(fmin, Math.min(a, b));
274 let hi = Math.min(fmax, Math.max(a, b));
275 if (hi - lo < (fmax - fmin) * 1e-4) return null;
276 if (lo <= fmin && hi >= fmax) return null;
277 return [lo, hi];
278 },
279 [xScale],
280 );
282 // Wheel zoom needs a non-passive listener to prevent page scroll
283 useEffect(() => {
284 const el = overRef.current;
285 if (!el || !onXRangeChange) return;
286 const onWheel = (e: WheelEvent) => {
287 const { x, inside } = toDataX(e.clientX, el);
288 if (!inside) return;
289 e.preventDefault();
290 const g = geom.current;
291 const factor = Math.exp(e.deltaY * 0.0015);
292 const lo = x - (x - g.xmin / xScale) * factor;
293 const hi = x + (g.xmax / xScale - x) * factor;
294 onXRangeChange(clampRange(lo, hi));
295 };
296 el.addEventListener("wheel", onWheel, { passive: false });
297 return () => el.removeEventListener("wheel", onWheel);
298 }, [onXRangeChange, toDataX, clampRange, xScale]);
300 return (
301 <div className="pl-plot" ref={boxRef}>
302 {(title || zoomed) && (
303 <div className="pl-plot-title">
304 {title}
305 {zoomed && onXRangeChange && (
306 <button className="pl-linkbtn" onClick={() => onXRangeChange(null)} title="double-click the plot also resets">
307 reset zoom
308 </button>
309 )}
310 </div>
311 )}
312 <div style={{ position: "relative", height }}>
313 <canvas ref={baseRef} style={{ position: "absolute", inset: 0, width: "100%", height }} />
314 <canvas
315 ref={overRef}
316 style={{ position: "absolute", inset: 0, width: "100%", height, cursor: onXRangeChange ? "crosshair" : onClick ? "pointer" : "default" }}
317 onMouseMove={(e) => {
318 const { px, x, inside } = toDataX(e.clientX, e.currentTarget);
319 if (dragRef.current) {
320 dragRef.current = { ...dragRef.current, x1: px };
321 setDrag(dragRef.current);
322 }
323 onHover?.(inside ? x : null);
324 }}
325 onMouseLeave={() => onHover?.(null)}
326 onMouseDown={(e) => {
327 if (!onXRangeChange || e.button !== 0) return;
328 const { px, inside } = toDataX(e.clientX, e.currentTarget);
329 if (!inside) return;
330 dragRef.current = { x0: px, x1: px };
331 setDrag(dragRef.current);
332 }}
333 onMouseUp={(e) => {
334 const d = dragRef.current;
335 dragRef.current = null;
336 setDrag(null);
337 const { x } = toDataX(e.clientX, e.currentTarget);
338 if (d && Math.abs(d.x1 - d.x0) > 4 && onXRangeChange) {
339 const g = geom.current;
340 const toX = (px: number) => (g.xmin + ((px - MARGIN.left) / pw) * (g.xmax - g.xmin)) / xScale;
341 onXRangeChange(clampRange(toX(d.x0), toX(d.x1)));
342 } else if (onClick) {
343 onClick(x);
344 }
345 }}
346 onDoubleClick={() => onXRangeChange?.(null)}
347 />
348 </div>
349 {series.some((s) => s.label) && (
350 <div className="pl-legend">
351 {series.map((s, i) => (
352 <span key={i} style={{ ["--c" as string]: s.color ?? DEFAULT_COLORS[i % DEFAULT_COLORS.length] }}>
353 {s.label}
354 </span>
355 ))}
356 {onXRangeChange && <span className="pl-hint">drag or scroll to zoom, double-click to reset</span>}
357 </div>
358 )}
359 </div>
360 );
361}
363function unitOf(label?: string): string {
364 const m = label?.match(/\[(.*)\]\s*$/);
365 return m ? ` ${m[1]}` : "";
366}
368/** First index with x[i] >= v (x increasing). */
369function lowerBound(x: ArrayLike<number>, v: number): number {
370 let lo = 0, hi = x.length;
371 while (lo < hi) {
372 const mid = (lo + hi) >> 1;
373 if (x[mid] < v) lo = mid + 1; else hi = mid;
374 }
375 return lo;
376}
378function nearestIndex(x: ArrayLike<number>, v: number): number {
379 const n = x.length;
380 if (n === 0) return -1;
381 const i = lowerBound(x, v);
382 if (i <= 0) return 0;
383 if (i >= n) return n - 1;
384 return Math.abs(x[i] - v) < Math.abs(x[i - 1] - v) ? i : i - 1;
385}
387export function ticks(min: number, max: number, n: number): number[] {
388 const span = max - min;
389 if (!(span > 0)) return [min];
390 const raw = span / n;
391 const mag = Math.pow(10, Math.floor(Math.log10(raw)));
392 const norm = raw / mag;
393 const step = (norm < 1.5 ? 1 : norm < 3.5 ? 2 : norm < 7.5 ? 5 : 10) * mag;
394 const out: number[] = [];
395 for (let t = Math.ceil(min / step) * step; t <= max + 1e-9 * span; t += step) out.push(Number(t.toPrecision(12)));
396 return out;
397}
399function logTicks(lmin: number, lmax: number): number[] {
400 const lo = Math.ceil(lmin), hi = Math.floor(lmax);
401 if (hi < lo) return [lmin, lmax];
402 // at most about six labelled decades, so wide dynamic ranges stay legible
403 const step = Math.max(1, Math.ceil((hi - lo + 1) / 6));
404 const out: number[] = [];
405 for (let e = lo; e <= hi; e += step) out.push(e);
406 return out;
407}
409export function fmtTick(x: number): string {
410 if (x === 0) return "0";
411 const a = Math.abs(x);
412 if (a >= 1e5 || a < 1e-3) return x.toExponential(2).replace("e+", "e");
413 return String(Number(x.toPrecision(4)));
414}