Split into home / problem / about pages; minimal landing with problems list
7 changed files+661−378
scripts/check-app.mjsmodified+10−6View file
@@ -61,14 +61,18 @@ try {
6161 await page.goto(`http://127.0.0.1:${port}/`, { waitUntil: "networkidle2" });
6262 await page.waitForSelector("h1");
6363
64- // The empty-state or chart should be present.
65- const hasChartArea = await page.evaluate(
66- () => document.body.innerText.includes("Work-precision results")
67- );
68- if (!hasChartArea) {
69- console.error("FAIL: work-precision section missing");
64+ // Home: the problem list with a clickable card.
65+ const card = await page.$('a[href="#/problem/laplace-dirichlet-2d"]');
66+ if (!card) {
67+ console.error("FAIL: problem card missing on home page");
7068 failures++;
69+ } else {
70+ await card.click();
7171 }
72+ await page.waitForFunction(
73+ () => document.body.innerText.includes("Work-precision results"),
74+ { timeout: 30000 }
75+ );
7276
7377 // One real solve through the worker: the Solution section's compute
7478 // button, default solver (mfs) at its default n.
src/app/App.tsxmodified+45−372View file
@@ -1,389 +1,62 @@
1-import { useEffect, useMemo, useState } from "react";
2-import {
3- INSTANCES,
4- getInstance,
5- PROBLEM_ID,
6-} from "../problems/laplace2d/spec";
7-import { SOLVERS, getSolver } from "../solvers";
8-import {
9- buildResultFile,
10- type ResultFile,
11- type ResultPoint,
12-} from "../harness/resultSchema";
13-import {
14- environmentLabel,
15- fetchCommittedResults,
16- isResultFile,
17- RESULTS_REPO_URL,
18-} from "./results";
19-import { solverColorVar } from "./colors";
20-import { sweepInBrowser } from "./workerClient";
21-import { WorkPrecisionChart, type ChartCurve } from "./components/WorkPrecisionChart";
22-import { PointsTable } from "./components/PointsTable";
23-import { DomainView } from "./components/DomainView";
24-import { SolutionSection } from "./components/SolutionSection";
1+import { useEffect, useState } from "react";
2+import { HomePage } from "./pages/HomePage";
3+import { AboutPage } from "./pages/AboutPage";
4+import { ProblemPage } from "./pages/ProblemPage";
255
266 const REPO_URL = "https://github.com/concept-collection/fastandaccurate";
27-const SPEC_URL = `${REPO_URL}/blob/main/docs/problems/laplace-dirichlet-2d.md`;
287
29-interface LocalRun {
30- key: string;
31- solverId: string;
32- instanceId: string;
33- repeats: number;
34- points: ResultPoint[];
35- done: boolean;
8+type Route =
9+ | { page: "home" }
10+ | { page: "about" }
11+ | { page: "problem"; problemId: string };
12+
13+function parseRoute(hash: string): Route {
14+ const path = hash.replace(/^#/, "");
15+ if (path === "/about") return { page: "about" };
16+ const m = path.match(/^\/problem\/([a-z0-9-]+)$/);
17+ if (m) return { page: "problem", problemId: m[1] };
18+ return { page: "home" };
3619 }
3720
38-export function App() {
39- const [instanceId, setInstanceId] = useState(INSTANCES[1].id);
40- const [committed, setCommitted] = useState<ResultFile[] | null>(null);
41- const [committedError, setCommittedError] = useState<string | null>(null);
42- const [loaded, setLoaded] = useState<ResultFile[]>([]);
43- const [localRuns, setLocalRuns] = useState<LocalRun[]>([]);
44- const [hidden, setHidden] = useState<Set<string>>(new Set());
45- const [running, setRunning] = useState<string | null>(null);
46- const [runStatus, setRunStatus] = useState<string | null>(null);
47- const [repeats, setRepeats] = useState(3);
48- const [machineLabel, setMachineLabel] = useState("");
49-
50- const inst = getInstance(instanceId);
51-
21+function useRoute(): Route {
22+ const [route, setRoute] = useState<Route>(() => parseRoute(location.hash));
5223 useEffect(() => {
53- fetchCommittedResults()
54- .then(setCommitted)
55- .catch((err) =>
56- setCommittedError(err instanceof Error ? err.message : String(err))
57- );
24+ const onChange = () => {
25+ setRoute(parseRoute(location.hash));
26+ window.scrollTo(0, 0);
27+ };
28+ window.addEventListener("hashchange", onChange);
29+ return () => window.removeEventListener("hashchange", onChange);
5830 }, []);
31+ return route;
32+}
5933
60- const allSolverIds = useMemo(() => {
61- const ids = new Set<string>(SOLVERS.map((s) => s.id));
62- committed?.forEach((r) => ids.add(r.solver.id));
63- loaded.forEach((r) => ids.add(r.solver.id));
64- return [...ids];
65- }, [committed, loaded]);
66-
67- const curves: ChartCurve[] = useMemo(() => {
68- const out: ChartCurve[] = [];
69- const color = (id: string) => solverColorVar(id, allSolverIds);
70- committed
71- ?.filter((r) => r.problem === PROBLEM_ID && r.instance === instanceId)
72- .forEach((r, i) => {
73- out.push({
74- key: `committed:${i}`,
75- solverId: r.solver.id,
76- label: `${r.solver.id} — ${environmentLabel(r)}`,
77- color: color(r.solver.id),
78- points: r.points,
79- });
80- });
81- loaded
82- .filter((r) => r.problem === PROBLEM_ID && r.instance === instanceId)
83- .forEach((r, i) => {
84- out.push({
85- key: `loaded:${i}`,
86- solverId: r.solver.id,
87- label: `${r.solver.id} — ${environmentLabel(r)} (loaded)`,
88- color: color(r.solver.id),
89- dash: "8 4",
90- points: r.points,
91- });
92- });
93- localRuns
94- .filter((r) => r.instanceId === instanceId)
95- .forEach((r) => {
96- out.push({
97- key: r.key,
98- solverId: r.solverId,
99- label: `${r.solverId} — this browser`,
100- color: color(r.solverId),
101- dash: "4 4",
102- open: true,
103- points: r.points,
104- });
105- });
106- return out.filter((c) => !hidden.has(c.solverId));
107- }, [committed, loaded, localRuns, instanceId, hidden, allSolverIds]);
108-
109- async function runSolver(solverId: string) {
110- const key = `local:${solverId}:${instanceId}:${Date.now()}`;
111- setLocalRuns((rs) => [
112- ...rs.filter((r) => !(r.solverId === solverId && r.instanceId === instanceId)),
113- { key, solverId, instanceId, repeats, points: [], done: false },
114- ]);
115- setRunning(solverId);
116- try {
117- const points = await sweepInBrowser(
118- instanceId,
119- solverId,
120- repeats,
121- (point, index, total) => {
122- setRunStatus(
123- `${solverId} on ${instanceId}: point ${index + 1}/${total} (n = ${point.n}) — rel max error ${point.relMax.toExponential(2)}`
124- );
125- setLocalRuns((rs) =>
126- rs.map((r) => (r.key === key ? { ...r, points: [...r.points, point] } : r))
127- );
128- }
129- );
130- setLocalRuns((rs) =>
131- rs.map((r) => (r.key === key ? { ...r, points, done: true } : r))
132- );
133- setRunStatus(null);
134- } catch (err) {
135- setRunStatus(
136- `${solverId} failed: ${err instanceof Error ? err.message : String(err)}`
137- );
138- } finally {
139- setRunning(null);
140- }
141- }
142-
143- async function downloadRun(run: LocalRun) {
144- const manifest = getSolver(run.solverId);
145- const result = await buildResultFile({
146- instance: getInstance(run.instanceId),
147- solver: {
148- id: manifest.id,
149- version: manifest.version,
150- backend: manifest.backend,
151- source: "builtin",
152- },
153- environment: {
154- kind: "browser",
155- runtime: navigator.userAgent,
156- numblVersion: __NUMBL_VERSION__,
157- machineLabel: machineLabel || undefined,
158- browserReproducible: true,
159- },
160- repeats: run.repeats,
161- points: run.points,
162- });
163- const blob = new Blob([JSON.stringify(result, null, 2) + "\n"], {
164- type: "application/json",
165- });
166- const a = document.createElement("a");
167- a.href = URL.createObjectURL(blob);
168- a.download = `${PROBLEM_ID}.${run.instanceId}.${run.solverId}.browser.json`;
169- a.click();
170- URL.revokeObjectURL(a.href);
171- }
172-
173- function loadFiles(files: FileList | null) {
174- if (!files) return;
175- for (const file of Array.from(files)) {
176- file.text().then((text) => {
177- try {
178- const data: unknown = JSON.parse(text);
179- if (isResultFile(data)) {
180- setLoaded((ls) => [...ls, data]);
181- } else {
182- alert(`${file.name} is not a fastandaccurate result file`);
183- }
184- } catch {
185- alert(`${file.name}: not valid JSON`);
186- }
187- });
188- }
189- }
34+export function App() {
35+ const route = useRoute();
19036
191- const cliUrl = `https://concept-collection.github.io/fastandaccurate/cli.tgz?v=${__BUILD_ID__}`;
37+ useEffect(() => {
38+ document.title =
39+ route.page === "about"
40+ ? "About — fastandaccurate"
41+ : route.page === "problem"
42+ ? `${route.problemId} — fastandaccurate`
43+ : "fastandaccurate — PDE solver benchmarks";
44+ }, [route]);
19245
19346 return (
19447 <main>
195- <h1>fastandaccurate</h1>
196- <p className="subtitle">Speed and accuracy benchmarks for PDE solvers</p>
197- <p>
198- Each <strong>problem</strong> here is posed in the continuum, with an
199- exact reference solution; a solver chooses its own discretization and
200- is scored at problem-specified evaluation points. The central object
201- is the <strong>work-precision curve</strong>: error against compute
202- time as the solver's resolution varies. There is deliberately no
203- single ranking; which curve is best can differ by accuracy regime,
204- instance, and machine. Results shown here are committed to a public{" "}
205- <a href={RESULTS_REPO_URL}>results repository</a> by pull request, and
206- any in-browser solver can be rerun on your own machine, right on this
207- page, to check them.
208- </p>
209- <p className="small muted">
210- <a href={REPO_URL}>Source</a> · <a href={SPEC_URL}>Problem specification</a> ·{" "}
211- <a href={RESULTS_REPO_URL}>Results repository</a> · Solvers run in
212- MATLAB syntax via <a href="https://numbl.org">numbl</a>, client side.
213- </p>
214-
215- <h2>The problem: laplace-dirichlet-2d</h2>
216- <p>
217- Solve Δu = 0 on the star-shaped domain with boundary
218- r(θ) = 1 + a·cos(kθ), with Dirichlet data u = g on the boundary. The
219- data comes from an exact harmonic function, a sum of three logarithmic
220- point sources placed a distance d outside the boundary, so errors are
221- measured against the true solution, not a reference computation. The
222- distance d sets the difficulty: the closer the sources, the shorter
223- the distance the data continues harmonically past the boundary, and
224- methods whose representations assume that continuation lose it. A
225- solver receives the curve (with derivatives), the boundary data as a
226- function of the boundary parameter, and the evaluation points, and
227- returns solution values at those points; reported time is the whole
228- solve including the solver's own discretization (median of repeats
229- after one untimed warmup). The precise statement, interface, and
230- protocol are in the <a href={SPEC_URL}>specification</a>.
231- </p>
232- <div className="row" style={{ marginTop: 14 }}>
233- <div>
234- <div style={{ marginBottom: 10 }}>
235- <label>
236- instance{" "}
237- <select
238- value={instanceId}
239- onChange={(e) => setInstanceId(e.target.value)}
240- >
241- {INSTANCES.map((i) => (
242- <option key={i.id} value={i.id}>
243- {i.id} — {i.label}
244- </option>
245- ))}
246- </select>
247- </label>
248- </div>
249- <p className="small muted" style={{ maxWidth: 380 }}>
250- {inst.description}
251- </p>
252- <table className="data">
253- <tbody>
254- <tr>
255- <th className="left">a</th>
256- <td>{inst.a}</td>
257- <th className="left">k</th>
258- <td>{inst.k}</td>
259- <th className="left">d</th>
260- <td>{inst.d}</td>
261- </tr>
262- </tbody>
263- </table>
264- </div>
265- <DomainView inst={inst} />
266- </div>
267-
268- <h2>Work-precision results</h2>
269- {committedError && (
270- <p className="small muted">
271- Committed results could not be loaded ({committedError}); showing
272- local runs only.
273- </p>
48+ {route.page !== "home" && (
49+ <nav className="topnav">
50+ <a href="#/">fastandaccurate</a>
51+ </nav>
27452 )}
275- <WorkPrecisionChart curves={curves} />
276- <div className="row" style={{ marginTop: 14, alignItems: "center" }}>
277- {SOLVERS.map((s) => (
278- <span key={s.id} style={{ whiteSpace: "nowrap" }}>
279- <label>
280- <input
281- type="checkbox"
282- checked={!hidden.has(s.id)}
283- onChange={(e) => {
284- setHidden((h) => {
285- const next = new Set(h);
286- if (e.target.checked) next.delete(s.id);
287- else next.add(s.id);
288- return next;
289- });
290- }}
291- />{" "}
292- <span
293- className="legend-swatch"
294- style={{ background: solverColorVar(s.id, allSolverIds) }}
295- />
296- {s.name}
297- </label>{" "}
298- <button
299- onClick={() => runSolver(s.id)}
300- disabled={running !== null}
301- title={`Run the full ${s.id} sweep on ${instanceId} in this browser`}
302- >
303- {running === s.id ? "running…" : "Run in this browser"}
304- </button>
305- </span>
306- ))}
307- <label>
308- repeats{" "}
309- <select
310- value={repeats}
311- onChange={(e) => setRepeats(parseInt(e.target.value, 10))}
312- >
313- {[1, 3, 5].map((r) => (
314- <option key={r} value={r}>
315- {r}
316- </option>
317- ))}
318- </select>
319- </label>
320- </div>
321- {runStatus && <p className="small muted">{runStatus}</p>}
322- <div className="row" style={{ marginTop: 10, alignItems: "center" }}>
323- <label className="small">
324- machine label{" "}
325- <input
326- type="text"
327- placeholder="e.g. office workstation"
328- value={machineLabel}
329- onChange={(e) => setMachineLabel(e.target.value)}
330- />
331- </label>
332- {localRuns
333- .filter((r) => r.done && r.instanceId === instanceId)
334- .map((r) => (
335- <button key={r.key} onClick={() => downloadRun(r)}>
336- Download {r.solverId} result JSON
337- </button>
338- ))}
339- <label className="small">
340- load result file{" "}
341- <input
342- type="file"
343- accept=".json,application/json"
344- multiple
345- onChange={(e) => loadFiles(e.target.files)}
346- />
347- </label>
348- </div>
349- <PointsTable curves={curves} />
350-
351- <h2>Solution and error</h2>
352- <p className="small muted" style={{ maxWidth: 640 }}>
353- Compute one solve at a chosen resolution and compare the field with
354- the exact solution. The solution uses a diverging scale about zero;
355- the error is the absolute pointwise difference on a log scale.
356- </p>
357- <SolutionSection inst={inst} />
358-
359- <h2>Run it outside the browser</h2>
360- <p style={{ maxWidth: 720 }}>
361- The same harness runs in node, with the same solvers, the same
362- protocol, and the same result format (node 20 or newer; no install
363- step):
364- </p>
365- <pre>{`npx ${cliUrl} run --label "my workstation"`}</pre>
366- <p style={{ maxWidth: 720 }}>
367- This writes one result JSON per instance and solver. To benchmark
368- your own solver, point the harness at a MATLAB function file that
369- implements the problem's solver interface:
370- </p>
371- <pre>{`npx ${cliUrl} run --solver-file my_method.m --solver-id my-method`}</pre>
372- <p style={{ maxWidth: 720 }}>
373- Result files can be loaded above (load result file) to view them
374- against the committed curves before submitting anything. To publish,
375- open a pull request adding the files under <code>results/</code> in
376- the <a href={RESULTS_REPO_URL}>results repository</a>; provenance
377- (machine, runtime, numbl version, solver version) travels inside each
378- file. Solvers in other languages are planned to enter the same way:
379- run offline, produce the same result format, submit by PR, with the
380- file marked as not reproducible in the browser.
381- </p>
382-
53+ {route.page === "home" && <HomePage />}
54+ {route.page === "about" && <AboutPage />}
55+ {route.page === "problem" && <ProblemPage problemId={route.problemId} />}
38356 <footer>
384- fastandaccurate · Apache-2.0 ·{" "}
385- <a href={REPO_URL}>concept-collection/fastandaccurate</a> · numbl{" "}
386- {__NUMBL_VERSION__}
57+ fastandaccurate · <a href="#/about">about</a> ·{" "}
58+ <a href={REPO_URL}>source</a> · Apache-2.0 · solvers run via{" "}
59+ <a href="https://numbl.org">numbl</a> {__NUMBL_VERSION__}
38760 </footer>
38861 </main>
38962 );
src/app/pages/AboutPage.tsxadded+94−0View file
@@ -0,0 +1,94 @@
1+import { RESULTS_REPO_URL } from "../results";
2+
3+const REPO_URL = "https://github.com/concept-collection/fastandaccurate";
4+
5+export function AboutPage() {
6+ const cliUrl = `https://concept-collection.github.io/fastandaccurate/cli.tgz?v=${__BUILD_ID__}`;
7+ return (
8+ <>
9+ <p className="small">
10+ <a href="#/">← problems</a>
11+ </p>
12+ <h1>About</h1>
13+ <p>
14+ A limitation of most solver comparisons is that they fix a
15+ discretization, which quietly decides much of the outcome. Here each{" "}
16+ <strong>problem</strong> is posed in the continuum, with an exact or
17+ highly accurate reference solution; a solver chooses its own
18+ discretization and is scored at problem-specified evaluation points.
19+ Each problem defines its own interface and a short list of official{" "}
20+ <strong>instances</strong> (parameter combinations) in a written
21+ specification, so every solver is compared on identical inputs.
22+ Solvers are MATLAB function files run by{" "}
23+ <a href="https://numbl.org">numbl</a> (MATLAB syntax in the browser
24+ and in node), so everything on this site runs client side, and the
25+ identical harness runs from the command line.
26+ </p>
27+
28+ <h2>Measurement</h2>
29+ <p>
30+ The central object is the <strong>work-precision curve</strong>:
31+ error against compute time, traced out as the solver's resolution
32+ parameter varies. Errors are measured at a fixed set of evaluation
33+ points defined per instance, relative to the reference solution.
34+ Timing is one untimed warmup run (which absorbs JIT compilation),
35+ then the median of repeated timed runs; a run includes the solver's
36+ own discretization, assembly, solve, and evaluation. There is
37+ deliberately no single ranking: which curve is best can differ by
38+ accuracy regime, instance, and machine, and the site presents the
39+ curves rather than a verdict. Times from different machines are not
40+ comparable; every result records its environment, and the charts
41+ label curves by machine.
42+ </p>
43+
44+ <h2>Results and provenance</h2>
45+ <p>
46+ Results are JSON files in{" "}
47+ <a href={RESULTS_REPO_URL}>fastandaccurate-results</a>, added by pull
48+ request; the site reads that repository statically, so there is no
49+ database and no server. Every result carries its provenance: the
50+ instance spec and its hash, solver id and version, timing protocol,
51+ runtime, numbl version, and machine. Results produced by in-browser
52+ solvers can be rerun by any visitor on their own machine, directly on
53+ the problem page. Results from solvers outside the repository (and,
54+ in the future, from other languages and hardware) enter the same way
55+ and are marked as not reproducible in the browser.
56+ </p>
57+
58+ <h2>Running outside the browser</h2>
59+ <p>
60+ The command line installs from this site itself (node 20 or newer;
61+ nothing on the npm registry):
62+ </p>
63+ <pre>{`npx ${cliUrl} run --label "my workstation"`}</pre>
64+ <p>
65+ This runs the standard sweeps and writes one result JSON per instance
66+ and solver. Useful flags: <code>--instance <id></code>,{" "}
67+ <code>--solver <id></code>, <code>--repeats N</code>,{" "}
68+ <code>--max-n N</code>, <code>--out dir</code>. To benchmark your own
69+ solver, point the harness at a MATLAB function file implementing the
70+ problem's solver interface:
71+ </p>
72+ <pre>{`npx ${cliUrl} run --solver-file my_method.m --solver-id my-method`}</pre>
73+ <p className="small muted">
74+ Note that npx caches by the exact URL string; the <code>?v=</code>{" "}
75+ suffix above ties the command to the current deployment so a later
76+ visit installs the current build.
77+ </p>
78+
79+ <h2>Submitting</h2>
80+ <p>
81+ Result files can be loaded on a problem page (load result file) to
82+ view them against the committed curves before submitting anything. To
83+ publish results, open a pull request adding the files under{" "}
84+ <code>results/</code> in the{" "}
85+ <a href={RESULTS_REPO_URL}>results repository</a>. To add a solver to
86+ the site itself, so visitors can rerun it in the browser, PR the
87+ solver directory and a manifest entry to{" "}
88+ <a href={REPO_URL}>the main repository</a>; submissions are reviewed
89+ against the problem specification, including that a solver must not
90+ special-case the known solution.
91+ </p>
92+ </>
93+ );
94+}
src/app/pages/HomePage.tsxadded+41−0View file
@@ -0,0 +1,41 @@
1+import { PROBLEMS } from "../../problems";
2+
3+export function HomePage() {
4+ return (
5+ <>
6+ <h1>fastandaccurate</h1>
7+ <p className="subtitle">Speed and accuracy benchmarks for PDE solvers</p>
8+ <p>
9+ Each problem is posed in the continuum with an exact or highly
10+ accurate reference solution; a solver chooses its own discretization
11+ and is compared by <em>work-precision curves</em>, error against
12+ compute time. Results can be reproduced in the browser and submitted
13+ by pull request. See <a href="#/about">About</a> for how measurement
14+ and submission work.
15+ </p>
16+
17+ <h2>Problems</h2>
18+ <div className="problem-list">
19+ {PROBLEMS.map((p) => (
20+ <a key={p.id} className="problem-card" href={`#/problem/${p.id}`}>
21+ <div className="problem-card-title">
22+ <code>{p.id}</code>
23+ <span className="badge">{p.dimension}</span>
24+ </div>
25+ <div className="problem-card-summary">{p.summary}</div>
26+ <div className="problem-card-meta">
27+ {p.instanceCount} instances · {p.solverCount} solvers · ground
28+ truth: {p.groundTruth}
29+ </div>
30+ </a>
31+ ))}
32+ </div>
33+ <p className="small muted" style={{ marginTop: 16 }}>
34+ More problems are planned: further 2D problems (Helmholtz,
35+ time-dependent), 3D problems, and near-boundary evaluation variants.
36+ Suggestions and contributions are welcome on{" "}
37+ <a href="https://github.com/concept-collection/fastandaccurate">GitHub</a>.
38+ </p>
39+ </>
40+ );
41+}
src/app/pages/ProblemPage.tsxadded+373−0View file
@@ -0,0 +1,373 @@
1+import { useEffect, useMemo, useState } from "react";
2+import {
3+ INSTANCES,
4+ getInstance,
5+ PROBLEM_ID,
6+} from "../../problems/laplace2d/spec";
7+import { SOLVERS, getSolver } from "../../solvers";
8+import {
9+ buildResultFile,
10+ type ResultFile,
11+ type ResultPoint,
12+} from "../../harness/resultSchema";
13+import {
14+ environmentLabel,
15+ fetchCommittedResults,
16+ isResultFile,
17+ RESULTS_REPO_URL,
18+} from "../results";
19+import { solverColorVar } from "../colors";
20+import { sweepInBrowser } from "../workerClient";
21+import {
22+ WorkPrecisionChart,
23+ type ChartCurve,
24+} from "../components/WorkPrecisionChart";
25+import { PointsTable } from "../components/PointsTable";
26+import { DomainView } from "../components/DomainView";
27+import { SolutionSection } from "../components/SolutionSection";
28+
29+const REPO_URL = "https://github.com/concept-collection/fastandaccurate";
30+const SPEC_URL = `${REPO_URL}/blob/main/docs/problems/laplace-dirichlet-2d.md`;
31+
32+interface LocalRun {
33+ key: string;
34+ solverId: string;
35+ instanceId: string;
36+ repeats: number;
37+ points: ResultPoint[];
38+ done: boolean;
39+}
40+
41+export function ProblemPage({ problemId }: { problemId: string }) {
42+ const [instanceId, setInstanceId] = useState(INSTANCES[1].id);
43+ const [committed, setCommitted] = useState<ResultFile[] | null>(null);
44+ const [committedError, setCommittedError] = useState<string | null>(null);
45+ const [loaded, setLoaded] = useState<ResultFile[]>([]);
46+ const [localRuns, setLocalRuns] = useState<LocalRun[]>([]);
47+ const [hidden, setHidden] = useState<Set<string>>(new Set());
48+ const [running, setRunning] = useState<string | null>(null);
49+ const [runStatus, setRunStatus] = useState<string | null>(null);
50+ const [repeats, setRepeats] = useState(3);
51+ const [machineLabel, setMachineLabel] = useState("");
52+
53+ const inst = getInstance(instanceId);
54+
55+ useEffect(() => {
56+ fetchCommittedResults()
57+ .then(setCommitted)
58+ .catch((err) =>
59+ setCommittedError(err instanceof Error ? err.message : String(err))
60+ );
61+ }, []);
62+
63+ const allSolverIds = useMemo(() => {
64+ const ids = new Set<string>(SOLVERS.map((s) => s.id));
65+ committed?.forEach((r) => ids.add(r.solver.id));
66+ loaded.forEach((r) => ids.add(r.solver.id));
67+ return [...ids];
68+ }, [committed, loaded]);
69+
70+ const curves: ChartCurve[] = useMemo(() => {
71+ const out: ChartCurve[] = [];
72+ const color = (id: string) => solverColorVar(id, allSolverIds);
73+ committed
74+ ?.filter((r) => r.problem === PROBLEM_ID && r.instance === instanceId)
75+ .forEach((r, i) => {
76+ out.push({
77+ key: `committed:${i}`,
78+ solverId: r.solver.id,
79+ label: `${r.solver.id} — ${environmentLabel(r)}`,
80+ color: color(r.solver.id),
81+ points: r.points,
82+ });
83+ });
84+ loaded
85+ .filter((r) => r.problem === PROBLEM_ID && r.instance === instanceId)
86+ .forEach((r, i) => {
87+ out.push({
88+ key: `loaded:${i}`,
89+ solverId: r.solver.id,
90+ label: `${r.solver.id} — ${environmentLabel(r)} (loaded)`,
91+ color: color(r.solver.id),
92+ dash: "8 4",
93+ points: r.points,
94+ });
95+ });
96+ localRuns
97+ .filter((r) => r.instanceId === instanceId)
98+ .forEach((r) => {
99+ out.push({
100+ key: r.key,
101+ solverId: r.solverId,
102+ label: `${r.solverId} — this browser`,
103+ color: color(r.solverId),
104+ dash: "4 4",
105+ open: true,
106+ points: r.points,
107+ });
108+ });
109+ return out.filter((c) => !hidden.has(c.solverId));
110+ }, [committed, loaded, localRuns, instanceId, hidden, allSolverIds]);
111+
112+ async function runSolver(solverId: string) {
113+ const key = `local:${solverId}:${instanceId}:${Date.now()}`;
114+ setLocalRuns((rs) => [
115+ ...rs.filter(
116+ (r) => !(r.solverId === solverId && r.instanceId === instanceId)
117+ ),
118+ { key, solverId, instanceId, repeats, points: [], done: false },
119+ ]);
120+ setRunning(solverId);
121+ try {
122+ const points = await sweepInBrowser(
123+ instanceId,
124+ solverId,
125+ repeats,
126+ (point, index, total) => {
127+ setRunStatus(
128+ `${solverId} on ${instanceId}: point ${index + 1}/${total} (n = ${point.n}) — rel max error ${point.relMax.toExponential(2)}`
129+ );
130+ setLocalRuns((rs) =>
131+ rs.map((r) =>
132+ r.key === key ? { ...r, points: [...r.points, point] } : r
133+ )
134+ );
135+ }
136+ );
137+ setLocalRuns((rs) =>
138+ rs.map((r) => (r.key === key ? { ...r, points, done: true } : r))
139+ );
140+ setRunStatus(null);
141+ } catch (err) {
142+ setRunStatus(
143+ `${solverId} failed: ${err instanceof Error ? err.message : String(err)}`
144+ );
145+ } finally {
146+ setRunning(null);
147+ }
148+ }
149+
150+ async function downloadRun(run: LocalRun) {
151+ const manifest = getSolver(run.solverId);
152+ const result = await buildResultFile({
153+ instance: getInstance(run.instanceId),
154+ solver: {
155+ id: manifest.id,
156+ version: manifest.version,
157+ backend: manifest.backend,
158+ source: "builtin",
159+ },
160+ environment: {
161+ kind: "browser",
162+ runtime: navigator.userAgent,
163+ numblVersion: __NUMBL_VERSION__,
164+ machineLabel: machineLabel || undefined,
165+ browserReproducible: true,
166+ },
167+ repeats: run.repeats,
168+ points: run.points,
169+ });
170+ const blob = new Blob([JSON.stringify(result, null, 2) + "\n"], {
171+ type: "application/json",
172+ });
173+ const a = document.createElement("a");
174+ a.href = URL.createObjectURL(blob);
175+ a.download = `${PROBLEM_ID}.${run.instanceId}.${run.solverId}.browser.json`;
176+ a.click();
177+ URL.revokeObjectURL(a.href);
178+ }
179+
180+ function loadFiles(files: FileList | null) {
181+ if (!files) return;
182+ for (const file of Array.from(files)) {
183+ file.text().then((text) => {
184+ try {
185+ const data: unknown = JSON.parse(text);
186+ if (isResultFile(data)) {
187+ setLoaded((ls) => [...ls, data]);
188+ } else {
189+ alert(`${file.name} is not a fastandaccurate result file`);
190+ }
191+ } catch {
192+ alert(`${file.name}: not valid JSON`);
193+ }
194+ });
195+ }
196+ }
197+
198+ if (problemId !== PROBLEM_ID) {
199+ return (
200+ <>
201+ <p className="small">
202+ <a href="#/">← problems</a>
203+ </p>
204+ <h1>Unknown problem</h1>
205+ <p>
206+ No problem named <code>{problemId}</code>.{" "}
207+ <a href="#/">Back to the problem list.</a>
208+ </p>
209+ </>
210+ );
211+ }
212+
213+ return (
214+ <>
215+ <p className="small">
216+ <a href="#/">← problems</a>
217+ </p>
218+ <h1>
219+ <code>{PROBLEM_ID}</code>
220+ </h1>
221+ <p className="subtitle">
222+ Interior Dirichlet Laplace problem on a star-shaped 2D domain
223+ </p>
224+ <p>
225+ Solve Δu = 0 on the domain with boundary r(θ) = 1 + a·cos(kθ), with
226+ Dirichlet data u = g on the boundary. The data comes from an exact
227+ harmonic function, a sum of three logarithmic point sources placed a
228+ distance d outside the boundary, so errors are measured against the
229+ true solution rather than a reference computation. The distance d
230+ sets the difficulty: the closer the sources, the shorter the distance
231+ the data continues harmonically past the boundary, and methods whose
232+ representations assume that continuation lose it. A solver receives
233+ the curve (with derivatives), the boundary data as a function of the
234+ boundary parameter, and the evaluation points, and returns solution
235+ values at those points. The precise statement, solver interface, and
236+ timing protocol are in the <a href={SPEC_URL}>specification</a>.
237+ </p>
238+ <div className="row" style={{ marginTop: 14 }}>
239+ <div>
240+ <div style={{ marginBottom: 10 }}>
241+ <label>
242+ instance{" "}
243+ <select
244+ value={instanceId}
245+ onChange={(e) => setInstanceId(e.target.value)}
246+ >
247+ {INSTANCES.map((i) => (
248+ <option key={i.id} value={i.id}>
249+ {i.id} — {i.label}
250+ </option>
251+ ))}
252+ </select>
253+ </label>
254+ </div>
255+ <p className="small muted" style={{ maxWidth: 380 }}>
256+ {inst.description}
257+ </p>
258+ <table className="data">
259+ <tbody>
260+ <tr>
261+ <th className="left">a</th>
262+ <td>{inst.a}</td>
263+ <th className="left">k</th>
264+ <td>{inst.k}</td>
265+ <th className="left">d</th>
266+ <td>{inst.d}</td>
267+ </tr>
268+ </tbody>
269+ </table>
270+ </div>
271+ <DomainView inst={inst} />
272+ </div>
273+
274+ <h2>Work-precision results</h2>
275+ {committedError && (
276+ <p className="small muted">
277+ Committed results could not be loaded ({committedError}); showing
278+ local runs only.
279+ </p>
280+ )}
281+ <WorkPrecisionChart curves={curves} />
282+ <div className="row" style={{ marginTop: 14, alignItems: "center" }}>
283+ {SOLVERS.map((s) => (
284+ <span key={s.id} style={{ whiteSpace: "nowrap" }}>
285+ <label>
286+ <input
287+ type="checkbox"
288+ checked={!hidden.has(s.id)}
289+ onChange={(e) => {
290+ setHidden((h) => {
291+ const next = new Set(h);
292+ if (e.target.checked) next.delete(s.id);
293+ else next.add(s.id);
294+ return next;
295+ });
296+ }}
297+ />{" "}
298+ <span
299+ className="legend-swatch"
300+ style={{ background: solverColorVar(s.id, allSolverIds) }}
301+ />
302+ {s.name}
303+ </label>{" "}
304+ <button
305+ onClick={() => runSolver(s.id)}
306+ disabled={running !== null}
307+ title={`Run the full ${s.id} sweep on ${instanceId} in this browser`}
308+ >
309+ {running === s.id ? "running…" : "Run in this browser"}
310+ </button>
311+ </span>
312+ ))}
313+ <label>
314+ repeats{" "}
315+ <select
316+ value={repeats}
317+ onChange={(e) => setRepeats(parseInt(e.target.value, 10))}
318+ >
319+ {[1, 3, 5].map((r) => (
320+ <option key={r} value={r}>
321+ {r}
322+ </option>
323+ ))}
324+ </select>
325+ </label>
326+ </div>
327+ {runStatus && <p className="small muted">{runStatus}</p>}
328+ <div className="row" style={{ marginTop: 10, alignItems: "center" }}>
329+ <label className="small">
330+ machine label{" "}
331+ <input
332+ type="text"
333+ placeholder="e.g. office workstation"
334+ value={machineLabel}
335+ onChange={(e) => setMachineLabel(e.target.value)}
336+ />
337+ </label>
338+ {localRuns
339+ .filter((r) => r.done && r.instanceId === instanceId)
340+ .map((r) => (
341+ <button key={r.key} onClick={() => downloadRun(r)}>
342+ Download {r.solverId} result JSON
343+ </button>
344+ ))}
345+ <label className="small">
346+ load result file{" "}
347+ <input
348+ type="file"
349+ accept=".json,application/json"
350+ multiple
351+ onChange={(e) => loadFiles(e.target.files)}
352+ />
353+ </label>
354+ </div>
355+ <PointsTable curves={curves} />
356+
357+ <h2>Solution and error</h2>
358+ <p className="small muted" style={{ maxWidth: 640 }}>
359+ Compute one solve at a chosen resolution and compare the field with
360+ the exact solution. The solution uses a diverging scale about zero;
361+ the error is the absolute pointwise difference on a log scale.
362+ </p>
363+ <SolutionSection inst={inst} />
364+
365+ <p className="small muted" style={{ marginTop: "2.2rem" }}>
366+ The same sweeps run outside the browser with the command line, and
367+ both browser and command-line results are submitted by pull request
368+ to the <a href={RESULTS_REPO_URL}>results repository</a>; see{" "}
369+ <a href="#/about">About</a>.
370+ </p>
371+ </>
372+ );
373+}
src/app/styles.cssmodified+66−0View file
@@ -228,3 +228,69 @@ footer {
228228 color: var(--text-3);
229229 font-size: 0.85rem;
230230 }
231+
232+.topnav {
233+ padding-top: 1.1rem;
234+ font-size: 0.9rem;
235+}
236+
237+.topnav a {
238+ color: var(--text-2);
239+ text-decoration: none;
240+ font-weight: 600;
241+}
242+
243+.topnav a:hover {
244+ color: var(--text);
245+}
246+
247+.problem-list {
248+ display: flex;
249+ flex-direction: column;
250+ gap: 12px;
251+ max-width: 720px;
252+}
253+
254+.problem-card {
255+ display: block;
256+ border: 1px solid var(--border);
257+ border-radius: 8px;
258+ padding: 14px 18px;
259+ text-decoration: none;
260+ color: var(--text);
261+ background: var(--surface);
262+}
263+
264+.problem-card:hover {
265+ border-color: var(--text-3);
266+}
267+
268+.problem-card-title {
269+ display: flex;
270+ align-items: center;
271+ gap: 10px;
272+ font-size: 1.02rem;
273+ font-weight: 650;
274+}
275+
276+.problem-card-summary {
277+ color: var(--text-2);
278+ margin-top: 4px;
279+ font-size: 0.92rem;
280+}
281+
282+.problem-card-meta {
283+ color: var(--text-3);
284+ margin-top: 6px;
285+ font-size: 0.82rem;
286+}
287+
288+.badge {
289+ font-size: 0.72rem;
290+ font-weight: 600;
291+ color: var(--text-2);
292+ border: 1px solid var(--border);
293+ border-radius: 4px;
294+ padding: 1px 6px;
295+ background: var(--surface-2);
296+}
src/problems/index.tsadded+32−0View file
@@ -0,0 +1,32 @@
1+// The problem registry: what the home page lists. Each problem has its
2+// own page, spec document, instances, and solver interface; new problems
3+// add an entry here.
4+
5+import { PROBLEM_ID, INSTANCES } from "./laplace2d/spec";
6+import { SOLVERS } from "../solvers";
7+
8+export interface ProblemInfo {
9+ id: string;
10+ title: string;
11+ dimension: "2D" | "3D";
12+ /** One or two sentences for the problem list. */
13+ summary: string;
14+ instanceCount: number;
15+ solverCount: number;
16+ groundTruth: string;
17+}
18+
19+export const PROBLEMS: ProblemInfo[] = [
20+ {
21+ id: PROBLEM_ID,
22+ title: "Interior Dirichlet Laplace problem",
23+ dimension: "2D",
24+ summary:
25+ "Laplace's equation on a star-shaped domain with Dirichlet data " +
26+ "manufactured from an exact harmonic function whose singularities " +
27+ "sit an adjustable distance outside the boundary.",
28+ instanceCount: INSTANCES.length,
29+ solverCount: SOLVERS.length,
30+ groundTruth: "exact",
31+ },
32+];