import * as THREE from "three";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
import { RENDER_OPTIONS } from "@config/config_frontend";
import {
fetchVWorldMeta,
getVWorldMapUrl,
fetchGisGeoJson,
type SurfacePointCloudSampleResponse,
} from "./B04_wf1_Surface_Api_Fetch";
export interface SurfacePointCloudViewer {
root: HTMLElement;
controlsGroup: HTMLElement;
optionsGroup: HTMLElement;
statusSpan: HTMLElement;
render: (data: SurfacePointCloudSampleResponse | null) => void;
updateBgMap: (projectId: string, bgLayer: string) => void;
updateGisLayer: (projectId: string, gisLayer: string) => void;
dispose: () => void;
}
export function createSurfacePointCloudViewer(): SurfacePointCloudViewer {
const root = document.createElement("div");
root.className = "point-viewer";
const statusSpan = document.createElement("span");
statusSpan.className = "b04-surface__status-info";
statusSpan.style.color = "var(--color-text-secondary)";
statusSpan.style.fontSize = "var(--text-caption)";
statusSpan.textContent = "포인트 데이터 로딩 중...";
const controlsDiv = document.createElement("div");
controlsDiv.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 toggleLabel = document.createElement("label");
toggleLabel.className = "toggle-label";
const axesCheck = document.createElement("input");
axesCheck.type = "checkbox";
axesCheck.checked = true;
toggleLabel.append(axesCheck, document.createTextNode(" 축"));
controlsDiv.append(btnIso, btnTop, btnFront, btnSide, btnReset, toggleLabel);
const controlsGroup = document.createElement("section");
controlsGroup.className = "b04-surface__group";
const controlsTitle = document.createElement("h3");
controlsTitle.className = "b04-surface__panel-title";
controlsTitle.textContent = "뷰어 시점 제어";
controlsGroup.append(controlsTitle, controlsDiv);
// 2. Options Sliders
const optionsDiv = document.createElement("div");
optionsDiv.className = "viewer-options";
const sizeLabel = document.createElement("label");
sizeLabel.textContent = "점 크기 ";
const sizeInput = document.createElement("input");
sizeInput.type = "range";
sizeInput.min = "0.1";
sizeInput.max = "2.5";
sizeInput.step = "0.01";
sizeInput.value = "0.42";
sizeLabel.append(sizeInput);
const densityLabel = document.createElement("label");
densityLabel.innerHTML = '밀도 100%';
const densityInput = document.createElement("input");
densityInput.type = "range";
densityInput.min = "1";
densityInput.max = "10";
densityInput.step = "1";
densityInput.value = "10";
densityLabel.append(densityInput);
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";
const optionsTitle = document.createElement("h3");
optionsTitle.className = "b04-surface__panel-title";
optionsTitle.textContent = "표시 옵션";
optionsGroup.append(optionsTitle, optionsDiv);
// 4. Viewer Area & Canvas
const viewerArea = document.createElement("div");
viewerArea.className = "three-viewer";
viewerArea.style.height = "520px";
const canvas = document.createElement("canvas");
canvas.className = "b04-surface-viewer__canvas";
viewerArea.append(canvas);
root.append(viewerArea);
// 5. 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.zIndex = "10";
scaleBar.style.display = "none";
scaleBar.style.flexDirection = "column";
scaleBar.style.alignItems = "center";
scaleBar.style.justifyContent = "flex-end";
scaleBar.style.pointerEvents = "none";
const scaleText = document.createElement("span");
scaleText.style.fontSize = "10px";
scaleText.style.fontWeight = "bold";
scaleText.style.color = "#1e293b";
scaleText.style.position = "absolute";
scaleText.style.bottom = "10px";
scaleText.style.whiteSpace = "nowrap";
scaleBar.append(scaleText);
viewerArea.append(scaleBar);
// 6. ThreeJS Setup
const renderer = new THREE.WebGLRenderer({
canvas,
antialias: RENDER_OPTIONS.antialias,
});
renderer.setPixelRatio(Math.min(window.devicePixelRatio, RENDER_OPTIONS.maxPixelRatio));
const scene = new THREE.Scene();
scene.background = new THREE.Color(0xf5f7f9);
const camera = new THREE.PerspectiveCamera(55, 1, 0.1, 22000);
const controls = new OrbitControls(camera, canvas);
controls.enableDamping = true;
controls.dampingFactor = 0.08;
controls.screenSpacePanning = true;
const axes = new THREE.AxesHelper(45);
axes.visible = axesCheck.checked;
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;
function resize(): void {
const rect = viewerArea.getBoundingClientRect();
const width = Math.max(1, Math.floor(rect.width));
const height = Math.max(1, Math.floor(rect.height));
renderer.setSize(width, height, false);
camera.aspect = width / height;
camera.updateProjectionMatrix();
}
function setCameraView(view: "iso" | "top" | "front" | "side"): void {
const positions: Record = {
iso: [120, 95, 135],
top: [0, 190, 0.001],
front: [0, 60, 240],
side: [240, 60, 0],
};
camera.position.set(...positions[view]);
camera.lookAt(0, 0, 0);
controls.target.set(0, 0, 0);
controls.update();
}
function animate(): void {
if (!root.isConnected) {
cancelAnimationFrame(animationFrame);
clearPoints();
controls.dispose();
renderer.dispose();
return;
}
resize();
controls.update();
renderer.render(scene, camera);
// Update scale bar overlay
if (currentData) {
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 xSpan = xMax - xMin;
const ySpan = yMax - yMin;
const zSpan = zMax - zMin;
const maxSpan = Math.max(xSpan, ySpan, zSpan);
const internalScale = 180 / maxSpan;
const dist = camera.position.distanceTo(controls.target);
const rect = viewerArea.getBoundingClientRect();
const clientWidth = rect.width || 1200;
const sceneUnitsPerPixel = (2 * Math.tan((camera.fov * Math.PI) / 360) * dist) / clientWidth;
const metersPerPixel = sceneUnitsPerPixel / internalScale;
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;
const pixels = (prettyMeters * internalScale) / sceneUnitsPerPixel;
scaleBar.style.display = "flex";
scaleBar.style.width = `${pixels}px`;
scaleText.textContent =
prettyMeters >= 1000 ? `${(prettyMeters / 1000).toFixed(0)} km` : `${prettyMeters} m`;
} else {
scaleBar.style.display = "none";
}
animationFrame = requestAnimationFrame(animate);
}
function clearPoints(): void {
if (!pointsObject) return;
pointsObject.geometry.dispose();
const material = pointsObject.material;
if (Array.isArray(material)) material.forEach((item) => item.dispose());
else material.dispose();
scene.remove(pointsObject);
pointsObject = null;
}
function renderPointCloudInternal(data: SurfacePointCloudSampleResponse): void {
clearPoints();
if (data.points.length === 0) return;
const bounds = data.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 hasRgb = data.rgb && data.rgb.length === data.points.length;
const densityVal = parseInt(densityInput.value, 10);
const step = Math.max(1, Math.ceil(10 / densityVal));
const renderPoints: typeof data.points = [];
const renderRgb: [number, number, number][] = [];
for (let i = 0; i < data.points.length; i += step) {
renderPoints.push(data.points[i]);
if (hasRgb && data.rgb) {
renderRgb.push(data.rgb[i]);
}
}
const positions = new Float32Array(renderPoints.length * 3);
const colors = new Float32Array(renderPoints.length * 3);
renderPoints.forEach((point, index) => {
const x = point[0];
const y = point[1];
const z = point[2];
positions[index * 3] = (x - xMid) * scale;
positions[index * 3 + 1] = (z - zMid) * scale;
positions[index * 3 + 2] = -(y - yMid) * scale;
let r = 0;
let g = 0;
let b = 0;
if (hasRgb && renderRgb[index]) {
const rgbPoint = renderRgb[index];
const maxVal = Math.max(rgbPoint[0], rgbPoint[1], rgbPoint[2]);
const divisor = maxVal > 255 ? 65535 : 255;
r = rgbPoint[0] / divisor;
g = rgbPoint[1] / divisor;
b = rgbPoint[2] / divisor;
} else {
const t = Math.max(0, Math.min(1, (z - zMin) / zSpan));
r = (36 + t * 190) / 255;
g = (86 + Math.sin(t * Math.PI) * 95) / 255;
b = (128 - t * 80) / 255;
}
colors[index * 3] = r;
colors[index * 3 + 1] = g;
colors[index * 3 + 2] = b;
});
const geometry = new THREE.BufferGeometry();
geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
geometry.setAttribute("color", new THREE.BufferAttribute(colors, 3));
geometry.computeBoundingSphere();
const sizeVal = parseFloat(sizeInput.value);
const material = new THREE.PointsMaterial({
size: sizeVal,
vertexColors: true,
sizeAttenuation: true,
});
pointsObject = new THREE.Points(geometry, material);
scene.add(pointsObject);
// Update status text
const nearestMin10 = Math.round(zMin / 10) * 10;
const nearestMax10 = Math.round(zMax / 10) * 10;
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 = {
지적도: 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
btnIso.addEventListener("click", () => setCameraView("iso"));
btnTop.addEventListener("click", () => setCameraView("top"));
btnFront.addEventListener("click", () => setCameraView("front"));
btnSide.addEventListener("click", () => setCameraView("side"));
btnReset.addEventListener("click", () => setCameraView("iso"));
axesCheck.addEventListener("change", () => {
axes.visible = axesCheck.checked;
});
sizeInput.addEventListener("input", () => {
if (pointsObject) {
(pointsObject.material as THREE.PointsMaterial).size = parseFloat(sizeInput.value);
}
});
densityInput.addEventListener("input", () => {
const val = parseInt(densityInput.value, 10);
const valSpan = densityLabel.querySelector(".viewer-option-val");
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);
}
}
});
animationFrame = requestAnimationFrame(animate);
return {
root,
controlsGroup,
optionsGroup,
statusSpan,
render,
updateBgMap,
updateGisLayer,
dispose: () => {
cancelAnimationFrame(animationFrame);
clearPoints();
clearBgMap();
clearGisLayers();
controls.dispose();
renderer.dispose();
},
};
}