1. 세류선 위 셀이 파랑으로 나오던 문제
원인: 등고선 TIN 보간면은 실제 물골(thalweg)을 재현하지 못해, 세류선 위
셀인데도 D8이 옆 사면으로 흘려보내 도로에 닿지 못했다.
조치 (2가지 함께):
- burn_stream_flow: 확정된 상류 세류망을 따라 격자 흐름 방향을 강제로 새긴다.
세류망은 이미 도로를 건너 하류로 빠지는 물길로 확정된 자료다.
- 사슬 추적의 종결 조건에 세류망 셀을 추가. 물이 세류에 합류한 시점에
도로 도달이 결정된다(사슬 끝이 도로 셀에 정확히 닿지 않아도 된다).
- StreamSplit.upstream 을 물 흐름 방향(상류->하류)으로 정렬. 방향은
_spread_network 확산 시 진입 끝점을 기록해 한 번에 정한다.
2. 화살표 8방위 -> 32방위
descent_azimuth: 지표면 기울기에서 연속 최급강하 방위를 뽑아 32단계로 양자화.
D8은 연결(도로 도달 판정)에만 쓰고 표시는 실제 지형 방위를 따른다.
세류망 새김 셀은 확정된 물길 방향을 그대로 쓴다.
응답 바이트: 하위 6비트=32방위(32=제자리, 33=표고없음), 0x80=도로 도달.
3. 확장 루프 분리
Basin._solve_grid 안에 있던 루프를 Flow.expand_until_closed 로 분리.
단계 검증 미리보기는 이 경로를 타지 않는다.
4. 도로 미도달 셀 화살표를 백색으로 변경(파랑 채움 + 백색 화살표).
합성 검증: 세류 새김 389셀 전부 도로 도달, 32방위 전부 등장,
지류가 본류 중간에 합류하는 갈래를 미연결로 오탐하던 경고 제거.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
382 lines
13 KiB
TypeScript
382 lines
13 KiB
TypeScript
/* =============================================================================
|
|
* B05_wf2_Route_Api_Fetch.ts
|
|
* 2차 워크플로우(경로 설계) API 클라이언트
|
|
*
|
|
* 백엔드 계약 (B05_wf2_Route_Router.py):
|
|
* POST /api/projects/{project_id}/route/solve → 경로 탐색 + DB 기록
|
|
* POST /api/projects/{project_id}/route/confirm → 최신 경로 확정
|
|
*
|
|
* 규칙:
|
|
* - 모든 제어 상수는 config_frontend에서 참조 (하드코딩 금지).
|
|
* - 오류 응답 형식 {status:"error", message:"..."}을 Error로 변환.
|
|
* ========================================================================== */
|
|
|
|
import { 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;
|
|
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;
|
|
}
|
|
|
|
/** 계획선 산출 요약 (실패 시 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<string, unknown>;
|
|
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<string, unknown> | null;
|
|
} | null;
|
|
route_points: Array<RoutePoint & { chainage_m?: number; slope_percent?: number }>;
|
|
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<string, unknown>;
|
|
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;
|
|
} | null;
|
|
}
|
|
|
|
/** 공통 fetch 헬퍼: 타임아웃 + 인증 헤더 + 오류 응답 변환. */
|
|
async function requestJson<T>(path: string, init: RequestInit): Promise<T> {
|
|
const controller = new AbortController();
|
|
const timeoutId = window.setTimeout(() => controller.abort(), API_TIMEOUT_MS);
|
|
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(timeoutId);
|
|
}
|
|
}
|
|
|
|
/** 경로 탐색을 실행한다 (비용면 생성 → Dijkstra/ridge-valley → GeoJSON 저장). */
|
|
export async function solveRoute(
|
|
projectId: string,
|
|
request: RouteSolveRequest,
|
|
): Promise<RouteSolveResponse> {
|
|
return requestJson<RouteSolveResponse>(`/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<string, number>;
|
|
curve_radii: Record<string, number>;
|
|
}
|
|
|
|
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<ProfileAlignmentSaveResponse> {
|
|
return requestJson<ProfileAlignmentSaveResponse>(
|
|
`/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" }>;
|
|
}
|
|
|
|
/** 프로젝트의 최신 경로를 확정한다. 비정규 측점이 있으면 그 횡단까지 생성한다. */
|
|
export async function confirmRoute(
|
|
projectId: string,
|
|
body: RouteConfirmRequest = {},
|
|
): Promise<RouteConfirmResponse> {
|
|
return requestJson<RouteConfirmResponse>(`/projects/${projectId}/route/confirm`, {
|
|
method: "POST",
|
|
body: JSON.stringify(body),
|
|
});
|
|
}
|
|
|
|
export async function fetchLatestRoute(projectId: string): Promise<RouteLatestResponse> {
|
|
return requestJson<RouteLatestResponse>(`/projects/${projectId}/route/latest`, {
|
|
method: "GET",
|
|
});
|
|
}
|
|
|
|
/* ── 배수유역도 (B05_wf2_Route_Router_Drainage.py) ───────────────────────── */
|
|
|
|
/** 관 매설 구조물 측점 후보 1개. reason: stream=세류 교차, spacing=300m 보충. */
|
|
export interface DrainageCandidate {
|
|
chainage_m: number;
|
|
x: number;
|
|
y: number;
|
|
lon: number;
|
|
lat: number;
|
|
reason: "stream" | "spacing" | "confirmed";
|
|
stream_name: string | null;
|
|
}
|
|
|
|
export interface DrainageCandidateResponse {
|
|
status: string;
|
|
project_id: string;
|
|
route_id: number;
|
|
candidates: DrainageCandidate[];
|
|
}
|
|
|
|
/** 관 1개가 받는 세부 배수유역. 관경(pipe_diameter_mm)은 수식 미확정이라 당분간 항상 null이다. */
|
|
export interface DrainageBasin {
|
|
index: number;
|
|
chainage_m: number;
|
|
/** 관(배관) 매설 지점 좌표 — 계획선 위 마커 렌더용. */
|
|
outlet_lonlat: [number, number];
|
|
polygon_lonlat: Array<[number, number]>;
|
|
area_m2: number;
|
|
relief_m: number;
|
|
flow_length_m: number;
|
|
pipe_diameter_mm: number | null;
|
|
}
|
|
|
|
export interface DrainageBasinResponse {
|
|
status: string;
|
|
project_id: string;
|
|
route_id: number;
|
|
/** 산정에 실제 사용된 배관 지점 — 유역이 없는 관도 포함(마커 동기화용). */
|
|
pipes: DrainageCandidate[];
|
|
/** 2차 전체 배수유역 외곽선 = 분수령. 세부유역은 전부 이 안쪽이라 능선을 따로 그리지 않는다. */
|
|
main_polygon_lonlat: Array<[number, number]>;
|
|
/** 도로 위 흐름 강도 — [누가거리 m, 그 구간으로 모이는 상류 면적 ㎡]. 관 추가 판단 근거. */
|
|
strength_profile: Array<[number, number]>;
|
|
/** 해석에 실제 사용된 격자 한 변(m). 셀 수 상한에 걸리면 백엔드가 키워서 돌려준다. */
|
|
grid_cell_m: number;
|
|
basins: DrainageBasin[];
|
|
}
|
|
|
|
/** 1차 배수유역 근거(단계 검증용). TIN·흐름 계산 없이 세류 상·하류 판정과 격자 범위만 준다. */
|
|
export interface DrainagePrimaryRegion {
|
|
status: string;
|
|
project_id: string;
|
|
route_id: number;
|
|
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]>;
|
|
};
|
|
/** 셀별 흐름 방향과 도로 도달 여부. 등고선이 없어 판정을 못하면 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;
|
|
/** 영구저장소에 남긴 검증용 GeoJSON 경로. */
|
|
saved_to: string | null;
|
|
}
|
|
|
|
export async function fetchDrainagePrimaryRegion(
|
|
projectId: string,
|
|
): Promise<DrainagePrimaryRegion> {
|
|
return requestJson<DrainagePrimaryRegion>(`/projects/${projectId}/drainage/primary-region`, {
|
|
method: "GET",
|
|
});
|
|
}
|
|
|
|
export async function fetchDrainageCandidates(
|
|
projectId: string,
|
|
): Promise<DrainageCandidateResponse> {
|
|
return requestJson<DrainageCandidateResponse>(`/projects/${projectId}/drainage/candidates`, {
|
|
method: "GET",
|
|
});
|
|
}
|
|
|
|
/** chainages를 주면 그 위치로 확정 산정하고, 비우면 자동 제안분으로 산정한다. */
|
|
export async function fetchDrainageBasins(
|
|
projectId: string,
|
|
chainages?: number[],
|
|
): Promise<DrainageBasinResponse> {
|
|
return requestJson<DrainageBasinResponse>(`/projects/${projectId}/drainage/basins`, {
|
|
method: "POST",
|
|
body: JSON.stringify({ chainages: chainages ?? [] }),
|
|
});
|
|
}
|