/ proof-of-concept / plate-mechanics-sim
Sign in
proof-of-concept / plate-mechanics-sim
plate-mechanics-sim / apps / lab / src / App.tsx
837 lines · 28.6 KBBlameHistoryRaw
1import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2import {
3 CostModel,
4 LIBRARY_FILES,
5 METHODS,
6 buildCompareBundle,
7 buildPlanBundle,
8 buildRunBundle,
9 defaultSpec,
10 getMethod,
11 methodBlockers,
12 parseCompareResult,
13 parseMethodPlan,
14 parsePlateResult,
15 validateSpec,
16 withEdits,
17 type BackendInfo,
18 type MethodDefinition,
19 type PlateResult,
20 type ProblemSpec,
21 type SourceBundle,
22} from "@plate-lab/core";
23import {
24 CodeView,
25 CompareView,
26 EquationsView,
27 FieldView,
28 ParameterPanel,
29 RunStatus,
30 useRunner,
31 useSolve,
32 type ParameterSection,
33} from "@plate-lab/components";
34import { ResultsView } from "./views/ResultsView";
35import { clearPersistentState, usePersistentState } from "./usePersistentState";
37/** A method id, or "compare" for two methods side by side */
38type Mode = string;
39type RightTab = "equations" | "source" | "run";
41/** The right column, in the order the tabs appear. Equations first: what is being solved before how. */
42const RIGHT_TABS: { id: RightTab; label: string }[] = [
43 { id: "equations", label: "Equations" },
44 { id: "source", label: "Source" },
45 { id: "run", label: "Run details" },
46];
48const IDLE = { status: "idle" } as const;
50/** What the plate is and what happens to it: the settings a first run needs. */
51const PROBLEM_SECTIONS: ParameterSection[] = ["plate", "material", "edges", "excitation"];
52/** Where things are measured and how the loss model is set: defaults are reasonable, rarely the point. */
53const ADVANCED_SECTIONS: ParameterSection[] = ["damping", "probe", "listener"];
54/** Validation paths belonging to the advanced sections, so a hidden error can still be found. */
55const ADVANCED_PREFIXES = ["damping", "probe", "listener"];
56/** Sample rate, window and frame count: numerics, but the same ones for every method. */
57const OUTPUT_SECTIONS: ParameterSection[] = ["output"];
59const SUMMARIES: Record<string, string> = Object.fromEntries(
60 LIBRARY_FILES.map((f) => [f.name, f.summary]),
61);
62SUMMARIES["main.m"] = "Generated from the controls: the params struct and the solver call";
63SUMMARIES["main_a.m"] = "Generated: first method of the comparison, matched in window and grid";
64SUMMARIES["main_b.m"] = "Generated: second method of the comparison";
65SUMMARIES["compare.m"] = "Compares the two result structs";
67/**
68 * In compare mode the two runs must cover the same window and, for a field
69 * comparison, the same grid. A grid-free method (modal) adopts the other's
70 * grid; both are cut to the shorter duration.
71 */
72function matchedSpecs(
73 spec: ProblemSpec,
74 a: MethodDefinition,
75 b: MethodDefinition,
76): [ProblemSpec, ProblemSpec] {
77 const duration = Math.min(a.durationOf(spec), b.durationOf(spec));
78 const grid = b.gridOf?.(spec) ?? a.gridOf?.(spec) ?? spec.output.grid;
79 const adapt = (m: MethodDefinition, other: MethodDefinition): ProblemSpec => {
80 let s: ProblemSpec = {
81 ...spec,
82 output: { ...spec.output, duration: m.gridOf ? spec.output.duration : duration, grid },
83 };
84 if (m.gridOf && m.durationOf(spec) !== duration) {
85 // shorten a grid-based method to the common window
86 s = { ...s, [m.settingsKey]: { ...(s[m.settingsKey] as object), duration } } as ProblemSpec;
87 }
88 if (m.gridOf && other.gridOf) {
89 const g = other.gridOf(spec);
90 if (m.gridOf(spec)[0] !== g[0] || m.gridOf(spec)[1] !== g[1]) {
91 // two grid methods: put both on b's grid so frames can be compared
92 s = {
93 ...s,
94 [m.settingsKey]: { ...(s[m.settingsKey] as object), nx: grid[0], ny: grid[1] },
95 } as ProblemSpec;
96 }
97 }
98 return s;
99 };
100 return [adapt(a, b), adapt(b, a)];
103function renameEntry(b: SourceBundle, name: string): SourceBundle {
104 return {
105 ...b,
106 entry: name,
107 files: b.files.map((f) => (f.name === b.entry ? { ...f, name } : f)),
108 };
111const isSpec = (v: unknown): v is ProblemSpec => {
112 const s = v as ProblemSpec | null;
113 return (
114 !!s && typeof s === "object" && !!s.plate && !!s.fd && !!s.listener?.patches && !!s.output?.grid
115 );
116};
117const isMode = (v: unknown): v is Mode =>
118 typeof v === "string" && (v === "compare" || METHODS.some((m) => m.id === v));
119const isPair = (v: unknown): v is [string, string] =>
120 Array.isArray(v) && v.length === 2 && v.every((x) => METHODS.some((m) => m.id === x));
121/** Older stored specs lack sections added later; fill them from the defaults. */
122const migrateSpec = (v: unknown): ProblemSpec => ({
123 ...defaultSpec(),
124 ...(v as Partial<ProblemSpec>),
125});
126const isRightTab = (v: unknown): v is RightTab => RIGHT_TABS.some((t) => t.id === v);
127const isBool = (v: unknown): v is boolean => typeof v === "boolean";
128const isEdits = (v: unknown): v is Record<string, string> =>
129 !!v && typeof v === "object" && Object.values(v as object).every((x) => typeof x === "string");
131export function App() {
132 // Everything the user chooses is remembered in this browser; "Reset to defaults" forgets it all.
133 const [storedSpec, setSpec] = usePersistentState<ProblemSpec>("spec-v2", defaultSpec, isSpec);
134 const spec = useMemo(() => migrateSpec(storedSpec), [storedSpec]);
135 const [mode, setMode] = usePersistentState<Mode>("mode", () => "modal", isMode);
136 const [pair, setPair] = usePersistentState<[string, string]>(
137 "comparePair",
138 () => ["modal", "fd"],
139 isPair,
140 );
141 const [edits, setEdits] = usePersistentState<Record<string, string>>(
142 "edits",
143 () => ({}),
144 isEdits,
145 );
146 // v3: the default view became the equations, so the old stored preference is dropped on purpose.
147 const [rightTab, setRightTab] = usePersistentState<RightTab>(
148 "rightTab-v3",
149 () => "equations",
150 isRightTab,
151 );
152 const [advancedOpen, setAdvancedOpen] = usePersistentState<boolean>(
153 "advancedOpen",
154 () => false,
155 isBool,
156 );
157 const runner = useRunner();
158 const [backend, setBackend] = useState<BackendInfo | null>(null);
159 useEffect(() => {
160 void runner.backend.then(setBackend);
161 }, [runner]);
163 const issues = useMemo(() => validateSpec(spec), [spec]);
164 const hasErrors = issues.some((i) => i.severity === "error");
165 const method = mode === "compare" ? null : getMethod(mode);
166 const methodA = getMethod(pair[0]);
167 const methodB = getMethod(pair[1]);
168 const blockers = method
169 ? methodBlockers(method, spec)
170 : methodBlockers(methodA, spec).concat(methodBlockers(methodB, spec));
171 /** The grid-based method whose plan drives the estimate and the CFL slider */
172 const planMethod: MethodDefinition | null = method
173 ? method.planFunction
174 ? method
175 : null
176 : methodB.planFunction
177 ? methodB
178 : methodA.planFunction
179 ? methodA
180 : null;
182 // Only the settings the methods in play actually read are shown; the rest would be dead controls.
183 const methodSections = useMemo<ParameterSection[]>(
184 () => [...new Set(method ? [method.settingsKey] : [methodA.settingsKey, methodB.settingsKey])],
185 [method, methodA, methodB],
186 );
188 // Source bundles: what the code view shows is what runs
189 const bundles = useMemo(() => {
190 if (mode === "compare") {
191 const [specA, specB] = matchedSpecs(spec, methodA, methodB);
192 const a = renameEntry(buildRunBundle(specA, methodA), "main_a.m");
193 const b = renameEntry(buildRunBundle(specB, methodB), "main_b.m");
194 const c = buildCompareBundle();
195 const display: SourceBundle = {
196 files: [a.files[0], b.files[0], c.files[0], ...LIBRARY_FILES],
197 entry: "compare.m",
198 resultVariable: "cmp",
199 };
200 return {
201 display,
202 runA: withEdits(a, edits),
203 runB: withEdits(b, edits),
204 runC: withEdits(c, edits),
205 };
206 }
207 const single = buildRunBundle(spec, method!);
208 return { display: single, run: withEdits(single, edits) };
209 }, [mode, spec, edits, method, methodA, methodB]);
211 const mainEdited = ["main.m", "main_a.m", "main_b.m"].some((n) => n in edits);
213 // Two result slots: the single method uses A; compare uses A and B
214 const solveA = useSolve(parsePlateResult);
215 const solveB = useSolve(parsePlateResult);
216 const solveCmp = useSolve(parseCompareResult);
217 const plan = useSolve(parseMethodPlan);
218 const cost = useRef(new CostModel());
220 // Keep the plan (step, cost, stability bound) current for the grid-based method in play
221 useEffect(() => {
222 if (!planMethod) return;
223 const t = window.setTimeout(() => {
224 const pb = buildPlanBundle(spec, planMethod);
225 if (pb) void plan.run({ bundle: withEdits(pb, edits) });
226 }, 250);
227 return () => window.clearTimeout(t);
228 // eslint-disable-next-line react-hooks/exhaustive-deps
229 }, [spec, planMethod, edits]);
230 const methodPlan = planMethod ? plan.last : null;
232 const running =
233 solveA.state.status === "running" ||
234 solveB.state.status === "running" ||
235 solveCmp.state.status === "running";
237 const observe = (
238 m: MethodDefinition,
239 r: { ok: boolean; wallMs: number; value?: unknown } | undefined,
240 ) => {
241 if (!r?.ok || !m.planFunction) return;
242 const res = parsePlateResult(r.value as never);
243 cost.current.observe(m, Number(res.info.extra.cell_steps), r.wallMs);
244 };
246 /** Identifies the inputs a result came from, so a result from other settings can say so. */
247 const stamp = useMemo(
248 () => JSON.stringify({ spec, edits, mode, pair }),
249 [spec, edits, mode, pair],
250 );
251 const [ranWith, setRanWith] = useState<{ mode: Mode; stamp: string } | null>(null);
253 const run = useCallback(async () => {
254 if (running || hasErrors || blockers.length) return;
255 // A run starts from a clean slate: the previous frames, plots and sound go away
256 // immediately, so nothing on screen is ever left over from different settings.
257 solveA.reset();
258 solveB.reset();
259 solveCmp.reset();
260 setRanWith({ mode, stamp });
261 if (method) {
262 const r = await solveA.run({ bundle: bundles.run! });
263 observe(method, r);
264 } else {
265 const a = await solveA.run({ bundle: bundles.runA! });
266 if (!a?.ok) return;
267 observe(methodA, a);
268 const b = await solveB.run({ bundle: bundles.runB! });
269 if (!b?.ok) return;
270 observe(methodB, b);
271 await solveCmp.run({ bundle: bundles.runC!, inputs: { A: a.value, B: b.value } });
272 }
273 // eslint-disable-next-line react-hooks/exhaustive-deps
274 }, [
275 running,
276 hasErrors,
277 blockers.length,
278 method,
279 methodA,
280 methodB,
281 bundles,
282 solveA,
283 solveB,
284 solveCmp,
285 mode,
286 stamp,
287 ]);
289 const cancel = () => {
290 solveA.cancel();
291 solveB.cancel();
292 solveCmp.cancel();
293 };
295 const resetAll = () => {
296 if (!window.confirm("Reset every setting and source edit to the defaults?")) return;
297 clearPersistentState();
298 setSpec(defaultSpec());
299 setMode("modal");
300 setEdits({});
301 setRightTab("equations");
302 setAdvancedOpen(false);
303 solveA.reset();
304 solveB.reset();
305 solveCmp.reset();
306 setRanWith(null);
307 };
309 useEffect(() => {
310 const onKey = (e: KeyboardEvent) => {
311 if ((e.ctrlKey || e.metaKey) && e.key === "Enter") {
312 e.preventDefault();
313 void run();
314 }
315 };
316 window.addEventListener("keydown", onKey);
317 return () => window.removeEventListener("keydown", onKey);
318 }, [run]);
320 // Results belong to the mode that produced them; switching method tabs shows that tab's own
321 // empty state rather than someone else's frames.
322 const showing = !!ranWith && ranWith.mode === mode;
323 // A comparison is three runs; whichever one failed is the state worth reporting.
324 const activeState = method
325 ? solveA.state
326 : solveA.state.status === "error"
327 ? solveA.state
328 : solveB.state.status === "error"
329 ? solveB.state
330 : solveCmp.state;
331 const currentState = showing ? activeState : IDLE;
332 const currentResult: PlateResult | null = method && showing ? solveA.last : null;
333 /** A result is on screen but the settings have moved on since it was computed. */
334 const stale =
335 !!ranWith &&
336 ranWith.mode === mode &&
337 !running &&
338 ranWith.stamp !== stamp &&
339 (currentResult !== null || solveCmp.last !== null);
340 const estimateMs =
341 planMethod && methodPlan
342 ? cost.current.estimateMs(planMethod, methodPlan.cell_steps)
343 : undefined;
344 const slow = estimateMs !== undefined && estimateMs > 5000;
345 const advancedIssue = issues.some(
346 (i) =>
347 i.severity === "error" &&
348 ADVANCED_PREFIXES.some((p) => i.path === p || i.path.startsWith(p + ".")),
349 );
350 const runLabel = method
351 ? `Run ${method.shortName}`
352 : `Run ${methodA.shortName} vs ${methodB.shortName}`;
354 return (
355 <div className="app">
356 <header className="app-header">
357 <div>
358 <h1>Plate Mechanics Lab</h1>
359 <div className="sub">
360 Vibration, stress and sound of a struck metal plate, solved by MATLAB code running in
361 your browser.
362 </div>
363 </div>
364 <div className="method-picker">
365 <span className="picker-label">Method</span>
366 <div className="mode-tabs" role="tablist">
367 {METHODS.map((m) => (
368 <button
369 key={m.id}
370 role="tab"
371 className={mode === m.id ? "active" : ""}
372 onClick={() => setMode(m.id)}
373 aria-selected={mode === m.id}
374 title={`${m.name}: ${m.summary}`}
375 >
376 {m.shortName}
377 </button>
378 ))}
379 <button
380 role="tab"
381 className={mode === "compare" ? "active" : ""}
382 onClick={() => setMode("compare")}
383 aria-selected={mode === "compare"}
384 title="Run two methods on the same problem and difference them"
385 >
386 Compare
387 </button>
388 </div>
389 </div>
390 </header>
391 <div className="app-body">
392 <aside className="col">
393 <div className="action-bar">
394 {running ? (
395 <button className="pl-btn pl-btn-danger run-btn" onClick={cancel}>
396 Cancel
397 </button>
398 ) : (
399 <button
400 className={`pl-btn pl-btn-primary run-btn${stale ? " run-btn-stale" : ""}`}
401 onClick={() => void run()}
402 disabled={hasErrors || blockers.length > 0}
403 title="Ctrl+Enter or Cmd+Enter"
404 >
405 {runLabel}
406 </button>
407 )}
408 <div className="action-meta">
409 {running && <span className="pl-badge pl-badge-info">running</span>}
410 {!running && currentState.status === "done" && (
411 <span className="pl-badge pl-badge-ok">finished</span>
412 )}
413 {!running && currentState.status === "error" && (
414 <span className="pl-badge pl-badge-error">error</span>
415 )}
416 {slow && (
417 <span className="pl-badge pl-badge-warn">
418 about {(estimateMs! / 1000).toFixed(0)} s
419 </span>
420 )}
421 {!slow && estimateMs !== undefined && (
422 <span className="pl-muted">
423 about{" "}
424 {estimateMs < 1000
425 ? `${estimateMs.toFixed(0)} ms`
426 : `${(estimateMs / 1000).toFixed(1)} s`}
427 </span>
428 )}
429 <span style={{ flex: 1 }} />
430 <button
431 className="pl-btn"
432 onClick={resetAll}
433 title="Forget every setting and source edit saved in this browser"
434 >
435 Reset to defaults
436 </button>
437 </div>
438 {hasErrors && !running && (
439 <div className="pl-issue">
440 Some settings are out of range; the groups that need attention are marked below.
441 </div>
442 )}
443 </div>
444 <div className="method-note">
445 <strong>{method ? method.name : `${methodA.name} vs ${methodB.name}`}</strong>
446 <div className="pl-muted">
447 {method
448 ? method.summary
449 : "The same problem solved twice, matched in window and grid, then differenced."}
450 </div>
451 </div>
452 {mainEdited && (
453 <div className="banner">
454 <span>
455 The generated script was edited, so the controls no longer feed the run. Keep
456 editing the source, or restore it to reconnect the controls.
457 </span>
458 <button
459 className="pl-btn"
460 onClick={() =>
461 setEdits((e) =>
462 Object.fromEntries(Object.entries(e).filter(([k]) => !k.startsWith("main"))),
463 )
464 }
465 >
466 Restore
467 </button>
468 </div>
469 )}
470 {mode === "compare" && (
471 <div className="pl-panel pair-picker">
472 <h3>Which two</h3>
473 <div className="pl-row">
474 <select
475 className="pl-select"
476 value={pair[0]}
477 onChange={(e) => setPair([e.target.value, pair[1]])}
478 aria-label="first method"
479 >
480 {METHODS.map((m) => (
481 <option key={m.id} value={m.id}>
482 {m.name}
483 </option>
484 ))}
485 </select>
486 <span className="pl-muted">against</span>
487 <select
488 className="pl-select"
489 value={pair[1]}
490 onChange={(e) => setPair([pair[0], e.target.value])}
491 aria-label="second method"
492 >
493 {METHODS.map((m) => (
494 <option key={m.id} value={m.id}>
495 {m.name}
496 </option>
497 ))}
498 </select>
499 </div>
500 <div className="pl-small pl-muted">
501 Both runs are cut to the shorter window; a grid-free method adopts the other's grid
502 so fields can be compared point by point.
503 </div>
504 </div>
505 )}
506 {blockers.map((b) => (
507 <div className="pl-note" key={b}>
508 {b}
509 </div>
510 ))}
511 <div className={mainEdited ? "pl-disabled" : undefined}>
512 <div className="panel-head">The problem</div>
513 <ParameterPanel
514 spec={spec}
515 onChange={setSpec}
516 method={method ?? undefined}
517 issues={issues}
518 sections={PROBLEM_SECTIONS}
519 open={["plate", "material", "excitation"]}
520 />
521 <div className="panel-head">Numerics</div>
522 <ParameterPanel
523 spec={spec}
524 onChange={setSpec}
525 method={method ?? undefined}
526 issues={issues}
527 fdDtMax={planMethod?.id === "fd" ? methodPlan?.dt_max : undefined}
528 sections={methodSections}
529 open={methodSections}
530 />
531 {methodPlan && planMethod && (
532 <div className="pl-small pl-muted plan-line">
533 {planMethod.shortName}: {methodPlan.nsteps.toLocaleString()} steps of{" "}
534 {(methodPlan.dt * 1e6).toPrecision(3)} µs, {methodPlan.cell_steps.toLocaleString()}{" "}
535 cell-steps.
536 </div>
537 )}
538 {/* The modal method has no grid or step of its own, so its simulated window lives here. */}
539 <ParameterPanel
540 spec={spec}
541 onChange={setSpec}
542 method={method ?? undefined}
543 issues={issues}
544 sections={OUTPUT_SECTIONS}
545 open={methodSections.includes("modal") ? OUTPUT_SECTIONS : []}
546 />
547 <details
548 className="advanced"
549 open={advancedOpen || advancedIssue}
550 onToggle={(e) => setAdvancedOpen((e.currentTarget as HTMLDetailsElement).open)}
551 >
552 <summary>
553 More settings: damping, probe, listener, output
554 {advancedIssue && (
555 <span className="pl-badge pl-badge-error" style={{ marginLeft: 6 }}>
556 check
557 </span>
558 )}
559 </summary>
560 <ParameterPanel
561 spec={spec}
562 onChange={setSpec}
563 method={method ?? undefined}
564 issues={issues}
565 sections={ADVANCED_SECTIONS}
566 open={ADVANCED_SECTIONS}
567 />
568 </details>
569 </div>
570 <div className="pl-small pl-muted">
571 Settings, source edits and the chosen method are saved in this browser. Ctrl+Enter runs.
572 </div>
573 </aside>
575 <main className="col">
576 {stale && (
577 <div className="banner">
578 <span>
579 The settings changed after this run, so what you see below is from the earlier ones.
580 </span>
581 <button className="pl-btn" onClick={() => void run()}>
582 Run again
583 </button>
584 </div>
585 )}
586 {currentState.status === "error" && (
587 <div className="pl-error">{currentState.run.error}</div>
588 )}
589 {mode !== "compare" && currentResult && method && (
590 <ResultsView result={currentResult} spec={spec} method={method} />
591 )}
592 {mode !== "compare" && !currentResult && (
593 <EmptyState status={currentState.status} label={runLabel} />
594 )}
595 {mode === "compare" && (
596 <CompareResults
597 cmp={showing ? solveCmp.last : null}
598 status={currentState.status}
599 a={showing ? solveA.last : null}
600 b={showing ? solveB.last : null}
601 labelA={methodA.name}
602 labelB={methodB.name}
603 spec={spec}
604 label={runLabel}
605 />
606 )}
607 </main>
609 <section className="col-right">
610 <div className="tabs" role="tablist">
611 {RIGHT_TABS.map((t) => (
612 <button
613 key={t.id}
614 className={rightTab === t.id ? "active" : ""}
615 onClick={() => setRightTab(t.id)}
616 role="tab"
617 aria-selected={rightTab === t.id}
618 >
619 {t.label}
620 </button>
621 ))}
622 </div>
623 {rightTab === "equations" && (
624 <div className="tab-body">
625 <div className="pl-small pl-muted tab-intro">
626 What {mode === "compare" ? methodB.name.toLowerCase() : "this method"} discretizes
627 and solves. Read the code that does it under Source.
628 </div>
629 <EquationsView equations={(method ?? methodB).equations} />
630 {mode === "compare" && (
631 <div className="pl-small pl-muted">
632 Shown: {methodB.name}. The other method's equations are under its own tab.
633 </div>
634 )}
635 </div>
636 )}
637 {rightTab === "source" && (
638 <CodeView
639 files={bundles.display.files}
640 edits={edits}
641 onEditsChange={setEdits}
642 order={
643 mode === "compare"
644 ? [
645 "main_a.m",
646 "main_b.m",
647 "compare.m",
648 "plate_compare.m",
649 ...methodA.readingOrder,
650 ...methodB.readingOrder,
651 ]
652 : ["main.m", ...(method?.readingOrder ?? [])]
653 }
654 summaries={SUMMARIES}
655 height="100%"
656 />
657 )}
658 {rightTab === "run" && (
659 <div className="tab-body" style={{ display: "flex", flexDirection: "column", gap: 12 }}>
660 {method && (
661 <RunStatus
662 state={currentState}
663 result={currentResult}
664 method={method}
665 backend={backend}
666 estimateMs={estimateMs}
667 onCancel={cancel}
668 />
669 )}
670 {mode === "compare" && (
671 <>
672 <h3 className="pl-small pl-muted">{methodA.name}</h3>
673 <RunStatus
674 state={showing ? solveA.state : IDLE}
675 result={showing ? solveA.last : null}
676 method={methodA}
677 backend={backend}
678 onCancel={cancel}
679 />
680 <h3 className="pl-small pl-muted">{methodB.name}</h3>
681 <RunStatus
682 state={showing ? solveB.state : IDLE}
683 result={showing ? solveB.last : null}
684 method={methodB}
685 backend={backend}
686 estimateMs={estimateMs}
687 onCancel={cancel}
688 />
689 <h3 className="pl-small pl-muted">Comparison</h3>
690 <RunStatus
691 state={showing ? solveCmp.state : IDLE}
692 backend={backend}
693 onCancel={cancel}
694 />
695 </>
696 )}
697 </div>
698 )}
699 </section>
700 </div>
701 </div>
702 );
705/** What the middle column says before anything has been solved. */
706function EmptyState({ status, label }: { status: string; label: string }) {
707 if (status === "running") return <div className="empty">Solving…</div>;
708 if (status === "error")
709 return (
710 <div className="empty">
711 The run failed. The message is above, and the full log is under Run details.
712 </div>
713 );
714 return (
715 <div className="empty">
716 <h2>Nothing solved yet</h2>
717 <ol>
718 <li>Set the plate, material and strike on the left.</li>
719 <li>
720 Press <strong>{label}</strong>, or Ctrl+Enter.
721 </li>
722 <li>Displacement, stress, the probe traces and the radiated sound appear here.</li>
723 </ol>
724 <p className="pl-muted">
725 The equations for the chosen method are on the right; Source shows the MATLAB that runs, and
726 you can edit it.
727 </p>
728 </div>
729 );
732function CompareResults({
733 cmp,
734 status,
735 a: modal,
736 b: fd,
737 labelA,
738 labelB,
739 spec,
740 label,
741}: {
742 cmp: ReturnType<typeof parseCompareResult> | null;
743 status: string;
744 a: PlateResult | null;
745 b: PlateResult | null;
746 labelA: string;
747 labelB: string;
748 spec: ProblemSpec;
749 label: string;
750}) {
751 const [k, setK] = useState(0);
752 if (!cmp || !modal || !fd) {
753 if (status === "running") return <div className="empty">Solving both methods…</div>;
754 return <EmptyState status={status} label={label} />;
755 }
756 const nf = Math.min(modal.frames.t.length, fd.frames.t.length);
757 const idx = Math.min(k, nf - 1);
758 const la = labelA.toLowerCase();
759 const lb = labelB.toLowerCase();
760 return (
761 <div className="results">
762 <CompareView cmp={cmp} labelA={la} labelB={lb} />
763 <div className="pl-row pl-small">
764 <span>Frame</span>
765 <input
766 type="range"
767 min={0}
768 max={Math.max(0, nf - 1)}
769 value={idx}
770 onChange={(e) => setK(Number(e.target.value))}
771 style={{ flex: 1 }}
772 />
773 <span className="pl-mono">{(modal.frames.t[idx] * 1000).toFixed(2)} ms</span>
774 </div>
775 <div className="two-up">
776 <FieldView
777 frames={modal.frames.w}
778 frameIndex={idx}
779 Lx={spec.plate.Lx}
780 Ly={spec.plate.Ly}
781 title={`w, ${la}`}
782 unit="µm"
783 scale={1e6}
784 symmetric
785 width={300}
786 />
787 <FieldView
788 frames={fd.frames.w}
789 frameIndex={idx}
790 Lx={spec.plate.Lx}
791 Ly={spec.plate.Ly}
792 title={`w, ${lb}`}
793 unit="µm"
794 scale={1e6}
795 symmetric
796 width={300}
797 />
798 </div>
799 {cmp.frames_diff && (
800 <FieldView
801 frames={cmp.frames_diff}
802 frameIndex={idx}
803 Lx={spec.plate.Lx}
804 Ly={spec.plate.Ly}
805 title={`difference, ${la} − ${lb}`}
806 unit="µm"
807 scale={1e6}
808 symmetric
809 width={300}
810 caption="Same colour convention; the range is that of the difference itself."
811 />
812 )}
813 <div className="two-up">
814 <FieldView
815 frames={modal.frames.vm}
816 frameIndex={idx}
817 Lx={spec.plate.Lx}
818 Ly={spec.plate.Ly}
819 title={`von Mises, ${la}`}
820 unit="MPa"
821 scale={1e-6}
822 width={300}
823 />
824 <FieldView
825 frames={fd.frames.vm}
826 frameIndex={idx}
827 Lx={spec.plate.Lx}
828 Ly={spec.plate.Ly}
829 title={`von Mises, ${lb}`}
830 unit="MPa"
831 scale={1e-6}
832 width={300}
833 />
834 </div>
835 </div>
836 );
↑↓ move↵openescclose