Files
Aislo/E02_StabGround/E02_StabGround_UI_Page.ts
T
eomsangdonandClaude Opus 5.5 f4077fbcf6 feat(532-3): E02 지하수 갈래 — 우기 규칙 넷(KDS 기본 · 지표 아래 깊이 비율 · r_u · 사용자 수위선 구간) · 엔진 꼴 바꿈 · 지하수 카드
- ground.json groundwater 칸 덧붙임(wet_mode · depth_ratio · ru · lines · 기존 칸 그대로) · 기본값 = KDS · 범위 칸 넷
- check_ground ↔ checkGround 짝 고침(갈래 · 비율 · r_u · 수위선 구간 · 점) · 거울 시험 판 넷 더함
- groundwater_for · station_groundwater — 측점 모형 → {mode: none | ru | line}(좌 · 우 비탈 높이 따로 · 덮지 않은 측점 = KDS)
- GET · [초기화] = 옛 정본 빠진 칸 기본값으로 채움 · 화면 옛 캐시도 서버 값으로 채움
- 화면 = 지하수 카드 따로(「KDS 기본 · 우기 기준안전율 1.2 와 짝」 안내) · 하중 카드 = 노면 하중 · 지진
- 시험 test_532_4_stab_walls_water(벽 · 지하수) · test_532_3 기본값 지하수 칸 대조 고침(칸 늘어남)

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

249 lines
8.7 KiB
TypeScript

/* =============================================================================
* E02_StabGround_UI_Page.ts
* E02 지반 조건(요구서 345 4-3 · 요구 347-2 · 3 · 4 · PLAN 532-3) — B05~B09 틀(group_workflow).
* GET /api/projects/{id}/stab/ground → 작업본(없으면 초기값) · 기본값 · 고칠 수 있는지.
* 조작 = sessionStorage 캐시만(자동저장 없음) · [저장] = PUT(서버가 다시 검사 · 측점은 서버 값) ·
* [초기화] = POST …/reset(초기값 덮어씀). 검사 = logic/E02_StabGround_Check(파이썬 짝).
* 칸 위 마우스 = 틀린 까닭 · 기본값 · 근거(지식DB 07 · KDS 11 70 05).
* ========================================================================== */
import "./E02_StabGround_UI_Style.css";
import { API_BASE_URL, CURRENT_PROJECT_ID_KEY, ROUTES } from "@config/config_frontend";
import {
createButton,
el,
hideLoadingOverlay,
showConfirmDialog,
showLoadingOverlay,
showToast,
} from "@ui/ui_template_elements";
import { attachHoverInfo } from "@ui/ui_template_hover_info";
import { t, type LocaleKey } from "@ui/ui_template_locale";
import { mountGroupStep } from "../A00_Common/group_workflow";
import { buildCards, type E02View } from "./E02_StabGround_UI_Tables";
import {
checkGround,
type CheckError,
type Ground,
type GroundDefaults,
} from "./logic/E02_StabGround_Check";
interface GroundResponse {
ground: Ground;
saved?: boolean;
defaults?: GroundDefaults;
editable?: boolean;
}
const cacheKey = (projectId: string): string => `e02_ground_cache:${projectId}`;
/** 검사 열쇠 → 범위 열쇠(limits) */
function limitKey(path: string): string {
const last = path.split(".").pop() ?? "";
if (path.startsWith("safety_factors.")) return "safety_factor";
if (last === "from_m" || last === "to_m") return "chainage_m";
if (/\.points\.\d+\.0$/.test(path)) return "offset_m";
if (/\.points\.\d+\.1$/.test(path)) return "water_depth_m";
return last;
}
function errorText(error: CheckError, defaults: GroundDefaults): string {
const text = t(`E02_Err_${error.code}` as LocaleKey);
const bound = defaults.limits[limitKey(error.path)];
return error.code === "range" && bound ? `${text} (${bound[0]} ~ ${bound[1]})` : text;
}
async function request(path: string, init?: RequestInit): Promise<GroundResponse> {
const response = await fetch(`${API_BASE_URL}${path}`, {
credentials: "include",
headers: { "Content-Type": "application/json" },
...init,
});
const body = (await response.json().catch(() => ({}))) as GroundResponse & { detail?: unknown };
if (!response.ok) {
const detail = body.detail;
if (typeof detail === "string") throw new Error(detail);
const errors = (detail as { errors?: CheckError[] } | undefined)?.errors;
throw new Error(errors ? `${t("E02_Invalid")} ${errors.length}` : `HTTP ${response.status}`);
}
return body;
}
export function renderE02StabGround(root: HTMLElement): void {
const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY);
const main = el("div", { className: "e02-page" });
const status = el("p", { className: "e02-status" });
const saveButton = createButton({
label: t("Common_Btn_Save"),
variant: "filled",
onClick: () => void save(),
});
const resetButton = createButton({
label: t("Common_Btn_Reset"),
variant: "danger-ghost",
onClick: () => void reset(),
});
saveButton.disabled = resetButton.disabled = true; // 읽어 온 뒤 켬
const actions = el("div", { className: "ui-sidebar-actions" });
actions.append(resetButton, saveButton);
const panel = el("div", { className: "ui-sidebar-panel e02-panel" });
panel.append(el("p", { className: "e02-hint", text: t("E02_Note") }), status, actions);
mountGroupStep(root, ROUTES.E02_STAB_GROUND, { mainContent: main, leftPanel: panel });
if (!projectId)
return void main.append(el("p", { className: "e02-empty", text: t("E02_NoProject") }));
let view: E02View | null = null;
let refreshStations = (): void => {};
let dirty = false;
const paintStatus = (errors: CheckError[]): void => {
const lines: string[] = [];
if (view && !view.editable) lines.push(t("E02_ReadOnly"));
if (dirty) lines.push(t("E02_Unsaved"));
if (errors.length) lines.push(`${t("E02_Invalid")} ${errors.length}`);
status.textContent = lines.join("\n");
status.classList.toggle("is-error", errors.length > 0);
};
/** 틀린 칸 표시 — 칸마다 is-invalid + data-error(마우스 글) */
const mark = (): CheckError[] => {
if (!view) return [];
const errors = checkGround(view.ground, view.defaults.limits);
for (const node of main.querySelectorAll<HTMLElement>(".is-invalid")) {
node.classList.remove("is-invalid");
delete node.dataset.error;
}
for (const error of errors) {
const node = main.querySelector<HTMLElement>(`[data-path="${error.path}"]`);
if (!node) continue;
node.classList.add("is-invalid");
node.dataset.error = errorText(error, view.defaults);
}
paintStatus(errors);
return errors;
};
const render = (): void => {
if (!view) return;
const cards = buildCards(view);
refreshStations = cards.refreshStations;
main.replaceChildren(cards.root);
mark();
};
const remember = (): void => {
if (!view) return;
dirty = true;
sessionStorage.setItem(cacheKey(projectId), JSON.stringify(view.ground));
};
const adopt = (body: GroundResponse, keepCache: boolean): void => {
if (!view || !body.defaults) return;
let ground = body.ground;
const cached = keepCache ? sessionStorage.getItem(cacheKey(projectId)) : null;
if (cached) ground = JSON.parse(cached) as Ground;
else sessionStorage.removeItem(cacheKey(projectId));
// 옛 캐시(지하수 갈래 칸 전) — 빠진 칸은 서버 값(서버가 기본값으로 채워 보냄)
ground.groundwater = { ...body.ground.groundwater, ...ground.groundwater };
dirty = Boolean(cached);
view.ground = ground;
view.defaults = body.defaults;
view.editable = body.editable ?? false;
saveButton.disabled = resetButton.disabled = !view.editable;
render();
};
async function save(): Promise<void> {
if (!view?.editable) return;
const errors = mark();
if (errors.length) {
showToast(
`${t("E02_Invalid")} ${errors.length} — ${errorText(errors[0], view.defaults)}`,
"error",
);
return;
}
showLoadingOverlay();
try {
const body = await request(`/projects/${projectId}/stab/ground`, {
method: "PUT",
body: JSON.stringify({ ground: view.ground }),
});
adopt({ ...body, defaults: view.defaults, editable: view.editable }, false);
showToast(t("E02_Saved"), "success");
} catch (error) {
showToast(error instanceof Error ? error.message : t("Common_Status_Error"), "error");
} finally {
hideLoadingOverlay();
}
}
async function reset(): Promise<void> {
if (!view?.editable) return;
if (
!(await showConfirmDialog(
t("E02_ResetConfirm"),
t("Common_Btn_Reset"),
t("Common_Btn_Cancel"),
))
)
return;
showLoadingOverlay();
try {
const body = await request(`/projects/${projectId}/stab/ground/reset`, { method: "POST" });
adopt({ ...body, defaults: view.defaults, editable: view.editable }, false);
showToast(t("E02_ResetDone"), "success");
} catch (error) {
showToast(error instanceof Error ? error.message : t("Common_Status_Error"), "error");
} finally {
hideLoadingOverlay();
}
}
attachHoverInfo(
main,
(target) => {
if (!view) return null;
const src = target.dataset.src;
const source = src ? view.defaults.sources[src] : undefined;
const userLayer = src?.startsWith("materials.") && !source;
return [
target.dataset.error,
target.dataset.def !== undefined ? `${t("E02_DefaultIs")} ${target.dataset.def}` : "",
source ?? (userLayer ? t("E02_UserLayer") : ""),
]
.filter(Boolean)
.join("\n");
},
"[data-path]",
);
void (async () => {
try {
const body = await request(`/projects/${projectId}/stab/ground`);
view = {
ground: body.ground,
defaults: body.defaults as GroundDefaults,
editable: false,
change: () => {
remember();
refreshStations();
mark();
},
rebuild: () => {
remember();
render();
},
};
adopt(body, true);
} catch (error) {
main.replaceChildren(
el("p", {
className: "e02-empty",
text: error instanceof Error ? error.message : t("Common_Status_Error"),
}),
);
}
})();
}