/* ============================================================================= * B05_Profile_UI_Page_Helpers.ts * B05 화면의 순수 도우미 — 설계폭 조회, 모델 경계 변환, 저장분 → 마커 복원, * 비정규 측점의 3D 좌표 선형보간. * * 화면 본체(B05_Profile_UI_Page)가 700줄 한계에 닿아 분리했다. * 전부 상태를 갖지 않는 변환 함수라 화면 흐름과 독립적이다. * ========================================================================== */ import type { RoutePanelValues } from "./B05_Profile_UI_Panel"; import type { ModelBounds, PlacedRoutePoint, RouteDesignPoints, RoutePointKind, } from "./B05_Profile_UI_Markers"; import type { CirclePoint, RouteLatestResponse, RoutePoint } from "./B05_Profile_Api_Fetch"; import type { PipeFacility } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; import type { SectionStation } from "../B06_Section/B06_Section_Api_Fetch"; import { irregularLabel, irregularStationId, type IrregularStation, } from "./B05_Profile_UI_IrregularStations"; type GradeClass = RoutePanelValues["gradeClass"]; export type RoadWidths = Record; export const DEFAULT_ROAD_WIDTHS: RoadWidths = { trunk: 3, branch: 3, work: 2.5 }; export async function fetchRoadWidths(projectId: string): Promise { const response = await fetch(`/api/projects/${projectId}/sections/road-widths`); if (!response.ok) return DEFAULT_ROAD_WIDTHS; const payload = (await response.json()) as { forest_road_min_width_m?: Partial }; return { ...DEFAULT_ROAD_WIDTHS, ...payload.forest_road_min_width_m }; } export function toBounds(bounds: { x_min: number; x_max: number; y_min: number; y_max: number; z_min: number; z_max: number; }): ModelBounds { return { x: [bounds.x_min, bounds.x_max], y: [bounds.y_min, bounds.y_max], z: [bounds.z_min, bounds.z_max], }; } export function placed( type: RoutePointKind, point: RoutePoint | CirclePoint, index = 0, ): PlacedRoutePoint { return { id: `${type}-restored-${index}`, type, x: point.x, y: point.y, // 표고를 모르면 모르는 채로 넘긴다 — 0으로 눕히면 마커가 지형 한참 아래 평면에 깔린다. z: point.z ?? null, ...(type === "ap" || type === "fp" ? { radius_m: (point as CirclePoint).radius_m ?? 25 } : {}), }; } export function restorePoints(latest: RouteLatestResponse): RouteDesignPoints { const points = latest.route_params?.points; return { bp: points?.bp ? placed("bp", points.bp) : null, ep: points?.ep ? placed("ep", points.ep) : null, cp: (points?.cp ?? []).map((point, index) => placed("cp", point, index)), ap: (points?.ap ?? []).map((point, index) => placed("ap", point, index)), fp: (points?.fp ?? []).map((point, index) => placed("fp", point, index)), }; } export function routePoint(point: PlacedRoutePoint): RoutePoint { // 서버 스키마는 표고를 빼면 "모름"으로 받는다 — null은 undefined로 바꿔 보낸다. return { x: point.x, y: point.y, z: point.z ?? undefined }; } export function circlePoint(point: PlacedRoutePoint): CirclePoint { return { ...routePoint(point), radius_m: point.radius_m ?? 25 }; } /** * 비정규 측점을 규칙 측점 좌표 사이 chainage로 선형보간해 `SectionStation`(월드 좌표·프레임 포함)으로 * 만든다. 백엔드가 아직 이 측점의 횡단을 생성하지 않으므로, 3D 표시에 필요한 위치만 근사한다. * 노선 범위를 벗어난 chainage는 제외한다. */ export function interpolateIrregularStations( base: SectionStation[], list: IrregularStation[], maxChainage: number, ): SectionStation[] { const sorted = [...base].sort((a, b) => a.chainage_m - b.chainage_m); if (!sorted.length) return []; const anchorAt = (chainage: number): SectionStation => { if (chainage <= sorted[0].chainage_m) return sorted[0]; const last = sorted[sorted.length - 1]; if (chainage >= last.chainage_m) return last; let lo = sorted[0]; let hi = last; for (let index = 1; index < sorted.length; index += 1) { if (sorted[index].chainage_m >= chainage) { lo = sorted[index - 1]; hi = sorted[index]; break; } } const span = hi.chainage_m - lo.chainage_m; const t = span > 1e-9 ? (chainage - lo.chainage_m) / span : 0; const lerp = (a: number, b: number): number => a + (b - a) * t; const centerZ = lo.center_z !== null && hi.center_z !== null ? lerp(lo.center_z, hi.center_z) : (lo.center_z ?? hi.center_z); return { ...lo, center_x: lerp(lo.center_x, hi.center_x), center_y: lerp(lo.center_y, hi.center_y), center_z: centerZ, frame: { left_xy: [ lerp(lo.frame.left_xy[0], hi.frame.left_xy[0]), lerp(lo.frame.left_xy[1], hi.frame.left_xy[1]), ], }, }; }; return list .filter((entry) => entry.chainage_m >= 0 && entry.chainage_m <= maxChainage + 1e-6) .map((entry) => ({ ...anchorAt(entry.chainage_m), station_id: irregularStationId(entry.id), chainage_m: entry.chainage_m, label: irregularLabel(entry), kind: "irregular" as const, // 3D 측점 라벨이 `측점번호 구조물명`으로 표기할 수 있게 구조물 이름을 실어 보낸다. structure: entry.structure, })); } /** 시설 종류별 표시 이름 — 그래프·3D 라벨용(배관은 관종·관경까지 따로 붙인다). */ export const FACILITY_NAMES: Record = { pipe: "배수관", box_culvert: "BOX암거", ford_pavement: "물넘이포장", ford_bridge: "세월교", };