/* ============================================================================= * B05_Profile_UI_Page_Actions.ts * 종단 보기의 **버튼 동작** — [경로 계산] · [저장]·[확정]에 보태는 종단 보기 몫. * * 종단 보기 본체(`design_view_profile`)에서 떼어냈다(700줄 제한, 2026-09-02). 화면 조립·로딩 * 순서는 본체에 남고, 여기에는 영구저장소를 건드리는 갈래만 둔다(CLAUDE.md 5장: 세션에 쌓인 * 조작은 [저장]·[확정]에서만 정본으로 나간다). [초기화]는 한 벌(`design_reset`)이 한다. * ========================================================================== */ import { hideLoadingOverlay, showLoadingOverlay, showToast } from "@ui/ui_template_elements"; import { confirmRoute, solveRoute, type RouteLatestResponse } from "./B05_Profile_Api_Fetch"; import type { DesignViewSave } from "../A00_Common/design/design_shared"; import { invalidateSectionDetail, saveCachedCrossPatches, } from "../B06_Section/B06_Section_Section_Store"; import { saveCorridorIfDirty } from "./B05_Profile_UI_Corridor"; import { circlePoint, routePoint } from "./B05_Profile_UI_Page_Helpers"; 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"; /** 버튼 동작이 페이지에서 받아 쓰는 창구 — 값은 바뀌므로 함수로 받는다. */ 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(); } } /** [저장] · [확정]에 종단 보기가 보태는 몫 — 차례는 저장 한 줄기(`saveDesign`)가 정하고 * 한 벌(`design_shared`)이 두 보기 몫을 모아 한 번에 보낸다(계획서 24-2a · 24-3b). 관(배수유역도) · * 계획고는 한 벌 종단도가 낸다. */ export function profileSaveParts(ctx: PageActionContext): DesignViewSave { const projectId = ctx.projectId; return { routeId: () => (ctx.routeReady() ? (ctx.latest()?.route?.id ?? null) : null), steps: { // 구조물 — 저장 뒤 서버 목록(새 식별자)으로 화면을 맞춘다. 실패는 자체 토스트로 알리고 // 세션에 남겨 두므로 저장 전체를 멈추지 않는다(2026-08-29 — CLAUDE.md 5장). structures: () => ctx.bridge().saveStructuresIfDirty(), // 상단측 + 비정규 측점 — 데이터로는 확정 저장하되, 단계 완료 전이는 하지 않는다. uphill: async () => { const latest = ctx.latest(); 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, ); }, }, // 횡단 보기를 세운 적 없이 [저장]하면 — 캐시에 얹힌 횡단 수정분을 함께 남긴다. 안 보내면 // 바로 아래 캐시 비우기에서 사라진다. 계획선 저장 **뒤에** 보내야 사용자 수정 1세트가 // 최종본으로 얹힌다(2026-08-24 사용자 지적). 횡단 보기가 서 있으면 그쪽 본문이 나간다. cross: async () => { const routeId = ctx.latest()?.route?.id; if (routeId != null) await saveCachedCrossPatches(projectId, routeId); }, done: async () => { // 서버가 종단 정본의 계획선을 다시 썼다 — 공유 캐시를 비워 횡단 보기가 옛 계획선을 못 보게 한다. invalidateSectionDetail(projectId); ctx.renderLatest(await ctx.loadLatest(true)); // 저장 = 코리도 영구저장 시점(2026-08-23) — 실패해도 저장은 성공 처리. const routeId = ctx.latest()?.route?.id; if (routeId) void saveCorridorIfDirty(projectId, routeId); }, }; }