export type ResultCsvInput = { variables: readonly { key: string }[]; series: Readonly>; }; export type ResultCsvWorkerMessage = | { type: "start"; keys: string[]; rowCount: number } | { type: "chunk"; offset: number; values: Float64Array } | { type: "finish" }; export type ResultCsvWorkerResponse = | { type: "complete"; blob: Blob } | { type: "error"; message: string }; function csvColumns(input: ResultCsvInput) { const times = input.series.time; if (!Array.isArray(times) || !times.length) { throw new Error("Simulation results must contain a non-empty time series."); } const variableKeys = input.variables.map((variable) => variable.key); if (!variableKeys.length) { throw new Error("Simulation results do not contain exportable variables."); } if (new Set(variableKeys).size !== variableKeys.length) { throw new Error("Simulation result metadata contains duplicate variable keys."); } const keys = ["time", ...variableKeys]; const expectedKeys = new Set(keys); const missing = [...expectedKeys].filter((key) => !Object.hasOwn(input.series, key)).sort(); const unknown = Object.keys(input.series).filter((key) => !expectedKeys.has(key)).sort(); if (missing.length || unknown.length) { const details = [missing.length ? `missing ${missing.join(", ")}` : "", unknown.length ? `unmapped ${unknown.join(", ")}` : ""].filter(Boolean); throw new Error(`Simulation result columns do not match metadata: ${details.join("; ")}.`); } for (const key of keys) { if (!Array.isArray(input.series[key]) || input.series[key].length !== times.length) { throw new Error(`Simulation result column '${key}' has an inconsistent length.`); } } return { keys, rowCount: times.length }; } export function resultCsvFilename(projectName: string): string { const stem = projectName.replace(/[<>:"/\\|?*\u0000-\u001f]/g, "_").replace(/^[ .]+|[ .]+$/g, ""); return `${Array.from(stem).slice(0, 80).join("") || "simulation"}-results.csv`; } /** Transfer at most 1 MiB per task; never stringify or clone the full result. */ export function exportResultCsv(input: ResultCsvInput, signal?: AbortSignal): Promise { return new Promise((resolve, reject) => { if (signal?.aborted) { reject(new DOMException("CSV export was cancelled.", "AbortError")); return; } let worker: Worker | undefined; let settled = false; let yieldTimer: ReturnType | undefined; let resumeYield: (() => void) | undefined; const finish = (error?: unknown, blob?: Blob) => { if (settled) return; settled = true; signal?.removeEventListener("abort", abort); if (yieldTimer !== undefined) clearTimeout(yieldTimer); resumeYield?.(); worker?.terminate(); if (error !== undefined) reject(error); else resolve(blob!); }; const abort = () => finish(new DOMException("CSV export was cancelled.", "AbortError")); signal?.addEventListener("abort", abort, { once: true }); const yieldMainThread = () => new Promise((resume) => { resumeYield = resume; yieldTimer = setTimeout(() => { yieldTimer = undefined; resumeYield = undefined; resume(); }, 0); }); try { const { keys, rowCount } = csvColumns(input); worker = new Worker(new URL("./resultCsv.worker.ts", import.meta.url), { type: "module" }); worker.onmessage = ({ data }: MessageEvent) => { if (data.type === "complete" && data.blob instanceof Blob) finish(undefined, data.blob); else finish(new Error(data.type === "error" ? data.message : "CSV worker returned an invalid response.")); }; worker.onerror = (event) => { event.preventDefault(); finish(new Error(event.message || "CSV worker failed.")); }; worker.onmessageerror = () => finish(new Error("CSV worker response could not be read.")); const transfer = async () => { // Let the pending indicator render before copying any numeric payload. await yieldMainThread(); if (settled) return; worker!.postMessage({ type: "start", keys, rowCount } satisfies ResultCsvWorkerMessage); const total = keys.length * rowCount; let column = 0; let row = 0; for (let offset = 0; offset < total;) { if (settled) return; const batch = new Float64Array(Math.min(128 * 1024, total - offset)); let filled = 0; while (filled < batch.length) { const source = input.series[keys[column]]; const count = Math.min(rowCount - row, batch.length - filled); for (let index = 0; index < count; index++) { const value = source[row + index]; if (typeof value !== "number") { throw new Error(`Simulation result column '${keys[column]}' contains non-numeric values.`); } batch[filled + index] = value; } row += count; filled += count; if (row === rowCount) { column++; row = 0; } } const transferredCount = batch.length; worker!.postMessage({ type: "chunk", offset, values: batch } satisfies ResultCsvWorkerMessage, [batch.buffer]); offset += transferredCount; if (offset < total) await yieldMainThread(); } if (!settled) worker!.postMessage({ type: "finish" } satisfies ResultCsvWorkerMessage); }; void transfer().catch(finish); } catch (error) { finish(error); } }); }