/* ============================================================================= * E03_StabSection_UI_Page.ts * E03 2D 측점 해석(요구서 345 4-3 · 요구 347 · 351 · PLAN 532-3) — B05~B09 틀(group_workflow). * 좌 = 측점 목록(조건 · 정렬 · N.G 만 · 대표 단면 별) · [해석] · [3D 보기] · [대표 단면 저장] * 위 = 평면 위험 띠 · 가운데 = 횡단(임계면 · 등안전율 · 후보 · 절편) · 우 = 결과 · 절편표 · 자유물체도 · 그래프 * 계산 = 서버 단독([해석] → POST …/stab/analyze2d · 잠금판 진행률 = GET …/stab/progress) · * 대표 단면 켬/끔 = sessionStorage 캐시 → [대표 단면 저장] = PUT(자동저장 없음). * 「보강 검토」 탭 = 우측을 가정 재해석 · 벽 외적 안정으로 바꿈(`UI_Reinforce` · 서버 따로 저장본). * 「확률 · 민감도」 탭 = 대표 단면 확률 해석(`UI_Prob` · 서버 따로 저장본 · 카드 안 진행률). * ========================================================================== */ import "./E03_StabSection_UI_Style.css"; import { API_BASE_URL, CURRENT_PROJECT_ID_KEY, ROUTES } from "@config/config_frontend"; import { createButton, createSelectField, el, hideLoadingOverlay, setLoadingOverlayProgress, showLoadingOverlay, showToast, } from "@ui/ui_template_elements"; import { t, type LocaleKey } from "@ui/ui_template_locale"; import { mountGroupStep } from "../A00_Common/group_workflow"; import { buildMissingBanner } from "../E01_StabRegister/E01_StabRegister_UI_Missing"; import { buildList } from "./E03_StabSection_UI_List"; import { buildPlan } from "./E03_StabSection_UI_Plan"; import { buildResult } from "./E03_StabSection_UI_Result"; import { buildLegends, createSectionCanvas, WALL_ID, type SectionToggles, } from "./E03_StabSection_UI_Section"; import { openView3d } from "./E03_StabSection_UI_View3D"; import { buildReinforce, type SlopeAsk, type WallAsk, type WallRecord, type WhatIfRecord, } from "./E03_StabSection_UI_Reinforce"; import { buildSupport, type SupportAsk, type SupportRecord } from "./E03_StabSection_UI_Support"; import { createProbPanel } from "./E03_StabSection_UI_Prob"; import type { Analysis, Condition, SectionView, SortMode, StationRow, } from "./logic/E03_StabSection_Logic"; interface Result2d { status: "ok" | "none"; fresh: boolean; editable: boolean; running: boolean; conditions: Condition[]; computed_at?: string; summary?: Record; stations: StationRow[]; /** 횡단 없어 해석에서 빠진 측점 누가거리(E01 복사본) */ missing_stations?: number[]; } interface Progress { running: boolean; percent: number; message: string; error: string | null; } 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) { throw new Error(typeof body.detail === "string" ? body.detail : `HTTP ${response.status}`); } return body; } const repKey = (projectId: string): string => `e03_representative_cache:${projectId}`; const sleep = (ms: number): Promise => new Promise((r) => setTimeout(r, ms)); function checkbox(label: string, checked: boolean, onChange: (v: boolean) => void): HTMLElement { const input = el("input", { attrs: { type: "checkbox" } }); input.checked = checked; input.addEventListener("change", () => onChange(input.checked)); return el("label", { className: "e03-check", children: [input, label] }); } export function renderE03StabSection(root: HTMLElement): void { const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY); const main = el("div", { className: "e03-page" }); const panel = el("div", { className: "ui-sidebar-panel e03-panel" }); mountGroupStep(root, ROUTES.E03_STAB_SECTION, { mainContent: main, leftPanel: panel }); if (!projectId) return void main.append(el("p", { className: "e03-empty", text: t("E03_NoProject") })); const pid: string = projectId; const base = `/projects/${pid}/stab`; const prob = createProbPanel(pid, base, () => paintSection()); let result: Result2d | null = null; const sections = new Map(); let condition: Condition = "dry"; let sort: SortMode = "chainage"; let ngOnly = false; let active: string | null = null; let side: string | null = null; let picked: number | null = null; const toggles: SectionToggles = { map: true, centers: false, candidates: true }; let mode: "result" | "reinforce" | "prob" = "result"; let busy = false; const slopeRecs = new Map(); // 측점 열쇠 → 마지막 가정 const wallRecs = new Map(); const supportRecs = new Map(); let repCache: Record = JSON.parse( sessionStorage.getItem(repKey(pid)) ?? "{}", ); // ---- 좌측 ---- const condField = createSelectField({ label: t("E03_Condition"), options: [], compact: true, onChange: (v) => { condition = v as Condition; side = null; picked = null; paint(); }, }); const sortField = createSelectField({ label: t("E03_Sort"), options: [ { value: "chainage", text: t("E03_Sort_Chainage") }, { value: "fs", text: t("E03_Sort_Fs") }, ], compact: true, onChange: (v) => { sort = v as SortMode; paintList(); }, }); const ngBox = checkbox(t("E03_NgOnly"), false, (v) => { ngOnly = v; paintList(); }); const status = el("p", { className: "e03-muted", attrs: { "data-role": "status" } }); const listHost = el("div", { className: "e03-panel", attrs: { style: "flex: 1 1 auto; min-height: 0" }, }); const analyzeButton = createButton({ label: t("E03_Analyze"), variant: "filled", onClick: () => void analyze(), }); const view3dButton = createButton({ label: t("E03_View3d"), variant: "ghost", onClick: () => void show3d(), }); const repButton = createButton({ label: t("E03_RepSave"), variant: "ghost", onClick: () => void saveRep(), }); const controls = el("div", { className: "e03-panel__row" }); controls.append(condField.root, sortField.root); const actions = el("div", { className: "ui-sidebar-actions" }); actions.append(repButton, view3dButton, analyzeButton); panel.append(controls, ngBox, status, listHost, actions); // ---- 본문 ---- const missingHost = el("div"); const planHost = el("div"); const toolbar = el("div", { className: "e03-card__head" }); const sectionCard = el("div", { className: "e03-card" }); const legendHost = el("div"); const canvas = createSectionCanvas((i) => { picked = i; paintSection(); }); sectionCard.append(toolbar, canvas.root, legendHost); const resultHost = el("div", { className: "e03-result", attrs: { style: "min-height: 0" } }); const body = el("div", { className: "e03-body" }); body.append(sectionCard, resultHost); main.append(missingHost, planHost, body); const repOf = (row: StationRow): { on: boolean; user: boolean | null } => { const server = row.representative ?? { auto: [], user: null, on: false }; if (!(row.key in repCache)) return { on: server.on, user: server.user }; const user = repCache[row.key]; return { on: user === null ? server.auto.length > 0 : user, user }; }; const repDirty = (): boolean => Object.keys(repCache).length > 0; const currentRow = (): StationRow | undefined => result?.stations.find((r) => r.key === active); /** 고른 측점 · 조건의 비탈 — 고른 게 없으면 안전율 가장 낮은 쪽 */ const currentAnalysis = (view: SectionView): Analysis | null => { const list = view.analyses.filter((a) => a.condition === condition); if (!list.length) return null; const found = side ? list.find((a) => a.side === side) : null; if (found) return found; const worst = list .slice() .sort((a, b) => (a.critical?.fs ?? Infinity) - (b.critical?.fs ?? Infinity))[0]; side = worst.side; return worst; }; const paintStatus = (): void => { const lines: string[] = []; if (!result) lines.push(t("E03_Loading")); else if (result.status === "none") lines.push(t("E03_NotYet")); else { lines.push(`${t("E03_Computed")} ${result.computed_at ?? ""}`); if (!result.fresh) lines.push(t("E03_Stale")); } if (result && !result.editable) lines.push(t("E03_ReadOnly")); if (repDirty()) lines.push(t("E03_RepUnsaved")); status.textContent = lines.join("\n"); analyzeButton.disabled = !result?.editable; repButton.disabled = !result?.editable || !repDirty(); view3dButton.disabled = result?.status !== "ok"; }; const paintList = (): void => { if (!result) return; listHost.replaceChildren( buildList( { rows: result.stations, conditions: result.conditions, condition, sort, ngOnly, active, representative: repOf, editable: result.editable, }, (key) => void select(key), (key) => { const row = result!.stations.find((r) => r.key === key)!; const now = repOf(row).on; const auto = (row.representative?.auto.length ?? 0) > 0; // 자동과 같아지면 사용자 값 지움(null) — 서버 값과 같으면 캐시에서 뺌 const next = !now === auto ? null : !now; if (next === (row.representative?.user ?? null)) delete repCache[key]; else repCache[key] = next; sessionStorage.setItem(repKey(pid), JSON.stringify(repCache)); paintList(); paintStatus(); }, ), ); }; const paintSection = (): void => { const view = active ? sections.get(active) : undefined; toolbar.replaceChildren(); resultHost.replaceChildren(); legendHost.replaceChildren(); if (!view) { canvas.render(null); const text = result?.status === "ok" ? t("E03_PickStation") : t("E03_NotYet"); resultHost.append( el("div", { className: "e03-card", children: [el("p", { className: "e03-empty", text })] }), ); return; } const analysis = currentAnalysis(view); const sides = view.analyses.filter((a) => a.condition === condition); const sideField = createSelectField({ options: sides.map((a) => ({ value: a.side, text: `${t(a.side === "left" ? "E03_Left" : "E03_Right")} · ${t(a.role === "cut" ? "E03_Cut" : "E03_Fill")}`, })), value: analysis?.side, compact: true, ariaLabel: t("E03_Side"), onChange: (v) => { side = v; picked = null; paintSection(); }, }); const title = el("h3", { className: "e03-card__title", text: `${view.label ?? view.key} · ${t(`E03_Cond_${condition}` as LocaleKey)}`, }); const flags = el("div", { className: "e03-panel__row" }); flags.append( checkbox(t("E03_Show_Map"), toggles.map, (v) => ((toggles.map = v), paintSection())), checkbox( t("E03_Show_Centers"), toggles.centers, (v) => ((toggles.centers = v), paintSection()), ), checkbox( t("E03_Show_Candidates"), toggles.candidates, (v) => ((toggles.candidates = v), paintSection()), ), ); const modeField = createSelectField({ options: [ { value: "result", text: t("E03_Tab_Result") }, { value: "reinforce", text: t("E03_Tab_Reinforce") }, { value: "prob", text: t("E03_Tab_Prob") }, ], value: mode, compact: true, ariaLabel: t("E03_Tab"), onChange: (v) => { mode = v as typeof mode; paintSection(); }, }); modeField.select.dataset.role = "mode"; toolbar.append(title, sideField.root, modeField.root, flags); const draw = { view, analysis, toggles, picked }; canvas.render(draw); legendHost.append(buildLegends(draw)); const rule = view.water_rule?.[condition]; if (rule?.mode === "ru") legendHost.append( el("p", { className: "e03-muted", text: t("E03_Water_Ru").replace("{v}", String(rule.ru)), attrs: { "data-role": "water-ru" }, }), ); if (view.regions.some((r) => r.material_id === WALL_ID)) legendHost.append(el("p", { className: "e03-muted", text: t("E03_Walls_Hint") })); const row = currentRow(); if (mode === "prob" && row) { resultHost.append( prob.render({ row, condition, side, editable: !!result?.editable && !busy }), ); return; } if (mode === "reinforce" && row) { if (row.walls && !wallRecs.has(row.key) && !busy && result?.editable) queueMicrotask(() => void runWall(row.key, { method: "coulomb", water_ratio: 0 })); resultHost.append( buildReinforce({ row, condition, side, editable: !!result?.editable, busy, slope: slopeRecs.get(row.key), wall: wallRecs.get(row.key), runSlope: (ask) => void runSlope(row.key, ask), runWall: (ask) => void runWall(row.key, ask), }), buildSupport({ row, view, condition, side, editable: !!result?.editable, busy, record: supportRecs.get(row.key), run: (ask) => void runSupport(row.key, ask), }), ); return; } resultHost.append( buildResult(view, analysis, picked, (i) => { picked = i; paintSection(); }), ); }; /** 가정 재해석 · 벽 검토 — 서버 계산(수십 초) 동안 단추만 잠금 */ async function reinforce( path: string, body: unknown, done: (value: T) => void, ): Promise { busy = true; paintSection(); try { done(await api(path, { method: "POST", body: JSON.stringify(body) })); } catch (error) { showToast(error instanceof Error ? error.message : t("Common_Status_Error"), "error"); } finally { busy = false; paintSection(); } } const runSlope = (key: string, ask: SlopeAsk): Promise => reinforce(`${base}/whatif/${encodeURIComponent(key)}`, ask, (rec) => { slopeRecs.set(key, rec); side = rec.assume.side; // 전 그림 = 가정한 비탈 }); const runSupport = (key: string, ask: SupportAsk): Promise => reinforce(`${base}/whatif/${encodeURIComponent(key)}`, ask, (rec) => { supportRecs.set(key, rec); side = rec.assume.side; }); const runWall = (key: string, ask: WallAsk): Promise => reinforce(`${base}/wallcheck/${encodeURIComponent(key)}`, ask, (rec) => wallRecs.set(key, rec), ); const paintPlan = (): void => { planHost.replaceChildren( result ? buildPlan(result.stations, condition, active, (key) => void select(key)) : el("div"), ); }; function paint(): void { paintStatus(); paintList(); paintPlan(); paintSection(); } async function select(key: string): Promise { if (active !== key) picked = null; active = key; paintList(); paintPlan(); if (result?.status === "ok" && !sections.has(key) && !currentRow()?.error) { try { sections.set(key, await api(`${base}/section/${encodeURIComponent(key)}`)); } catch (error) { showToast(error instanceof Error ? error.message : t("Common_Status_Error"), "error"); } } if (active === key) paintSection(); } async function load(): Promise { const before = result?.status; result = await api(`${base}/result2d`); if (before === "none" && result.status === "ok") active = null; // 첫 해석 뒤 = 가장 낮은 측점으로 sections.clear(); missingHost.replaceChildren(buildMissingBanner(result.missing_stations)); const conds = result.conditions; if (!conds.includes(condition)) condition = conds[0] ?? "dry"; condField.setOptions( conds.map((c) => ({ value: c, text: t(`E03_Cond_${c}` as LocaleKey) })), condition, ); const keys = result.stations.map((r) => r.key); repCache = Object.fromEntries(Object.entries(repCache).filter(([k]) => keys.includes(k))); if (!active || !keys.includes(active)) { // 처음 = 그 조건 가장 낮은 안전율 측점 const worst = result.stations .filter((r) => r.min[condition] !== null && r.min[condition] !== undefined) .sort((a, b) => (a.min[condition] as number) - (b.min[condition] as number))[0]; active = worst?.key ?? keys[0] ?? null; } paint(); if (active) await select(active); if (result.running) await watch(); } /** 잠금판 + 진행률 — 끝나면 다시 읽음 */ async function watch(): Promise { showLoadingOverlay(); try { for (;;) { const p = await api(`${base}/progress`); setLoadingOverlayProgress( p.percent / 100, `${t("E03_Analyzing")} ${p.percent}% · ${p.message}`, ); if (!p.running) { if (p.error) showToast(p.error, "error"); else showToast(t("E03_Done"), "success"); break; } await sleep(1000); } } finally { hideLoadingOverlay(); } await load(); } async function analyze(): Promise { try { const response = await api<{ status: string }>(`${base}/analyze2d`, { method: "POST" }); if (response.status === "fresh") { showToast(t("E03_Fresh"), "info"); await load(); return; } await watch(); } catch (error) { showToast(error instanceof Error ? error.message : t("Common_Status_Error"), "error"); } } async function saveRep(): Promise { try { const overrides: Record = {}; for (const row of result?.stations ?? []) { const user = row.key in repCache ? repCache[row.key] : (row.representative?.user ?? null); if (user !== null) overrides[row.key] = user; } await api(`${base}/representative`, { method: "PUT", body: JSON.stringify({ overrides }) }); repCache = {}; sessionStorage.removeItem(repKey(pid)); showToast(t("E03_RepSaved"), "success"); await load(); } catch (error) { showToast(error instanceof Error ? error.message : t("Common_Status_Error"), "error"); } } async function show3d(): Promise { if (!result) return; try { const data = await api[0]>(`${base}/corridor3d`); await openView3d(data, result.stations, condition, t(`E03_Cond_${condition}` as LocaleKey)); } catch (error) { showToast(error instanceof Error ? error.message : t("Common_Status_Error"), "error"); } } paintStatus(); load().catch((error) => { status.textContent = error instanceof Error ? error.message : t("Common_Status_Error"); }); }