/* ============================================================================= * 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(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 []; } } /** 안내 글 한 줄 — 걸이 이름(`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( (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 { const key = currentKey(); 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 !== currentKey()) 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.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 { 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("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); remember(); } 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 }); }