/ proof-of-concept / plate-mechanics-sim
Sign in
proof-of-concept / plate-mechanics-sim
plate-mechanics-sim / packages / components / src / field / FieldView.tsx
140 lines · 4.9 KBBlameHistoryRaw
1import { useEffect, useMemo, useRef } from "react";
2import {
3 frameOf,
4 getColormap,
5 rangeOf,
6 rasterizeField,
7 symmetricRange,
8 type Field2D,
9 type FieldFrames,
10 type Range,
11} from "@plate-lab/core";
12import { Colorbar, fmt } from "./Colorbar";
14export interface FieldMarker {
15 x: number;
16 y: number;
17 label: string;
18 color?: string;
21export interface FieldViewProps {
22 /** Either a single field or a frame stack with an index */
23 field?: Field2D;
24 frames?: FieldFrames;
25 frameIndex?: number;
26 /** Physical extents [m] */
27 Lx: number;
28 Ly: number;
29 title?: string;
30 unit?: string;
31 /** Display multiplier applied to values in labels (e.g. 1e-6 for MPa) */
32 scale?: number;
33 colormap?: string;
34 /** "frame": rescale per frame; "global": one range over all frames; or a fixed range */
35 range?: "frame" | "global" | Range;
36 /** Force a symmetric range about zero (for signed fields) */
37 symmetric?: boolean;
38 markers?: FieldMarker[];
39 /** Canvas width in CSS pixels; height follows the aspect ratio */
40 width?: number;
41 caption?: string;
44/**
45 * Paints a field on the plate outline. Values pass straight from the solver
46 * through a colormap; nothing here changes them.
47 */
48export function FieldView(props: FieldViewProps) {
49 const { frames, frameIndex = 0, Lx, Ly, title, unit, scale = 1, colormap, symmetric, markers = [], width = 420, caption } = props;
50 const field: Field2D | undefined = props.field ?? (frames ? frameOf(frames, Math.min(frameIndex, frames.nf - 1)) : undefined);
51 const cmId = colormap ?? (symmetric ? "bwr" : "viridis");
53 const globalRange = useMemo(() => {
54 const r = frames ? rangeOf(frames.data) : field ? rangeOf(field.data) : { min: 0, max: 1 };
55 return symmetric ? symmetricRange(r) : r;
56 }, [frames, field, symmetric]);
58 const range: Range = useMemo(() => {
59 const mode = props.range ?? "global";
60 if (typeof mode === "object") return mode;
61 if (mode === "global" || !field) return globalRange;
62 const r = rangeOf(field.data);
63 return symmetric ? symmetricRange(r) : r;
64 }, [props.range, field, globalRange, symmetric]);
66 const canvasRef = useRef<HTMLCanvasElement>(null);
67 const offRef = useRef<HTMLCanvasElement | null>(null);
68 const pixRef = useRef<Uint8ClampedArray | undefined>(undefined);
70 const height = Math.max(60, Math.round((width * Ly) / Lx));
72 useEffect(() => {
73 const canvas = canvasRef.current;
74 if (!canvas || !field) return;
75 const ctx = canvas.getContext("2d");
76 if (!ctx) return;
77 const dpr = Math.min(2, window.devicePixelRatio || 1);
78 canvas.width = Math.round(width * dpr);
79 canvas.height = Math.round(height * dpr);
80 // Rasterize at grid resolution, then scale with smoothing onto the plate outline
81 if (!offRef.current) offRef.current = document.createElement("canvas");
82 const off = offRef.current;
83 off.width = field.nx;
84 off.height = field.ny;
85 const pix = rasterizeField(field, range, getColormap(cmId), pixRef.current);
86 pixRef.current = pix;
87 const octx = off.getContext("2d");
88 if (!octx) return;
89 octx.putImageData(new ImageData(pix as Uint8ClampedArray<ArrayBuffer>, field.nx, field.ny), 0, 0);
90 ctx.save();
91 ctx.scale(dpr, dpr);
92 ctx.clearRect(0, 0, width, height);
93 ctx.imageSmoothingEnabled = true;
94 // Grid nodes sit at cell centres of the image; map node (0..nx-1) to the full extent
95 const sx = width / (field.nx - 1);
96 const sy = height / (field.ny - 1);
97 ctx.drawImage(off, -sx / 2, -sy / 2, width + sx, height + sy);
98 ctx.strokeStyle = "#333";
99 ctx.lineWidth = 1;
100 ctx.strokeRect(0.5, 0.5, width - 1, height - 1);
101 for (const m of markers) {
102 const px = (m.x / Lx) * width;
103 const py = height - (m.y / Ly) * height;
104 ctx.beginPath();
105 ctx.arc(px, py, 5, 0, 2 * Math.PI);
106 ctx.fillStyle = "rgba(255,255,255,0.85)";
107 ctx.fill();
108 ctx.strokeStyle = m.color ?? "#000";
109 ctx.lineWidth = 1.5;
110 ctx.stroke();
111 ctx.font = "11px system-ui, sans-serif";
112 ctx.fillStyle = "#000";
113 ctx.strokeStyle = "rgba(255,255,255,0.9)";
114 ctx.lineWidth = 3;
115 ctx.strokeText(m.label, px + 8, py - 6);
116 ctx.fillText(m.label, px + 8, py - 6);
117 }
118 ctx.restore();
119 }, [field, range, cmId, width, height, markers, Lx, Ly]);
121 const peak = field ? rangeOf(field.data) : null;
123 return (
124 <div className="pl-fieldview">
125 <div className="pl-fieldview-head">
126 <strong>{title}</strong>
127 {peak && (
128 <span className="pl-muted pl-mono">
129 {fmt(peak.min * scale)} to {fmt(peak.max * scale)} {unit}
130 </span>
131 )}
132 </div>
133 <div className="pl-fieldview-body">
134 <canvas ref={canvasRef} style={{ width, height, aspectRatio: `${Lx} / ${Ly}` }} aria-label={title} />
135 <Colorbar range={range} colormap={cmId} unit={unit} scale={scale} height={height} />
136 </div>
137 {caption && <div className="pl-small pl-muted">{caption}</div>}
138 </div>
139 );
↑↓ move↵openescclose