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