/* ============================================================================= * 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 & { phi: number }; } const cacheKey = (projectId: string): string => `e06_rock_cache:${projectId}`; async function api(path: string, init?: RequestInit): Promise { 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(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( [ ["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( [ ["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 { active = id; const [key, sideName] = id.split("|"); try { shown = await api( `/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 { const body = await api(`/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 { if (!view?.editable) return false; if (invalidCount()) { showToast(`${t("E06_Invalid")} ${invalidCount()}`, "error"); return false; } const body = await api(`/projects/${projectId}/stab/rock`, { method: "PUT", body: JSON.stringify({ joints: view.doc }), }); adopt(body, false); return true; } async function save(): Promise { 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 { 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(`/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 { 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(`/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"), }), ); } })(); }