Files
Aislo/E03_StabSection/E03_StabSection_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

645 lines
22 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) ·
* 대표 단면 별 = 누를 때마다 바로 PUT(요구 390) · 떠날 때 저장 중이면 공용 물음 [취소][폐기][저장](572-3).
* 「보강 검토」 탭 = 우측을 가정 재해석 · 벽 외적 안정으로 바꿈(`UI_Reinforce` · 서버 따로 저장본).
* 「확률 · 민감도」 탭 = 대표 단면 확률 해석(`UI_Prob` · 서버 따로 저장본 · 카드 안 진행률).
* ========================================================================== */
import "./E03_StabSection_UI_Style.css";
import { API_BASE_URL, ROUTES } from "@config/config_frontend";
import {
createButton,
createInputField,
createSelectField,
el,
hideLoadingOverlay,
setLoadingOverlayProgress,
showLoadingOverlay,
showToast,
} from "@ui/ui_template_elements";
import { t, type LocaleKey } from "@ui/ui_template_locale";
import { createSegment } from "@ui/ui_template_segment";
import { currentProjectId } from "../A00_Common/b_project_switch";
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, type PlanView } from "./E03_StabSection_UI_Plan";
import { inferStationInterval, stationLabel } from "../common_util/common_util_station";
import { card, check, emptyBox, muted } from "./E03_StabSection_UI_Parts";
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>;
/** 2D 탐색 한계 — "model_end" · "2h" · 수(비탈 끝 + m) */
search_reach?: string | number;
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 sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
export function renderE03StabSection(root: HTMLElement): void {
const projectId = currentProjectId();
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,
dirty: () => repPending > 0,
save: () => repSaving.then(() => true),
});
if (!projectId) return void main.append(emptyBox(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 interval = 20; // 측점 간격 — 측점 목록에서 추정(요구 390)
const planView: PlanView = {};
let repSaving: Promise<void> = Promise.resolve(); // 별 누름마다 차례로 저장
let repPending = 0; // 아직 안 끝난 별 저장(떠남 물음)
// ---- 좌측 ----
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();
},
});
type ReachPick = "model_end" | "2h" | "m";
const reachSegment = createSegment({
options: [
{ value: "model_end", label: t("E03_Reach_model_end") },
{ value: "2h", label: t("E03_Reach_2h_Short"), title: t("E03_Reach_2h") },
{ value: "m", label: t("E03_Reach_m_Short"), title: t("E03_Reach_m") },
],
value: "model_end",
size: 24,
ariaLabel: t("E03_Reach"),
onChange: () => paintReach(),
});
reachSegment.root.dataset.role = "reach";
reachSegment.root.title = t("E03_Reach_Hint");
const reachMeters = createInputField({
type: "number",
min: 0.1,
compact: true,
unit: "m",
onInput: () => paintReach(),
});
reachMeters.input.dataset.role = "reach-m";
reachMeters.input.title = t("E03_Reach_Hint");
const reachLabelNode = muted(t("E03_Reach"));
const reachNote = muted("", "reach-note");
const reachLabel = (reach: string | number | undefined): string =>
typeof reach === "number"
? t("E03_Reach_m_Value").replace("{v}", String(reach))
: t(reach === "2h" ? "E03_Reach_2h" : "E03_Reach_model_end");
/** 고른 값(서버로 보낼 꼴) — 정한 m 이 비면 null */
const reachPicked = (): string | number | null => {
const pick = reachSegment.get() as ReachPick;
if (pick !== "m") return pick;
const meters = Number(reachMeters.input.value);
return meters > 0 ? meters : null;
};
/** 정한 m 칸 보임 · 「이 결과」 한 줄 — 고른 값이 결과와 다르면 「해석 전」 */
const paintReach = (): void => {
reachMeters.root.hidden = reachSegment.get() !== "m";
if (result?.status !== "ok") return void (reachNote.textContent = "");
const used = result.search_reach ?? "model_end";
const picked = reachPicked();
reachNote.textContent =
`${t("E03_Reach_Used")} ${reachLabel(used)}` +
(picked !== null && picked !== used ? ` · ${t("E03_Reach_Pending")}` : "");
};
/** 서버 저장값을 칸에 맞춤 */
const showReach = (reach: string | number | undefined): void => {
const custom = typeof reach === "number";
reachSegment.set(custom ? "m" : reach === "2h" ? "2h" : "model_end");
if (custom) reachMeters.input.value = String(reach);
paintReach();
};
const ngBox = check(t("E03_NgOnly"), false, (v) => {
ngOnly = v;
paintList();
});
const status = muted("", "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 controls = el("div", { className: "e03-panel__row" });
controls.append(condField.root, sortField.root);
const actions = el("div", { className: "ui-sidebar-actions" });
actions.append(view3dButton, analyzeButton);
panel.append(
controls,
reachLabelNode,
reachSegment.root,
reachMeters.root,
reachNote,
ngBox,
status,
listHost,
actions,
);
// ---- 본문 ----
const missingHost = el("div");
const planHost = el("div");
const toolbar = el("div", { className: "e03-card__head" });
const sectionCard = 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 };
return { on: server.on, user: server.user };
};
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"));
status.textContent = lines.join("\n");
analyzeButton.disabled = !result?.editable;
reachMeters.input.disabled = !result?.editable;
reachSegment.root.querySelectorAll("button").forEach((b) => (b.disabled = !result?.editable));
view3dButton.disabled = result?.status !== "ok";
};
const paintList = (): void => {
if (!result) return;
listHost.replaceChildren(
buildList(
{
rows: result.stations,
conditions: result.conditions,
condition,
sort,
ngOnly,
active,
interval,
representative: repOf,
editable: result.editable,
},
(key) => void select(key),
(key) => void toggleRep(key),
),
);
};
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");
const none = card();
none.append(emptyBox(text));
resultHost.append(none);
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: `${stationLabel(view.chainage_m, interval, 1)} · ${t(`E03_Cond_${condition}` as LocaleKey)}`,
});
const flags = el("div", { className: "e03-panel__row" });
flags.append(
check(t("E03_Show_Map"), toggles.map, (v) => ((toggles.map = v), paintSection())),
check(t("E03_Show_Centers"), toggles.centers, (v) => ((toggles.centers = v), paintSection())),
check(
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(muted(t("E03_Water_Ru").replace("{v}", String(rule.ru)), "water-ru"));
if (view.regions.some((r) => r.material_id === WALL_ID))
legendHost.append(muted(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();
},
analysis ? row?.cases[condition]?.[analysis.side] : undefined,
),
);
};
/** 가정 재해석 · 벽 검토 — 서버 계산(수십 초) 동안 단추만 잠금 */
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,
interval,
planView,
(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();
showReach(result.search_reach);
interval = inferStationInterval(
result.stations.slice().sort((a, b) => a.chainage_m - b.chainage_m),
);
missingHost.replaceChildren(buildMissingBanner(result.missing_stations, interval));
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);
delete planView.zoom; // 다시 읽음 = 띠 원배율
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> {
const reach = reachPicked();
if (reach === null) {
reachMeters.setError(t("E03_Reach_Invalid"));
return;
}
reachMeters.setError();
try {
const response = await api<{ status: string }>(`${base}/analyze2d`, {
method: "POST",
body: JSON.stringify({ search_reach: reach }),
});
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");
}
}
/** 별 누름 = 바로 저장(요구 390) — 자동과 같아지면 사용자 값 지움(null) · 저장 뒤 보고서 갱신 */
function toggleRep(key: string): Promise<void> {
repPending += 1;
repSaving = repSaving
.then(async () => {
const row = result?.stations.find((r) => r.key === key);
if (!row) return;
const server = row.representative ?? { auto: [], user: null, on: false };
const auto = server.auto.length > 0;
const next = !server.on === auto ? null : !server.on;
const overrides: Record<string, boolean | null> = {};
for (const r of result?.stations ?? []) {
const user = r.key === key ? next : (r.representative?.user ?? null);
if (user !== null) overrides[r.key] = user;
}
try {
await api(`${base}/representative`, {
method: "PUT",
body: JSON.stringify({ overrides }),
});
row.representative = { ...server, user: next, on: next === null ? auto : next };
paintList();
void refreshReport();
} catch (error) {
showToast(error instanceof Error ? error.message : t("Common_Status_Error"), "error");
}
})
.finally(() => {
repPending -= 1;
});
return repSaving;
}
/** 보고서 갱신 — E05 [보고서 갱신] 과 같은 길(GET 이 작업본 없으면 처음 만듦 · 뒤에서) */
let reportRun = 0;
async function refreshReport(): Promise<void> {
const run = ++reportRun;
await sleep(800); // 별을 잇달아 누르면 마지막 한 번만
if (run !== reportRun) return;
try {
const state = await api<{ book: unknown; rev: string | null }>(
`/projects/${pid}/stab/report`,
);
if (!state.book || run !== reportRun) return;
const body = await api<{ overwritten?: string[] }>(`/projects/${pid}/stab/report/refresh`, {
method: "POST",
body: JSON.stringify({ rev: state.rev }),
});
const over = body.overwritten ?? [];
if (over.length) showToast(`${t("E05_Overwritten")} ${over.join(" · ")}`, "warning", 8000);
} catch {
/* 보고서는 E05 를 열 때 stale 로 알림 */
}
}
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");
});
}