- PipePoint.id · ensure_pipe_ids(빈 id = p<누가거리 cm> · 겹치면 꼬리) — 읽기(parse) · 되붙이기(carry · 원본 id) · 저장(save) 세 자리 · 옛 자료는 다음 저장에 남음 · 노선 투영 · 새 자동 관 = 새 id - 화면 관 모양 DetailPipePoint.id · 배관 편집기 관이 id 를 쥐고 요청에 실음 · reconcilePipes 제자리 · 하나 옮긴 관 id 따라감 - 시험 test_347_pipe_id(8 · station_key = pipe:<id> · 화면 거울) · test_b04_pipe_points_facility 옛 자료 = id 하나만 더함 · test_312 옛 분석 = 낡음(409) 뒤 지문 찍고 자동 배치(새 지문 동작에 맞춤) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XWh5wNNycMkbwMpACz1rte
658 lines
26 KiB
TypeScript
658 lines
26 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_SURFACE_BUILD_TIMEOUT_MS,
|
||
API_TIMEOUT_MS,
|
||
} from "@config/config_frontend";
|
||
import { showToast } from "@ui/ui_template_elements";
|
||
import { fetchCachedBytes } from "../A00_Common/b_asset_cache";
|
||
import type { GroupItems, SummaryItem } from "../common_util/common_util_summary_item";
|
||
|
||
/** 지표면 분석 실행 요청 (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 정본)의 평면 범위. 지도 초기 화면을 도로 중심으로 맞출 때 쓴다. */
|
||
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> {
|
||
// 조합 하나를 새로 만드는 요청 — 실측 114초(4,900만 점)라 분석용 60초로도 abort 된다.
|
||
return requestJson<SurfaceAnalyzeResponse>(
|
||
`/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<SheetMethodListResponse> {
|
||
return requestJson<SheetMethodListResponse>(`/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<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",
|
||
});
|
||
}
|
||
|
||
/** 점군 미리보기 고정 표본 이진(IndexedDB 보관 · ETag) — 다시 열 때 받지 않는다. */
|
||
export async function fetchSurfacePointCloud(
|
||
projectId: string,
|
||
filter?: string,
|
||
): Promise<SurfacePointCloudSampleResponse> {
|
||
const query = filter ? `?filter=${encodeURIComponent(filter)}` : "";
|
||
const bytes = await fetchCachedBytes(
|
||
projectId,
|
||
`${API_BASE_URL}/projects/${projectId}/surface/point-cloud/binary${query}`,
|
||
);
|
||
return { status: "success", project_id: projectId, ...decodePointPreview(bytes) };
|
||
}
|
||
|
||
/**
|
||
* 점군 미리보기 이진 풀기 — 짝 = `B04_PreProcess_Engine_PointPreview.py` `ensure_point_preview`.
|
||
* [머리 길이 u32][머리 JSON][4 배수 채움][xyz f32 × 3n][rgb u16 × 3n].
|
||
*/
|
||
export function decodePointPreview(
|
||
bytes: ArrayBuffer,
|
||
): Omit<SurfacePointCloudSampleResponse, "status" | "project_id"> {
|
||
const headLength = new DataView(bytes).getUint32(0, true);
|
||
const head = JSON.parse(new TextDecoder().decode(new Uint8Array(bytes, 4, headLength))) as {
|
||
point_count: number;
|
||
sampled_count: number;
|
||
bounds: SurfaceBounds;
|
||
has_rgb: boolean;
|
||
};
|
||
const count = head.sampled_count;
|
||
const offset = Math.ceil((4 + headLength) / 4) * 4;
|
||
const xyz = new Float32Array(bytes, offset, count * 3);
|
||
const points: [number, number, number][] = new Array(count);
|
||
for (let index = 0; index < count; index += 1) {
|
||
points[index] = [xyz[index * 3], xyz[index * 3 + 1], xyz[index * 3 + 2]];
|
||
}
|
||
let rgb: [number, number, number][] | undefined;
|
||
if (head.has_rgb) {
|
||
const colors = new Uint16Array(bytes, offset + xyz.byteLength, count * 3);
|
||
rgb = new Array(count);
|
||
for (let index = 0; index < count; index += 1) {
|
||
rgb[index] = [colors[index * 3], colors[index * 3 + 1], colors[index * 3 + 2]];
|
||
}
|
||
}
|
||
return { point_count: head.point_count, sampled_count: count, bounds: head.bounds, points, rgb };
|
||
}
|
||
|
||
/** 확정 지표면 구성 + 지형 가장자리만 조회한다(수 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 정본)의 평면 점 목록. 사업지 좌표계(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 정본) + 도엽 등고선·세류선으로 유역을 끝까지 분석하고
|
||
* 결과를 영구저장소에 남긴다. 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 컨테이너 병합). 같은 계곡 교차점에서 유량·지형에
|
||
* 따라 택일한다 — 관 지점 정본 종류 = 구조물 표 정본(`catalogRow().owner` · 323-7) · 이름 = 표 이름 ·
|
||
* 형상마다 손코딩 폼이 있어 종류 더하기(본보기)에서 뺌. 교량은 임도용이 아니다. */
|
||
export type PipeFacility = "pipe" | "box_culvert" | "ford_pavement" | "ford_bridge" | "revetment";
|
||
|
||
export interface DetailPipePoint {
|
||
chainage_m: number;
|
||
/** 관 고정 id(347 덧 · 측점 열쇠 `pipe:<id>`) — 서버가 붙임 · 옮겨도 그대로. */
|
||
id?: string;
|
||
lonlat: [number, number];
|
||
source: PipeSource;
|
||
/** 시설 종류 — 응답에서 생략되면 기본 배관. */
|
||
facility?: PipeFacility;
|
||
/** 기준점 앞뒤 구간(유입·유출 부속 폭). 없으면 폭 0 — 사용자가 필요할 때 벌린다. */
|
||
start_m?: number;
|
||
end_m?: number;
|
||
/** 유무·종류 수준의 시설 옵션(예: 세월교 관 종류/크기/수량). 상세 치수는 B06/B07. */
|
||
options?: Record<string, string | number>;
|
||
/** 이 관이 뜻하는 구조물 집계표 항목(21-1 · options 밖 칸) — 없으면 null. */
|
||
summary_item?: SummaryItem | null;
|
||
/** 옵션 그룹마다 고른 집계 항목 `{그룹 id: 참조}`(PLAN 46-3) — 없으면 그룹 미지정. */
|
||
group_items?: GroupItems | null;
|
||
}
|
||
|
||
/** 편집·저장 요청에 싣는 관 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]>;
|
||
/** 조각·구멍을 모두 편 링 목록. 도넛 유역과 떨어진 조각을 그대로 그린다(even-odd). */
|
||
polygon_rings_lonlat?: Array<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;
|
||
/** 필요 통수단면적(㎡) = 설계유량 / 유속. 물넘이·세월교 개략 단면의 출발값. */
|
||
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<Array<[number, number]>>;
|
||
/** [저장] 때 못 만든 구조물 조합 사본 폴더(142-2 · `*` = 통째 실패). */
|
||
snapshot_failed?: string[];
|
||
/** 지문(342-3) — 지금 노선 · 관 자리. */
|
||
route_signature?: string;
|
||
pipe_signature?: string;
|
||
/** 분석이 낡음(노선이 분석 때와 다름) — 까닭 글 · 세부 유역 없음 · 관 자리만. */
|
||
stale?: string | null;
|
||
/** 관 자리 후보(347-3) — 제안만 · [적용] 때 관 초안에. */
|
||
pipe_candidates?: Array<{ chainage_m: number; lonlat: [number, number] }>;
|
||
}
|
||
|
||
let lastNote = { text: "", at: 0 };
|
||
/** 못 만든 구조물 조합 사본(저장 응답 `snapshot_failed` · 142-2) — [저장] 알림 한 줄 ·
|
||
* 관 · 구조물 저장이 같은 맞춤을 두 번 알리면 한 번만. */
|
||
export function noteSnapshotFailed(failed: readonly string[] | undefined): void {
|
||
if (!failed?.length) return;
|
||
const text = failed.includes("*")
|
||
? "구조물 조합 사본 맞춤에 실패했습니다 — 다시 [저장]해 주세요."
|
||
: `구조물 조합 사본 ${failed.length}곳을 못 만들었습니다(원본 조합 없음) — 조합을 다시 골라 [저장]해 주세요.`;
|
||
const now = Date.now();
|
||
if (text === lastNote.text && now - lastNote.at < 5000) return;
|
||
lastNote = { text, at: now };
|
||
showToast(text, "error");
|
||
}
|
||
|
||
/** 저장된 관 매설 지점과 그 세부유역. 저장분이 없으면 백엔드가 자동 생성해 돌려준다. */
|
||
export async function fetchDetailPipePoints(projectId: string): Promise<DetailBasinResponse> {
|
||
return requestJson<DetailBasinResponse>(`/projects/${projectId}/drainage/pipe-points`, {
|
||
method: "GET",
|
||
});
|
||
}
|
||
|
||
/** 편집 중인 관 목록으로 세부유역을 다시 나눈다(저장하지 않는다).
|
||
*
|
||
* 빈 목록 = 관 0개(312) · 기본 관 + 자동 보충으로 되돌리기는 `resetDetailPipePoints`(DELETE)만. */
|
||
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[],
|
||
recompute = false,
|
||
): Promise<DetailBasinResponse> {
|
||
// [확정](recompute) = 낡았으면 서버가 지금 노선으로 배수유역부터 다시 셈(347-2) — 오래 걸림.
|
||
const saved = await requestJson<DetailBasinResponse>(
|
||
`/projects/${projectId}/drainage/pipe-points${recompute ? "?recompute=true" : ""}`,
|
||
{ method: "PUT", body: JSON.stringify({ points }) },
|
||
recompute ? API_ANALYSIS_TIMEOUT_MS : API_TIMEOUT_MS,
|
||
);
|
||
noteSnapshotFailed(saved.snapshot_failed);
|
||
return saved;
|
||
}
|