/* ============================================================================= * 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(자동저장 없음). * ========================================================================== */ 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 { buildList } from "./E03_StabSection_UI_List"; import { buildPlan } from "./E03_StabSection_UI_Plan"; import { buildResult } from "./E03_StabSection_UI_Result"; import { buildLegends, createSectionCanvas, type SectionToggles, } from "./E03_StabSection_UI_Section"; import { openView3d } from "./E03_StabSection_UI_View3D"; 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[]; } 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`; 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 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 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(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()), ), ); toolbar.append(title, sideField.root, flags); const draw = { view, analysis, toggles, picked }; canvas.render(draw); legendHost.append( buildLegends(draw), el("p", { className: "e03-muted", text: t("E03_NoWalls") }), ); resultHost.append( buildResult(view, analysis, picked, (i) => { picked = i; paintSection(); }), ); }; 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 { result = await api(`${base}/result2d`); sections.clear(); 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"); }); }