Files
Aislo/E04_Stab3D/E04_Stab3D_UI_Page.ts
T

448 lines
16 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.
/* =============================================================================
* E04_Stab3D_UI_Page.ts
* E04 안정성평가 3D 해석(요구 347-1 · 요구서 4-2 · 4-3 · PLAN 533-3) — B05~B09 틀(group_workflow).
* 좌 = 구간 목록 · 탐색 설정(기본 · 고급 접힘) · [해석] · 진행률 · [끊기]
* 가운데 = 평면 지도(셀별 최소 안전율 · 임계 체적) · [3D 보기](지표 + 노선면 + 임계 활동면)
* 우 = 구간 결과(3D 최소 · 2D 최소(같은 구간 E03 측점 · 3D 와 같은 조건 안전율 최소) · 3D/2D 비 · 체적 · 방법)
* 계산 = 서버 단독(POST …/stab/analyze3d · 뒤에서) · 화면은 값 · 그림만. 탐색 설정 = sessionStorage.
* ========================================================================== */
import "./E04_Stab3D_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, el, showToast } 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 { createPlanMap, type MapLayer, type StationPoint } from "./E04_Stab3D_UI_Map";
import { createView3D } from "./E04_Stab3D_UI_View3D";
import { buildSettingsCards, type Settings } from "./E04_Stab3D_UI_Settings";
import {
FS_STEPS,
VOLUME_STEPS,
legendRows,
fs2dOf,
min2d,
ratio3d2d,
stepColor,
type Result3d,
} from "./logic/E04_Stab3D_Raster";
interface SavedSummary {
key: string;
start_m: number;
end_m: number;
f_min: number;
volume: number;
method: string;
stale: boolean;
}
interface Overview {
editable: boolean;
sections: { start_m: number; end_m: number }[];
stations: StationPoint[];
line: number[][];
saved: SavedSummary[];
defaults: Settings;
max_section_m: number;
}
interface Progress {
running: boolean;
percent: number;
message: string | null;
error: string | null;
cancelled: boolean;
key: string | null;
}
const cacheKey = (projectId: string): string => `e04_settings:${projectId}`;
const sectionKey = (start: number, end: number): string => `${start.toFixed(2)}_${end.toFixed(2)}`;
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;
throw new Error(typeof detail === "string" ? detail : `HTTP ${response.status}`);
}
return body;
}
/** E03 2D 측점 줄(GET …/stab/result2d · fs_by_condition · min = 조건별) — 없으면 빈 목록 */
type Station2d = Parameters<typeof fs2dOf>[0] & { chainage_m: number };
async function fetch2d(projectId: string): Promise<Station2d[]> {
try {
const body = await api<{ stations?: Station2d[] }>(`/projects/${projectId}/stab/result2d`);
return (body.stations ?? []).map((s) => ({ ...s, chainage_m: Number(s.chainage_m) }));
} catch {
return [];
}
}
function legend(layer: MapLayer): HTMLElement {
const box = el("div", { className: "e04-legend" });
box.append(el("strong", { text: t(layer === "fs" ? "E04_Legend_FS" : "E04_Legend_Volume") }));
const rows = layer === "fs" ? legendRows(FS_STEPS, 1) : legendRows(VOLUME_STEPS, 0);
for (const row of [...rows, { color: stepColor(FS_STEPS, null), label: t("E04_NoData") }]) {
const item = el("span", { className: "e04-legend__row" });
const swatch = el("i", { className: "e04-legend__swatch" });
swatch.style.background = row.color;
item.append(swatch, el("span", { text: row.label }));
box.append(item);
}
return box;
}
export function renderE04Stab3D(root: HTMLElement): void {
const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY);
const panel = el("div", { className: "ui-sidebar-panel e04-panel" });
const main = el("div", { className: "e04-page" });
mountGroupStep(root, ROUTES.E04_STAB_3D, { mainContent: main, leftPanel: panel });
if (!projectId)
return void main.append(el("p", { className: "e04-empty", text: t("E04_NoProject") }));
// ---- 가운데 ----
const map = createPlanMap();
const view3d = createView3D();
view3d.root.hidden = true;
let mode: "plan" | "3d" = "plan";
let layer: MapLayer = "fs";
const toggle = (labels: [LocaleKey, string][], onPick: (value: string) => void) => {
const group = el("div", { className: "e04-toggle" });
const buttons = labels.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", () => {
buttons.forEach((b) => b.classList.toggle("is-on", b === button));
onPick(value);
});
return button;
});
group.append(...buttons);
return group;
};
const legendSlot = el("div", { className: "e04-legend-slot" });
const paintLegend = (): void => legendSlot.replaceChildren(legend(layer));
const stage = el("div", { className: "e04-stage" });
stage.append(map.root, view3d.root, legendSlot);
const toolbar = el("div", { className: "e04-toolbar" });
toolbar.append(
toggle(
[
["E04_View_Plan", "plan"],
["E04_View_3D", "3d"],
],
(value) => {
mode = value as "plan" | "3d";
map.root.hidden = mode !== "plan";
view3d.root.hidden = mode !== "3d";
view3d.setActive(mode === "3d");
if (mode === "3d") view3d.show(result, overview?.line ?? [], layer);
},
),
toggle(
[
["E04_Layer_FS", "fs"],
["E04_Layer_Volume", "volume"],
],
(value) => {
layer = value as MapLayer;
map.setLayer(layer);
if (mode === "3d") view3d.show(result, overview?.line ?? [], layer, false);
paintLegend();
},
),
);
const center = el("div", { className: "e04-center" });
center.append(toolbar, stage);
paintLegend();
// ---- 우 ----
const resultBody = el("div", { className: "e04-result" });
const side = createSidePanels({
prefKey: "e04-stab3d-result",
panels: [{ id: "result", title: t("E04_Result_Title"), body: resultBody }],
defaultOpen: "result",
defaultWidth: 320,
follow: false,
});
const row = el("div", { className: "ui-side-panels-row e04-row" });
row.append(center, side.root);
main.append(row);
// ---- 좌 ----
const sectionList = el("div", { className: "e04-sections" });
const sectionCard = createPanelCard(t("E04_Card_Sections"), false);
const settings = buildSettingsCards();
sectionCard.append(sectionList, settings.rangeRow);
const progressBar = el("progress", { className: "e04-progress", attrs: { max: "100" } });
const progressText = el("p", { className: "e04-progress__text" });
const cancelButton = createButton({
label: t("E04_Cancel"),
variant: "danger-ghost",
size: "sm",
onClick: () => void api(`/projects/${projectId}/stab/analyze3d/cancel`, { method: "POST" }),
});
const progressBox = el("div", { className: "e04-progress-box" });
progressBox.append(progressBar, progressText, cancelButton);
progressBox.hidden = true;
const analyzeButton = createButton({
label: t("E04_Analyze"),
variant: "filled",
onClick: () => void analyze(false),
});
const reanalyzeButton = createButton({
label: t("E04_Reanalyze"),
variant: "ghost",
onClick: () => void analyze(true),
});
analyzeButton.disabled = reanalyzeButton.disabled = true;
const actions = el("div", { className: "ui-sidebar-actions" });
actions.append(reanalyzeButton, analyzeButton);
const readOnly = el("p", { className: "e04-hint", text: t("E04_ReadOnly") });
readOnly.hidden = true;
panel.append(
el("p", { className: "e04-hint", text: t("E04_Hint") }),
readOnly,
sectionCard,
settings.basicCard,
settings.advancedCard,
progressBox,
actions,
);
attachCollapsible(panel);
// ---- 상태 ----
let overview: Overview | null = null;
let result: Result3d | null = null;
let stations2d: Station2d[] = [];
let running = false;
const remember = (): void =>
sessionStorage.setItem(cacheKey(projectId), JSON.stringify(settings.read()));
let shownKey = "";
settings.onChange(() => {
remember();
const { start_m, end_m } = settings.read();
map.setRange(start_m, end_m);
paintSections();
if (shownKey !== sectionKey(start_m, end_m)) void openResult();
});
function paintButtons(): void {
const editable = Boolean(overview?.editable);
analyzeButton.disabled = reanalyzeButton.disabled = !editable || running || !overview;
readOnly.hidden = !overview || editable;
}
function paintSections(): void {
if (!overview) return;
const { start_m, end_m } = settings.read();
const savedOf = new Map(overview.saved.map((s) => [s.key, s]));
const custom = overview.saved.filter(
(s) => !overview!.sections.some((x) => sectionKey(x.start_m, x.end_m) === s.key),
);
const items = [
...overview.sections,
...custom.map((s) => ({ start_m: s.start_m, end_m: s.end_m })),
];
sectionList.replaceChildren(
...items.map((section) => {
const key = sectionKey(section.start_m, section.end_m);
const saved = savedOf.get(key);
const button = el("button", {
className: `e04-section${key === sectionKey(start_m, end_m) ? " is-active" : ""}`,
attrs: { type: "button", "data-key": key },
});
const chip = el("i", { className: "e04-section__chip" });
chip.style.background = saved ? stepColor(FS_STEPS, saved.f_min) : "transparent";
button.append(
chip,
el("span", { text: `${section.start_m.toFixed(0)} – ${section.end_m.toFixed(0)} m` }),
el("span", {
className: "e04-section__value",
text: saved
? `Fs ${saved.f_min.toFixed(2)}${saved.stale ? ` · ${t("E04_Stale")}` : ""}`
: "",
}),
);
button.addEventListener("click", () => settings.setRange(section.start_m, section.end_m));
return button;
}),
);
}
function paintResult(): void {
if (!result) {
resultBody.replaceChildren(el("p", { className: "e04-empty", text: t("E04_Result_None") }));
return;
}
const s = result.summary;
const f2d = min2d(
stations2d.map((x) => ({ chainage_m: x.chainage_m, fs_min: fs2dOf(x, s.condition) })),
s.start_m,
s.end_m,
);
const ratio = ratio3d2d(s.f_min, f2d);
const station = overview?.stations.reduce<StationPoint | null>(
(best, x) =>
!best || Math.abs(x.chainage_m - s.chainage_m) < Math.abs(best.chainage_m - s.chainage_m)
? x
: best,
null,
);
const rows: [LocaleKey, string, string?][] = [
["E04_Result_F3d", s.f_min.toFixed(3), stepColor(FS_STEPS, s.f_min)],
[
"E04_Result_Verdict",
`${t(s.verdict === "ok" ? "E04_Verdict_ok" : "E04_Verdict_ng")} (${t(
`E04_Condition_${s.condition}` as LocaleKey,
)} ≥ ${s.reference_fs.toFixed(2)})`,
],
["E04_Result_F2d", f2d === null ? t("E04_No2D") : f2d.toFixed(3)],
["E04_Result_Ratio", ratio === null ? "-" : ratio.toFixed(3)],
["E04_Result_Volume", `${Math.round(s.volume).toLocaleString()} m³`],
["E04_Result_Method", t(`E04_MethodName_${s.method}` as LocaleKey)],
["E04_Result_Bishop", s.by_method.bishop.toFixed(3)],
["E04_Result_Janbu", s.by_method.janbu_corrected.toFixed(3)],
["E04_Result_Hovland", s.by_method.hovland.toFixed(3)],
["E04_Result_Where", station?.label ?? `${s.chainage_m.toFixed(1)} m`],
["E04_Result_Cells", s.cells.toLocaleString()],
["E04_Result_Trials", s.trials.toLocaleString()],
["E04_Result_Time", `${result.elapsed_s.toFixed(1)} s`],
];
const list = el("dl", { className: "e04-result__list" });
for (const [key, value, color] of rows) {
const dd = el("dd", { text: value, attrs: { "data-field": key } });
if (key === "E04_Result_Verdict") dd.classList.add(`is-${s.verdict}`);
if (color) {
const chip = el("i", { className: "e04-section__chip" });
chip.style.background = color;
dd.prepend(chip);
}
list.append(el("dt", { text: t(key) }), dd);
}
const notes: HTMLElement[] = [];
if (result.stale) notes.push(el("p", { className: "e04-warn", text: t("E04_Result_Stale") }));
notes.push(el("p", { className: "e04-note", text: t("E04_Result_Note") }));
resultBody.replaceChildren(list, ...notes);
}
async function openResult(): Promise<void> {
const { start_m, end_m } = settings.read();
const key = sectionKey(start_m, end_m);
shownKey = key;
const saved = overview?.saved.some((s) => s.key === key);
result = saved
? await api<Result3d>(`/projects/${projectId}/stab/result3d?key=${key}`).catch(() => null)
: null;
if (key !== sectionKey(settings.read().start_m, settings.read().end_m)) return; // 그새 바뀜
map.setResult(result);
if (mode === "3d") view3d.show(result, overview?.line ?? [], layer);
paintResult();
}
async function refresh(): Promise<void> {
overview = await api<Overview>(`/projects/${projectId}/stab/result3d`);
paintButtons();
paintSections();
}
async function poll(): Promise<void> {
while (root.isConnected) {
const p = await api<Progress>(`/projects/${projectId}/stab/progress3d`).catch(() => null);
if (!p) break;
running = p.running;
progressBox.hidden = !p.running;
progressBar.value = p.percent;
progressText.textContent = `${t("E04_Running")} ${p.percent}% · ${p.message ?? ""}`;
paintButtons();
if (!p.running) {
if (p.error) showToast(p.error, "error");
else if (p.cancelled) showToast(t("E04_Cancelled"), "info");
else showToast(t("E04_Done"), "success");
await refresh();
await openResult();
return;
}
await new Promise((resolve) => setTimeout(resolve, 700));
}
}
async function analyze(force: boolean): Promise<void> {
const values = settings.read();
const { start_m, end_m, condition, ...search } = values;
try {
const body = await api<{ status: string }>(`/projects/${projectId}/stab/analyze3d`, {
method: "POST",
body: JSON.stringify({ start_m, end_m, condition, search, force }),
});
if (body.status === "cached") {
showToast(t("E04_Cached"), "info");
await openResult();
return;
}
running = true;
paintButtons();
progressBox.hidden = false;
void poll();
} catch (error) {
showToast(error instanceof Error ? error.message : String(error), "error");
}
}
// ---- 처음 ----
void (async () => {
try {
await refresh();
} catch (error) {
main.replaceChildren(
el("p", {
className: "e04-empty",
text: error instanceof Error ? error.message : String(error),
}),
);
return;
}
const ov = overview!;
settings.setDefaults(ov.defaults, ov.max_section_m);
const cached = JSON.parse(
sessionStorage.getItem(cacheKey(projectId)) ?? "null",
) as Settings | null;
const first = ov.saved[0] ?? ov.sections[0];
settings.write(
cached ?? {
...ov.defaults,
start_m: first?.start_m ?? 0,
end_m: first?.end_m ?? 100,
condition: "dry",
},
);
map.setRoute(ov.line, ov.stations);
const { start_m, end_m } = settings.read();
map.setRange(start_m, end_m);
paintSections();
stations2d = await fetch2d(projectId);
await openResult();
const p = await api<Progress>(`/projects/${projectId}/stab/progress3d`).catch(() => null);
if (p?.running) void poll();
})();
// 떠날 때 — WebGL · 관찰자 정리
const leave = new MutationObserver(() => {
if (root.isConnected && main.isConnected) return;
leave.disconnect();
map.dispose();
view3d.dispose();
});
leave.observe(document.body, { childList: true, subtree: true });
}