/* ============================================================================= * B05_Profile_Api_Replan.ts * 계획노선 두 벌(예상노선·계획노선) 읽기와 노선 갈아 끼우기 요청. * * GET /projects/{id}/route/plan → 예상노선·계획노선 정점(사업지 좌표계 m) * POST /projects/{id}/route/replan → 노선 초안 저장(346-3 · draft = 계약 RouteDraft · as_initial) * POST /projects/{id}/route/replan/reset → 계획노선을 예상노선으로 되돌리고 재계산 * GET /projects/{id}/route/terrain-sources → 고를 수 있는 분석 지형(라이다 · 수치지형도) * * 편집 창 분석(지반고 · 횡단 미리보기 · 추천)은 **고른 분석 지형**(`terrain`)을 실어 * 보낸다(PLAN 133-1) — [확인] 재계산은 확정 지표면 그대로라 안 실음. * * 재계산은 배수유역부터 전 단계를 다시 도는 무거운 작업이라(용화 67측점 기준 3분대) * 타임아웃을 길게 잡는다 — 기본값으로 두면 중간에 끊긴다. * ========================================================================== */ import { API_BASE_URL } from "@config/config_frontend"; import { readState, writeState } from "../A00_Common/b_page_state"; import { heightAt } from "../common_util/common_util_surface_sampler"; import { loadDesignTerrainGrid } from "./B05_Profile_Api_TerrainGrid"; import type { RouteDraft } from "../A00_Common/design/logic/design_pipeline_types"; /** 편집 창 분석 지형 — las = 확정 지표면(라이다) · sheet = 수치지형도 서피스. */ export type AnalysisTerrain = "las" | "sheet"; /** 편집기가 열 때 알리는 지형 사정(352-3) — 저장하지 않음. 고를 수 있나(라이다 · 수치지형도 둘) · 설계 지형. */ const terrainContext = new Map(); export const setTerrainContext = ( projectId: string, choosable: boolean, design: AnalysisTerrain, ): void => void terrainContext.set(projectId, { choosable, design }); /** 지금 쓰는 분석 지형 — 하나뿐인 프로젝트는 그것 · 둘이면 사용자가 고른 값(화면 상태 보관소 pref 통 · * 노선 편집 고정과 같은 자리), 고른 적 없으면 데이터대로(= 설계 지형 · 라이다가 있으면 라이다). * 고른 값은 [저장] 때 설계 지형이 됨(PLAN 133-2). */ export const analysisTerrain = ( projectId: string, fallback: AnalysisTerrain = "las", ): AnalysisTerrain => { const context = terrainContext.get(projectId); if (context && !context.choosable) return context.design; const saved = readState("routeedit-terrain", projectId); return saved === "sheet" || saved === "las" ? saved : (context?.design ?? fallback); }; /** 사용자가 고를 때만 기억 — 열 때 기본값은 쓰지 않음. */ export const setAnalysisTerrain = (projectId: string, terrain: AnalysisTerrain): void => writeState("routeedit-terrain", terrain, projectId); /** 이 프로젝트가 고를 수 있는 분석 지형(둘이면 고르기를 띄운다)과 지금 설계 지형. */ export async function fetchTerrainSources( projectId: string, ): Promise<{ sources: AnalysisTerrain[]; design: AnalysisTerrain }> { const payload = await requestJson<{ sources?: AnalysisTerrain[]; design?: AnalysisTerrain }>( `/projects/${projectId}/route/terrain-sources`, { method: "GET" }, 60000, ); return { sources: payload.sources ?? [], design: payload.design === "sheet" ? "sheet" : "las" }; } /** 노선 재계산 대기 상한 — 배수유역 분석(90초대)까지 포함해 넉넉히 잡는다. */ const REPLAN_TIMEOUT_MS = 15 * 60 * 1000; /** 사용자가 잡아 옮기는 제어점 하나 — 서버가 이 노드로 폴리라인을 만든다. */ export interface RoutePlanNode { x: number; y: number; /** 직전·직후 구간이 이루는 내각(도). 끝점은 null. */ inner_angle_deg: number | null; /** 이 자리에 끼운 원호 반지름(m). 곡선을 지운 자리는 null. */ radius_m: number | null; tangent_m: number | null; /** 법정 기준 위반 표시 — 값은 내되 막지 않는다. */ violations: string[]; } /** 직선 사이에 놓인 **곡선 성분 하나** — 화면이 손잡이와 R 칸을 그리는 재료. */ export interface RoutePlanCurve { /** 앞뒤 직선을 늘려 만나는 자리(교각점). **반지름을 바꿔도 여기는 안 움직인다.** */ apex: [number, number]; radius_m: number; tangent_m: number; inner_angle_deg: number; /** 곡선 시작점 — 직선이 곡선에 닿는 자리. 사용자가 잡는 손잡이다. */ start: [number, number]; /** 곡선 끝점. */ end: [number, number]; /** 이 곡선이 대신하는 꺾임점 구간(첫·끝) — 편집이 어느 노드를 건드리는지 알려 준다. */ node_first: number; node_last: number; violations: string[]; } export interface RoutePlanResponse { status: string; project_id: string; /** 예상노선(원본) **점 묶음** [[x, y], …] — 사업지 좌표계(m). 폴리라인이 아니다. */ expected: Array<[number, number]>; /** 계획노선 폴리라인(원호 포함) — 그려 보이는 선. 잡는 대상이 아니다. */ planned: Array<[number, number]>; /** 잡아 옮기는 노드(꺾임점). 편집은 이것으로 한다(2026-09-06 사용자 지시). */ nodes: RoutePlanNode[]; /** 직선·곡선 성분 — 곡선 시작·끝점과 반지름. 화면이 이것으로 손잡이를 그린다. */ curves: RoutePlanCurve[]; /** 이 프로젝트에 적용한 법정 최소곡선반지름(m) — **기본값·위반 표시 기준**. */ min_radius_m: number; /** **못 넘는** 곡선반지름 하한(m). 0이면 제한 없음(작업임도). 기본값과 다른 값이다. */ limit_radius_m?: number; /** **못 넘는** 곡선 길이 하한(m). 0이면 제한 없음 — 지금은 전부 0(법에 값이 없음). */ limit_curve_length_m?: number; curve_count: number; violation_count: number; /** 사용자가 고친 계획노선이 저장돼 있으면 true. */ edited: boolean; /** [측점 스냅] 켬(요구 224-2) — [확인] 때 곡선표에 적은 값. 칸 없으면 끔. */ station_snap?: boolean; /** 현재 초기값 — 서버가 [확인] 때 프로젝트에 적어 둔 초기노선 갈래(계획서 20). */ current_initial?: { logic: string; weights: Record | null }; /** 전처리가 만든 초기 노선(예상노선 추적)의 노드 · 곡선 — [초기화]가 재계산 없이 놓는다. */ initial_route?: { nodes: RoutePlanNode[]; curves: RoutePlanCurve[] }; } export interface RouteReplanResponse { status: string; project_id: string; route_id: number | null; total_length_m: number | null; vertex_count?: number; /** 노선 저장 결과(346-3 · 서버 `B05_Profile_Route_Save` 머리) — 관 지킴 · 새 교차 · 오목 보충. */ pipes?: { kept: number; crossing: number; concave: number }; structures?: { moved: number; far: number }; /** 새 노선에 못 넘긴 횡단 고침(측점이 사라짐) 수. */ cross_dropped?: number; /** 배수유역 저장분이 새 노선과 안 맞음(다시 안 셈). */ drainage_stale?: boolean; initial_refreshed?: boolean; } /** 노선 저장 본문 — `draft` 는 계약 `RouteDraft` 그대로 · `as_initial` = 추천 [적용](초기값 다시 뜸). */ export interface RouteSaveBody { draft: RouteDraft; initial?: unknown; balanced_profile?: boolean; as_initial?: boolean; } export async function requestJson( path: string, init: RequestInit, timeoutMs: number, ): Promise { const controller = new AbortController(); const timer = window.setTimeout(() => controller.abort(), timeoutMs); try { const response = await fetch(`${API_BASE_URL}${path}`, { ...init, credentials: "include", headers: { "Content-Type": "application/json", ...(init.headers ?? {}) }, signal: controller.signal, }); const payload = (await response.json()) as T & { message?: string }; if (!response.ok) throw new Error(payload.message ?? `HTTP ${response.status}`); return payload; } finally { window.clearTimeout(timer); } } /** 예상노선·계획노선을 함께 읽는다(편집 모달이 점선·실선으로 그린다). */ export async function fetchRoutePlan(projectId: string): Promise { return requestJson( `/projects/${projectId}/route/plan`, { method: "GET" }, 60000, ); } /** 꺾임점 하나에 실어 보내는 편집값 — 곡선을 둘지, 반지름을 못박을지. */ export interface RouteReplanVertex { x: number; y: number; /** 이 자리에 곡선을 둘지. 끄면 직선이 그대로 꺾인다(곡선 삭제). */ curve?: boolean; /** 못박을 반지름(m). 없으면 서버가 고른다. */ radius_m?: number | null; } /** 노선 초안을 정본으로 저장한다(346-3) — 서버가 노선 → 관 · 구조물 따라감 → 종횡단 → 계획고 옮김 → * 횡단 이월 → B06 확정까지 돈다. 실패(409)면 서버가 옛 노선으로 되살리고 오류 글을 낸다. * * 편집 세 가지가 모두 초안 한 벌로 나간다(2026-09-07 사용자 지시) — **직선 삭제·추가**는 노드를 빼거나 * 더하는 것, **곡선 삭제·추가**는 `curveOn` 을 끄고 켜는 것, **반지름 변경**은 `curveRadius`. */ export async function replanRoute( projectId: string, body: RouteSaveBody, ): Promise { return requestJson( `/projects/${projectId}/route/replan`, { method: "POST", body: JSON.stringify(body) }, REPLAN_TIMEOUT_MS, ); } /** 점 묶음의 **지반고**를 묻는다(계획서 0-9 ⑤·⑧). * * 새 계산이 아니라 확정된 지표면을 **읽기만** 하므로 편집 중에 불러도 된다 — 다만 끄는 동안 * 프레임마다 부르지는 않는다(찍는 순간에만). 지표면 밖은 `null` 로 온다. * 설계 지형 격자를 받아 두었고 분석 지형이 그것과 같으면 **브라우저 표본**(서버와 같은 값 · * PLAN 344-1) — 아니면 서버에 물음. 자릿수는 서버 응답과 같게 mm. */ export async function fetchRouteElevations( projectId: string, points: Array<[number, number]>, ): Promise> { const local = await loadDesignTerrainGrid(projectId); if (local && analysisTerrain(projectId) === local.design) { return points.map(([x, y]) => { const z = heightAt(local.grid, x, y); return Number.isNaN(z) ? null : Math.round(z * 1000) / 1000; }); } const payload = await requestJson<{ z: Array }>( `/projects/${projectId}/route/elevations`, { method: "POST", body: JSON.stringify({ points, terrain: analysisTerrain(projectId) }) }, 60000, ); return payload.z; } /** 횡단 미리보기 한 장 — 고치던 노선 그대로 그 측점만 서버가 셈해 준다(계획서 0-9 ⑧). */ export interface CrossPreviewResponse { status: string; chainage_m: number; label: string | null; uphill_side: string | null; plan_radius_m: number | null; curve_widening_m: number | null; /** 샘플이 덮은 횡단 반폭(m) — 요청값, 비었으면 서버 기본값. */ cross_half_width_m: number; /** 원지반 횡단 샘플. */ samples: Array<{ offset_m?: number; elevation_m?: number | null; valid: boolean }>; /** 기본 계획 횡단 — B06 `compute_cross_design` 이 낸 것. 계획고를 못 세우면 null. */ design: { design_line: Array<{ offset_m: number; elevation_m: number }>; cut_area_m2: number; fill_area_m2: number; [key: string]: unknown; } | null; } export interface CrossPreviewRequest { vertices: Array<{ x: number; y: number; curve: boolean; radius_m: number | null }>; chainage_m: number; min_radius_m: number; station_interval_m: number; /** 횡단 반폭(m) — 없으면 서버 기본값. */ cross_half_width_m?: number; /** [측점 스냅] 켬 — 호 안 측점 하나를 곡선 중점으로(요구 224-2). */ station_snap?: boolean; } /** 한 측점 횡단을 묻는다. 종·횡단을 한 번 돌리므로 **한두 초** 걸린다(사용자 확정: 괜찮음). */ export async function fetchCrossPreview( projectId: string, request: CrossPreviewRequest, ): Promise { return requestJson( `/projects/${projectId}/route/cross-preview`, { method: "POST", body: JSON.stringify({ ...request, terrain: analysisTerrain(projectId) }), }, 120000, ); } /** 계획노선을 예상노선으로 되돌리고 같은 재계산을 돈다(노선 초기화). */ export async function resetRoutePlan(projectId: string): Promise { return requestJson( `/projects/${projectId}/route/replan/reset`, { method: "POST" }, REPLAN_TIMEOUT_MS, ); }