Files
Aislo/E07_StabArea/E07_StabArea_UI_Page.ts
T

498 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =============================================================================
* 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<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 ?? "");
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<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 = 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<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.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<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(
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<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 });
}