1179 lines
44 KiB
JavaScript
1179 lines
44 KiB
JavaScript
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();
|