/* ============================================================================= * design_view_profile_sections.ts * 종단 보기의 **노선 · 종단면 그리기** — 최신 노선 · 측점선 · 종단면 자료(불러오기 · 그리기 · * 옛 측점 이관). `design_view_profile.ts` 가 700줄을 넘어 뗀 조각(345-1). 상태는 본체가 쥔 * `ProfileViewState` 한 벌을 같이 본다. * ========================================================================== */ import { showToast } from "@ui/ui_template_elements"; import type { SurfaceConfirmedResponse } from "../../B04_PreProcess/B04_PreProcess_Api_Fetch"; import type { RouteLatestResponse } from "../../B05_Profile/B05_Profile_Api_Fetch"; import type { RoutePanel } from "../../B05_Profile/B05_Profile_UI_Panel"; import type { createRouteProfilePanel } from "../../B05_Profile/B05_Profile_UI_Profile_Panel"; import type { createStructuresBridge } from "../../B05_Profile/B05_Profile_UI_Page_Structures"; import type { RouteViewer } from "../../B05_Profile/B05_Profile_UI_Viewer"; import type { SectionDetailResponse } from "../../B06_Section/B06_Section_Api_Fetch"; import { loadSectionDetail, subscribeSectionDetail, swapSectionStations, } from "../../B06_Section/B06_Section_Section_Store"; import { useDesignStore } from "./design_store"; import { migrateLegacyStations } from "../../B05_Profile/B05_Profile_Api_Structures"; import { loadCorridorIfFresh } from "../../B05_Profile/B05_Profile_UI_Corridor"; import { stationLinesOf } from "../../B05_Profile/B05_Profile_UI_Page_Stations"; import { restorePoints, type RoadWidths } from "../../B05_Profile/B05_Profile_UI_Page_Helpers"; import { panelValuesOf } from "../../B05_Profile/B05_Profile_UI_Page_Latest"; /** 종단 보기 본체 · 조각이 함께 보는 값 한 벌. */ export interface ProfileViewState { /** 확정 지표면 — 모델 id 와 3D 로딩에 필요한 범위만 들고 있는다(2026-09-06 호출 정리). */ confirmedSurface: SurfaceConfirmedResponse | null; latest: RouteLatestResponse | null; roadWidths: RoadWidths; detail: SectionDetailResponse | null; /** [예상형상] 표시 상태 — 측점 바를 계획고 위로 올릴지 판단한다(패널 기본 ON). */ corridorVisible: boolean; /** 3D 길 모형이 지금 정본보다 낡았나 — [3D 업데이트] 표시와 한 값 · 모달을 열 때 다시 짓는 기준(345-4). */ corridorPending: boolean; routeReady: boolean; restoring: boolean; } export interface ProfileSectionsDeps { projectId: string; state: ProfileViewState; viewer: RouteViewer; panel: () => RoutePanel; profilePanel: () => ReturnType; bridge: () => ReturnType; /** 종단 보기가 보이나 — 숨은 동안 갈아끼운 상세는 다시 보일 때 그림(346-2 · 없으면 늘 보임). */ visible?: () => boolean; } /** 구 구조물 측점 이관을 이미 시도한 노선 — 탭 수명 동안 유지한다. */ const migratedRoutes = new Set(); export function createProfileSections(deps: ProfileSectionsDeps) { const { projectId, state, viewer } = deps; function restorePanel(next: RouteLatestResponse): void { deps.panel().restore(panelValuesOf(next)); viewer.markers.setPoints(restorePoints(next)); } function renderStationLines(detail: SectionDetailResponse): void { const lines = stationLinesOf({ detail, irregular: deps.bridge().irregularStations(), routePoints: state.latest?.route_points ?? [], liveSamples: deps.profilePanel().alignmentSamples(), corridorVisible: state.corridorVisible, }); viewer.renderStationLines(lines, state.roadWidths[deps.panel().values().gradeClass] / 2); } /** 지금 종단면 자료로 측점선만 다시 그린다(자료가 없으면 그대로). */ function redrawStationLines(): void { if (state.detail) renderStationLines(state.detail); } function renderSections(detail: SectionDetailResponse, routeId?: number): void { const panel = deps.panel(); const profilePanel = deps.profilePanel(); state.detail = detail; profilePanel.render(detail, panel.values().stationInterval ?? undefined, routeId); // 저장된 계획선의 정책은 만들 당시 기준이라 지금 설정(임도 종류·설계속도·지형)과 // 다를 수 있다 — 그릴 때마다 현재 기준으로 맞춘다(2026-08-19). profilePanel.setGradeLimit(panel.gradeLimitPct()); profilePanel.setEnforceMinCover(panel.values().enforcePipeClearance); profilePanel.setStationDisplay(panel.stationDisplayOffset()); profilePanel.setIrregularStations(deps.bridge().irregularStations()); renderStationLines(detail); // 예상형상 코리도 — 저장본이 지금 정본과 맞을 때만 올린다. 낡았으면 [3D 업데이트] 대기 // 표시만 켠다(2026-09-06 실측: 받아놓고 버리는 데다 다시 만드느라 전환이 16.7초까지 갔다). const routePoints = state.latest?.route_points ?? []; if (routePoints.length > 1) { void loadCorridorIfFresh( viewer, projectId, routeId ?? state.latest?.route?.id, detail, routePoints, profilePanel.alignmentSamples() ?? undefined, ).then((fresh) => setCorridorPending(!fresh)); } } async function restoreSections(routeId: number): Promise { const profilePanel = deps.profilePanel(); let detail: SectionDetailResponse; profilePanel.setLoading("종단면 자료를 불러오는 중…"); try { // 공유 캐시 — 횡단 보기가 이미 받아 뒀으면 같은 객체를 재사용해 두 보기가 같은 값을 본다. detail = await loadSectionDetail(projectId, routeId); } catch { // 종횡단 데이터 자체가 없는 경우(생성 실패·최초 진입)는 빈 안내로 둔다. state.detail = null; profilePanel.setLoading(null); profilePanel.clear(); viewer.renderStationLines([], 0); return; } profilePanel.setLoading(null); followSwap(routeId); try { renderSections(detail, routeId); // 구 확정분에 남은 비정규 측점(자유 텍스트)을 구조물 정본으로 1회 이관한다 — 멱등이지만 // 탭 수명 동안 노선마다 한 번만 시도한다(2026-08-17 · 09-06 실측). const legacy = detail.longitudinal.stations .filter((station) => station.kind === "irregular") .map((station) => ({ chainage_m: station.chainage_m, structure: station.structure ?? "", })); const migrateKey = `${projectId}:${routeId}`; if (legacy.length && !migratedRoutes.has(migrateKey)) { migratedRoutes.add(migrateKey); void migrateLegacyStations(projectId, legacy) .then((result) => { if (result.migrated > 0) { showToast(`구 구조물 측점 ${result.migrated}건을 구조물 목록으로 옮겼습니다.`); return deps .bridge() .refreshFromServer() .then(() => undefined); } return undefined; }) .catch(() => { /* 이관 실패는 치명적이지 않다 — 다음 진입에서 다시 시도된다. */ }); } } catch (error) { // 렌더 실패를 "데이터 없음"으로 감추면 원인을 알 수 없게 된다 — 실패 사실을 드러낸다. console.error("B05 종단면도 렌더 실패", error); showToast( error instanceof Error ? `종단면도 표시 실패: ${error.message}` : "종단면도 표시에 실패했습니다.", "error", ); } } /* 측점 수가 바뀜(346-2 · 묶음 측점 → 횡단 쪽이 상세를 갈아끼움) — 종단도 · 표 · 3D 측점선을 새 목록으로 * 다시(보일 때만 · 숨었으면 다시 보일 때 `flushSwap`). 노선마다 한 번 듣고, 노선이 바뀌면 옛 것을 끊음. */ let swap: { routeId: number; stop: () => void } | null = null; let swapPending = false; function followSwap(routeId: number): void { if (swap?.routeId === routeId) return; swap?.stop(); const stopListen = subscribeSectionDetail(projectId, routeId, (next) => { state.detail = next; swapPending = !(deps.visible?.() ?? true); if (!swapPending) renderSections(next, routeId); }); // 묶음 측점(가게)이 바뀌면 갈아끼움 — 횡단 보기를 안 열었어도(그쪽도 부르면 둘째는 「같은 목록」) const stopStore = useDesignStore(projectId, null).subscribe("stations", (stations) => { if (stations?.length) void swapSectionStations(projectId, routeId, stations); }); swap = { routeId, stop: () => { stopListen(); stopStore(); }, }; } /** 숨은 동안 받은 새 상세를 그림 — 종단 보기가 다시 보일 때. */ function flushSwap(): void { if (!swapPending || !state.detail || !swap) return; swapPending = false; renderSections(state.detail, swap.routeId); } /** 「3D 늦음」 — [3D 업데이트] 표시(setCorridorPending 그대로)와 상태 한 칸을 같이 맞춘다. */ function setCorridorPending(pending: boolean): void { state.corridorPending = pending; deps.panel().setCorridorPending(pending); } function renderLatest(next: RouteLatestResponse): void { state.latest = next; state.routeReady = Boolean(next.route && next.route_points.length > 1); deps.profilePanel().setRoutePolyline(next.route_points ?? []); if (next.route) { const stored = next.route.algorithm_params ?? {}; viewer.markers.renderRoute( next.route_points, (stored.curve_warning_segments as Array<{ polyline_start_index: number; polyline_end_index: number; }>) ?? [], ); } } return { restorePanel, renderStationLines, redrawStationLines, renderSections, restoreSections, renderLatest, setCorridorPending, flushSwap, stopSwap: () => swap?.stop(), }; }