/* ============================================================================= * B05_Profile_UI_Page_Actions.ts * B05 페이지의 **버튼 동작** — [경로 계산]·[임시저장]·[초기화]. * * `B05_Profile_UI_Page` 에서 떼어냈다(700줄 제한, 2026-09-02). 화면 조립·로딩 순서는 * 페이지에 남고, 여기에는 영구저장소를 건드리는 세 갈래만 둔다(CLAUDE.md 5장: * 세션에 쌓인 조작은 [저장]·[확정]에서만 정본으로 나간다). * ========================================================================== */ import { ROUTES } from "@config/config_frontend"; import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; import { hideLoadingOverlay, showConfirmDialog, showLoadingOverlay, showToast, } from "@ui/ui_template_elements"; import { navigateTo } from "../A00_Common/router"; import { clearRouteLatestCache, confirmRoute, resetRouteDesign, solveRoute, type RouteLatestResponse, } from "./B05_Profile_Api_Fetch"; import { flushCulvertOptions } from "../B06_Section/B06_Section_Api_Culvert_Options"; import { flushPendingPipes } from "./B05_Profile_Api_Pipes_Draft"; import { invalidateSectionDetail, saveCachedCrossPatches, } from "../B06_Section/B06_Section_Section_Store"; import { clearDrafts } from "../A00_Common/b_page_state"; import { saveCorridorIfDirty } from "./B05_Profile_UI_Corridor"; import { circlePoint, routePoint } from "./B05_Profile_UI_Page_Helpers"; import { clearAlignmentDrafts } from "./B05_Profile_UI_Profile_Edit"; import type { createRoutePanel } from "./B05_Profile_UI_Panel"; import type { createRouteProfilePanel } from "./B05_Profile_UI_Profile_Panel"; import type { createStructuresBridge } from "./B05_Profile_UI_Page_Structures"; import type { createRouteViewer } from "./B05_Profile_UI_Viewer"; function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; } /** 버튼 동작이 페이지에서 받아 쓰는 창구 — 값은 바뀌므로 함수로 받는다. */ export interface PageActionContext { projectId: string; latest: () => RouteLatestResponse | null; /** 확정 지표면 응답 — 모델 id 는 `model_id` 다(2026-09-06 호출 정리). */ confirmedSurface: () => { model_id: number | null } | null; routeReady: () => boolean; viewer: () => ReturnType; panel: () => ReturnType; profilePanel: () => ReturnType; bridge: () => ReturnType; /** 상단측(측구 방향) 사용자 변경분 — 키는 누가거리 문자열. */ uphillOverrides: Map; persistUphillOverrides: () => void; loadLatest: (forceFresh?: boolean) => Promise; renderLatest: (next: RouteLatestResponse) => void; restoreSections: (routeId: number) => Promise; } /** [경로 계산] — 마커·패널 값으로 노선을 풀고 종횡단까지 다시 받는다. */ export async function solveRouteAction(ctx: PageActionContext): Promise { const latest = ctx.latest(); const confirmedSurface = ctx.confirmedSurface(); if (!confirmedSurface || !latest) return; const points = ctx.viewer().markers.getPoints(); if (!points.bp || !points.ep) { showToast("BP와 EP를 지형에 배치하세요.", "error"); return; } const values = ctx.panel().values(); showLoadingOverlay(); try { const solved = await solveRoute(ctx.projectId, { filter_key: latest.surface_params.source_filter, method: latest.surface_params.method, smooth: latest.surface_params.smooth, surface_model_id: confirmedSurface.model_id ?? undefined, algorithm: values.algorithm, bp: routePoint(points.bp), ep: routePoint(points.ep), cp: points.cp.map(routePoint), ap: points.ap.map(circlePoint), fp: points.fp.map(circlePoint), grade_class: values.gradeClass, paved: values.paved, min_curve_radius_m: values.minCurveRadius, max_uphill_grade: values.maxUphillGrade, max_downhill_grade: values.maxDownhillGrade, min_uphill_grade: values.minUphillGrade, min_downhill_grade: values.minDownhillGrade, allow_avoid_pass_through: values.allowAvoidPassThrough, station_interval_m: values.stationInterval, cross_half_width_m: null, cross_sample_interval_m: values.crossSampleInterval, long_sample_interval_m: values.longSampleInterval, terrain_type: values.terrainType, design_speed_kph: values.designSpeed, max_grade_pct: values.maxGradePct, min_vertical_radius_m: values.minVerticalRadius, min_tangent_length_m: values.minTangentLength, start_elevation_offset_m: values.startElevationOffset, end_elevation_offset_m: values.endElevationOffset, enforce_pipe_clearance: values.enforcePipeClearance, }); // 새 경로는 측점 구성이 달라지므로 이전 상단측 변경분을 폐기한다(자동 판정 재사용). ctx.uphillOverrides.clear(); ctx.persistUphillOverrides(); ctx.renderLatest(await ctx.loadLatest(true)); await ctx.restoreSections(solved.route_id); if (solved.cross_section_count === null) { showToast("경로는 저장되었지만 종횡단 생성에 실패했습니다.", "error"); } else if (solved.grade_summary === null) { showToast("경로·종횡단은 저장되었지만 계획선 산출에 실패했습니다.", "error"); } else { showToast("최적 경로 계산이 완료되었습니다.", "success"); } } catch (error) { showToast(error instanceof Error ? error.message : "경로 계산에 실패했습니다.", "error"); } finally { hideLoadingOverlay(); } } /** [임시저장] — 현재 편집을 영구저장소에 남기되 워크플로 단계·페이지는 그대로 둔다. * 종·횡 통합 확정은 B06 [확정]이 담당한다(2026-08-08 워크플로우 재정의). */ export async function tempSaveAction(ctx: PageActionContext): Promise { if (!ctx.routeReady()) return; const latest = ctx.latest(); const projectId = ctx.projectId; // 관 매설 지점을 B04와 같은 저장소에 남긴다 — 저장 실패가 임시저장을 막지는 않는다. // 세션 초안(B06 저장도 같은 창구를 쓴다)을 먼저 내보내고, 화면 목록으로 한 번 더 맞춘다. await flushPendingPipes(projectId).catch(() => undefined); await ctx .profilePanel() .drainage.savePipes() .catch(() => 0); showLoadingOverlay(); try { // 종단 계획선 편집은 화면에서만 계산해 두었으므로 저장 시점에 영속화한다. await ctx.profilePanel().save(); // 구조물도 조작분이 세션에만 있다(2026-08-29 — CLAUDE.md 5장). 실패는 자체 토스트로 // 알리고 세션에 남겨 두므로, 여기서 저장 전체를 멈추지 않는다. await ctx.bridge().saveStructuresIfDirty(); // 비정규 측점·상단측 변경분까지 데이터로는 확정 저장하되, 단계 완료 전이는 하지 않는다. await confirmRoute( projectId, { filter_key: latest?.surface_params.source_filter, method: latest?.surface_params.method, smooth: latest?.surface_params.smooth, surface_model_id: ctx.confirmedSurface()?.model_id ?? undefined, irregular_stations: ctx .bridge() .irregularStations() .map((station) => ({ chainage_m: station.chainage_m, structure: station.structure, })), // 상단측(측구 방향) 사용자 변경분 — 종단 정본에 병합되어 B06이 그대로 소비한다. uphill_overrides: [...ctx.uphillOverrides.entries()].map(([chainage, side]) => ({ chainage_m: Number(chainage), side, })), }, false, ); // B06 조정창에서 만진 배수관 구간값도 세션에만 있다 — 함께 내보낸다 // (CLAUDE.md 5장: 영구저장은 [저장]·[확정]에서만). 실패해도 저장은 진행한다. if (latest?.route?.id != null) { await flushCulvertOptions(projectId, `b06:culvertopt:${projectId}:${latest.route.id}`).catch( () => undefined, ); } // B06에서 만져 **캐시에 얹힌** 횡단 수정분을 함께 남긴다 — 안 보내면 바로 아래 // 캐시 비우기에서 사라진다. 계획선 저장 **뒤에** 보내야 사용자 수정 1세트가 // 최종본으로 얹힌다(2026-08-24 사용자 지적). if (latest?.route?.id != null) { await saveCachedCrossPatches(projectId, latest.route.id); } // 서버가 종단 정본의 계획선을 다시 썼다 — 공유 캐시를 비워 B06이 옛 계획선을 못 보게 한다. invalidateSectionDetail(projectId); ctx.renderLatest(await ctx.loadLatest(true)); // 임시저장 = 코리도 영구저장 시점(2026-08-23) — 실패해도 임시저장은 성공 처리. if (latest?.route?.id) void saveCorridorIfDirty(projectId, latest.route.id); showToast(L("B05_Route_TempSave_Success"), "success"); } catch (error) { showToast(error instanceof Error ? error.message : L("B05_Route_TempSave_Failed"), "error"); } finally { hideLoadingOverlay(); } } /** [초기화] — 사용자 편집 전부 폐기, 계획노선 CSV 기본값으로 B05·B06 재계산 후 재진입. * 네이티브 confirm은 공용 헤드 브라우저가 자동 취소해 버튼이 죽은 듯 보였다 * (2026-08-19 진단) — 화면 안 모달로 확인받는다. */ export async function resetDesignAction(ctx: PageActionContext): Promise { if (!(await showConfirmDialog(L("B05_Route_Reset_Confirm"), "초기화"))) return; showLoadingOverlay(); try { await resetRouteDesign(ctx.projectId); // 옛 경로 기준 캐시를 전부 비우고 페이지를 새로 그린다 — 초기 계산본이 정본이 된다. clearRouteLatestCache(ctx.projectId); invalidateSectionDetail(ctx.projectId); // 사용자 조작(② 초안)은 **등록표 한 곳**에서 통째로 버린다(2026-09-06 일원화). // 예전에는 파일마다 따로 지워 새 값이 늘 때 빠뜨리기 쉬웠다. 노선 범위 초안은 // 노선이 바뀌면 키가 달라져 자연히 딸려 오지 않는다. clearDrafts(ctx.projectId, ctx.latest()?.route?.id ?? null); ctx.uphillOverrides.clear(); // 계획선 편집 초안도 함께 버린다 — 남기면 초기값 위에 옛 편집이 다시 얹혀 계획선이 // 측점에서 원지반선과 만나지 않는다(2026-09-04 실측: 새로고침 후 최대 6.0m 어긋남). clearAlignmentDrafts(); showToast(L("B05_Route_Reset_Success"), "success"); navigateTo(ROUTES.B05_PROFILE); } catch (error) { showToast(error instanceof Error ? error.message : L("B05_Route_Reset_Failed"), "error"); } finally { hideLoadingOverlay(); } }