/* ============================================================================= * 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) · * 대표 단면 별 = 누를 때마다 바로 PUT(요구 390) · 떠날 때 저장 중이면 공용 물음 [취소][폐기][저장](572-3). * 「보강 검토」 탭 = 우측을 가정 재해석 · 벽 외적 안정으로 바꿈(`UI_Reinforce` · 서버 따로 저장본). * 「확률 · 민감도」 탭 = 대표 단면 확률 해석(`UI_Prob` · 서버 따로 저장본 · 카드 안 진행률). * ========================================================================== */ import "./E03_StabSection_UI_Style.css"; import { API_BASE_URL, ROUTES } from "@config/config_frontend"; import { createButton, createInputField, createSelectField, el, hideLoadingOverlay, setLoadingOverlayProgress, showLoadingOverlay, showToast, } from "@ui/ui_template_elements"; import { t, type LocaleKey } from "@ui/ui_template_locale"; import { createSegment } from "@ui/ui_template_segment"; import { currentProjectId } from "../A00_Common/b_project_switch"; 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, type PlanView } from "./E03_StabSection_UI_Plan"; import { inferStationInterval, stationLabel } from "../common_util/common_util_station"; import { card, check, emptyBox, muted } from "./E03_StabSection_UI_Parts"; 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; /** 2D 탐색 한계 — "model_end" · "2h" · 수(비탈 끝 + m) */ search_reach?: string | number; 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 sleep = (ms: number): Promise => new Promise((r) => setTimeout(r, ms)); export function renderE03StabSection(root: HTMLElement): void { const projectId = currentProjectId(); 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, dirty: () => repPending > 0, save: () => repSaving.then(() => true), }); if (!projectId) return void main.append(emptyBox(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 interval = 20; // 측점 간격 — 측점 목록에서 추정(요구 390) const planView: PlanView = {}; let repSaving: Promise = Promise.resolve(); // 별 누름마다 차례로 저장 let repPending = 0; // 아직 안 끝난 별 저장(떠남 물음) // ---- 좌측 ---- 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(); }, }); type ReachPick = "model_end" | "2h" | "m"; const reachSegment = createSegment({ options: [ { value: "model_end", label: t("E03_Reach_model_end") }, { value: "2h", label: t("E03_Reach_2h_Short"), title: t("E03_Reach_2h") }, { value: "m", label: t("E03_Reach_m_Short"), title: t("E03_Reach_m") }, ], value: "model_end", size: 24, ariaLabel: t("E03_Reach"), onChange: () => paintReach(), }); reachSegment.root.dataset.role = "reach"; reachSegment.root.title = t("E03_Reach_Hint"); const reachMeters = createInputField({ type: "number", min: 0.1, compact: true, unit: "m", onInput: () => paintReach(), }); reachMeters.input.dataset.role = "reach-m"; reachMeters.input.title = t("E03_Reach_Hint"); const reachLabelNode = muted(t("E03_Reach")); const reachNote = muted("", "reach-note"); const reachLabel = (reach: string | number | undefined): string => typeof reach === "number" ? t("E03_Reach_m_Value").replace("{v}", String(reach)) : t(reach === "2h" ? "E03_Reach_2h" : "E03_Reach_model_end"); /** 고른 값(서버로 보낼 꼴) — 정한 m 이 비면 null */ const reachPicked = (): string | number | null => { const pick = reachSegment.get() as ReachPick; if (pick !== "m") return pick; const meters = Number(reachMeters.input.value); return meters > 0 ? meters : null; }; /** 정한 m 칸 보임 · 「이 결과」 한 줄 — 고른 값이 결과와 다르면 「해석 전」 */ const paintReach = (): void => { reachMeters.root.hidden = reachSegment.get() !== "m"; if (result?.status !== "ok") return void (reachNote.textContent = ""); const used = result.search_reach ?? "model_end"; const picked = reachPicked(); reachNote.textContent = `${t("E03_Reach_Used")} ${reachLabel(used)}` + (picked !== null && picked !== used ? ` · ${t("E03_Reach_Pending")}` : ""); }; /** 서버 저장값을 칸에 맞춤 */ const showReach = (reach: string | number | undefined): void => { const custom = typeof reach === "number"; reachSegment.set(custom ? "m" : reach === "2h" ? "2h" : "model_end"); if (custom) reachMeters.input.value = String(reach); paintReach(); }; const ngBox = check(t("E03_NgOnly"), false, (v) => { ngOnly = v; paintList(); }); const status = muted("", "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 controls = el("div", { className: "e03-panel__row" }); controls.append(condField.root, sortField.root); const actions = el("div", { className: "ui-sidebar-actions" }); actions.append(view3dButton, analyzeButton); panel.append( controls, reachLabelNode, reachSegment.root, reachMeters.root, reachNote, ngBox, status, listHost, actions, ); // ---- 본문 ---- const missingHost = el("div"); const planHost = el("div"); const toolbar = el("div", { className: "e03-card__head" }); const sectionCard = 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 }; return { on: server.on, user: server.user }; }; 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")); status.textContent = lines.join("\n"); analyzeButton.disabled = !result?.editable; reachMeters.input.disabled = !result?.editable; reachSegment.root.querySelectorAll("button").forEach((b) => (b.disabled = !result?.editable)); view3dButton.disabled = result?.status !== "ok"; }; const paintList = (): void => { if (!result) return; listHost.replaceChildren( buildList( { rows: result.stations, conditions: result.conditions, condition, sort, ngOnly, active, interval, representative: repOf, editable: result.editable, }, (key) => void select(key), (key) => void toggleRep(key), ), ); }; 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"); const none = card(); none.append(emptyBox(text)); resultHost.append(none); 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: `${stationLabel(view.chainage_m, interval, 1)} · ${t(`E03_Cond_${condition}` as LocaleKey)}`, }); const flags = el("div", { className: "e03-panel__row" }); flags.append( check(t("E03_Show_Map"), toggles.map, (v) => ((toggles.map = v), paintSection())), check(t("E03_Show_Centers"), toggles.centers, (v) => ((toggles.centers = v), paintSection())), check( 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(muted(t("E03_Water_Ru").replace("{v}", String(rule.ru)), "water-ru")); if (view.regions.some((r) => r.material_id === WALL_ID)) legendHost.append(muted(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(); }, analysis ? row?.cases[condition]?.[analysis.side] : undefined, ), ); }; /** 가정 재해석 · 벽 검토 — 서버 계산(수십 초) 동안 단추만 잠금 */ 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, interval, planView, (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(); showReach(result.search_reach); interval = inferStationInterval( result.stations.slice().sort((a, b) => a.chainage_m - b.chainage_m), ); missingHost.replaceChildren(buildMissingBanner(result.missing_stations, interval)); 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); delete planView.zoom; // 다시 읽음 = 띠 원배율 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 { const reach = reachPicked(); if (reach === null) { reachMeters.setError(t("E03_Reach_Invalid")); return; } reachMeters.setError(); try { const response = await api<{ status: string }>(`${base}/analyze2d`, { method: "POST", body: JSON.stringify({ search_reach: reach }), }); 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"); } } /** 별 누름 = 바로 저장(요구 390) — 자동과 같아지면 사용자 값 지움(null) · 저장 뒤 보고서 갱신 */ function toggleRep(key: string): Promise { repPending += 1; repSaving = repSaving .then(async () => { const row = result?.stations.find((r) => r.key === key); if (!row) return; const server = row.representative ?? { auto: [], user: null, on: false }; const auto = server.auto.length > 0; const next = !server.on === auto ? null : !server.on; const overrides: Record = {}; for (const r of result?.stations ?? []) { const user = r.key === key ? next : (r.representative?.user ?? null); if (user !== null) overrides[r.key] = user; } try { await api(`${base}/representative`, { method: "PUT", body: JSON.stringify({ overrides }), }); row.representative = { ...server, user: next, on: next === null ? auto : next }; paintList(); void refreshReport(); } catch (error) { showToast(error instanceof Error ? error.message : t("Common_Status_Error"), "error"); } }) .finally(() => { repPending -= 1; }); return repSaving; } /** 보고서 갱신 — E05 [보고서 갱신] 과 같은 길(GET 이 작업본 없으면 처음 만듦 · 뒤에서) */ let reportRun = 0; async function refreshReport(): Promise { const run = ++reportRun; await sleep(800); // 별을 잇달아 누르면 마지막 한 번만 if (run !== reportRun) return; try { const state = await api<{ book: unknown; rev: string | null }>( `/projects/${pid}/stab/report`, ); if (!state.book || run !== reportRun) return; const body = await api<{ overwritten?: string[] }>(`/projects/${pid}/stab/report/refresh`, { method: "POST", body: JSON.stringify({ rev: state.rev }), }); const over = body.overwritten ?? []; if (over.length) showToast(`${t("E05_Overwritten")} ${over.join(" · ")}`, "warning", 8000); } catch { /* 보고서는 E05 를 열 때 stale 로 알림 */ } } 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"); }); }