/ proof-of-concept / plate-dynamics-lab
Sign in
proof-of-concept / plate-dynamics-lab
plate-dynamics-lab / packages / components / src / plots / LinePlot.tsx
522 lines · 16.7 KBBlameHistoryRaw
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[];
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;
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;
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 {
60 series,
61 title,
62 xLabel,
63 yLabel,
64 xScale = 1,
65 yScale = 1,
66 logY,
67 defaultXRange,
68 onXRangeChange,
69 yRange,
70 cursorX,
71 hoverX,
72 onHover,
73 onClick,
74 height = 200,
75 yUnit,
76 } = props;
77 const xRange = props.xRange ?? defaultXRange ?? null;
78 const zoomed = !!props.xRange;
79 const baseRef = useRef<HTMLCanvasElement>(null);
80 const overRef = useRef<HTMLCanvasElement>(null);
81 const boxRef = useRef<HTMLDivElement>(null);
82 const geom = useRef<Geometry>({ width: 400, height, xmin: 0, xmax: 1, ymin: 0, ymax: 1 });
83 const [width, setWidth] = useState(400);
84 const [drag, setDrag] = useState<{ x0: number; x1: number } | null>(null);
85 const dragRef = useRef<{ x0: number; x1: number } | null>(null);
87 // Track the container width
88 useLayoutEffect(() => {
89 const box = boxRef.current;
90 if (!box) return;
91 const update = () => setWidth(Math.max(120, box.clientWidth));
92 update();
93 const ro = new ResizeObserver(update);
94 ro.observe(box);
95 return () => ro.disconnect();
96 }, []);
98 const pw = width - MARGIN.left - MARGIN.right;
99 const ph = height - MARGIN.top - MARGIN.bottom;
101 // Data extents in display units
102 const fullX = useRef<XRange>([0, 1]);
104 // ---- Data layer
105 useEffect(() => {
106 const canvas = baseRef.current;
107 if (!canvas) return;
108 const dpr = Math.min(2, window.devicePixelRatio || 1);
109 canvas.width = Math.round(width * dpr);
110 canvas.height = Math.round(height * dpr);
111 const ctx = canvas.getContext("2d");
112 if (!ctx) return;
113 ctx.save();
114 ctx.scale(dpr, dpr);
115 ctx.clearRect(0, 0, width, height);
117 let dxmin = Infinity,
118 dxmax = -Infinity;
119 for (const s of series) {
120 for (let i = 0; i < s.x.length; i++) {
121 const x = s.x[i] * xScale;
122 if (!Number.isFinite(x)) continue;
123 if (x < dxmin) dxmin = x;
124 if (x > dxmax) dxmax = x;
125 }
126 }
127 if (!Number.isFinite(dxmin) || dxmin === dxmax) [dxmin, dxmax] = [0, 1];
128 fullX.current = [dxmin, dxmax];
129 let xmin = dxmin,
130 xmax = dxmax;
131 if (xRange) [xmin, xmax] = [xRange[0] * xScale, xRange[1] * xScale];
132 if (xmin === xmax) xmax = xmin + 1;
134 // y extents over the visible window
135 let ymin = Infinity,
136 ymax = -Infinity;
137 for (const s of series) {
138 for (let i = 0; i < s.x.length; i++) {
139 const x = s.x[i] * xScale;
140 if (x < xmin || x > xmax) continue;
141 let y = s.y[i] * yScale;
142 if (!Number.isFinite(y)) continue;
143 if (logY) {
144 if (y <= 0) continue;
145 y = Math.log10(y);
146 }
147 if (y < ymin) ymin = y;
148 if (y > ymax) ymax = y;
149 }
150 }
151 if (yRange)
152 [ymin, ymax] = logY
153 ? [Math.log10(yRange[0] * yScale), Math.log10(yRange[1] * yScale)]
154 : [yRange[0] * yScale, yRange[1] * yScale];
155 if (!Number.isFinite(ymin)) [ymin, ymax] = [0, 1];
156 if (ymin === ymax) [ymin, ymax] = [ymin - 1, ymax + 1];
157 if (!yRange && !logY) {
158 const pad = 0.05 * (ymax - ymin);
159 ymin -= pad;
160 ymax += pad;
161 }
162 geom.current = { width, height, xmin, xmax, ymin, ymax };
163 const X = (x: number) => MARGIN.left + ((x - xmin) / (xmax - xmin)) * pw;
164 const Y = (y: number) => MARGIN.top + (1 - (y - ymin) / (ymax - ymin)) * ph;
166 ctx.font = "10px system-ui, sans-serif";
167 ctx.fillStyle = "#5b6573";
168 ctx.strokeStyle = "#e3e6ea";
169 ctx.lineWidth = 1;
170 for (const t of ticks(xmin, xmax, Math.max(3, Math.floor(pw / 70)))) {
171 const px = X(t);
172 ctx.beginPath();
173 ctx.moveTo(px, MARGIN.top);
174 ctx.lineTo(px, MARGIN.top + ph);
175 ctx.stroke();
176 ctx.textAlign = "center";
177 ctx.fillText(fmtTick(t), px, MARGIN.top + ph + 12);
178 }
179 const yt = logY ? logTicks(ymin, ymax) : ticks(ymin, ymax, 5);
180 for (const t of yt) {
181 const py = Y(t);
182 ctx.beginPath();
183 ctx.moveTo(MARGIN.left, py);
184 ctx.lineTo(MARGIN.left + pw, py);
185 ctx.stroke();
186 ctx.textAlign = "right";
187 ctx.fillText(logY ? fmtTick(Math.pow(10, t)) : fmtTick(t), MARGIN.left - 4, py + 3);
188 }
189 ctx.strokeStyle = "#8a93a0";
190 ctx.strokeRect(MARGIN.left + 0.5, MARGIN.top + 0.5, pw - 1, ph - 1);
191 if (xLabel) {
192 ctx.textAlign = "center";
193 ctx.fillText(xLabel, MARGIN.left + pw / 2, height - 4);
194 }
195 if (yLabel) {
196 ctx.save();
197 ctx.translate(10, MARGIN.top + ph / 2);
198 ctx.rotate(-Math.PI / 2);
199 ctx.textAlign = "center";
200 ctx.fillText(yLabel, 0, 0);
201 ctx.restore();
202 }
204 ctx.save();
205 ctx.beginPath();
206 ctx.rect(MARGIN.left, MARGIN.top, pw, ph);
207 ctx.clip();
208 series.forEach((s, si) => {
209 ctx.strokeStyle = s.color ?? DEFAULT_COLORS[si % DEFAULT_COLORS.length];
210 ctx.lineWidth = s.width ?? 1.2;
211 ctx.setLineDash(s.dash ?? []);
212 ctx.beginPath();
213 let pen = false;
214 const n = s.x.length;
215 // Visible index window (x assumed increasing), decimated to about two points per pixel
216 const i0 = Math.max(0, lowerBound(s.x, xmin / xScale) - 1);
217 const i1 = Math.min(n - 1, lowerBound(s.x, xmax / xScale) + 1);
218 const stride = Math.max(1, Math.floor((i1 - i0) / (pw * 2)));
219 for (let i = i0; i <= i1; i += stride) {
220 const x = s.x[i] * xScale;
221 let y = s.y[i] * yScale;
222 if (logY) y = y > 0 ? Math.log10(y) : NaN;
223 if (!Number.isFinite(x) || !Number.isFinite(y)) {
224 pen = false;
225 continue;
226 }
227 const px = X(x),
228 py = Y(y);
229 if (pen) ctx.lineTo(px, py);
230 else {
231 ctx.moveTo(px, py);
232 pen = true;
233 }
234 }
235 ctx.stroke();
236 });
237 ctx.setLineDash([]);
238 ctx.restore();
239 ctx.restore();
240 }, [series, xScale, yScale, logY, xRange, yRange, height, width, xLabel, yLabel, pw, ph]);
242 // ---- Overlay layer: cursor, crosshair with readout, drag selection
243 useEffect(() => {
244 const canvas = overRef.current;
245 if (!canvas) return;
246 const dpr = Math.min(2, window.devicePixelRatio || 1);
247 canvas.width = Math.round(width * dpr);
248 canvas.height = Math.round(height * dpr);
249 const ctx = canvas.getContext("2d");
250 if (!ctx) return;
251 ctx.save();
252 ctx.scale(dpr, dpr);
253 ctx.clearRect(0, 0, width, height);
254 const g = geom.current;
255 const X = (x: number) => MARGIN.left + ((x - g.xmin) / (g.xmax - g.xmin)) * pw;
256 const Y = (y: number) => MARGIN.top + (1 - (y - g.ymin) / (g.ymax - g.ymin)) * ph;
257 ctx.beginPath();
258 ctx.rect(MARGIN.left, MARGIN.top, pw, ph);
259 ctx.clip();
260 if (cursorX !== undefined && Number.isFinite(cursorX)) {
261 const px = X(cursorX * xScale);
262 ctx.strokeStyle = "#333";
263 ctx.lineWidth = 1;
264 ctx.beginPath();
265 ctx.moveTo(px, MARGIN.top);
266 ctx.lineTo(px, MARGIN.top + ph);
267 ctx.stroke();
268 }
269 if (hoverX !== undefined && hoverX !== null && Number.isFinite(hoverX)) {
270 const hx = hoverX * xScale;
271 if (hx >= g.xmin && hx <= g.xmax) {
272 const px = X(hx);
273 ctx.strokeStyle = "rgba(0,0,0,0.35)";
274 ctx.lineWidth = 1;
275 ctx.setLineDash([3, 3]);
276 ctx.beginPath();
277 ctx.moveTo(px, MARGIN.top);
278 ctx.lineTo(px, MARGIN.top + ph);
279 ctx.stroke();
280 ctx.setLineDash([]);
281 ctx.font = "10px system-ui, sans-serif";
282 const lines: { text: string; color: string; py: number }[] = [];
283 series.forEach((s, si) => {
284 const i = nearestIndex(s.x, hoverX);
285 if (i < 0) return;
286 const y = s.y[i] * yScale;
287 if (!Number.isFinite(y)) return;
288 const color = s.color ?? DEFAULT_COLORS[si % DEFAULT_COLORS.length];
289 const py = Y(logY ? (y > 0 ? Math.log10(y) : g.ymin) : y);
290 ctx.fillStyle = color;
291 ctx.beginPath();
292 ctx.arc(px, py, 3, 0, 2 * Math.PI);
293 ctx.fill();
294 lines.push({
295 text: `${s.label ? s.label + ": " : ""}${fmtTick(y)}${yUnit ? " " + yUnit : ""}`,
296 color,
297 py,
298 });
299 });
300 const boxW = Math.max(60, ...lines.map((l) => ctx.measureText(l.text).width + 10));
301 const right = px + 8 + boxW > MARGIN.left + pw;
302 const bx = right ? px - 8 - boxW : px + 8;
303 const by = MARGIN.top + 4;
304 const boxH = 14 * (lines.length + 1) + 4;
305 ctx.fillStyle = "rgba(255,255,255,0.92)";
306 ctx.fillRect(bx, by, boxW, boxH);
307 ctx.strokeStyle = "#c9ced5";
308 ctx.strokeRect(bx + 0.5, by + 0.5, boxW - 1, boxH - 1);
309 ctx.textAlign = "left";
310 ctx.fillStyle = "#5b6573";
311 ctx.fillText(
312 `${xLabel ? xLabel.replace(/\s*\[.*\]\s*$/, "") + " " : ""}${fmtTick(hx)}${unitOf(xLabel)}`,
313 bx + 5,
314 by + 12,
315 );
316 lines.forEach((l, k) => {
317 ctx.fillStyle = l.color;
318 ctx.fillText(l.text, bx + 5, by + 12 + 14 * (k + 1));
319 });
320 }
321 }
322 if (drag) {
323 const a = Math.min(drag.x0, drag.x1),
324 b = Math.max(drag.x0, drag.x1);
325 ctx.fillStyle = "rgba(31,95,168,0.15)";
326 ctx.fillRect(a, MARGIN.top, b - a, ph);
327 }
328 ctx.restore();
329 }, [
330 cursorX,
331 hoverX,
332 drag,
333 series,
334 xScale,
335 yScale,
336 logY,
337 width,
338 height,
339 pw,
340 ph,
341 xLabel,
342 yUnit,
343 xRange,
344 ]);
346 // ---- Interaction
347 const toDataX = useCallback(
348 (clientX: number, el: HTMLElement) => {
349 const rect = el.getBoundingClientRect();
350 const px = clientX - rect.left;
351 const g = geom.current;
352 const f = (px - MARGIN.left) / pw;
353 return { px, x: (g.xmin + f * (g.xmax - g.xmin)) / xScale, inside: f >= 0 && f <= 1 };
354 },
355 [pw, xScale],
356 );
358 const clampRange = useCallback(
359 (a: number, b: number): XRange | null => {
360 const [fmin, fmax] = fullX.current.map((v) => v / xScale) as XRange;
361 let lo = Math.max(fmin, Math.min(a, b));
362 let hi = Math.min(fmax, Math.max(a, b));
363 if (hi - lo < (fmax - fmin) * 1e-4) return null;
364 if (lo <= fmin && hi >= fmax) return null;
365 return [lo, hi];
366 },
367 [xScale],
368 );
370 // Wheel zoom needs a non-passive listener to prevent page scroll
371 useEffect(() => {
372 const el = overRef.current;
373 if (!el || !onXRangeChange) return;
374 const onWheel = (e: WheelEvent) => {
375 const { x, inside } = toDataX(e.clientX, el);
376 if (!inside) return;
377 e.preventDefault();
378 const g = geom.current;
379 const factor = Math.exp(e.deltaY * 0.0015);
380 const lo = x - (x - g.xmin / xScale) * factor;
381 const hi = x + (g.xmax / xScale - x) * factor;
382 onXRangeChange(clampRange(lo, hi));
383 };
384 el.addEventListener("wheel", onWheel, { passive: false });
385 return () => el.removeEventListener("wheel", onWheel);
386 }, [onXRangeChange, toDataX, clampRange, xScale]);
388 return (
389 <div className="pl-plot" ref={boxRef}>
390 {(title || zoomed) && (
391 <div className="pl-plot-title">
392 {title}
393 {zoomed && onXRangeChange && (
394 <button
395 className="pl-linkbtn"
396 onClick={() => onXRangeChange(null)}
397 title="double-click the plot also resets"
398 >
399 reset zoom
400 </button>
401 )}
402 </div>
403 )}
404 <div style={{ position: "relative", height }}>
405 <canvas ref={baseRef} style={{ position: "absolute", inset: 0, width: "100%", height }} />
406 <canvas
407 ref={overRef}
408 style={{
409 position: "absolute",
410 inset: 0,
411 width: "100%",
412 height,
413 cursor: onXRangeChange ? "crosshair" : onClick ? "pointer" : "default",
414 }}
415 onMouseMove={(e) => {
416 const { px, x, inside } = toDataX(e.clientX, e.currentTarget);
417 if (dragRef.current) {
418 dragRef.current = { ...dragRef.current, x1: px };
419 setDrag(dragRef.current);
420 }
421 onHover?.(inside ? x : null);
422 }}
423 onMouseLeave={() => onHover?.(null)}
424 onMouseDown={(e) => {
425 if (!onXRangeChange || e.button !== 0) return;
426 const { px, inside } = toDataX(e.clientX, e.currentTarget);
427 if (!inside) return;
428 dragRef.current = { x0: px, x1: px };
429 setDrag(dragRef.current);
430 }}
431 onMouseUp={(e) => {
432 const d = dragRef.current;
433 dragRef.current = null;
434 setDrag(null);
435 const { x } = toDataX(e.clientX, e.currentTarget);
436 if (d && Math.abs(d.x1 - d.x0) > 4 && onXRangeChange) {
437 const g = geom.current;
438 const toX = (px: number) =>
439 (g.xmin + ((px - MARGIN.left) / pw) * (g.xmax - g.xmin)) / xScale;
440 onXRangeChange(clampRange(toX(d.x0), toX(d.x1)));
441 } else if (onClick) {
442 onClick(x);
443 }
444 }}
445 onDoubleClick={() => onXRangeChange?.(null)}
446 />
447 </div>
448 {series.some((s) => s.label) && (
449 <div className="pl-legend">
450 {series.map((s, i) => (
451 <span
452 key={i}
453 style={{ ["--c" as string]: s.color ?? DEFAULT_COLORS[i % DEFAULT_COLORS.length] }}
454 >
455 {s.label}
456 </span>
457 ))}
458 {onXRangeChange && (
459 <span className="pl-hint">drag or scroll to zoom, double-click to reset</span>
460 )}
461 </div>
462 )}
463 </div>
464 );
467function unitOf(label?: string): string {
468 const m = label?.match(/\[(.*)\]\s*$/);
469 return m ? ` ${m[1]}` : "";
472/** First index with x[i] >= v (x increasing). */
473function lowerBound(x: ArrayLike<number>, v: number): number {
474 let lo = 0,
475 hi = x.length;
476 while (lo < hi) {
477 const mid = (lo + hi) >> 1;
478 if (x[mid] < v) lo = mid + 1;
479 else hi = mid;
480 }
481 return lo;
484function nearestIndex(x: ArrayLike<number>, v: number): number {
485 const n = x.length;
486 if (n === 0) return -1;
487 const i = lowerBound(x, v);
488 if (i <= 0) return 0;
489 if (i >= n) return n - 1;
490 return Math.abs(x[i] - v) < Math.abs(x[i - 1] - v) ? i : i - 1;
493export function ticks(min: number, max: number, n: number): number[] {
494 const span = max - min;
495 if (!(span > 0)) return [min];
496 const raw = span / n;
497 const mag = Math.pow(10, Math.floor(Math.log10(raw)));
498 const norm = raw / mag;
499 const step = (norm < 1.5 ? 1 : norm < 3.5 ? 2 : norm < 7.5 ? 5 : 10) * mag;
500 const out: number[] = [];
501 for (let t = Math.ceil(min / step) * step; t <= max + 1e-9 * span; t += step)
502 out.push(Number(t.toPrecision(12)));
503 return out;
506function logTicks(lmin: number, lmax: number): number[] {
507 const lo = Math.ceil(lmin),
508 hi = Math.floor(lmax);
509 if (hi < lo) return [lmin, lmax];
510 // at most about six labelled decades, so wide dynamic ranges stay legible
511 const step = Math.max(1, Math.ceil((hi - lo + 1) / 6));
512 const out: number[] = [];
513 for (let e = lo; e <= hi; e += step) out.push(e);
514 return out;
517export function fmtTick(x: number): string {
518 if (x === 0) return "0";
519 const a = Math.abs(x);
520 if (a >= 1e5 || a < 1e-3) return x.toExponential(2).replace("e+", "e");
521 return String(Number(x.toPrecision(4)));
↑↓ move↵openescclose