1import { useEffect, useMemo, useState } from "react";
2import type { FieldFrames, MethodDefinition, PlateResult, ProblemSpec } from "@plate-lab/core";
3import {
4 AudioPlayer,
5 FieldView,
6 FramePlayer,
7 LinePlot,
8 ValidityNotes,
9 useFramePlayer,
10 useSharedAxis,
11 type FieldMarker,
12} from "@plate-lab/components";
14type Quantity = "w" | "vm" | "sp" | "env_vm" | "env_sp";
16const QUANTITIES: {
17 id: Quantity;
18 label: string;
19 unit: string;
20 scale: number;
21 symmetric: boolean;
22}[] = [
23 { id: "w", label: "Displacement w", unit: "µm", scale: 1e6, symmetric: true },
24 { id: "vm", label: "Von Mises stress (surface)", unit: "MPa", scale: 1e-6, symmetric: false },
25 {
26 id: "sp",
27 label: "Principal stress, signed (surface)",
28 unit: "MPa",
29 scale: 1e-6,
30 symmetric: true,
31 },
32 { id: "env_vm", label: "Peak von Mises over time", unit: "MPa", scale: 1e-6, symmetric: false },
33 {
34 id: "env_sp",
35 label: "Peak |principal stress| over time",
36 unit: "MPa",
37 scale: 1e-6,
38 symmetric: false,
39 },
40];
42export interface ResultsViewProps {
43 result: PlateResult;
44 spec: ProblemSpec;
45 method: MethodDefinition;
46 fieldWidth?: number;
47}
49/** Fields, probe series, spectrum, sound and validity for one result. */
50export function ResultsView({ result, spec, method, fieldWidth = 460 }: ResultsViewProps) {
51 // Displacement first: it is the primary unknown, and the stresses below are derived from it.
52 const [quantity, setQuantity] = useState<Quantity>("w");
53 const [rangeMode, setRangeMode] = useState<"global" | "frame">("global");
54 const q = QUANTITIES.find((x) => x.id === quantity)!;
55 const nf = result.frames.t.length;
56 // The animation waits to be started; a run that finishes should not take over the screen.
57 const player = useFramePlayer(nf, 15, false);
58 // A paused animation would otherwise open on t = 0, which is a blank plate.
59 const startFrame = useMemo(() => peakFrame(result.frames.w), [result]);
60 const setIndex = player.setIndex;
61 useEffect(() => setIndex(startFrame), [startFrame, setIndex]);
62 const frames: FieldFrames | undefined =
63 quantity === "w"
64 ? result.frames.w
65 : quantity === "vm"
66 ? result.frames.vm
67 : quantity === "sp"
68 ? result.frames.sp
69 : undefined;
70 const field =
71 quantity === "env_vm"
72 ? result.envelope.vm
73 : quantity === "env_sp"
74 ? result.envelope.sp
75 : undefined;
76 const tFrame = result.frames.t[Math.min(player.index, nf - 1)] ?? 0;
78 const supportX = result.info.extra.support_x;
79 const supportY = result.info.extra.support_y;
80 const markers: FieldMarker[] = useMemo(() => {
81 const m: FieldMarker[] = [
82 { x: spec.excitation.x, y: spec.excitation.y, label: "strike", color: "#d1495b" },
83 { x: result.probe.x, y: result.probe.y, label: "probe", color: "#1f5fa8" },
84 ];
85 // a centre-pinned plate reports the node it is held at
86 if (typeof supportX === "number" && typeof supportY === "number")
87 m.push({ x: supportX, y: supportY, label: "support", color: "#2a9d8f" });
88 return m;
89 }, [spec.excitation.x, spec.excitation.y, result.probe.x, result.probe.y, supportX, supportY]);
91 const onPlotClick = (t: number) => {
92 let best = 0;
93 for (let k = 1; k < nf; k++)
94 if (Math.abs(result.frames.t[k] - t) < Math.abs(result.frames.t[best] - t)) best = k;
95 player.setIndex(best);
96 player.setPlaying(false);
97 };
99 const fNyq = result.info.fs / 2;
100 const axis = useSharedAxis();
101 const spectrumAxis = useSharedAxis();
102 const audioT = useMemo(
103 () => Float64Array.from({ length: result.audio.p.length }, (_, i) => i / result.audio.fs),
104 [result.audio],
105 );
107 return (
108 <div className="results">
109 <div className="results-toolbar sound-bar">
110 <AudioPlayer
111 p={result.audio.p}
112 fs={result.audio.fs}
113 label="sound"
114 filename={`plate-${result.method}`}
115 />
116 </div>
117 <div className="results-toolbar">
118 <label className="pl-small pl-muted">show</label>
119 <select
120 className="pl-select"
121 style={{ width: "auto" }}
122 value={quantity}
123 onChange={(e) => setQuantity(e.target.value as Quantity)}
124 >
125 {QUANTITIES.map((x) => (
126 <option key={x.id} value={x.id}>
127 {x.label}
128 </option>
129 ))}
130 </select>
131 {frames && (
132 <label className="pl-small pl-muted">
133 colour range{" "}
134 <select
135 className="pl-select"
136 style={{ width: "auto" }}
137 value={rangeMode}
138 onChange={(e) => setRangeMode(e.target.value as "global" | "frame")}
139 >
140 <option value="global">fixed over all frames</option>
141 <option value="frame">rescaled each frame</option>
142 </select>
143 </label>
144 )}
145 {!result.info.stable && (
146 <span className="pl-badge pl-badge-error">
147 diverged at step {result.info.diverged_step}: frames stop there
148 </span>
149 )}
150 </div>
151 <FieldView
152 frames={frames}
153 field={field}
154 frameIndex={player.index}
155 Lx={spec.plate.Lx}
156 Ly={spec.plate.Ly}
157 shape={spec.plate.shape}
158 title={q.label}
159 unit={q.unit}
160 scale={q.scale}
161 symmetric={q.symmetric}
162 range={rangeMode}
163 markers={markers}
164 width={fieldWidth}
165 caption={
166 quantity.startsWith("env")
167 ? "Peak over the saved frames, so a sample of the true peak. Surface z = h/2; the opposite face carries the opposite sign."
168 : quantity === "w"
169 ? "Transverse displacement; positive towards the listener."
170 : "Surface stress at z = h/2 from the curvature of w; the opposite face carries the opposite sign."
171 }
172 />
173 {spec.plate.shape === "ellipse" && result.method !== "modal" && (
174 <div className="pl-small pl-muted">
175 The solver's grid spans the box around the ellipse; the edge is a staircase of grid nodes,
176 drawn here clipped to the true outline. Stress on the outermost ring is copied from the
177 nearest node with a full stencil.
178 </div>
179 )}
180 {frames && <FramePlayer {...player} times={result.frames.t} />}
182 <LinePlot
183 title={`Displacement at the probe (${(result.probe.x * 1000).toFixed(0)}, ${(result.probe.y * 1000).toFixed(0)}) mm`}
184 series={[{ x: result.t, y: result.probe.w, label: "w" }]}
185 xScale={1000}
186 yScale={1e6}
187 xLabel="time [ms]"
188 yLabel="w [µm]"
189 yUnit="µm"
190 cursorX={tFrame}
191 onClick={onPlotClick}
192 height={170}
193 {...axis}
194 />
195 <LinePlot
196 title="Surface stress at the probe"
197 series={[
198 { x: result.t, y: result.probe.vm, label: "von Mises" },
199 { x: result.t, y: result.probe.sp, label: "principal (signed)", color: "#d1495b" },
200 ]}
201 xScale={1000}
202 yScale={1e-6}
203 xLabel="time [ms]"
204 yLabel="σ [MPa]"
205 yUnit="MPa"
206 cursorX={tFrame}
207 onClick={onPlotClick}
208 height={170}
209 {...axis}
210 />
211 <LinePlot
212 title="Amplitude spectrum at the probe (Hann window)"
213 series={[
214 { x: result.spectrum.f, y: result.spectrum.w, label: "w [m]" },
215 {
216 x: result.spectrum.f,
217 y: result.spectrum.vm,
218 label: "von Mises [Pa]",
219 color: "#d1495b",
220 },
221 ]}
222 defaultXRange={[0, Math.min(fNyq, spec.modal.fmax * 1.1)]}
223 logY
224 xLabel="frequency [Hz]"
225 yLabel="amplitude"
226 height={170}
227 {...spectrumAxis}
228 />
229 <div className="pl-small pl-muted">
230 Frequency resolution {(1 / (result.t[result.t.length - 1] - result.t[0])).toFixed(1)} Hz
231 from a {((result.t[result.t.length - 1] - result.t[0]) * 1000).toFixed(1)} ms record.
232 {result.method === "fd"
233 ? " The finite-difference record is short, so peaks are broad."
234 : ""}
235 </div>
237 <div>
238 <div className="pl-plot-title">Radiated sound at the listener</div>
239 <LinePlot
240 series={[{ x: audioT, y: result.audio.p, label: "pressure" }]}
241 xScale={1000}
242 xLabel="time after first arrival [ms]"
243 yLabel="p [Pa]"
244 yUnit="Pa"
245 height={120}
246 {...axis}
247 />
248 <div className="pl-small pl-muted">
249 Rayleigh integral, listener {spec.listener.z} m above the plate;{" "}
250 {method.supports.audio === "clip"
251 ? "only the simulated window is available, so this is a short clip."
252 : "propagation delay of the nearest patch removed."}
253 </div>
254 </div>
256 {result.modes && <ModesTable result={result} />}
258 <details>
259 <summary className="pl-small">What this result idealizes</summary>
260 <ValidityNotes method={method} result={result} />
261 </details>
262 </div>
263 );
264}
266/**
267 * The frame of largest displacement. The player starts here so a run that has just
268 * finished shows the plate in motion rather than the flat state before the strike.
269 */
270function peakFrame(f: FieldFrames | undefined): number {
271 if (!f) return 0;
272 const n = f.nx * f.ny;
273 let best = 0;
274 let bestv = -1;
275 for (let k = 0; k < f.nf; k++) {
276 let v = 0;
277 for (let i = k * n; i < (k + 1) * n; i++) {
278 const a = Math.abs(f.data[i]);
279 if (a > v) v = a;
280 }
281 if (v > bestv) {
282 bestv = v;
283 best = k;
284 }
285 }
286 return best;
287}
289function ModesTable({ result }: { result: PlateResult }) {
290 const m = result.modes!;
291 const n = Math.min(12, m.f_hz.length);
292 return (
293 <details>
294 <summary className="pl-small">
295 Modes used: {m.f_hz.length} below {result.info.extra.fmax} Hz
296 {m.truncated ? " (truncated)" : ""}
297 </summary>
298 <table className="modes-table">
299 <thead>
300 <tr>
301 <th>(m, n)</th>
302 <th>f [Hz]</th>
303 <th>ζ = σ/ω</th>
304 <th>T60 [s]</th>
305 <th>force factor G</th>
306 </tr>
307 </thead>
308 <tbody>
309 {Array.from({ length: n }, (_, k) => (
310 <tr key={k}>
311 <td>
312 ({m.m[k]}, {m.n[k]})
313 </td>
314 <td>{m.f_hz[k].toFixed(1)}</td>
315 <td>{(m.sigma[k] / (2 * Math.PI * m.f_hz[k])).toExponential(2)}</td>
316 <td>{(6.91 / m.sigma[k]).toFixed(2)}</td>
317 <td>{m.G[k].toExponential(2)}</td>
318 </tr>
319 ))}
320 </tbody>
321 </table>
322 <div className="pl-small pl-muted">
323 First {n} of {m.f_hz.length}. T60 is the time for a mode to decay by 60 dB under this
324 damping model.
325 </div>
326 </details>
327 );
328}