/* ============================================================================= * 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 · 뒤에서) · 화면은 값 · 그림만. 탐색 설정 = sessionStorage. * ========================================================================== */ import "./E04_Stab3D_UI_Style.css"; import { API_BASE_URL, CURRENT_PROJECT_ID_KEY, ROUTES } from "@config/config_frontend"; import { attachCollapsible, createPanelCard } from "@ui/ui_template_collapsible"; import { createButton, el, showToast } from "@ui/ui_template_elements"; import { t, type LocaleKey } from "@ui/ui_template_locale"; import { createSidePanels } from "@ui/ui_template_side_panels"; import { mountGroupStep } from "../A00_Common/group_workflow"; 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; 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 cacheKey = (projectId: string): string => `e04_settings:${projectId}`; const sectionKey = (start: number, end: number): string => `${start.toFixed(2)}_${end.toFixed(2)}`; async function api(path: string, init?: RequestInit): Promise { 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[0] & { chainage_m: number }; async function fetch2d(projectId: string): Promise { 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 []; } } function legend(layer: MapLayer): HTMLElement { const box = el("div", { className: "e04-legend" }); box.append(el("strong", { text: t(layer === "fs" ? "E04_Legend_FS" : "E04_Legend_Volume") })); const rows = layer === "fs" ? legendRows(FS_STEPS, 1) : legendRows(VOLUME_STEPS, 0); for (const row of [...rows, { color: stepColor(FS_STEPS, null), label: t("E04_NoData") }]) { const item = el("span", { className: "e04-legend__row" }); const swatch = el("i", { className: "e04-legend__swatch" }); swatch.style.background = row.color; item.append(swatch, el("span", { text: row.label })); box.append(item); } return box; } export function renderE04Stab3D(root: HTMLElement): void { const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY); 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(el("p", { className: "e04-empty", text: 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 = el("div", { className: "e04-toggle" }); const buttons = labels.map(([key, value], i) => { const button = createButton({ label: t(key), variant: "ghost", size: "sm" }); button.dataset.value = value; button.classList.toggle("is-on", i === 0); button.addEventListener("click", () => { buttons.forEach((b) => b.classList.toggle("is-on", b === button)); onPick(value); }); return button; }); group.append(...buttons); return group; }; 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 = el("progress", { className: "e04-progress", attrs: { max: "100" } }); const progressText = el("p", { className: "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, progressText, cancelButton); progressBox.hidden = true; const analyzeButton = createButton({ label: t("E04_Analyze"), variant: "filled", onClick: () => void analyze(false), }); const reanalyzeButton = createButton({ label: t("E04_Reanalyze"), variant: "ghost", onClick: () => void analyze(true), }); analyzeButton.disabled = reanalyzeButton.disabled = true; const actions = el("div", { className: "ui-sidebar-actions" }); actions.append(reanalyzeButton, analyzeButton); const readOnly = el("p", { className: "e04-hint", text: t("E04_ReadOnly") }); readOnly.hidden = true; panel.append( el("p", { className: "e04-hint", text: t("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; const remember = (): void => sessionStorage.setItem(cacheKey(projectId), JSON.stringify(settings.read())); let shownKey = ""; settings.onChange(() => { remember(); const { start_m, end_m } = settings.read(); map.setRange(start_m, end_m); paintSections(); if (shownKey !== sectionKey(start_m, end_m)) void openResult(); }); function paintButtons(): void { const editable = Boolean(overview?.editable); analyzeButton.disabled = reanalyzeButton.disabled = !editable || running || !overview; readOnly.hidden = !overview || editable; } function paintSections(): void { if (!overview) return; const { start_m, end_m } = 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 })), ]; sectionList.replaceChildren( ...items.map((section) => { const key = sectionKey(section.start_m, section.end_m); const saved = savedOf.get(key); const button = el("button", { className: `e04-section${key === sectionKey(start_m, end_m) ? " is-active" : ""}`, attrs: { type: "button", "data-key": key }, }); const chip = el("i", { className: "e04-section__chip" }); chip.style.background = saved ? stepColor(FS_STEPS, saved.f_min) : "transparent"; button.append( chip, el("span", { text: `${section.start_m.toFixed(0)} – ${section.end_m.toFixed(0)} m` }), el("span", { className: "e04-section__value", text: saved ? `Fs ${saved.f_min.toFixed(2)}${saved.stale ? ` · ${t("E04_Stale")}` : ""}` : "", }), ); button.addEventListener("click", () => settings.setRange(section.start_m, section.end_m)); return button; }), ); } function paintResult(): void { if (!result) { resultBody.replaceChildren(el("p", { className: "e04-empty", text: 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 station = overview?.stations.reduce( (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?.label ?? `${s.chainage_m.toFixed(1)} m`], ["E04_Result_Cells", s.cells.toLocaleString()], ["E04_Result_Trials", s.trials.toLocaleString()], ["E04_Result_Time", `${result.elapsed_s.toFixed(1)} s`], ]; const list = el("dl", { className: "e04-result__list" }); for (const [key, value, color] of rows) { const dd = el("dd", { text: value, attrs: { "data-field": key } }); if (key === "E04_Result_Verdict") dd.classList.add(`is-${s.verdict}`); if (color) { const chip = el("i", { className: "e04-section__chip" }); chip.style.background = color; dd.prepend(chip); } list.append(el("dt", { text: t(key) }), dd); } const notes: HTMLElement[] = []; if (result.stale) notes.push(el("p", { className: "e04-warn", text: t("E04_Result_Stale") })); notes.push(el("p", { className: "e04-note", text: t("E04_Result_Note") })); resultBody.replaceChildren(list, ...notes); } async function openResult(): Promise { const { start_m, end_m } = settings.read(); const key = sectionKey(start_m, end_m); shownKey = key; const saved = overview?.saved.some((s) => s.key === key); result = saved ? await api(`/projects/${projectId}/stab/result3d?key=${key}`).catch(() => null) : null; if (key !== sectionKey(settings.read().start_m, settings.read().end_m)) return; // 그새 바뀜 map.setResult(result); if (mode === "3d") view3d.show(result, overview?.line ?? [], layer); paintResult(); } async function refresh(): Promise { overview = await api(`/projects/${projectId}/stab/result3d`); paintButtons(); paintSections(); } async function poll(): Promise { while (root.isConnected) { const p = await api(`/projects/${projectId}/stab/progress3d`).catch(() => null); if (!p) break; running = p.running; progressBox.hidden = !p.running; progressBar.value = p.percent; progressText.textContent = `${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(force: boolean): Promise { 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 }), }); 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( el("p", { className: "e04-empty", text: error instanceof Error ? error.message : String(error), }), ); return; } const ov = overview!; settings.setDefaults(ov.defaults, ov.max_section_m); const cached = JSON.parse( sessionStorage.getItem(cacheKey(projectId)) ?? "null", ) as Settings | null; 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: "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 p = await api(`/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 }); }