feat: protect unsaved camera work

This commit is contained in:
Codex
2026-08-28 13:09:55 +09:00
parent 513de2b51f
commit b03eccde50
10 changed files with 192 additions and 17 deletions
+8
View File
@@ -65,6 +65,14 @@ WebGPU가 감지돼도 실제 장치는 실행 시 확인합니다. 계산 중 *
- rectangular와 Fermat spiral preset을 같은 Scene/Experiment에서 연속 실행하면 **배열 비교 기록**에
backend, timing과 평가 요약이 남습니다.
- 입력을 바꾼 뒤 화면에 남은 결과는 이전 실행 snapshot이며 UI가 이전 설정 결과임을 표시합니다.
- Camera에서 거리·음속·seed를 바꾼 Scene과 scan·STFT·band를 바꾼 Experiment는 우측의 각
**JSON 저장** 버튼으로 보존할 수 있습니다.
- **전체 기록 JSON**은 세션에 남은 모든 배열 비교 요약을
`micarrsim.comparison-history` 문서로 저장합니다.
미저장 Scene·Experiment·Result·비교 기록은 우측 저장 상태에 표시됩니다. 직전 Result를 저장하지
않고 새 실행을 시작하면 교체 여부를 확인하며, 미저장 항목이나 실행 중 계산이 있는 상태에서
페이지를 닫으면 브라우저가 한 번 더 확인합니다.
## Result JSON
+8 -2
View File
@@ -61,7 +61,7 @@
</section>
<section class="history-section">
<div class="section-heading"><div><p class="eyebrow">Same-scene comparison</p><h2>배열 비교 기록</h2></div><button class="text-button" id="clear-history-button" type="button">기록 비우기</button></div>
<div class="section-heading"><div><p class="eyebrow">Same-scene comparison</p><h2>배열 비교 기록</h2></div><div class="history-actions"><button class="text-button" id="export-history-button" type="button" disabled>전체 기록 JSON</button><button class="text-button" id="clear-history-button" type="button">기록 비우기</button></div></div>
<p class="section-copy">비교 배열 또는 Array JSON을 바꿔 다시 실행하면 같은 scene·scan·STFT 결과를 이 세션에서 나란히 비교합니다.</p>
<div class="history-list" id="history-list"></div>
</section>
@@ -124,7 +124,13 @@
<div class="band-editor-list" id="band-list"></div>
</section>
<section class="output-card"><button class="button button--ghost button--full" id="export-experiment-button" type="button">Experiment JSON 저장</button></section>
<section class="output-card">
<div class="output-actions">
<button class="button button--ghost" id="export-scene-button" type="button">Scene JSON 저장</button>
<button class="button button--ghost" id="export-experiment-button" type="button">Experiment JSON 저장</button>
</div>
<p class="output-save-state" id="output-save-state">저장하지 않은 변경 없음</p>
</section>
<section class="validation-card" aria-live="polite">
<div class="validation-dot" id="validation-dot"></div>
+100 -5
View File
@@ -82,7 +82,10 @@ const elements = {
postprocess: byId("postprocess"),
addBandButton: byId("add-band-button"),
bandList: byId("band-list"),
exportSceneButton: byId("export-scene-button"),
exportExperimentButton: byId("export-experiment-button"),
exportHistoryButton: byId("export-history-button"),
outputSaveState: byId("output-save-state"),
validationCard: document.querySelector(".validation-card"),
validationTitle: byId("validation-title"),
validationMessage: byId("validation-message"),
@@ -101,6 +104,9 @@ const state = {
active: null,
pendingInputOperations: 0,
importGenerations: { array: 0, scene: 0, experiment: 0 },
sceneDirty: false,
experimentDirty: false,
resultDirty: false,
};
const sceneVisualization = createSceneVisualization(elements.sceneCanvas);
@@ -141,6 +147,29 @@ function setRunStatus(message) {
elements.runStatus.textContent = message;
}
function unsavedWorkLabels() {
const labels = [];
if (state.sceneDirty) labels.push("Scene");
if (state.experimentDirty) labels.push("Experiment");
if (state.resultDirty) labels.push("Result");
if (state.history.some((item) => !item.comparisonSaved)) labels.push("비교 기록");
return labels;
}
function syncUnsavedState() {
const labels = unsavedWorkLabels();
document.documentElement.dataset.unsavedWork = labels.join(",");
elements.outputSaveState.textContent = labels.length > 0
? `미저장 · ${labels.join(" · ")}`
: "저장하지 않은 변경 없음";
elements.outputSaveState.classList.toggle("is-dirty", labels.length > 0);
elements.exportHistoryButton.disabled = state.history.length === 0;
}
function hasUnsavedWork() {
return Boolean(state.active) || unsavedWorkLabels().length > 0;
}
function setTransientError(error, prefix = "오류") {
const message = error instanceof Error ? error.message : String(error);
setRunStatus(`${prefix} · ${message}`);
@@ -194,6 +223,9 @@ function readFiniteInput(element) {
}
function markChanged(options = {}) {
const dirty = options.dirty ?? "experiment";
if (dirty === "scene" || dirty === "both") state.sceneDirty = true;
if (dirty === "experiment" || dirty === "both") state.experimentDirty = true;
state.revision += 1;
if (state.lastRun) state.lastRun.stale = true;
if (["error", "canceled"].includes(document.documentElement.dataset.runState)) {
@@ -207,6 +239,7 @@ function markChanged(options = {}) {
renderSceneVisualization();
renderResult();
renderHistory();
syncUnsavedState();
scheduleValidation();
}
@@ -496,6 +529,7 @@ function renderResult() {
function renderHistory() {
elements.historyList.replaceChildren();
elements.exportHistoryButton.disabled = state.history.length === 0;
const signature = comparisonSignature();
const items = state.history.filter((item) => item.comparisonSignature === signature);
if (items.length === 0) {
@@ -718,6 +752,17 @@ async function runSimulation() {
await validateCurrent();
return;
}
if (
state.resultDirty &&
!window.confirm("직전 Result를 아직 저장하지 않았습니다. 새 실행으로 교체할까요?")
) {
setRunStatus("직전 Result를 먼저 저장한 뒤 다시 실행할 수 있습니다.");
return;
}
if (state.resultDirty) {
state.resultDirty = false;
syncUnsavedState();
}
const snapshot = {
arrayDefinition: structuredClone(state.arrayDefinition),
scene: structuredClone(state.scene),
@@ -818,8 +863,11 @@ async function runSimulation() {
inputHash,
});
historyItem.comparisonSignature = comparisonSignature(snapshot.scene, snapshot.experiment);
historyItem.comparisonSaved = false;
state.history.unshift(historyItem);
state.history.length = Math.min(state.history.length, MAX_HISTORY_ITEMS);
state.resultDirty = true;
syncUnsavedState();
document.documentElement.dataset.runState = "done";
document.documentElement.dataset.runBackend = run.backend;
document.documentElement.dataset.resultBandCount = String(run.bands.length);
@@ -921,12 +969,18 @@ async function importDocument(kind, input) {
state.scene = value;
state.experiment.sceneId = value.id;
alignExperimentToScene(state.experiment, value);
state.sceneDirty = false;
assetRuntime.reset();
} else {
state.experiment = value;
state.experimentDirty = false;
}
state.visibleBandIds = new Set(state.experiment.frequencyBands.map((band) => band.id));
markChanged({ syncControls: true, renderBands: true });
markChanged({
syncControls: true,
renderBands: true,
dirty: kind === "experiment" ? "none" : "experiment",
});
renderAssets();
setRunStatus(`${file.name} ${kind} 문서를 불러왔습니다.`);
} catch (error) {
@@ -949,19 +1003,19 @@ function installDocumentInputListeners() {
const value = readFiniteInput(elements.sceneDistance);
if (value === null) return syncControls();
shiftSceneDistance(state.scene, state.experiment, value);
markChanged();
markChanged({ dirty: "both" });
});
elements.speedOfSound.addEventListener("change", () => {
const value = readFiniteInput(elements.speedOfSound);
if (value === null) return syncControls();
state.scene.speedOfSound = value;
markChanged();
markChanged({ dirty: "scene" });
});
elements.sceneSeed.addEventListener("change", () => {
const value = readFiniteInput(elements.sceneSeed);
if (value === null) return syncControls();
state.scene.seed = value;
markChanged();
markChanged({ dirty: "scene" });
});
elements.backend.addEventListener("change", () => {
state.experiment.beamforming.backend = elements.backend.value;
@@ -1103,6 +1157,20 @@ function installActionListeners() {
});
elements.overlayOpacity.addEventListener("input", renderSceneVisualization);
elements.showTruth.addEventListener("change", renderSceneVisualization);
elements.exportSceneButton.addEventListener("click", async () => {
const issues = await validateScene(state.scene);
if (issues.length > 0) {
setTransientError(
new Error(issues.slice(0, 4).map(formatIssue).join(" · ")),
"Scene 저장 실패",
);
return;
}
downloadJson(state.scene, `${safeFileStem(state.scene.id)}.scene.json`);
state.sceneDirty = false;
syncUnsavedState();
setRunStatus("Scene JSON을 저장했습니다.");
});
elements.exportExperimentButton.addEventListener("click", async () => {
const issues = await validateExperiment(state.experiment, {
arrayDefinition: state.arrayDefinition,
@@ -1116,6 +1184,8 @@ function installActionListeners() {
return;
}
downloadJson(state.experiment, `${safeFileStem(state.experiment.id)}.experiment.json`);
state.experimentDirty = false;
syncUnsavedState();
setRunStatus("Experiment JSON을 저장했습니다.");
});
elements.exportResultButton.addEventListener("click", () => {
@@ -1124,14 +1194,38 @@ function installActionListeners() {
state.lastRun.resultDocument,
`${safeFileStem(state.lastRun.snapshot.experiment.id)}.result.json`,
);
state.resultDirty = false;
syncUnsavedState();
setRunStatus("검증된 Result JSON을 저장했습니다.");
});
elements.exportHistoryButton.addEventListener("click", () => {
if (state.history.length === 0) return;
const items = state.history.map(({ comparisonSignature, comparisonSaved, ...item }) => item);
downloadJson(
{
schemaVersion: "1.0.0",
kind: "micarrsim.comparison-history",
createdAt: new Date().toISOString(),
items,
},
"micarrsim.comparison-history.json",
);
for (const item of state.history) item.comparisonSaved = true;
syncUnsavedState();
setRunStatus("전체 배열 비교 기록 JSON을 저장했습니다.");
});
elements.clearHistoryButton.addEventListener("click", () => {
const signature = comparisonSignature();
state.history = state.history.filter((item) => item.comparisonSignature !== signature);
renderHistory();
syncUnsavedState();
});
window.addEventListener("beforeunload", () => {
window.addEventListener("beforeunload", (event) => {
if (window.top !== window || !hasUnsavedWork()) return;
event.preventDefault();
event.returnValue = "";
});
window.addEventListener("unload", () => {
cancelSimulation();
sceneVisualization.destroy();
arrayVisualization.destroy();
@@ -1164,6 +1258,7 @@ async function initialize() {
renderResult();
renderHistory();
renderSceneVisualization();
syncUnsavedState();
await validateCurrent();
setEditableControlsDisabled(false);
renderBandEditors();
+1
View File
@@ -224,6 +224,7 @@ export function createHistorySummary({ arrayDefinition, scene, experiment, run,
const firstMatchedPeak = evaluation?.peaks.find((peak) => peak.sourceId) ?? evaluation?.peaks[0];
return {
id: `${Date.now()}-${inputHash.slice(0, 8)}`,
createdAt: new Date().toISOString(),
arrayId: arrayDefinition.id,
arrayName: arrayDefinition.name,
sceneId: scene.id,
+5 -1
View File
@@ -46,6 +46,7 @@
.metric-card small { display: block; margin-top: 5px; color: var(--muted); font-size: .62rem; }
.text-button, .mini-button { padding: 5px 7px; border: 1px solid transparent; border-radius: 8px; color: var(--muted); background: transparent; cursor: pointer; font-size: .68rem; }
.history-list { display: grid; gap: 8px; margin-top: 14px; }
.history-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 4px; }
.history-empty { padding: 12px; color: var(--muted); font-size: .72rem; text-align: center; }
.history-card { display: grid; grid-template-columns: minmax(150px, 1.5fr) repeat(4, minmax(85px, .75fr)); gap: 10px; align-items: center; padding: 12px 14px; border: 1px solid var(--line); border-radius: 12px; background: #0b1714; }
.history-card strong, .history-card span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
@@ -78,6 +79,9 @@
.asset-status.is-error span { color: var(--danger); }
.asset-empty { color: var(--muted); font-size: .68rem; }
.builder-link { display: block; margin-top: 11px; color: var(--cyan); font-size: .68rem; text-decoration: none; }
.output-actions { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
.output-save-state { margin: 9px 0 0; color: var(--muted); font-size: .65rem; text-align: center; }
.output-save-state.is-dirty { color: var(--amber); }
.field { display: grid; min-width: 0; gap: 6px; }
.field > span { color: var(--muted); font-size: .67rem; font-weight: 650; }
.field input, .field select, .band-editor input { width: 100%; min-height: 37px; padding: 8px 9px; border: 1px solid var(--line); border-radius: 9px; color: var(--text); background: #0b1714; }
@@ -121,7 +125,7 @@
.history-card strong { grid-column: 1 / -1; }
}
@media (max-width: 430px) {
.field-grid, .band-editor { grid-template-columns: 1fr; }
.field-grid, .band-editor, .output-actions { grid-template-columns: 1fr; }
.band-editor__remove { min-height: 34px; }
.metric-grid { grid-template-columns: 1fr 1fr; }
}