From 8eb5ba54506514432e7d06ad4f21dfd56b2a023b Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 16 Oct 2026 11:02:18 +0900 Subject: [PATCH] =?UTF-8?q?refactor(=EC=A2=85=EB=8B=A8=EB=B3=B4=EA=B8=B0):?= =?UTF-8?q?=20345-1=20design=5Fview=5Fprofile=20=EC=AA=BC=EA=B0=AC=20?= =?UTF-8?q?=E2=80=94=20=EB=85=B8=EC=84=A0=20=C2=B7=20=EC=A2=85=EB=8B=A8?= =?UTF-8?q?=EB=A9=B4=20=EA=B7=B8=EB=A6=AC=EA=B8=B0(=5Fsections)=20=C2=B7?= =?UTF-8?q?=203D=20=EB=AA=AB(=5F3d)=20=C2=B7=20=EC=83=81=ED=83=9C=20?= =?UTF-8?q?=ED=95=9C=20=EB=B2=8C(ProfileViewState)=20=C2=B7=20=EB=8F=99?= =?UTF-8?q?=EC=9E=91=20=EA=B7=B8=EB=8C=80=EB=A1=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - design_view_profile.ts 700 → 517줄 · _sections 179 · _3d 114 - 시험: 관련 51벌 370 통과(이전과 같은 3 실패 = 205_1 · 더하기 글 · 손 안 댄 곳) · typecheck 통과 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VmC34fsxxbweEG3RUjs3Mn --- A00_Common/design/design_view_profile.ts | 309 ++++-------------- A00_Common/design/design_view_profile_3d.ts | 114 +++++++ .../design/design_view_profile_sections.ts | 179 ++++++++++ 3 files changed, 355 insertions(+), 247 deletions(-) create mode 100644 A00_Common/design/design_view_profile_3d.ts create mode 100644 A00_Common/design/design_view_profile_sections.ts diff --git a/A00_Common/design/design_view_profile.ts b/A00_Common/design/design_view_profile.ts index 3bb3c6acb..232e1817d 100644 --- a/A00_Common/design/design_view_profile.ts +++ b/A00_Common/design/design_view_profile.ts @@ -5,21 +5,15 @@ * * 여기서는 종단 보기 몫만 건다 — 3D · 좌측 종단 칸 재료 · 구조물 다리 · 종단도 알림 · 저장 몫. * (옛 `B05_Profile_UI_Page.ts` 의 본체다. 버튼 동작은 `_Page_Actions`, 최신 노선 캐시는 - * `_Page_Latest`, 3D 측점 바는 `_Page_Stations`, 숨김 · 보임은 `_Page_Life` 가 맡는다.) + * `_Page_Latest`, 3D 측점 바는 `_Page_Stations`, 숨김 · 보임은 `_Page_Life` 가 맡는다. + * 노선 · 종단면 그리기는 `design_view_profile_sections`, 3D 몫은 `design_view_profile_3d`.) * ========================================================================== */ 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 { - clearRouteLatestCache, - updateContourInterval, - type RouteLatestResponse, -} from "../../B05_Profile/B05_Profile_Api_Fetch"; +import { fetchConfirmedSurface } from "../../B04_PreProcess/B04_PreProcess_Api_Fetch"; +import { clearRouteLatestCache } 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"; @@ -51,28 +45,18 @@ import { rememberRockBoundaryDefault, rememberStandardDefaults, } from "../../B06_Section/B06_Section_UI_Standard_Panel"; +import { fetchSectionContext } from "../../B06_Section/B06_Section_Api_Fetch"; import { - fetchSectionContext, - type SectionDetailResponse, -} from "../../B06_Section/B06_Section_Api_Fetch"; -import { loadSectionDetail } from "../../B06_Section/B06_Section_Section_Store"; -import { - migrateLegacyStations, onStaleStructureTypes, structureAnchorM, } 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 { saveCorridorIfDirty } from "../../B05_Profile/B05_Profile_UI_Corridor"; import { profileSaveParts, 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 { createLatestSource } from "../../B05_Profile/B05_Profile_UI_Page_Latest"; import { setPipeCrossOpener, setPipeSectionsSource } from "./design_structure_pipe_calc"; import type { RouteProfilePanelCallbacks } from "../../B05_Profile/B05_Profile_UI_Profile_Panel_Types"; import "../../B05_Profile/B05_Profile_UI_Style.css"; @@ -85,14 +69,11 @@ import { DEFAULT_ROAD_WIDTHS, fetchRoadWidths, loadUphillOverrides, - restorePoints, saveUphillOverrides, - toBounds, toGradeClass, } from "../../B05_Profile/B05_Profile_UI_Page_Helpers"; - -/** 구 구조물 측점 이관을 이미 시도한 노선 — 탭 수명 동안 유지한다. */ -const migratedRoutes = new Set(); +import { createProfileSections, type ProfileViewState } from "./design_view_profile_sections"; +import { createProfile3d } from "./design_view_profile_3d"; type PipeReport = Parameters>[0]; @@ -133,7 +114,7 @@ export async function mountProfileView( * 관도 없는 자리면 그 측점선 · 3D 띠(횡단에서 고른 일반 측점 · 188-1) — 구조물 고름은 선 없음(189-1). */ const selectGraphAt = (id: string | null, chainageM: number | null): void => { const structurePick = !!readSelection(activeProjectId)?.structureId; - const sections = structurePick ? [] : (currentSectionDetail?.cross_sections ?? []); + const sections = structurePick ? [] : (state.detail?.cross_sections ?? []); const target = graphTargetAt(id, chainageM, lastPipes ?? [], sections); if ("pipeChainageM" in target) return profilePanel.setSelectedStructure(pipeIdAt(target.pipeChainageM, activeProjectId)); @@ -199,19 +180,19 @@ export async function mountProfileView( }, ); - /** 확정 지표면 — 모델 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; + const state: ProfileViewState = { + confirmedSurface: null, + latest: null, + roadWidths: DEFAULT_ROAD_WIDTHS, + detail: null, + corridorVisible: true, + routeReady: false, + restoring: true, + }; // 배관 관 길이 · 흙막이 개수(54-1) — 종단 보기도 같은 횡단 자료로(B05 = B06) - setPipeSectionsSource(() => currentSectionDetail?.cross_sections ?? null); + setPipeSectionsSource(() => state.detail?.cross_sections ?? null); // 좌측 단 수를 바꾸면 횡단 보기를 숨긴 채 세워 같은 단 수 제어로 씀(54-27) setPipeCrossOpener(() => shared.ensureView("cross")); - /** [예상형상] 표시 상태 — 측점 바를 계획고 위로 올릴지 판단한다(패널 기본 ON). */ - let corridorVisible = true; - let routeReady = false; - let restoring = true; // 선택 동기화 재진입 가드(3D↔그래프↔사이드바 상호 갱신의 무한 재귀 차단). let selectionSyncing = false; @@ -239,8 +220,7 @@ export async function mountProfileView( markStation: (chainageM) => profilePanel.drainage.markStation(chainageM), selectStructure3D: (chainageM) => viewer.structurePick.selectAtChainage(chainageM), regularChainage: (stationId) => - currentSectionDetail?.cross_sections.find((entry) => entry.station_id === stationId) - ?.chainage_m, + state.detail?.cross_sections.find((entry) => entry.station_id === stationId)?.chainage_m, remember: (chainageM) => rememberPick(chainageM, null), }); @@ -253,7 +233,7 @@ export async function mountProfileView( let oldSnapped = false; /** 옛 소수 측점(관 · 구조물)을 정수 m 로 — 관 · 구조물 · 계획고 편집이 다 실린 뒤 한 번(272-4). */ function snapOld(): void { - if (oldSnapped || restoring || !lastPipes) return; + if (oldSnapped || state.restoring || !lastPipes) return; oldSnapped = true; panel.structures.snapOld(); } @@ -269,6 +249,24 @@ export async function mountProfileView( const latestSource = createLatestSource(activeProjectId); const loadLatest = latestSource.load; + const sections = createProfileSections({ + projectId: activeProjectId, + state, + viewer, + panel: () => panel, + profilePanel: () => profilePanel, + bridge: () => bridge, + }); + const { restorePanel, renderLatest, restoreSections } = sections; + const view3d = createProfile3d({ + projectId: activeProjectId, + state, + viewer, + host, + latestSource, + profilePanel: () => profilePanel, + sections: () => sections, + }); const panel = createRoutePanel( { @@ -280,44 +278,12 @@ export async function mountProfileView( // 측점 눈금 간격은 좌측 패널이 쥔 값을 그대로 넘긴다 — 모달이 따로 굳히지 않는다. 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, + surfaceModelId: state.confirmedSurface?.model_id ?? null, + contourIntervalM: state.latest?.surface_params.contour_interval_m, + smooth: state.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); - }, + onContourApply: (interval) => view3d.applyContours(interval), + ...view3d.viewCallbacks, onMovePoint: viewer.beginMoveSelected, onDeletePoint: viewer.markers.deleteSelected, onRadiusChange: (radius) => viewer.markers.updateSelected({ radius_m: radius }), @@ -362,11 +328,11 @@ export async function mountProfileView( /** 입력·마커 변경 시 측점 라인만 다시 그린다. 지형 구분·등급·기울기 세부 입력은 종단 그래프의 * 위반 판정 기준까지 **즉시** 갈아 끼운다(2026-08-19 사용자 지시 6). */ function markStale(): void { - if (restoring || !routeReady) return; + if (state.restoring || !state.routeReady) return; profilePanel.setGradeLimit(panel.gradeLimitPct()); // 횡단배수 최소고 강제 체크는 재계산 없이 편집 가드에만 즉시 반영된다(2026-09-01). profilePanel.setEnforceMinCover(panel.values().enforcePipeClearance); - if (currentSectionDetail) renderStationLines(currentSectionDetail); + sections.redrawStationLines(); } viewer.markers.onChange(markStale); @@ -377,31 +343,13 @@ export async function mountProfileView( }); 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, - }); - 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, + renderStationLines: () => sections.redrawStationLines(), + isRestoring: () => state.restoring, }); // 종단도 · 3D 강조 = 고름 한 칸에서 파생 · 이 보기가 보일 때만(횡단 보기는 제 규칙 · 233-1 · 233-2) const profilePick = bindProfilePick( @@ -411,129 +359,18 @@ export async function mountProfileView( { irregular: () => bridge.irregularStations(), pipes: () => lastPipes ?? [], - sections: () => currentSectionDetail?.cross_sections ?? [], + sections: () => state.detail?.cross_sections ?? [], }, ); // 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"); - } - } + bridge.mountViewerMenu(viewer, () => state.latest?.route_points ?? []); // [경로 계산] · 저장 몫은 따로 뗀 모듈이 맡는다(2026-09-02). const actionContext: PageActionContext = { projectId: activeProjectId, - latest: () => latest, - confirmedSurface: () => confirmedSurface, - routeReady: () => routeReady, + latest: () => state.latest, + confirmedSurface: () => state.confirmedSurface, + routeReady: () => state.routeReady, viewer: () => viewer, panel: () => panel, profilePanel: () => profilePanel, @@ -551,13 +388,13 @@ export async function mountProfileView( }); // 탭이 다시 보일 때 정의 판이 바뀌었으면 종류 · 패널 다시 읽음(322-0) - const reloadIfShown = (): void => void (host.visible() && !restoring && bridge.load()); + const reloadIfShown = (): void => void (host.visible() && !state.restoring && bridge.load()); host.onDispose(onStaleStructureTypes(reloadIfShown)); // 한 페이지 두 보기 — 숨김 · 보임 · 떠남(계획서 24-3a · 24-3b). bindProfileViewLife(host, { projectId: activeProjectId, viewer, - ready: () => !restoring, + ready: () => !state.restoring, // 다시 보이면 횡단 보기가 한 벌(폼 · 종단도)에 실어 둔 값을 종단 보기 값으로 되돌린다. reload: async () => { await bridge.load(); @@ -577,7 +414,7 @@ export async function mountProfileView( }, // 보기를 떠나는 자리 = 코리도 영구저장 시점(2026-08-23 사용자 확정) — 이동은 막지 않는다. onHide: () => { - if (latest?.route?.id) void saveCorridorIfDirty(activeProjectId, latest.route.id); + if (state.latest?.route?.id) void saveCorridorIfDirty(activeProjectId, state.latest.route.id); }, }); @@ -609,7 +446,7 @@ export async function mountProfileView( fetchRoadWidths(activeProjectId), ]); if (host.disposed()) return; - roadWidths = configuredRoadWidths; + state.roadWidths = configuredRoadWidths; panel.restore({ stationInterval: sectionContext.defaults.station_interval_m, crossSampleInterval: sectionContext.defaults.cross_sample_interval_m, @@ -633,8 +470,8 @@ export async function mountProfileView( // ② 확정 지표면 — 모델 id·범위를 한 번에 받는다. const confirmed = await fetchConfirmedSurface(activeProjectId); - confirmedSurface = confirmed.model_id === null ? null : confirmed; - if (!confirmedSurface) { + state.confirmedSurface = confirmed.model_id === null ? null : confirmed; + if (!state.confirmedSurface) { // 새 자료가 올라와 옛 결과가 지워진 상태 — 여기서 보여 줄 게 없다. leaveForDashboard(); return; @@ -668,33 +505,11 @@ export async function mountProfileView( showToast(error instanceof Error ? error.message : "화면을 불러오지 못했습니다.", "error"); } finally { progress.remove(); - restoring = false; + state.restoring = false; snapOld(); 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"); - } - })(); + void view3d.loadTerrain(); } diff --git a/A00_Common/design/design_view_profile_3d.ts b/A00_Common/design/design_view_profile_3d.ts new file mode 100644 index 000000000..16a7dda32 --- /dev/null +++ b/A00_Common/design/design_view_profile_3d.ts @@ -0,0 +1,114 @@ +/* ============================================================================= + * design_view_profile_3d.ts + * 종단 보기의 **3D 몫** — 보기 조작 줄 콜백 · [3D 업데이트] · 등고선 다시 적용 · 3D 지형 올리기. + * `design_view_profile.ts` 가 700줄을 넘어 뗀 조각(345-1). 상태는 본체가 쥔 `ProfileViewState`. + * ========================================================================== */ + +import { showToast } from "@ui/ui_template_elements"; +import { updateContourInterval } from "../../B05_Profile/B05_Profile_Api_Fetch"; +import { refreshCorridor, saveCorridorIfDirty } from "../../B05_Profile/B05_Profile_UI_Corridor"; +import type { createLatestSource } from "../../B05_Profile/B05_Profile_UI_Page_Latest"; +import { toBounds } from "../../B05_Profile/B05_Profile_UI_Page_Helpers"; +import type { ViewControlCallbacks } from "../../B05_Profile/B05_Profile_UI_Panel_View"; +import type { createRouteProfilePanel } from "../../B05_Profile/B05_Profile_UI_Profile_Panel"; +import type { RouteViewer } from "../../B05_Profile/B05_Profile_UI_Viewer"; +import type { DesignPageHost } from "./design_page"; +import type { createProfileSections, ProfileViewState } from "./design_view_profile_sections"; + +export interface Profile3dDeps { + projectId: string; + state: ProfileViewState; + viewer: RouteViewer; + host: DesignPageHost; + latestSource: ReturnType; + profilePanel: () => ReturnType; + sections: () => ReturnType; +} + +export function createProfile3d(deps: Profile3dDeps) { + const { projectId, state, viewer } = deps; + + /** 보기 조작 줄(ISO · 켬끔 · [3D 업데이트])이 부르는 것 — 좌측 패널 콜백에 섞어 넘긴다. */ + const viewCallbacks: ViewControlCallbacks = { + onSurfaceVisible: viewer.setSurfaceVisible, + onCorridorVisible: (visible) => { + state.corridorVisible = visible; + viewer.setCorridorVisible(visible); + // 측점 바 기준면이 바뀐다(예상형상 위 ↔ 원지반 위) — 다시 그린다. + deps.sections().redrawStationLines(); + }, + 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 () => { + const detail = state.detail; + const latest = state.latest; + if (!detail || !latest?.route?.id) return; + const routePoints = latest.route_points ?? []; + if (routePoints.length <= 1) return; + await refreshCorridor( + viewer, + projectId, + latest.route.id, + detail, + routePoints, + deps.profilePanel().alignmentSamples() ?? undefined, + ); + // 측점 바·라벨·램프도 계획고를 따라 움직여야 한다(2026-08-23 지적 ③). + deps.sections().renderStationLines(detail); + // 만든 것을 **바로 올려 둔다**(2026-09-06) — 다음 진입이 네트워크 없이 선다. + void saveCorridorIfDirty(projectId, latest.route.id); + }, + }; + + async function applyContours(interval: number): Promise { + // 화면 전체를 잠그지 않는다 — 진행 표시는 [재적용] 버튼이 맡는다(CLAUDE.md 5장). + try { + await viewer.reloadContours(interval); + await updateContourInterval(projectId, interval); + // 세션 캐시에도 반영해 다음 진입 시 옛 등고선 간격으로 복원되지 않게 한다. + const cached = deps.latestSource.read(); + if (cached) { + deps.latestSource.write({ + ...cached, + surface_params: { ...cached.surface_params, contour_interval_m: interval }, + }); + } + } catch (error) { + showToast(error instanceof Error ? error.message : "등고선 조회에 실패했습니다.", "error"); + } + } + + /** ④ 3D 지형 — 화면을 잡지 않고 뒤에서 올린다. 실패해도 나머지는 그대로 쓴다. */ + async function loadTerrain(): Promise { + const surface = state.confirmedSurface; + const current = state.latest; + if (!surface || !current || deps.host.disposed()) return; + try { + // 범위는 ②에서 받아 둔 확정 응답에 이미 들어 있다 — 다시 부르지 않는다. + if (!surface.bounds) throw new Error("지표면 범위 정보를 찾을 수 없습니다."); + await viewer.loadSurface( + projectId, + surface.model_id as number, + current.surface_params.method, + current.surface_params.smooth, + current.surface_params.contour_interval_m, + toBounds(surface.bounds), + ); + if (deps.host.disposed()) return; // 올리는 사이 페이지를 떠났다. + deps.sections().renderLatest(current); // 지형이 올라온 뒤에야 마커·측점선이 지형 위에 놓인다. + deps.sections().redrawStationLines(); + showToast("3D 지형 준비 완료", "success"); + } catch (error) { + showToast(error instanceof Error ? error.message : "3D 지형을 불러오지 못했습니다.", "error"); + } + } + + return { viewCallbacks, applyContours, loadTerrain }; +} diff --git a/A00_Common/design/design_view_profile_sections.ts b/A00_Common/design/design_view_profile_sections.ts new file mode 100644 index 000000000..bd0e8118b --- /dev/null +++ b/A00_Common/design/design_view_profile_sections.ts @@ -0,0 +1,179 @@ +/* ============================================================================= + * 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 } from "../../B06_Section/B06_Section_Section_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; + routeReady: boolean; + restoring: boolean; +} + +export interface ProfileSectionsDeps { + projectId: string; + state: ProfileViewState; + viewer: RouteViewer; + panel: () => RoutePanel; + profilePanel: () => ReturnType; + bridge: () => ReturnType; +} + +/** 구 구조물 측점 이관을 이미 시도한 노선 — 탭 수명 동안 유지한다. */ +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) => panel.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); + 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", + ); + } + } + + 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, + }; +}