729 lines
25 KiB
TypeScript
729 lines
25 KiB
TypeScript
import * as THREE from "three";
|
|
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 type { SurfaceModelSummary } from "./B04_wf1_Surface_Api_Fetch";
|
|
import { fetchVWorldMeta, getVWorldMapUrl, fetchGisGeoJson } from "./B04_wf1_Surface_Api_Fetch";
|
|
|
|
export interface SurfaceTerrainViewer {
|
|
root: HTMLElement;
|
|
render: (projectId: string, models: readonly SurfaceModelSummary[]) => void;
|
|
updateBgMap: (projectId: string, bgLayer: string) => void;
|
|
updateGisLayer: (projectId: string, gisLayer: string) => void;
|
|
dispose: () => void;
|
|
}
|
|
|
|
export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
|
|
const root = document.createElement("div");
|
|
root.className = "terrain-model-group panel panel--no-bottom";
|
|
root.style.marginTop = "var(--spacing-24)";
|
|
|
|
// Header Title
|
|
const header = document.createElement("div");
|
|
header.className = "panel-title";
|
|
header.style.display = "flex";
|
|
header.style.justifyContent = "space-between";
|
|
header.style.alignItems = "center";
|
|
|
|
const titleSpan = document.createElement("span");
|
|
titleSpan.innerHTML = "🏕️ 지면 필터별 5가지 지표면 모델 비교";
|
|
|
|
const statusSpan = document.createElement("span");
|
|
statusSpan.className = "terrain-status";
|
|
statusSpan.style.fontSize = "var(--text-caption)";
|
|
statusSpan.style.color = "var(--color-text-secondary)";
|
|
statusSpan.textContent = "모델 선택 대기 중...";
|
|
|
|
header.append(titleSpan, statusSpan);
|
|
root.append(header);
|
|
|
|
// Filters and Methods selector bars
|
|
const selectorBar = document.createElement("div");
|
|
selectorBar.className = "filter-selector-bar terrain-selector-bar";
|
|
selectorBar.style.padding = "var(--spacing-12) var(--spacing-16)";
|
|
selectorBar.style.background = "var(--color-bg-light)";
|
|
selectorBar.style.borderBottom = "1px solid var(--color-border)";
|
|
selectorBar.style.display = "flex";
|
|
selectorBar.style.flexDirection = "column";
|
|
selectorBar.style.gap = "var(--spacing-12)";
|
|
|
|
// 1. Source Filter row
|
|
const filterRow = document.createElement("div");
|
|
filterRow.style.display = "flex";
|
|
filterRow.style.alignItems = "center";
|
|
filterRow.style.gap = "var(--spacing-12)";
|
|
|
|
const filterLabel = document.createElement("span");
|
|
filterLabel.className = "filter-bar-label";
|
|
filterLabel.textContent = "서피스 기준 데이터:";
|
|
filterLabel.style.fontWeight = "bold";
|
|
filterLabel.style.minWidth = "120px";
|
|
|
|
const filterSegmented = document.createElement("div");
|
|
filterSegmented.className = "segmented";
|
|
|
|
const filters = [
|
|
{ key: "csf", label: "CSF" },
|
|
{ key: "pmf", label: "PMF" },
|
|
{ key: "grid_min_z", label: "Grid Min-Z" },
|
|
];
|
|
|
|
let activeFilter = "csf";
|
|
const filterButtons = filters.map((f) => {
|
|
const btn = document.createElement("button");
|
|
btn.type = "button";
|
|
btn.textContent = f.label;
|
|
btn.className = f.key === activeFilter ? "active" : "";
|
|
btn.addEventListener("click", () => {
|
|
filterButtons.forEach((b) => b.classList.remove("active"));
|
|
btn.classList.add("active");
|
|
activeFilter = f.key;
|
|
updateSelectedModel();
|
|
});
|
|
filterSegmented.append(btn);
|
|
return btn;
|
|
});
|
|
filterRow.append(filterLabel, filterSegmented);
|
|
|
|
// 2. Method row
|
|
const methodRow = document.createElement("div");
|
|
methodRow.style.display = "flex";
|
|
methodRow.style.alignItems = "center";
|
|
methodRow.style.gap = "var(--spacing-12)";
|
|
|
|
const methodLabel = document.createElement("span");
|
|
methodLabel.className = "filter-bar-label";
|
|
methodLabel.textContent = "지표면 표현 방식:";
|
|
methodLabel.style.fontWeight = "bold";
|
|
methodLabel.style.minWidth = "120px";
|
|
|
|
const methodSegmented = document.createElement("div");
|
|
methodSegmented.className = "segmented";
|
|
|
|
const methods = [
|
|
{ key: "tin", label: "TIN" },
|
|
{ key: "dtm", label: "DTM (Grid)" },
|
|
{ key: "nurbs", label: "NURBS" },
|
|
{ key: "implicit", label: "Implicit" },
|
|
{ key: "meshfree", label: "Meshfree" },
|
|
];
|
|
|
|
let activeMethod = "dtm";
|
|
const methodButtons = methods.map((m) => {
|
|
const btn = document.createElement("button");
|
|
btn.type = "button";
|
|
btn.textContent = m.label;
|
|
btn.className = m.key === activeMethod ? "active" : "";
|
|
btn.addEventListener("click", () => {
|
|
methodButtons.forEach((b) => b.classList.remove("active"));
|
|
btn.classList.add("active");
|
|
activeMethod = m.key;
|
|
updateSelectedModel();
|
|
});
|
|
methodSegmented.append(btn);
|
|
return btn;
|
|
});
|
|
methodRow.append(methodLabel, methodSegmented);
|
|
selectorBar.append(filterRow, methodRow);
|
|
root.append(selectorBar);
|
|
|
|
// Viewer Controls toolbar & checkbox options
|
|
const toolbar = document.createElement("div");
|
|
toolbar.className = "point-toolbar";
|
|
toolbar.style.display = "flex";
|
|
toolbar.style.justifyContent = "space-between";
|
|
toolbar.style.alignItems = "center";
|
|
toolbar.style.padding = "var(--spacing-12) var(--spacing-16)";
|
|
toolbar.style.background = "var(--color-bg-card)";
|
|
toolbar.style.borderBottom = "1px solid var(--color-border)";
|
|
|
|
const leftControls = document.createElement("div");
|
|
leftControls.className = "viewer-controls";
|
|
|
|
const btnIso = document.createElement("button");
|
|
btnIso.type = "button";
|
|
btnIso.innerHTML = "🔍 사시도";
|
|
const btnTop = document.createElement("button");
|
|
btnTop.type = "button";
|
|
btnTop.textContent = "상단";
|
|
const btnFront = document.createElement("button");
|
|
btnFront.type = "button";
|
|
btnFront.textContent = "정면";
|
|
const btnSide = document.createElement("button");
|
|
btnSide.type = "button";
|
|
btnSide.textContent = "측면";
|
|
const btnReset = document.createElement("button");
|
|
btnReset.type = "button";
|
|
btnReset.innerHTML = "🔄 리셋";
|
|
|
|
const toggleAxesLabel = document.createElement("label");
|
|
toggleAxesLabel.className = "toggle-label";
|
|
const axesCheck = document.createElement("input");
|
|
axesCheck.type = "checkbox";
|
|
axesCheck.checked = true;
|
|
toggleAxesLabel.append(axesCheck, document.createTextNode(" 축"));
|
|
|
|
leftControls.append(btnIso, btnTop, btnFront, btnSide, btnReset, toggleAxesLabel);
|
|
|
|
const rightControls = document.createElement("div");
|
|
rightControls.style.display = "flex";
|
|
rightControls.style.alignItems = "center";
|
|
rightControls.style.gap = "var(--spacing-16)";
|
|
|
|
// Surface Toggle
|
|
const surfLabel = document.createElement("label");
|
|
surfLabel.className = "toggle-label";
|
|
const surfCheck = document.createElement("input");
|
|
surfCheck.type = "checkbox";
|
|
surfCheck.checked = true;
|
|
surfLabel.append(surfCheck, document.createTextNode(" 서피스"));
|
|
|
|
// Smooth Toggle (for tin/dtm)
|
|
const smoothLabel = document.createElement("label");
|
|
smoothLabel.className = "toggle-label";
|
|
const smoothCheck = document.createElement("input");
|
|
smoothCheck.type = "checkbox";
|
|
smoothCheck.checked = true;
|
|
smoothLabel.append(smoothCheck, document.createTextNode(" 스무딩"));
|
|
|
|
// Contour Toggle
|
|
const contourLabel = document.createElement("label");
|
|
contourLabel.className = "toggle-label";
|
|
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.style.display = "flex";
|
|
intervalForm.style.alignItems = "center";
|
|
intervalForm.style.gap = "var(--spacing-4)";
|
|
|
|
const intervalInput = document.createElement("input");
|
|
intervalInput.type = "number";
|
|
intervalInput.value = "5.0";
|
|
intervalInput.step = "0.5";
|
|
intervalInput.min = "0.5";
|
|
intervalInput.style.width = "60px";
|
|
intervalInput.style.padding = "var(--spacing-4) var(--spacing-8)";
|
|
intervalInput.style.border = "1px solid var(--color-border)";
|
|
intervalInput.style.borderRadius = "var(--radius-normal)";
|
|
|
|
const intervalSubmit = document.createElement("button");
|
|
intervalSubmit.type = "submit";
|
|
intervalSubmit.textContent = "적용";
|
|
intervalSubmit.style.padding = "var(--spacing-4) var(--spacing-12)";
|
|
|
|
intervalForm.append(
|
|
document.createTextNode("간격 "),
|
|
intervalInput,
|
|
document.createTextNode("m "),
|
|
intervalSubmit,
|
|
);
|
|
|
|
rightControls.append(surfLabel, smoothLabel, contourLabel, intervalForm);
|
|
toolbar.append(leftControls, rightControls);
|
|
root.append(toolbar);
|
|
|
|
// 3D View container
|
|
const viewerArea = document.createElement("div");
|
|
viewerArea.className = "three-viewer";
|
|
viewerArea.style.height = "520px";
|
|
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.style.position = "absolute";
|
|
scaleBar.style.bottom = "16px";
|
|
scaleBar.style.left = "16px";
|
|
scaleBar.style.background = "rgba(255, 255, 255, 0.9)";
|
|
scaleBar.style.border = "1.5px solid #1e293b";
|
|
scaleBar.style.borderTop = "none";
|
|
scaleBar.style.height = "8px";
|
|
scaleBar.style.width = "100px";
|
|
scaleBar.style.zIndex = "10";
|
|
scaleBar.style.display = "none";
|
|
scaleBar.style.flexDirection = "column";
|
|
scaleBar.style.alignItems = "center";
|
|
scaleBar.style.justifyContent = "flex-end";
|
|
|
|
const scaleLabel = document.createElement("span");
|
|
scaleLabel.style.fontSize = "10px";
|
|
scaleLabel.style.fontWeight = "bold";
|
|
scaleLabel.style.color = "#1e293b";
|
|
scaleLabel.style.position = "absolute";
|
|
scaleLabel.style.bottom = "10px";
|
|
scaleLabel.style.whiteSpace = "nowrap";
|
|
scaleBar.append(scaleLabel);
|
|
viewerArea.append(scaleBar);
|
|
|
|
// 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);
|
|
|
|
// Three.js context variables
|
|
let currentProjectId = "";
|
|
let currentModelsList: readonly SurfaceModelSummary[] = [];
|
|
|
|
const scene = new THREE.Scene();
|
|
scene.background = new THREE.Color(0xf5f7f9);
|
|
|
|
const camera = new THREE.PerspectiveCamera(50, 1, 0.01, 100000);
|
|
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
|
|
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
|
|
|
|
const controls = new OrbitControls(camera, renderer.domElement);
|
|
controls.enableDamping = true;
|
|
controls.dampingFactor = 0.08;
|
|
controls.screenSpacePanning = true;
|
|
|
|
scene.add(new THREE.HemisphereLight(0xffffff, 0x445566, 1.8));
|
|
const directional = new THREE.DirectionalLight(0xffffff, 1.5);
|
|
directional.position.set(100, 180, 120);
|
|
scene.add(directional);
|
|
|
|
const axes = new THREE.AxesHelper(25);
|
|
axes.visible = axesCheck.checked;
|
|
scene.add(axes);
|
|
|
|
const contourGroup = new THREE.Group();
|
|
contourGroup.visible = contourCheck.checked;
|
|
scene.add(contourGroup);
|
|
|
|
let terrainMesh: THREE.Object3D | null = null;
|
|
const labelElements: HTMLDivElement[] = [];
|
|
|
|
function disposeObject(obj: THREE.Object3D) {
|
|
obj.traverse((child) => {
|
|
const renderable = child as THREE.Mesh | THREE.Points | THREE.LineSegments;
|
|
renderable.geometry?.dispose();
|
|
const material = renderable.material;
|
|
if (Array.isArray(material)) material.forEach((item) => item.dispose());
|
|
else material?.dispose();
|
|
});
|
|
}
|
|
|
|
function clearMesh() {
|
|
if (terrainMesh) {
|
|
scene.remove(terrainMesh);
|
|
disposeObject(terrainMesh);
|
|
terrainMesh = null;
|
|
}
|
|
}
|
|
|
|
function clearContours() {
|
|
while (contourGroup.children.length > 0) {
|
|
const child = contourGroup.children[0];
|
|
contourGroup.remove(child);
|
|
if (child instanceof THREE.LineSegments) {
|
|
child.geometry.dispose();
|
|
(child.material as THREE.Material).dispose();
|
|
}
|
|
}
|
|
labelElements.forEach((el) => el.remove());
|
|
labelElements.length = 0;
|
|
legendBar.style.display = "none";
|
|
}
|
|
|
|
const getFitParams = (object: THREE.Object3D) => {
|
|
const box = new THREE.Box3().setFromObject(object);
|
|
const center = box.getCenter(new THREE.Vector3());
|
|
const size = box.getSize(new THREE.Vector3());
|
|
const span = Math.max(size.x, size.y, size.z, 1);
|
|
return { center, span };
|
|
};
|
|
|
|
const fitCamera = (object: THREE.Object3D) => {
|
|
const { center, span } = getFitParams(object);
|
|
controls.target.copy(center);
|
|
camera.position.set(center.x, center.y + span * 1.2, center.z + 0.001);
|
|
camera.near = Math.max(span / 10000, 0.01);
|
|
camera.far = span * 100;
|
|
camera.updateProjectionMatrix();
|
|
controls.update();
|
|
};
|
|
|
|
function setCameraView(view: "iso" | "top" | "front" | "side") {
|
|
if (!terrainMesh) return;
|
|
const { center, span } = getFitParams(terrainMesh);
|
|
controls.target.copy(center);
|
|
if (view === "iso") {
|
|
camera.position.set(center.x + span * 0.8, center.y + span * 0.65, center.z + span * 0.9);
|
|
} else if (view === "top") {
|
|
camera.position.set(center.x, center.y + span * 1.2, center.z + 0.001);
|
|
} else if (view === "front") {
|
|
camera.position.set(center.x, center.y, center.z + span * 1.2);
|
|
} else if (view === "side") {
|
|
camera.position.set(center.x + span * 1.2, center.y, center.z);
|
|
}
|
|
camera.updateProjectionMatrix();
|
|
controls.update();
|
|
}
|
|
|
|
// Load mesh and contours
|
|
async function updateSelectedModel() {
|
|
if (!currentProjectId || currentModelsList.length === 0) return;
|
|
|
|
clearMesh();
|
|
clearContours();
|
|
scaleBar.style.display = "none";
|
|
statusSpan.textContent = "모델 조회 중...";
|
|
|
|
// 1. Find matching model in list
|
|
// model_type is TIN / DTM / NURBS / Implicit / Meshfree (we match activeMethod)
|
|
// model_file_path contains the activeFilter (e.g. csf, pmf, grid_min_z)
|
|
const match = currentModelsList.find((m) => {
|
|
const typeMatches = m.model_type.toLowerCase() === activeMethod.toLowerCase();
|
|
const pathContainsFilter =
|
|
m.model_file_path && m.model_file_path.toLowerCase().includes(activeFilter.toLowerCase());
|
|
return typeMatches && pathContainsFilter;
|
|
});
|
|
|
|
if (!match) {
|
|
statusSpan.textContent = "일치하는 완성된 모델을 찾을 수 없습니다.";
|
|
return;
|
|
}
|
|
|
|
const modelId = match.id;
|
|
const isSmooth = (activeMethod === "tin" || activeMethod === "dtm") && smoothCheck.checked;
|
|
|
|
statusSpan.textContent = "3D 메쉬 파일 다운로드 중...";
|
|
const previewUrl = `${API_BASE_URL}/projects/${currentProjectId}/surface/models/${modelId}/preview?smooth=${isSmooth}`;
|
|
|
|
try {
|
|
if (activeMethod === "meshfree") {
|
|
new PLYLoader().load(
|
|
previewUrl,
|
|
(geometry) => {
|
|
geometry.computeBoundingSphere();
|
|
const material = new THREE.PointsMaterial({
|
|
size: 0.35,
|
|
vertexColors: geometry.hasAttribute("color"),
|
|
sizeAttenuation: true,
|
|
});
|
|
const points = new THREE.Points(geometry, material);
|
|
points.visible = surfCheck.checked;
|
|
terrainMesh = points;
|
|
scene.add(points);
|
|
fitCamera(points);
|
|
statusSpan.textContent = `${activeFilter.toUpperCase()} · ${activeMethod.toUpperCase()} 표시 중`;
|
|
loadContourLines(modelId, isSmooth);
|
|
},
|
|
undefined,
|
|
() => {
|
|
statusSpan.textContent = "3D 파일 로드에 실패했습니다.";
|
|
},
|
|
);
|
|
} else {
|
|
new GLTFLoader().load(
|
|
previewUrl,
|
|
(gltf) => {
|
|
gltf.scene.traverse((child) => {
|
|
if (child instanceof THREE.Mesh) {
|
|
child.material.side = THREE.DoubleSide;
|
|
child.material.vertexColors = child.geometry.hasAttribute("color");
|
|
}
|
|
});
|
|
gltf.scene.visible = surfCheck.checked;
|
|
terrainMesh = gltf.scene;
|
|
scene.add(gltf.scene);
|
|
fitCamera(gltf.scene);
|
|
statusSpan.textContent = `${activeFilter.toUpperCase()} · ${activeMethod.toUpperCase()} 표시 중`;
|
|
loadContourLines(modelId, isSmooth);
|
|
},
|
|
undefined,
|
|
() => {
|
|
statusSpan.textContent = "3D 메쉬 파일이 없거나 로드할 수 없습니다.";
|
|
},
|
|
);
|
|
}
|
|
} catch (e) {
|
|
statusSpan.textContent = "에러 발생";
|
|
}
|
|
}
|
|
|
|
async function loadContourLines(modelId: number, isSmooth: boolean) {
|
|
const interval = parseFloat(intervalInput.value) || 5.0;
|
|
const contourUrl = `${API_BASE_URL}/projects/${currentProjectId}/surface/models/${modelId}/contour?interval=${interval}&smooth=${isSmooth}`;
|
|
|
|
try {
|
|
const res = await fetch(contourUrl);
|
|
if (!res.ok) throw new Error("등고선 조회 실패");
|
|
const data = await res.json();
|
|
|
|
clearContours();
|
|
|
|
const bounds = data.bounds;
|
|
if (!bounds) return;
|
|
|
|
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;
|
|
|
|
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 linePoints: THREE.Vector3[] = [];
|
|
for (let i = 0; i < points.length - 1; i++) {
|
|
linePoints.push(points[i], points[i + 1]);
|
|
}
|
|
|
|
const geometry = new THREE.BufferGeometry().setFromPoints(linePoints);
|
|
const isMajor = c.level % (interval * 5) === 0;
|
|
const material = new THREE.LineBasicMaterial({
|
|
color: isMajor ? 0xd97706 : 0xf59e0b,
|
|
linewidth: isMajor ? 2 : 1,
|
|
transparent: true,
|
|
opacity: 0.8,
|
|
});
|
|
|
|
const segments = new THREE.LineSegments(geometry, material);
|
|
contourGroup.add(segments);
|
|
|
|
if (isMajor && points.length > 4) {
|
|
const labelPos = points[Math.floor(points.length / 2)];
|
|
const labelDiv = document.createElement("div");
|
|
labelDiv.className = "contour-label";
|
|
labelDiv.innerText = `${Math.round(c.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);
|
|
}
|
|
});
|
|
|
|
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";
|
|
}
|
|
} catch (e) {
|
|
legendBar.style.display = "none";
|
|
}
|
|
}
|
|
|
|
// Animation render loop
|
|
let animationFrameId = 0;
|
|
function animate() {
|
|
if (!root.isConnected) {
|
|
cancelAnimationFrame(animationFrameId);
|
|
clearMesh();
|
|
clearContours();
|
|
controls.dispose();
|
|
renderer.dispose();
|
|
return;
|
|
}
|
|
controls.update();
|
|
|
|
// Render scale bar dynamically
|
|
if (terrainMesh && terrainMesh.visible) {
|
|
scaleBar.style.display = "flex";
|
|
const dist = camera.position.distanceTo(controls.target);
|
|
const metersPerPixel =
|
|
(2 * Math.tan((camera.fov * Math.PI) / 360) * dist) / viewerArea.clientWidth;
|
|
const roughMeters = 100 * metersPerPixel;
|
|
const prettyMeters =
|
|
roughMeters < 5
|
|
? 2
|
|
: roughMeters < 15
|
|
? 10
|
|
: roughMeters < 35
|
|
? 20
|
|
: roughMeters < 75
|
|
? 50
|
|
: roughMeters < 150
|
|
? 100
|
|
: roughMeters < 350
|
|
? 200
|
|
: roughMeters < 750
|
|
? 500
|
|
: roughMeters < 1500
|
|
? 1000
|
|
: roughMeters < 3500
|
|
? 2000
|
|
: roughMeters < 7500
|
|
? 5000
|
|
: 10000;
|
|
scaleBar.style.width = `${prettyMeters / metersPerPixel}px`;
|
|
scaleLabel.textContent =
|
|
prettyMeters >= 1000 ? `${(prettyMeters / 1000).toFixed(0)} km` : `${prettyMeters} m`;
|
|
} else {
|
|
scaleBar.style.display = "none";
|
|
}
|
|
|
|
// Update labels position
|
|
labelElements.forEach((label) => {
|
|
if (typeof (label as any).__updateLabelPos === "function") {
|
|
(label as any).__updateLabelPos();
|
|
}
|
|
});
|
|
|
|
renderer.render(scene, camera);
|
|
animationFrameId = requestAnimationFrame(animate);
|
|
}
|
|
|
|
// Event Listeners
|
|
btnIso.addEventListener("click", () => setCameraView("iso"));
|
|
btnTop.addEventListener("click", () => setCameraView("top"));
|
|
btnFront.addEventListener("click", () => setCameraView("front"));
|
|
btnSide.addEventListener("click", () => setCameraView("side"));
|
|
btnReset.addEventListener("click", () => {
|
|
if (terrainMesh) fitCamera(terrainMesh);
|
|
});
|
|
|
|
axesCheck.addEventListener("change", () => {
|
|
axes.visible = axesCheck.checked;
|
|
});
|
|
|
|
surfCheck.addEventListener("change", () => {
|
|
if (terrainMesh) {
|
|
terrainMesh.visible = surfCheck.checked;
|
|
}
|
|
});
|
|
|
|
smoothCheck.addEventListener("change", () => {
|
|
updateSelectedModel();
|
|
});
|
|
|
|
contourCheck.addEventListener("change", () => {
|
|
contourGroup.visible = contourCheck.checked;
|
|
labelElements.forEach((el) => {
|
|
el.style.display = contourCheck.checked ? "block" : "none";
|
|
});
|
|
});
|
|
|
|
intervalForm.addEventListener("submit", (e) => {
|
|
e.preventDefault();
|
|
updateSelectedModel();
|
|
});
|
|
|
|
// Resize handler
|
|
const resizeObserver = new ResizeObserver((entries) => {
|
|
for (const entry of entries) {
|
|
const width = entry.contentRect.width || 800;
|
|
const height = entry.contentRect.height || 520;
|
|
camera.aspect = width / height;
|
|
camera.updateProjectionMatrix();
|
|
renderer.setSize(width, height);
|
|
}
|
|
});
|
|
resizeObserver.observe(viewerArea);
|
|
|
|
animationFrameId = requestAnimationFrame(animate);
|
|
|
|
return {
|
|
root,
|
|
render(projectId, models) {
|
|
currentProjectId = projectId;
|
|
currentModelsList = models;
|
|
updateSelectedModel();
|
|
},
|
|
updateBgMap(projectId, bgLayer) {
|
|
// 포인트클라우드 뷰어와 인터페이스 조화를 위해 빈 껍데기 함수 정의
|
|
},
|
|
updateGisLayer(projectId, gisLayer) {
|
|
// 포인트클라우드 뷰어와 인터페이스 조화를 위해 빈 껍데기 함수 정의
|
|
},
|
|
dispose() {
|
|
cancelAnimationFrame(animationFrameId);
|
|
resizeObserver.disconnect();
|
|
clearMesh();
|
|
clearContours();
|
|
controls.dispose();
|
|
renderer.dispose();
|
|
},
|
|
};
|
|
}
|