495 lines
18 KiB
TypeScript
495 lines
18 KiB
TypeScript
/* =============================================================================
|
||
* 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 { 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 { 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<string, number | string | null>;
|
||
|
||
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<T>(path: string, init?: RequestInit): Promise<T> {
|
||
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 = 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(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<string, InputFieldHandle>();
|
||
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(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 = 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 => 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(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) => [
|
||
shortLabel(r.label, r.chainage_m),
|
||
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<void> {
|
||
overview = await api<Overview>(`/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<void> {
|
||
while (root.isConnected) {
|
||
const p = await api<Progress>(`/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(force: boolean): Promise<void> {
|
||
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(emptyBox(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<Progress>(`/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 });
|
||
}
|