feat(E04): 화면 열쇠에 조건 · 건기 · 우기 결과 함께 표시 · 열 때 저장 설정 되살림 · [다시 해석] 뺌 (PLAN 564-2)

- 구간 목록 = 구간마다 건기 · 우기 안전율 둘 다 · 고른 조건 결과를 엶
- 캐시가 없으면 저장된 결과의 탐색 설정으로 칸을 채움
- [다시 해석] 뺌 — 엔진이 바뀌면 지문(엔진 파일 해시)이 바뀌어 [해석]이 다시 셈
- [기본값]이 조건을 건기로 되돌리지 않음

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-27 20:00:22 +09:00
co-authored by Claude Opus 5.5
parent f747e41e28
commit 205bc275cd
3 changed files with 54 additions and 37 deletions
+50 -34
View File
@@ -4,7 +4,8 @@
* 좌 = 구간 목록 · 탐색 설정(기본 · 고급 접힘) · [해석] · 진행률 · [끊기]
* 가운데 = 평면 지도(셀별 최소 안전율 · 임계 체적) · [3D 보기](지표 + 노선면 + 임계 활동면)
* 우 = 구간 결과(3D 최소 · 2D 최소(같은 구간 E03 측점 · 3D 와 같은 조건 안전율 최소) · 3D/2D 비 · 체적 · 방법)
* 계산 = 서버 단독(POST …/stab/analyze3d · 뒤에서) · 화면은 값 · 그림만. 탐색 설정 = sessionStorage.
* 계산 = 서버 단독(POST …/stab/analyze3d · 뒤에서) · 화면은 값 · 그림만. 탐색 설정 = sessionStorage
* · 없으면 저장된 결과의 설정 · 결과 열쇠 = 구간 + 조건(건기 · 우기 따로 남음).
* ========================================================================== */
import "./E04_Stab3D_UI_Style.css";
@@ -44,6 +45,7 @@ interface SavedSummary {
f_min: number;
volume: number;
method: string;
condition: string;
stale: boolean;
}
@@ -67,7 +69,10 @@ interface Progress {
}
const cacheKey = (projectId: string): string => `e04_settings:${projectId}`;
const sectionKey = (start: number, end: number): string => `${start.toFixed(2)}_${end.toFixed(2)}`;
const CONDITIONS = ["dry", "wet"] as const;
const rangeKey = (start: number, end: number): string => `${start.toFixed(2)}_${end.toFixed(2)}`;
const sectionKey = (start: number, end: number, condition: string): string =>
`${rangeKey(start, end)}_${condition}`;
async function api<T>(path: string, init?: RequestInit): Promise<T> {
const response = await fetch(`${API_BASE_URL}${path}`, {
@@ -218,16 +223,11 @@ export function renderE04Stab3D(root: HTMLElement): void {
const analyzeButton = createButton({
label: t("E04_Analyze"),
variant: "filled",
onClick: () => void analyze(false),
onClick: () => void analyze(),
});
const reanalyzeButton = createButton({
label: t("E04_Reanalyze"),
variant: "ghost",
onClick: () => void analyze(true),
});
analyzeButton.disabled = reanalyzeButton.disabled = true;
analyzeButton.disabled = true;
const actions = el("div", { className: "ui-sidebar-actions" });
actions.append(reanalyzeButton, analyzeButton);
actions.append(analyzeButton);
const readOnly = note(t("E04_ReadOnly"), "e04-hint");
readOnly.hidden = true;
panel.append(
@@ -252,40 +252,46 @@ export function renderE04Stab3D(root: HTMLElement): void {
let shownKey = "";
settings.onChange(() => {
remember();
const { start_m, end_m } = settings.read();
const { start_m, end_m, condition } = settings.read();
map.setRange(start_m, end_m);
paintSections();
if (shownKey !== sectionKey(start_m, end_m)) void openResult();
if (shownKey !== sectionKey(start_m, end_m, condition)) void openResult();
});
function paintButtons(): void {
const editable = Boolean(overview?.editable);
analyzeButton.disabled = reanalyzeButton.disabled = !editable || running || !overview;
analyzeButton.disabled = !editable || running || !overview;
readOnly.hidden = !overview || editable;
}
function paintSections(): void {
if (!overview) return;
const { start_m, end_m } = settings.read();
const { start_m, end_m, condition } = settings.read();
const savedOf = new Map(overview.saved.map((s) => [s.key, s]));
const custom = overview.saved.filter(
(s) => !overview!.sections.some((x) => sectionKey(x.start_m, x.end_m) === s.key),
);
const items = [
...overview.sections,
...custom.map((s) => ({ start_m: s.start_m, end_m: s.end_m })),
];
const items = new Map(overview.sections.map((x) => [rangeKey(x.start_m, x.end_m), x]));
for (const s of overview.saved) {
const key = rangeKey(s.start_m, s.end_m);
if (!items.has(key)) items.set(key, { start_m: s.start_m, end_m: s.end_m });
}
sectionList.replaceChildren(
...items.map((section) => {
const key = sectionKey(section.start_m, section.end_m);
const saved = savedOf.get(key);
...[...items].map(([key, section]) => {
const byCondition = CONDITIONS.map((c) => savedOf.get(`${key}_${c}`));
const saved = savedOf.get(`${key}_${condition}`);
const row = createListRow({
label: `${section.start_m.toFixed(0)} – ${section.end_m.toFixed(0)} m`,
trailing: saved
? `Fs ${saved.f_min.toFixed(2)}${saved.stale ? ` · ${t("E04_Stale")}` : ""}`
: "",
trailing: byCondition
.flatMap((x) =>
x
? [
`${t(`E04_Short_${x.condition}` as LocaleKey)} ${x.f_min.toFixed(2)}${
x.stale ? ` ${t("E04_Stale")}` : ""
}`,
]
: [],
)
.join(" · "),
stripe: saved ? stepColor(FS_STEPS, saved.f_min) : "transparent",
active: key === sectionKey(start_m, end_m),
active: key === rangeKey(start_m, end_m),
onClick: () => settings.setRange(section.start_m, section.end_m),
});
row.classList.add("e04-section");
@@ -364,15 +370,19 @@ export function renderE04Stab3D(root: HTMLElement): void {
resultBody.replaceChildren(list, ...notes);
}
const currentKey = (): string => {
const { start_m, end_m, condition } = settings.read();
return sectionKey(start_m, end_m, condition);
};
async function openResult(): Promise<void> {
const { start_m, end_m } = settings.read();
const key = sectionKey(start_m, end_m);
const key = currentKey();
shownKey = key;
const saved = overview?.saved.some((s) => s.key === key);
result = saved
? await api<Result3d>(`/projects/${projectId}/stab/result3d?key=${key}`).catch(() => null)
: null;
if (key !== sectionKey(settings.read().start_m, settings.read().end_m)) return; // 그새 바뀜
if (key !== currentKey()) return; // 그새 바뀜
map.setResult(result);
if (mode === "3d") view3d.show(result, overview?.line ?? [], layer);
paintResult();
@@ -405,13 +415,13 @@ export function renderE04Stab3D(root: HTMLElement): void {
}
}
async function analyze(force: boolean): Promise<void> {
async function analyze(): Promise<void> {
const values = settings.read();
const { start_m, end_m, condition, ...search } = values;
try {
const body = await api<{ status: string }>(`/projects/${projectId}/stab/analyze3d`, {
method: "POST",
body: JSON.stringify({ start_m, end_m, condition, search, force }),
body: JSON.stringify({ start_m, end_m, condition, search }),
});
if (body.status === "cached") {
showToast(t("E04_Cached"), "info");
@@ -446,7 +456,7 @@ export function renderE04Stab3D(root: HTMLElement): void {
...ov.defaults,
start_m: first?.start_m ?? 0,
end_m: first?.end_m ?? 100,
condition: "dry",
condition: ov.saved[0]?.condition ?? "dry",
},
);
map.setRoute(ov.line, ov.stations);
@@ -455,6 +465,12 @@ export function renderE04Stab3D(root: HTMLElement): void {
paintSections();
stations2d = await fetch2d(projectId);
await openResult();
// 캐시가 없으면 저장된 결과의 탐색 설정을 되살림
const opened = result as Result3d | null; // openResult 가 바꿈(흐름 좁힘 풀기)
if (!cached && opened?.settings) {
settings.write(opened.settings as Partial<Settings>);
remember();
}
const p = await api<Progress>(`/projects/${projectId}/stab/progress3d`).catch(() => null);
if (p?.running) void poll();
})();
+2 -2
View File
@@ -98,8 +98,8 @@ export function buildSettingsCards() {
variant: "ghost",
size: "sm",
onClick: () => {
const range = { start_m: read().start_m, end_m: read().end_m };
write({ ...read(), ...defaults, ...range, condition: "dry" });
const { start_m, end_m, condition } = read(); // 구간 · 조건은 그대로
write({ ...read(), ...defaults, start_m, end_m, condition });
changed();
},
}),
+2 -1
View File
@@ -39,7 +39,6 @@ export const ui_locales_e04 = {
E04_Condition_wet: ["우기 (지표까지 포화)", "Wet (saturated to surface)"],
E04_Defaults: ["기본값", "Defaults"],
E04_Analyze: ["해석", "Analyze"],
E04_Reanalyze: ["다시 해석", "Re-analyze"],
E04_Cancel: ["끊기", "Stop"],
E04_Running: ["해석 중", "Analyzing"],
E04_Cancelled: ["해석을 끊었습니다.", "Analysis stopped."],
@@ -48,6 +47,8 @@ export const ui_locales_e04 = {
E04_ReadOnly: ["보기만 허용된 프로젝트입니다.", "View-only project."],
E04_Saved: ["결과 있음", "Saved"],
E04_Stale: ["옛 결과", "Outdated"],
E04_Short_dry: ["건기", "Dry"],
E04_Short_wet: ["우기", "Wet"],
/* --- 가운데 지도 · 3D --- */
E04_View_Plan: ["평면 지도", "Plan map"],