498 lines
17 KiB
TypeScript
498 lines
17 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 { 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 });
|
||
}
|