Files
Aislo/E06_StabRock/E06_StabRock_UI_Page.ts
eomsangdonandClaude Opus 5.5 c3fbcb13f1 feat(D · E): 떠남 = 공용 물음 [취소 · 폐기 · 저장] · 캐시 = 등록표 · 프로젝트 = 탭 currentProjectId (PLAN 572-3 · 요구 400~403)
- D02 · D03 · E02 말없이 저장 → mountGroupStep {dirty, save, discard} 공용 물음
- D04 · E05 · E06 가드 새로 · E03 = 대표 단면 별 저장 진행 중일 때만 물음
- D02 · E02~E07 세션 직접 읽기 · 쓰기 → 등록표 d02-points · e02-ground · e03-prob · e04-settings · e05-report · e06-rock · e07-settings
- 아홉 페이지 localStorage 지금 프로젝트 직접 읽기 → currentProjectId() (다른 탭이 바꿔도 이 탭 그대로)
- test_560_4 D · E 줄 넓힘(옛 beforeLeave 단언 → dirty · save) · test_401 PENDING 에서 D · E 뺌

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

388 lines
13 KiB
TypeScript

/* =============================================================================
* E06_StabRock_UI_Page.ts
* E06 암반 평사투영 해석(요구 345 · 351 · 요구서 4-2 2차 · PLAN 536-1) — B05~B09 틀(group_workflow).
* 좌 = 절리군 표 · 해석 설정 · [초기화] [해석]
* 가운데 = 위험 띠 · 스테레오넷(등각 · 등면적 · 보기 = 전체 · 평면 · 쐐기 · 전도)
* 우 = 측점별 모드 수 · 최소 안전율 · 판정 · 고른 측점 안전율 표
* 조작 = 캐시 `e06-rock` 만(자동저장 없음) · [초기화] = POST …/reset ·
* [해석] = 저장(고침이 있으면 PUT) → POST …/analyze(서버 단독) → 결과 GET · 저장 단추 없음.
* 떠날 때 고친 절리군이 있으면 공용 물음 [취소][폐기][저장 = PUT](572-3).
* ========================================================================== */
import "./E06_StabRock_UI_Style.css";
import { API_BASE_URL, ROUTES } from "@config/config_frontend";
import { createBanner } from "@ui/ui_template_banner";
import { attachCollapsible } from "@ui/ui_template_collapsible";
import {
createButton,
el,
hideLoadingOverlay,
showConfirmDialog,
showLoadingOverlay,
showToast,
} from "@ui/ui_template_elements";
import { t } from "@ui/ui_template_locale";
import { createSidePanels } from "@ui/ui_template_side_panels";
import { inferStationInterval, stationLabel } from "../common_util/common_util_station";
import { clearState, readState, writeState } from "../A00_Common/b_page_state";
import { currentProjectId } from "../A00_Common/b_project_switch";
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";
import { emptyBox, note, toggle } from "./E06_StabRock_UI_Parts";
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 };
}
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;
}
export function renderE06StabRock(root: HTMLElement): void {
const projectId = currentProjectId();
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,
dirty: () => Boolean(view?.editable) && dirty,
save: async () => {
showLoadingOverlay();
try {
return await putJoints(); // 틀린 칸 · 실패 = 머묾
} catch (error) {
showToast(error instanceof Error ? error.message : t("Common_Status_Error"), "error");
return false;
} finally {
hideLoadingOverlay();
}
},
discard: () => clearState("e06-rock", projectId),
});
if (!projectId) return void main.append(emptyBox(t("E06_NoProject")));
// ---- 좌 ----
const cards = el("div", { className: "e06-cards" });
const status = el("div", { className: "e06-status" });
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(),
});
resetButton.disabled = analyzeButton.disabled = true;
const actions = el("div", { className: "ui-sidebar-actions" });
actions.append(resetButton, analyzeButton);
panel.append(note(t("E06_Note")), cards, status, actions);
attachCollapsible(cards);
// ---- 가운데 · 우 ----
const staleBar = createBanner({ tone: "warning", text: t("E06_Stale") });
staleBar.classList.add("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>(
[
[t("E06_View_All"), "all"],
[t("E06_Mode_planar"), "planar"],
[t("E06_Mode_wedge"), "wedge"],
[t("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;
let interval = 20; // 측점 간격 — 처음 GET 의 측점 목록에서 추정
/** 측점 글 = No.+거리(소수 한 자리) — 서버 label(STA. 꼴) 대신 */
const relabel = (e: StationEntry): void => {
e.label = stationLabel(e.chainage_m, interval, 1);
};
const invalidCount = (): number => cards.querySelectorAll('[aria-invalid="true"]').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.replaceChildren(...(lines.length ? [note(lines.join("\n"), "e06-status__text")] : []));
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;
writeState("e06-rock", view.doc, projectId);
};
function paintProjection(): void {
projectionToggle.replaceChildren(
toggle<Projection>(
[
[t("E06_Proj_Angle"), "equal_angle"],
[t("E06_Proj_Area"), "equal_area"],
],
projection,
(v) => {
projection = v; // 보기 고름 — 정본 아님(해석 지문에도 안 들어감)
paintNet();
},
),
);
}
function paintNet(): void {
legendSlot.replaceChildren(netLegend(netMode));
if (!shown) {
title.textContent = "";
netSlot.replaceChildren(emptyBox(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))
: note(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}`,
);
relabel(shown.entry);
} 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;
result?.stations.forEach(relabel);
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 ? readState<JointsDoc>("e06-rock", projectId) : null;
if (!cached) clearState("e06-rock", projectId);
view.doc = cached ?? body.joints;
dirty = Boolean(cached);
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 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.replaceChildren(note(t("E06_Running"), "e06-status__text"));
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 ?? [];
interval = inferStationInterval(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(
emptyBox(error instanceof Error ? error.message : t("Common_Status_Error")),
);
}
})();
}