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 markers: FieldMarker[] = useMemo(
79 () => [
80 { x: spec.excitation.x, y: spec.excitation.y, label: "strike", color: "#d1495b" },
81 { x: result.probe.x, y: result.probe.y, label: "probe", color: "#1f5fa8" },
82 ],
83 [spec.excitation.x, spec.excitation.y, result.probe.x, result.probe.y],
84 );
86 const onPlotClick = (t: number) => {
87 let best = 0;
88 for (let k = 1; k < nf; k++)
89 if (Math.abs(result.frames.t[k] - t) < Math.abs(result.frames.t[best] - t)) best = k;
90 player.setIndex(best);
91 player.setPlaying(false);
92 };
94 const fNyq = result.info.fs / 2;
95 const axis = useSharedAxis();
96 const spectrumAxis = useSharedAxis();
97 const audioT = useMemo(
98 () => Float64Array.from({ length: result.audio.p.length }, (_, i) => i / result.audio.fs),
99 [result.audio],
100 );
102 return (
103 <div className="results">
104 <div className="results-toolbar sound-bar">
105 <AudioPlayer p={result.audio.p} fs={result.audio.fs} label="sound" />
106 </div>
107 <div className="results-toolbar">
108 <label className="pl-small pl-muted">show</label>
109 <select
110 className="pl-select"
111 style={{ width: "auto" }}
112 value={quantity}
113 onChange={(e) => setQuantity(e.target.value as Quantity)}
114 >
115 {QUANTITIES.map((x) => (
116 <option key={x.id} value={x.id}>
117 {x.label}
118 </option>
119 ))}
120 </select>
121 {frames && (
122 <label className="pl-small pl-muted">
123 colour range{" "}
124 <select
125 className="pl-select"
126 style={{ width: "auto" }}
127 value={rangeMode}
128 onChange={(e) => setRangeMode(e.target.value as "global" | "frame")}
129 >
130 <option value="global">fixed over all frames</option>
131 <option value="frame">rescaled each frame</option>
132 </select>
133 </label>
134 )}
135 {!result.info.stable && (
136 <span className="pl-badge pl-badge-error">
137 diverged at step {result.info.diverged_step}: frames stop there
138 </span>
139 )}
140 </div>
141 <FieldView
142 frames={frames}
143 field={field}
144 frameIndex={player.index}
145 Lx={spec.plate.Lx}
146 Ly={spec.plate.Ly}
147 title={q.label}
148 unit={q.unit}
149 scale={q.scale}
150 symmetric={q.symmetric}
151 range={rangeMode}
152 markers={markers}
153 width={fieldWidth}
154 caption={
155 quantity.startsWith("env")
156 ? "Peak over the saved frames, so a sample of the true peak. Surface z = h/2; the opposite face carries the opposite sign."
157 : quantity === "w"
158 ? "Transverse displacement; positive towards the listener."
159 : "Surface stress at z = h/2 from the curvature of w; the opposite face carries the opposite sign."
160 }
161 />
162 {frames && <FramePlayer {...player} times={result.frames.t} />}
164 <LinePlot
165 title={`Displacement at the probe (${(result.probe.x * 1000).toFixed(0)}, ${(result.probe.y * 1000).toFixed(0)}) mm`}
166 series={[{ x: result.t, y: result.probe.w, label: "w" }]}
167 xScale={1000}
168 yScale={1e6}
169 xLabel="time [ms]"
170 yLabel="w [µm]"
171 yUnit="µm"
172 cursorX={tFrame}
173 onClick={onPlotClick}
174 height={170}
175 {...axis}
176 />
177 <LinePlot
178 title="Surface stress at the probe"
179 series={[
180 { x: result.t, y: result.probe.vm, label: "von Mises" },
181 { x: result.t, y: result.probe.sp, label: "principal (signed)", color: "#d1495b" },
182 ]}
183 xScale={1000}
184 yScale={1e-6}
185 xLabel="time [ms]"
186 yLabel="σ [MPa]"
187 yUnit="MPa"
188 cursorX={tFrame}
189 onClick={onPlotClick}
190 height={170}
191 {...axis}
192 />
193 <LinePlot
194 title="Amplitude spectrum at the probe (Hann window)"
195 series={[
196 { x: result.spectrum.f, y: result.spectrum.w, label: "w [m]" },
197 {
198 x: result.spectrum.f,
199 y: result.spectrum.vm,
200 label: "von Mises [Pa]",
201 color: "#d1495b",
202 },
203 ]}
204 defaultXRange={[0, Math.min(fNyq, spec.modal.fmax * 1.1)]}
205 logY
206 xLabel="frequency [Hz]"
207 yLabel="amplitude"
208 height={170}
209 {...spectrumAxis}
210 />
211 <div className="pl-small pl-muted">
212 Frequency resolution {(1 / (result.t[result.t.length - 1] - result.t[0])).toFixed(1)} Hz
213 from a {((result.t[result.t.length - 1] - result.t[0]) * 1000).toFixed(1)} ms record.
214 {result.method === "fd"
215 ? " The finite-difference record is short, so peaks are broad."
216 : ""}
217 </div>
219 <div>
220 <div className="pl-plot-title">Radiated sound at the listener</div>
221 <LinePlot
222 series={[{ x: audioT, y: result.audio.p, label: "pressure" }]}
223 xScale={1000}
224 xLabel="time after first arrival [ms]"
225 yLabel="p [Pa]"
226 yUnit="Pa"
227 height={120}
228 {...axis}
229 />
230 <div className="pl-small pl-muted">
231 Rayleigh integral, listener {spec.listener.z} m above the plate;{" "}
232 {method.supports.audio === "clip"
233 ? "only the simulated window is available, so this is a short clip."
234 : "propagation delay of the nearest patch removed."}
235 </div>
236 </div>
238 {result.modes && <ModesTable result={result} />}
240 <details>
241 <summary className="pl-small">What this result idealizes</summary>
242 <ValidityNotes method={method} result={result} />
243 </details>
244 </div>
245 );
246}
248/**
249 * The frame of largest displacement. The player starts here so a run that has just
250 * finished shows the plate in motion rather than the flat state before the strike.
251 */
252function peakFrame(f: FieldFrames | undefined): number {
253 if (!f) return 0;
254 const n = f.nx * f.ny;
255 let best = 0;
256 let bestv = -1;
257 for (let k = 0; k < f.nf; k++) {
258 let v = 0;
259 for (let i = k * n; i < (k + 1) * n; i++) {
260 const a = Math.abs(f.data[i]);
261 if (a > v) v = a;
262 }
263 if (v > bestv) {
264 bestv = v;
265 best = k;
266 }
267 }
268 return best;
269}
271function ModesTable({ result }: { result: PlateResult }) {
272 const m = result.modes!;
273 const n = Math.min(12, m.f_hz.length);
274 return (
275 <details>
276 <summary className="pl-small">
277 Modes used: {m.f_hz.length} below {result.info.extra.fmax} Hz
278 {m.truncated ? " (truncated)" : ""}
279 </summary>
280 <table className="modes-table">
281 <thead>
282 <tr>
283 <th>(m, n)</th>
284 <th>f [Hz]</th>
285 <th>ζ = σ/ω</th>
286 <th>T60 [s]</th>
287 <th>force factor G</th>
288 </tr>
289 </thead>
290 <tbody>
291 {Array.from({ length: n }, (_, k) => (
292 <tr key={k}>
293 <td>
294 ({m.m[k]}, {m.n[k]})
295 </td>
296 <td>{m.f_hz[k].toFixed(1)}</td>
297 <td>{(m.sigma[k] / (2 * Math.PI * m.f_hz[k])).toExponential(2)}</td>
298 <td>{(6.91 / m.sigma[k]).toFixed(2)}</td>
299 <td>{m.G[k].toExponential(2)}</td>
300 </tr>
301 ))}
302 </tbody>
303 </table>
304 <div className="pl-small pl-muted">
305 First {n} of {m.f_hz.length}. T60 is the time for a mode to decay by 60 dB under this
306 damping model.
307 </div>
308 </details>
309 );
310}