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