Merge remote-tracking branch 'origin/sub_laptop_1' into main_laptop_1
This commit is contained in:
@@ -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<boolean> {
|
||||
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<any>(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<boolean> =>
|
||||
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,
|
||||
|
||||
@@ -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<typeof buildTerrainViewerChrome>;
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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<boolean> {
|
||||
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<any>(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;
|
||||
}
|
||||
}
|
||||
@@ -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<string, string | number>;
|
||||
}>,
|
||||
) => 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,
|
||||
|
||||
@@ -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<string, string | number>;
|
||||
}>,
|
||||
) => 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;
|
||||
}
|
||||
Reference in New Issue
Block a user