- UI_Prob(새 파일): 대표 단면 · 고른 비탈 · 조건 · 단면 물성 × c · φ · γ(+ 수위선 = 지하수위 이동 · r_u 갈래 = r_u) · 쓰기 · 분포 · 변동계수/σ · 절단 ±kσ · 임계면 고정 / 매회 탐색 · 방법 · LHS / MC · N · 시드 · c–φ 상관 · [기본값] · 입력 = sessionStorage 캐시(자동저장 없음) · 계산 = 서버(잠금판 없이 카드 안 진행률) - UI_ProbChart(새 파일): F < 1 빨강 히스토그램 · F = 1 · 기준 · 결정론 선 · 누적분포(Pf) · 민감도(범위 %) · 토네이도 - logic/E03_StabSection_Prob.ts: 변수 줄 · 기본값 후보(물성 덮어쓰기) · 요청 꼴 · 바꾼 줄 수 · 눈금 - Page: 보기 고름에 「확률 · 민감도」 · 문구 locale_e03 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
540 lines
19 KiB
TypeScript
540 lines
19 KiB
TypeScript
/* =============================================================================
|
|
* E03_StabSection_UI_Page.ts
|
|
* E03 2D 측점 해석(요구서 345 4-3 · 요구 347 · 351 · PLAN 532-3) — B05~B09 틀(group_workflow).
|
|
* 좌 = 측점 목록(조건 · 정렬 · N.G 만 · 대표 단면 별) · [해석] · [3D 보기] · [대표 단면 저장]
|
|
* 위 = 평면 위험 띠 · 가운데 = 횡단(임계면 · 등안전율 · 후보 · 절편) · 우 = 결과 · 절편표 · 자유물체도 · 그래프
|
|
* 계산 = 서버 단독([해석] → POST …/stab/analyze2d · 잠금판 진행률 = GET …/stab/progress) ·
|
|
* 대표 단면 켬/끔 = sessionStorage 캐시 → [대표 단면 저장] = PUT(자동저장 없음).
|
|
* 「보강 검토」 탭 = 우측을 가정 재해석 · 벽 외적 안정으로 바꿈(`UI_Reinforce` · 서버 따로 저장본).
|
|
* 「확률 · 민감도」 탭 = 대표 단면 확률 해석(`UI_Prob` · 서버 따로 저장본 · 카드 안 진행률).
|
|
* ========================================================================== */
|
|
|
|
import "./E03_StabSection_UI_Style.css";
|
|
import { API_BASE_URL, CURRENT_PROJECT_ID_KEY, ROUTES } from "@config/config_frontend";
|
|
import {
|
|
createButton,
|
|
createSelectField,
|
|
el,
|
|
hideLoadingOverlay,
|
|
setLoadingOverlayProgress,
|
|
showLoadingOverlay,
|
|
showToast,
|
|
} from "@ui/ui_template_elements";
|
|
import { t, type LocaleKey } from "@ui/ui_template_locale";
|
|
import { mountGroupStep } from "../A00_Common/group_workflow";
|
|
import { buildMissingBanner } from "../E01_StabRegister/E01_StabRegister_UI_Missing";
|
|
import { buildList } from "./E03_StabSection_UI_List";
|
|
import { buildPlan } from "./E03_StabSection_UI_Plan";
|
|
import { buildResult } from "./E03_StabSection_UI_Result";
|
|
import {
|
|
buildLegends,
|
|
createSectionCanvas,
|
|
WALL_ID,
|
|
type SectionToggles,
|
|
} from "./E03_StabSection_UI_Section";
|
|
import { openView3d } from "./E03_StabSection_UI_View3D";
|
|
import {
|
|
buildReinforce,
|
|
type SlopeAsk,
|
|
type WallAsk,
|
|
type WallRecord,
|
|
type WhatIfRecord,
|
|
} from "./E03_StabSection_UI_Reinforce";
|
|
import { buildSupport, type SupportAsk, type SupportRecord } from "./E03_StabSection_UI_Support";
|
|
import { createProbPanel } from "./E03_StabSection_UI_Prob";
|
|
import type {
|
|
Analysis,
|
|
Condition,
|
|
SectionView,
|
|
SortMode,
|
|
StationRow,
|
|
} from "./logic/E03_StabSection_Logic";
|
|
|
|
interface Result2d {
|
|
status: "ok" | "none";
|
|
fresh: boolean;
|
|
editable: boolean;
|
|
running: boolean;
|
|
conditions: Condition[];
|
|
computed_at?: string;
|
|
summary?: Record<string, unknown>;
|
|
stations: StationRow[];
|
|
/** 횡단 없어 해석에서 빠진 측점 누가거리(E01 복사본) */
|
|
missing_stations?: number[];
|
|
}
|
|
|
|
interface Progress {
|
|
running: boolean;
|
|
percent: number;
|
|
message: string;
|
|
error: string | null;
|
|
}
|
|
|
|
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) {
|
|
throw new Error(typeof body.detail === "string" ? body.detail : `HTTP ${response.status}`);
|
|
}
|
|
return body;
|
|
}
|
|
|
|
const repKey = (projectId: string): string => `e03_representative_cache:${projectId}`;
|
|
const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
|
|
|
|
function checkbox(label: string, checked: boolean, onChange: (v: boolean) => void): HTMLElement {
|
|
const input = el("input", { attrs: { type: "checkbox" } });
|
|
input.checked = checked;
|
|
input.addEventListener("change", () => onChange(input.checked));
|
|
return el("label", { className: "e03-check", children: [input, label] });
|
|
}
|
|
|
|
export function renderE03StabSection(root: HTMLElement): void {
|
|
const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY);
|
|
const main = el("div", { className: "e03-page" });
|
|
const panel = el("div", { className: "ui-sidebar-panel e03-panel" });
|
|
mountGroupStep(root, ROUTES.E03_STAB_SECTION, { mainContent: main, leftPanel: panel });
|
|
if (!projectId)
|
|
return void main.append(el("p", { className: "e03-empty", text: t("E03_NoProject") }));
|
|
const pid: string = projectId;
|
|
const base = `/projects/${pid}/stab`;
|
|
const prob = createProbPanel(pid, base, () => paintSection());
|
|
|
|
let result: Result2d | null = null;
|
|
const sections = new Map<string, SectionView>();
|
|
let condition: Condition = "dry";
|
|
let sort: SortMode = "chainage";
|
|
let ngOnly = false;
|
|
let active: string | null = null;
|
|
let side: string | null = null;
|
|
let picked: number | null = null;
|
|
const toggles: SectionToggles = { map: true, centers: false, candidates: true };
|
|
let mode: "result" | "reinforce" | "prob" = "result";
|
|
let busy = false;
|
|
const slopeRecs = new Map<string, WhatIfRecord>(); // 측점 열쇠 → 마지막 가정
|
|
const wallRecs = new Map<string, WallRecord>();
|
|
const supportRecs = new Map<string, SupportRecord>();
|
|
let repCache: Record<string, boolean | null> = JSON.parse(
|
|
sessionStorage.getItem(repKey(pid)) ?? "{}",
|
|
);
|
|
|
|
// ---- 좌측 ----
|
|
const condField = createSelectField({
|
|
label: t("E03_Condition"),
|
|
options: [],
|
|
compact: true,
|
|
onChange: (v) => {
|
|
condition = v as Condition;
|
|
side = null;
|
|
picked = null;
|
|
paint();
|
|
},
|
|
});
|
|
const sortField = createSelectField({
|
|
label: t("E03_Sort"),
|
|
options: [
|
|
{ value: "chainage", text: t("E03_Sort_Chainage") },
|
|
{ value: "fs", text: t("E03_Sort_Fs") },
|
|
],
|
|
compact: true,
|
|
onChange: (v) => {
|
|
sort = v as SortMode;
|
|
paintList();
|
|
},
|
|
});
|
|
const ngBox = checkbox(t("E03_NgOnly"), false, (v) => {
|
|
ngOnly = v;
|
|
paintList();
|
|
});
|
|
const status = el("p", { className: "e03-muted", attrs: { "data-role": "status" } });
|
|
const listHost = el("div", {
|
|
className: "e03-panel",
|
|
attrs: { style: "flex: 1 1 auto; min-height: 0" },
|
|
});
|
|
const analyzeButton = createButton({
|
|
label: t("E03_Analyze"),
|
|
variant: "filled",
|
|
onClick: () => void analyze(),
|
|
});
|
|
const view3dButton = createButton({
|
|
label: t("E03_View3d"),
|
|
variant: "ghost",
|
|
onClick: () => void show3d(),
|
|
});
|
|
const repButton = createButton({
|
|
label: t("E03_RepSave"),
|
|
variant: "ghost",
|
|
onClick: () => void saveRep(),
|
|
});
|
|
const controls = el("div", { className: "e03-panel__row" });
|
|
controls.append(condField.root, sortField.root);
|
|
const actions = el("div", { className: "ui-sidebar-actions" });
|
|
actions.append(repButton, view3dButton, analyzeButton);
|
|
panel.append(controls, ngBox, status, listHost, actions);
|
|
|
|
// ---- 본문 ----
|
|
const missingHost = el("div");
|
|
const planHost = el("div");
|
|
const toolbar = el("div", { className: "e03-card__head" });
|
|
const sectionCard = el("div", { className: "e03-card" });
|
|
const legendHost = el("div");
|
|
const canvas = createSectionCanvas((i) => {
|
|
picked = i;
|
|
paintSection();
|
|
});
|
|
sectionCard.append(toolbar, canvas.root, legendHost);
|
|
const resultHost = el("div", { className: "e03-result", attrs: { style: "min-height: 0" } });
|
|
const body = el("div", { className: "e03-body" });
|
|
body.append(sectionCard, resultHost);
|
|
main.append(missingHost, planHost, body);
|
|
|
|
const repOf = (row: StationRow): { on: boolean; user: boolean | null } => {
|
|
const server = row.representative ?? { auto: [], user: null, on: false };
|
|
if (!(row.key in repCache)) return { on: server.on, user: server.user };
|
|
const user = repCache[row.key];
|
|
return { on: user === null ? server.auto.length > 0 : user, user };
|
|
};
|
|
const repDirty = (): boolean => Object.keys(repCache).length > 0;
|
|
|
|
const currentRow = (): StationRow | undefined => result?.stations.find((r) => r.key === active);
|
|
|
|
/** 고른 측점 · 조건의 비탈 — 고른 게 없으면 안전율 가장 낮은 쪽 */
|
|
const currentAnalysis = (view: SectionView): Analysis | null => {
|
|
const list = view.analyses.filter((a) => a.condition === condition);
|
|
if (!list.length) return null;
|
|
const found = side ? list.find((a) => a.side === side) : null;
|
|
if (found) return found;
|
|
const worst = list
|
|
.slice()
|
|
.sort((a, b) => (a.critical?.fs ?? Infinity) - (b.critical?.fs ?? Infinity))[0];
|
|
side = worst.side;
|
|
return worst;
|
|
};
|
|
|
|
const paintStatus = (): void => {
|
|
const lines: string[] = [];
|
|
if (!result) lines.push(t("E03_Loading"));
|
|
else if (result.status === "none") lines.push(t("E03_NotYet"));
|
|
else {
|
|
lines.push(`${t("E03_Computed")} ${result.computed_at ?? ""}`);
|
|
if (!result.fresh) lines.push(t("E03_Stale"));
|
|
}
|
|
if (result && !result.editable) lines.push(t("E03_ReadOnly"));
|
|
if (repDirty()) lines.push(t("E03_RepUnsaved"));
|
|
status.textContent = lines.join("\n");
|
|
analyzeButton.disabled = !result?.editable;
|
|
repButton.disabled = !result?.editable || !repDirty();
|
|
view3dButton.disabled = result?.status !== "ok";
|
|
};
|
|
|
|
const paintList = (): void => {
|
|
if (!result) return;
|
|
listHost.replaceChildren(
|
|
buildList(
|
|
{
|
|
rows: result.stations,
|
|
conditions: result.conditions,
|
|
condition,
|
|
sort,
|
|
ngOnly,
|
|
active,
|
|
representative: repOf,
|
|
editable: result.editable,
|
|
},
|
|
(key) => void select(key),
|
|
(key) => {
|
|
const row = result!.stations.find((r) => r.key === key)!;
|
|
const now = repOf(row).on;
|
|
const auto = (row.representative?.auto.length ?? 0) > 0;
|
|
// 자동과 같아지면 사용자 값 지움(null) — 서버 값과 같으면 캐시에서 뺌
|
|
const next = !now === auto ? null : !now;
|
|
if (next === (row.representative?.user ?? null)) delete repCache[key];
|
|
else repCache[key] = next;
|
|
sessionStorage.setItem(repKey(pid), JSON.stringify(repCache));
|
|
paintList();
|
|
paintStatus();
|
|
},
|
|
),
|
|
);
|
|
};
|
|
|
|
const paintSection = (): void => {
|
|
const view = active ? sections.get(active) : undefined;
|
|
toolbar.replaceChildren();
|
|
resultHost.replaceChildren();
|
|
legendHost.replaceChildren();
|
|
if (!view) {
|
|
canvas.render(null);
|
|
const text = result?.status === "ok" ? t("E03_PickStation") : t("E03_NotYet");
|
|
resultHost.append(
|
|
el("div", { className: "e03-card", children: [el("p", { className: "e03-empty", text })] }),
|
|
);
|
|
return;
|
|
}
|
|
const analysis = currentAnalysis(view);
|
|
const sides = view.analyses.filter((a) => a.condition === condition);
|
|
const sideField = createSelectField({
|
|
options: sides.map((a) => ({
|
|
value: a.side,
|
|
text: `${t(a.side === "left" ? "E03_Left" : "E03_Right")} · ${t(a.role === "cut" ? "E03_Cut" : "E03_Fill")}`,
|
|
})),
|
|
value: analysis?.side,
|
|
compact: true,
|
|
ariaLabel: t("E03_Side"),
|
|
onChange: (v) => {
|
|
side = v;
|
|
picked = null;
|
|
paintSection();
|
|
},
|
|
});
|
|
const title = el("h3", {
|
|
className: "e03-card__title",
|
|
text: `${view.label ?? view.key} · ${t(`E03_Cond_${condition}` as LocaleKey)}`,
|
|
});
|
|
const flags = el("div", { className: "e03-panel__row" });
|
|
flags.append(
|
|
checkbox(t("E03_Show_Map"), toggles.map, (v) => ((toggles.map = v), paintSection())),
|
|
checkbox(
|
|
t("E03_Show_Centers"),
|
|
toggles.centers,
|
|
(v) => ((toggles.centers = v), paintSection()),
|
|
),
|
|
checkbox(
|
|
t("E03_Show_Candidates"),
|
|
toggles.candidates,
|
|
(v) => ((toggles.candidates = v), paintSection()),
|
|
),
|
|
);
|
|
const modeField = createSelectField({
|
|
options: [
|
|
{ value: "result", text: t("E03_Tab_Result") },
|
|
{ value: "reinforce", text: t("E03_Tab_Reinforce") },
|
|
{ value: "prob", text: t("E03_Tab_Prob") },
|
|
],
|
|
value: mode,
|
|
compact: true,
|
|
ariaLabel: t("E03_Tab"),
|
|
onChange: (v) => {
|
|
mode = v as typeof mode;
|
|
paintSection();
|
|
},
|
|
});
|
|
modeField.select.dataset.role = "mode";
|
|
toolbar.append(title, sideField.root, modeField.root, flags);
|
|
const draw = { view, analysis, toggles, picked };
|
|
canvas.render(draw);
|
|
legendHost.append(buildLegends(draw));
|
|
const rule = view.water_rule?.[condition];
|
|
if (rule?.mode === "ru")
|
|
legendHost.append(
|
|
el("p", {
|
|
className: "e03-muted",
|
|
text: t("E03_Water_Ru").replace("{v}", String(rule.ru)),
|
|
attrs: { "data-role": "water-ru" },
|
|
}),
|
|
);
|
|
if (view.regions.some((r) => r.material_id === WALL_ID))
|
|
legendHost.append(el("p", { className: "e03-muted", text: t("E03_Walls_Hint") }));
|
|
const row = currentRow();
|
|
if (mode === "prob" && row) {
|
|
resultHost.append(
|
|
prob.render({ row, condition, side, editable: !!result?.editable && !busy }),
|
|
);
|
|
return;
|
|
}
|
|
if (mode === "reinforce" && row) {
|
|
if (row.walls && !wallRecs.has(row.key) && !busy && result?.editable)
|
|
queueMicrotask(() => void runWall(row.key, { method: "coulomb", water_ratio: 0 }));
|
|
resultHost.append(
|
|
buildReinforce({
|
|
row,
|
|
condition,
|
|
side,
|
|
editable: !!result?.editable,
|
|
busy,
|
|
slope: slopeRecs.get(row.key),
|
|
wall: wallRecs.get(row.key),
|
|
runSlope: (ask) => void runSlope(row.key, ask),
|
|
runWall: (ask) => void runWall(row.key, ask),
|
|
}),
|
|
buildSupport({
|
|
row,
|
|
view,
|
|
condition,
|
|
side,
|
|
editable: !!result?.editable,
|
|
busy,
|
|
record: supportRecs.get(row.key),
|
|
run: (ask) => void runSupport(row.key, ask),
|
|
}),
|
|
);
|
|
return;
|
|
}
|
|
resultHost.append(
|
|
buildResult(view, analysis, picked, (i) => {
|
|
picked = i;
|
|
paintSection();
|
|
}),
|
|
);
|
|
};
|
|
|
|
/** 가정 재해석 · 벽 검토 — 서버 계산(수십 초) 동안 단추만 잠금 */
|
|
async function reinforce<T>(
|
|
path: string,
|
|
body: unknown,
|
|
done: (value: T) => void,
|
|
): Promise<void> {
|
|
busy = true;
|
|
paintSection();
|
|
try {
|
|
done(await api<T>(path, { method: "POST", body: JSON.stringify(body) }));
|
|
} catch (error) {
|
|
showToast(error instanceof Error ? error.message : t("Common_Status_Error"), "error");
|
|
} finally {
|
|
busy = false;
|
|
paintSection();
|
|
}
|
|
}
|
|
const runSlope = (key: string, ask: SlopeAsk): Promise<void> =>
|
|
reinforce<WhatIfRecord>(`${base}/whatif/${encodeURIComponent(key)}`, ask, (rec) => {
|
|
slopeRecs.set(key, rec);
|
|
side = rec.assume.side; // 전 그림 = 가정한 비탈
|
|
});
|
|
const runSupport = (key: string, ask: SupportAsk): Promise<void> =>
|
|
reinforce<SupportRecord>(`${base}/whatif/${encodeURIComponent(key)}`, ask, (rec) => {
|
|
supportRecs.set(key, rec);
|
|
side = rec.assume.side;
|
|
});
|
|
const runWall = (key: string, ask: WallAsk): Promise<void> =>
|
|
reinforce<WallRecord>(`${base}/wallcheck/${encodeURIComponent(key)}`, ask, (rec) =>
|
|
wallRecs.set(key, rec),
|
|
);
|
|
|
|
const paintPlan = (): void => {
|
|
planHost.replaceChildren(
|
|
result ? buildPlan(result.stations, condition, active, (key) => void select(key)) : el("div"),
|
|
);
|
|
};
|
|
|
|
function paint(): void {
|
|
paintStatus();
|
|
paintList();
|
|
paintPlan();
|
|
paintSection();
|
|
}
|
|
|
|
async function select(key: string): Promise<void> {
|
|
if (active !== key) picked = null;
|
|
active = key;
|
|
paintList();
|
|
paintPlan();
|
|
if (result?.status === "ok" && !sections.has(key) && !currentRow()?.error) {
|
|
try {
|
|
sections.set(key, await api<SectionView>(`${base}/section/${encodeURIComponent(key)}`));
|
|
} catch (error) {
|
|
showToast(error instanceof Error ? error.message : t("Common_Status_Error"), "error");
|
|
}
|
|
}
|
|
if (active === key) paintSection();
|
|
}
|
|
|
|
async function load(): Promise<void> {
|
|
const before = result?.status;
|
|
result = await api<Result2d>(`${base}/result2d`);
|
|
if (before === "none" && result.status === "ok") active = null; // 첫 해석 뒤 = 가장 낮은 측점으로
|
|
sections.clear();
|
|
missingHost.replaceChildren(buildMissingBanner(result.missing_stations));
|
|
const conds = result.conditions;
|
|
if (!conds.includes(condition)) condition = conds[0] ?? "dry";
|
|
condField.setOptions(
|
|
conds.map((c) => ({ value: c, text: t(`E03_Cond_${c}` as LocaleKey) })),
|
|
condition,
|
|
);
|
|
const keys = result.stations.map((r) => r.key);
|
|
repCache = Object.fromEntries(Object.entries(repCache).filter(([k]) => keys.includes(k)));
|
|
if (!active || !keys.includes(active)) {
|
|
// 처음 = 그 조건 가장 낮은 안전율 측점
|
|
const worst = result.stations
|
|
.filter((r) => r.min[condition] !== null && r.min[condition] !== undefined)
|
|
.sort((a, b) => (a.min[condition] as number) - (b.min[condition] as number))[0];
|
|
active = worst?.key ?? keys[0] ?? null;
|
|
}
|
|
paint();
|
|
if (active) await select(active);
|
|
if (result.running) await watch();
|
|
}
|
|
|
|
/** 잠금판 + 진행률 — 끝나면 다시 읽음 */
|
|
async function watch(): Promise<void> {
|
|
showLoadingOverlay();
|
|
try {
|
|
for (;;) {
|
|
const p = await api<Progress>(`${base}/progress`);
|
|
setLoadingOverlayProgress(
|
|
p.percent / 100,
|
|
`${t("E03_Analyzing")} ${p.percent}% · ${p.message}`,
|
|
);
|
|
if (!p.running) {
|
|
if (p.error) showToast(p.error, "error");
|
|
else showToast(t("E03_Done"), "success");
|
|
break;
|
|
}
|
|
await sleep(1000);
|
|
}
|
|
} finally {
|
|
hideLoadingOverlay();
|
|
}
|
|
await load();
|
|
}
|
|
|
|
async function analyze(): Promise<void> {
|
|
try {
|
|
const response = await api<{ status: string }>(`${base}/analyze2d`, { method: "POST" });
|
|
if (response.status === "fresh") {
|
|
showToast(t("E03_Fresh"), "info");
|
|
await load();
|
|
return;
|
|
}
|
|
await watch();
|
|
} catch (error) {
|
|
showToast(error instanceof Error ? error.message : t("Common_Status_Error"), "error");
|
|
}
|
|
}
|
|
|
|
async function saveRep(): Promise<void> {
|
|
try {
|
|
const overrides: Record<string, boolean | null> = {};
|
|
for (const row of result?.stations ?? []) {
|
|
const user = row.key in repCache ? repCache[row.key] : (row.representative?.user ?? null);
|
|
if (user !== null) overrides[row.key] = user;
|
|
}
|
|
await api(`${base}/representative`, { method: "PUT", body: JSON.stringify({ overrides }) });
|
|
repCache = {};
|
|
sessionStorage.removeItem(repKey(pid));
|
|
showToast(t("E03_RepSaved"), "success");
|
|
await load();
|
|
} catch (error) {
|
|
showToast(error instanceof Error ? error.message : t("Common_Status_Error"), "error");
|
|
}
|
|
}
|
|
|
|
async function show3d(): Promise<void> {
|
|
if (!result) return;
|
|
try {
|
|
const data = await api<Parameters<typeof openView3d>[0]>(`${base}/corridor3d`);
|
|
await openView3d(data, result.stations, condition, t(`E03_Cond_${condition}` as LocaleKey));
|
|
} catch (error) {
|
|
showToast(error instanceof Error ? error.message : t("Common_Status_Error"), "error");
|
|
}
|
|
}
|
|
|
|
paintStatus();
|
|
load().catch((error) => {
|
|
status.textContent = error instanceof Error ? error.message : t("Common_Status_Error");
|
|
});
|
|
}
|