/* ============================================================================= * B04_PreProcess_Api_Fetch.ts * 1차 워크플로우(지표면 분석) API 클라이언트 * * 백엔드 계약 (B04_PreProcess_Router.py): * POST /api/projects/{project_id}/surface/analyze → 분석 실행 + DB 기록 * GET /api/projects/{project_id}/surface/models → 모델 목록 조회 * * 규칙: * - 모든 제어 상수는 config_frontend에서 참조 (하드코딩 금지). * - 오류 응답 형식 {status:"error", message:"..."}을 Error로 변환. * ========================================================================== */ import { API_ANALYSIS_TIMEOUT_MS, API_BASE_URL, API_SURFACE_BUILD_TIMEOUT_MS, API_TIMEOUT_MS, } from "@config/config_frontend"; /** 지표면 분석 실행 요청 (SurfaceAnalyzeRequest) */ export interface SurfaceAnalyzeRequest { input_file_id: number; source_filters?: string[]; methods?: string[]; force?: boolean; } /** 지표면 분석 실행 결과 (SurfaceAnalyzeResponse) */ export interface SurfaceAnalyzeResponse { status: string; project_id: string; ground_summary: Record; manifest_status: string; surface_model_ids: number[]; } export interface SurfaceConfirmResponse { status: string; project_id: string; model_id: number; confirmed: boolean; } export interface SurfaceConfirmOptions { smooth: boolean; contour_interval_m: number; } /** 저장된 지표면 모델 요약 (SurfaceModelSummary) */ export interface SurfaceModelSummary { id: number; model_type: string; status: string; resolution_m: number | null; model_file_path: string | null; generation_params: Record | null; created_at: string | null; } export interface SurfaceInputFileSummary { id: number; file_type: string; original_filename: string; raw_file_path: string; file_size_mb: number | null; crs_epsg: number | null; status: string | null; created_at: string | null; } export interface SurfaceInputFileListResponse { status: string; project_id: string; files: SurfaceInputFileSummary[]; } export interface SurfaceBounds { x_min: number; x_max: number; y_min: number; y_max: number; z_min: number; z_max: number; } export interface SurfacePointCloudSampleResponse { status: string; project_id: string; point_count: number; sampled_count: number; bounds: SurfaceBounds; points: [number, number, number][]; rgb?: [number, number, number][]; } export interface SurfaceGroundStatsResponse { status: string; project_id: string; filters: Record>; } export interface SurfaceStatusResponse { project_id: string; status: "pending" | "in_progress" | "completed" | "failed"; model_count: number; progress_percent: number; current_stage: string; message: string; } /** 지표면 모델 목록 응답 (SurfaceModelListResponse) */ export interface SurfaceModelListResponse { status: string; project_id: string; models: SurfaceModelSummary[]; } /** 확정 지표면 요약 (SurfaceConfirmedResponse). * 포인트 배열 없이 확정 구성과 지형 가장자리만 담는다 — 진입 판정·준비화면·B05 공용. */ export interface SurfaceConfirmedResponse { status: string; project_id: string; model_id: number | null; source_filter: string | null; method: string | null; smooth: boolean | null; contour_interval_m: number | null; /** 확정 구성이 바뀌었는지 한 줄로 비교하기 위한 값. */ signature: string; point_count: number | null; bounds: { x_min: number; x_max: number; y_min: number; y_max: number; z_min: number; z_max: number; } | null; /** 계획노선(B03 정본)의 평면 범위. 지도 초기 화면을 도로 중심으로 맞출 때 쓴다. */ route_bounds: { x_min: number; x_max: number; y_min: number; y_max: number } | 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); } } /** 지표면 분석을 실행한다 (LAS 구조화 → 지면 필터 → 지표면 모델 생성). */ export async function analyzeSurface( projectId: string, request: SurfaceAnalyzeRequest, ): Promise { // 조합 하나를 새로 만드는 요청 — 실측 114초(4,900만 점)라 분석용 60초로도 abort 된다. return requestJson( `/projects/${projectId}/surface/analyze`, { method: "POST", body: JSON.stringify(request) }, API_SURFACE_BUILD_TIMEOUT_MS, ); } /** 도엽 보간 방식 목록 — `built`가 false면 아직 만들지 않은 방식이다. */ export interface SheetMethodListResponse { status: string; methods: Array<{ key: string; label: string; built: boolean }>; } export async function listSheetMethods(projectId: string): Promise { return requestJson(`/projects/${projectId}/surface/sheet-methods`, { method: "GET", }); } /** 도엽 서피스 한 방식을 만들어 등록한다. 조합 생성이라 타임아웃을 길게 준다. */ export async function buildSheetSurface( projectId: string, method: string, ): Promise<{ status: string; method: string; surface_model_ids: number[] }> { return requestJson( `/projects/${projectId}/surface/sheet-surface`, { method: "POST", body: JSON.stringify({ method }) }, API_SURFACE_BUILD_TIMEOUT_MS, ); } /** 프로젝트의 지표면 모델 목록을 조회한다. */ export async function listSurfaceModels(projectId: string): Promise { return requestJson(`/projects/${projectId}/surface/models`, { method: "GET", }); } /** 선택한 지표면 모델을 확정하고 WF1 단계를 완료한다. */ export async function confirmSurfaceModel( projectId: string, modelId: number, options: SurfaceConfirmOptions, ): Promise { return requestJson(`/projects/${projectId}/surface/confirm`, { method: "POST", body: JSON.stringify({ model_id: modelId, ...options }), }); } export async function listSurfaceInputFiles( projectId: string, ): Promise { return requestJson(`/projects/${projectId}/surface/input-files`, { method: "GET", }); } export async function fetchSurfacePointCloud( projectId: string, filter?: string, ): Promise { const query = filter ? `?filter=${encodeURIComponent(filter)}` : ""; return requestJson( `/projects/${projectId}/surface/point-cloud${query}`, { method: "GET", }, ); } /** 확정 지표면 구성 + 지형 가장자리만 조회한다(수 KB). * 포인트클라우드 전체(수십 MB)를 받지 않고도 3D 좌표 환산에 필요한 값을 얻는다. */ export async function fetchConfirmedSurface(projectId: string): Promise { return requestJson(`/projects/${projectId}/surface/confirmed`, { method: "GET", }); } export async function fetchSurfaceGroundStats( projectId: string, ): Promise { return requestJson(`/projects/${projectId}/surface/ground-stats`, { method: "GET", }); } export async function fetchSurfaceStatus(projectId: string): Promise { return requestJson(`/projects/${projectId}/surface/status`, { method: "GET", }); } export interface VWorldMeta { x_min: number; x_max: number; y_min: number; y_max: number; width_meters: number; height_meters: number; center_x: number; center_y: number; lon_min: number; lon_max: number; lat_min: number; lat_max: number; } export function getVWorldMapUrl(projectId: string, layerName: string): string { return `${API_BASE_URL}/projects/${projectId}/vworld-map?layer_name=${layerName}`; } export async function fetchVWorldMeta(projectId: string, layerName: string): Promise { return requestJson(`/projects/${projectId}/vworld-meta?layer_name=${layerName}`, { method: "GET", }); } export async function fetchGisGeoJson(projectId: string, layer: string): Promise { return requestJson(`/projects/${projectId}/geojson?layer=${encodeURIComponent(layer)}`, { method: "GET", }); } /** 계획노선(B03 정본)의 평면 점 목록. 사업지 좌표계(m) — 배경 지도 메타와 같은 좌표계다. */ export interface PlannedRouteResponse { status: string; points: Array<{ x: number; y: number }>; } /** 2D 지도에 계획선을 겹쳐 그리기 위한 점 목록을 받는다. 없으면 빈 목록이 온다. */ export async function fetchPlannedRoute(projectId: string): Promise { return requestJson(`/projects/${projectId}/planned-route`, { method: "GET", }); } /* ── 배수유역 분석 (B04_PreProcess_Router_Watershed.py) ──────────────────── * 관리자 확인용. 계획 노선(B03 정본) + 도엽 등고선·세류선으로 유역을 끝까지 분석하고 * 결과를 영구저장소에 남긴다. 30초 안팎이 걸리므로 여기서 한 번만 돌린다. * ------------------------------------------------------------------------ */ /** 관 매설 지점 1개. reason: stream=세류 교차, spacing=간격 보충, confirmed=사용자 확정. */ export interface WatershedPipe { chainage_m: number; x: number; y: number; lon: number; lat: number; reason: string; stream_name: string | null; } /** 1차 배수유역 근거(단계 검증용). TIN·흐름 계산 없이 세류 상·하류 판정과 격자 범위만 준다. */ export interface WatershedAnalysis { status: string; project_id: string; /** 분석에 쓴 계획 노선 파일명(B03 업로드). */ route_source: string; radius_m: number; /** 도로와 만난 세류선의 상류측 = 1차 영역의 기준선. */ upstream_lines: Array>; /** 교차했으나 하류로 판정해 제외한 조각. 판정이 맞는지 눈으로 대조하는 용도. */ downstream_lines: Array>; /** 상·하류 어느 망에도 이어지지 않아 제외한 세류 조각 수. */ no_contact_count: number; /** 노선이 1차 영역 밖으로 나간 길이(m). 크면 반경을 올려야 한다는 신호. */ road_outside_m: number; /** 1차 영역(상류 세류망 버퍼 합집합)의 외곽 링 목록. */ region_rings: Array>; grid: { cell_m: number; rows: number; cols: number; /** bbox 전체 셀 수(참고값). */ bbox_cells: number; /** 1차 영역에 걸쳐 실제로 생성된 셀 수. */ cells: number; width_m: number; height_m: number; /** 격자 bbox 링. 화면은 이 사각형을 rows×cols로 나눠 셀 좌표를 얻는다. */ bbox_lonlat: Array<[number, number]>; /** 실제 생성된 셀 구간 [행, 시작열, 끝열(포함)]. 낱개 셀 대신 구간으로 온다. */ row_spans: Array<[number, number, number]>; }; /** 최외곽 적색 셀 주변 확장 결과. */ expansion: { rounds: number; /** 새로 추가한 셀에 적색이 없어 스스로 멈췄는가. */ closed: boolean; added_cells: number; /** 확장 전(1차 영역) 셀 수. */ initial_cells: number; }; /** 셀별 흐름 방향과 도로 도달 여부. 등고선이 없어 판정을 못하면 null. */ flow: { encoding: "base64-uint8"; /** 방위 분해능(32). 코드 0 = 화면 오른쪽, 시계방향 증가. */ azimuth_steps: number; /** 제자리(더 낮은 이웃 없음)를 뜻하는 코드. */ sink_code: number; /** 표고가 없어 판정 못한 셀 코드. */ invalid_code: number; cells: number; reaches_road: number; no_road: number; /** 격자에는 있으나 등고선 TIN 밖이라 표고가 없어 판정 못한 셀. */ unanalyzed: number; /** 확정된 상류 세류망을 따라 흐름을 강제로 새긴 셀 수. */ burned: number; outer_seeds: number; interior_seeds: number; /** 셀당 1바이트. 하위 6비트=32방위 코드(32=제자리, 33=무효), 0x80=도로 도달. * 순서는 grid.row_spans를 행 → 구간 → 열 오름차순으로 훑은 순서와 같다. */ data: string; } | null; /** 2차 전체 배수유역 외곽선(= 분수령). 적색 셀 전체의 외곽. */ basin_polygon_lonlat: Array<[number, number]>; basin_area_m2: number; /** 도로 위 흐름 강도 — [누가거리 m, 그 구간으로 모이는 상류 면적 ㎡]. * 1m 간격 **구간 합**이다(평균·리샘플이 아니라 계산 원본 그대로). */ strength_profile: Array<[number, number]>; /** 유입 집중점 — [누가거리 m, 유입면적 ㎡, 구역 번호, 구역 내 순위]. * 구역은 시점·기본 관·종점으로 자른 구간이며, 구역마다 `floor(길이/관 최대간격)+1`개를 뽑는다. */ inflow_hotspots: Array<[number, number, number, number]>; /** 기본 관 매설 위치 — 도로 × 세류선 교차점. */ pipes: WatershedPipe[]; /** B05용 평균 흐름 화살표 — [lon, lat, 방위(도), 도로도달, 셀 수]. * 세류·도로 셀을 뺀 10m 블록 평균이라 사면 경향만 남는다. */ flow_arrows: Array<[number, number, number, boolean, number]>; /** 화살표 사이 실제 간격(m). 화면이 화살표를 이보다 짧게 그려 서로 닿지 않게 한다. */ arrow_spacing_m: number; /** 계산하지 않고 저장분을 그대로 돌려준 응답인지. */ from_cache: boolean; /** 영구저장소에 남긴 검증용 GeoJSON 경로. */ saved_to: string | null; } /** 배수유역 분석 결과를 받는다. * * `refresh`를 주지 않으면 영구저장소에 남은 결과를 그대로 받아 즉시 끝난다. * `refresh=true`면 처음부터 다시 계산하므로 수십 초가 걸린다. */ export async function fetchWatershedAnalysis( projectId: string, refresh = false, ): Promise { return requestJson( `/projects/${projectId}/drainage/primary-region?refresh=${refresh}`, { method: "GET" }, refresh ? API_ANALYSIS_TIMEOUT_MS : API_TIMEOUT_MS, ); } /** 도로 한 지점으로 들어오는 셀들의 외곽선(검토용). 계산이 아니라 저장된 귀속 배열 조회다. */ export interface RoadInflowResponse { status: string; chainage_m: number; /** 기여 셀을 모은 도로 구간 길이(m). */ span_m: number; cell_count: number; area_m2: number; /** 가장 먼 셀이 이 지점까지 흘러온 물길 길이(m). */ max_path_length_m: number; /** 기여 셀 덩어리들의 바깥 링(WGS84). 큰 조각부터. */ rings_lonlat: Array>; } /** 취소는 지원하지 않는다(`requestJson`이 자체 타임아웃 신호를 쓴다) — 호출측에서 늦게 온 * 응답을 버리는 방식으로 처리한다. */ export async function fetchRoadInflow( projectId: string, chainageM: number, ): Promise { return requestJson( `/projects/${projectId}/drainage/road-inflow?chainage_m=${chainageM}`, { method: "GET" }, ); } /* ── 상세 배수유역(관 매설 지점 + 세부유역 분할) ─────────────────────────── */ /** 관이 그 자리에 있는 이유. 백엔드 `common_util_drainage_pipes`가 정의처다. */ export type PipeSource = "stream" | "spacing" | "user"; /** 계곡 통과 시설 종류(2026-08-17 컨테이너 병합). 같은 계곡 교차점에서 유량·지형에 * 따라 택일한다 — 정의처는 백엔드 `common_util_drainage_pipes`. 교량은 임도용이 아니다. */ export type PipeFacility = "pipe" | "box_culvert" | "ford_pavement" | "ford_bridge" | "revetment"; export const PIPE_FACILITY_LABELS: ReadonlyArray<[PipeFacility, string]> = [ ["pipe", "배수관"], ["box_culvert", "BOX암거"], ["ford_pavement", "물넘이포장"], ["ford_bridge", "세월교"], ["revetment", "기슭막이"], ]; export interface DetailPipePoint { chainage_m: number; lonlat: [number, number]; source: PipeSource; /** 시설 종류 — 응답에서 생략되면 기본 배관. */ facility?: PipeFacility; /** 기준점 앞뒤 구간(유입·유출 부속 폭). 없으면 폭 0 — 사용자가 필요할 때 벌린다. */ start_m?: number; end_m?: number; /** 유무·종류 수준의 시설 옵션(예: 세월교 관 종류/크기/수량). 상세 치수는 B06/B07. */ options?: Record; } /** 편집·저장 요청에 싣는 관 1건 — 좌표(lonlat)는 서버가 다시 계산하므로 뺀다. */ export type DetailPipeInput = Omit; export interface DetailBasin { index: number; chainage_m: number; outlet_lonlat: [number, number]; /** 가장 넓은 조각의 외곽 링 하나 — 중심 계산처럼 링 하나면 되는 자리에 쓴다. */ polygon_lonlat: Array<[number, number]>; /** 조각·구멍을 모두 편 링 목록. 도넛 유역과 떨어진 조각을 그대로 그린다(even-odd). */ polygon_rings_lonlat?: Array>; area_m2: number; relief_m: number; flow_length_m: number; /** 배수 유효직경(합리식 산출, mm). 강우량표가 아직 없으면 null → "미정" 표기. */ pipe_diameter_mm: number | null; /** 산출 근거 — 홍수도달시간(분), 설계강우강도(mm/hr), 설계유량(m³/s, 2.0배 반영). */ tc_minutes?: number | null; intensity_mm_hr?: number | null; design_flow_m3s?: number | null; /** 유효직경이 관 최대 규격 초과 — 세월교·물넘이·교량 검토 대상(임도설치규정 제12조). */ bridge_required?: boolean; /** 필요 통수단면적(㎡) = 설계유량 / 유속. 물넘이·세월교 개략 단면의 출발값. */ required_area_m2?: number | null; /** 유량 근거 추천 구조물(2026-08-17 사용자 확정) — pipe/box_culvert/ford_bridge. */ recommended_facility?: PipeFacility; /** 추천 관경(㎜) — 배관일 때만. 레지스트리 선택지로 스냅한 값이다. */ recommended_diameter_mm?: number | null; } export interface DetailBasinResponse { status: string; project_id: string; /** 종단 Z 출처(design_profile / route_points / surface / csv). */ z_source: string; route_length_m: number; max_spacing_m: number; min_spacing_m: number; /** 응답의 관 목록이 저장분에서 온 것인지. */ saved: boolean; pipe_points: DetailPipePoint[]; basins: DetailBasin[]; pipe_count: number; /** 도로 1m 구간별 유입 면적 — [누가거리, 면적]. 계획선 색칠에 쓴다. */ strength_profile: Array<[number, number]>; /** 유입 집중점 — [누가거리, 유입면적, 구역번호, 구역 내 순위]. */ inflow_hotspots: Array<[number, number, number, number]>; /** B04가 분석에 쓴 계획 노선 선형(lon/lat). */ route_lonlat: Array<[number, number]>; /** 2차 전체 배수유역 외곽선 = 분수령. 해석 결과 그대로. */ main_polygon_lonlat: Array<[number, number]>; /** B04 해석 격자 한 변(m). */ grid_cell_m: number; /** 평균 흐름 화살표 — [x, y(사업지 CRS m), 방위(도), 도로도달, 셀 수]. */ flow_arrows: Array<[number, number, number, boolean, number]>; /** 화살표 사이 실제 간격(m). */ arrow_spacing_m: number; /** 유역 안쪽 상류 세류망 — 하이라이트 토글용. */ upstream_lonlat: Array>; } /** 저장된 관 매설 지점과 그 세부유역. 저장분이 없으면 백엔드가 자동 생성해 돌려준다. */ export async function fetchDetailPipePoints(projectId: string): Promise { return requestJson(`/projects/${projectId}/drainage/pipe-points`, { method: "GET", }); } /** 편집 중인 관 목록으로 세부유역을 다시 나눈다(저장하지 않는다). * * `points`를 비우면 저장분을 무시하고 기본 관 + 자동 보충으로 되돌린다. */ export async function computeDetailBasins( projectId: string, points: DetailPipeInput[], ): Promise { return requestJson(`/projects/${projectId}/drainage/detail-basins`, { method: "POST", body: JSON.stringify({ points }), }); } /** 저장된 관 지점을 버리고 기본 관 + 자동 보충 배치로 되돌린다("초기화"). * * 화면만 되돌리면 다시 들어왔을 때 옛 관이 살아나므로 저장분까지 지운다. */ export async function resetDetailPipePoints(projectId: string): Promise { return requestJson(`/projects/${projectId}/drainage/pipe-points`, { method: "DELETE", }); } /** 관 매설 지점을 정본으로 확정하고 세부유역 산출물까지 남긴다(모델 확정 시점). */ export async function saveDetailPipePoints( projectId: string, points: DetailPipeInput[], ): Promise { return requestJson(`/projects/${projectId}/drainage/pipe-points`, { method: "PUT", body: JSON.stringify({ points }), }); }