/* ============================================================================= * B05_Profile_UI_RouteEdit_Apply.ts * 노선 편집(평면)의 **노선 저장 이음**(346-3) — [노선 반영] 단추는 없앰. * * · 페이지 [저장](`design_save`)이 `pendingRouteSave` 로 지금 노선 초안을 받아 한 본문(`POST /design/save` · * `route` 칸 = `RouteSaveBody`)에 싣는다 — 노선이 마지막 저장 그대로면 null(안 보냄 · 472-6 6-B). * · 서버가 노선 저장 → 관 · 구조물 다시 얹기 → 세션 횡단 고침 넘김(`routeCarryPart` 가 낸 옛 노선 칸 · 같은 TS)까지 * 한 번에 · 다 저장한 뒤 `routeSaved` · 결과 한 줄 · 페이지는 새로고침 없이 다시 세운다(`rebuildAfterRouteSave`). * · 추천 [적용] = `saveRecommendedRoute` — `as_initial: true` 로 바로 저장(초기값 = 추천 노선 · 사용자 확정). * 편집기가 살아 있는 동안 여기 등록부에 「지금 초안」 · 「다시 세우기」를 건다(프로젝트 하나에 하나). * 실패하면 서버가 옛 노선으로 되살리고 화면 초안은 그대로 남는다(오류 한 줄은 부르는 쪽). * ========================================================================== */ import { clearDrafts, clearResults, clearState, readState } from "../A00_Common/b_page_state"; import type { RouteDraft } from "../A00_Common/design/logic/design_pipeline_types"; import { useDesignStore } from "../A00_Common/design/design_store"; import { commitRouteFacilities } from "../A00_Common/design/design_pipeline_page"; import { currentRoute, remountRoute } from "../A00_Common/router"; import { hideLoadingOverlay, showLoadingOverlay, showToast } from "@ui/ui_template_elements"; import { invalidateSectionDetail } from "../B06_Section/B06_Section_Section_Store"; import { routeCarryInputs } from "../B06_Section/B06_Section_Station_Keys"; import { sectionConfirmBody, type StandardCrossSection, } from "../B06_Section/B06_Section_Api_Fetch"; import { readStandardCrossSession } from "../B06_Section/B06_Section_UI_Standard_Panel"; import { balloonOffsetsPayload } from "@util/common_util_mass_haul_balance_view"; import type { SavePart } from "../A00_Common/design/design_save_api"; import { clearRouteLatestCache } from "./B05_Profile_Api_Fetch"; import { replanRoute, type RouteReplanResponse, type RouteSaveBody, } from "./B05_Profile_Api_Replan"; import type { SnapSetting } from "./B05_Profile_UI_RouteEdit_SnapStep"; type Vertex = [number, number]; type RouteSettings = SnapSetting & { terrain: string | null }; type RouteNodes = { planned: Vertex[]; curveOn: boolean[]; curveRadius: Array } & { curveLock?: RouteDraft["curveLock"]; curveArc?: Array; }; export interface RouteApplyParams { /** 옛 [노선 반영] 자리(단추 · 대기 막) — 346-3 부터 안 씀(대기는 페이지 잠금 막 하나). */ overlay?: HTMLElement; busy?: HTMLElement; projectId: string; /** 지금 편집값 — 저장하는 순간에 읽는다. */ nodes: () => RouteNodes; /** 마지막 저장 뒤 노선이 바뀌었나 — 안 바뀌었으면 보내지 않는다. */ changed: () => boolean; /** 설계 지형으로 보낼 분석 지형(PLAN 133-2) — 고르기가 안 보이면 null(지금 설계 지형 그대로). */ terrain: () => string | null; /** [측점 스냅] 켬끔(요구 224-2) · 간격(390-5) — 곡선표에 적혀 측점 셈이 읽음. */ snap: () => SnapSetting; /** 초기노선 갈래 — 저장에 지금 추천을 싣고, 결과 캐시를 비운 뒤 다시 적는다. */ initial: { current: () => unknown; keep: () => void; }; /** 저장이 끝나면 페이지를 새로고침 없이 다시 세운다. */ onApplied: () => void | Promise; } interface RouteSource { pending: () => Promise; settings: () => RouteSettings; saved: () => void; rebuild: () => void | Promise; } const sources = new Map(); /** 노드 · 곡선 편집값 → 계약 `RouteDraft`(R · 길이 고정도 실음 — 서버가 곡선표 `edits` 에 둠 · 430). */ export function routeDraftOf(nodes: RouteNodes, settings: RouteSettings): RouteDraft { return { rev: 0, nodes: nodes.planned.map(([x, y]): Vertex => [x, y]), curveOn: nodes.planned.map((_, index) => nodes.curveOn[index] !== false), curveRadius: nodes.planned.map((_, index) => nodes.curveRadius[index] ?? null), curveLock: nodes.planned.map((_, index) => nodes.curveLock?.[index] ?? null), curveArc: nodes.planned.map((_, index) => nodes.curveArc?.[index] ?? null), stationSnap: settings.stationSnap, stationSnapStep: settings.stationSnapStep ?? null, terrain: settings.terrain, }; } /** 편집기를 등록부에 건다 — 같은 프로젝트에 새 편집기가 서면 그쪽으로 바뀐다. 돌려준 함수 = 편집기가 떠날 때 풂 * (안 풀면 노선 저장 뒤 다시 선 페이지에서 옛 편집기 「고친 것 있음」이 [저장] · [확정]마다 노선 저장을 다시 돌림 · 445-2). */ export function bindRouteApply(params: RouteApplyParams): () => void { const settings = () => ({ ...params.snap(), terrain: params.terrain() }); const source: RouteSource = { pending: async () => { const nodes = params.nodes(); if (nodes.planned.length < 2 || !params.changed()) return null; return { draft: routeDraftOf(nodes, settings()), initial: params.initial.current(), // 손으로 고친 노선 = 계획고 옮김만(344-2 · 349-0v) — 균형 종단은 추천 [적용](`saveRecommendedRoute`)만. }; }, settings, saved: () => params.initial.keep(), rebuild: params.onApplied, }; sources.set(params.projectId, source); return () => void (sources.get(params.projectId) === source && sources.delete(params.projectId)); } /** 지금 보낼 노선 초안 — 편집기가 있으면 그것(안 바뀌었으면 null) · 없으면 가게 `routeDraft`. */ export async function pendingRouteSave(projectId: string): Promise { const source = sources.get(projectId); if (source) return source.pending(); const draft = useDesignStore(projectId, null).get("routeDraft"); return draft && draft.nodes.length >= 2 ? { draft } : null; } /** 노선 초안 저장(추천 [적용]) — 실패하면 throw(서버가 되살림 · 화면 초안 그대로). 새 노선 번호 · 결과를 돌려줌. */ export async function saveRouteDraft( projectId: string, body: RouteSaveBody, ): Promise { const saved = await replanRoute(projectId, body); routeSaved(projectId); return saved; } /** 노선을 저장함(서버가 다 저장한 뒤) — 묶음의 초안 전 관 · 구조물 버림(초안 비움을 「버림」으로 되돌리지 않게 · * 저장한 초안 자리가 정본) · 가게 노선 초안 비움 · 조회 결과 캐시 비움(옛 노선 것 · 추천은 새 노선의 출발점이라 * 다시 적음) · 편집기 「저장됨」. */ export function routeSaved(projectId: string): void { commitRouteFacilities(projectId); useDesignStore(projectId, null).set("routeDraft", null); clearResults(projectId); sources.get(projectId)?.saved(); } /** 저장 결과 한 줄 — 관 지킴 · 새 교차 관 · 구조물 옮김 · 멀어짐 · 노선 밖 고침 · 배수유역 낡음. */ export function routeSaveSummary(saved: RouteReplanResponse, outside = 0): string { const parts = [ `관 지킴 ${saved.pipes?.kept ?? 0}`, `새 교차 관 ${saved.pipes?.crossing ?? 0}`, `구조물 옮김 ${saved.structures?.moved ?? 0}`, `멀어짐 ${saved.structures?.far ?? 0}`, ]; const dropped = (saved.cross_dropped ?? 0) + outside; if (dropped) parts.push(`노선 밖 고침 ${dropped} 건`); if (saved.drainage_stale) parts.push("배수유역 낡음"); return `노선 저장 — ${parts.join(" · ")}`; } /** * 노선 바뀐 [저장]의 횡단 조각(472-6 6-B) — 옛 노선 세션 고침 원본(측점 열쇠 칸 · 누가거리 칸 · 열쇠 목록 · 관 장부)을 * `carry` 로 · 횡단 본문 = 표준 횡단면 · 풍선 자리(옛 `saveCachedCrossPatches` 와 같은 칸). 새 측점 patch 는 서버가 * 같은 TS 로 셈(Node `stations.carry`) · 고침이 없어도 한 번 나감(429 ④). 손 상단측은 안 보냄(요구 274 · 새 지형이 * 정함) — 다 저장한 뒤 세션 상단측 · 관 종류 바꿈 자리를 비우고 버린 상단측 수를 알림. */ export function routeCarryPart( projectId: string, fromRouteId: number, ): { carry: Record; cross: SavePart } { const standard = readStandardCrossSession(projectId) as StandardCrossSection | null; const uphill = Object.keys(readState("uphill", projectId) ?? {}).length; return { carry: routeCarryInputs(projectId, fromRouteId), cross: { body: sectionConfirmBody(standard ?? undefined, undefined, { balloon_offsets: balloonOffsetsPayload(), }), after: () => { clearState("kindreset", projectId); // 비울 자리를 보냈음(318-4) clearState("uphill", projectId); if (uphill) showToast(`상단측 손 고침 ${uphill}곳 — 노선이 바뀌어 새 지형으로 정했습니다.`, "info"); }, }, }; } /** 저장 뒤 페이지를 새로고침 없이 다시 세운다 — 편집기가 걸어 둔 길(옛 노선 캐시 비움 포함) · 없으면 라우터. */ export async function rebuildAfterRouteSave(projectId: string): Promise { const source = sources.get(projectId); if (source) return source.rebuild(); clearRouteLatestCache(projectId); invalidateSectionDetail(projectId); remountRoute(currentRoute()); } /** * 추천 [적용] — 받은 노드로 서버 노선을 바로 저장하고(`as_initial` · 초기값 = 추천 노선) 페이지를 다시 * 세운다. 잠금 막 = 「노선 재탐색」 자리 하나. 새 초기값에서 다시 시작하므로 초안은 비운다([초기화]와 같음). * 실패하면 false — 서버가 옛 노선으로 되살렸고 화면 노선은 그대로. */ export async function saveRecommendedRoute( projectId: string, nodes: RouteNodes, initial: unknown, balancedProfile: boolean, ): Promise { const settings = sources.get(projectId)?.settings() ?? { stationSnap: false, terrain: null }; showLoadingOverlay(); try { await saveRouteDraft(projectId, { // 383-1 — 추천 선은 곡선 MC 를 일반측점에 맞춘 선 → [측점 스냅] 켬 · 측점 No.(390-5)로 저장 draft: routeDraftOf(nodes, { ...settings, stationSnap: true, stationSnapStep: null }), initial, balanced_profile: balancedProfile, as_initial: true, }); clearDrafts(projectId); sources.get(projectId)?.saved(); showToast("추천 노선을 저장했습니다 — 새 초기값입니다.", "success"); await rebuildAfterRouteSave(projectId); return true; } catch (error) { showToast( `추천 노선 저장에 실패했습니다.${error instanceof Error ? ` ${error.message}` : ""}`, "error", ); return false; } finally { hideLoadingOverlay(); } }