196 lines
6.6 KiB
JavaScript
196 lines
6.6 KiB
JavaScript
"use strict";
|
||
|
||
const elements = {
|
||
form: document.querySelector("#solve-form"),
|
||
cells: document.querySelector("#cells"),
|
||
cellsValue: document.querySelector("#cells-value"),
|
||
order: document.querySelector("#order"),
|
||
button: document.querySelector("#solve-button"),
|
||
status: document.querySelector("#runtime-status"),
|
||
statusDot: document.querySelector("#status-dot"),
|
||
capabilities: document.querySelector("#capability-list"),
|
||
canvas: document.querySelector("#mesh-canvas"),
|
||
runIndex: document.querySelector("#run-index"),
|
||
dofs: document.querySelector("#metric-dofs"),
|
||
iterations: document.querySelector("#metric-iterations"),
|
||
error: document.querySelector("#metric-error"),
|
||
time: document.querySelector("#metric-time"),
|
||
log: document.querySelector("#runtime-log"),
|
||
footerVersion: document.querySelector("#footer-version"),
|
||
};
|
||
|
||
let mfemModule;
|
||
let completedRuns = 0;
|
||
let lastCells = Number(elements.cells.value);
|
||
|
||
function setStatus(message, state = "loading") {
|
||
elements.status.textContent = message;
|
||
elements.statusDot.dataset.state = state;
|
||
}
|
||
|
||
function appendLog(message) {
|
||
const timestamp = new Date().toLocaleTimeString([], {
|
||
hour: "2-digit",
|
||
minute: "2-digit",
|
||
second: "2-digit",
|
||
});
|
||
const prior = elements.log.textContent.startsWith("Waiting") ? "" : `${elements.log.textContent}\n`;
|
||
elements.log.textContent = `${prior}[${timestamp}] ${message}`;
|
||
}
|
||
|
||
function drawMesh(cells, solved = false) {
|
||
const canvas = elements.canvas;
|
||
const context = canvas.getContext("2d");
|
||
const ratio = Math.max(1, window.devicePixelRatio || 1);
|
||
const bounds = canvas.getBoundingClientRect();
|
||
const width = Math.max(320, Math.round(bounds.width));
|
||
const height = Math.max(260, Math.round(bounds.height));
|
||
canvas.width = width * ratio;
|
||
canvas.height = height * ratio;
|
||
context.scale(ratio, ratio);
|
||
|
||
const gradient = context.createRadialGradient(
|
||
width * 0.5,
|
||
height * 0.46,
|
||
10,
|
||
width * 0.5,
|
||
height * 0.5,
|
||
width * 0.66,
|
||
);
|
||
gradient.addColorStop(0, solved ? "rgba(125, 249, 197, 0.30)" : "rgba(89, 199, 255, 0.16)");
|
||
gradient.addColorStop(0.55, solved ? "rgba(35, 124, 126, 0.20)" : "rgba(27, 72, 104, 0.13)");
|
||
gradient.addColorStop(1, "rgba(8, 14, 24, 0)");
|
||
context.fillStyle = gradient;
|
||
context.fillRect(0, 0, width, height);
|
||
|
||
const padding = Math.max(30, Math.min(width, height) * 0.11);
|
||
const side = Math.min(width - 2 * padding, height - 2 * padding);
|
||
const left = (width - side) / 2;
|
||
const top = (height - side) / 2;
|
||
const step = side / cells;
|
||
|
||
context.strokeStyle = solved ? "rgba(151, 255, 215, 0.30)" : "rgba(138, 207, 236, 0.22)";
|
||
context.lineWidth = 1;
|
||
context.beginPath();
|
||
for (let index = 0; index <= cells; index += 1) {
|
||
const offset = Math.round(index * step) + 0.5;
|
||
context.moveTo(left + offset, top);
|
||
context.lineTo(left + offset, top + side);
|
||
context.moveTo(left, top + offset);
|
||
context.lineTo(left + side, top + offset);
|
||
}
|
||
context.stroke();
|
||
|
||
context.strokeStyle = solved ? "#7df9c5" : "#59c7ff";
|
||
context.lineWidth = 2;
|
||
context.strokeRect(left, top, side, side);
|
||
}
|
||
|
||
function renderCapabilities(capabilities) {
|
||
const entries = [
|
||
["Wasm", capabilities.wasm],
|
||
["MFEM", true],
|
||
["libCEED", capabilities.ceed],
|
||
["zlib", capabilities.zlib],
|
||
["MPI", capabilities.mpi],
|
||
];
|
||
elements.capabilities.replaceChildren(
|
||
...entries.map(([name, enabled]) => {
|
||
const badge = document.createElement("span");
|
||
badge.className = enabled ? "capability enabled" : "capability disabled";
|
||
badge.textContent = enabled ? name : `${name} off`;
|
||
return badge;
|
||
}),
|
||
);
|
||
elements.footerVersion.textContent = `MFEM ${capabilities.mfemVersion} · WebAssembly`;
|
||
}
|
||
|
||
function formatScientific(value) {
|
||
return Number(value).toExponential(3);
|
||
}
|
||
|
||
function formatTime(seconds) {
|
||
const milliseconds = Number(seconds) * 1000;
|
||
return milliseconds < 1000 ? `${milliseconds.toFixed(1)} ms` : `${Number(seconds).toFixed(2)} s`;
|
||
}
|
||
|
||
async function runSolve() {
|
||
const cells = Number(elements.cells.value);
|
||
const order = Number(elements.order.value);
|
||
elements.button.disabled = true;
|
||
elements.button.classList.add("working");
|
||
setStatus("Solving finite-element system", "working");
|
||
appendLog(`Starting ${cells}×${cells} mesh at polynomial order ${order}.`);
|
||
|
||
await new Promise((resolve) => window.setTimeout(resolve, 20));
|
||
try {
|
||
const raw = mfemModule.ccall(
|
||
"mfem_demo_solve",
|
||
"string",
|
||
["number", "number"],
|
||
[cells, order],
|
||
);
|
||
const result = JSON.parse(raw);
|
||
if (!result.ok) {
|
||
throw new Error(result.error || "The MFEM solve failed.");
|
||
}
|
||
|
||
completedRuns += 1;
|
||
lastCells = cells;
|
||
elements.runIndex.textContent = `Run ${String(completedRuns).padStart(2, "0")}`;
|
||
elements.dofs.textContent = Number(result.trueDofs).toLocaleString();
|
||
elements.iterations.textContent = Number(result.iterations).toLocaleString();
|
||
elements.error.textContent = formatScientific(result.l2Error);
|
||
elements.time.textContent = formatTime(result.elapsedSeconds);
|
||
drawMesh(cells, true);
|
||
setStatus("Solve complete", "ready");
|
||
appendLog(
|
||
`Converged in ${result.iterations} iterations with residual ${formatScientific(result.finalNorm)}.`,
|
||
);
|
||
} catch (error) {
|
||
setStatus("Solve failed", "error");
|
||
appendLog(error instanceof Error ? error.message : String(error));
|
||
} finally {
|
||
elements.button.disabled = false;
|
||
elements.button.classList.remove("working");
|
||
}
|
||
}
|
||
|
||
elements.cells.addEventListener("input", () => {
|
||
elements.cellsValue.value = elements.cells.value;
|
||
drawMesh(Number(elements.cells.value), false);
|
||
});
|
||
|
||
elements.form.addEventListener("submit", (event) => {
|
||
event.preventDefault();
|
||
void runSolve();
|
||
});
|
||
|
||
window.addEventListener("resize", () => drawMesh(lastCells, completedRuns > 0));
|
||
|
||
async function initialize() {
|
||
drawMesh(lastCells, false);
|
||
try {
|
||
if (typeof MFEMDemoModule !== "function") {
|
||
throw new Error("The generated MFEM WebAssembly loader was not found.");
|
||
}
|
||
mfemModule = await MFEMDemoModule({
|
||
locateFile: (path) => new URL(path, window.location.href).href,
|
||
print: (message) => appendLog(message),
|
||
printErr: (message) => appendLog(`Runtime: ${message}`),
|
||
});
|
||
const capabilities = JSON.parse(
|
||
mfemModule.ccall("mfem_demo_capabilities", "string", [], []),
|
||
);
|
||
renderCapabilities(capabilities);
|
||
elements.button.disabled = false;
|
||
setStatus("Runtime ready", "ready");
|
||
appendLog(`Loaded MFEM ${capabilities.mfemVersion} from WebAssembly.`);
|
||
} catch (error) {
|
||
setStatus("Runtime failed to load", "error");
|
||
appendLog(error instanceof Error ? error.message : String(error));
|
||
}
|
||
}
|
||
|
||
void initialize();
|