/* ============================================================================= * B06_Section_UI_Page_Design_Sync.ts * 측점 설계 선택 반영·재계산 창구. `_UI_Page` 700줄 제한 대응으로 떼어낸 것이며 * 동작은 그대로다(2026-09-13 분리). * - `handleDesignChange` : 버튼 선택 즉시 반영 + 세션 초안 기록 + 재계산 * - `recomputeIfRock` : 암 경계·암 절토 경사 변경 후 암 측점만 재계산 * - `reconcileStaleDesigns`: 낡은 design 일괄 재계산(B05 와 같은 창구) * - `syncBermSpans` : 구조물 목록의 소단을 세션 사본으로 펴기 * - `applyPanelToAll` : 표준 횡단면 설정 [전체 반영] * ========================================================================== */ import { writeCrossDesignChoice } from "./B06_Section_Cross_Design_Session"; import { showToast } from "@ui/ui_template_elements"; import { refreshCrossDesigns } from "./B06_Section_Cross_Refresh"; import { bermSpansFromStructures, readBermSpans, writeBermSpans, } from "./B06_Section_UI_Page_Persist"; import { readAlignmentDraft } from "../B05_Profile/B05_Profile_UI_Profile_Edit"; import { hasStaleDesigns } from "./B06_Section_UI_Section_Common"; import { L } from "./B06_Section_UI_Page_Common"; import type { SectionDetailResponse } from "./B06_Section_Api_Fetch"; import type { CrossDesignChange, SectionViewController } from "./B06_Section_UI_Section_View"; import type { StructureInstance } from "../B05_Profile/B05_Profile_Api_Structures"; export interface DesignSyncContext { projectId: string | null; routeId: () => number | null; detail: () => SectionDetailResponse | null; /** 뷰는 이 모듈보다 **뒤에** 만들어지므로 그때 채워지는 참조를 통해 부른다. */ view: () => SectionViewController; } export interface DesignSyncController { handleDesignChange: (chainageM: number, change: CrossDesignChange) => Promise; recomputeIfRock: (chainageM: number) => void; reconcileStaleDesigns: (options?: { force?: boolean }) => Promise; syncBermSpans: (structures: ReadonlyArray) => void; applyPanelToAll: () => Promise; } export function createDesignSync(ctx: DesignSyncContext): DesignSyncController { const { projectId } = ctx; /** * 측점 설계 버튼 변경 처리: (1) 선택을 즉시 로컬 반영해 해당 카드만 리프레시(버튼 즉시 반응), * (2) 서버에서 단면적을 계산·저장하고 최신 요청이면 그 카드만 다시 갱신한다. 전체 재렌더 없음. * 설정 패널 편집값을 요청에 실어 요청값 → DB 저장 옵션 → config 기본값 우선순위를 지킨다. */ async function handleDesignChange(chainageM: number, change: CrossDesignChange): Promise { const sectionDetail = ctx.detail(); const currentRouteId = ctx.routeId(); if (!projectId || currentRouteId === null || !sectionDetail) return; const target = sectionDetail.cross_sections.find( (section) => Math.abs(section.chainage_m - chainageM) < 0.01, ); if (!target) return; // (1) 즉시 로컬 반영: 선택 버튼만 갱신(숫자·설계선은 기존값 유지) → 해당 카드만 교체. if (target.design) { target.design = { ...target.design, ground_type: change.ground_type, section_mode: change.section_mode, ditch_side: change.ditch_side ?? target.design.ditch_side, ditch_type: change.ditch_type, paved: change.paved, two_stage_slope: change.two_stage_slope, ditch_choice: change.ditch_choice, }; ctx.view().refreshCard(chainageM); } // (2) 선택은 **세션 초안**으로 남긴다 — 화면을 오가거나 새로고침해도 남고, // [저장]·[확정] 때 한 번에 정본으로 나간다(2026-09-06 사용자 확정: 캐시가 저절로 // 영구저장소로 새면 안 된다). 예전에는 여기서 서버가 계산하고 바로 저장했다. writeCrossDesignChoice(projectId, currentRouteId, chainageM, { ground_type: change.ground_type, section_mode: change.section_mode, ditch_side: change.ditch_side ?? null, ditch_type: change.ditch_type, paved: change.paved, two_stage_slope: change.two_stage_slope, ditch_choice: change.ditch_choice, }); // (3) 계산은 브라우저 안에서 — B05·B06 이 같이 쓰는 창구 하나로 돌린다. await reconcileStaleDesigns({ force: true }); } /** 현재 design 값에서 재계산용 change를 복원한다(암 경계 오프셋 변경 시 재계산 트리거). */ function changeFromDesign(chainageM: number): CrossDesignChange | null { const target = ctx .detail() ?.cross_sections.find((section) => Math.abs(section.chainage_m - chainageM) < 0.01); const design = target?.design; if (!design) return null; return { ground_type: design.ground_type, section_mode: design.section_mode, ditch_side: design.ditch_side ?? null, ditch_type: design.ditch_type ?? "standard", paved: design.paved, two_stage_slope: design.two_stage_slope ?? true, ditch_choice: design.ditch_choice ?? null, }; } /** 암 경계 오프셋 변경 후 암 지반이면 2단계 무릎·단면적을 서버 재계산한다. */ function recomputeIfRock(chainageM: number): void { const change = changeFromDesign(chainageM); if (change && change.ground_type !== "soil") void handleDesignChange(chainageM, change); } /** * 로드 시 stale design을 최신 엔진·최신 종단 계획고로 자동 재계산한다(E-1 + N-6). * 대상: (1) 2단계 경사 필드(`two_stage_slope`)가 없는 옛 암 측점, (2) B05에서 종단이 * 변경·확정돼 저장된 계산 기준 계획고(`design.design_elevation_m`)가 현재 계획선 * (`design_profiles`) 보간값과 어긋난 측점. 종단이 안 바뀐 측점은 0건이라 불필요한 API * 호출이 없다. * * 재계산은 측점별 순차 호출이 아니라 **일괄 프리뷰 1회**로 한다(2026-08-04 사용자 확인 * — 예전 for-await 루프는 stale 측점 수만큼 왕복하며 카드가 하나씩 바뀌어 "이력 재생"처럼 * 보였고 제일 느렸다). 편집 델타는 B05 세션 초안이 있으면 그것을(두 화면 동일 계획선), * 없으면 저장분(profile_alignment.edits)을 쓴다. 측점별 사용자 선택값(지반유형·단면유형· * 측구·암 경계)은 서버가 저장분에서 유지하고, 세션에만 있는 암 경계 오프셋은 함께 실어 보낸다. */ async function reconcileStaleDesigns(options?: { force?: boolean }): Promise { const sectionDetail = ctx.detail(); const currentRouteId = ctx.routeId(); if (!sectionDetail || !projectId || currentRouteId === null) return; const draft = readAlignmentDraft(currentRouteId); // 낡음 판정은 B05와 **같은 규칙** 하나뿐이다(공용 판정 — 계획고 어긋남 + // 옛 암 2단계 필드 누락). 조건이 갈리면 같은 데이터가 두 화면에서 다른 값이 된다. // // 다만 **미저장 세션 편집이 있으면 판정을 건너뛰고 무조건 맞춘다**. 서버에서 갓 받은 // 저장분끼리는 늘 일치해 「낡지 않음」으로 나오는데, B05가 세션에 남긴 편집은 그 안에 // 없어 B06이 재계산을 통째로 건너뛰었다 — 같은 시점에 B05 절토 4,774.1㎥ ↔ B06 // 4,515.0㎥ 로 갈렸다(2026-09-03 실측). 재계산은 브라우저 안에서 끝나 값싸다. if (!options?.force && !draft && !hasStaleDesigns(sectionDetail)) return; // 진입 정합은 화면을 잠그지 않는다 — 카드가 도착하는 대로 조용히 갱신된다 // (CLAUDE.md 5장). try { const alignment = sectionDetail.longitudinal.profile_alignment as | { edits?: { station_offsets?: Record; curve_radii?: Record; }; } | undefined; const edits = draft ?? { station_offsets: alignment?.edits?.station_offsets ?? {}, curve_radii: alignment?.edits?.curve_radii ?? {}, }; // 재계산은 B05와 **같은 창구**를 쓴다 — 인자가 갈리면 같은 데이터가 두 화면에서 // 다른 값이 된다(2026-09-03 사용자 지시로 일원화). const updated = await refreshCrossDesigns({ projectId, routeId: currentRouteId, detail: sectionDetail, edits, }); // 카드는 한꺼번에 갈아 끼운다 — 측점마다 `refreshCard` 를 부르면 그때마다 종단 // 그래프·유토곡선까지 다시 그려 측점 수만큼 화면이 멈췄다(2026-09-12). ctx.view().refreshCards(updated); } catch (error) { const detail = error instanceof Error ? ` ${error.message}` : ""; showToast(`${L("B06_Design_Failed")}${detail}`, "error"); } } /** * 구조물 목록의 **소단**(C군 사면안정)을 세션 사본으로 편다 — 재계산이 측점마다 읽는 값이다. * * 사용자는 「구조물 배치」에서 놓고(2026-09-07 확정), 계산은 그 사본만 본다. 달라졌을 때만 * 다시 계산한다 — 목록은 화면을 열 때도 오므로 매번 돌리면 진입이 느려진다. */ function syncBermSpans(structures: ReadonlyArray): void { const currentRouteId = ctx.routeId(); if (!projectId || currentRouteId === null) return; const next = bermSpansFromStructures(structures); if (JSON.stringify(next) === JSON.stringify(readBermSpans(projectId, currentRouteId))) return; writeBermSpans(projectId, currentRouteId, next); void reconcileStaleDesigns({ force: true }); } /** 패널 [전체 반영](N-2-1): design 보유 전 측점을 패널 최신값으로 순차 재계산한다. * handleDesignChange가 standardPanel.getValues()를 실어 보내므로 표준단면 수치만 * 갱신되고 측점별 버튼 선택값은 보존된다. 순차 await로 동시 API 호출 수를 제한한다. */ let applyingAll = false; async function applyPanelToAll(): Promise { const sectionDetail = ctx.detail(); if (applyingAll || !sectionDetail || !projectId || ctx.routeId() === null) return; const targets = sectionDetail.cross_sections.filter((section) => section.design); if (!targets.length) return; // 화면을 잠그지 않는다 — 카드가 하나씩 갱신되는 것이 곧 진행 표시다(CLAUDE.md 5장). // 대신 도는 동안 다시 누르는 것만 막는다. applyingAll = true; try { for (const section of targets) { const change = changeFromDesign(section.chainage_m); if (change) await handleDesignChange(section.chainage_m, change); } showToast(L("B06_Std_ApplyAll_Success"), "success"); } finally { applyingAll = false; } } return { handleDesignChange, recomputeIfRock, reconcileStaleDesigns, syncBermSpans, applyPanelToAll, }; }