80c0b6dPlate dynamics lab: plates in realizable mounts under gravity, explicit finite differences on WebGPUJeremy Magland 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 PlateShape,
11 type Range,
12} from "@plate-dyn/core";
13import { Colorbar, fmt } from "./Colorbar";
15export interface FieldMarker {
16 x: number;
17 y: number;
18 label: string;
19 color?: string;
20}
22/** A rectangle drawn on the plate, in plate coordinates [m] */
23export interface FieldRect {
24 x0: number;
25 y0: number;
26 x1: number;
27 y1: number;
28 label?: string;
29 color?: string;
30}
32/** An arrow drawn from a point, in plate coordinates, with a direction in the plate's plane */
33export interface FieldArrow {
34 x: number;
35 y: number;
36 dx: number;
37 dy: number;
38 label?: string;
39 color?: string;
40}
42export interface FieldViewProps {
43 /** Either a single field or a frame stack with an index */
44 field?: Field2D;
45 frames?: FieldFrames;
46 frameIndex?: number;
47 /** Physical extents of the bounding box [m] */
48 Lx: number;
49 Ly: number;
50 /** Outline of the plate within the box; default rectangle */
51 shape?: PlateShape;
52 title?: string;
53 unit?: string;
54 /** Display multiplier applied to values in labels (e.g. 1e-6 for MPa) */
55 scale?: number;
56 colormap?: string;
57 /** "frame": rescale per frame; "global": one range over all frames; or a fixed range */
58 range?: "frame" | "global" | Range;
59 /** Force a symmetric range about zero (for signed fields) */
60 symmetric?: boolean;
61 markers?: FieldMarker[];
62 rects?: FieldRect[];
63 arrow?: FieldArrow;
64 /** Canvas width in CSS pixels; height follows the aspect ratio */
65 width?: number;
66 caption?: string;
67}
69/**
70 * Paints a field on the plate outline. Values pass straight from the solver
71 * through a colormap; nothing here changes them. Nodes the solver marked NaN
72 * (outside an elliptical plate) come out transparent, and the image is clipped
73 * to the plate's true outline.
74 */
75export function FieldView(props: FieldViewProps) {
76 const {
77 frames,
78 frameIndex = 0,
79 Lx,
80 Ly,
81 shape = "rectangle",
82 title,
83 unit,
84 scale = 1,
85 colormap,
86 symmetric,
87 markers = [],
88 rects = [],
89 arrow,
90 width = 420,
91 caption,
92 } = props;
93 const field: Field2D | undefined =
94 props.field ?? (frames ? frameOf(frames, Math.min(frameIndex, frames.nf - 1)) : undefined);
95 const cmId = colormap ?? (symmetric ? "bwr" : "viridis");
96 const ellipse = shape === "ellipse";
98 const globalRange = useMemo(() => {
99 const r = frames ? rangeOf(frames.data) : field ? rangeOf(field.data) : { min: 0, max: 1 };
100 return symmetric ? symmetricRange(r) : r;
101 }, [frames, field, symmetric]);
103 const range: Range = useMemo(() => {
104 const mode = props.range ?? "global";
105 if (typeof mode === "object") return mode;
106 if (mode === "global" || !field) return globalRange;
107 const r = rangeOf(field.data);
108 return symmetric ? symmetricRange(r) : r;
109 }, [props.range, field, globalRange, symmetric]);
111 const canvasRef = useRef<HTMLCanvasElement>(null);
112 const offRef = useRef<HTMLCanvasElement | null>(null);
113 const pixRef = useRef<Uint8ClampedArray | undefined>(undefined);
115 const height = Math.max(60, Math.round((width * Ly) / Lx));
117 useEffect(() => {
118 const canvas = canvasRef.current;
119 if (!canvas || !field) return;
120 const ctx = canvas.getContext("2d");
121 if (!ctx) return;
122 const dpr = Math.min(2, window.devicePixelRatio || 1);
123 canvas.width = Math.round(width * dpr);
124 canvas.height = Math.round(height * dpr);
125 // Rasterize at grid resolution, then scale with smoothing onto the plate outline
126 if (!offRef.current) offRef.current = document.createElement("canvas");
127 const off = offRef.current;
128 off.width = field.nx;
129 off.height = field.ny;
130 const pix = rasterizeField(field, range, getColormap(cmId), pixRef.current);
131 pixRef.current = pix;
132 const octx = off.getContext("2d");
133 if (!octx) return;
134 octx.putImageData(
135 new ImageData(pix as Uint8ClampedArray<ArrayBuffer>, field.nx, field.ny),
136 0,
137 0,
138 );
139 ctx.save();
140 ctx.scale(dpr, dpr);
141 ctx.clearRect(0, 0, width, height);
142 ctx.imageSmoothingEnabled = true;
143 const outline = () => {
144 ctx.beginPath();
145 ctx.ellipse(width / 2, height / 2, width / 2 - 0.75, height / 2 - 0.75, 0, 0, 2 * Math.PI);
146 };
147 if (ellipse) {
148 ctx.save();
149 outline();
150 ctx.clip();
151 }
152 // Grid nodes sit at cell centres of the image; map node (0..nx-1) to the full extent
153 const sx = width / (field.nx - 1);
154 const sy = height / (field.ny - 1);
155 ctx.drawImage(off, -sx / 2, -sy / 2, width + sx, height + sy);
156 if (ellipse) ctx.restore();
157 ctx.strokeStyle = "#333";
158 ctx.lineWidth = 1;
159 if (ellipse) {
160 outline();
161 ctx.stroke();
162 } else {
163 ctx.strokeRect(0.5, 0.5, width - 1, height - 1);
164 }
165 const toPx = (x: number, y: number): [number, number] => [
166 (x / Lx) * width,
167 height - (y / Ly) * height,
168 ];
169 for (const r of rects) {
170 const [x0, y0] = toPx(r.x0, r.y1);
171 const [x1, y1] = toPx(r.x1, r.y0);
172 ctx.fillStyle = "rgba(60,60,70,0.18)";
173 ctx.fillRect(x0, y0, x1 - x0, y1 - y0);
174 ctx.strokeStyle = r.color ?? "#333";
175 ctx.lineWidth = 1;
176 ctx.strokeRect(x0, y0, x1 - x0, y1 - y0);
177 if (r.label) {
178 ctx.font = "11px system-ui, sans-serif";
179 ctx.fillStyle = "#000";
180 ctx.strokeStyle = "rgba(255,255,255,0.9)";
181 ctx.lineWidth = 3;
182 ctx.strokeText(r.label, x0 + 3, y1 - 4);
183 ctx.fillText(r.label, x0 + 3, y1 - 4);
184 }
185 }
186 if (arrow && (arrow.dx !== 0 || arrow.dy !== 0)) {
187 const [x0, y0] = toPx(arrow.x, arrow.y);
188 const len = 0.18 * Math.min(width, height);
189 const n = Math.hypot(arrow.dx, arrow.dy);
190 const ux = arrow.dx / n;
191 const uy = -arrow.dy / n; // screen y is down
192 const x1 = x0 + ux * len;
193 const y1 = y0 + uy * len;
194 ctx.strokeStyle = arrow.color ?? "#d1495b";
195 ctx.fillStyle = arrow.color ?? "#d1495b";
196 ctx.lineWidth = 2;
197 ctx.beginPath();
198 ctx.moveTo(x0, y0);
199 ctx.lineTo(x1, y1);
200 ctx.stroke();
201 ctx.beginPath();
202 ctx.moveTo(x1, y1);
203 ctx.lineTo(x1 - ux * 8 - uy * 5, y1 - uy * 8 + ux * 5);
204 ctx.lineTo(x1 - ux * 8 + uy * 5, y1 - uy * 8 - ux * 5);
205 ctx.closePath();
206 ctx.fill();
207 if (arrow.label) {
208 ctx.font = "11px system-ui, sans-serif";
209 ctx.fillStyle = "#000";
210 ctx.strokeStyle = "rgba(255,255,255,0.9)";
211 ctx.lineWidth = 3;
212 ctx.strokeText(arrow.label, x1 + 6, y1 + 4);
213 ctx.fillText(arrow.label, x1 + 6, y1 + 4);
214 }
215 }
216 for (const m of markers) {
217 const [px, py] = toPx(m.x, m.y);
218 ctx.beginPath();
219 ctx.arc(px, py, 5, 0, 2 * Math.PI);
220 ctx.fillStyle = "rgba(255,255,255,0.85)";
221 ctx.fill();
222 ctx.strokeStyle = m.color ?? "#000";
223 ctx.lineWidth = 1.5;
224 ctx.stroke();
225 ctx.font = "11px system-ui, sans-serif";
226 ctx.fillStyle = "#000";
227 ctx.strokeStyle = "rgba(255,255,255,0.9)";
228 ctx.lineWidth = 3;
229 ctx.strokeText(m.label, px + 8, py - 6);
230 ctx.fillText(m.label, px + 8, py - 6);
231 }
232 ctx.restore();
233 }, [field, range, cmId, width, height, markers, rects, arrow, Lx, Ly, ellipse]);
235 const peak = field ? rangeOf(field.data) : null;
237 return (
238 <div className="pl-fieldview">
239 <div className="pl-fieldview-head">
240 <strong>{title}</strong>
241 {peak && (
242 <span className="pl-muted pl-mono">
243 {fmt(peak.min * scale)} to {fmt(peak.max * scale)} {unit}
244 </span>
245 )}
246 </div>
247 <div className="pl-fieldview-body">
248 <canvas
249 ref={canvasRef}
250 style={{
251 width,
252 height,
253 aspectRatio: `${Lx} / ${Ly}`,
254 border: ellipse ? "none" : undefined,
255 background: ellipse ? "transparent" : undefined,
256 }}
257 aria-label={title}
258 />
259 <Colorbar range={range} colormap={cmId} unit={unit} scale={scale} height={height} />
260 </div>
261 {caption && <div className="pl-small pl-muted">{caption}</div>}
262 </div>
263 );
264}