Files
Aislo/E04_Stab3D/E04_Stab3D_UI_Page.ts
eomsangdonandClaude Opus 5.5 c3fbcb13f1 feat(D · E): 떠남 = 공용 물음 [취소 · 폐기 · 저장] · 캐시 = 등록표 · 프로젝트 = 탭 currentProjectId (PLAN 572-3 · 요구 400~403)
- D02 · D03 · E02 말없이 저장 → mountGroupStep {dirty, save, discard} 공용 물음
- D04 · E05 · E06 가드 새로 · E03 = 대표 단면 별 저장 진행 중일 때만 물음
- D02 · E02~E07 세션 직접 읽기 · 쓰기 → 등록표 d02-points · e02-ground · e03-prob · e04-settings · e05-report · e06-rock · e07-settings
- 아홉 페이지 localStorage 지금 프로젝트 직접 읽기 → currentProjectId() (다른 탭이 바꿔도 이 탭 그대로)
- test_560_4 D · E 줄 넓힘(옛 beforeLeave 단언 → dirty · save) · test_401 PENDING 에서 D · E 뺌

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 18:09:08 +09:00

494 lines
18 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =============================================================================
* E04_Stab3D_UI_Page.ts
* E04 안정성평가 3D 해석(요구 347-1 · 요구서 4-2 · 4-3 · PLAN 533-3) — B05~B09 틀(group_workflow).
* 좌 = 구간 목록 · 탐색 설정(기본 · 고급 접힘) · [해석] · 진행률 · [끊기]
* 가운데 = 평면 지도(셀별 최소 안전율 · 임계 체적) · [3D 보기](지표 + 노선면 + 임계 활동면)
* 우 = 구간 결과(3D 최소 · 2D 최소(같은 구간 E03 측점 · 3D 와 같은 조건 안전율 최소) · 3D/2D 비 · 체적 · 방법)
* 계산 = 서버 단독(POST …/stab/analyze3d · 뒤에서) · 화면은 값 · 그림만. 탐색 설정 = 캐시 `e04-settings`
* · 없으면 저장된 결과의 설정 · 결과 열쇠 = 구간 + 조건(건기 · 우기 따로 남음).
* ========================================================================== */
import "./E04_Stab3D_UI_Style.css";
import { API_BASE_URL, ROUTES } from "@config/config_frontend";
import { attachCollapsible, createPanelCard } from "@ui/ui_template_collapsible";
import { createBadge } from "@ui/ui_template_badge";
import { createBanner } from "@ui/ui_template_banner";
import { createButton, el, showToast } from "@ui/ui_template_elements";
import { createEmpty } from "@ui/ui_template_empty";
import { createFloatPanel } from "@ui/ui_template_float_panel";
import { createKv, type KvRow } from "@ui/ui_template_kv";
import { createLegend } from "@ui/ui_template_flow_legend";
import { createListRow } from "@ui/ui_template_list_row";
import { t, type LocaleKey } from "@ui/ui_template_locale";
import { createProgressBar } from "@ui/ui_template_progress";
import { createSegment } from "@ui/ui_template_segment";
import { createSidePanels } from "@ui/ui_template_side_panels";
import { createText } from "@ui/ui_template_text";
import { readState, writeState } from "../A00_Common/b_page_state";
import { currentProjectId } from "../A00_Common/b_project_switch";
import { mountGroupStep } from "../A00_Common/group_workflow";
import { inferStationInterval, stationLabel } from "../common_util/common_util_station";
import { createPlanMap, type MapLayer, type StationPoint } from "./E04_Stab3D_UI_Map";
import { createView3D } from "./E04_Stab3D_UI_View3D";
import { buildSettingsCards, type Settings } from "./E04_Stab3D_UI_Settings";
import {
FS_STEPS,
VOLUME_STEPS,
legendRows,
fs2dOf,
min2d,
ratio3d2d,
stepColor,
type Result3d,
} from "./logic/E04_Stab3D_Raster";
interface SavedSummary {
key: string;
start_m: number;
end_m: number;
f_min: number;
volume: number;
method: string;
condition: string;
stale: boolean;
}
interface Overview {
editable: boolean;
sections: { start_m: number; end_m: number }[];
stations: StationPoint[];
line: number[][];
saved: SavedSummary[];
defaults: Settings;
max_section_m: number;
}
interface Progress {
running: boolean;
percent: number;
message: string | null;
error: string | null;
cancelled: boolean;
key: string | null;
}
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}`, {
credentials: "include",
headers: { "Content-Type": "application/json" },
...init,
});
const body = (await response.json().catch(() => ({}))) as T & { detail?: unknown };
if (!response.ok) {
const detail = body.detail;
throw new Error(typeof detail === "string" ? detail : `HTTP ${response.status}`);
}
return body;
}
/** E03 2D 측점 줄(GET …/stab/result2d · fs_by_condition · min = 조건별) — 없으면 빈 목록 */
type Station2d = Parameters<typeof fs2dOf>[0] & { chainage_m: number };
async function fetch2d(projectId: string): Promise<Station2d[]> {
try {
const body = await api<{ stations?: Station2d[] }>(`/projects/${projectId}/stab/result2d`);
return (body.stations ?? []).map((s) => ({ ...s, chainage_m: Number(s.chainage_m) }));
} catch {
return [];
}
}
/** 안내 글 한 줄 — 걸이 이름(`e04-hint` 같은)만 얹음 */
function note(text: string, hook: string): HTMLElement {
const node = createText(text, "muted");
node.classList.add(hook);
return node;
}
function emptyBox(text: string): HTMLElement {
const node = createEmpty({ text, small: true });
node.classList.add("e04-empty");
return node;
}
/** 지도 범례 — 떠 있는 판(머리 = 제목) 안에 범례 부품 */
function legend(layer: MapLayer): HTMLElement {
const rows = layer === "fs" ? legendRows(FS_STEPS, 1) : legendRows(VOLUME_STEPS, 0);
const panel = createFloatPanel({
title: t(layer === "fs" ? "E04_Legend_FS" : "E04_Legend_Volume"),
});
panel.root.classList.add("e04-legend");
panel.body.append(
createLegend(
[...rows, { color: stepColor(FS_STEPS, null), label: t("E04_NoData") }].map((row) => ({
label: row.label,
color: row.color,
})),
{ vertical: true },
),
);
return panel.root;
}
export function renderE04Stab3D(root: HTMLElement): void {
const projectId = currentProjectId();
const panel = el("div", { className: "ui-sidebar-panel e04-panel" });
const main = el("div", { className: "e04-page" });
mountGroupStep(root, ROUTES.E04_STAB_3D, { mainContent: main, leftPanel: panel });
if (!projectId) return void main.append(emptyBox(t("E04_NoProject")));
// ---- 가운데 ----
const map = createPlanMap();
const view3d = createView3D();
view3d.root.hidden = true;
let mode: "plan" | "3d" = "plan";
let layer: MapLayer = "fs";
const toggle = (labels: [LocaleKey, string][], onPick: (value: string) => void) => {
const group = createSegment({
options: labels.map(([key, value]) => ({ value, label: t(key) })),
value: labels[0][1],
size: 24,
onChange: onPick,
});
group.root.classList.add("e04-toggle");
return group.root;
};
const legendSlot = el("div", { className: "e04-legend-slot" });
const paintLegend = (): void => legendSlot.replaceChildren(legend(layer));
const stage = el("div", { className: "e04-stage" });
stage.append(map.root, view3d.root, legendSlot);
const toolbar = el("div", { className: "e04-toolbar" });
toolbar.append(
toggle(
[
["E04_View_Plan", "plan"],
["E04_View_3D", "3d"],
],
(value) => {
mode = value as "plan" | "3d";
map.root.hidden = mode !== "plan";
view3d.root.hidden = mode !== "3d";
view3d.setActive(mode === "3d");
if (mode === "3d") view3d.show(result, overview?.line ?? [], layer);
},
),
toggle(
[
["E04_Layer_FS", "fs"],
["E04_Layer_Volume", "volume"],
],
(value) => {
layer = value as MapLayer;
map.setLayer(layer);
if (mode === "3d") view3d.show(result, overview?.line ?? [], layer, false);
paintLegend();
},
),
);
const center = el("div", { className: "e04-center" });
center.append(toolbar, stage);
paintLegend();
// ---- 우 ----
const resultBody = el("div", { className: "e04-result" });
const side = createSidePanels({
prefKey: "e04-stab3d-result",
panels: [{ id: "result", title: t("E04_Result_Title"), body: resultBody }],
defaultOpen: "result",
defaultWidth: 320,
follow: false,
});
const row = el("div", { className: "ui-side-panels-row e04-row" });
row.append(center, side.root);
main.append(row);
// ---- 좌 ----
const sectionList = el("div", { className: "e04-sections" });
const sectionCard = createPanelCard(t("E04_Card_Sections"), false);
const settings = buildSettingsCards();
sectionCard.append(sectionList, settings.rangeRow);
const progressBar = createProgressBar();
const progressText = note("", "e04-progress__text");
const cancelButton = createButton({
label: t("E04_Cancel"),
variant: "danger-ghost",
size: "sm",
onClick: () => void api(`/projects/${projectId}/stab/analyze3d/cancel`, { method: "POST" }),
});
const progressBox = el("div", { className: "e04-progress-box" });
progressBox.append(progressBar.root, progressText, cancelButton);
progressBox.hidden = true;
const analyzeButton = createButton({
label: t("E04_Analyze"),
variant: "filled",
onClick: () => void analyze(),
});
analyzeButton.disabled = true;
const actions = el("div", { className: "ui-sidebar-actions" });
actions.append(analyzeButton);
const readOnly = note(t("E04_ReadOnly"), "e04-hint");
readOnly.hidden = true;
panel.append(
note(t("E04_Hint"), "e04-hint"),
readOnly,
sectionCard,
settings.basicCard,
settings.advancedCard,
progressBox,
actions,
);
attachCollapsible(panel);
// ---- 상태 ----
let overview: Overview | null = null;
let result: Result3d | null = null;
let stations2d: Station2d[] = [];
let running = false;
/** 측점 글 = No.+거리(소수 한 자리) · 간격 = 측점 목록 추정 */
const station = (chainage: number): string =>
stationLabel(chainage, inferStationInterval(overview?.stations ?? []), 1);
const remember = (): void => writeState("e04-settings", settings.read(), projectId);
let shownKey = "";
settings.onChange(() => {
remember();
const { start_m, end_m, condition } = settings.read();
map.setRange(start_m, end_m);
paintSections();
if (shownKey !== sectionKey(start_m, end_m, condition)) void openResult();
});
function paintButtons(): void {
const editable = Boolean(overview?.editable);
analyzeButton.disabled = !editable || running || !overview;
readOnly.hidden = !overview || editable;
}
function paintSections(): void {
if (!overview) return;
const { start_m, end_m, condition } = settings.read();
const savedOf = new Map(overview.saved.map((s) => [s.key, s]));
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(([key, section]) => {
const byCondition = CONDITIONS.map((c) => savedOf.get(`${key}_${c}`));
const saved = savedOf.get(`${key}_${condition}`);
const row = createListRow({
label: `${station(section.start_m)} – ${station(section.end_m)}`,
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 === rangeKey(start_m, end_m),
onClick: () => settings.setRange(section.start_m, section.end_m),
});
row.classList.add("e04-section");
row.dataset.key = key;
return row;
}),
);
}
function paintResult(): void {
if (!result) {
resultBody.replaceChildren(emptyBox(t("E04_Result_None")));
return;
}
const s = result.summary;
const f2d = min2d(
stations2d.map((x) => ({ chainage_m: x.chainage_m, fs_min: fs2dOf(x, s.condition) })),
s.start_m,
s.end_m,
);
const ratio = ratio3d2d(s.f_min, f2d);
const nearest = overview?.stations.reduce<StationPoint | null>(
(best, x) =>
!best || Math.abs(x.chainage_m - s.chainage_m) < Math.abs(best.chainage_m - s.chainage_m)
? x
: best,
null,
);
const rows: [LocaleKey, string, string?][] = [
["E04_Result_F3d", s.f_min.toFixed(3), stepColor(FS_STEPS, s.f_min)],
[
"E04_Result_Verdict",
`${t(s.verdict === "ok" ? "E04_Verdict_ok" : "E04_Verdict_ng")} (${t(
`E04_Condition_${s.condition}` as LocaleKey,
)} ≥ ${s.reference_fs.toFixed(2)})`,
],
["E04_Result_F2d", f2d === null ? t("E04_No2D") : f2d.toFixed(3)],
["E04_Result_Ratio", ratio === null ? "-" : ratio.toFixed(3)],
["E04_Result_Volume", `${Math.round(s.volume).toLocaleString()} m³`],
["E04_Result_Method", t(`E04_MethodName_${s.method}` as LocaleKey)],
["E04_Result_Bishop", s.by_method.bishop.toFixed(3)],
["E04_Result_Janbu", s.by_method.janbu_corrected.toFixed(3)],
["E04_Result_Hovland", s.by_method.hovland.toFixed(3)],
["E04_Result_Where", station(nearest?.chainage_m ?? s.chainage_m)],
["E04_Result_Cells", s.cells.toLocaleString()],
["E04_Result_Columns", s.columns == null ? "-" : s.columns.toLocaleString()],
["E04_Result_Trials", s.trials.toLocaleString()],
["E04_Result_Time", `${result.elapsed_s.toFixed(1)} s`],
];
const kv: KvRow[] = rows.map(([key, value, color]) => {
const cell = document.createDocumentFragment();
if (color) {
const chip = el("i", { className: "e04-section__chip" });
chip.style.background = color;
cell.append(chip);
}
cell.append(value);
if (key === "E04_Result_Columns" && s.few_columns) {
cell.append(" ", createBadge(t("E04_Result_FewColumns"), "warning", { size: 18 }));
}
return {
label: t(key),
value: cell,
...(key === "E04_Result_Verdict" ? { ok: s.verdict === "ok" } : {}),
};
});
const list = createKv(kv);
list.classList.add("e04-result__list");
list.querySelectorAll("dd").forEach((dd, i) => {
dd.setAttribute("data-field", rows[i][0]);
if (rows[i][0] === "E04_Result_Verdict") dd.classList.add(`is-${s.verdict}`);
});
const notes: HTMLElement[] = [];
if (result.stale) {
const warn = createBanner({ tone: "warning", text: t("E04_Result_Stale") });
warn.classList.add("e04-warn");
notes.push(warn);
}
notes.push(note(t("E04_Result_Note"), "e04-note"));
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 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 !== currentKey()) return; // 그새 바뀜
map.setResult(result);
if (mode === "3d") view3d.show(result, overview?.line ?? [], layer);
paintResult();
}
async function refresh(): Promise<void> {
overview = await api<Overview>(`/projects/${projectId}/stab/result3d`);
paintButtons();
paintSections();
}
async function poll(): Promise<void> {
while (root.isConnected) {
const p = await api<Progress>(`/projects/${projectId}/stab/progress3d`).catch(() => null);
if (!p) break;
running = p.running;
progressBox.hidden = !p.running;
progressBar.set(p.percent / 100);
progressText.replaceChildren(`${t("E04_Running")} ${p.percent}% · ${p.message ?? ""}`);
paintButtons();
if (!p.running) {
if (p.error) showToast(p.error, "error");
else if (p.cancelled) showToast(t("E04_Cancelled"), "info");
else showToast(t("E04_Done"), "success");
await refresh();
await openResult();
return;
}
await new Promise((resolve) => setTimeout(resolve, 700));
}
}
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 }),
});
if (body.status === "cached") {
showToast(t("E04_Cached"), "info");
await openResult();
return;
}
running = true;
paintButtons();
progressBox.hidden = false;
void poll();
} catch (error) {
showToast(error instanceof Error ? error.message : String(error), "error");
}
}
// ---- 처음 ----
void (async () => {
try {
await refresh();
} catch (error) {
main.replaceChildren(emptyBox(error instanceof Error ? error.message : String(error)));
return;
}
const ov = overview!;
settings.setDefaults(ov.defaults, ov.max_section_m);
const cached = readState<Settings>("e04-settings", projectId);
const first = ov.saved[0] ?? ov.sections[0];
settings.write(
cached ?? {
...ov.defaults,
start_m: first?.start_m ?? 0,
end_m: first?.end_m ?? 100,
condition: ov.saved[0]?.condition ?? "dry",
},
);
map.setRoute(ov.line, ov.stations);
const { start_m, end_m } = settings.read();
map.setRange(start_m, end_m);
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();
})();
// 떠날 때 — WebGL · 관찰자 정리
const leave = new MutationObserver(() => {
if (root.isConnected && main.isConnected) return;
leave.disconnect();
map.dispose();
view3d.dispose();
});
leave.observe(document.body, { childList: true, subtree: true });
}