/* ============================================================================= * 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 · 뒤에서) · 화면은 값 · 그림만. 조건 = 캐시 `e07-settings` * · 없으면 지난 결과 조건 + 물성은 지금 E02 값. * ========================================================================== */ import "./E07_StabArea_UI_Style.css"; import { API_BASE_URL, 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 { createBanner } from "@ui/ui_template_banner"; import { createEmpty } from "@ui/ui_template_empty"; import { createFloatPanel } from "@ui/ui_template_float_panel"; import { createLegend } from "@ui/ui_template_flow_legend"; import { createKv } from "@ui/ui_template_kv"; import { t, type LocaleKey } from "@ui/ui_template_locale"; import { createProgressBar } from "@ui/ui_template_progress"; import { createSegment } from "@ui/ui_template_segment"; import { createTable } from "@ui/ui_template_table"; import { createSidePanels } from "@ui/ui_template_side_panels"; import { createText } from "@ui/ui_template_text"; import { inferStationInterval, stationLabel } from "../common_util/common_util_station"; 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 { 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"], ]; 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 ?? ""); /** 안내 글 한 줄 — 걸이 이름(`e07-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("e07-empty"); return node; } /** 지도 범례 — 떠 있는 판(머리 = 제목) 안에 범례 부품 */ function legend(layer: MapLayer, scenario: unknown): HTMLElement { const panel = createFloatPanel({ title: t(layer === "cls" ? "E07_Legend_Class" : "E07_Legend_FS"), }); panel.root.classList.add("e07-legend"); if (scenario) panel.body.append(note(scenarioName(scenario), "e07-legend__sub")); 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)}`, })); panel.body.append(createLegend(rows, { vertical: true })); return panel.root; } export function renderE07StabArea(root: HTMLElement): void { const projectId = currentProjectId(); 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(emptyBox(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 layerToggle = createSegment({ options: [ { value: "cls", label: t("E07_Layer_Class") }, { value: "fs", label: t("E07_Layer_FS") }, ], value: "cls", size: 24, onChange: (value) => { layer = value as MapLayer; map.setLayer(layer); paintLegend(); }, }); const toggle = layerToggle.root; toggle.classList.add("e07-toggle"); 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 = note(t("E07_Defaults_Source"), "e07-hint"); 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 = createProgressBar(); const progressText = note("", "e07-progress__text"); const progressBox = el("div", { className: "e07-progress-box" }); progressBox.append(progressBar.root, 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(), }); const actions = el("div", { className: "ui-sidebar-actions" }); actions.append(defaultsButton, analyzeButton); const readOnly = note(t("E07_ReadOnly"), "e07-hint"); readOnly.hidden = true; panel.append( note(t("E07_Hint"), "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 => writeState("e07-settings", read(), projectId); panel.addEventListener("input", remember); scenario.select.addEventListener("change", () => { paintScenario(); remember(); }); fields.get("band_m")!.input.addEventListener("input", paintScenario); /** E02 물성 값(범위 위 = 기본값과 같은 폭) — 없는 id 면 빈 것 */ function materialValues(id: unknown): Settings { const m = overview?.materials.find((x) => x.id === id); if (!m) return {}; const rangeC = Number(overview!.defaults.c_hi) - Number(overview!.defaults.c); const rangePhi = Number(overview!.defaults.phi_hi) - Number(overview!.defaults.phi); return { gamma_t: m.gamma_t, gamma_sat: m.gamma_sat, c: m.c, c_hi: m.c + rangeC, phi: m.phi, phi_hi: m.phi + rangePhi, }; } material.select.addEventListener("change", () => { const values = materialValues(material.select.value); for (const [key, v] of Object.entries(values)) fields.get(key)!.input.value = String(v); remember(); }); function paintButtons(): void { const editable = Boolean(overview?.editable); analyzeButton.disabled = !editable || running || !overview; readOnly.hidden = !overview || editable; } function paintResult(): void { const result = overview?.result; if (!result) { resultBody.replaceChildren(emptyBox(t("E07_Result_None"))); return; } const s = result.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", " ")}`, ], ]; const list = createKv(pairs.map(([key, value]) => ({ label: t(key), value }))); list.classList.add("e07-summary"); list.querySelectorAll("dd").forEach((dd, i) => dd.setAttribute("data-field", pairs[i][0])); const total = Object.values(s.areas_m2).reduce((a, b) => a + b, 0) || 1; /** 색 칩 + 글 한 칸 */ const chipCell = (color: string, text: string): Node => { const cell = document.createDocumentFragment(); cell.append(swatch(color), el("span", { text })); return cell; }; const areas = createTable( [ { label: t("E07_Col_Class") }, { label: t("E07_Col_Area"), align: "end" }, { label: "%", align: "end" }, ], CLASS_KEYS.map((k, i) => [ chipCell(CLASS_COLORS[i], t(`E07_Class_${k}` as LocaleKey)), Math.round(s.areas_m2[k] ?? 0).toLocaleString(), (((s.areas_m2[k] ?? 0) / total) * 100).toFixed(1), ]), { density: "data" }, ); areas.querySelector("table")!.classList.add("e07-table", "e07-areas"); areas .querySelectorAll("tbody tr") .forEach((tr, i) => ((tr as HTMLElement).dataset.class = CLASS_KEYS[i])); const stations = createTable( [ { label: t("E07_Col_Station") }, { label: t("E07_Col_FsMin"), align: "end" }, { label: t("E07_Col_Ratio"), align: "end" }, { label: t("E07_Col_Level") }, { label: t("E07_Col_RiskArea"), align: "end" }, ], result.stations.map((r) => [ stationLabel(r.chainage_m, inferStationInterval(overview?.stations ?? []), 1), r.fs_min === undefined ? "-" : r.fs_min.toFixed(2), r.risk_ratio === undefined ? "-" : (r.risk_ratio * 100).toFixed(1), r.level ? chipCell(LEVEL_COLORS[r.level], t(`E07_Level_${r.level}` as LocaleKey)) : "", r.risk_area_m2 === undefined ? "-" : Math.round(r.risk_area_m2).toLocaleString(), ]), { density: "data" }, ); const table = stations.querySelector("table")!; table.classList.add("e07-table", "e07-stations"); table.querySelectorAll("tbody tr").forEach((row, i) => { const tr = row as HTMLElement; const r = result.stations[i]; tr.dataset.chainage = String(r.chainage_m); tr.classList.toggle("is-risk", r.level === "high"); tr.addEventListener("click", () => { table.querySelectorAll("tr.is-on").forEach((x) => x.classList.remove("is-on", "is-active")); tr.classList.add("is-on", "is-active"); map.focus(r.chainage_m); }); }); const notes: HTMLElement[] = []; if (result.stale) { const warn = createBanner({ tone: "warning", text: t("E07_Result_Stale") }); warn.classList.add("e07-warn"); notes.push(warn); } notes.push(note(t("E07_Result_Note"), "e07-note")); resultBody.replaceChildren( list, el("h4", { text: t("E07_Areas_Title") }), areas, el("h4", { text: t("E07_Stations_Title") }), stations, ...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.set(p.percent / 100); progressText.replaceChildren(`${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(): Promise { try { const body = await api<{ status: string }>(`/projects/${projectId}/stab/area/analyze`, { method: "POST", body: JSON.stringify(read()), }); 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(emptyBox(error instanceof Error ? error.message : String(error))); return; } const ov = overview!; const cached = readState("e07-settings", projectId); // 옛 조건(빠진 칸 · 없어진 시나리오)은 기본값으로 채움 · 캐시가 없으면 물성 = 지금 E02 값 const last = ov.result?.settings as Settings | undefined; write({ ...ov.defaults, ...(cached ?? { ...last, ...materialValues(last?.material_id ?? ov.defaults.material_id) }), }); 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 }); }