Files
Aislo/E03_StabSection/E03_StabSection_UI_Page.ts
T
eomsangdonandClaude Opus 5.5 7d5f516b84 feat(E03): 측점 No.+거리 · 위험 띠 확대 · 이동 · 측점 글 · 900 높이 겹침 · 대표 단면 바로 저장 (PLAN 566-2)
- 좌측 목록 · 횡단 카드 제목 = 공용 stationLabel(누가, 측점 목록 추정 간격, 소수 한 자리)
- 평면 위험 띠 = 공용 attachSvgZoomPan(휠 · 가운데 끌기 · 두 번 누름 원배율) · 측점 글 솎음(logic labelStride · planLabelKeys) · 선 굵기 · 글 크기 고정 · 배율은 다시 그려도 유지
- .e03-page 행 셋(auto auto minmax) — 화면 높이 900 에서 띠 · 본문 겹침 고침
- 대표 단면 별 = 누르면 바로 PUT representative · 뒤에서 E05 보고서 갱신 길(GET report · POST refresh) · [대표 단면 저장] 단추 · 캐시 뺌
- 시험 532_3 logic TS 에 솎음 걸음 · 글 열쇠

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 19:58:44 +09:00

545 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, 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>;
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 = 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(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(); // 별 누름마다 차례로 저장
// ---- 좌측 ----
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 = 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, 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;
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();
}),
);
};
/** 가정 재해석 · 벽 검토 — 서버 계산(수십 초) 동안 단추만 잠금 */
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();
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);
interval = inferStationInterval(
result.stations.slice().sort((a, b) => a.chainage_m - b.chainage_m),
);
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> {
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");
}
}
/** 별 누름 = 바로 저장(요구 390) — 자동과 같아지면 사용자 값 지움(null) · 저장 뒤 보고서 갱신 */
function toggleRep(key: string): Promise<void> {
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");
}
});
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");
});
}