Files
Aislo/E07_StabArea/E07_StabArea_UI_Page.ts
T
eomsangdonandClaude Opus 5.5 c3fbcb13f1 feat(D · E): 떠남 = 공용 물음 [취소 · 폐기 · 저장] · 캐시 = 등록표 · 프로젝트 = 탭 currentProjectId (PLAN 572-3 · 요구 400~403)
- D02 · D03 · E02 말없이 저장 → mountGroupStep {dirty, save, discard} 공용 물음
- D04 · E05 · E06 가드 새로 · E03 = 대표 단면 별 저장 진행 중일 때만 물음
- D02 · E02~E07 세션 직접 읽기 · 쓰기 → 등록표 d02-points · e02-ground · e03-prob · e04-settings · e05-report · e06-rock · e07-settings
- 아홉 페이지 localStorage 지금 프로젝트 직접 읽기 → currentProjectId() (다른 탭이 바꿔도 이 탭 그대로)
- test_560_4 D · E 줄 넓힘(옛 beforeLeave 단언 → dirty · save) · test_401 PENDING 에서 D · E 뺌

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 18:09:08 +09:00

499 lines
18 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 · 뒤에서) · 화면은 값 · 그림만. 조건 = 캐시 `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<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"],
];
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 = 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<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(),
});
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<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(): Promise<void> {
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<Settings>("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<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 });
}