1import { useEffect, useMemo, useRef, useState } from "react";
2import {
3 prepareAudio,
4 rangeOf,
5 symmetricRange,
6 type Field2D,
7 type FieldFrames,
8 type MethodDefinition,
9 type PlateResult,
10 type ProblemSpec,
11 type Range,
12} from "@plate-dyn/core";
13import {
14 AudioPlayer,
15 FieldView,
16 FramePlayer,
17 LinePlot,
18 MovieExport,
19 Plate3DView,
20 ValidityNotes,
21 useFramePlayer,
22 useSharedAxis,
23 type FieldArrow,
24 type FieldMarker,
25 type FieldRect,
26 type MovieOverlay,
27 type Plate3DHandle,
28} from "@plate-dyn/components";
30type Quantity = "w" | "w_total" | "vm" | "sp" | "env_vm" | "env_sp" | "w0" | "nvm";
32const QUANTITIES: {
33 id: Quantity;
34 label: string;
35 unit: string;
36 scale: number;
37 symmetric: boolean;
38 frames: boolean;
39}[] = [
40 {
41 id: "w",
42 label: "Flexural displacement",
43 unit: "µm",
44 scale: 1e6,
45 symmetric: true,
46 frames: true,
47 },
48 {
49 id: "w_total",
50 label: "Displacement with the rigid-body motion",
51 unit: "mm",
52 scale: 1e3,
53 symmetric: true,
54 frames: true,
55 },
56 {
57 id: "vm",
58 label: "Von Mises stress (surface)",
59 unit: "MPa",
60 scale: 1e-6,
61 symmetric: false,
62 frames: true,
63 },
64 {
65 id: "sp",
66 label: "Principal stress, signed (surface)",
67 unit: "MPa",
68 scale: 1e-6,
69 symmetric: true,
70 frames: true,
71 },
72 {
73 id: "env_vm",
74 label: "Peak von Mises over time",
75 unit: "MPa",
76 scale: 1e-6,
77 symmetric: false,
78 frames: false,
79 },
80 {
81 id: "env_sp",
82 label: "Peak |principal stress| over time",
83 unit: "MPa",
84 scale: 1e-6,
85 symmetric: false,
86 frames: false,
87 },
88 {
89 id: "w0",
90 label: "Static sag under gravity",
91 unit: "mm",
92 scale: 1e3,
93 symmetric: true,
94 frames: false,
95 },
96 {
97 id: "nvm",
98 label: "Self-weight in-plane stress (von Mises)",
99 unit: "kPa",
100 scale: 1e-3,
101 symmetric: false,
102 frames: false,
103 },
104];
106export interface ResultsViewProps {
107 result: PlateResult;
108 spec: ProblemSpec;
109 method: MethodDefinition;
110 fieldWidth?: number;
111}
113/** The plate in its mount, the fields, the rigid-body and support series, the probe, the sound. */
114export function ResultsView({ result, spec, method, fieldWidth = 460 }: ResultsViewProps) {
115 const [quantity, setQuantity] = useState<Quantity>("w");
116 const [rangeMode, setRangeMode] = useState<"global" | "frame">("global");
117 const [flexScale, setFlexScale] = useState(200);
118 const [rigidScale, setRigidScale] = useState(1);
119 const q = QUANTITIES.find((x) => x.id === quantity)!;
120 const nf = result.frames.t.length;
121 const player = useFramePlayer(nf, 30, false);
122 const startFrame = useMemo(() => peakFrame(result.frames.w), [result]);
123 const setIndex = player.setIndex;
124 useEffect(() => setIndex(startFrame), [startFrame, setIndex]);
126 const sceneRef = useRef<Plate3DHandle>(null);
127 const nvm = useMemo(() => inPlaneVonMises(result), [result]);
128 const frames: FieldFrames | undefined =
129 quantity === "w"
130 ? result.frames.w
131 : quantity === "w_total"
132 ? result.frames.w_total
133 : quantity === "vm"
134 ? result.frames.vm
135 : quantity === "sp"
136 ? result.frames.sp
137 : undefined;
138 const field: Field2D | undefined =
139 quantity === "env_vm"
140 ? result.envelope.vm
141 : quantity === "env_sp"
142 ? result.envelope.sp
143 : quantity === "w0"
144 ? result.static.w0
145 : quantity === "nvm"
146 ? nvm
147 : undefined;
148 const tFrame = result.frames.t[Math.min(player.index, nf - 1)] ?? 0;
150 // the 3D surface is coloured by the same quantity when it is a frame stack
151 const colorRange: Range | undefined = useMemo(() => {
152 if (!frames) return undefined;
153 const r = rangeOf(frames.data);
154 return q.symmetric ? symmetricRange(r) : r;
155 }, [frames, q.symmetric]);
157 const markers: FieldMarker[] = useMemo(() => {
158 const m: FieldMarker[] = [
159 {
160 x: spec.excitation.x,
161 y: spec.excitation.y,
162 label: spec.excitation.type === "impulse" ? "strike" : "pull",
163 color: "#d1495b",
164 },
165 { x: result.probe.x, y: result.probe.y, label: "probe", color: "#1f5fa8" },
166 ];
167 const mt = spec.mount;
168 if (mt.type === "thread") m.push({ x: mt.x, y: mt.y, label: "hole", color: "#2a9d8f" });
169 if (mt.type === "threads") {
170 m.push({ x: mt.x1, y: mt.y1, label: "hole 1", color: "#2a9d8f" });
171 m.push({ x: mt.x2, y: mt.y2, label: "hole 2", color: "#2a9d8f" });
172 }
173 if (mt.type === "pinched") m.push({ x: mt.x, y: mt.y, label: "fingers", color: "#2a9d8f" });
174 if (mt.type === "cymbal")
175 m.push({ x: spec.plate.Lx / 2, y: spec.plate.Ly / 2, label: "felts", color: "#2a9d8f" });
176 return m;
177 }, [spec, result.probe.x, result.probe.y]);
178 const rects: FieldRect[] = useMemo(() => {
179 const mt = spec.mount;
180 if (mt.type === "pads")
181 return mt.pads.map((p, k) => ({
182 x0: p.x - p.rx,
183 y0: p.y - p.ry,
184 x1: p.x + p.rx,
185 y1: p.y + p.ry,
186 label: `pad ${k + 1}`,
187 }));
188 if (mt.type === "vise") {
189 const { Lx, Ly } = spec.plate;
190 const d = mt.depth;
191 const r =
192 mt.edge === "x0"
193 ? { x0: 0, y0: 0, x1: d, y1: Ly }
194 : mt.edge === "x1"
195 ? { x0: Lx - d, y0: 0, x1: Lx, y1: Ly }
196 : mt.edge === "y0"
197 ? { x0: 0, y0: 0, x1: Lx, y1: d }
198 : { x0: 0, y0: Ly - d, x1: Lx, y1: Ly };
199 return [{ ...r, label: "jaws" }];
200 }
201 return [];
202 }, [spec]);
203 const arrow: FieldArrow | undefined = useMemo(() => {
204 const s = result.static;
205 if (s.g === 0) return undefined;
206 if (s.gx === 0 && s.gy === 0) return undefined;
207 return { x: s.xc, y: s.yc, dx: s.gx, dy: s.gy, label: "down" };
208 }, [result.static]);
210 const onPlotClick = (t: number) => {
211 let best = 0;
212 for (let k = 1; k < nf; k++)
213 if (Math.abs(result.frames.t[k] - t) < Math.abs(result.frames.t[best] - t)) best = k;
214 player.setIndex(best);
215 player.setPlaying(false);
216 };
218 const axis = useSharedAxis();
219 const spectrumAxis = useSharedAxis();
220 const audioT = useMemo(
221 () => Float64Array.from({ length: result.audio.p.length }, (_, i) => i / result.audio.fs),
222 [result.audio],
223 );
224 const rigidUnits = result.rigid.names.map((_, k) => (k === 0 ? "mm" : "mrad"));
225 const rigidColors = ["#1f5fa8", "#d1495b", "#2a9d8f"];
226 const supportColors = [
227 "#1f5fa8",
228 "#d1495b",
229 "#2a9d8f",
230 "#e9a300",
231 "#7b4fa6",
232 "#555",
233 "#a0522d",
234 "#008b8b",
235 ];
236 const orientation = describeOrientation(result, spec);
237 // The movie records the scene as it stands: same orientation, same colouring,
238 // the sound the player and the WAV export use, and a caption that says what
239 // is drawn and by how much it is exaggerated.
240 const audioPrep = useMemo(() => prepareAudio(result.audio.p, result.audio.fs), [result.audio]);
241 const movieSource = useMemo(
242 () => ({
243 canvas: () => sceneRef.current?.canvas() ?? null,
244 drawFrame: (k: number, size?: { width: number; height: number }) =>
245 sceneRef.current?.drawFrame(k, size),
246 }),
247 [],
248 );
249 const movieOverlay: MovieOverlay = useMemo(
250 () => ({
251 title: `${describePlate(spec)}, ${MOUNT_TEXT[spec.mount.type]}`,
252 note: `flexure ×${flexScale}, rigid motion ×${rigidScale}`,
253 legend:
254 frames && colorRange
255 ? {
256 label: q.label,
257 unit: q.unit,
258 min: colorRange.min,
259 max: colorRange.max,
260 scale: q.scale,
261 colormap: q.symmetric ? "bwr" : "viridis",
262 }
263 : undefined,
264 }),
265 [spec, flexScale, rigidScale, frames, colorRange, q],
266 );
268 return (
269 <div className="results">
270 <div className="results-toolbar sound-bar">
271 <AudioPlayer
272 p={result.audio.p}
273 fs={result.audio.fs}
274 label="sound"
275 filename={`plate-${spec.mount.type}`}
276 />
277 </div>
279 <div className="pl-scene">
280 <div className="pl-plot-title">The plate in its mount</div>
281 <Plate3DView
282 ref={sceneRef}
283 result={result}
284 spec={spec}
285 frameIndex={player.index}
286 colorFrames={frames}
287 colorRange={colorRange}
288 colormap={q.symmetric ? "bwr" : "viridis"}
289 flexScale={flexScale}
290 rigidScale={rigidScale}
291 width={fieldWidth}
292 height={Math.round(fieldWidth * 0.7)}
293 />
294 <div className="pl-scene-controls">
295 <label>
296 flexure ×{flexScale}
297 <input
298 type="range"
299 min={0}
300 max={4}
301 step={0.05}
302 value={Math.log10(Math.max(1, flexScale))}
303 onChange={(e) => setFlexScale(Math.round(10 ** Number(e.target.value)))}
304 />
305 </label>
306 <label>
307 rigid motion ×{rigidScale}
308 <input
309 type="range"
310 min={0}
311 max={2}
312 step={0.05}
313 value={Math.log10(Math.max(1, rigidScale))}
314 onChange={(e) => setRigidScale(Math.round(10 ** Number(e.target.value)))}
315 />
316 </label>
317 <span className="pl-muted">drag to orbit, wheel to zoom</span>
318 </div>
319 <MovieExport
320 source={movieSource}
321 frameTimes={result.frames.t}
322 audio={audioPrep}
323 overlay={movieOverlay}
324 aspect={0.7}
325 filename={`plate-${spec.mount.type}`}
326 restoreFrame={player.index}
327 onRecording={(on) => on && player.setPlaying(false)}
328 />
329 <div className="pl-small pl-muted">
330 {orientation} The static sag and the flexural field are exaggerated by the first factor,
331 the swing and sway by the second (1 is true scale); the colour is the quantity chosen
332 below.
333 </div>
334 </div>
336 {frames && <FramePlayer {...player} times={result.frames.t} timeScale={1} timeUnit="s" />}
338 <div className="results-toolbar">
339 <label className="pl-small pl-muted">show</label>
340 <select
341 className="pl-select"
342 style={{ width: "auto" }}
343 value={quantity}
344 onChange={(e) => setQuantity(e.target.value as Quantity)}
345 >
346 {QUANTITIES.filter((x) => x.id !== "nvm" || nvm).map((x) => (
347 <option key={x.id} value={x.id}>
348 {x.label}
349 </option>
350 ))}
351 </select>
352 {frames && (
353 <label className="pl-small pl-muted">
354 colour range{" "}
355 <select
356 className="pl-select"
357 style={{ width: "auto" }}
358 value={rangeMode}
359 onChange={(e) => setRangeMode(e.target.value as "global" | "frame")}
360 >
361 <option value="global">fixed over all frames</option>
362 <option value="frame">rescaled each frame</option>
363 </select>
364 </label>
365 )}
366 {!result.info.stable && (
367 <span className="pl-badge pl-badge-error">
368 diverged at step {result.info.diverged_step}: frames stop there
369 </span>
370 )}
371 </div>
372 <FieldView
373 frames={frames}
374 field={field}
375 frameIndex={player.index}
376 Lx={spec.plate.Lx}
377 Ly={spec.plate.Ly}
378 shape={spec.plate.shape}
379 title={q.label}
380 unit={q.unit}
381 scale={q.scale}
382 symmetric={q.symmetric}
383 range={rangeMode}
384 markers={markers}
385 rects={rects}
386 arrow={arrow}
387 width={fieldWidth}
388 caption={captionFor(quantity)}
389 />
390 {spec.plate.shape === "ellipse" && (
391 <div className="pl-small pl-muted">
392 The grid spans the box around the ellipse; the edge is a staircase of grid nodes, drawn
393 here clipped to the true outline. Stress on the outermost ring is copied from the nearest
394 node with a full stencil.
395 </div>
396 )}
398 {result.rigid.nr > 0 && (
399 <>
400 <LinePlot
401 title="Rigid-body motion about the pivot"
402 series={result.rigid.series.map((s, k) => ({
403 x: result.t,
404 y: s,
405 label: `${result.rigid.names[k]} [${rigidUnits[k]}]`,
406 color: rigidColors[k],
407 }))}
408 yScale={1e3}
409 xLabel="time [s]"
410 yLabel="mm, mrad"
411 yUnit=""
412 cursorX={tFrame}
413 onClick={onPlotClick}
414 height={170}
415 {...axis}
416 />
417 <div className="pl-small pl-muted">
418 The translation along the normal at the pivot (
419 {(result.rigid.pivot.x * 1000).toFixed(0)}, {(result.rigid.pivot.y * 1000).toFixed(0)})
420 mm in millimetres, the two rotations in milliradians; all measured from the static
421 equilibrium. Rigid-body modes of this mount:{" "}
422 {result.rigid.hz
423 .map(
424 (h, k) =>
425 `${Number.isFinite(h) ? h.toFixed(2) + " Hz" : "unstable"} (${modeName(result, k)})`,
426 )
427 .join(", ")}
428 .
429 </div>
430 </>
431 )}
433 {result.supports.names.length > 0 && (
434 <>
435 <LinePlot
436 title="Force the supports apply to the plate"
437 series={result.supports.series.map((s, k) => ({
438 x: result.t,
439 y: s,
440 label: result.supports.names[k],
441 color: supportColors[k % supportColors.length],
442 }))}
443 xLabel="time [s]"
444 yLabel="F [N]"
445 yUnit="N"
446 cursorX={tFrame}
447 onClick={onPlotClick}
448 height={150}
449 {...axis}
450 />
451 <div className="pl-small pl-muted">
452 Positive pushes the plate towards +z (a pad or the lower felt), negative pulls it back
453 (a thread's transverse spring, the upper felt). At rest:{" "}
454 {result.supports.names
455 .map((n, k) => `${n} ${result.supports.static[k].toFixed(2)} N`)
456 .join(", ")}
457 . A contact at zero has lost touch with the plate.
458 </div>
459 </>
460 )}
462 <LinePlot
463 title={`Displacement at the probe (${(result.probe.x * 1000).toFixed(0)}, ${(result.probe.y * 1000).toFixed(0)}) mm`}
464 series={[{ x: result.t, y: result.probe.w, label: "w (rigid + flexural)" }]}
465 yScale={1e6}
466 xLabel="time [s]"
467 yLabel="w [µm]"
468 yUnit="µm"
469 cursorX={tFrame}
470 onClick={onPlotClick}
471 height={170}
472 {...axis}
473 />
474 <LinePlot
475 title="Surface stress at the probe"
476 series={[
477 { x: result.t, y: result.probe.vm, label: "von Mises" },
478 { x: result.t, y: result.probe.sp, label: "principal (signed)", color: "#d1495b" },
479 ]}
480 yScale={1e-6}
481 xLabel="time [s]"
482 yLabel="σ [MPa]"
483 yUnit="MPa"
484 cursorX={tFrame}
485 onClick={onPlotClick}
486 height={170}
487 {...axis}
488 />
489 <LinePlot
490 title="Amplitude spectrum at the probe (Hann window)"
491 series={[
492 { x: result.spectrum.f, y: result.spectrum.w, label: "w [m]" },
493 {
494 x: result.spectrum.f,
495 y: result.spectrum.vm,
496 label: "von Mises [Pa]",
497 color: "#d1495b",
498 },
499 ]}
500 defaultXRange={[0, Math.min(result.info.fs / 2, 5000)]}
501 logY
502 xLabel="frequency [Hz]"
503 yLabel="amplitude"
504 height={170}
505 {...spectrumAxis}
506 />
507 <div className="pl-small pl-muted">
508 Frequency resolution {(1 / (result.t[result.t.length - 1] - result.t[0])).toFixed(2)} Hz
509 from a {(result.t[result.t.length - 1] - result.t[0]).toFixed(2)} s record. The pendulum
510 modes sit at the far left; zoom in to see them.
511 </div>
513 <div>
514 <div className="pl-plot-title">Radiated sound at the listener</div>
515 <LinePlot
516 series={[{ x: audioT, y: result.audio.p, label: "pressure" }]}
517 xLabel="time after first arrival [s]"
518 yLabel="p [Pa]"
519 yUnit="Pa"
520 height={120}
521 {...axis}
522 />
523 <div className="pl-small pl-muted">
524 {result.audio.model === "unbaffled"
525 ? "Unbaffled estimate: the back face's contribution, taken around the nearest edge, cancels the front at low frequency. "
526 : "Rayleigh integral in an infinite baffle. "}
527 Listener {spec.listener.z} m from the plate; propagation delay of the nearest patch
528 removed.
529 </div>
530 </div>
532 <details>
533 <summary className="pl-small">What this result idealizes</summary>
534 <ValidityNotes method={method} result={result} />
535 </details>
536 </div>
537 );
538}
540const MOUNT_TEXT: Record<string, string> = {
541 thread: "hanging from one thread",
542 threads: "hanging from two threads",
543 pads: "resting on pads",
544 cymbal: "on a cymbal stand",
545 vise: "one edge in a vise",
546 pinched: "pinched between fingers",
547};
549/** The stock, for the movie's title: "300 × 200 × 2 mm sheet, A36 structural steel". */
550function describePlate(spec: ProblemSpec): string {
551 const p = spec.plate;
552 const mm = (x: number) => Number((x * 1000).toPrecision(3)).toString();
553 const round = p.shape === "ellipse" && p.Lx === p.Ly;
554 const noun = p.shape === "rectangle" ? "sheet" : round ? "disk" : "ellipse";
555 const dims = round ? `${mm(p.Lx)} × ${mm(p.h)}` : `${mm(p.Lx)} × ${mm(p.Ly)} × ${mm(p.h)}`;
556 return `${dims} mm ${noun}, ${spec.material.name}`;
557}
559function captionFor(q: Quantity): string {
560 switch (q) {
561 case "w":
562 return "The flexural part of the departure from rest: the rigid-body swing and sway are taken out. Positive along the plate normal.";
563 case "w_total":
564 return "Flexural field plus the rigid-body motion, measured from the static equilibrium; the swing dominates on a hung plate.";
565 case "vm":
566 case "sp":
567 return "Surface stress at z = h/2 from the curvature of the flexural field; the opposite face carries the opposite sign.";
568 case "env_vm":
569 case "env_sp":
570 return "Peak over the saved frames, so a sample of the true peak. Surface z = h/2.";
571 case "w0":
572 return "Deflection at rest under gravity: the sag of a horizontal plate into its pads or from its clamp. A hanging plate has none.";
573 case "nvm":
574 return "Von Mises equivalent of the in-plane stress the plate's own weight puts in it, from the plane-stress solve; largest near the hole or the jaws.";
575 }
576}
578function modeName(result: PlateResult, k: number): string {
579 return result.rigid.modeNames[k] ?? `mode ${k + 1}`;
580}
582function describeOrientation(result: PlateResult, spec: ProblemSpec): string {
583 const s = result.static;
584 const m = spec.mount;
585 if (s.g === 0) return "Gravity is off.";
586 if (m.type === "thread" || m.type === "threads")
587 return `The plate hangs vertically; it is turned ${(Math.atan2(s.gx, -s.gy) * (180 / Math.PI)).toFixed(1)}° in its plane so its centre of mass is below the ${m.type === "thread" ? "hole" : "line of the holes"}.`;
588 if (m.type === "pads" || m.type === "cymbal") return "The plate lies horizontal on its supports.";
589 if (m.type === "vise")
590 return `The plate is tilted ${m.tilt}° from hanging straight down out of the jaws.`;
591 return `The plate is held at ${m.tilt}° from hanging straight below the fingers.`;
592}
594/** Von Mises equivalent of the self-weight stress resultants, per unit thickness. */
595function inPlaneVonMises(result: PlateResult): Field2D | undefined {
596 const s = result.static;
597 if (!s.Nxx || !s.Nyy || !s.Nxy) return undefined;
598 const n = s.Nxx.data.length;
599 const out = new Float64Array(n);
600 for (let i = 0; i < n; i++) {
601 const a = s.Nxx.data[i];
602 const b = s.Nyy.data[i];
603 const c = s.Nxy.data[i];
604 out[i] = Math.sqrt(a * a - a * b + b * b + 3 * c * c);
605 }
606 return { nx: s.Nxx.nx, ny: s.Nxx.ny, data: out };
607}
609/**
610 * The frame of largest flexural displacement. The player starts here so a run
611 * that has just finished shows the plate in motion.
612 */
613function peakFrame(f: FieldFrames | undefined): number {
614 if (!f) return 0;
615 const n = f.nx * f.ny;
616 let best = 0;
617 let bestv = -1;
618 for (let k = 0; k < f.nf; k++) {
619 let v = 0;
620 for (let i = k * n; i < (k + 1) * n; i++) {
621 const a = Math.abs(f.data[i]);
622 if (a > v) v = a;
623 }
624 if (v > bestv) {
625 bestv = v;
626 best = k;
627 }
628 }
629 return best;
630}