Files
Aislo/E06_StabRock/E06_StabRock_UI_Page.ts
T
eomsangdonandClaude Opus 5.5 f2e8bd6d5f feat(536-1): E06 암반 평사투영 — Markland 판정 · Hoek-Bray 평면 · 쐐기 안전율 · 스테레오넷 · 절리 입력 · 화면
- 엔진(자체 · NumPy · SciPy): 평면 · 쐐기(교선) · 전도 Markland(측방 한계 20° 기본) · 평면 Hoek-Bray(인장균열 없음 · 임계 · 깊이 · 수압 · kh · 수동 볼트) · 쐐기 SWedge 꼴(미끄럼 방향 8 경우 · 두 면 · 한 면 · 수압 중력식 · kh · 볼트)
- 스테레오넷 좌표(등각 · 등면적 하반구 · 극점 · 대원 · 교선 · 마찰원 · 일광 엔벨로프 · 위험 영역 = 판정 부등식)
- 길: 절리 작업본 E06_StabRock/joints.json(초기값 · 저장 · 초기화) · 해석 = 암 측점 깎기 쪽만 · 파생 저장본 stab_rock · 결과 · 측점 스테레오넷
- 화면: 좌 절리군 표 · 설정 / 가운데 위험 띠 · 스테레오넷 / 우 측점 결과 · 안전율 표 · E 단계 막대에 E06(끝에 덧붙임)
- 시험 test_536_1(Hoek Sau Mau Ping 0.8184 · 1.0655 · Sharma 윗면 무게 · Hoek-Bray 쐐기 계수 · Kumsar · Priest 두 예제 · Markland 경계 · 투영 왕복 · 위험 영역 = 판정) · test_528 E 단계 수 4 → 5

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

398 lines
13 KiB
TypeScript

/* =============================================================================
* E06_StabRock_UI_Page.ts
* E06 암반 평사투영 해석(요구 345 · 351 · 요구서 4-2 2차 · PLAN 536-1) — B05~B09 틀(group_workflow).
* 좌 = 절리군 표 · 해석 설정 · [초기화] [저장] [해석]
* 가운데 = 위험 띠 · 스테레오넷(등각 · 등면적 · 보기 = 전체 · 평면 · 쐐기 · 전도)
* 우 = 측점별 모드 수 · 최소 안전율 · 판정 · 고른 측점 안전율 표
* 조작 = sessionStorage 캐시만(자동저장 없음) · [저장] = PUT · [초기화] = POST …/reset ·
* [해석] = 저장 안 한 고침이 있으면 먼저 저장 → POST …/analyze(서버 단독) → 결과 GET.
* ========================================================================== */
import "./E06_StabRock_UI_Style.css";
import { API_BASE_URL, CURRENT_PROJECT_ID_KEY, ROUTES } from "@config/config_frontend";
import { attachCollapsible } from "@ui/ui_template_collapsible";
import {
createButton,
el,
hideLoadingOverlay,
showConfirmDialog,
showLoadingOverlay,
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 {
buildJointCards,
type JointsDoc,
type JointsView,
type Projection,
type RockDefaults,
} from "./E06_StabRock_UI_Joints";
import {
buildBand,
buildDetail,
buildList,
entryId,
type RockResult,
type StationEntry,
} from "./E06_StabRock_UI_Result";
import { drawStereonet, netLegend, type NetData, type NetMode } from "./E06_StabRock_UI_Stereonet";
interface RockResponse {
joints: JointsDoc;
saved?: boolean;
defaults?: RockDefaults;
stations?: { chainage_m: number; label: string; ground_type: string }[];
editable?: boolean;
}
interface ResultResponse {
status: string;
stale: boolean;
result: RockResult | null;
}
interface StationResponse {
entry: StationEntry;
stereonet: Record<Projection, NetData> & { phi: number };
}
const cacheKey = (projectId: string): string => `e06_rock_cache:${projectId}`;
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;
if (typeof detail === "string") throw new Error(detail);
const errors = (detail as { errors?: unknown[] } | undefined)?.errors;
throw new Error(errors ? `${t("E06_Invalid")} ${errors.length}` : `HTTP ${response.status}`);
}
return body;
}
function toggle<T extends string>(options: [LocaleKey, T][], value: T, onPick: (v: T) => void) {
const group = el("div", { className: "e06-toggle" });
const buttons = options.map(([key, v]) => {
const button = createButton({ label: t(key), variant: "ghost", size: "sm" });
button.dataset.value = v;
button.classList.toggle("is-on", v === value);
button.addEventListener("click", () => {
buttons.forEach((b) => b.classList.toggle("is-on", b === button));
onPick(v);
});
return button;
});
group.append(...buttons);
return group;
}
export function renderE06StabRock(root: HTMLElement): void {
const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY);
const panel = el("div", { className: "ui-sidebar-panel e06-panel" });
const main = el("div", { className: "e06-page" });
mountGroupStep(root, ROUTES.E06_STAB_ROCK, { mainContent: main, leftPanel: panel });
if (!projectId)
return void main.append(el("p", { className: "e06-empty", text: t("E06_NoProject") }));
// ---- 좌 ----
const cards = el("div", { className: "e06-cards" });
const status = el("p", { className: "e06-status" });
const saveButton = createButton({
label: t("Common_Btn_Save"),
variant: "ghost",
onClick: () => void save(),
});
const resetButton = createButton({
label: t("Common_Btn_Reset"),
variant: "danger-ghost",
onClick: () => void reset(),
});
const analyzeButton = createButton({
label: t("E06_Analyze"),
variant: "filled",
onClick: () => void analyze(),
});
saveButton.disabled = resetButton.disabled = analyzeButton.disabled = true;
const actions = el("div", { className: "ui-sidebar-actions" });
actions.append(resetButton, saveButton, analyzeButton);
panel.append(el("p", { className: "e06-hint", text: t("E06_Note") }), cards, status, actions);
attachCollapsible(cards);
// ---- 가운데 · 우 ----
const staleBar = el("div", { className: "e06-stale", text: t("E06_Stale") });
staleBar.hidden = true;
const bandSlot = el("div");
const title = el("strong", { className: "e06-title" });
const netSlot = el("div", { className: "e06-net-slot" });
const legendSlot = el("div");
let projection: Projection = "equal_angle";
let netMode: NetMode = "all";
const toolbar = el("div", { className: "e06-toolbar" });
const projectionToggle = el("div");
toolbar.append(
title,
projectionToggle,
toggle<NetMode>(
[
["E06_View_All", "all"],
["E06_Mode_planar", "planar"],
["E06_Mode_wedge", "wedge"],
["E06_Mode_toppling", "toppling"],
],
netMode,
(v) => {
netMode = v;
paintNet();
},
),
);
const stage = el("div", { className: "e06-stage" });
stage.append(netSlot, legendSlot);
const center = el("div", { className: "e06-center" });
center.append(staleBar, bandSlot, toolbar, stage);
const listSlot = el("div", { className: "e06-list-slot" });
const detailSlot = el("div");
const sideBody = el("div", { className: "e06-side" });
sideBody.append(listSlot, detailSlot);
const side = createSidePanels({
prefKey: "e06-stab-rock-result",
panels: [{ id: "result", title: t("E06_Result_Title"), body: sideBody }],
defaultOpen: "result",
defaultWidth: 380,
follow: false,
});
const row = el("div", { className: "ui-side-panels-row e06-row" });
row.append(center, side.root);
main.append(row);
// ---- 상태 ----
let view: JointsView | null = null;
let dirty = false;
let result: RockResult | null = null;
let active: string | null = null;
let shown: StationResponse | null = null;
const invalidCount = (): number => cards.querySelectorAll(".is-invalid").length;
const paintStatus = (): void => {
const lines: string[] = [];
if (view && !view.editable) lines.push(t("E06_ReadOnly"));
if (dirty) lines.push(t("E06_Unsaved"));
const bad = invalidCount();
if (bad) lines.push(`${t("E06_Invalid")} ${bad}`);
status.textContent = lines.join("\n");
status.classList.toggle("is-error", bad > 0);
};
const renderCards = (): void => {
if (!view) return;
// 다시 그려도 접힘은 그대로
const folded = [...cards.children].map((c) => c.classList.contains("is-collapsed"));
const built = buildJointCards(view);
built.forEach(
(c, i) => folded[i] !== undefined && c.classList.toggle("is-collapsed", folded[i]),
);
cards.replaceChildren(...built);
paintStatus();
};
const remember = (): void => {
if (!view) return;
dirty = true;
sessionStorage.setItem(cacheKey(projectId), JSON.stringify(view.doc));
};
function paintProjection(): void {
projectionToggle.replaceChildren(
toggle<Projection>(
[
["E06_Proj_Angle", "equal_angle"],
["E06_Proj_Area", "equal_area"],
],
projection,
(v) => {
projection = v; // 보기 고름 — 정본 아님(해석 지문에도 안 들어감)
paintNet();
},
),
);
}
function paintNet(): void {
legendSlot.replaceChildren(netLegend(netMode));
if (!shown) {
title.textContent = "";
netSlot.replaceChildren(el("p", { className: "e06-empty", text: t("E06_PickStation") }));
return;
}
const e = shown.entry;
title.textContent = `${e.label ?? e.key} · ${t(e.slope.side === "left" ? "E06_Left" : "E06_Right")} · φ ${shown.stereonet.phi}°`;
netSlot.replaceChildren(
drawStereonet(shown.stereonet[projection], netMode, {
planar: e.kinematic.planar,
toppling: e.kinematic.toppling,
wedge: e.kinematic.wedge.map((w) => w.ids.join("|")),
}),
);
}
function paintResult(): void {
const stations = result?.stations ?? [];
bandSlot.replaceChildren(buildBand(stations, active, (id) => void select(id)));
listSlot.replaceChildren(
stations.length
? buildList(stations, active, (id) => void select(id))
: el("p", { className: "e06-hint", text: t(result ? "E06_NoRock" : "E06_NoResult") }),
);
detailSlot.replaceChildren(shown ? buildDetail(shown.entry) : el("span"));
paintNet();
}
async function select(id: string): Promise<void> {
active = id;
const [key, sideName] = id.split("|");
try {
shown = await api<StationResponse>(
`/projects/${projectId}/stab/rock/result?station=${encodeURIComponent(key)}&side=${sideName}`,
);
} catch (error) {
shown = null;
showToast(error instanceof Error ? error.message : t("Common_Status_Error"), "error");
}
paintResult();
}
async function loadResult(): Promise<void> {
const body = await api<ResultResponse>(`/projects/${projectId}/stab/rock/result`);
result = body.result;
staleBar.hidden = !body.stale;
const stations = result?.stations ?? [];
const keep = stations.find((e) => entryId(e) === active);
const first = keep ?? stations.find((e) => e.verdict === "NG") ?? stations[0];
if (first) await select(entryId(first));
else {
active = null;
shown = null;
paintResult();
}
}
const adopt = (body: RockResponse, keepCache: boolean): void => {
if (!view) return;
const cached = keepCache ? sessionStorage.getItem(cacheKey(projectId)) : null;
if (!cached) sessionStorage.removeItem(cacheKey(projectId));
view.doc = cached ? (JSON.parse(cached) as JointsDoc) : body.joints;
dirty = Boolean(cached);
saveButton.disabled = resetButton.disabled = analyzeButton.disabled = !view.editable;
renderCards();
};
async function putJoints(): Promise<boolean> {
if (!view?.editable) return false;
if (invalidCount()) {
showToast(`${t("E06_Invalid")} ${invalidCount()}`, "error");
return false;
}
const body = await api<RockResponse>(`/projects/${projectId}/stab/rock`, {
method: "PUT",
body: JSON.stringify({ joints: view.doc }),
});
adopt(body, false);
return true;
}
async function save(): Promise<void> {
showLoadingOverlay();
try {
if (await putJoints()) {
showToast(t("E06_Saved"), "success");
await loadResult();
}
} catch (error) {
showToast(error instanceof Error ? error.message : t("Common_Status_Error"), "error");
} finally {
hideLoadingOverlay();
}
}
async function reset(): Promise<void> {
if (!view?.editable) return;
const ok = await showConfirmDialog(
t("E06_ResetConfirm"),
t("Common_Btn_Reset"),
t("Common_Btn_Cancel"),
);
if (!ok) return;
showLoadingOverlay();
try {
const body = await api<RockResponse>(`/projects/${projectId}/stab/rock/reset`, {
method: "POST",
});
adopt(body, false);
showToast(t("E06_ResetDone"), "success");
await loadResult();
} catch (error) {
showToast(error instanceof Error ? error.message : t("Common_Status_Error"), "error");
} finally {
hideLoadingOverlay();
}
}
async function analyze(): Promise<void> {
if (!view?.editable) return;
analyzeButton.disabled = true;
status.textContent = t("E06_Running");
try {
if (dirty && !(await putJoints())) return;
const body = await api<{ status: string; summary?: RockResult["summary"] }>(
`/projects/${projectId}/stab/rock/analyze`,
{ method: "POST" },
);
await loadResult();
showToast(t(body.status === "fresh" ? "E06_Fresh" : "E06_Done"), "success");
} catch (error) {
showToast(error instanceof Error ? error.message : t("Common_Status_Error"), "error");
} finally {
analyzeButton.disabled = !view.editable;
paintStatus();
}
}
void (async () => {
try {
const body = await api<RockResponse>(`/projects/${projectId}/stab/rock`);
const stations = body.stations ?? [];
projection = body.joints.settings.projection;
paintProjection();
view = {
doc: body.joints,
defaults: body.defaults as RockDefaults,
editable: body.editable ?? false,
endM: stations.length ? stations[stations.length - 1].chainage_m : 0,
change: () => {
remember();
paintStatus();
},
rebuild: () => {
remember();
renderCards();
},
};
adopt(body, true);
await loadResult();
} catch (error) {
main.replaceChildren(
el("p", {
className: "e06-empty",
text: error instanceof Error ? error.message : t("Common_Status_Error"),
}),
);
}
})();
}