Files
Aislo/B04_PreProcess/B04_PreProcess_Api_Fetch.ts
T
eomsangdonandClaude Opus 5 deaf51778f feat(B05): 구조물 컨테이너 병합 2단계 — 측점 표기·기준점 입력·계곡 통과 시설 UI
PLAN 2026-08-17 「B05 구조물 컨테이너 병합」 프론트엔드.

- B05_Profile_Util_Station.ts 신설: 측점번호+잔여거리("3+18.0") ↔ 누가거리
  변환·서식·해석. 누가거리 직접 입력도 허용, 부동소수 올림 보정.
- 구조물 패널: 위치 입력을 측점 표기 텍스트로 재편. 점형 = 기준 측점 1칸,
  구간형 = 기준점(비우면 시작)+시작+종료 3칸, 시작≤기준≤종료 검증과 잘못된
  입력 붉힘. 목록 표기도 측점식. 구간형 마크 드래그는 기준점 기준으로 시작·
  종료가 따라온다. 상세(detail) 옵션은 폼에서 숨긴다 — B05는 유무·종류·위치
  단계(2026-08-17 사용자 확정).
- 우클릭 메뉴 필터 완화: managed_by와 b05 phase 필수만 제외 — 상세 필수였던
  타입(옹벽·돌쌓기 등)도 우클릭 한 번으로 추가된다.
- B05_Profile_UI_Drainage_Facility.ts 신설(패널 700줄 제한 대응 분리): 관 마커
  선택 시 시설 종류(배관/BOX암거/물넘이/세월교)·구간·세월교 관 종류/크기/수량
  폼. FacilityStore가 시설 확장 정보를 보관하고 세부유역 재계산·저장 요청에
  되붙인다(관 이동 후에도 최근접 승계 — 백엔드 carry와 같은 기준). 응답이
  정본이라 apply()에서 전량 재구성.
- B04_PreProcess_Api_Fetch.ts: PipeFacility·DetailPipeInput 타입, 요청·응답에
  시설 필드 반영.

npm run typecheck·npm run build 통과. 통합 서클마크 표시와 구 비정규 측점
이관·폐기(3단계)는 배관 측점선 체계 이전과 묶어 다음 작업 — PLAN.md 체크리스트
에 미완 사유 기록.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 09:06:19 +09:00

555 lines
20 KiB
TypeScript

/* =============================================================================
* 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_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<string, unknown>;
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<string, unknown> | 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<string, Record<string, unknown>>;
}
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 CSV)의 평면 범위. 지도 초기 화면을 도로 중심으로 맞출 때 쓴다. */
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<T>(
path: string,
init: RequestInit,
timeoutMs: number = API_TIMEOUT_MS,
): Promise<T> {
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<SurfaceAnalyzeResponse> {
return requestJson<SurfaceAnalyzeResponse>(`/projects/${projectId}/surface/analyze`, {
method: "POST",
body: JSON.stringify(request),
});
}
/** 프로젝트의 지표면 모델 목록을 조회한다. */
export async function listSurfaceModels(projectId: string): Promise<SurfaceModelListResponse> {
return requestJson<SurfaceModelListResponse>(`/projects/${projectId}/surface/models`, {
method: "GET",
});
}
/** 선택한 지표면 모델을 확정하고 WF1 단계를 완료한다. */
export async function confirmSurfaceModel(
projectId: string,
modelId: number,
options: SurfaceConfirmOptions,
): Promise<SurfaceConfirmResponse> {
return requestJson<SurfaceConfirmResponse>(`/projects/${projectId}/surface/confirm`, {
method: "POST",
body: JSON.stringify({ model_id: modelId, ...options }),
});
}
export async function listSurfaceInputFiles(
projectId: string,
): Promise<SurfaceInputFileListResponse> {
return requestJson<SurfaceInputFileListResponse>(`/projects/${projectId}/surface/input-files`, {
method: "GET",
});
}
export async function fetchSurfacePointCloud(
projectId: string,
filter?: string,
): Promise<SurfacePointCloudSampleResponse> {
const query = filter ? `?filter=${encodeURIComponent(filter)}` : "";
return requestJson<SurfacePointCloudSampleResponse>(
`/projects/${projectId}/surface/point-cloud${query}`,
{
method: "GET",
},
);
}
/** 확정 지표면 구성 + 지형 가장자리만 조회한다(수 KB).
* 포인트클라우드 전체(수십 MB)를 받지 않고도 3D 좌표 환산에 필요한 값을 얻는다. */
export async function fetchConfirmedSurface(projectId: string): Promise<SurfaceConfirmedResponse> {
return requestJson<SurfaceConfirmedResponse>(`/projects/${projectId}/surface/confirmed`, {
method: "GET",
});
}
export async function fetchSurfaceGroundStats(
projectId: string,
): Promise<SurfaceGroundStatsResponse> {
return requestJson<SurfaceGroundStatsResponse>(`/projects/${projectId}/surface/ground-stats`, {
method: "GET",
});
}
export async function fetchSurfaceStatus(projectId: string): Promise<SurfaceStatusResponse> {
return requestJson<SurfaceStatusResponse>(`/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<VWorldMeta> {
return requestJson<VWorldMeta>(`/projects/${projectId}/vworld-meta?layer_name=${layerName}`, {
method: "GET",
});
}
export async function fetchGisGeoJson(projectId: string, layer: string): Promise<any> {
return requestJson<any>(`/projects/${projectId}/geojson?layer=${encodeURIComponent(layer)}`, {
method: "GET",
});
}
/** 계획노선(B03 업로드 CSV)의 평면 점 목록. 사업지 좌표계(m) — 배경 지도 메타와 같은 좌표계다. */
export interface PlannedRouteResponse {
status: string;
points: Array<{ x: number; y: number }>;
}
/** 2D 지도에 계획선을 겹쳐 그리기 위한 점 목록을 받는다. 없으면 빈 목록이 온다. */
export async function fetchPlannedRoute(projectId: string): Promise<PlannedRouteResponse> {
return requestJson<PlannedRouteResponse>(`/projects/${projectId}/planned-route`, {
method: "GET",
});
}
/* ── 배수유역 분석 (B04_PreProcess_Router_Watershed.py) ────────────────────
* 관리자 확인용. 계획 노선(B03 CSV) + 도엽 등고선·세류선으로 유역을 끝까지 분석하고
* 결과를 영구저장소에 남긴다. 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<Array<[number, number]>>;
/** 교차했으나 하류로 판정해 제외한 조각. 판정이 맞는지 눈으로 대조하는 용도. */
downstream_lines: Array<Array<[number, number]>>;
/** 상·하류 어느 망에도 이어지지 않아 제외한 세류 조각 수. */
no_contact_count: number;
/** 노선이 1차 영역 밖으로 나간 길이(m). 크면 반경을 올려야 한다는 신호. */
road_outside_m: number;
/** 1차 영역(상류 세류망 버퍼 합집합)의 외곽 링 목록. */
region_rings: Array<Array<[number, number]>>;
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<WatershedAnalysis> {
return requestJson<WatershedAnalysis>(
`/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<Array<[number, number]>>;
}
/** 취소는 지원하지 않는다(`requestJson`이 자체 타임아웃 신호를 쓴다) — 호출측에서 늦게 온
* 응답을 버리는 방식으로 처리한다. */
export async function fetchRoadInflow(
projectId: string,
chainageM: number,
): Promise<RoadInflowResponse> {
return requestJson<RoadInflowResponse>(
`/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";
export const PIPE_FACILITY_LABELS: ReadonlyArray<[PipeFacility, string]> = [
["pipe", "배관"],
["box_culvert", "BOX암거"],
["ford_pavement", "물넘이포장"],
["ford_bridge", "세월교"],
];
export interface DetailPipePoint {
chainage_m: number;
lonlat: [number, number];
source: PipeSource;
/** 시설 종류 — 응답에서 생략되면 기본 배관. */
facility?: PipeFacility;
/** 기준점 앞뒤 구간(유입·유출 부속 폭). 없으면 폭 0 — 사용자가 필요할 때 벌린다. */
start_m?: number;
end_m?: number;
/** 유무·종류 수준의 시설 옵션(예: 세월교 관 종류/크기/수량). 상세 치수는 B06/B07. */
options?: Record<string, string | number>;
}
/** 편집·저장 요청에 싣는 관 1건 — 좌표(lonlat)는 서버가 다시 계산하므로 뺀다. */
export type DetailPipeInput = Omit<DetailPipePoint, "lonlat">;
export interface DetailBasin {
index: number;
chainage_m: number;
outlet_lonlat: [number, number];
polygon_lonlat: Array<[number, number]>;
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;
}
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<Array<[number, number]>>;
}
/** 저장된 관 매설 지점과 그 세부유역. 저장분이 없으면 백엔드가 자동 생성해 돌려준다. */
export async function fetchDetailPipePoints(projectId: string): Promise<DetailBasinResponse> {
return requestJson<DetailBasinResponse>(`/projects/${projectId}/drainage/pipe-points`, {
method: "GET",
});
}
/** 편집 중인 관 목록으로 세부유역을 다시 나눈다(저장하지 않는다).
*
* `points`를 비우면 저장분을 무시하고 기본 관 + 자동 보충으로 되돌린다. */
export async function computeDetailBasins(
projectId: string,
points: DetailPipeInput[],
): Promise<DetailBasinResponse> {
return requestJson<DetailBasinResponse>(`/projects/${projectId}/drainage/detail-basins`, {
method: "POST",
body: JSON.stringify({ points }),
});
}
/** 저장된 관 지점을 버리고 기본 관 + 자동 보충 배치로 되돌린다("초기화").
*
* 화면만 되돌리면 다시 들어왔을 때 옛 관이 살아나므로 저장분까지 지운다. */
export async function resetDetailPipePoints(projectId: string): Promise<DetailBasinResponse> {
return requestJson<DetailBasinResponse>(`/projects/${projectId}/drainage/pipe-points`, {
method: "DELETE",
});
}
/** 관 매설 지점을 정본으로 확정하고 세부유역 산출물까지 남긴다(모델 확정 시점). */
export async function saveDetailPipePoints(
projectId: string,
points: DetailPipeInput[],
): Promise<DetailBasinResponse> {
return requestJson<DetailBasinResponse>(`/projects/${projectId}/drainage/pipe-points`, {
method: "PUT",
body: JSON.stringify({ points }),
});
}