/* ============================================================================= * E07_StabArea_UI_Page.ts * E07 광역 자연사면 위험 지도(요구 345 · 351 · 요구서 4-2 2차 · PLAN 536-2) — B05~B09 틀(group_workflow). * 좌 = 토층 물성(E02 물성 고름 · 범위) · 시나리오(표준 습윤지수 T/R · 최악 완전 포화) · 노선 띠 · [해석] · 진행률 * 가운데 = 평면 지도(SINMAP 등급 · 안전율) · 범례 * 우 = 요약(등급별 면적 · 위험 면적) · 측점별 위험 표(누름 = 지도 그 측점) * 계산 = 서버 단독(POST …/stab/area/analyze · 뒤에서) · 화면은 값 · 그림만. 조건 = sessionStorage. * ========================================================================== */ import "./E07_StabArea_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, createInputField, createSelectField, el, showToast, type InputFieldHandle, } 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 { createAreaMap, type MapLayer, type StationPoint } from "./E07_StabArea_UI_Map"; import { CLASS_COLORS, CLASS_KEYS, FS_STEPS, LEVEL_COLORS, LEVEL_KEYS, decode, type AreaResult, } from "./logic/E07_StabArea_Raster"; interface Material { id: string; name: string; gamma_t: number; gamma_sat: number; c: number; phi: number; } type Settings = Record; interface Overview { editable: boolean; defaults: Settings; materials: Material[]; line: number[][]; stations: StationPoint[]; result: AreaResult | null; } interface Progress { running: boolean; percent: number; message: string | null; error: string | null; } const NUMBER_FIELDS: [string, LocaleKey, "soil" | "rain" | "band"][] = [ ["gamma_t", "E07_GammaT", "soil"], ["gamma_sat", "E07_GammaSat", "soil"], ["c", "E07_C", "soil"], ["c_hi", "E07_CHi", "soil"], ["phi", "E07_Phi", "soil"], ["phi_hi", "E07_PhiHi", "soil"], ["c_root", "E07_CRoot", "soil"], ["soil_depth_m", "E07_Depth", "soil"], ["tr_lo", "E07_TrLo", "rain"], ["tr_hi", "E07_TrHi", "rain"], ["band_m", "E07_Band", "band"], ["cell_m", "E07_Cell", "band"], ["level_lo_pct", "E07_LevelLo", "band"], ["level_hi_pct", "E07_LevelHi", "band"], ]; const cacheKey = (projectId: string): string => `e07_settings:${projectId}`; const shortLabel = (label: string | null, ch: number): string => (label ?? `${ch.toFixed(1)} m`).replace(/^STA\./, "").replace(/\.0+$/, ""); 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; const text = Array.isArray(detail) ? detail.map((d) => (d as { msg?: string }).msg ?? "").join(" · ") : detail; throw new Error(typeof text === "string" && text ? text : `HTTP ${response.status}`); } return body; } function swatch(color: string): HTMLElement { const chip = el("i", { className: "e07-chip" }); chip.style.background = color; return chip; } const SCENARIOS = ["sinmap", "saturated"]; const scenarioName = (value: unknown): string => SCENARIOS.includes(String(value)) ? t(`E07_Scenario_${value}` as LocaleKey) : String(value ?? ""); function legend(layer: MapLayer, scenario: unknown): HTMLElement { const box = el("div", { className: "e07-legend" }); box.append(el("strong", { text: t(layer === "cls" ? "E07_Legend_Class" : "E07_Legend_FS") })); if (scenario) box.append(el("small", { className: "e07-legend__sub", text: scenarioName(scenario) })); const rows = layer === "cls" ? CLASS_KEYS.map((k, i) => ({ color: CLASS_COLORS[i], label: t(`E07_Class_${k}` as LocaleKey), })) : FS_STEPS.map((s, i) => ({ color: s.color, label: i === 0 ? `< ${s.below.toFixed(1)}` : s.below === Infinity ? `≥ ${FS_STEPS[i - 1].below.toFixed(1)}` : `${FS_STEPS[i - 1].below.toFixed(1)} – ${s.below.toFixed(1)}`, })); for (const row of rows) { const item = el("span", { className: "e07-legend__row" }); item.append(swatch(row.color), el("span", { text: row.label })); box.append(item); } return box; } export function renderE07StabArea(root: HTMLElement): void { const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY); const panel = el("div", { className: "ui-sidebar-panel e07-panel" }); const main = el("div", { className: "e07-page" }); mountGroupStep(root, ROUTES.E07_STAB_AREA, { mainContent: main, leftPanel: panel }); if (!projectId) return void main.append(el("p", { className: "e07-empty", text: t("E07_NoProject") })); // ---- 가운데 ---- const map = createAreaMap(); let overview: Overview | null = null; let layer: MapLayer = "cls"; const legendSlot = el("div", { className: "e07-legend-slot" }); const paintLegend = (): void => legendSlot.replaceChildren(legend(layer, overview?.result?.settings.scenario)); const toggle = el("div", { className: "e07-toggle" }); const layerButtons = ( [ ["E07_Layer_Class", "cls"], ["E07_Layer_FS", "fs"], ] as [LocaleKey, MapLayer][] ).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", () => { layerButtons.forEach((b) => b.classList.toggle("is-on", b === button)); layer = value; map.setLayer(layer); paintLegend(); }); return button; }); toggle.append(...layerButtons); const stage = el("div", { className: "e07-stage" }); stage.append(map.root, legendSlot); const center = el("div", { className: "e07-center" }); center.append(toggle, stage); paintLegend(); // ---- 우 ---- const resultBody = el("div", { className: "e07-result" }); const side = createSidePanels({ prefKey: "e07-stab-area-result", panels: [{ id: "result", title: t("E07_Result_Title"), body: resultBody }], defaultOpen: "result", defaultWidth: 380, follow: false, }); const row = el("div", { className: "ui-side-panels-row e07-row" }); row.append(center, side.root); main.append(row); // ---- 좌 ---- const fields = new Map(); const cards = { soil: createPanelCard(t("E07_Card_Soil"), false), rain: createPanelCard(t("E07_Card_Rain"), false), band: createPanelCard(t("E07_Card_Band"), false), }; const material = createSelectField({ label: t("E07_Material"), options: [], compact: true }); const scenario = createSelectField({ label: t("E07_Scenario"), options: SCENARIOS.map((value) => ({ value, text: scenarioName(value) })), compact: true, }); cards.soil.append(material.root); cards.rain.append(scenario.root); const sourceHint = el("p", { className: "e07-hint", text: t("E07_Defaults_Source") }); for (const [key, label, card] of NUMBER_FIELDS) { const field = createInputField({ label: t(label), type: "number", compact: true }); field.input.step = "any"; field.input.dataset.key = key; fields.set(key, field); cards[card].append(field.root); } const progressBar = el("progress", { className: "e07-progress", attrs: { max: "100" } }); const progressText = el("p", { className: "e07-progress__text" }); const progressBox = el("div", { className: "e07-progress-box" }); progressBox.append(progressBar, progressText); progressBox.hidden = true; const defaultsButton = createButton({ label: t("E07_Defaults"), variant: "ghost", onClick: () => overview && write(overview.defaults), }); const analyzeButton = createButton({ label: t("E07_Analyze"), variant: "filled", onClick: () => void analyze(false), }); const reanalyzeButton = createButton({ label: t("E07_Reanalyze"), variant: "ghost", onClick: () => void analyze(true), }); const actions = el("div", { className: "ui-sidebar-actions" }); actions.append(defaultsButton, reanalyzeButton, analyzeButton); const readOnly = el("p", { className: "e07-hint", text: t("E07_ReadOnly") }); readOnly.hidden = true; panel.append( el("p", { className: "e07-hint", text: t("E07_Hint") }), readOnly, cards.soil, cards.rain, cards.band, sourceHint, progressBox, actions, ); attachCollapsible(panel); // ---- 상태 ---- let running = false; function read(): Settings { const out: Settings = { material_id: material.select.value || null }; out.scenario = scenario.select.value; for (const [key, field] of fields) out[key] = Number(field.input.value); return out; } function write(values: Settings): void { for (const [key, field] of fields) field.input.value = String(values[key] ?? ""); material.select.value = String(values.material_id ?? ""); scenario.select.value = SCENARIOS.includes(String(values.scenario)) ? String(values.scenario) : SCENARIOS[0]; paintScenario(); remember(); } function paintScenario(): void { const wet = scenario.select.value === "sinmap"; for (const key of ["tr_lo", "tr_hi"]) fields.get(key)!.root.hidden = !wet; map.setBand(Number(fields.get("band_m")!.input.value) || 100); } const remember = (): void => sessionStorage.setItem(cacheKey(projectId), JSON.stringify(read())); panel.addEventListener("input", remember); scenario.select.addEventListener("change", () => { paintScenario(); remember(); }); fields.get("band_m")!.input.addEventListener("input", paintScenario); material.select.addEventListener("change", () => { const m = overview?.materials.find((x) => x.id === material.select.value); if (!m) return; const set = (key: string, v: number) => (fields.get(key)!.input.value = String(v)); const rangeC = Number(overview!.defaults.c_hi) - Number(overview!.defaults.c); const rangePhi = Number(overview!.defaults.phi_hi) - Number(overview!.defaults.phi); set("gamma_t", m.gamma_t); set("gamma_sat", m.gamma_sat); set("c", m.c); set("c_hi", m.c + rangeC); set("phi", m.phi); set("phi_hi", m.phi + rangePhi); remember(); }); function paintButtons(): void { const editable = Boolean(overview?.editable); analyzeButton.disabled = reanalyzeButton.disabled = !editable || running || !overview; readOnly.hidden = !overview || editable; } function paintResult(): void { const result = overview?.result; if (!result) { resultBody.replaceChildren(el("p", { className: "e07-empty", text: t("E07_Result_None") })); return; } const s = result.summary; const list = el("dl", { className: "e07-summary" }); const pairs: [LocaleKey, string][] = [ ["E07_Scenario", scenarioName(result.settings.scenario)], ["E07_Sum_FsMin", s.fs_min === null ? "-" : s.fs_min.toFixed(3)], ["E07_Sum_Risk", `${Math.round(s.risk_area_m2).toLocaleString()} m²`], [ "E07_Sum_Levels", `${LEVEL_KEYS.map((k) => `${t(`E07_Level_${k}` as LocaleKey)} ${s.station_levels[k]}`).join(" · ")} / ${s.stations}`, ], [ "E07_Sum_Covered", `${Math.round(s.covered_area_m2).toLocaleString()} / ${Math.round(s.band_area_m2).toLocaleString()} m²`, ], [ "E07_Sum_Time", `${result.elapsed_s.toFixed(1)} s · ${result.computed_at.replace("T", " ")}`, ], ]; for (const [key, value] of pairs) list.append( el("dt", { text: t(key) }), el("dd", { text: value, attrs: { "data-field": key } }), ); const areas = el("table", { className: "e07-table e07-areas" }); const total = Object.values(s.areas_m2).reduce((a, b) => a + b, 0) || 1; areas.append( el("tr", { children: [ el("th", { text: t("E07_Col_Class") }), el("th", { text: t("E07_Col_Area") }), el("th", { text: "%" }), ], }), ...CLASS_KEYS.map((k, i) => { const name = el("td"); name.append( swatch(CLASS_COLORS[i]), el("span", { text: t(`E07_Class_${k}` as LocaleKey) }), ); return el("tr", { attrs: { "data-class": k }, children: [ name, el("td", { text: Math.round(s.areas_m2[k] ?? 0).toLocaleString() }), el("td", { text: (((s.areas_m2[k] ?? 0) / total) * 100).toFixed(1) }), ], }); }), ); const table = el("table", { className: "e07-table e07-stations" }); table.append( el("tr", { children: [ el("th", { text: t("E07_Col_Station") }), el("th", { text: t("E07_Col_FsMin") }), el("th", { text: t("E07_Col_Ratio") }), el("th", { text: t("E07_Col_Level") }), el("th", { text: t("E07_Col_RiskArea") }), ], }), ...result.stations.map((r) => { const level = el("td"); if (r.level) level.append( swatch(LEVEL_COLORS[r.level]), el("span", { text: t(`E07_Level_${r.level}` as LocaleKey) }), ); const tr = el("tr", { className: r.level === "high" ? "is-risk" : "", attrs: { "data-chainage": String(r.chainage_m) }, children: [ el("td", { text: shortLabel(r.label, r.chainage_m) }), el("td", { text: r.fs_min === undefined ? "-" : r.fs_min.toFixed(2) }), el("td", { text: r.risk_ratio === undefined ? "-" : (r.risk_ratio * 100).toFixed(1), }), level, el("td", { text: r.risk_area_m2 === undefined ? "-" : Math.round(r.risk_area_m2).toLocaleString(), }), ], }); tr.addEventListener("click", () => { table.querySelectorAll("tr.is-on").forEach((x) => x.classList.remove("is-on")); tr.classList.add("is-on"); map.focus(r.chainage_m); }); return tr; }), ); const notes: HTMLElement[] = []; if (result.stale) notes.push(el("p", { className: "e07-warn", text: t("E07_Result_Stale") })); notes.push(el("p", { className: "e07-note", text: t("E07_Result_Note") })); resultBody.replaceChildren( list, el("h4", { text: t("E07_Areas_Title") }), areas, el("h4", { text: t("E07_Stations_Title") }), table, ...notes, ); } async function refresh(): Promise { overview = await api(`/projects/${projectId}/stab/area`); material.setOptions( overview.materials.map((m) => ({ value: m.id, text: m.name })), String(overview.defaults.material_id ?? ""), ); map.setResult(overview.result ? decode(overview.result) : null); paintButtons(); paintResult(); paintLegend(); } async function poll(): Promise { while (root.isConnected) { const p = await api(`/projects/${projectId}/stab/area/progress`).catch(() => null); if (!p) break; running = p.running; progressBox.hidden = !p.running; progressBar.value = p.percent; progressText.textContent = `${t("E07_Running")} ${p.percent}% · ${p.message ?? ""}`; paintButtons(); if (!p.running) { if (p.error) showToast(p.error, "error"); else showToast(t("E07_Done"), "success"); await refresh(); return; } await new Promise((resolve) => setTimeout(resolve, 500)); } } async function analyze(force: boolean): Promise { try { const body = await api<{ status: string }>(`/projects/${projectId}/stab/area/analyze`, { method: "POST", body: JSON.stringify({ ...read(), force }), }); if (body.status === "cached") { showToast(t("E07_Cached"), "info"); await refresh(); return; } running = true; paintButtons(); progressBox.hidden = false; void poll(); } catch (error) { showToast(error instanceof Error ? error.message : String(error), "error"); } } // ---- 처음 ---- paintButtons(); void (async () => { try { await refresh(); } catch (error) { main.replaceChildren( el("p", { className: "e07-empty", text: error instanceof Error ? error.message : String(error), }), ); return; } const ov = overview!; const cached = JSON.parse( sessionStorage.getItem(cacheKey(projectId)) ?? "null", ) as Settings | null; // 옛 조건(빠진 칸 · 없어진 시나리오)은 기본값으로 채움 write({ ...ov.defaults, ...(cached ?? (ov.result?.settings as Settings | undefined)) }); map.setRoute(ov.line, ov.stations); const p = await api(`/projects/${projectId}/stab/area/progress`).catch(() => null); if (p?.running) void poll(); })(); const leave = new MutationObserver(() => { if (root.isConnected && main.isConnected) return; leave.disconnect(); map.dispose(); }); leave.observe(document.body, { childList: true, subtree: true }); }