- 엔진(자체 · 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>
398 lines
13 KiB
TypeScript
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"),
|
|
}),
|
|
);
|
|
}
|
|
})();
|
|
}
|