/* ============================================================================= * design_view_profile.ts * 한 페이지의 **종단 보기**(계획서 24-3b) — 가운데 위 칸 = 3D 화면. 좌측 패널 · 아래 단추 · * 종단도(유토곡선 · 배수유역도)는 한 벌(`design_shared`)을 횡단 보기와 같은 노드로 쓴다. * * 여기서는 종단 보기 몫만 건다 — 3D · 좌측 종단 칸 재료 · 구조물 다리 · 종단도 알림 · 저장 몫. * (옛 `B05_Profile_UI_Page.ts` 의 본체다. 버튼 동작은 `_Page_Actions`, 최신 노선 캐시는 * `_Page_Latest`, 3D 측점 바는 `_Page_Stations`, 숨김 · 보임은 `_Page_Life` 가 맡는다.) * ========================================================================== */ import { ROUTES } from "@config/config_frontend"; import { showToast } from "@ui/ui_template_elements"; import { purgeOtherProjects } from "../b_asset_cache"; import { createProgressCircle } from "@ui/ui_template_progress"; import { fetchConfirmedSurface, type SurfaceConfirmedResponse, } from "../../B04_PreProcess/B04_PreProcess_Api_Fetch"; import { updateContourInterval, type RouteLatestResponse, } from "../../B05_Profile/B05_Profile_Api_Fetch"; import { createRoutePanel } from "../../B05_Profile/B05_Profile_UI_Panel"; import { createRouteProfilePanel } from "../../B05_Profile/B05_Profile_UI_Profile_Panel"; import { leaveForDashboard } from "../b_missing_data_guard"; import { remountRoute } from "../router"; import { openRouteEditModal } from "../../B05_Profile/B05_Profile_UI_RouteEdit"; import { createSelectionSync } from "../../B05_Profile/B05_Profile_UI_Selection"; import { readStructurePick, restoreStructurePick, wireStructurePick, } from "../../B05_Profile/B05_Profile_UI_Structure_Pick_Session"; import { NO_SELECTION, nearestWithin, selectDesign } from "./design_selection"; import type { DesignPageHost } from "./design_page"; import { bindDesignView, designReady, forwardPipeOptions, type DesignPageShared, } from "./design_shared"; import { bindProfileViewLife } from "../../B05_Profile/B05_Profile_UI_Page_Life"; import { structureAnchorM } from "../../B05_Profile/B05_Profile_Api_Structures"; import { createStructuresBridge } from "../../B05_Profile/B05_Profile_UI_Page_Structures"; import { createRouteViewer } from "../../B05_Profile/B05_Profile_UI_Viewer"; import { irregularStationId, isPipeStation, } from "../../B05_Profile/B05_Profile_UI_IrregularStations"; import { rememberRockBoundaryDefault, rememberStandardDefaults, } from "../../B06_Section/B06_Section_UI_Standard_Panel"; import { fetchSectionContext, type SectionDetailResponse, } from "../../B06_Section/B06_Section_Api_Fetch"; import { loadSectionDetail } from "../../B06_Section/B06_Section_Section_Store"; import { migrateLegacyStations } from "../../B05_Profile/B05_Profile_Api_Structures"; import { pipeToFacilityItem } from "../../B05_Profile/B05_Profile_UI_Structures_Panel_Types"; import { loadCorridorIfFresh, refreshCorridor, saveCorridorIfDirty, } from "../../B05_Profile/B05_Profile_UI_Corridor"; import { profileSaveParts, solveRouteAction, type PageActionContext, } from "../../B05_Profile/B05_Profile_UI_Page_Actions"; import { createLatestSource, panelValuesOf } from "../../B05_Profile/B05_Profile_UI_Page_Latest"; import { stationLinesOf } from "../../B05_Profile/B05_Profile_UI_Page_Stations"; import type { RouteProfilePanelCallbacks } from "../../B05_Profile/B05_Profile_UI_Profile_Panel_Types"; import "../../B05_Profile/B05_Profile_UI_Style.css"; // 700줄 제한으로 잘라낸 조각 — 본체 **다음에** 불러야 캐스케이드 순서가 같다(2026-09-04). import "../../B05_Profile/B05_Profile_UI_Style_Table.css"; import "../../B05_Profile/B05_Profile_UI_Style_Drainage.css"; import "../../B05_Profile/B05_Profile_UI_Style_Structures.css"; import { DEFAULT_ROAD_WIDTHS, fetchRoadWidths, loadUphillOverrides, restorePoints, saveUphillOverrides, toBounds, toGradeClass, } from "../../B05_Profile/B05_Profile_UI_Page_Helpers"; /** 구 구조물 측점 이관을 이미 시도한 노선 — 탭 수명 동안 유지한다. */ const migratedRoutes = new Set(); type PipeReport = Parameters>[0]; export async function mountProfileView( center: HTMLElement, host: DesignPageHost, shared: DesignPageShared, ): Promise { const activeProjectId = shared.projectId; if (!activeProjectId) return; // 새로고침으로 바로 들어온 경우에도 다른 프로젝트 자료는 보관함에서 지운다. void purgeOtherProjects(activeProjectId); const viewer = createRouteViewer(); // 구 우클릭 메뉴의 자유 텍스트 구조물 → 레지스트리 타입(2026-08-17 이관 매핑과 동일). const LEGACY_TYPE_IDS: Record = { 기성막이: "revetment", 대피로: "refuge", 기타: "etc", }; /** 고른 것을 selection 한 곳에 적는다 — 횡단 보기로 이어지는 값이다(chainageM null = 해제). */ const rememberPick = (chainageM: number | null, structureId: string | null): void => selectDesign(activeProjectId, chainageM === null ? NO_SELECTION : { chainageM, structureId }, { from: "b05", }); /** 배수유역도가 마지막으로 알린 관 목록 — 숨어 있다 다시 보일 때 폼 · 3D 에 다시 싣는다. */ let lastPipes: PipeReport | null = shared.lastPipes ?? null; const applyPipes = (pipes: PipeReport): void => { // 횡단배수는 계획선을 그 지점에 물리는 시설이라 그래프 세로 점선·계획고 틸팅 // 버튼이 필요하다(2026-08-17 사용자 지시). 알약 레인은 종류·선택을 맡는다. bridge.setPipes(pipes); panel.structures.setPipeFacilities(pipes.map(pipeToFacilityItem)); profilePanel.setMinCoverPipes(pipes); restorePick(); // 관 목록이 실린 뒤라야 폼이 그 시설을 찾는다. }; // 종단도 한 벌 — 종단 보기가 보일 때 받는 알림만 건다(계획서 24-3b). const profilePanel = createRouteProfilePanel( shared, (stationId) => { viewer.markers.selectStation(stationId); syncIrregularSelection(stationId); }, // [초기선 복원] 시 그래프의 배관 투영도 지운다(관 정본은 배수유역 초기화가 맡는다). () => bridge.clearProjectedStations(), { // 계획선 편집 프리뷰가 횡단 설계선을 갱신해도 3D는 **자동으로 따라오지 않는다** // (2026-09-01 사용자 지시). 갱신은 상단 [3D 업데이트] 버튼으로 모았다. onCrossDesignsUpdated: () => panel.setCorridorPending(true), // 관 매설 목록 → 그래프 배관 측점선·통합 목록을 한 방향으로 맞춘다. 정본은 배수유역도의 // 관 지점이다. 숨어 있을 때는 담아만 두고 다시 보일 때 싣는다(폼은 한 벌이라 횡단 보기 몫). onPipesChanged: (pipes) => { lastPipes = pipes; if (host.visible()) applyPipes(pipes); }, onStructureMove: (from, to, station) => { if (isPipeStation(station)) profilePanel.drainage.movePipe(from, to); }, onStructureRemove: (station) => { if (isPipeStation(station)) profilePanel.drainage.removePipe(station.chainage_m); }, // 우클릭 [배관 추가] — 사이드 폼에 종류·측점을 실어 주고 [추가]로 확정(2026-08-18). onPipeAdd: (chainage) => panel.structures.addAt(chainage, "pipe"), onStructureAdd: (chainage, type) => panel.structures.addAt(chainage, LEGACY_TYPE_IDS[type] ?? "etc"), onIrregularSelect: (station) => syncIrregularSelection(irregularStationId(station.id)), // 배수유역도에서 유역·관 마커를 고르면 그 관의 측점을 그래프·3D·사이드 패널에서도 고른다. onBasinSelected: (chainageM) => selectStationOfPipe(chainageM), onPipeSelected: (chainageM) => selectStationOfPipe(chainageM), // 구조물 알약 — 그래프에서 고르면 사이드 폼도 같은 항목을 연다. 계곡 통과 시설 // (가상 id `pipe-*`)은 관 정본 소관이라 누가거리로 되돌려 보낸다(2026-08-17). onStructureSelect: (structureId) => { const chainage = bridge.pipeMarkChainage(structureId); rememberPick(bridge.markChainage(structureId), chainage === null ? structureId : null); if (chainage !== null) { panel.structures.selectPipeByChainage(chainage); selectStationOfPipe(chainage); return; } panel.structures.selectById(structureId); }, onStructureMarkMove: (structureId, toChainage) => { const chainage = bridge.pipeMarkChainage(structureId); if (chainage !== null) { profilePanel.drainage.movePipe(chainage, toChainage); return; } panel.structures.moveById(structureId, toChainage); }, onStructureTypeAdd: (chainage, typeId) => panel.structures.addAt(chainage, typeId), }, ); /** 확정 지표면 — 모델 id 와 3D 로딩에 필요한 범위만 들고 있는다(2026-09-06 호출 정리). */ let confirmedSurface: SurfaceConfirmedResponse | null = null; let latest: RouteLatestResponse | null = null; let roadWidths = DEFAULT_ROAD_WIDTHS; let currentSectionDetail: SectionDetailResponse | null = null; /** [예상형상] 표시 상태 — 측점 바를 계획고 위로 올릴지 판단한다(패널 기본 ON). */ let corridorVisible = true; let routeReady = false; let restoring = true; // 선택 동기화 재진입 가드(3D↔그래프↔사이드바 상호 갱신의 무한 재귀 차단). let selectionSyncing = false; /* ── 측점 상단측(=측구 방향) 사용자 변경분 ───────────────────────────── * solve가 자동 판정한 uphill_side를 3D 램프 클릭으로 바꾼 값. 세션에 보관했다가 * 저장 시 uphill_overrides로 백엔드/DB(종단 정본)에 병합한다. */ const uphillOverrides = loadUphillOverrides(activeProjectId); const uphillKey = (chainage: number): string => chainage.toFixed(3); const { syncIrregularSelection, syncBasinHighlight, selectStationOfPipe } = createSelectionSync({ stations: () => bridge.irregularStations(), isSyncing: () => selectionSyncing, setSyncing: (value) => { selectionSyncing = value; }, selectMarker: (id) => viewer.markers.selectStation(id), selectGraph: (id) => profilePanel.setSelectedStation(id), selectSidebar: (chainageM) => panel.structures.selectPipeByChainage(chainageM), selectBasin: (chainageM) => profilePanel.drainage.selectBasinByChainage(chainageM), selectPipeForm: (chainageM) => profilePanel.drainage.selectPipeAtChainage(chainageM), markStation: (chainageM) => profilePanel.drainage.markStation(chainageM), selectStructure3D: (chainageM) => viewer.structurePick.selectAtChainage(chainageM), }); /** 세션에 남은 구조물 선택을 되살린다 — 횡단 보기에서 고른 것도 그대로 이어 받는다 * (2026-09-04). 이미 무언가 골라져 있으면 건드리지 않는다. */ function restorePick(): void { if (panel.structures.hasSelection()) return; const pick = viewer.structurePick; restoreStructurePick(pick, activeProjectId, panel.structures, selectStationOfPipe); } const latestSource = createLatestSource(activeProjectId); const loadLatest = latestSource.load; const panel = createRoutePanel( { onSolve: () => void solveRouteAction(actionContext), // 계획노선 편집 — 모달 [확인]에서 서버가 배수유역부터 다시 계산하므로, 끝나면 // 옛 노선 기준 캐시를 버리고 페이지를 새로 세운다([초기화]와 같은 뒷정리). onEditPlannedRoute: () => void openRouteEditModal(activeProjectId, () => remountRoute(ROUTES.B05_PROFILE), { // 측점 눈금 간격은 좌측 패널이 쥔 값을 그대로 넘긴다 — 모달이 따로 굳히지 않는다. stationIntervalM: panel.values().stationInterval ?? undefined, // 바탕 등고선 — 확정 지표면이 있으면 모달이 LAS 등고선을 쓴다(계획서 0-9 ⑥). surfaceModelId: confirmedSurface?.model_id ?? null, contourIntervalM: latest?.surface_params.contour_interval_m, smooth: latest?.surface_params.smooth, }), onContourApply: (interval) => applyContours(interval), onSurfaceVisible: viewer.setSurfaceVisible, onCorridorVisible: (visible) => { corridorVisible = visible; viewer.setCorridorVisible(visible); // 측점 바 기준면이 바뀐다(예상형상 위 ↔ 원지반 위) — 다시 그린다. if (currentSectionDetail) renderStationLines(currentSectionDetail); }, onContoursVisible: viewer.setContoursVisible, onAxesVisible: viewer.setAxesVisible, onStationLinesVisible: viewer.setStationLinesVisible, onStationLabelsVisible: viewer.setStationLabelsVisible, onSurfaceGrayscale: viewer.setSurfaceGrayscale, onView: viewer.setView, onResetView: () => viewer.setView("top"), onProjection: viewer.setProjection, // [3D 업데이트](2026-09-01) — 밀린 계획선 편집을 예상형상·측점선에 한 번에 반영한다. onCorridorRefresh: async () => { if (!currentSectionDetail || !latest?.route?.id) return; const routePoints = latest.route_points ?? []; if (routePoints.length <= 1) return; await refreshCorridor( viewer, activeProjectId, latest.route.id, currentSectionDetail, routePoints, profilePanel.alignmentSamples() ?? undefined, ); // 측점 바·라벨·램프도 계획고를 따라 움직여야 한다(2026-08-23 지적 ③). renderStationLines(currentSectionDetail); // 만든 것을 **바로 올려 둔다**(2026-09-06) — 다음 진입이 네트워크 없이 선다. void saveCorridorIfDirty(activeProjectId, latest.route.id); }, onMovePoint: viewer.beginMoveSelected, onDeletePoint: viewer.markers.deleteSelected, onRadiusChange: (radius) => viewer.markers.updateSelected({ radius_m: radius }), onInputChange: markStale, onStationDisplayChange: (offset) => profilePanel.setStationDisplay(offset), onStructuresChange: (next) => bridge.applyStructures(next), // 계곡 통과 시설(A군) — 관 지점 정본(배수유역 패널) 경유(2026-08-17 통합). onPipeFacilityAdd: (chainage, attributes) => profilePanel.drainage.addPipe(chainage, attributes), onPipeFacilityUpdate: (from, to, attributes) => { profilePanel.drainage.updatePipeFacility(from, to, attributes); // 한 벌 폼 — 횡단 보기 캐시(세월교 · BOX 스펙)도 같은 값으로(계획서 24-3b). forwardPipeOptions(shared, from, attributes.options as Record); }, onPipeFacilityRemove: (chainage) => profilePanel.drainage.removePipe(chainage), onPipeFacilitySelect: (chainage) => { rememberPick(chainage, null); // 목록 선택도 횡단 보기로 이어진다. if (selectionSyncing) return; selectionSyncing = true; try { // null = 사이드 재클릭 해제 — 그래프·3D·배수유역도 선택도 함께 푼다(2026-08-18). const station = chainage === null ? undefined : nearestWithin(bridge.irregularStations(), chainage, (entry) => entry.chainage_m); const id = station ? irregularStationId(station.id) : null; viewer.markers.selectStation(id); profilePanel.setSelectedStation(id); syncBasinHighlight(id); // 배수유역 마커 선택 + 부속 옵션 폼도 연다(2026-08-17 전역 선택 동기화). profilePanel.drainage.selectPipeAtChainage(chainage); } finally { selectionSyncing = false; } }, onStructureSelect: (structure) => { // 목록에서 고른 구조물도 횡단 보기로 이어진다 — 해제(null)는 여기서 적지 않는다(관 폼을 // 여는 길이 구조물 폼을 먼저 비우며 이 콜백을 null 로 부르기 때문). if (structure) rememberPick(structureAnchorM(structure), structure.structure_id ?? null); if (selectionSyncing) return; selectionSyncing = true; try { profilePanel.setSelectedStructure(structure?.structure_id ?? null); } finally { selectionSyncing = false; } }, }, shared, ); wireStructurePick(viewer.structurePick, activeProjectId, panel.structures, selectStationOfPipe); /** 입력·마커 변경 시 측점 라인만 다시 그린다. 지형 구분·등급·기울기 세부 입력은 종단 그래프의 * 위반 판정 기준까지 **즉시** 갈아 끼운다(2026-08-19 사용자 지시 6). */ function markStale(): void { if (restoring || !routeReady) return; profilePanel.setGradeLimit(panel.gradeLimitPct()); // 횡단배수 최소고 강제 체크는 재계산 없이 편집 가드에만 즉시 반영된다(2026-09-01). profilePanel.setEnforceMinCover(panel.values().enforcePipeClearance); if (currentSectionDetail) renderStationLines(currentSectionDetail); } viewer.markers.onChange(markStale); viewer.markers.onSelectionChange(panel.setSelected); viewer.markers.onStationSelectionChange((stationId) => { profilePanel.setSelectedStation(stationId); syncIrregularSelection(stationId); }); // 3D 램프 클릭 → 그 측을 상단측(측구 방향)으로 지정하고 세션 보관 + 램프 재렌더. viewer.markers.onUphillPick((stationId, side) => { if (!currentSectionDetail) return; const base = currentSectionDetail.longitudinal.stations.find( (station) => station.station_id === stationId, ); let chainage = base?.chainage_m; if (chainage === undefined) { const prefix = irregularStationId(""); if (stationId.startsWith(prefix)) { const id = stationId.slice(prefix.length); chainage = bridge.irregularStations().find((entry) => entry.id === id)?.chainage_m; } } if (chainage === undefined) return; uphillOverrides.set(uphillKey(chainage), side); saveUphillOverrides(activeProjectId, uphillOverrides); renderStationLines(currentSectionDetail); }); viewer.root.append(panel.viewControls); function restorePanel(next: RouteLatestResponse): void { panel.restore(panelValuesOf(next)); viewer.markers.setPoints(restorePoints(next)); } function renderStationLines(detail: SectionDetailResponse): void { const lines = stationLinesOf({ detail, irregular: bridge.irregularStations(), routePoints: latest?.route_points ?? [], liveSamples: profilePanel.alignmentSamples(), corridorVisible, uphillOverrides, }); viewer.renderStationLines(lines, roadWidths[panel.values().gradeClass] / 2); } /** 구조물·계곡 통과 시설을 사이드 목록·그래프·3D에 맞추는 다리(정본 저장까지 맡는다). */ const bridge = createStructuresBridge({ projectId: activeProjectId, panel: () => panel, profilePanel: () => profilePanel, renderStationLines: () => { if (currentSectionDetail) renderStationLines(currentSectionDetail); }, isRestoring: () => restoring, }); // 3D 노선 우클릭 → 구조물 배치 메뉴(그래프·배수유역도와 동일, 2026-08-19). bridge.mountViewerMenu(viewer, () => latest?.route_points ?? []); function renderSections(detail: SectionDetailResponse, routeId?: number): void { currentSectionDetail = 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(bridge.irregularStations()); renderStationLines(detail); // 예상형상 코리도 — 저장본이 지금 정본과 맞을 때만 올린다. 낡았으면 [3D 업데이트] 대기 // 표시만 켠다(2026-09-06 실측: 받아놓고 버리는 데다 다시 만드느라 전환이 16.7초까지 갔다). const routePoints = latest?.route_points ?? []; if (routePoints.length > 1) { void loadCorridorIfFresh( viewer, activeProjectId, routeId ?? latest?.route?.id, detail, routePoints, profilePanel.alignmentSamples() ?? undefined, ).then((fresh) => panel.setCorridorPending(!fresh)); } } async function restoreSections(routeId: number): Promise { let detail: SectionDetailResponse; profilePanel.setLoading("종단면 자료를 불러오는 중…"); try { // 공유 캐시 — 횡단 보기가 이미 받아 뒀으면 같은 객체를 재사용해 두 보기가 같은 값을 본다. detail = await loadSectionDetail(activeProjectId, routeId); } catch { // 종횡단 데이터 자체가 없는 경우(생성 실패·최초 진입)는 빈 안내로 둔다. currentSectionDetail = null; profilePanel.setLoading(null); profilePanel.clear(); viewer.renderStationLines([], 0); return; } profilePanel.setLoading(null); 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 = `${activeProjectId}:${routeId}`; if (legacy.length && !migratedRoutes.has(migrateKey)) { migratedRoutes.add(migrateKey); void migrateLegacyStations(activeProjectId, legacy) .then((result) => { if (result.migrated > 0) { showToast(`구 구조물 측점 ${result.migrated}건을 구조물 목록으로 옮겼습니다.`); return bridge.refreshFromServer().then(() => undefined); } return undefined; }) .catch(() => { /* 이관 실패는 치명적이지 않다 — 다음 진입에서 다시 시도된다. */ }); } } catch (error) { // 렌더 실패를 "데이터 없음"으로 감추면 원인을 알 수 없게 된다 — 실패 사실을 드러낸다. console.error("B05 종단면도 렌더 실패", error); showToast( error instanceof Error ? `종단면도 표시 실패: ${error.message}` : "종단면도 표시에 실패했습니다.", "error", ); } } function renderLatest(next: RouteLatestResponse): void { latest = next; routeReady = Boolean(next.route && next.route_points.length > 1); 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; }>) ?? [], ); } } async function applyContours(interval: number): Promise { // 화면 전체를 잠그지 않는다 — 진행 표시는 [재적용] 버튼이 맡는다(CLAUDE.md 5장). try { await viewer.reloadContours(interval); await updateContourInterval(activeProjectId, interval); // 세션 캐시에도 반영해 다음 진입 시 옛 등고선 간격으로 복원되지 않게 한다. const cached = latestSource.read(); if (cached) { latestSource.write({ ...cached, surface_params: { ...cached.surface_params, contour_interval_m: interval }, }); } } catch (error) { showToast(error instanceof Error ? error.message : "등고선 조회에 실패했습니다.", "error"); } } // [경로 계산] · 저장 몫은 따로 뗀 모듈이 맡는다(2026-09-02). const actionContext: PageActionContext = { projectId: activeProjectId, latest: () => latest, confirmedSurface: () => confirmedSurface, routeReady: () => routeReady, viewer: () => viewer, panel: () => panel, profilePanel: () => profilePanel, bridge: () => bridge, uphillOverrides, persistUphillOverrides: () => saveUphillOverrides(activeProjectId, uphillOverrides), loadLatest, renderLatest, restoreSections, }; // [저장] · [확정] · [초기화]에 보태는 종단 보기 몫 — 한 벌이 두 보기 몫을 모아 보낸다. bindDesignView(shared, "profile", { save: profileSaveParts(actionContext), onReset: () => uphillOverrides.clear(), }); // 한 페이지 두 보기 — 숨김 · 보임 · 떠남(계획서 24-3a · 24-3b). bindProfileViewLife(host, { projectId: activeProjectId, viewer, ready: () => !restoring, // 다시 보이면 횡단 보기가 한 벌(폼 · 종단도)에 실어 둔 값을 종단 보기 값으로 되돌린다. reload: async () => { await bridge.load(); if (lastPipes) applyPipes(lastPipes); }, syncPick: () => { if (readStructurePick(activeProjectId)) { restoreStructurePick( viewer.structurePick, activeProjectId, panel.structures, selectStationOfPipe, ); return; } viewer.markers.selectStation(null); profilePanel.setSelectedStation(null); }, // 보기를 떠나는 자리 = 코리도 영구저장 시점(2026-08-23 사용자 확정) — 이동은 막지 않는다. onHide: () => { if (latest?.route?.id) void saveCorridorIfDirty(activeProjectId, latest.route.id); }, }); /* ── 진입 로딩 ───────────────────────────────────────────────────────── * 화면 틀을 먼저 띄우고 자료가 끝나는 순서대로 채운다(2026-08-01 사용자 지시). * 3D 지형은 **보조 자료라 로딩에 넣지 않는다**(2026-09-04 사용자 지시). 서클은 3D 뷰포트 * 정중앙에 둔다(하단 종단 패널에 가려지는 것은 무방). */ const LOAD_STEP_COUNT = 3; const progress = createProgressCircle({ label: "노선 정보를 불러오는 중…", overlay: true }); viewer.root.append(progress.root); let loadedSteps = 0; function advanceLoading(label: string): void { loadedSteps += 1; progress.set(Math.min(1, loadedSteps / LOAD_STEP_COUNT), label); } const mainContent = document.createElement("div"); mainContent.className = "b05-route__main"; mainContent.append(viewer.root); center.append(mainContent); try { // ① 좌측 폼·노선 설정값 — 캐시가 있으면 그것으로 **먼저** 그리고 신선도는 뒤에서 확인한다 // (2026-09-06). 캐시가 없을 때만 DB를 기다린다. const cachedLatest = latestSource.read(); const [latestResponse, sectionContext, configuredRoadWidths] = await Promise.all([ cachedLatest ? Promise.resolve(cachedLatest) : loadLatest(true), fetchSectionContext(activeProjectId), fetchRoadWidths(activeProjectId), ]); if (host.disposed()) return; roadWidths = configuredRoadWidths; panel.restore({ stationInterval: sectionContext.defaults.station_interval_m, crossSampleInterval: sectionContext.defaults.cross_sample_interval_m, longSampleInterval: sectionContext.defaults.long_sample_interval_m, // 임도 종류는 프로젝트 등록(B02)이 정본이다 — 화면에서 고르지 않는다(2026-08-19). gradeClass: toGradeClass(sectionContext.road_type), }); // 유토곡선용 설정 — config_system 정의 그대로 받아 횡단 보기와 같은 계산을 태운다. profilePanel.setEarthworkContext({ conversion: sectionContext.earthwork_conversion, haulLimits: sectionContext.haul_equipment_limits, naturalSpoilMinSlope: sectionContext.natural_spoil_min_ground_slope ?? undefined, }); // 표준횡단 config 기본값도 기억한다 — 계획선 편집 중 횡단 재계산이 브라우저에서 돈다 // (2026-09-03 로컬 계산 전환). 프론트에 config 사본을 두지 않으려는 같은 원칙이다. rememberStandardDefaults(activeProjectId, sectionContext.standard_cross_section); rememberRockBoundaryDefault(activeProjectId, sectionContext.rock_boundary_default_offset_m); restorePanel(latestResponse); renderLatest(latestResponse); advanceLoading("확정 지표면 모델을 확인하는 중…"); // ② 확정 지표면 — 모델 id·범위를 한 번에 받는다. const confirmed = await fetchConfirmedSurface(activeProjectId); confirmedSurface = confirmed.model_id === null ? null : confirmed; if (!confirmedSurface) { // 새 자료가 올라와 옛 결과가 지워진 상태 — 여기서 보여 줄 게 없다. leaveForDashboard(); return; } advanceLoading("종단면 자료를 불러오는 중…"); // ③ 종단면·횡단 자료 — 하단 패널을 3D보다 먼저 채운다. if (latestResponse.route) await restoreSections(latestResponse.route.id); // 횡단 보기가 먼저 서서 배수유역도가 관 목록을 이미 알렸다면 그것을 먼저 싣는다 — 빈 목록으로 // 시작하면 아래 구조물 로딩이 관 대조로 관을 지우고 초안 옵션이 자동 배치로 되돌아간다(24-7a). if (lastPipes) applyPipes(lastPipes); // 구조물 타입 레지스트리·정본 — 노선이 없어도 목록은 보여 준다(추가는 노선 이후). await bridge.load(); advanceLoading(""); designReady(shared); // 캐시로 그렸다면 뒤에서 신선도만 확인한다 — 다른 탭이 자료를 갈아 끼웠을 때만 다시 그린다. if (cachedLatest) { void latestSource.verify(latestResponse, async (fresh) => { renderLatest(fresh); if (fresh.route?.id) await restoreSections(fresh.route.id); }); } } catch (error) { showToast(error instanceof Error ? error.message : "화면을 불러오지 못했습니다.", "error"); } finally { progress.remove(); restoring = false; restorePick(); // 관·구조물 목록 중 늦게 오는 쪽이 있어 여기서 한 번 더. } // ④ 3D 지형 — 화면을 잡지 않고 뒤에서 올린다. 실패해도 나머지는 그대로 쓴다. void (async () => { const surface = confirmedSurface; const current = latest as RouteLatestResponse | null; // 닫힘 안에서 채워져 흐름 분석이 못 봄 if (!surface || !current || host.disposed()) return; try { // 범위는 ②에서 받아 둔 확정 응답에 이미 들어 있다 — 다시 부르지 않는다. if (!surface.bounds) throw new Error("지표면 범위 정보를 찾을 수 없습니다."); await viewer.loadSurface( activeProjectId, surface.model_id as number, current.surface_params.method, current.surface_params.smooth, current.surface_params.contour_interval_m, toBounds(surface.bounds), ); if (host.disposed()) return; // 올리는 사이 페이지를 떠났다. renderLatest(current); // 지형이 올라온 뒤에야 마커·측점선이 지형 위에 놓인다. if (currentSectionDetail) renderStationLines(currentSectionDetail); showToast("3D 지형 준비 완료", "success"); } catch (error) { showToast(error instanceof Error ? error.message : "3D 지형을 불러오지 못했습니다.", "error"); } })(); }