import { CURRENT_PROJECT_ID_KEY, ROUTES } from "@config/config_frontend"; import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; import { hideLoadingOverlay, showConfirmDialog, showLoadingOverlay, showToast, } from "@ui/ui_template_elements"; import { purgeOtherProjects } from "../A00_Common/b_asset_cache"; import { createProgressCircle } from "@ui/ui_template_progress"; import { createWorkflowLayout } from "@ui/ui_template_workflow_layout"; import { workflowSteps } from "../A00_Common/b_page_scaffold"; import { fetchWorkflowState, goToWorkflowStage, WORKFLOW_STEP_ROUTES, } from "../A00_Common/b_workflow_nav"; import { fetchConfirmedSurface, listSurfaceModels, type SurfaceModelSummary, } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; import { clearRouteLatestCache, confirmRoute, fetchLatestRoute, readRouteLatestCache, resetRouteDesign, writeRouteLatestCache, solveRoute, updateContourInterval, type RouteLatestResponse, } from "./B05_Profile_Api_Fetch"; import { createRoutePanel, type RoutePanelValues } from "./B05_Profile_UI_Panel"; import { createRouteProfilePanel } from "./B05_Profile_UI_Profile_Panel"; import { leaveForDashboard } from "../A00_Common/b_missing_data_guard"; import { navigateTo } from "../A00_Common/router"; import { createSelectionSync } from "./B05_Profile_UI_Selection"; import { createStructuresBridge } from "./B05_Profile_UI_Page_Structures"; import { createRouteViewer } from "./B05_Profile_UI_Viewer"; import { irregularStationId, isPipeStation } from "./B05_Profile_UI_IrregularStations"; import { fetchSectionContext, type SectionDetailResponse, } from "../B06_Section/B06_Section_Api_Fetch"; import { invalidateSectionDetail, loadSectionDetail, } from "../B06_Section/B06_Section_Section_Store"; import { migrateLegacyStations } from "./B05_Profile_Api_Structures"; import "./B05_Profile_UI_Style.css"; import "./B05_Profile_UI_Style_Structures.css"; import { circlePoint, DEFAULT_ROAD_WIDTHS, fetchRoadWidths, interpolateIrregularStations, restorePoints, routePoint, toBounds, toGradeClass, } from "./B05_Profile_UI_Page_Helpers"; function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; } export async function renderB05Route(root: HTMLElement): Promise { const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY); if (!projectId) { showToast("프로젝트를 먼저 선택하세요.", "error"); return; } const activeProjectId: string = projectId; // 새로고침으로 바로 들어온 경우에도 다른 프로젝트 자료는 보관함에서 지운다. void purgeOtherProjects(activeProjectId); const viewer = createRouteViewer(); // 구 우클릭 메뉴의 자유 텍스트 구조물 → 레지스트리 타입(2026-08-17 이관 매핑과 동일). const LEGACY_TYPE_IDS: Record = { 기성막이: "revetment", 대피로: "refuge", 기타: "etc", }; const profilePanel = createRouteProfilePanel( activeProjectId, (stationId) => { viewer.markers.selectStation(stationId); syncIrregularSelection(stationId); }, // [초기선 복원] 시 그래프의 배관 투영도 지운다(관 정본은 배수유역 초기화가 맡는다). () => bridge.clearProjectedStations(), { // 관 매설 목록 → 그래프 배관 측점선·통합 목록을 한 방향으로 맞춘다. // 정본은 배수유역도의 관 지점이며, 화면 목록은 그것을 실체화한 것이다. onPipesChanged: (pipes) => { // 횡단배수는 계획선을 그 지점에 물리는 시설이라 그래프 세로 점선·계획고 틸팅 // 버튼이 필요하다(2026-08-17 사용자 지시). 알약 레인은 종류·선택을 맡는다. bridge.setPipes(pipes); panel.structures.setPipeFacilities( pipes.map((pipe) => ({ chainage_m: pipe.chainage_m, facility: pipe.facility, start_m: pipe.start_m, end_m: pipe.end_m, source: pipe.source, design_flow_m3s: pipe.design_flow_m3s, options: pipe.options, })), ); }, // 그래프 측점선은 이제 배관(계곡 통과 시설) 투영뿐이다 — 수동 구조물은 // 서클마크·구조물 배치 목록이 담당한다(2026-08-17 컨테이너 병합). onStructureMove: (from, to, station) => { if (isPipeStation(station)) profilePanel.drainage.movePipe(from, to); }, onStructureRemove: (station) => { if (isPipeStation(station)) profilePanel.drainage.removePipe(station.chainage_m); }, // 우클릭 [배관 추가] — 즉시 추가 대신 사이드 폼에 종류·측점을 실어 주고, // A군 임시 배치(세부유역 미리보기)로 이어진다. [추가]로 확정(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), // 관 마커 선택(유역 없는 관 포함)도 같은 경로로 전 화면을 맞춘다(2026-08-17). onPipeSelected: (chainageM) => selectStationOfPipe(chainageM), // 구조물 알약 — 그래프에서 고르면 사이드 폼도 같은 항목을 연다. 계곡 통과 시설 // (가상 id `pipe-*`)은 관 정본 소관이라 누가거리로 되돌려 보낸다(2026-08-17). onStructureSelect: (structureId) => { const chainage = bridge.pipeMarkChainage(structureId); 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), }, ); /** * 그래프·3D에서 비정규 측점을 고르면 사이드바 입력 폼에 로드해 수정/삭제할 수 있게 한다. * 선택은 3D·그래프·사이드바가 서로를 갱신하므로, `selectionSyncing` 가드로 재진입을 막아 * 무한 재귀(스택 오버플로우·프리즈)를 방지한다. */ let confirmedSurface: SurfaceModelSummary | null = null; let latest: RouteLatestResponse | null = null; let roadWidths = DEFAULT_ROAD_WIDTHS; let currentSectionDetail: SectionDetailResponse | null = null; let routeReady = false; let restoring = true; // 선택 동기화 재진입 가드(3D↔그래프↔사이드바 상호 갱신의 무한 재귀 차단). let selectionSyncing = false; /* ── 측점 상단측(=측구 방향) 사용자 변경분 ───────────────────────────── * solve가 자동 판정한 uphill_side를 3D 램프 클릭으로 바꾼 값. 세션에 보관했다가 * 경로 확정 시 uphill_overrides로 백엔드/DB(종단 정본)에 병합한다. */ const uphillSessionKey = `b05:uphill:${activeProjectId}`; const uphillOverrides = new Map(); const uphillKey = (chainage: number): string => chainage.toFixed(3); try { const raw = window.sessionStorage.getItem(uphillSessionKey); if (raw) { Object.entries(JSON.parse(raw) as Record).forEach( ([chainage, side]) => { if (side === "left" || side === "right") uphillOverrides.set(chainage, side); }, ); } } catch { /* 손상된 세션 값은 무시 — 자동 판정값으로 재시작. */ } 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), // 통합 「구조물 배치」 목록의 계곡 통과 시설 항목을 강조한다(2026-08-17 통합). selectSidebar: (chainageM) => panel.structures.selectPipeByChainage(chainageM), selectBasin: (chainageM) => profilePanel.drainage.selectBasinByChainage(chainageM), // 배관이면 배수유역 마커 선택 + 부속 옵션 폼까지 연다(2026-08-17 전역 선택 동기화). selectPipeForm: (chainageM) => profilePanel.drainage.selectPipeAtChainage(chainageM), markStation: (chainageM) => profilePanel.drainage.markStation(chainageM), }); function persistUphillOverrides(): void { try { window.sessionStorage.setItem( uphillSessionKey, JSON.stringify(Object.fromEntries(uphillOverrides)), ); } catch { /* 세션 저장 실패는 무시 — 값은 메모리에 유지된다. */ } } /* ── 최신 경로/설정값 세션 캐시 ──────────────────────────────────────── * 확정 이력이 있으면 매 진입마다 DB(latest) 조회 대신 브라우저 세션 캐시를 * 우선 사용해 응답속도를 높인다. 캐시 미스면 latest를 조회해 적재하고, * solve·확정 성공 시 신선한 값으로 갱신한다(세션 = 탭 단위, 탭 종료 시 소멸). */ const readLatestCache = (): RouteLatestResponse | null => readRouteLatestCache(activeProjectId); const writeLatestCache = (value: RouteLatestResponse): void => writeRouteLatestCache(activeProjectId, value); /** 캐시 우선 latest 로드. forceFresh=true(solve/확정 직후)는 항상 DB를 읽고 캐시를 갱신한다. */ async function loadLatest(forceFresh = false): Promise { if (!forceFresh) { const cached = readLatestCache(); if (cached) return cached; } const fresh = await fetchLatestRoute(activeProjectId); writeLatestCache(fresh); return fresh; } const panel = createRoutePanel({ onSolve: () => void solve(), onTempSave: () => void tempSave(), onGoCross: () => navigateTo(ROUTES.B06_SECTION), onReset: () => void resetDesign(), onContourApply: (interval) => void applyContours(interval), onSurfaceVisible: viewer.setSurfaceVisible, onContoursVisible: viewer.setContoursVisible, onAxesVisible: viewer.setAxesVisible, onStationLinesVisible: viewer.setStationLinesVisible, onStationLabelsVisible: viewer.setStationLabelsVisible, onSurfaceGrayscale: viewer.setSurfaceGrayscale, onView: viewer.setView, onResetView: () => viewer.setView("top"), 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), onPipeFacilityRemove: (chainage) => profilePanel.drainage.removePipe(chainage), onPipeFacilitySelect: (chainage) => { if (selectionSyncing) return; selectionSyncing = true; try { // null = 사이드 재클릭 해제 — 그래프·3D·배수유역도 선택도 함께 푼다(2026-08-18). const station = chainage === null ? undefined : bridge .irregularStations() .find((entry) => Math.abs(entry.chainage_m - chainage) < 0.05); 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) => { if (selectionSyncing) return; selectionSyncing = true; try { profilePanel.setSelectedStructure(structure?.structure_id ?? null); } finally { selectionSyncing = false; } }, }); /** 입력·마커 변경 시 측점 라인만 다시 그린다 — 확정 게이트는 폐지(B06 통합 확정). * 지형 구분·등급·기울기 세부 입력은 종단 그래프의 위반 판정 기준까지 **즉시** * 갈아 끼운다(2026-08-19 사용자 지시 6 — 예전에는 재계산·재진입 때만 반영). */ function markStale(): void { if (restoring || !routeReady) return; profilePanel.setGradeLimit(panel.gradeLimitPct()); 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); persistUphillOverrides(); renderStationLines(currentSectionDetail); }); viewer.root.append(panel.viewControls); function restorePanel(next: RouteLatestResponse): void { const options = next.route_params?.options ?? {}; panel.restore({ contourInterval: next.surface_params.contour_interval_m, algorithm: next.route_params?.algorithm as RoutePanelValues["algorithm"] | undefined, gradeClass: options.grade_class as RoutePanelValues["gradeClass"] | undefined, paved: options.paved as boolean | undefined, minCurveRadius: options.min_curve_radius_m as number | undefined, maxUphillGrade: options.max_uphill_grade as number | undefined, maxDownhillGrade: options.max_downhill_grade as number | undefined, minUphillGrade: options.min_uphill_grade as number | undefined, minDownhillGrade: options.min_downhill_grade as number | undefined, allowAvoidPassThrough: options.allow_avoid_pass_through as boolean | undefined, stationInterval: next.route_params?.station_interval_m ?? undefined, crossSampleInterval: next.route_params?.cross_sample_interval_m ?? undefined, longSampleInterval: next.route_params?.long_sample_interval_m ?? undefined, terrainType: options.terrain_type as RoutePanelValues["terrainType"] | undefined, designSpeed: options.design_speed_kph as RoutePanelValues["designSpeed"] | undefined, maxGradePct: next.route_params?.max_grade_pct ?? undefined, minVerticalRadius: next.route_params?.min_vertical_radius_m ?? undefined, minTangentLength: next.route_params?.min_tangent_length_m ?? undefined, startElevationOffset: next.route_params?.start_elevation_offset_m ?? undefined, endElevationOffset: next.route_params?.end_elevation_offset_m ?? undefined, }); viewer.markers.setPoints(restorePoints(next)); } function renderStationLines(detail: SectionDetailResponse): void { // 확정 때 종단 정본에 병합해 둔 비정규 측점은 그리지 않는다. 화면의 정본은 사이드바 목록이고 // 관을 옮기면 그 목록만 따라오므로, 둘을 겹쳐 그리면 옮기기 전 자리가 그대로 남는다 // (2026-08-02 사용자 보고). 저장분은 진입 시 `restoreSections`가 목록으로 되살린다. const regular = detail.longitudinal.stations.filter((station) => station.kind !== "irregular"); const injected = interpolateIrregularStations( regular, bridge.irregularStations(), detail.longitudinal.length_m, // 중심 좌표는 노선 폴리라인 기준 — 3D 마커가 노선 위에 정확히 얹힌다(2026-08-19). latest?.route_points ?? [], ); // 측점 바 양 끝 램프용 상단측: 사용자 변경분 → solve 자동 판정 순으로 적용. const withUphill = [...regular, ...injected].map((station) => ({ ...station, uphill_side: uphillOverrides.get(uphillKey(station.chainage_m)) ?? station.uphill_side ?? null, })); viewer.renderStationLines(withUphill, 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.setStationDisplay(panel.stationDisplayOffset()); profilePanel.setIrregularStations(bridge.irregularStations()); renderStationLines(detail); } async function restoreSections(routeId: number): Promise { let detail: SectionDetailResponse; profilePanel.setLoading("종단면 자료를 불러오는 중…"); try { // 공유 캐시 — B06이 이미 받아 뒀으면 같은 객체를 재사용해 두 페이지가 항상 같은 값을 본다. 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 컨테이너 병합 3단계). const legacy = detail.longitudinal.stations .filter((station) => station.kind === "irregular") .map((station) => ({ chainage_m: station.chainage_m, structure: station.structure ?? "", })); if (legacy.length) { 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 { showLoadingOverlay(); try { await viewer.reloadContours(interval); await updateContourInterval(activeProjectId, interval); // 세션 캐시에도 반영해 다음 진입 시 옛 등고선 간격으로 복원되지 않게 한다. const cached = readLatestCache(); if (cached) { writeLatestCache({ ...cached, surface_params: { ...cached.surface_params, contour_interval_m: interval }, }); } } catch (error) { showToast(error instanceof Error ? error.message : "등고선 조회에 실패했습니다.", "error"); } finally { hideLoadingOverlay(); } } async function solve(): Promise { if (!confirmedSurface || !latest) return; const points = viewer.markers.getPoints(); if (!points.bp || !points.ep) { showToast("BP와 EP를 지형에 배치하세요.", "error"); return; } const values = panel.values(); showLoadingOverlay(); try { const solved = await solveRoute(activeProjectId, { filter_key: latest.surface_params.source_filter, method: latest.surface_params.method, smooth: latest.surface_params.smooth, surface_model_id: confirmedSurface.id, algorithm: values.algorithm, bp: routePoint(points.bp), ep: routePoint(points.ep), cp: points.cp.map(routePoint), ap: points.ap.map(circlePoint), fp: points.fp.map(circlePoint), grade_class: values.gradeClass, paved: values.paved, min_curve_radius_m: values.minCurveRadius, max_uphill_grade: values.maxUphillGrade, max_downhill_grade: values.maxDownhillGrade, min_uphill_grade: values.minUphillGrade, min_downhill_grade: values.minDownhillGrade, allow_avoid_pass_through: values.allowAvoidPassThrough, station_interval_m: values.stationInterval, cross_half_width_m: null, cross_sample_interval_m: values.crossSampleInterval, long_sample_interval_m: values.longSampleInterval, terrain_type: values.terrainType, design_speed_kph: values.designSpeed, max_grade_pct: values.maxGradePct, min_vertical_radius_m: values.minVerticalRadius, min_tangent_length_m: values.minTangentLength, start_elevation_offset_m: values.startElevationOffset, end_elevation_offset_m: values.endElevationOffset, }); // 새 경로는 측점 구성이 달라지므로 이전 상단측 변경분을 폐기한다(자동 판정 재사용). uphillOverrides.clear(); persistUphillOverrides(); renderLatest(await loadLatest(true)); await restoreSections(solved.route_id); if (solved.cross_section_count === null) { showToast("경로는 저장되었지만 종횡단 생성에 실패했습니다.", "error"); } else if (solved.grade_summary === null) { showToast("경로·종횡단은 저장되었지만 계획선 산출에 실패했습니다.", "error"); } else { showToast("최적 경로 계산이 완료되었습니다.", "success"); } } catch (error) { showToast(error instanceof Error ? error.message : "경로 계산에 실패했습니다.", "error"); } finally { hideLoadingOverlay(); } } /** [임시저장] — 현재 편집을 영구저장소에 남기되 워크플로 단계·페이지는 그대로 둔다. * 종·횡 통합 확정은 B06 [확정]이 담당한다(2026-08-08 워크플로우 재정의). */ async function tempSave(): Promise { if (!routeReady) return; // 관 매설 지점을 B04와 같은 저장소에 남긴다 — 저장 실패가 임시저장을 막지는 않는다. await profilePanel.drainage.savePipes().catch(() => 0); showLoadingOverlay(); try { // 종단 계획선 편집은 화면에서만 계산해 두었으므로 저장 시점에 영속화한다. await profilePanel.save(); // 서버가 종단 정본의 계획선을 다시 썼다 — 공유 캐시를 비워 B06이 옛 계획선을 못 보게 한다. invalidateSectionDetail(activeProjectId); // 비정규 측점·상단측 변경분까지 데이터로는 확정 저장하되, 단계 완료 전이는 하지 않는다. await confirmRoute( activeProjectId, { filter_key: latest?.surface_params.source_filter, method: latest?.surface_params.method, smooth: latest?.surface_params.smooth, surface_model_id: confirmedSurface?.id, irregular_stations: bridge.irregularStations().map((station) => ({ chainage_m: station.chainage_m, structure: station.structure, })), // 상단측(측구 방향) 사용자 변경분 — 종단 정본에 병합되어 B06이 그대로 소비한다. uphill_overrides: [...uphillOverrides.entries()].map(([chainage, side]) => ({ chainage_m: Number(chainage), side, })), }, false, ); renderLatest(await loadLatest(true)); showToast(L("B05_Route_TempSave_Success"), "success"); } catch (error) { showToast(error instanceof Error ? error.message : L("B05_Route_TempSave_Failed"), "error"); } finally { hideLoadingOverlay(); } } /** [초기화] — 사용자 편집 전부 폐기, 계획노선 CSV 기본값으로 B05·B06 재계산 후 재진입. * 네이티브 confirm은 공용 헤드 브라우저가 자동 취소해 버튼이 죽은 듯 보였다 * (2026-08-19 진단) — 화면 안 모달로 확인받는다. */ async function resetDesign(): Promise { if (!(await showConfirmDialog(L("B05_Route_Reset_Confirm"), "초기화"))) return; showLoadingOverlay(); try { await resetRouteDesign(activeProjectId); // 옛 경로 기준 캐시를 전부 비우고 페이지를 새로 그린다 — 초기 계산본이 정본이 된다. clearRouteLatestCache(activeProjectId); invalidateSectionDetail(activeProjectId); showToast(L("B05_Route_Reset_Success"), "success"); navigateTo(ROUTES.B05_PROFILE); } catch (error) { showToast(error instanceof Error ? error.message : L("B05_Route_Reset_Failed"), "error"); } finally { hideLoadingOverlay(); } } /* ── 진입 로딩 ───────────────────────────────────────────────────────── * 전부 받아 놓고 한 번에 그리면 몇 초 동안 빈 화면만 보인다. 화면 틀을 먼저 띄우고 * 자료가 끝나는 순서대로 채운다. 3D 지형이 가장 느리므로 맨 마지막에 올리고, 그동안 * 3D 뷰포트에 공통 프로그레스 서클을 띄운다(2026-08-01 사용자 지시). */ const LOAD_STEP_COUNT = 5; // 3D 뷰포트 정중앙. 하단 종단 패널(z-index 3)보다 아래라 패널에 가려지는 것은 무방하다 // (2026-08-01 사용자 지시). 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 workflowState = await fetchWorkflowState(activeProjectId); advanceLoading("노선 정보를 불러오는 중…"); const mainContent = document.createElement("div"); mainContent.className = "b05-route__main"; mainContent.append(viewer.root, profilePanel.root); const layout = createWorkflowLayout({ title: L("B05_Route_Title"), steps: workflowSteps(), activeStep: 2, leftPanel: panel.root, mainContent, stages: workflowState.stages, currentStage: workflowState.current_stage, routes: WORKFLOW_STEP_ROUTES, onStepClick: (stepIndex) => goToWorkflowStage(activeProjectId, WORKFLOW_STEP_ROUTES[stepIndex]), }); layout.root.classList.add("b05-route-layout"); root.replaceChildren(layout.root); try { // ② 좌측 폼·노선 설정값 — 도착하는 대로 폼과 3D 마커 복원에 쓴다. const [latestResponse, sectionContext, configuredRoadWidths] = await Promise.all([ // 세션 캐시 우선(응답속도) — 최초 진입/캐시 미스 시에만 DB(latest)를 읽는다. loadLatest(), fetchSectionContext(activeProjectId), fetchRoadWidths(activeProjectId), ]); 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 사용자 확정). B02의 main/branch/fire/stream을 계획선 기준 // 코드로 옮긴다: main=간선, fire=산불진화, 그 밖(구 지선·계류보전)은 작업. gradeClass: toGradeClass(sectionContext.road_type), }); // 유토곡선용 설정 — 토량환산계수·운반장비 경계·자연방토 판정 경사를 config_system // 정의 그대로 받아 B06과 같은 계산을 태운다(프론트 사본 금지). profilePanel.setEarthworkContext({ conversion: sectionContext.earthwork_conversion, haulLimits: sectionContext.haul_equipment_limits, naturalSpoilMinSlope: sectionContext.natural_spoil_min_ground_slope ?? undefined, }); restorePanel(latestResponse); renderLatest(latestResponse); latest = latestResponse; advanceLoading("확정 지표면 모델을 확인하는 중…"); // ③ 확정 지표면 모델 목록. const models = await listSurfaceModels(activeProjectId); confirmedSurface = models.models.find((model) => model.status === "CONFIRMED") ?? null; advanceLoading("종단면 자료를 불러오는 중…"); // ④ 종단면·횡단 자료 — 하단 패널을 3D보다 먼저 채운다. if (latestResponse.route) await restoreSections(latestResponse.route.id); advanceLoading("3D 지형을 불러오는 중…"); // ⑤ 3D 지형 — 가장 무거우므로 맨 마지막. if (!confirmedSurface) { // 새 자료가 올라와 옛 결과가 지워진 상태 — 여기서 보여 줄 게 없다. leaveForDashboard(); return; } else { // 지형 가장자리만 필요하다 — 포인트클라우드 전체(수십 MB)는 받지 않는다. const confirmed = await fetchConfirmedSurface(activeProjectId); if (!confirmed.bounds) throw new Error("지표면 범위 정보를 찾을 수 없습니다."); await viewer.loadSurface( activeProjectId, confirmedSurface.id, latestResponse.surface_params.method, latestResponse.surface_params.smooth, latestResponse.surface_params.contour_interval_m, toBounds(confirmed.bounds), ); // 지형이 올라온 뒤에야 마커·측점선이 지형 위에 놓인다. renderLatest(latestResponse); if (currentSectionDetail) renderStationLines(currentSectionDetail); } // 구조물 타입 레지스트리·정본 — 노선이 없어도 목록은 보여 준다(추가는 노선 이후). await bridge.load(); advanceLoading(""); } catch (error) { showToast(error instanceof Error ? error.message : "화면을 불러오지 못했습니다.", "error"); } finally { progress.remove(); restoring = false; } }