/* ============================================================================= * B05_Profile_Api_Fetch.ts * 2차 워크플로우(경로 설계) API 클라이언트 * * 백엔드 계약 (B05_Profile_Router.py): * POST /api/projects/{project_id}/route/solve → 경로 탐색 + DB 기록 * POST /api/projects/{project_id}/route/confirm → 최신 경로 확정 * * 규칙: * - 모든 제어 상수는 config_frontend에서 참조 (하드코딩 금지). * - 오류 응답 형식 {status:"error", message:"..."}을 Error로 변환. * ========================================================================== */ import { clearState, readState, writeState } from "../A00_Common/b_page_state"; import { API_ANALYSIS_TIMEOUT_MS, API_BASE_URL, API_TIMEOUT_MS } from "@config/config_frontend"; /** 경로 제어점 (BP/EP/CP) */ export interface RoutePoint { x: number; y: number; z?: number; order?: number; } export interface CirclePoint extends RoutePoint { radius_m: number; } /** 경로 탐색 실행 요청 (RouteSolveRequest) */ export interface RouteSolveRequest { filter_key: string; method?: string; smooth?: boolean; surface_model_id?: number | null; algorithm?: string; bp: RoutePoint; ep: RoutePoint; cp?: RoutePoint[]; ap?: CirclePoint[]; fp?: CirclePoint[]; grade_class?: string; paved?: boolean; min_curve_radius_m?: number | null; max_uphill_grade?: number | null; max_downhill_grade?: number | null; min_uphill_grade?: number | null; min_downhill_grade?: number | null; allow_avoid_pass_through?: boolean; station_interval_m?: number | null; cross_half_width_m?: number | null; cross_sample_interval_m?: number | null; long_sample_interval_m?: number | null; terrain_type?: string; /** 설계속도(km/h) — 종단 법정 기준 축. 임도 기본 20(2026-08-19). */ design_speed_kph?: number | null; main_direction?: string; max_grade_pct?: number | null; min_vertical_radius_m?: number | null; min_tangent_length_m?: number | null; balance_segment_length_m?: number | null; start_elevation_offset_m?: number | null; end_elevation_offset_m?: number | null; /** 횡단배수 최소 계획고 강제 — 기본 해제(2026-09-01 사용자 지시). */ enforce_pipe_clearance?: boolean | null; } /** 계획선 산출 요약 (실패 시 null) */ export interface RouteGradeSummary { id: string; cut_area_m2: number; fill_area_m2: number; balance_error_m2: number; max_grade_pct: number; vertical_curve_count: number; pvi_count: number; balance_segment_count: number; balanced: boolean; main_direction: string; suggested_elevation_offset_m: number | null; warnings: string[]; } /** 경로 탐색 실행 결과 (RouteSolveResponse) */ export interface RouteSolveResponse { status: string; project_id: string; route_id: number; total_length_m: number; metrics: Record; required_points_ok: boolean; route_data_path: string; longitudinal_length_m: number | null; cross_section_count: number | null; grade_summary: RouteGradeSummary | null; } /** 경로 확정 결과 (RouteConfirmResponse) */ export interface RouteConfirmResponse { status: string; project_id: string; route_id: number; confirmed: boolean; } export interface RouteLatestResponse { status: string; project_id: string; route: { id: number; status: string; surface_model_id: number | null; total_length_m: number | null; min_slope: number | null; max_slope: number | null; mean_slope: number | null; cost_score: number | null; algorithm_params?: Record | null; } | null; route_points: Array; surface_params: { source_filter: string; method: string; smooth: boolean; contour_interval_m: number; }; route_params: { points?: { bp?: RoutePoint | null; ep?: RoutePoint | null; cp?: RoutePoint[]; ap?: CirclePoint[]; fp?: CirclePoint[]; }; options?: Record; algorithm?: string; station_interval_m?: number | null; cross_half_width_m?: number | null; cross_sample_interval_m?: number | null; long_sample_interval_m?: number | null; max_grade_pct?: number | null; min_vertical_radius_m?: number | null; min_tangent_length_m?: number | null; balance_segment_length_m?: number | null; start_elevation_offset_m?: number | null; end_elevation_offset_m?: number | null; enforce_pipe_clearance?: boolean | null; } | null; } /** 공통 fetch 헬퍼: 타임아웃 + 인증 헤더 + 오류 응답 변환. * * `timeoutMs`를 주면 그 값으로 끊는다. 격자 해석처럼 오래 걸리는 요청은 * `API_ANALYSIS_TIMEOUT_MS`를 넘긴다 — 기본값으로 두면 계산 도중 abort 된다. */ async function requestJson( path: string, init: RequestInit, timeoutMs: number = API_TIMEOUT_MS, ): Promise { const controller = new AbortController(); const timeoutId = 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; } catch (error) { // AbortError 원문("signal is aborted without reason")은 원인을 알 수 없으니 바꿔 준다. if (error instanceof DOMException && error.name === "AbortError") { throw new Error(`요청이 ${Math.round(timeoutMs / 1000)}초 안에 끝나지 않았습니다.`); } throw error; } finally { window.clearTimeout(timeoutId); } } /** 경로 탐색을 실행한다 (비용면 생성 → Dijkstra/ridge-valley → GeoJSON 저장). */ export async function solveRoute( projectId: string, request: RouteSolveRequest, ): Promise { return requestJson(`/projects/${projectId}/route/solve`, { method: "POST", body: JSON.stringify(request), }); } /** 등고선 간격 재적용 값을 서버(stage 1 params)에 영속화한다. */ export async function updateContourInterval( projectId: string, contourIntervalM: number, ): Promise<{ status: string; contour_interval_m: number }> { return requestJson<{ status: string; contour_interval_m: number }>( `/projects/${projectId}/route/contour-interval`, { method: "PUT", body: JSON.stringify({ contour_interval_m: contourIntervalM }) }, ); } /** 종단 계획선 편집 델타 (자동 선형 대비 측점 계획고 델타 + 종단곡선 반경). */ export interface ProfileAlignmentEdits { station_offsets: Record; curve_radii: Record; } export interface ProfileAlignmentSaveResponse { status: string; project_id: string; route_id: number; profile_alignment: unknown; grade_summary: RouteGradeSummary | null; } /** * 종단 계획선 사용자 편집을 영속화한다. * 화면은 즉시 계산해 보여주고, 여기서 **편집 델타만** 보내면 서버가 저장된 자동 * 선형에 다시 얹어 정본(longitudinal.json)을 만든다. */ export async function saveProfileAlignment( projectId: string, routeId: number, edits: ProfileAlignmentEdits, ): Promise { return requestJson( `/projects/${projectId}/route/profile-alignment`, { method: "PUT", body: JSON.stringify({ route_id: routeId, ...edits }), }, ); } /** 확정 시 비정규 측점 횡단을 생성하기 위한 입력(빈 값이면 확정만 한다). */ export interface RouteConfirmRequest { filter_key?: string; method?: string; smooth?: boolean; surface_model_id?: number; irregular_stations?: Array<{ chainage_m: number; structure: string }>; /** 측점 상단측(=측구 방향) 사용자 변경분 — 3D 램프 클릭으로 지정. */ uphill_overrides?: Array<{ chainage_m: number; side: "left" | "right" }>; } /** 프로젝트의 최신 경로를 확정한다. 비정규 측점이 있으면 그 횡단까지 생성한다. * `markStageComplete=false`는 [임시저장]용 — 데이터(경로 CONFIRMED·비정규 횡단·상단측 * 병합)는 그대로 저장하되 워크플로 stage 2 완료 전이를 하지 않는다(2026-08-08 재정의). */ export async function confirmRoute( projectId: string, body: RouteConfirmRequest = {}, markStageComplete = true, ): Promise { const query = markStageComplete ? "" : "?mark_stage_complete=false"; return requestJson(`/projects/${projectId}/route/confirm${query}`, { method: "POST", body: JSON.stringify(body), }); } /** 세션에 쌓인 상단측(측구 방향) 변경분을 정본으로 내보낸다 — B06 [저장]·[확정]용. * 3D 램프 클릭은 B05 화면에서만 생기지만 저장 버튼은 B06 에도 있다(B05·B06 은 한 페이지). * B06 에서 저장하면 이 값이 세션에만 남아 확정 뒤 옛 측구 방향이 그대로 쓰였다 * (2026-09-06 대응표 조사). 비어 있으면 요청을 내지 않는다. */ export async function flushUphillOverrides(projectId: string): Promise { const stored = readState>("uphill", projectId); const overrides = Object.entries(stored ?? {}) .filter(([, side]) => side === "left" || side === "right") .map(([chainage, side]) => ({ chainage_m: Number(chainage), side })); if (!overrides.length) return; await confirmRoute(projectId, { uphill_overrides: overrides }, false); } /** [초기화] 응답 — 초기 자동 계산 상태로 재구성된 경로. */ export interface RouteResetResponse { status: string; project_id: string; route_id: number; deleted_routes: number; /** 초기값 스냅샷을 되돌렸으면 true. false면 스냅샷이 없어 재계산으로 폴백한 것이다. */ restored?: boolean; } /** [초기화] — 사용자 편집을 전부 버리고 초기값으로 되돌린다. 초기값 스냅샷이 있으면 * 복원이라 빠르지만, 없는 옛 프로젝트는 재계산으로 폴백하므로 분석용 타임아웃을 쓴다. * 초기 설계가 실패로 끝난 프로젝트는 서버가 409로 거부한다 — 되돌릴 기준이 없어 * 재계산으로 얼버무리지 않는다(2026-09-02). 그 안내 문구가 그대로 오류 토스트에 뜬다. */ export async function resetRouteDesign(projectId: string): Promise { return requestJson( `/projects/${projectId}/route/reset`, { method: "POST" }, API_ANALYSIS_TIMEOUT_MS, ); } export async function fetchLatestRoute(projectId: string): Promise { return requestJson(`/projects/${projectId}/route/latest`, { method: "GET", }); } /* 최신 경로 응답은 ④ 계산 결과다 — 키·이관은 등록표(`b_page_state`)가 맡는다. B04에서 지표면을 다시 확정하면 옛 확정값이 남아 B05가 이전 지형을 그리므로, 확정 직후 `clearRouteLatestCache`로 버린다. */ /** 세션 캐시에서 최신 경로 응답을 읽는다. 없거나 깨졌으면 null(다음 진입은 DB 조회). */ export function readRouteLatestCache(projectId: string): RouteLatestResponse | null { return readState("latest", projectId); } /** 최신 경로 응답을 세션 캐시에 넣는다. 용량 초과 등으로 실패해도 화면은 그대로 돈다. */ export function writeRouteLatestCache(projectId: string, value: RouteLatestResponse): void { writeState("latest", value, projectId); } export function clearRouteLatestCache(projectId: string): void { clearState("latest", projectId); }