Files
Aislo/B04_PreProcess/B04_PreProcess_Api_Fetch.ts
T
eomsangdonandClaude Opus 5.5 44561251ea feat(배수유역): 347 덧 관 고정 id — 측점 열쇠 pipe:<id> · 옮겨도 · 노선 바뀜 · 다시 셈에도 그대로
- 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
2026-10-16 12:02:02 +09:00

658 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =============================================================================
* 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;
}