diff --git a/B04_PreProcess/B04_PreProcess_UI_TerrainViewer.ts b/B04_PreProcess/B04_PreProcess_UI_TerrainViewer.ts index 86e879a3..ad92027c 100644 --- a/B04_PreProcess/B04_PreProcess_UI_TerrainViewer.ts +++ b/B04_PreProcess/B04_PreProcess_UI_TerrainViewer.ts @@ -3,11 +3,11 @@ import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js"; import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js"; import { PLYLoader } from "three/examples/jsm/loaders/PLYLoader.js"; import { API_BASE_URL } from "@config/config_frontend"; -import { fetchCachedBytes, fetchCachedJson } from "../A00_Common/b_asset_cache"; +import { fetchCachedBytes } from "../A00_Common/b_asset_cache"; import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; -import { createProgressCircle } from "@ui/ui_template_progress"; // 계획선 색은 2D 지도·B05 배수유역도와 한 곳에서 나온다 — 같은 선을 다른 색으로 그리지 않는다. -import { createTerrainCompass } from "@ui/ui_template_compass"; +import { buildTerrainViewerChrome } from "./B04_PreProcess_UI_TerrainViewer_Chrome"; +import { loadContourLinesInto } from "./B04_PreProcess_UI_TerrainViewer_Contours"; import { routeLineColor } from "./B04_PreProcess_UI_MapRender"; import type { SurfaceBounds, SurfaceModelSummary } from "./B04_PreProcess_Api_Fetch"; import { @@ -21,10 +21,6 @@ import { type SurfaceCameraState, } from "./B04_PreProcess_UI_Camera"; -/** 화면에 띄우는 등고 라벨 상한 — 긴 등고선부터 채운다. 조각이 많은 지형에서 - * 라벨이 수백 개가 되면 매 프레임 위치 재계산이 화면을 멈춰 세운다(2026-08-30). */ -const MAX_CONTOUR_LABELS = 40; - function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; } @@ -56,166 +52,32 @@ export interface SurfaceTerrainViewer { } export function createSurfaceTerrainViewer(): SurfaceTerrainViewer { - const root = document.createElement("div"); - root.className = "terrain-model-group"; - - const statusSpan = document.createElement("span"); - statusSpan.className = "terrain-status"; - statusSpan.style.fontSize = "var(--text-caption)"; - statusSpan.style.color = "var(--color-text-secondary)"; - statusSpan.textContent = "모델 선택 대기 중..."; + // DOM 뼈대는 700줄 제한으로 `_TerrainViewer_Chrome` 로 옮겼다 — 이름·순서는 그대로다. + const { + root, + statusSpan, + axesCheck, + surfCheck, + smoothLabel, + smoothSelect, + contourCheck, + intervalForm, + intervalInput, + intervalSubmit, + optionsContent, + viewerArea, + canvas, + scaleBar, + scaleLabel, + compass, + legendBar, + maxValSpan, + minValSpan, + progress, + } = buildTerrainViewerChrome(); let activeFilter = "csf"; let activeMethod = "dtm"; - const axesCheck = document.createElement("input"); - axesCheck.type = "checkbox"; - axesCheck.checked = false; - - const rightControls = document.createElement("div"); - rightControls.className = "viewer-options model-display-options"; - - // Surface Toggle - const surfLabel = document.createElement("label"); - surfLabel.className = "toggle-label toggle-button"; - const surfCheck = document.createElement("input"); - surfCheck.type = "checkbox"; - surfCheck.checked = true; - surfLabel.append(surfCheck, document.createTextNode(" 서피스")); - - // 스무딩(tin/dtm 전용) — 지면 필터·서피스와 함께 "지표면 분석" 컨테이너로 옮겼다. - // 주변 입력과 양식을 맞추려고 버튼이 아니라 드롭다운이다(2026-08-01 사용자 지시). - // 상태·재렌더 배선은 여기 그대로 두고, 페이지는 이 엘리먼트를 원하는 자리에 놓기만 한다. - const smoothLabel = document.createElement("label"); - smoothLabel.className = "b04-surface__field"; - const smoothCaption = document.createElement("span"); - smoothCaption.textContent = L("B04_Surface_Field_Smoothing"); - const smoothSelect = document.createElement("select"); - smoothSelect.className = "b04-surface__select"; - ( - [ - ["on", "B04_Surface_Smoothing_On"], - ["off", "B04_Surface_Smoothing_Off"], - ] as const - ).forEach(([value, key]) => { - const option = document.createElement("option"); - option.value = value; - option.textContent = L(key); - smoothSelect.append(option); - }); - smoothSelect.value = "on"; - smoothLabel.append(smoothCaption, smoothSelect); - - // Contour Toggle - const contourLabel = document.createElement("label"); - contourLabel.className = "toggle-label toggle-button"; - const contourCheck = document.createElement("input"); - contourCheck.type = "checkbox"; - contourCheck.checked = true; - contourLabel.append(contourCheck, document.createTextNode(" 등고선")); - - // Contour Interval input form - const intervalForm = document.createElement("form"); - intervalForm.className = "contour-interval-form"; - - const intervalInput = document.createElement("input"); - intervalInput.type = "number"; - intervalInput.value = "1.0"; - intervalInput.step = "0.5"; - intervalInput.min = "0.5"; - intervalInput.className = "contour-interval-input"; - - const intervalSubmit = document.createElement("button"); - intervalSubmit.type = "submit"; - intervalSubmit.textContent = "적용"; - intervalSubmit.className = "contour-interval-submit"; - - intervalForm.append( - document.createTextNode("간격 "), - intervalInput, - document.createTextNode("m "), - intervalSubmit, - ); - - const axesLabel = document.createElement("label"); - axesLabel.className = "toggle-label toggle-button"; - axesLabel.append(axesCheck, document.createTextNode(" 축")); - rightControls.append(axesLabel, surfLabel, contourLabel, intervalForm); - - // 표시 토글 묶음 + 상태 줄. 컨테이너(제목)는 페이지가 만든다 — 포인트 옵션과 한 칸을 쓴다. - const optionsContent = document.createElement("div"); - optionsContent.className = "terrain-options-content"; - optionsContent.append(rightControls, statusSpan); - - // 3D View container - const viewerArea = document.createElement("div"); - viewerArea.className = "three-viewer"; - viewerArea.style.position = "relative"; - viewerArea.style.borderRadius = "0 0 var(--radius-cards) var(--radius-cards)"; - viewerArea.style.overflow = "hidden"; - - const canvas = document.createElement("canvas"); - canvas.className = "b04-surface-viewer__canvas"; - viewerArea.append(canvas); - root.append(viewerArea); - - // Scale bar overlay - const scaleBar = document.createElement("div"); - scaleBar.className = "b04-surface__scale"; - scaleBar.hidden = true; - - const scaleLabel = document.createElement("span"); - scaleBar.append(scaleLabel); - viewerArea.append(scaleBar); - - // 방위 콤파스 — 축척 막대 반대편(우하단). 바늘 각도는 애니메이션 루프가 맞춘다. - const compass = createTerrainCompass({ className: "b04-surface__compass" }); - viewerArea.append(compass.root); - - // Elevation bounds legend bar overlay (I-403) - const legendBar = document.createElement("div"); - legendBar.style.position = "absolute"; - legendBar.style.top = "16px"; - legendBar.style.right = "16px"; - legendBar.style.background = "rgba(255, 255, 255, 0.9)"; - legendBar.style.border = "1px solid #cbd5e1"; - legendBar.style.borderRadius = "6px"; - legendBar.style.padding = "8px"; - legendBar.style.width = "50px"; - legendBar.style.display = "none"; // hidden until contours are loaded - legendBar.style.flexDirection = "column"; - legendBar.style.alignItems = "center"; - legendBar.style.zIndex = "10"; - legendBar.style.boxShadow = "0 2px 6px rgba(0,0,0,0.08)"; - legendBar.style.pointerEvents = "none"; - - const maxValSpan = document.createElement("span"); - maxValSpan.style.fontSize = "10px"; - maxValSpan.style.fontWeight = "bold"; - maxValSpan.style.color = "#b91c1c"; - maxValSpan.style.marginBottom = "4px"; - - const gradientDiv = document.createElement("div"); - gradientDiv.style.width = "12px"; - gradientDiv.style.height = "120px"; - gradientDiv.style.background = - "linear-gradient(to bottom, #d60000 0%, #ff5100 25%, #e6a100 50%, #228b22 75%, #3a85ff 100%)"; - gradientDiv.style.borderRadius = "2px"; - gradientDiv.style.border = "1px solid #94a3b8"; - - const minValSpan = document.createElement("span"); - minValSpan.style.fontSize = "10px"; - minValSpan.style.fontWeight = "bold"; - minValSpan.style.color = "#1d4ed8"; - minValSpan.style.marginTop = "4px"; - - legendBar.append(maxValSpan, gradientDiv, minValSpan); - viewerArea.append(legendBar); - - // 뷰포트 정중앙 로딩 서클 — 메쉬 파일은 수십 MB라 내려받는 동안 화면이 비어 보인다. - const progress = createProgressCircle({ overlay: true }); - progress.root.hidden = true; - viewerArea.append(progress.root); - /** 진행률(0~1, 모르면 null)과 문구를 표시한다. label이 null이면 서클을 감춘다. */ function showProgress(ratio: number | null, label: string | null): void { progress.root.hidden = label === null; @@ -600,157 +462,35 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer { } } - async function loadContourLines( + /** 등고선 적재는 700줄 제한으로 `_TerrainViewer_Contours` 로 옮겼다 — 호출부는 그대로다. */ + const loadContourLines = ( modelId: number, isSmooth: boolean, recalculate = false, - ): Promise { - const interval = parseFloat(intervalInput.value) || 1.0; - const projectId = currentProjectId; - const contourUrl = `${API_BASE_URL}/projects/${projectId}/surface/models/${modelId}/contour?interval=${interval}&smooth=${isSmooth}&recalculate=${recalculate}`; - - try { - // 등고선도 보관함에서 먼저 찾는다 — 같은 파일을 새로고침마다 다시 내려받지 않는다. - const data = await fetchCachedJson(projectId, contourUrl); - if ( - currentProjectId !== projectId || - currentModelId !== modelId || - currentModelSmooth !== isSmooth || - (parseFloat(intervalInput.value) || 1.0) !== interval - ) { - return false; - } - - clearContours(); - - const bounds = data.bounds; - if (!bounds) return false; - - const cx = (bounds.x[0] + bounds.x[1]) / 2; - const cy = (bounds.y[0] + bounds.y[1]) / 2; - const cz = (bounds.z[0] + bounds.z[1]) / 2; - - const transform = (coords: [number, number, number][]) => { - return coords.map(([x_model, y_model, z_model]) => { - const x_scene = x_model - cx; - const y_scene = z_model - cz; - const z_scene = -(y_model - cy); - return new THREE.Vector3(x_scene, y_scene, z_scene); - }); - }; - - let minH = Infinity; - let maxH = -Infinity; - // 등고선 한 가닥마다 3D 객체를 만들면 수백 개가 되어 그리기가 느려진다. - // 주곡선·보조곡선 두 덩어리로 합쳐 객체 2개만 만든다(2026-08-01). - const majorPoints: THREE.Vector3[] = []; - const minorPoints: THREE.Vector3[] = []; - const labelCandidates: { - level: number; - position: THREE.Vector3; - length: number; - }[] = []; - - data.contours.forEach((c: any) => { - if (c.level < minH) minH = c.level; - if (c.level > maxH) maxH = c.level; - - const points = transform(c.coordinates); - if (points.length < 2) return; - - const isMajor = c.level % (interval * 5) === 0; - const bucket = isMajor ? majorPoints : minorPoints; - for (let i = 0; i < points.length - 1; i++) { - bucket.push(points[i], points[i + 1]); - } - - // 라벨은 여기서 만들지 않고 후보만 모은다 — 등고선이 잘게 쪼개지면 조각마다 - // 라벨이 붙어 수백 개가 되고, 매 프레임 위치 재계산이 화면을 멈춰 세운다 - // (2026-08-30 사용자 보고). 아래에서 긴 것부터 상한만큼만 만든다. - if (isMajor && points.length > 4) { - let length = 0; - for (let i = 0; i < points.length - 1; i++) { - length += points[i].distanceTo(points[i + 1]); - } - labelCandidates.push({ - level: c.level, - position: points[Math.floor(points.length / 2)], - length, - }); - } - }); - - labelCandidates.sort((a, b) => b.length - a.length); - for (const candidate of labelCandidates.slice(0, MAX_CONTOUR_LABELS)) { - const labelPos = candidate.position; - const labelDiv = document.createElement("div"); - labelDiv.className = "contour-label"; - labelDiv.innerText = `${Math.round(candidate.level)}m`; - labelDiv.style.position = "absolute"; - labelDiv.style.background = "rgba(255, 255, 255, 0.85)"; - labelDiv.style.border = "1px solid #d97706"; - labelDiv.style.color = "#b45309"; - labelDiv.style.padding = "1px 4px"; - labelDiv.style.borderRadius = "3px"; - labelDiv.style.fontSize = "9px"; - labelDiv.style.fontWeight = "bold"; - labelDiv.style.pointerEvents = "none"; - labelDiv.style.zIndex = "5"; - labelDiv.style.transform = "translate(-50%, -50%)"; - - (labelDiv as any).__updateLabelPos = () => { - if (!contourCheck.checked) { - labelDiv.style.display = "none"; - return; - } - const proj = labelPos.clone().project(camera); - const x = (proj.x * 0.5 + 0.5) * viewerArea.clientWidth; - const y = (-(proj.y * 0.5) + 0.5) * viewerArea.clientHeight; - - if (proj.z > 1) { - labelDiv.style.display = "none"; - } else { - labelDiv.style.display = "block"; - labelDiv.style.left = `${x}px`; - labelDiv.style.top = `${y}px`; - } - }; - - viewerArea.appendChild(labelDiv); - labelElements.push(labelDiv); - labelsDirty = true; - } - - // 합쳐 둔 점들을 주곡선·보조곡선 각각 한 덩어리로 올린다. - [ - { points: minorPoints, color: 0xf59e0b }, - { points: majorPoints, color: 0xd97706 }, - ].forEach(({ points, color }) => { - if (points.length === 0) return; - const geometry = new THREE.BufferGeometry().setFromPoints(points); - const material = new THREE.LineBasicMaterial({ - color, - transparent: true, - opacity: 0.8, - }); - contourGroup.add(new THREE.LineSegments(geometry, material)); - }); - - if (minH !== Infinity && maxH !== -Infinity) { - const nearestMin10 = Math.round(minH / 10) * 10; - const nearestMax10 = Math.round(maxH / 10) * 10; - maxValSpan.textContent = `${nearestMax10}m`; - minValSpan.textContent = `${nearestMin10}m`; - legendBar.style.display = "flex"; - } else { - legendBar.style.display = "none"; - } - return true; - } catch (e) { - legendBar.style.display = "none"; - return false; - } - } + ): Promise => + loadContourLinesInto( + { + viewerArea, + legendBar, + maxValSpan, + minValSpan, + intervalInput, + camera, + contourGroup, + contourCheck, + projectId: () => currentProjectId, + currentModelId: () => currentModelId, + currentModelSmooth: () => currentModelSmooth, + labelElements, + setLabelsDirty: (dirty) => { + labelsDirty = dirty; + }, + clearContours, + }, + modelId, + isSmooth, + recalculate, + ); async function loadSelectedContours( modelId: number, diff --git a/B04_PreProcess/B04_PreProcess_UI_TerrainViewer_Chrome.ts b/B04_PreProcess/B04_PreProcess_UI_TerrainViewer_Chrome.ts new file mode 100644 index 00000000..9df8648a --- /dev/null +++ b/B04_PreProcess/B04_PreProcess_UI_TerrainViewer_Chrome.ts @@ -0,0 +1,209 @@ +/* ============================================================================= + * B04_PreProcess_UI_TerrainViewer_Chrome.ts + * 지표면 3D 뷰어의 **DOM 뼈대** — 상태줄·표시 토글·등고선 간격 폼·뷰포트·축척 막대· + * 방위 컴파스·표고 범례·로딩 서클을 만들어 넘긴다. + * + * `B04_PreProcess_UI_TerrainViewer` 가 700줄을 넘겨 떼어낸 조각이다(2026-09-04). + * 만드는 순서·클래스·인라인 스타일은 옮기기 전 그대로다. 이벤트 배선과 THREE 렌더링은 + * 뷰어 본체에 남아 있고, 여기서는 엘리먼트만 만든다. + * ========================================================================== */ + +import { createTerrainCompass } from "@ui/ui_template_compass"; +import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; +import { createProgressCircle } from "@ui/ui_template_progress"; + +function L(key: keyof typeof ui_locales): string { + return ui_locales[key][currentLanguageIndex]; +} + +/** 뷰어 본체가 이어 쓸 엘리먼트 묶음 — 이름은 분리 전 지역변수와 같다. */ +export type TerrainViewerChrome = ReturnType; + +export function buildTerrainViewerChrome() { + const root = document.createElement("div"); + root.className = "terrain-model-group"; + + const statusSpan = document.createElement("span"); + statusSpan.className = "terrain-status"; + statusSpan.style.fontSize = "var(--text-caption)"; + statusSpan.style.color = "var(--color-text-secondary)"; + statusSpan.textContent = "모델 선택 대기 중..."; + + const axesCheck = document.createElement("input"); + axesCheck.type = "checkbox"; + axesCheck.checked = false; + + const rightControls = document.createElement("div"); + rightControls.className = "viewer-options model-display-options"; + + // Surface Toggle + const surfLabel = document.createElement("label"); + surfLabel.className = "toggle-label toggle-button"; + const surfCheck = document.createElement("input"); + surfCheck.type = "checkbox"; + surfCheck.checked = true; + surfLabel.append(surfCheck, document.createTextNode(" 서피스")); + + // 스무딩(tin/dtm 전용) — 지면 필터·서피스와 함께 "지표면 분석" 컨테이너로 옮겼다. + // 주변 입력과 양식을 맞추려고 버튼이 아니라 드롭다운이다(2026-08-01 사용자 지시). + // 상태·재렌더 배선은 여기 그대로 두고, 페이지는 이 엘리먼트를 원하는 자리에 놓기만 한다. + const smoothLabel = document.createElement("label"); + smoothLabel.className = "b04-surface__field"; + const smoothCaption = document.createElement("span"); + smoothCaption.textContent = L("B04_Surface_Field_Smoothing"); + const smoothSelect = document.createElement("select"); + smoothSelect.className = "b04-surface__select"; + ( + [ + ["on", "B04_Surface_Smoothing_On"], + ["off", "B04_Surface_Smoothing_Off"], + ] as const + ).forEach(([value, key]) => { + const option = document.createElement("option"); + option.value = value; + option.textContent = L(key); + smoothSelect.append(option); + }); + smoothSelect.value = "on"; + smoothLabel.append(smoothCaption, smoothSelect); + + // Contour Toggle + const contourLabel = document.createElement("label"); + contourLabel.className = "toggle-label toggle-button"; + const contourCheck = document.createElement("input"); + contourCheck.type = "checkbox"; + contourCheck.checked = true; + contourLabel.append(contourCheck, document.createTextNode(" 등고선")); + + // Contour Interval input form + const intervalForm = document.createElement("form"); + intervalForm.className = "contour-interval-form"; + + const intervalInput = document.createElement("input"); + intervalInput.type = "number"; + intervalInput.value = "1.0"; + intervalInput.step = "0.5"; + intervalInput.min = "0.5"; + intervalInput.className = "contour-interval-input"; + + const intervalSubmit = document.createElement("button"); + intervalSubmit.type = "submit"; + intervalSubmit.textContent = "적용"; + intervalSubmit.className = "contour-interval-submit"; + + intervalForm.append( + document.createTextNode("간격 "), + intervalInput, + document.createTextNode("m "), + intervalSubmit, + ); + + const axesLabel = document.createElement("label"); + axesLabel.className = "toggle-label toggle-button"; + axesLabel.append(axesCheck, document.createTextNode(" 축")); + rightControls.append(axesLabel, surfLabel, contourLabel, intervalForm); + + // 표시 토글 묶음 + 상태 줄. 컨테이너(제목)는 페이지가 만든다 — 포인트 옵션과 한 칸을 쓴다. + const optionsContent = document.createElement("div"); + optionsContent.className = "terrain-options-content"; + optionsContent.append(rightControls, statusSpan); + + // 3D View container + const viewerArea = document.createElement("div"); + viewerArea.className = "three-viewer"; + viewerArea.style.position = "relative"; + viewerArea.style.borderRadius = "0 0 var(--radius-cards) var(--radius-cards)"; + viewerArea.style.overflow = "hidden"; + + const canvas = document.createElement("canvas"); + canvas.className = "b04-surface-viewer__canvas"; + viewerArea.append(canvas); + root.append(viewerArea); + + // Scale bar overlay + const scaleBar = document.createElement("div"); + scaleBar.className = "b04-surface__scale"; + scaleBar.hidden = true; + + const scaleLabel = document.createElement("span"); + scaleBar.append(scaleLabel); + viewerArea.append(scaleBar); + + // 방위 콤파스 — 축척 막대 반대편(우하단). 바늘 각도는 애니메이션 루프가 맞춘다. + const compass = createTerrainCompass({ className: "b04-surface__compass" }); + viewerArea.append(compass.root); + + // Elevation bounds legend bar overlay (I-403) + const legendBar = document.createElement("div"); + legendBar.style.position = "absolute"; + legendBar.style.top = "16px"; + legendBar.style.right = "16px"; + legendBar.style.background = "rgba(255, 255, 255, 0.9)"; + legendBar.style.border = "1px solid #cbd5e1"; + legendBar.style.borderRadius = "6px"; + legendBar.style.padding = "8px"; + legendBar.style.width = "50px"; + legendBar.style.display = "none"; // hidden until contours are loaded + legendBar.style.flexDirection = "column"; + legendBar.style.alignItems = "center"; + legendBar.style.zIndex = "10"; + legendBar.style.boxShadow = "0 2px 6px rgba(0,0,0,0.08)"; + legendBar.style.pointerEvents = "none"; + + const maxValSpan = document.createElement("span"); + maxValSpan.style.fontSize = "10px"; + maxValSpan.style.fontWeight = "bold"; + maxValSpan.style.color = "#b91c1c"; + maxValSpan.style.marginBottom = "4px"; + + const gradientDiv = document.createElement("div"); + gradientDiv.style.width = "12px"; + gradientDiv.style.height = "120px"; + gradientDiv.style.background = + "linear-gradient(to bottom, #d60000 0%, #ff5100 25%, #e6a100 50%, #228b22 75%, #3a85ff 100%)"; + gradientDiv.style.borderRadius = "2px"; + gradientDiv.style.border = "1px solid #94a3b8"; + + const minValSpan = document.createElement("span"); + minValSpan.style.fontSize = "10px"; + minValSpan.style.fontWeight = "bold"; + minValSpan.style.color = "#1d4ed8"; + minValSpan.style.marginTop = "4px"; + + legendBar.append(maxValSpan, gradientDiv, minValSpan); + viewerArea.append(legendBar); + + // 뷰포트 정중앙 로딩 서클 — 메쉬 파일은 수십 MB라 내려받는 동안 화면이 비어 보인다. + const progress = createProgressCircle({ overlay: true }); + progress.root.hidden = true; + viewerArea.append(progress.root); + + return { + root, + statusSpan, + axesCheck, + rightControls, + surfLabel, + surfCheck, + smoothLabel, + smoothCaption, + smoothSelect, + contourLabel, + contourCheck, + intervalForm, + intervalInput, + intervalSubmit, + axesLabel, + optionsContent, + viewerArea, + canvas, + scaleBar, + scaleLabel, + compass, + legendBar, + maxValSpan, + gradientDiv, + minValSpan, + progress, + }; +} diff --git a/B04_PreProcess/B04_PreProcess_UI_TerrainViewer_Contours.ts b/B04_PreProcess/B04_PreProcess_UI_TerrainViewer_Contours.ts new file mode 100644 index 00000000..a9e59ff2 --- /dev/null +++ b/B04_PreProcess/B04_PreProcess_UI_TerrainViewer_Contours.ts @@ -0,0 +1,189 @@ +/* ============================================================================= + * B04_PreProcess_UI_TerrainViewer_Contours.ts + * 지표면 3D 뷰어의 **등고선 적재** — 서버(또는 보관함)에서 등고선을 받아 THREE 라인으로 + * 얹고, 표고 범례(최고·최저)를 갱신한다. + * + * `B04_PreProcess_UI_TerrainViewer` 가 700줄을 넘겨 떼어낸 조각이다(2026-09-04). + * 본문 로직·수치는 그대로이고, 뷰어 클로저가 쥐고 있던 값만 `ctx` 로 받는다. + * ========================================================================== */ + +import * as THREE from "three"; + +import { API_BASE_URL } from "@config/config_frontend"; +import { fetchCachedJson } from "../A00_Common/b_asset_cache"; + +/** 화면에 띄우는 등고 라벨 상한 — 긴 등고선부터 채운다(분리 전 상수 그대로). */ +const MAX_CONTOUR_LABELS = 40; + +/** 뷰어 본체가 쥔 값 중 등고선 적재에 필요한 것들. */ +export interface ContourLoadContext { + viewerArea: HTMLElement; + legendBar: HTMLElement; + maxValSpan: HTMLElement; + minValSpan: HTMLElement; + intervalInput: HTMLInputElement; + camera: THREE.PerspectiveCamera; + contourGroup: THREE.Group; + contourCheck: HTMLInputElement; + /** 지금 보고 있는 프로젝트·모델 — 응답이 늦게 와도 최신 요청만 반영하려고 함수로 받는다. */ + projectId: () => string; + currentModelId: () => number | null; + currentModelSmooth: () => boolean; + /** 등고 라벨 DOM 목록(뷰어와 **같은 배열**을 공유한다)과 다시 배치하라는 표시. */ + labelElements: HTMLDivElement[]; + setLabelsDirty: (dirty: boolean) => void; + clearContours: () => void; +} + +export async function loadContourLinesInto( + ctx: ContourLoadContext, + modelId: number, + isSmooth: boolean, + recalculate = false, +): Promise { + const interval = parseFloat(ctx.intervalInput.value) || 1.0; + const projectId = ctx.projectId(); + const contourUrl = `${API_BASE_URL}/projects/${projectId}/surface/models/${modelId}/contour?interval=${interval}&smooth=${isSmooth}&recalculate=${recalculate}`; + + try { + // 등고선도 보관함에서 먼저 찾는다 — 같은 파일을 새로고침마다 다시 내려받지 않는다. + const data = await fetchCachedJson(projectId, contourUrl); + if ( + ctx.projectId() !== projectId || + ctx.currentModelId() !== modelId || + ctx.currentModelSmooth() !== isSmooth || + (parseFloat(ctx.intervalInput.value) || 1.0) !== interval + ) { + return false; + } + + ctx.clearContours(); + + const bounds = data.bounds; + if (!bounds) return false; + + const cx = (bounds.x[0] + bounds.x[1]) / 2; + const cy = (bounds.y[0] + bounds.y[1]) / 2; + const cz = (bounds.z[0] + bounds.z[1]) / 2; + + const transform = (coords: [number, number, number][]) => { + return coords.map(([x_model, y_model, z_model]) => { + const x_scene = x_model - cx; + const y_scene = z_model - cz; + const z_scene = -(y_model - cy); + return new THREE.Vector3(x_scene, y_scene, z_scene); + }); + }; + + let minH = Infinity; + let maxH = -Infinity; + // 등고선 한 가닥마다 3D 객체를 만들면 수백 개가 되어 그리기가 느려진다. + // 주곡선·보조곡선 두 덩어리로 합쳐 객체 2개만 만든다(2026-08-01). + const majorPoints: THREE.Vector3[] = []; + const minorPoints: THREE.Vector3[] = []; + const labelCandidates: { + level: number; + position: THREE.Vector3; + length: number; + }[] = []; + + data.contours.forEach((c: any) => { + if (c.level < minH) minH = c.level; + if (c.level > maxH) maxH = c.level; + + const points = transform(c.coordinates); + if (points.length < 2) return; + + const isMajor = c.level % (interval * 5) === 0; + const bucket = isMajor ? majorPoints : minorPoints; + for (let i = 0; i < points.length - 1; i++) { + bucket.push(points[i], points[i + 1]); + } + + // 라벨은 여기서 만들지 않고 후보만 모은다 — 등고선이 잘게 쪼개지면 조각마다 + // 라벨이 붙어 수백 개가 되고, 매 프레임 위치 재계산이 화면을 멈춰 세운다 + // (2026-08-30 사용자 보고). 아래에서 긴 것부터 상한만큼만 만든다. + if (isMajor && points.length > 4) { + let length = 0; + for (let i = 0; i < points.length - 1; i++) { + length += points[i].distanceTo(points[i + 1]); + } + labelCandidates.push({ + level: c.level, + position: points[Math.floor(points.length / 2)], + length, + }); + } + }); + + labelCandidates.sort((a, b) => b.length - a.length); + for (const candidate of labelCandidates.slice(0, MAX_CONTOUR_LABELS)) { + const labelPos = candidate.position; + const labelDiv = document.createElement("div"); + labelDiv.className = "contour-label"; + labelDiv.innerText = `${Math.round(candidate.level)}m`; + labelDiv.style.position = "absolute"; + labelDiv.style.background = "rgba(255, 255, 255, 0.85)"; + labelDiv.style.border = "1px solid #d97706"; + labelDiv.style.color = "#b45309"; + labelDiv.style.padding = "1px 4px"; + labelDiv.style.borderRadius = "3px"; + labelDiv.style.fontSize = "9px"; + labelDiv.style.fontWeight = "bold"; + labelDiv.style.pointerEvents = "none"; + labelDiv.style.zIndex = "5"; + labelDiv.style.transform = "translate(-50%, -50%)"; + + (labelDiv as any).__updateLabelPos = () => { + if (!ctx.contourCheck.checked) { + labelDiv.style.display = "none"; + return; + } + const proj = labelPos.clone().project(ctx.camera); + const x = (proj.x * 0.5 + 0.5) * ctx.viewerArea.clientWidth; + const y = (-(proj.y * 0.5) + 0.5) * ctx.viewerArea.clientHeight; + + if (proj.z > 1) { + labelDiv.style.display = "none"; + } else { + labelDiv.style.display = "block"; + labelDiv.style.left = `${x}px`; + labelDiv.style.top = `${y}px`; + } + }; + + ctx.viewerArea.appendChild(labelDiv); + ctx.labelElements.push(labelDiv); + ctx.setLabelsDirty(true); + } + + // 합쳐 둔 점들을 주곡선·보조곡선 각각 한 덩어리로 올린다. + [ + { points: minorPoints, color: 0xf59e0b }, + { points: majorPoints, color: 0xd97706 }, + ].forEach(({ points, color }) => { + if (points.length === 0) return; + const geometry = new THREE.BufferGeometry().setFromPoints(points); + const material = new THREE.LineBasicMaterial({ + color, + transparent: true, + opacity: 0.8, + }); + ctx.contourGroup.add(new THREE.LineSegments(geometry, material)); + }); + + if (minH !== Infinity && maxH !== -Infinity) { + const nearestMin10 = Math.round(minH / 10) * 10; + const nearestMax10 = Math.round(maxH / 10) * 10; + ctx.maxValSpan.textContent = `${nearestMax10}m`; + ctx.minValSpan.textContent = `${nearestMin10}m`; + ctx.legendBar.style.display = "flex"; + } else { + ctx.legendBar.style.display = "none"; + } + return true; + } catch (e) { + ctx.legendBar.style.display = "none"; + return false; + } +} diff --git a/B05_Profile/B05_Profile_UI_Profile_Panel.ts b/B05_Profile/B05_Profile_UI_Profile_Panel.ts index 08880c4b..23da8778 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Panel.ts @@ -14,7 +14,6 @@ import type { SectionDetailResponse } from "../B06_Section/B06_Section_Api_Fetch import { createWorkflowPanelHandle } from "@ui/ui_template_overlay"; import { createPanelResizer } from "@ui/ui_template_resizer"; import { createDrainagePanel } from "./B05_Profile_UI_Drainage_Panel"; -import type { PipeFacility, PipeSource } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; import type { StructureInstance, StructureType } from "./B05_Profile_Api_Structures"; import { createProfileTableOverlay, @@ -95,47 +94,10 @@ const CROSS_PREVIEW_DEBOUNCE_MS = 60; * 편집 기능을 끄고(지반선·계획선 차트만 표시) 재계산을 안내하는 편이 안전하다. */ -/** 종단 테이블 구조물 라인·배수유역도가 Page로 올려 보내는 알림. */ -export interface RouteProfilePanelCallbacks { - /** 관 매설 목록이 바뀜 — 화면의 배관 투영·통합 목록을 이 목록으로 맞춘다. - * 유효직경(mm)은 관경 자동 지정, 시설 종류·구간은 통합 표시에 쓴다(2026-08-17). */ - onPipesChanged?: ( - pipes: Array<{ - chainage_m: number; - effective_diameter_mm: number | null; - /** 담당 유역의 설계유량(㎥/s) — 물넘이·세월교 개략 단면의 입력. */ - design_flow_m3s?: number | null; - facility: PipeFacility; - start_m?: number; - end_m?: number; - source?: PipeSource; - options?: Record; - }>, - ) => void; - /** 테이블에서 구조물 라인을 끌어 옮김. */ - onStructureMove?: (fromChainageM: number, toChainageM: number, station: IrregularStation) => void; - /** 테이블 우클릭으로 구조물(배관 포함)을 지움. */ - onStructureRemove?: (station: IrregularStation) => void; - /** 테이블 빈 자리 우클릭으로 배관을 넣음. */ - onPipeAdd?: (chainageM: number) => void; - /** 종단·배수유역도 우클릭으로 배관 외 구조물(기성막이/대피로/기타)을 넣음. */ - onStructureAdd?: (chainageM: number, type: "기성막이" | "대피로" | "기타") => void; - /** 구조물 라인을 눌러 고름. */ - onIrregularSelect?: (station: IrregularStation) => void; - /** 종단 그래프에서 구조물 서클마크를 고름(해제면 null). */ - onStructureSelect?: (structureId: string | null) => void; - /** 종단 그래프에서 구조물 서클마크를 끌어 옮김. */ - onStructureMarkMove?: (structureId: string, toChainageM: number) => void; - /** 종단 그래프 우클릭으로 레지스트리 타입을 지정해 구조물을 넣음. */ - onStructureTypeAdd?: (chainageM: number, typeId: string) => void; - /** 배수유역도에서 유역을 고름 — 그 관의 누가거리(해제면 null). 그래프·사이드 패널을 맞춘다. */ - onBasinSelected?: (chainageM: number | null) => void; - /** 배수유역도에서 관 마커를 고름(유역 없는 관 포함) — 전 화면 동기화용(2026-08-17). */ - onPipeSelected?: (chainageM: number | null) => void; - /** 계획선 편집 프리뷰가 공유 캐시의 횡단 설계(설계선 포함)를 갱신한 뒤 — - * 3D 코리도 등 파생 표시 재빌드용(2026-08-23). */ - onCrossDesignsUpdated?: () => void; -} +/** 콜백 타입은 700줄 제한으로 `_Panel_Types` 로 옮겼다 — 옛 임포트 경로가 그대로 + * 동작하도록 여기서 다시 내보낸다(2026-09-04). */ +import type { RouteProfilePanelCallbacks } from "./B05_Profile_UI_Profile_Panel_Types"; +export type { RouteProfilePanelCallbacks } from "./B05_Profile_UI_Profile_Panel_Types"; export function createRouteProfilePanel( projectId: string, diff --git a/B05_Profile/B05_Profile_UI_Profile_Panel_Types.ts b/B05_Profile/B05_Profile_UI_Profile_Panel_Types.ts new file mode 100644 index 00000000..78c08118 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Profile_Panel_Types.ts @@ -0,0 +1,52 @@ +/* ============================================================================= + * B05_Profile_UI_Profile_Panel_Types.ts + * 종단 패널이 Page 로 올려 보내는 알림(콜백) 타입. + * + * `B05_Profile_UI_Profile_Panel` 이 700줄을 넘겨 **타입만** 떼어낸 조각이다(2026-09-04). + * 이름·필드·주석은 그대로이고, 패널이 다시 `export` 해 옛 임포트 경로도 그대로 동작한다. + * ========================================================================== */ + +import type { PipeFacility, PipeSource } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; +import type { IrregularStation } from "./B05_Profile_UI_IrregularStations"; + +/** 종단 테이블 구조물 라인·배수유역도가 Page로 올려 보내는 알림. */ +export interface RouteProfilePanelCallbacks { + /** 관 매설 목록이 바뀜 — 화면의 배관 투영·통합 목록을 이 목록으로 맞춘다. + * 유효직경(mm)은 관경 자동 지정, 시설 종류·구간은 통합 표시에 쓴다(2026-08-17). */ + onPipesChanged?: ( + pipes: Array<{ + chainage_m: number; + effective_diameter_mm: number | null; + /** 담당 유역의 설계유량(㎥/s) — 물넘이·세월교 개략 단면의 입력. */ + design_flow_m3s?: number | null; + facility: PipeFacility; + start_m?: number; + end_m?: number; + source?: PipeSource; + options?: Record; + }>, + ) => void; + /** 테이블에서 구조물 라인을 끌어 옮김. */ + onStructureMove?: (fromChainageM: number, toChainageM: number, station: IrregularStation) => void; + /** 테이블 우클릭으로 구조물(배관 포함)을 지움. */ + onStructureRemove?: (station: IrregularStation) => void; + /** 테이블 빈 자리 우클릭으로 배관을 넣음. */ + onPipeAdd?: (chainageM: number) => void; + /** 종단·배수유역도 우클릭으로 배관 외 구조물(기성막이/대피로/기타)을 넣음. */ + onStructureAdd?: (chainageM: number, type: "기성막이" | "대피로" | "기타") => void; + /** 구조물 라인을 눌러 고름. */ + onIrregularSelect?: (station: IrregularStation) => void; + /** 종단 그래프에서 구조물 서클마크를 고름(해제면 null). */ + onStructureSelect?: (structureId: string | null) => void; + /** 종단 그래프에서 구조물 서클마크를 끌어 옮김. */ + onStructureMarkMove?: (structureId: string, toChainageM: number) => void; + /** 종단 그래프 우클릭으로 레지스트리 타입을 지정해 구조물을 넣음. */ + onStructureTypeAdd?: (chainageM: number, typeId: string) => void; + /** 배수유역도에서 유역을 고름 — 그 관의 누가거리(해제면 null). 그래프·사이드 패널을 맞춘다. */ + onBasinSelected?: (chainageM: number | null) => void; + /** 배수유역도에서 관 마커를 고름(유역 없는 관 포함) — 전 화면 동기화용(2026-08-17). */ + onPipeSelected?: (chainageM: number | null) => void; + /** 계획선 편집 프리뷰가 공유 캐시의 횡단 설계(설계선 포함)를 갱신한 뒤 — + * 3D 코리도 등 파생 표시 재빌드용(2026-08-23). */ + onCrossDesignsUpdated?: () => void; +}