feat: protect unsaved camera work
This commit is contained in:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user