Files
Aislo/E03_StabSection/E03_StabSection_UI_Page.ts
T
eomsangdonandClaude Opus 5.5 8aeae00470 feat(536-3): E 보강 검토 뒷부분 — 네일 · 앵커 가정 입력 · supports 꼴 POST whatif · 최소 길이 찾기 · 횡단 그림 · 결과 표 · E05 네일 · 앵커 줄 (엔진 고리 전 = 그림만)
- logic/E03_StabSection_Support: 배치(행 수 · 첫 행 높이 · 수직 · 수평 간격 · 제원) → supports(브레인 정함 꼴 + side) · 머리 = 비탈 면 · 방향 = 활동 반대(비탈 속) · 그림 좌표(머리 · 끝 · 정착 구간 · 임계면 교점 · mode)
- UI_Support: 「네일 · 앵커 가정」 카드 · 기본값 후보(지식DB 안정성평가 02 §4 · 사용자 확정 전) · [보강 해석] · [최소 길이 찾기] · 동원력 · 지배 표 · 후 단면 mode 색
- Router_WhatIf: supports 받음 · 엔진에 supports 인자 있으면 넘김(cases[i].result.support_forces 읽음) · 없으면 supports_pending · 최소 길이 = 성긴 걸음 + 이분(0.5 m · 25 m 까지) · 지금 기울기 그대로면 원본 설계선 · 저장본 종류 support
- E05 s8: 1 절 가정 줄에 네일 · 앵커 · 3 절 네일마다 동원력 · 지배(있을 때만)
- 시험 test_536_3 14(배치 해석해 · 그림 좌표 · 엔진 전 그림만 · 가짜 엔진 최소 길이 5.0 m · E05 줄)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 04:18:18 +09:00

530 lines
18 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` · 서버 따로 저장본).
* ========================================================================== */
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 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`;
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" = "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: 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 === "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");
});
}