사용자 화면 피드백 3건(2026-08-17) 반영 + PLAN 3단계(이관·폐기). - 「구조물 배치」 단일 섹션: 구 비정규 측점 섹션을 흡수·삭제하고 「구조물 추가」를 개칭. 위치 입력은 측점번호+잔여거리 두 칸(구 비정규 UI 방식), 순서 = 시작 측점 → 기준 측점 → 종료 측점(점형은 기준만, 비우면 시작). - A그룹 제어 통합: A군 종류 목록에 계곡 통과 시설(배관/BOX암거/물넘이/세월교) 표시 — 추가는 관 지점 정본 경유(onPipeAdd, 시설 종류 = type_id), 목록에 "배수유역 연동"으로 병합 표시·선택 동기화·삭제. 노출형 횡단수로·개거는 수동 구조물. - 그래프 측점 표현: 서클마크 툴팁·벌룬 위치를 누가거리에서 측점번호+잔여 거리 표기로 변경(mountStructureMarks에 측점간격 주입). - 구 비정규 측점 이관: POST /route/structures/migrate 신설 — 기존 Migration 매핑(배관 제외·기성막이→기슭막이·대피로→대피소) 사용, 정본 기존 (타입, 기준점) 점유 검사로 멱등. Page 진입 시 구 확정분을 자동 이관하고 건수를 토스트로 알린다. TDD 라우터 테스트 4건. - 구 UI 폐기: onIrregularChange/Select 콜백 제거, 배관 투영은 Page의 pipesToStations()가 직접 생성(시설 종류별 라벨). 그래프 측점선 드래그· 삭제는 배관 전용으로 단일화, 구 우클릭 항목은 레지스트리 타입으로 매핑. drainage addPipe(chainage, facility?) 확장 — 통합 목록에서 시설 종류를 지정해 추가하면 재계산 요청에 실려 정본까지 간다. tmp/tests 92건·ruff·typecheck·build 통과. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
194 lines
7.2 KiB
TypeScript
194 lines
7.2 KiB
TypeScript
/* =============================================================================
|
|
* B05_Profile_Api_Structures.ts
|
|
* 구조물 타입 레지스트리·구조물 정본 API 클라이언트.
|
|
*
|
|
* 백엔드 계약 (B05_Profile_Structures_Router.py):
|
|
* GET /api/projects/structure-types → 타입 레지스트리
|
|
* GET /api/projects/{project_id}/route/structures → 목록 + 판번호
|
|
* PUT /api/projects/{project_id}/route/structures → 목록 전체 덮어쓰기
|
|
*
|
|
* 타입 정의를 화면에 박아 두지 않는다 — 레지스트리 파일 하나만 고치면 폼까지 따라오게
|
|
* 하려는 것이라, 목록은 반드시 서버에서 받아 온다.
|
|
* ========================================================================== */
|
|
|
|
import { API_BASE_URL, API_TIMEOUT_MS } from "@config/config_frontend";
|
|
|
|
/** 배치형태 — 점형(측점 1개) / 구간형(시~종점) / 부지형(위치+면적). */
|
|
export type StructurePlacement = "point" | "interval" | "site";
|
|
/** 노선 기준 설치 측. 횡단(B06) 연계에 쓴다. */
|
|
export type StructureSide = "left" | "right" | "center" | "cross";
|
|
|
|
export interface StructureOptionField {
|
|
key: string;
|
|
label: string;
|
|
input: "select" | "number" | "text";
|
|
choices: string[];
|
|
unit: string | null;
|
|
default: string | number | null;
|
|
/** 미확정 항목(기본값 없음) — 사용자가 값을 넣어야 저장된다. */
|
|
required?: boolean;
|
|
/** 입력 시점 — B05는 유무·종류·위치만 받고 상세 치수(detail)는 B06/B07에서 받는다
|
|
* (2026-08-17 사용자 확정). detail이면 required여도 B05 폼에 그리지 않는다. */
|
|
phase?: "b05" | "detail";
|
|
}
|
|
|
|
/** B05 배치 폼에 그릴 옵션인가 — 상세(detail)는 B06/B07 몫이라 숨긴다. */
|
|
export function isB05Option(option: StructureOptionField): boolean {
|
|
return (option.phase ?? "b05") !== "detail";
|
|
}
|
|
|
|
export interface StructureType {
|
|
type_id: string;
|
|
group: string;
|
|
name: string;
|
|
placement: StructurePlacement;
|
|
options: StructureOptionField[];
|
|
style: { color?: string; abbr?: string };
|
|
drawing_views: string[];
|
|
/** 다른 정본이 관리하는 타입(배관 = pipe_points.json) — 구조물 목록에 넣지 않는다. */
|
|
managed_by: string | null;
|
|
reference_only: boolean;
|
|
enabled: boolean;
|
|
}
|
|
|
|
export interface StructureInstance {
|
|
structure_id?: string | null;
|
|
type_id: string;
|
|
placement: StructurePlacement;
|
|
chainage_m?: number | null;
|
|
start_m?: number | null;
|
|
end_m?: number | null;
|
|
side: StructureSide;
|
|
offset_m: number;
|
|
options: Record<string, string | number>;
|
|
memo: string;
|
|
placement_source: "manual" | "suggested" | "automatic";
|
|
status: "draft" | "confirmed";
|
|
revision: number;
|
|
geometry: Record<string, unknown> | null;
|
|
}
|
|
|
|
interface StructureTypesResponse {
|
|
status: string;
|
|
schema_version: number;
|
|
types: StructureType[];
|
|
}
|
|
|
|
export interface StructureListResponse {
|
|
status: string;
|
|
project_id: string;
|
|
revision: number;
|
|
structures: StructureInstance[];
|
|
}
|
|
|
|
export interface StructureSaveResponse {
|
|
status: string;
|
|
project_id: string;
|
|
revision: number;
|
|
count: number;
|
|
/** 설계 영향 변경이라 B06 이후를 되돌려야 했는가. */
|
|
needs_downstream_invalidation: boolean;
|
|
/** 실제로 되돌렸는가. needs와 어긋나면 화면이 사용자에게 알린다. */
|
|
invalidated_downstream: boolean;
|
|
}
|
|
|
|
/** 다른 창이 먼저 저장해 판번호가 어긋났다 — 화면이 최신본을 다시 받아야 한다. */
|
|
export class StructureConflictError extends Error {
|
|
constructor(
|
|
message: string,
|
|
readonly currentRevision: number,
|
|
) {
|
|
super(message);
|
|
this.name = "StructureConflictError";
|
|
}
|
|
}
|
|
|
|
async function requestJson<T>(path: string, init: RequestInit = {}): Promise<T> {
|
|
const controller = new AbortController();
|
|
const timer = window.setTimeout(() => controller.abort(), API_TIMEOUT_MS);
|
|
try {
|
|
const response = await fetch(`${API_BASE_URL}${path}`, {
|
|
credentials: "include",
|
|
headers: { "Content-Type": "application/json" },
|
|
signal: controller.signal,
|
|
...init,
|
|
});
|
|
const payload = await response.json().catch(() => null);
|
|
if (!response.ok) {
|
|
const message =
|
|
(payload && typeof payload.message === "string" && payload.message) ||
|
|
`요청이 실패했습니다 (${response.status}).`;
|
|
if (response.status === 409) {
|
|
throw new StructureConflictError(message, Number(payload?.revision ?? 0));
|
|
}
|
|
throw new Error(message);
|
|
}
|
|
return payload as T;
|
|
} finally {
|
|
window.clearTimeout(timer);
|
|
}
|
|
}
|
|
|
|
/** 타입 레지스트리는 서버 배포 중에 바뀌지 않으므로 탭 수명 동안 한 번만 받는다. */
|
|
let typesCache: Promise<StructureType[]> | null = null;
|
|
|
|
export function fetchStructureTypes(): Promise<StructureType[]> {
|
|
if (!typesCache) {
|
|
typesCache = requestJson<StructureTypesResponse>("/projects/structure-types", {
|
|
method: "GET",
|
|
})
|
|
.then((payload) => payload.types.filter((type) => type.enabled))
|
|
.catch((error) => {
|
|
typesCache = null; // 실패한 약속을 남겨 두면 다시 시도할 수 없다.
|
|
throw error;
|
|
});
|
|
}
|
|
return typesCache;
|
|
}
|
|
|
|
export async function fetchStructures(projectId: string): Promise<StructureListResponse> {
|
|
return requestJson<StructureListResponse>(`/projects/${projectId}/route/structures`, {
|
|
method: "GET",
|
|
});
|
|
}
|
|
|
|
export async function saveStructures(
|
|
projectId: string,
|
|
baseRevision: number,
|
|
structures: StructureInstance[],
|
|
): Promise<StructureSaveResponse> {
|
|
return requestJson<StructureSaveResponse>(`/projects/${projectId}/route/structures`, {
|
|
method: "PUT",
|
|
body: JSON.stringify({ base_revision: baseRevision, structures }),
|
|
});
|
|
}
|
|
|
|
/** 구 비정규 측점(자유 텍스트)을 구조물 정본으로 이관한다. 멱등 — 배관은 관 지점
|
|
* 정본 소관이라 서버가 걸러내고, 이미 정본에 있는 (타입, 위치)는 건너뛴다. */
|
|
export async function migrateLegacyStations(
|
|
projectId: string,
|
|
stations: Array<{ chainage_m: number; structure: string }>,
|
|
): Promise<{ status: string; migrated: number; revision: number }> {
|
|
return requestJson(`/projects/${projectId}/route/structures/migrate`, {
|
|
method: "POST",
|
|
body: JSON.stringify({ stations }),
|
|
});
|
|
}
|
|
|
|
/** 종단도 마크 위치 = 기준점. 구간형도 chainage_m이 기준점이다(2026-08-17 사용자
|
|
* 확정: 기준점에 마킹 + 시작·종료 측점). 기준점이 없는 기존 저장분은 시점으로 본다. */
|
|
export function structureAnchorM(structure: StructureInstance): number {
|
|
return structure.chainage_m ?? structure.start_m ?? 0;
|
|
}
|
|
|
|
/** 타입 정의의 기본값으로 옵션을 채운다(신규 추가·타입 변경 시). */
|
|
export function defaultOptions(type: StructureType): Record<string, string | number> {
|
|
const options: Record<string, string | number> = {};
|
|
type.options.forEach((field) => {
|
|
// 기본값이 있는 항목만 채운다. 필수 선택지의 첫 항목을 대신 넣어 주면 사용자가
|
|
// 고르지도 않은 재료·형식이 확정값으로 저장된다(2026-08-16 크로스체크 지적 2).
|
|
if (field.default !== null && field.default !== undefined) options[field.key] = field.default;
|
|
});
|
|
return options;
|
|
}
|