Files
Aislo/B05_Profile/B05_Profile_Api_Fetch.ts
T
eomsangdon 08c0dc3228 feat(B05,B06): 자동저장을 걷어내고 초기값 스냅샷으로 초기화한다
CLAUDE.md 5장(조작·데이터 흐름 정책)을 코드에 반영한다. 조작분은 세션에만 쌓고
영구저장은 [저장]·[확정]에서만 하며, [초기화]는 재계산이 아니라 초기값 복원이다.

자동저장 폐지
- B06 조정창 기준벽 구간값: 800ms 디바운스 PUT을 없애고 세션(b06:culvertopt)에
  담는다. flushCulvertOptions()를 B06 [저장]·[확정]과 B05 [저장]이 부른다.
- B05 구조물: 조작 즉시 PUT + 서버 재조회로 화면을 덮어쓰던 것을 세션
  (b05:structures) 적재로 바꾼다. 저장 전에도 고르고 지울 수 있도록 식별자를
  crypto.randomUUID()로 미리 발급한다(서버는 빈 값일 때만 새로 발급).
- B05에서 만지고 B06에서 확정하는 경로를 위해 flushPendingStructures()를 공용화.

초기값 스냅샷
- common_util_initial_snapshot: 자동설계 체인 성공 직후 정본 파일 4트리와
  routes+자식 4표를 initial_snapshot/에 뜬다. 이후 읽기 전용.
- reset_route_design: 스냅샷이 있으면 DELETE와 같은 트랜잭션에서 행을 되세우고
  파일을 되돌린다. 없으면 종전 재계산 폴백. 응답에 restored를 더한다.

조작 응답
- 등고선 재적용·B06 진입 정합·[모두 적용]에서 전체 화면 오버레이 제거.
- 재계산이 design을 갈아끼울 때 extra_spans를 보존한다(다른 조작값과 동일).

테스트: tmp/tests/test_initial_snapshot.py 5건 추가. 245 passed·8 failed(기존
실패 — 기슭막이 이관 때 placement가 interval→point로 바뀐 것을 테스트 미반영).
2026-08-29 11:15:29 +09:00

326 lines
11 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 { 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;
}
/** 계획선 산출 요약 (실패 시 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 헬퍼: 타임아웃 + 인증 헤더 + 오류 응답 변환.
*
* `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),
});
}
/** [초기화] 응답 — 초기 자동 계산 상태로 재구성된 경로. */
export interface RouteResetResponse {
status: string;
project_id: string;
route_id: number;
deleted_routes: number;
/** 초기값 스냅샷을 되돌렸으면 true. false면 스냅샷이 없어 재계산으로 폴백한 것이다. */
restored?: boolean;
}
/** [초기화] — 사용자 편집을 전부 버리고 초기값으로 되돌린다. 초기값 스냅샷이 있으면
* 복원이라 빠르지만, 없는 옛 프로젝트는 재계산으로 폴백하므로 분석용 타임아웃을 쓴다. */
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",
});
}
/** B05가 최신 경로·확정 설정값을 탭 세션에 담아 둘 때 쓰는 키(유일한 정의처). */
export const routeLatestCacheKey = (projectId: string): string => `b05:latest:${projectId}`;
/** 담아 둔 최신 경로 값을 버린다. B04에서 지표면을 다시 확정하면 옛 확정값이 남아
* B05가 이전 지형을 그리게 되므로, 확정 직후 이 값을 지운다. */
/** 세션 캐시에서 최신 경로 응답을 읽는다. 없거나 깨졌으면 null(다음 진입은 DB 조회). */
export function readRouteLatestCache(projectId: string): RouteLatestResponse | null {
try {
const raw = window.sessionStorage.getItem(routeLatestCacheKey(projectId));
return raw ? (JSON.parse(raw) as RouteLatestResponse) : null;
} catch {
return null;
}
}
/** 최신 경로 응답을 세션 캐시에 넣는다. 용량 초과 등으로 실패하면 캐시를 비운다. */
export function writeRouteLatestCache(projectId: string, value: RouteLatestResponse): void {
const key = routeLatestCacheKey(projectId);
try {
window.sessionStorage.setItem(key, JSON.stringify(value));
} catch {
try {
window.sessionStorage.removeItem(key);
} catch {
/* noop */
}
}
}
export function clearRouteLatestCache(projectId: string): void {
try {
window.sessionStorage.removeItem(routeLatestCacheKey(projectId));
} catch {
/* 세션 접근 실패 시에는 다음 진입에서 DB를 읽게 되므로 그대로 둔다. */
}
}