448 lines
16 KiB
TypeScript
448 lines
16 KiB
TypeScript
/* =============================================================================
|
||
* 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 });
|
||
}
|