import { evaluateDasBand, exportBandEvaluation } from "/packages/acoustics-core/evaluation.js"; import { downloadJson, readJsonFile } from "/packages/contracts/io.js"; import { validateArrayDefinition, validateExperiment, validateResult, validateScene, } from "/packages/contracts/validation.js"; import { createCameraAssetRuntime } from "./asset-runtime.js"; import { alignExperimentToScene, canonicalStringify, createHistorySummary, createResultDocument, estimateExperimentWorkload, hashSimulationInputs, safeFileStem, shiftSceneDistance, } from "./model.js"; import { createArrayVisualization, createSceneVisualization } from "./visualization.js"; const DEFAULT_DOCUMENT_URLS = { arrayDefinition: "/assets/array-geometries/rectangular-16x12.array.json", scene: "/assets/sample-scenes/single-tone.scene.json", experiment: "/assets/sample-scenes/single-tone.experiment.json", }; const ARRAY_PRESET_URLS = new Map([ ["rectangular-16x12", "/assets/array-geometries/rectangular-16x12.array.json"], ["fermat-spiral-192", "/assets/array-geometries/fermat-spiral-192.array.json"], ]); const BAND_COLORS = ["#65f2bf", "#58c7ff", "#ff7f8f", "#ffc56e", "#b294ff", "#8fe388", "#ff9ee5", "#83a8ff"]; const MAX_HISTORY_ITEMS = 24; const byId = (id) => document.getElementById(id); const elements = { runStatus: byId("run-status"), runButton: byId("run-button"), cancelButton: byId("cancel-button"), exportResultButton: byId("export-result-button"), sceneInput: byId("scene-input"), arrayInput: byId("array-input"), experimentInput: byId("experiment-input"), assetInput: byId("asset-input"), workspaceTitle: byId("workspace-title"), sceneMeta: byId("scene-meta"), sceneCanvas: byId("scene-canvas"), canvasMessage: byId("canvas-message"), canvasBadge: byId("canvas-badge"), bandLegend: byId("band-legend"), overlayOpacity: byId("overlay-opacity"), showTruth: byId("show-truth"), resultBadge: byId("result-badge"), emptyResult: byId("empty-result"), metricGrid: byId("metric-grid"), clearHistoryButton: byId("clear-history-button"), historyList: byId("history-list"), arrayTitle: byId("array-title"), microphoneCount: byId("microphone-count"), arrayPreset: byId("array-preset"), arrayCanvas: byId("array-canvas"), arrayMeta: byId("array-meta"), sourceSummary: byId("source-summary"), assetList: byId("asset-list"), experimentName: byId("experiment-name"), experimentId: byId("experiment-id"), sceneDistance: byId("scene-distance"), speedOfSound: byId("speed-of-sound"), sceneSeed: byId("scene-seed"), backend: byId("backend"), gridPreset: byId("grid-preset"), scanColumns: byId("scan-columns"), scanRows: byId("scan-rows"), scanXMin: byId("scan-x-min"), scanXMax: byId("scan-x-max"), scanYMin: byId("scan-y-min"), scanYMax: byId("scan-y-max"), workloadSummary: byId("workload-summary"), fftSize: byId("fft-size"), hopSize: byId("hop-size"), normalization: byId("normalization"), dynamicRange: byId("dynamic-range"), postprocess: byId("postprocess"), addBandButton: byId("add-band-button"), bandList: byId("band-list"), exportExperimentButton: byId("export-experiment-button"), validationCard: document.querySelector(".validation-card"), validationTitle: byId("validation-title"), validationMessage: byId("validation-message"), }; const state = { arrayDefinition: null, scene: null, experiment: null, revision: 0, validationSequence: 0, validation: { valid: false, issues: [] }, visibleBandIds: new Set(), lastRun: null, history: [], active: null, pendingInputOperations: 0, importGenerations: { array: 0, scene: 0, experiment: 0 }, }; const sceneVisualization = createSceneVisualization(elements.sceneCanvas); const arrayVisualization = createArrayVisualization(elements.arrayCanvas); const assetRuntime = createCameraAssetRuntime(() => { renderAssets(); renderSceneVisualization(); scheduleValidation(); }); let validationQueued = false; let jobSequence = 0; function formatNumber(value, digits = 1) { return Number.isFinite(value) ? value.toFixed(digits) : "—"; } function formatCount(value) { if (!Number.isFinite(value)) return "—"; return new Intl.NumberFormat("ko-KR", { notation: value >= 1e6 ? "compact" : "standard", maximumFractionDigits: 1, }).format(value); } function formatIssue(issue) { return `${issue.path}: ${issue.message}`; } function comparisonSignature(scene = state.scene, sourceExperiment = state.experiment) { if (!scene || !sourceExperiment) return ""; const experiment = structuredClone(sourceExperiment); experiment.arrayId = "$array"; return canonicalStringify({ scene, experiment }); } function setRunStatus(message) { elements.runStatus.textContent = message; } function setTransientError(error, prefix = "오류") { const message = error instanceof Error ? error.message : String(error); setRunStatus(`${prefix} · ${message}`); elements.validationCard.classList.remove("is-valid", "is-warning"); elements.validationCard.classList.add("is-invalid"); elements.validationTitle.textContent = prefix; elements.validationMessage.textContent = message; } function setConfigurationLocked(locked) { setEditableControlsDisabled(locked); elements.runButton.disabled = locked || !state.validation.valid; elements.cancelButton.classList.toggle("is-hidden", !locked); elements.exportResultButton.disabled = locked || !state.lastRun; } function setEditableControlsDisabled(disabled) { document.querySelectorAll( ".control-panel input, .control-panel select, .control-panel button, .workspace-tools input", ).forEach((control) => { control.disabled = disabled; }); } function beginInputOperation() { state.pendingInputOperations += 1; document.documentElement.dataset.inputBusy = "true"; elements.runButton.disabled = true; document.querySelectorAll(".workspace-tools input").forEach((input) => { input.disabled = true; }); } function endInputOperation() { state.pendingInputOperations = Math.max(0, state.pendingInputOperations - 1); document.documentElement.dataset.inputBusy = String(state.pendingInputOperations > 0); if ( state.pendingInputOperations === 0 && !state.active && document.documentElement.dataset.appReady === "true" ) { document.querySelectorAll(".workspace-tools input").forEach((input) => { input.disabled = false; }); } scheduleValidation(); } function readFiniteInput(element) { return Number.isFinite(element.valueAsNumber) ? element.valueAsNumber : null; } function markChanged(options = {}) { state.revision += 1; if (state.lastRun) state.lastRun.stale = true; if (["error", "canceled"].includes(document.documentElement.dataset.runState)) { document.documentElement.dataset.runState = "idle"; } if (options.syncControls) syncControls(); if (options.renderBands) renderBandEditors(); renderDocumentSummary(); renderLegend(); renderWorkload(); renderSceneVisualization(); renderResult(); renderHistory(); scheduleValidation(); } function syncControls() { const { scene, experiment } = state; if (!scene || !experiment) return; elements.experimentName.value = experiment.name; elements.experimentId.value = experiment.id; elements.sceneDistance.value = experiment.scan.z; elements.speedOfSound.value = scene.speedOfSound; elements.sceneSeed.value = scene.seed; elements.backend.value = experiment.beamforming.backend; elements.scanColumns.value = experiment.scan.columns; elements.scanRows.value = experiment.scan.rows; elements.scanXMin.value = experiment.scan.xMin; elements.scanXMax.value = experiment.scan.xMax; elements.scanYMin.value = experiment.scan.yMin; elements.scanYMax.value = experiment.scan.yMax; elements.fftSize.value = experiment.stft.fftSize; elements.hopSize.value = experiment.stft.hopSize; elements.normalization.value = experiment.beamforming.normalization; elements.dynamicRange.value = experiment.beamforming.dynamicRangeDb; elements.arrayPreset.value = ARRAY_PRESET_URLS.has(state.arrayDefinition.id) ? state.arrayDefinition.id : "custom"; elements.gridPreset.value = experiment.scan.columns === experiment.scan.rows && [50, 100, 200].includes(experiment.scan.rows) ? String(experiment.scan.rows) : "custom"; } function describeSignal(signal) { if (signal.type === "tone") return `${formatNumber(signal.frequency, 0)} Hz tone`; if (signal.type === "noise") return `${signal.color} noise`; return `asset · ${signal.assetId}`; } function renderDocumentSummary() { const { arrayDefinition, scene, experiment } = state; if (!arrayDefinition || !scene || !experiment) return; elements.workspaceTitle.textContent = scene.name; elements.sceneMeta.textContent = `${scene.sampleRate.toLocaleString("ko-KR")} Hz · ${formatNumber(scene.duration, 3)} s · ` + `${scene.sources.length} source · scan z ${formatNumber(experiment.scan.z, 2)} m`; elements.arrayTitle.textContent = arrayDefinition.name; elements.microphoneCount.textContent = `${arrayDefinition.microphones.length} ch`; elements.arrayMeta.textContent = `${arrayDefinition.id} · ${formatNumber(arrayDefinition.aperture.width, 3)} × ` + `${formatNumber(arrayDefinition.aperture.height, 3)} m`; elements.sourceSummary.replaceChildren(); for (const source of scene.sources) { const line = document.createElement("div"); line.className = "source-line"; const name = document.createElement("strong"); name.textContent = source.name; const detail = document.createElement("span"); detail.textContent = `${describeSignal(source.signal)} · ` + `(${source.position.map((value) => formatNumber(value, 2)).join(", ")}) m`; line.append(name, detail); elements.sourceSummary.append(line); } arrayVisualization.render({ arrayDefinition }); } function renderAssets() { elements.assetList.replaceChildren(); if (!state.scene) return; const statuses = assetRuntime.statuses(state.scene); if (statuses.length === 0) { const empty = document.createElement("p"); empty.className = "asset-empty"; empty.textContent = "이 장면은 외부 asset 없이 바로 실행됩니다."; elements.assetList.append(empty); return; } for (const status of statuses) { const row = document.createElement("div"); row.className = `asset-status ${status.ready ? "is-ready" : status.error ? "is-error" : ""}`; const label = document.createElement("strong"); label.textContent = status.descriptor.fileName; const value = document.createElement("span"); value.textContent = status.ready ? "연결됨" : status.error ? "오류" : "파일 필요"; value.title = status.error ?? ""; row.append(label, value); elements.assetList.append(row); } } function renderBandEditors() { elements.bandList.replaceChildren(); if (!state.experiment) return; state.experiment.frequencyBands.forEach((band, index) => { const editor = document.createElement("div"); editor.className = "band-editor"; const fields = [ ["id", "text", band.id, "Band ID"], ["minHz", "number", band.minHz, "Min Hz"], ["maxHz", "number", band.maxHz, "Max Hz"], ["color", "color", band.color, "Color"], ]; for (const [key, type, value, label] of fields) { const input = document.createElement("input"); input.type = type; input.value = value; input.dataset.bandIndex = String(index); input.dataset.bandKey = key; input.setAttribute("aria-label", `${band.id} ${label}`); if (type === "number") { input.min = "0"; input.step = "1"; } editor.append(input); } const remove = document.createElement("button"); remove.type = "button"; remove.className = "band-editor__remove"; remove.dataset.removeBand = String(index); remove.textContent = "×"; remove.title = "대역 삭제"; remove.disabled = state.experiment.frequencyBands.length === 1 || Boolean(state.active); editor.append(remove); elements.bandList.append(editor); }); elements.addBandButton.disabled = state.experiment.frequencyBands.length >= 8 || Boolean(state.active); } function renderLegend() { elements.bandLegend.replaceChildren(); if (!state.experiment) return; for (const band of state.experiment.frequencyBands) { const label = document.createElement("label"); label.className = "legend-toggle"; label.style.setProperty("--band-color", band.color); const input = document.createElement("input"); input.type = "checkbox"; input.checked = state.visibleBandIds.has(band.id); input.dataset.visibleBand = band.id; const swatch = document.createElement("span"); swatch.className = "legend-swatch"; const text = document.createElement("span"); text.textContent = `${band.id} · ${formatNumber(band.minHz, 0)}–${formatNumber(band.maxHz, 0)} Hz`; label.append(input, swatch, text); elements.bandLegend.append(label); } } function renderWorkload() { if (!state.arrayDefinition || !state.scene || !state.experiment) return; try { const workload = estimateExperimentWorkload(state.arrayDefinition, state.scene, state.experiment); elements.workloadSummary.textContent = `${workload.pointCount.toLocaleString("ko-KR")} points × ${workload.microphoneCount} ch · ` + `${workload.frameCount} frames · ${workload.selectedBinCount} bins · ` + `약 ${formatCount(workload.complexMacCount)} complex MAC · steering ${formatNumber(workload.distanceMiB, 1)} MiB`; const heavyCpu = state.experiment.beamforming.backend === "cpu" && workload.complexMacCount > 5e8; elements.workloadSummary.classList.toggle("is-warning", heavyCpu || workload.pointCount >= 40000); } catch (error) { elements.workloadSummary.textContent = `연산량을 계산할 수 없습니다 · ${error.message}`; elements.workloadSummary.classList.add("is-warning"); } } function bandsForVisualization(lastRun) { if (!lastRun) return []; const definitions = new Map(lastRun.snapshot.experiment.frequencyBands.map((band) => [band.id, band])); const snapshotIds = [...definitions.keys()]; const currentIds = state.experiment.frequencyBands.map((band) => band.id); const sameBandSet = snapshotIds.length === currentIds.length && snapshotIds.every((id) => currentIds.includes(id)); const visibleBandIds = sameBandSet ? state.visibleBandIds : lastRun.visibleBandIds; return lastRun.run.bands.map((band) => ({ ...band, color: band.color ?? definitions.get(band.id)?.color ?? "#65f2bf", visible: visibleBandIds.has(band.id), })); } function renderSceneVisualization() { if (!state.scene || !state.experiment) return; const displayed = state.lastRun; const snapshot = displayed?.snapshot ?? { scene: state.scene, experiment: state.experiment }; sceneVisualization.render({ scene: snapshot.scene, experiment: snapshot.experiment, image: displayed ? displayed.imageElement : assetRuntime.imageElement, bands: bandsForVisualization(displayed), evaluations: displayed?.evaluations, dynamicRangeDb: snapshot.experiment.beamforming.dynamicRangeDb, opacity: Number(elements.overlayOpacity.value), showTruth: elements.showTruth.checked, }); elements.canvasMessage.classList.toggle("is-hidden", Boolean(displayed || assetRuntime.imageElement)); if (!displayed) { elements.canvasBadge.textContent = "결과 없음"; } else if (displayed.stale) { elements.canvasBadge.textContent = `이전 설정 결과 · ${displayed.run.backend.toUpperCase()}`; } else { elements.canvasBadge.textContent = `${displayed.run.backend.toUpperCase()} · ${displayed.snapshot.experiment.scan.columns}×` + `${displayed.snapshot.experiment.scan.rows} · ${formatNumber(displayed.run.timingMs.total, 0)} ms`; } } function appendMetric(label, value, detail, color) { const card = document.createElement("article"); card.className = "metric-card"; if (color) card.style.borderTopColor = color; const labelElement = document.createElement("span"); labelElement.textContent = label; const valueElement = document.createElement("strong"); valueElement.textContent = value; const detailElement = document.createElement("small"); detailElement.textContent = detail; card.append(labelElement, valueElement, detailElement); elements.metricGrid.append(card); } function renderResult() { const displayed = state.lastRun; elements.exportResultButton.disabled = Boolean(state.active) || !displayed; elements.metricGrid.replaceChildren(); elements.emptyResult.classList.toggle("is-hidden", Boolean(displayed)); elements.metricGrid.classList.toggle("is-hidden", !displayed); if (!displayed) { elements.resultBadge.textContent = "아직 실행하지 않음"; return; } elements.resultBadge.textContent = displayed.stale ? `이전 설정 · ${displayed.run.backend.toUpperCase()}` : `${displayed.run.backend.toUpperCase()} · ${displayed.inputHash.slice(0, 10)}`; appendMetric( "Backend", displayed.run.backend.toUpperCase(), displayed.run.fallback ? "WebGPU → CPU fallback" : "요청한 계산 경로", ); appendMetric("전체 시간", `${formatNumber(displayed.run.timingMs.total, 0)} ms`, "signal generation + DAS"); appendMetric( "신호 합성", `${formatNumber(displayed.run.timingMs.signalGeneration, 0)} ms`, `${displayed.snapshot.arrayDefinition.microphones.length} channels`, ); appendMetric( "Beamforming", `${formatNumber(displayed.run.timingMs.beamforming, 0)} ms`, `${displayed.snapshot.experiment.scan.columns}×${displayed.snapshot.experiment.scan.rows} grid`, ); for (const band of displayed.snapshot.experiment.frequencyBands) { const evaluation = displayed.evaluations.get(band.id); if (!evaluation) continue; const matchedErrors = evaluation.sourceMatches .filter((match) => match.withinTolerance && Number.isFinite(match.localizationErrorM)) .map((match) => match.localizationErrorM); const localization = matchedErrors.length ? Math.min(...matchedErrors) : null; appendMetric( `${band.id} · 위치 오차`, localization === null ? "미검출" : `${formatNumber(localization * 100, 1)} cm`, `${evaluation.counts.truePositive}/${evaluation.counts.truth} source`, band.color, ); appendMetric( `${band.id} · Truth energy`, `${formatNumber(evaluation.metrics.energyInsideTruthRatio * 100, 1)}%`, "ground-truth 반경 내", band.color, ); appendMetric( `${band.id} · Peak/sidelobe`, `${formatNumber(evaluation.metrics.peakToSidelobeDb, 1)} dB`, "높을수록 분리 우수", band.color, ); appendMetric( `${band.id} · Precision / Recall`, `${formatNumber(evaluation.metrics.precision, 2)} / ${formatNumber(evaluation.metrics.recall, 2)}`, `${evaluation.counts.detected} peaks`, band.color, ); appendMetric( `${band.id} · Main lobe`, `${formatNumber(evaluation.diagnostics.mainLobeWidthM * 100, 1)} cm`, "half-power equivalent diameter", band.color, ); } } function renderHistory() { elements.historyList.replaceChildren(); const signature = comparisonSignature(); const items = state.history.filter((item) => item.comparisonSignature === signature); if (items.length === 0) { const empty = document.createElement("div"); empty.className = "history-empty"; empty.textContent = "현재 scene·설정의 비교 실행 기록이 없습니다."; elements.historyList.append(empty); return; } for (const item of items) { const card = document.createElement("article"); card.className = "history-card"; const name = document.createElement("strong"); name.textContent = item.arrayName; const fields = [ `${item.backend.toUpperCase()} · ${item.pointCount.toLocaleString("ko-KR")} pt`, `${formatNumber(item.totalMs, 0)} ms`, item.localizationErrorM === null ? "오차 —" : `오차 ${formatNumber(item.localizationErrorM * 100, 1)} cm`, item.peakToSidelobeDb === null ? "PSR —" : `PSR ${formatNumber(item.peakToSidelobeDb, 1)} dB`, ]; card.append(name); for (const field of fields) { const value = document.createElement("span"); value.textContent = field; card.append(value); } elements.historyList.append(card); } } function validationWarnings() { const warnings = []; if (!state.scene || !state.experiment || !state.arrayDefinition) return warnings; const zMismatch = state.scene.sources.some( (source) => Math.abs(source.position[2] - state.experiment.scan.z) > 1e-6, ); if (zMismatch) { warnings.push("일부 source z가 scan z와 달라 ground truth에는 평면 투영값을 사용합니다."); } if ( state.scene.image?.plane && Math.abs(state.scene.image.plane.z - state.experiment.scan.z) > 1e-6 ) { warnings.push("배경 image plane z와 scan z가 다릅니다. 이미지는 x/y 좌표만 투영해 표시합니다."); } try { const workload = estimateExperimentWorkload(state.arrayDefinition, state.scene, state.experiment); if (state.experiment.beamforming.backend === "cpu" && workload.complexMacCount > 5e8) { warnings.push("CPU 계산량이 큽니다. Auto/WebGPU 또는 더 작은 grid를 권장합니다."); } else if (workload.pointCount >= 40000) { warnings.push("큰 grid입니다. 실행 중에는 취소 버튼으로 즉시 중단할 수 있습니다."); } } catch { // Contract validation reports the actionable error. } return warnings; } async function validateCurrent() { const sequence = ++state.validationSequence; if (!state.arrayDefinition || !state.scene || !state.experiment) { const validation = { valid: false, issues: [{ path: "$", message: "문서를 불러오는 중입니다." }], warnings: [], }; if (sequence === state.validationSequence) applyValidation(validation); return validation; } const revision = state.revision; const snapshot = structuredClone({ arrayDefinition: state.arrayDefinition, scene: state.scene, experiment: state.experiment, }); const [arrayIssues, sceneIssues, experimentIssues] = await Promise.all([ validateArrayDefinition(snapshot.arrayDefinition), validateScene(snapshot.scene), validateExperiment(snapshot.experiment, { arrayDefinition: snapshot.arrayDefinition, scene: snapshot.scene, }), ]); const issues = [ ...arrayIssues.map((issue) => ({ ...issue, path: `array${issue.path}` })), ...sceneIssues.map((issue) => ({ ...issue, path: `scene${issue.path}` })), ...experimentIssues.map((issue) => ({ ...issue, path: `experiment${issue.path}` })), ]; const missingAssets = assetRuntime.missingAssetIds(snapshot.scene); if (missingAssets.length > 0) { issues.push({ path: "scene.assets", message: `원본 파일을 연결하세요: ${missingAssets.join(", ")}`, }); } if (state.pendingInputOperations > 0) { issues.push({ path: "$", message: "선택한 파일을 확인하고 있습니다. 잠시 기다리세요." }); } const validation = { valid: issues.length === 0, issues, warnings: validationWarnings(), revision, }; if (sequence === state.validationSequence) applyValidation(validation); return validation; } function applyValidation(validation) { state.validation = validation; elements.validationCard.classList.remove("is-valid", "is-invalid", "is-warning"); if (!validation.valid) { elements.validationCard.classList.add("is-invalid"); elements.validationTitle.textContent = `${validation.issues.length}개 항목 확인 필요`; elements.validationMessage.textContent = validation.issues.slice(0, 2).map(formatIssue).join(" · "); } else if (validation.warnings.length > 0) { elements.validationCard.classList.add("is-warning"); elements.validationTitle.textContent = "실행 가능 · 주의"; elements.validationMessage.textContent = validation.warnings.join(" · "); } else { elements.validationCard.classList.add("is-valid"); elements.validationTitle.textContent = "실행 준비 완료"; elements.validationMessage.textContent = "계약 검증과 asset 연결 상태가 정상입니다."; } document.documentElement.dataset.cameraValid = String(validation.valid); if (!state.active) { elements.runButton.disabled = !validation.valid; if (validation.valid) { elements.runButton.textContent = state.lastRun ? "▶ 다시 실행" : "▶ 기본 예제 실행"; if (!state.lastRun && document.documentElement.dataset.runState === "idle") { setRunStatus( `${state.arrayDefinition.microphones.length} ch · ` + `${state.experiment.scan.columns}×${state.experiment.scan.rows} · 실행 준비 완료`, ); } } } } function scheduleValidation() { if (validationQueued) return; validationQueued = true; queueMicrotask(async () => { validationQueued = false; await validateCurrent(); }); } function sourcesForBand(scene, band) { return scene.sources.filter((source) => { if (source.signal.type !== "tone") return true; return source.signal.frequency >= band.minHz && source.signal.frequency <= band.maxHz; }); } function createAbortError() { const error = new Error("시뮬레이션이 취소되었습니다."); error.name = "AbortError"; return error; } function progressMessage(message) { if (message.message) return message.message; const progress = message.progress; if (progress?.totalBins) { return `DAS 계산 중 · ${progress.completedBins}/${progress.totalBins} bins`; } if (progress?.totalChunks) { return `WebGPU 계산 중 · ${progress.completedChunks}/${progress.totalChunks} chunks`; } return `계산 중 · ${message.stage ?? "worker"}`; } function runWorker(activity, payload, transfer) { return new Promise((resolve, reject) => { const worker = new Worker(new URL("./simulation-worker.js", import.meta.url), { type: "module" }); activity.worker = worker; activity.reject = reject; worker.addEventListener("message", (event) => { const message = event.data; if (message?.jobId !== activity.jobId || state.active !== activity || activity.finished) return; if (message.type === "progress") { document.documentElement.dataset.runStage = message.stage ?? "worker"; setRunStatus(progressMessage(message)); } else if (message.type === "fallback") { activity.fallback = { code: message.code, message: message.message }; setRunStatus(`WebGPU 사용 불가 · CPU로 계속합니다 (${message.code})`); } else if (message.type === "result") { activity.finished = true; resolve(message.run); } else if (message.type === "error") { activity.finished = true; const error = new Error(message.error?.message ?? "Worker 실행에 실패했습니다."); error.name = message.error?.name ?? "Error"; error.code = message.error?.code ?? ""; reject(error); } }); worker.addEventListener("error", (event) => { if (activity.finished) return; activity.finished = true; reject(new Error(event.message || "Simulation Worker를 시작하지 못했습니다.")); }); worker.postMessage(payload, transfer); }); } async function runSimulation() { if (state.active) return; const validation = await validateCurrent(); if (!validation.valid) { setTransientError( new Error(validation.issues.slice(0, 2).map(formatIssue).join(" · ")), "실행 전 검증 실패", ); return; } if (validation.revision !== state.revision || state.pendingInputOperations > 0) { setRunStatus("입력 파일 또는 설정이 갱신되었습니다. 검증 완료 후 다시 실행하세요."); await validateCurrent(); return; } const snapshot = { arrayDefinition: structuredClone(state.arrayDefinition), scene: structuredClone(state.scene), experiment: structuredClone(state.experiment), }; const activity = { jobId: `camera-${Date.now()}-${++jobSequence}`, revision: state.revision, abortController: new AbortController(), worker: null, reject: null, canceled: false, finished: false, }; state.active = activity; document.documentElement.dataset.runState = "running"; document.documentElement.dataset.runStage = "assets"; setConfigurationLocked(true); elements.runButton.textContent = "계산 중…"; setRunStatus("실행 입력을 고정하고 asset을 준비하고 있습니다…"); try { const inputHashPromise = hashSimulationInputs(snapshot); const assetBuffers = await assetRuntime.decodeAudioAssets( snapshot.scene, activity.abortController.signal, ({ completed, total, fileName }) => { if (state.active !== activity) return; setRunStatus( total > 0 ? `오디오 decode · ${completed}/${total}${fileName ? ` · ${fileName}` : ""}` : "Synthetic signal 준비 완료", ); }, ); if (activity.canceled || state.active !== activity) throw createAbortError(); document.documentElement.dataset.runStage = "worker"; const run = await runWorker( activity, { type: "run", jobId: activity.jobId, scene: snapshot.scene, arrayDefinition: snapshot.arrayDefinition, experiment: snapshot.experiment, assetBuffers, }, assetRuntime.transferListForAudio(assetBuffers), ); if (activity.canceled || state.active !== activity) throw createAbortError(); setRunStatus("Peak 검출과 정량 지표를 계산하고 있습니다…"); const evaluations = new Map(); const exportedEvaluations = new Map(); for (const band of run.bands) { const definition = snapshot.experiment.frequencyBands.find( (candidate) => candidate.id === band.id, ); const evaluation = evaluateDasBand({ rawMap: band.rawMap, scan: snapshot.experiment.scan, sources: sourcesForBand(snapshot.scene, definition), dynamicRangeDb: snapshot.experiment.beamforming.dynamicRangeDb, }); evaluations.set(band.id, evaluation); exportedEvaluations.set(band.id, exportBandEvaluation(evaluation)); } const inputHash = await inputHashPromise; const resultDocument = createResultDocument({ ...snapshot, run, evaluations: exportedEvaluations, inputHash, }); const resultIssues = await validateResult(resultDocument, { experiment: snapshot.experiment, scene: snapshot.scene, }); if (resultIssues.length > 0) { throw new Error( `Result 계약 검증 실패 · ${resultIssues.slice(0, 3).map(formatIssue).join(" · ")}`, ); } const completedRun = { snapshot, run, evaluations, exportedEvaluations, inputHash, resultDocument, stale: state.revision !== activity.revision, imageElement: assetRuntime.imageElement, visibleBandIds: new Set(state.visibleBandIds), }; state.lastRun = completedRun; const historyItem = createHistorySummary({ ...snapshot, run, evaluations: exportedEvaluations, inputHash, }); historyItem.comparisonSignature = comparisonSignature(snapshot.scene, snapshot.experiment); state.history.unshift(historyItem); state.history.length = Math.min(state.history.length, MAX_HISTORY_ITEMS); document.documentElement.dataset.runState = "done"; document.documentElement.dataset.runBackend = run.backend; document.documentElement.dataset.resultBandCount = String(run.bands.length); setRunStatus( `${run.backend.toUpperCase()} 완료 · ${formatNumber(run.timingMs.total, 0)} ms · ` + `hash ${inputHash.slice(0, 10)}`, ); renderResult(); renderHistory(); renderSceneVisualization(); } catch (error) { if (error?.name === "AbortError" || activity.canceled) { document.documentElement.dataset.runState = "canceled"; setRunStatus("계산을 취소했습니다. 입력과 직전 결과는 그대로 유지됩니다."); } else { document.documentElement.dataset.runState = "error"; setTransientError(error, "실행 실패"); } } finally { activity.worker?.terminate(); if (state.active === activity) state.active = null; setConfigurationLocked(false); elements.runButton.textContent = state.lastRun ? "▶ 다시 실행" : "▶ 기본 예제 실행"; renderBandEditors(); await validateCurrent(); } } function cancelSimulation() { const activity = state.active; if (!activity || activity.canceled) return; activity.canceled = true; activity.abortController.abort(createAbortError()); activity.worker?.terminate(); activity.reject?.(createAbortError()); setRunStatus("계산을 취소하는 중…"); } async function loadJson(url) { const response = await fetch(url); if (!response.ok) throw new Error(`${url} 응답 ${response.status}`); return response.json(); } async function loadArrayPreset(presetId) { const url = ARRAY_PRESET_URLS.get(presetId); if (!url) { elements.arrayInput.click(); syncControls(); return; } const generation = ++state.importGenerations.array; beginInputOperation(); try { setRunStatus(`${presetId} 비교 배열을 불러오고 있습니다…`); const value = await loadJson(url); if (generation !== state.importGenerations.array) return; const issues = await validateArrayDefinition(value); if (generation !== state.importGenerations.array) return; if (issues.length > 0) throw new Error(issues.slice(0, 4).map(formatIssue).join(" · ")); state.arrayDefinition = value; state.experiment.arrayId = value.id; markChanged({ syncControls: true }); setRunStatus(`${value.name} 배열을 선택했습니다. 실행하면 같은 설정 기록에 추가됩니다.`); } catch (error) { syncControls(); setTransientError(error, "비교 배열 불러오기 실패"); } finally { endInputOperation(); } } async function importDocument(kind, input) { const file = input.files?.[0]; input.value = ""; if (!file) return; const generation = ++state.importGenerations[kind]; beginInputOperation(); try { const value = await readJsonFile(file); if (generation !== state.importGenerations[kind]) return; let issues; if (kind === "array") { issues = await validateArrayDefinition(value); } else if (kind === "scene") { issues = await validateScene(value); } else { issues = await validateExperiment(value, { arrayDefinition: state.arrayDefinition, scene: state.scene, }); } if (generation !== state.importGenerations[kind]) return; if (issues.length > 0) throw new Error(issues.slice(0, 4).map(formatIssue).join(" · ")); if (kind === "array") { state.arrayDefinition = value; state.experiment.arrayId = value.id; } else if (kind === "scene") { state.scene = value; state.experiment.sceneId = value.id; alignExperimentToScene(state.experiment, value); assetRuntime.reset(); } else { state.experiment = value; } state.visibleBandIds = new Set(state.experiment.frequencyBands.map((band) => band.id)); markChanged({ syncControls: true, renderBands: true }); renderAssets(); setRunStatus(`${file.name} ${kind} 문서를 불러왔습니다.`); } catch (error) { setTransientError(error, `${file.name} 불러오기 실패`); } finally { endInputOperation(); } } function installDocumentInputListeners() { elements.experimentName.addEventListener("input", () => { state.experiment.name = elements.experimentName.value; markChanged(); }); elements.experimentId.addEventListener("input", () => { state.experiment.id = elements.experimentId.value; markChanged(); }); elements.sceneDistance.addEventListener("change", () => { const value = readFiniteInput(elements.sceneDistance); if (value === null) return syncControls(); shiftSceneDistance(state.scene, state.experiment, value); markChanged(); }); elements.speedOfSound.addEventListener("change", () => { const value = readFiniteInput(elements.speedOfSound); if (value === null) return syncControls(); state.scene.speedOfSound = value; markChanged(); }); elements.sceneSeed.addEventListener("change", () => { const value = readFiniteInput(elements.sceneSeed); if (value === null) return syncControls(); state.scene.seed = value; markChanged(); }); elements.backend.addEventListener("change", () => { state.experiment.beamforming.backend = elements.backend.value; markChanged(); }); elements.gridPreset.addEventListener("change", () => { if (elements.gridPreset.value === "custom") return; const side = Number(elements.gridPreset.value); state.experiment.scan.columns = side; state.experiment.scan.rows = side; markChanged({ syncControls: true }); }); const scanBindings = [ [elements.scanColumns, "columns", true], [elements.scanRows, "rows", true], [elements.scanXMin, "xMin", false], [elements.scanXMax, "xMax", false], [elements.scanYMin, "yMin", false], [elements.scanYMax, "yMax", false], ]; for (const [element, key, integer] of scanBindings) { element.addEventListener("change", () => { const value = readFiniteInput(element); if (value === null) return syncControls(); state.experiment.scan[key] = integer ? Math.trunc(value) : value; markChanged({ syncControls: true }); }); } elements.fftSize.addEventListener("change", () => { state.experiment.stft.fftSize = Number(elements.fftSize.value); if (state.experiment.stft.hopSize > state.experiment.stft.fftSize) { state.experiment.stft.hopSize = state.experiment.stft.fftSize / 2; } markChanged({ syncControls: true }); }); elements.hopSize.addEventListener("change", () => { const value = readFiniteInput(elements.hopSize); if (value === null) return syncControls(); state.experiment.stft.hopSize = Math.trunc(value); markChanged({ syncControls: true }); }); elements.normalization.addEventListener("change", () => { state.experiment.beamforming.normalization = elements.normalization.value; markChanged(); }); elements.dynamicRange.addEventListener("change", () => { const value = readFiniteInput(elements.dynamicRange); if (value === null) return syncControls(); state.experiment.beamforming.dynamicRangeDb = value; markChanged(); }); } function installBandListeners() { elements.bandList.addEventListener("change", (event) => { const input = event.target.closest("input[data-band-index]"); if (!input) return; const band = state.experiment.frequencyBands[Number(input.dataset.bandIndex)]; if (!band) return; const key = input.dataset.bandKey; if (key === "id") { const previousId = band.id; band.id = input.value; state.visibleBandIds.delete(previousId); state.visibleBandIds.add(band.id); } else if (key === "color") { band.color = input.value; } else if (Number.isFinite(input.valueAsNumber)) { band[key] = input.valueAsNumber; } markChanged(); }); elements.bandList.addEventListener("click", (event) => { const button = event.target.closest("button[data-remove-band]"); if (!button || state.experiment.frequencyBands.length === 1) return; const [removed] = state.experiment.frequencyBands.splice(Number(button.dataset.removeBand), 1); state.visibleBandIds.delete(removed.id); markChanged({ renderBands: true }); }); elements.addBandButton.addEventListener("click", () => { if (state.experiment.frequencyBands.length >= 8) return; const used = new Set(state.experiment.frequencyBands.map((band) => band.id)); let suffix = state.experiment.frequencyBands.length + 1; while (used.has(`band-${suffix}`)) suffix += 1; const previous = state.experiment.frequencyBands.at(-1); const nyquist = state.scene.sampleRate / 2; const minHz = Math.min(Math.max(0, previous?.maxHz ?? 900), nyquist - 101); const band = { id: `band-${suffix}`, minHz, maxHz: Math.min(nyquist - 1, minHz + 200), color: BAND_COLORS[state.experiment.frequencyBands.length % BAND_COLORS.length], }; state.experiment.frequencyBands.push(band); state.visibleBandIds.add(band.id); markChanged({ renderBands: true }); }); elements.bandLegend.addEventListener("change", (event) => { const input = event.target.closest("input[data-visible-band]"); if (!input) return; if (input.checked) state.visibleBandIds.add(input.dataset.visibleBand); else state.visibleBandIds.delete(input.dataset.visibleBand); renderSceneVisualization(); }); } function installActionListeners() { elements.runButton.addEventListener("click", runSimulation); elements.cancelButton.addEventListener("click", cancelSimulation); elements.sceneInput.addEventListener("change", () => importDocument("scene", elements.sceneInput)); elements.arrayInput.addEventListener("change", () => importDocument("array", elements.arrayInput)); elements.experimentInput.addEventListener( "change", () => importDocument("experiment", elements.experimentInput), ); elements.arrayPreset.addEventListener("change", () => loadArrayPreset(elements.arrayPreset.value)); elements.assetInput.addEventListener("change", async () => { const files = [...(elements.assetInput.files ?? [])]; elements.assetInput.value = ""; if (files.length === 0) return; beginInputOperation(); try { setRunStatus(`${files.length}개 asset의 SHA-256을 확인하고 있습니다…`); const report = await assetRuntime.bindFiles(files, state.scene); if (report.rejected.length > 0) { setTransientError( new Error(report.rejected.map((item) => `${item.fileName}: ${item.message}`).join(" · ")), "Asset 연결 실패", ); } else { setRunStatus(`${report.bound.length}개 asset descriptor를 연결했습니다.`); } } catch (error) { setTransientError(error, "Asset 연결 실패"); } finally { renderAssets(); endInputOperation(); } }); elements.overlayOpacity.addEventListener("input", renderSceneVisualization); elements.showTruth.addEventListener("change", renderSceneVisualization); elements.exportExperimentButton.addEventListener("click", async () => { const issues = await validateExperiment(state.experiment, { arrayDefinition: state.arrayDefinition, scene: state.scene, }); if (issues.length > 0) { setTransientError( new Error(issues.slice(0, 4).map(formatIssue).join(" · ")), "Experiment 저장 실패", ); return; } downloadJson(state.experiment, `${safeFileStem(state.experiment.id)}.experiment.json`); setRunStatus("Experiment JSON을 저장했습니다."); }); elements.exportResultButton.addEventListener("click", () => { if (!state.lastRun) return; downloadJson( state.lastRun.resultDocument, `${safeFileStem(state.lastRun.snapshot.experiment.id)}.result.json`, ); setRunStatus("검증된 Result JSON을 저장했습니다."); }); elements.clearHistoryButton.addEventListener("click", () => { const signature = comparisonSignature(); state.history = state.history.filter((item) => item.comparisonSignature !== signature); renderHistory(); }); window.addEventListener("beforeunload", () => { cancelSimulation(); sceneVisualization.destroy(); arrayVisualization.destroy(); assetRuntime.dispose(); }); } async function initialize() { installDocumentInputListeners(); installBandListeners(); installActionListeners(); setEditableControlsDisabled(true); document.documentElement.dataset.runState = "idle"; try { const [arrayDefinition, scene, experiment] = await Promise.all([ loadJson(DEFAULT_DOCUMENT_URLS.arrayDefinition), loadJson(DEFAULT_DOCUMENT_URLS.scene), loadJson(DEFAULT_DOCUMENT_URLS.experiment), ]); state.arrayDefinition = arrayDefinition; state.scene = scene; state.experiment = experiment; state.visibleBandIds = new Set(experiment.frequencyBands.map((band) => band.id)); syncControls(); renderBandEditors(); renderDocumentSummary(); renderAssets(); renderLegend(); renderWorkload(); renderResult(); renderHistory(); renderSceneVisualization(); await validateCurrent(); setEditableControlsDisabled(false); renderBandEditors(); elements.runButton.disabled = !state.validation.valid; document.documentElement.dataset.appReady = "true"; } catch (error) { document.documentElement.dataset.appReady = "error"; setTransientError(error, "기본 fixture 초기화 실패"); } } initialize();