/* ============================================================================= * B06_Section_UI_Page_Persist.ts * B06 페이지의 **저장 흐름** — 암 경계선 세션 저장소, 편집분 수집, [저장]·[확정]. * * `B06_Section_UI_Page` 에서 떼어낸 몫이다(700줄 제한, 2026-09-02). 화면 조립·조정창 * 배선은 페이지에 남고, 여기에는 "세션에 쌓인 조작을 정본으로 내보내는" 경로만 둔다 * (CLAUDE.md 5장 데이터 3층: 영구저장은 [저장]·[확정]에서만). * ========================================================================== */ import { hideLoadingOverlay, showLoadingOverlay, showToast } from "@ui/ui_template_elements"; import { goToWorkflowStage, WORKFLOW_STEP_ROUTES } from "../A00_Common/b_workflow_nav"; import { confirmSections, saveSections, type CrossSectionPatch, type SectionContextResponse, type SectionDetailResponse, } from "./B06_Section_Api_Fetch"; import { flushPendingStructures } from "../B05_Profile/B05_Profile_Api_Structures"; import { flushUphillOverrides } from "../B05_Profile/B05_Profile_Api_Fetch"; import { buildCrossPatches, type CrossPatchSources } from "./B06_Section_UI_Page_Patches"; import type { StandardCrossSection } from "./B06_Section_Api_Fetch"; import type { RockBoundaryControl } from "./B06_Section_UI_Section_View"; import { computeMassHaul, massHaulPayload } from "@util/common_util_mass_haul"; import { computeHaulPlan } from "@util/common_util_mass_haul_balance"; import { balloonOffsetsPayload } from "@util/common_util_mass_haul_balance_view"; import { L } from "./B06_Section_UI_Page_Common"; /** * 암 경계선 오프셋 세션 키 — 저장소와 재계산 창구가 **같은 자리**를 보게 정의처를 하나로 둔다. * (`B06_Section_Cross_Refresh` 가 패널 없는 B05에서도 같은 값을 읽어 서버로 보낸다.) */ export function rockBoundarySessionKey(projectId: string, routeId: number): string { return `b06:rockb:${projectId}:${routeId}`; } /** 세션에 쌓인 암 경계선 오프셋(측점키 → m). 없거나 손상되면 빈 객체. */ export function readRockBoundarySession( projectId: string, routeId: number, ): Record { try { const raw = window.sessionStorage.getItem(rockBoundarySessionKey(projectId, routeId)); const parsed = raw ? (JSON.parse(raw) as unknown) : null; return parsed && typeof parsed === "object" ? (parsed as Record) : {}; } catch { return {}; } } /** 암 경계선 오프셋 저장소 — 값(Map)과 조정창 제어기를 함께 낸다. */ export interface RockBoundaryStore { /** 측점키(누가거리 2자리) → 오프셋(m). `buildCrossPatches` 가 그대로 읽는다. */ offsets: Map; control: RockBoundaryControl; /** 세션값 읽기 — 프로젝트·노선이 정해진 뒤에 부른다. */ load: () => void; /** context(config) 기본값·스텝 반영. */ setDefaults: (defaultOffsetM: number, stepM: number) => void; } /** * 암 경계선 오프셋(측점별) 세션 저장소. * * 서버 재계산 없이 프론트 세션(sessionStorage)에 보관하고, 종횡단 확정 시 * cross_patches로 DB(data.design.rock_boundary_offset_m)에 병합한다. */ export function createRockBoundaryStore(options: { sessionKey: () => string | null; detail: () => SectionDetailResponse | null; refreshCard: (chainageM: number) => void; /** 경계 이동 → 2단계 무릎·단면적 재계산. */ recompute: (chainageM: number) => void; }): RockBoundaryStore { const { sessionKey, detail, refreshCard, recompute } = options; let rockBoundaryDefault = -0.5; let rockBoundaryStep = 0.1; /** * 암 경계선 오프셋은 **0을 넘을 수 없다**(2026-08-02 사용자 지시). 오프셋은 지면선에서 * 아래로 파고든 깊이라, 양수가 되면 경계선이 지표면 위로 떠올라 토사층이 음수가 된다. * DB에 옛 양수값이 남아 있어도 읽는 즉시 0으로 눌러 계산이 뒤집히지 않게 한다. */ const clamp = (value: number): number => Math.min(value, 0); const offsets = new Map(); const key = (chainageM: number): string => chainageM.toFixed(2); function persist(): void { const storageKey = sessionKey(); if (!storageKey) return; try { window.sessionStorage.setItem(storageKey, JSON.stringify(Object.fromEntries(offsets))); } catch { /* 세션 저장 실패는 무시(용량 초과 등) — 값은 메모리에 유지된다. */ } } return { offsets, load(): void { offsets.clear(); const storageKey = sessionKey(); if (!storageKey) return; try { const raw = window.sessionStorage.getItem(storageKey); if (!raw) return; const parsed = JSON.parse(raw) as Record; Object.entries(parsed).forEach(([chainage, offset]) => { if (Number.isFinite(offset)) offsets.set(chainage, offset); }); } catch { /* 손상된 세션 값은 무시 — 기본값으로 재시작. */ } }, setDefaults(defaultOffsetM: number, stepM: number): void { rockBoundaryDefault = defaultOffsetM; rockBoundaryStep = stepM; }, control: { get stepM() { return rockBoundaryStep; }, get defaultOffsetM() { return rockBoundaryDefault; }, offsetFor: (section) => clamp( offsets.get(key(section.chainage_m)) ?? section.design?.rock_boundary_offset_m ?? rockBoundaryDefault, ), adjust: (chainageM, deltaM) => { const stored = detail()?.cross_sections.find( (section) => Math.abs(section.chainage_m - chainageM) < 0.01, )?.design?.rock_boundary_offset_m; const current = clamp(offsets.get(key(chainageM)) ?? stored ?? rockBoundaryDefault); offsets.set(key(chainageM), clamp(Math.round((current + deltaM) * 100) / 100)); persist(); refreshCard(chainageM); recompute(chainageM); }, reset: (chainageM) => { offsets.set(key(chainageM), rockBoundaryDefault); persist(); refreshCard(chainageM); recompute(chainageM); }, }, }; } /** [저장]·[확정]이 함께 쓰는 페이지 상태 창구. */ export interface SectionPersistContext { projectId: string | null; routeId: () => number | null; detail: () => SectionDetailResponse | null; context: () => SectionContextResponse | null; standardValues: () => StandardCrossSection | undefined; /** 조정창 구간값을 정본으로 내보낸다(세션 → 서버). */ flushCulvertOptions: () => Promise; /** 측점별 편집분의 출처 묶음 — 세션·제어기에 흩어진 값을 페이지가 모아 준다. */ patchSources: () => CrossPatchSources; } /** 확정과 임시 저장이 함께 보내는 편집분(암 경계선 오프셋 + 유토곡선 + balloon 위치). */ export function collectSectionEdits(ctx: SectionPersistContext): { crossPatches: CrossSectionPatch[]; massHaul: Record | undefined; } { const crossPatches = buildCrossPatches(ctx.patchSources()); // 유토곡선은 화면 표시 내내 프론트 메모리에만 있다가 저장 시점에만 영구 저장된다. const detail = ctx.detail(); const context = ctx.context(); const result = detail && context?.earthwork_conversion ? computeMassHaul( detail.cross_sections, context.earthwork_conversion, context.natural_spoil_min_ground_slope ?? undefined, ) : null; return { crossPatches, massHaul: result ? massHaulPayload( result, computeHaulPlan(result, context?.haul_equipment_limits), balloonOffsetsPayload(), ) : undefined, }; } /** 세션에 쌓인 조정창·구조물 조작을 정본으로 내보낸다 — [저장]·[확정] 공통 앞단. */ async function flushPendingEdits(ctx: SectionPersistContext, projectId: string): Promise { // 조정창 구간값은 세션에만 있다 — 정본 payload를 모으기 전에 내보낸다 // (CLAUDE.md 5장: 영구저장은 [저장]·[확정]에서만). await ctx.flushCulvertOptions(); // B05 3D에서 바꾼 상단측(측구 방향)도 여기서 내보낸다 — 예전에는 B05 [임시저장]에만 // 실려, B06에서 저장·확정하면 세션에만 남아 옛 방향이 정본에 그대로 있었다 // (2026-09-06). 서버가 종단 정본과 저장된 횡단 설계를 함께 갱신하므로 아래 // 횡단 patch 저장보다 **먼저** 나가야 사용자 수정이 위에 얹힌다. await flushUphillOverrides(projectId).catch(() => undefined); // B05에서 만지고 넘어온 구조물 조작분도 여기서 정본에 남긴다. 실패해도 횡단 // 저장까지 막지는 않는다 — 미저장분은 세션에 남으므로 다시 시도할 수 있다 // (2026-08-29 실측: 타입이 거절되자 sections/save가 아예 나가지 않았다). await flushPendingStructures(projectId).catch((error) => { const detail = error instanceof Error ? ` ${error.message}` : ""; showToast(`구조물 저장에 실패했습니다.${detail}`, "error"); }); } /** 임시 저장 — 저장만 하고 페이지는 그대로 둔다. */ export async function saveCurrentSections(ctx: SectionPersistContext): Promise { const routeId = ctx.routeId(); if (!ctx.projectId || routeId === null) return; showLoadingOverlay(); try { await flushPendingEdits(ctx, ctx.projectId); const edits = collectSectionEdits(ctx); await saveSections( ctx.projectId, routeId, ctx.standardValues(), edits.crossPatches.length ? edits.crossPatches : undefined, edits.massHaul, ); showToast(L("B06_Profile_Save_Success"), "success"); } catch (error) { const detail = error instanceof Error ? ` ${error.message}` : ""; showToast(`${L("B06_Profile_Save_Failed")}${detail}`, "error"); } finally { hideLoadingOverlay(); } } /** 확정 — 저장 뒤 다음 단계(상세설계)로 넘어간다. */ export async function confirmCurrentSections(ctx: SectionPersistContext): Promise { const routeId = ctx.routeId(); if (!ctx.projectId || routeId === null) return; showLoadingOverlay(); try { await flushPendingEdits(ctx, ctx.projectId); const edits = collectSectionEdits(ctx); await confirmSections( ctx.projectId, routeId, ctx.standardValues(), edits.crossPatches.length ? edits.crossPatches : undefined, edits.massHaul, ); showToast(L("B06_Profile_Confirm_Success"), "success"); goToWorkflowStage(ctx.projectId, WORKFLOW_STEP_ROUTES[4]); } catch (error) { const detail = error instanceof Error ? error.message : L("B06_Profile_Confirm_Failed"); showToast(`${L("B06_Profile_Confirm_Failed")} ${detail}`, "error"); } finally { hideLoadingOverlay(); } }