This commit is contained in:
2026-07-15 18:33:33 +09:00
parent 6b1583103f
commit f533082537
201 changed files with 19776 additions and 563 deletions
+255 -2
View File
@@ -1,7 +1,12 @@
import * as THREE from "three";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
import { RENDER_OPTIONS } from "@config/config_frontend";
import type { SurfacePointCloudSampleResponse } from "./B04_wf1_Surface_Api_Fetch";
import {
fetchVWorldMeta,
getVWorldMapUrl,
fetchGisGeoJson,
type SurfacePointCloudSampleResponse,
} from "./B04_wf1_Surface_Api_Fetch";
export interface SurfacePointCloudViewer {
root: HTMLElement;
@@ -9,6 +14,8 @@ export interface SurfacePointCloudViewer {
optionsGroup: HTMLElement;
statusSpan: HTMLElement;
render: (data: SurfacePointCloudSampleResponse | null) => void;
updateBgMap: (projectId: string, bgLayer: string) => void;
updateGisLayer: (projectId: string, gisLayer: string) => void;
dispose: () => void;
}
@@ -85,7 +92,53 @@ export function createSurfacePointCloudViewer(): SurfacePointCloudViewer {
densityInput.value = "10";
densityLabel.append(densityInput);
optionsDiv.append(sizeLabel, densityLabel);
const bgLabel = document.createElement("label");
bgLabel.textContent = "배경 지도 ";
bgLabel.style.display = "flex";
bgLabel.style.flexDirection = "column";
bgLabel.style.gap = "var(--spacing-4)";
bgLabel.style.marginTop = "var(--spacing-8)";
const bgSelect = document.createElement("select");
bgSelect.className = "b04-surface__select";
const bgOptions = [
{ value: "none", label: "없음" },
{ value: "satellite", label: "위성사진" },
{ value: "hybrid", label: "하이브리드 지도" },
{ value: "white", label: "백지도" },
];
bgOptions.forEach((opt) => {
const o = document.createElement("option");
o.value = opt.value;
o.textContent = opt.label;
bgSelect.append(o);
});
bgLabel.append(bgSelect);
const gisLabel = document.createElement("label");
gisLabel.textContent = "국가 GIS 레이어 ";
gisLabel.style.display = "flex";
gisLabel.style.flexDirection = "column";
gisLabel.style.gap = "var(--spacing-4)";
gisLabel.style.marginTop = "var(--spacing-8)";
const gisSelect = document.createElement("select");
gisSelect.className = "b04-surface__select";
const gisOptions = [
{ value: "none", label: "없음" },
{ value: "지적도", label: "연속지적도" },
{ value: "수계망", label: "수계망" },
{ value: "산사태", label: "산사태위험등급" },
{ value: "행정구역_시군구", label: "시군구 경계" },
{ value: "행정구역_읍면동", label: "읍면동 경계" },
];
gisOptions.forEach((opt) => {
const o = document.createElement("option");
o.value = opt.value;
o.textContent = opt.label;
gisSelect.append(o);
});
gisLabel.append(gisSelect);
optionsDiv.append(sizeLabel, densityLabel, bgLabel, gisLabel);
const optionsGroup = document.createElement("section");
optionsGroup.className = "b04-surface__group";
@@ -151,6 +204,8 @@ export function createSurfacePointCloudViewer(): SurfacePointCloudViewer {
scene.add(axes);
let pointsObject: THREE.Points | null = null;
let bgPlane: THREE.Mesh | null = null;
let gisObjects: THREE.Object3D[] = [];
let animationFrame = 0;
let currentData: SurfacePointCloudSampleResponse | null = null;
@@ -343,15 +398,204 @@ export function createSurfacePointCloudViewer(): SurfacePointCloudViewer {
statusSpan.textContent = ` ${renderPoints.length.toLocaleString()}개 점 표시 중 [높이범위: ~${nearestMin10}m ... ${nearestMax10}m~]`;
}
function clearBgMap(): void {
if (bgPlane) {
bgPlane.geometry.dispose();
if (Array.isArray(bgPlane.material)) {
bgPlane.material.forEach((mat) => mat.dispose());
} else {
bgPlane.material.dispose();
}
scene.remove(bgPlane);
bgPlane = null;
}
}
function clearGisLayers(): void {
gisObjects.forEach((obj) => {
if (obj instanceof THREE.Line) {
obj.geometry.dispose();
if (Array.isArray(obj.material)) {
obj.material.forEach((mat) => mat.dispose());
} else {
obj.material.dispose();
}
}
scene.remove(obj);
});
gisObjects = [];
}
function updateBgMap(projectId: string, bgLayer: string): void {
clearBgMap();
if (bgLayer === "none" || !currentData) return;
const bounds = currentData.bounds;
const xMin = bounds.x_min ?? 0;
const xMax = bounds.x_max ?? 0;
const yMin = bounds.y_min ?? 0;
const yMax = bounds.y_max ?? 0;
const zMin = bounds.z_min ?? 0;
const zMax = bounds.z_max ?? 0;
const xMid = (xMin + xMax) / 2;
const yMid = (yMin + yMax) / 2;
const zMid = (zMin + zMax) / 2;
const xSpan = Math.max(xMax - xMin, 1e-9);
const ySpan = Math.max(yMax - yMin, 1e-9);
const zSpan = Math.max(zMax - zMin, 1e-9);
const scale = 180 / Math.max(xSpan, ySpan, zSpan);
const targetLayer = bgLayer === "gray" ? "white" : bgLayer;
fetchVWorldMeta(projectId, targetLayer)
.then((meta) => {
const planeW = meta.width_meters * scale;
const planeH = meta.height_meters * scale;
const planeGeo = new THREE.PlaneGeometry(planeW, planeH);
const textureLoader = new THREE.TextureLoader();
textureLoader.load(getVWorldMapUrl(projectId, targetLayer), (texture) => {
const planeMat = new THREE.MeshBasicMaterial({
map: texture,
side: THREE.DoubleSide,
transparent: true,
opacity: 0.85,
});
bgPlane = new THREE.Mesh(planeGeo, planeMat);
bgPlane.rotation.x = -Math.PI / 2;
const planeCenterX = (meta.center_x - xMid) * scale;
const planeCenterY = -(meta.center_y - yMid) * scale;
const planeCenterZ = (zMin - zMid) * scale - 0.5;
bgPlane.position.set(planeCenterX, planeCenterZ, planeCenterY);
scene.add(bgPlane);
});
})
.catch((e) => console.log("VWorld 배경 로드 실패:", e.message));
}
function updateGisLayer(projectId: string, gisLayer: string): void {
clearGisLayers();
if (gisLayer === "none" || !currentData) return;
const bounds = currentData.bounds;
const xMin = bounds.x_min ?? 0;
const xMax = bounds.x_max ?? 0;
const yMin = bounds.y_min ?? 0;
const yMax = bounds.y_max ?? 0;
const zMin = bounds.z_min ?? 0;
const zMax = bounds.z_max ?? 0;
const xMid = (xMin + xMax) / 2;
const yMid = (yMin + yMax) / 2;
const zMid = (zMin + zMax) / 2;
const xSpan = Math.max(xMax - xMin, 1e-9);
const ySpan = Math.max(yMax - yMin, 1e-9);
const zSpan = Math.max(zMax - zMin, 1e-9);
const scale = 180 / Math.max(xSpan, ySpan, zSpan);
Promise.all([fetchGisGeoJson(projectId, gisLayer), fetchVWorldMeta(projectId, "white")])
.then(([geoData, meta]) => {
const lonRange = meta.lon_max - meta.lon_min;
const latRange = meta.lat_max - meta.lat_min;
const xRange = meta.x_max - meta.x_min;
const yRange = meta.y_max - meta.y_min;
const toLocal = (lon: number, lat: number): [number, number] => {
const localX_m = meta.x_min + ((lon - meta.lon_min) / lonRange) * xRange;
const localY_m = meta.y_min + ((lat - meta.lat_min) / latRange) * yRange;
return [(localX_m - xMid) * scale, -(localY_m - yMid) * scale];
};
const features = geoData.features || [];
const colors: Record<string, number> = {
지적도: 0xff5500,
수계망: 0x00aaff,
산사태: 0xff0055,
행정구역_시군구: 0x333333,
행정구역_읍면동: 0x666666,
};
const layerColor = colors[gisLayer] || 0x00aa00;
const groundZ = (zMin - zMid) * scale + 0.1;
features.forEach((feat: any) => {
const geom = feat.geometry;
if (!geom) return;
const drawRing = (ring: number[][]) => {
const pts: THREE.Vector3[] = ring.map((pt) => {
const [lx, ly] = toLocal(pt[0], pt[1]);
return new THREE.Vector3(lx, groundZ, ly);
});
if (pts.length === 0) return;
const lineGeo = new THREE.BufferGeometry().setFromPoints(pts);
const lineMat = new THREE.LineBasicMaterial({ color: layerColor });
const line = new THREE.Line(lineGeo, lineMat);
scene.add(line);
gisObjects.push(line);
};
const drawCoordinates = (coords: any[], type: string) => {
if (type === "Polygon") {
coords.forEach((ring: number[][]) => drawRing(ring));
} else if (type === "MultiPolygon") {
coords.forEach((poly: any[]) => drawCoordinates(poly, "Polygon"));
} else if (type === "LineString") {
drawRing(coords as number[][]);
} else if (type === "MultiLineString") {
(coords as number[][][]).forEach((line) => drawRing(line));
}
};
drawCoordinates(geom.coordinates, geom.type);
});
})
.catch((err) => console.error("GIS 벡터 로드 실패:", err));
}
const getProjectIdLocal = () => {
return localStorage.getItem("current_project_id");
};
bgSelect.addEventListener("change", () => {
const projId = getProjectIdLocal();
if (projId) {
updateBgMap(projId, bgSelect.value);
}
});
gisSelect.addEventListener("change", () => {
const projId = getProjectIdLocal();
if (projId) {
updateGisLayer(projId, gisSelect.value);
}
});
function render(data: SurfacePointCloudSampleResponse | null): void {
currentData = data;
clearPoints();
clearBgMap();
clearGisLayers();
if (!data) {
statusSpan.textContent = "데이터가 존재하지 않습니다.";
return;
}
renderPointCloudInternal(data);
setCameraView("top");
const projId = getProjectIdLocal();
if (projId) {
if (bgSelect.value !== "none") {
updateBgMap(projId, bgSelect.value);
}
if (gisSelect.value !== "none") {
updateGisLayer(projId, gisSelect.value);
}
}
}
// Event Listeners
@@ -377,6 +621,11 @@ export function createSurfacePointCloudViewer(): SurfacePointCloudViewer {
if (valSpan) valSpan.textContent = `${val * 10}%`;
if (currentData) {
renderPointCloudInternal(currentData);
const projId = getProjectIdLocal();
if (projId) {
if (bgSelect.value !== "none") updateBgMap(projId, bgSelect.value);
if (gisSelect.value !== "none") updateGisLayer(projId, gisSelect.value);
}
}
});
@@ -388,9 +637,13 @@ export function createSurfacePointCloudViewer(): SurfacePointCloudViewer {
optionsGroup,
statusSpan,
render,
updateBgMap,
updateGisLayer,
dispose: () => {
cancelAnimationFrame(animationFrame);
clearPoints();
clearBgMap();
clearGisLayers();
controls.dispose();
renderer.dispose();
},