feat(B06): 구조물 면적·유토곡선 서버 계산 자리 마련 + 상단측 저장 누락 수정 계산 자리 일원화(CLAUDE.md 5장) — 브라우저에서만 돌던 두 계산을 서버가 같은 TS 로 한 번 더 돌려 정본에 얹음. 파이썬 포팅 금지(기하가 두 벌이 되면 그림과 수량이 갈림). - B06_Section_Server_Calc_Node.ts 신설 — 구조물 폐회로 면적 계산 후 그 위에서 유토곡선을 쌓음(화면과 같은 순서). balloon 위치는 서버가 만들지 않음. - B06_Section_Structure_Layouts.ts 신설 — 정본만 읽는 제어기 흉내를 B07 도면에서 떼어 공용화. B07·서버가 같은 한 벌을 씀. - common_util_node_bundle.py 신설 — 번들 빌드·실행 배관 공용화(코리도도 이걸 씀). - 전처리 체인(초기값 스냅샷 앞)·[저장]·[확정]에서 서버 재계산 호출. - 상단측(측구 방향) 변경이 B05 [임시저장]에만 실리던 것을 B06 [저장]·[확정]에도 실음 — flushUphillOverrides. - 죽은 세션 등록 항목 pipes 제거(읽는 곳도 쓰는 곳도 없었음). 검증: tsc --noEmit 통과, pytest 386 passed, tmp/tests/test_b06_server_calc_node.mjs 통과. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> @
327 lines
12 KiB
TypeScript
327 lines
12 KiB
TypeScript
/* =============================================================================
|
|
* B05_Profile_Api_Fetch.ts
|
|
* 2차 워크플로우(경로 설계) API 클라이언트
|
|
*
|
|
* 백엔드 계약 (B05_Profile_Router.py):
|
|
* POST /api/projects/{project_id}/route/solve → 경로 탐색 + DB 기록
|
|
* POST /api/projects/{project_id}/route/confirm → 최신 경로 확정
|
|
*
|
|
* 규칙:
|
|
* - 모든 제어 상수는 config_frontend에서 참조 (하드코딩 금지).
|
|
* - 오류 응답 형식 {status:"error", message:"..."}을 Error로 변환.
|
|
* ========================================================================== */
|
|
|
|
import { clearState, readState, writeState } from "../A00_Common/b_page_state";
|
|
|
|
import { API_ANALYSIS_TIMEOUT_MS, 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;
|
|
/** 설계속도(km/h) — 종단 법정 기준 축. 임도 기본 20(2026-08-19). */
|
|
design_speed_kph?: number | null;
|
|
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;
|
|
/** 횡단배수 최소 계획고 강제 — 기본 해제(2026-09-01 사용자 지시). */
|
|
enforce_pipe_clearance?: boolean | 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;
|
|
enforce_pipe_clearance?: boolean | null;
|
|
} | 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);
|
|
}
|
|
}
|
|
|
|
/** 경로 탐색을 실행한다 (비용면 생성 → 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" }>;
|
|
}
|
|
|
|
/** 프로젝트의 최신 경로를 확정한다. 비정규 측점이 있으면 그 횡단까지 생성한다.
|
|
* `markStageComplete=false`는 [임시저장]용 — 데이터(경로 CONFIRMED·비정규 횡단·상단측
|
|
* 병합)는 그대로 저장하되 워크플로 stage 2 완료 전이를 하지 않는다(2026-08-08 재정의). */
|
|
export async function confirmRoute(
|
|
projectId: string,
|
|
body: RouteConfirmRequest = {},
|
|
markStageComplete = true,
|
|
): Promise<RouteConfirmResponse> {
|
|
const query = markStageComplete ? "" : "?mark_stage_complete=false";
|
|
return requestJson<RouteConfirmResponse>(`/projects/${projectId}/route/confirm${query}`, {
|
|
method: "POST",
|
|
body: JSON.stringify(body),
|
|
});
|
|
}
|
|
|
|
/** 세션에 쌓인 상단측(측구 방향) 변경분을 정본으로 내보낸다 — B06 [저장]·[확정]용.
|
|
* 3D 램프 클릭은 B05 화면에서만 생기지만 저장 버튼은 B06 에도 있다(B05·B06 은 한 페이지).
|
|
* B06 에서 저장하면 이 값이 세션에만 남아 확정 뒤 옛 측구 방향이 그대로 쓰였다
|
|
* (2026-09-06 대응표 조사). 비어 있으면 요청을 내지 않는다. */
|
|
export async function flushUphillOverrides(projectId: string): Promise<void> {
|
|
const stored = readState<Record<string, "left" | "right">>("uphill", projectId);
|
|
const overrides = Object.entries(stored ?? {})
|
|
.filter(([, side]) => side === "left" || side === "right")
|
|
.map(([chainage, side]) => ({ chainage_m: Number(chainage), side }));
|
|
if (!overrides.length) return;
|
|
await confirmRoute(projectId, { uphill_overrides: overrides }, false);
|
|
}
|
|
|
|
/** [초기화] 응답 — 초기 자동 계산 상태로 재구성된 경로. */
|
|
export interface RouteResetResponse {
|
|
status: string;
|
|
project_id: string;
|
|
route_id: number;
|
|
deleted_routes: number;
|
|
/** 초기값 스냅샷을 되돌렸으면 true. false면 스냅샷이 없어 재계산으로 폴백한 것이다. */
|
|
restored?: boolean;
|
|
}
|
|
|
|
/** [초기화] — 사용자 편집을 전부 버리고 초기값으로 되돌린다. 초기값 스냅샷이 있으면
|
|
* 복원이라 빠르지만, 없는 옛 프로젝트는 재계산으로 폴백하므로 분석용 타임아웃을 쓴다.
|
|
* 초기 설계가 실패로 끝난 프로젝트는 서버가 409로 거부한다 — 되돌릴 기준이 없어
|
|
* 재계산으로 얼버무리지 않는다(2026-09-02). 그 안내 문구가 그대로 오류 토스트에 뜬다. */
|
|
export async function resetRouteDesign(projectId: string): Promise<RouteResetResponse> {
|
|
return requestJson<RouteResetResponse>(
|
|
`/projects/${projectId}/route/reset`,
|
|
{ method: "POST" },
|
|
API_ANALYSIS_TIMEOUT_MS,
|
|
);
|
|
}
|
|
|
|
export async function fetchLatestRoute(projectId: string): Promise<RouteLatestResponse> {
|
|
return requestJson<RouteLatestResponse>(`/projects/${projectId}/route/latest`, {
|
|
method: "GET",
|
|
});
|
|
}
|
|
|
|
/* 최신 경로 응답은 ④ 계산 결과다 — 키·이관은 등록표(`b_page_state`)가 맡는다.
|
|
B04에서 지표면을 다시 확정하면 옛 확정값이 남아 B05가 이전 지형을 그리므로, 확정
|
|
직후 `clearRouteLatestCache`로 버린다. */
|
|
|
|
/** 세션 캐시에서 최신 경로 응답을 읽는다. 없거나 깨졌으면 null(다음 진입은 DB 조회). */
|
|
export function readRouteLatestCache(projectId: string): RouteLatestResponse | null {
|
|
return readState<RouteLatestResponse>("latest", projectId);
|
|
}
|
|
|
|
/** 최신 경로 응답을 세션 캐시에 넣는다. 용량 초과 등으로 실패해도 화면은 그대로 돈다. */
|
|
export function writeRouteLatestCache(projectId: string, value: RouteLatestResponse): void {
|
|
writeState("latest", value, projectId);
|
|
}
|
|
|
|
export function clearRouteLatestCache(projectId: string): void {
|
|
clearState("latest", projectId);
|
|
}
|