260710_1
This commit is contained in:
@@ -5,18 +5,132 @@ import type { SurfacePointCloudSampleResponse } from "./B04_wf1_Surface_Api_Fetc
|
||||
|
||||
export interface SurfacePointCloudViewer {
|
||||
root: HTMLElement;
|
||||
controlsGroup: HTMLElement;
|
||||
optionsGroup: HTMLElement;
|
||||
statusSpan: HTMLElement;
|
||||
render: (data: SurfacePointCloudSampleResponse | null) => void;
|
||||
dispose: () => void;
|
||||
}
|
||||
|
||||
export function createSurfacePointCloudViewer(): SurfacePointCloudViewer {
|
||||
const root = document.createElement("div");
|
||||
root.className = "b04-surface-viewer";
|
||||
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 = '밀도 <span class="viewer-option-val">100%</span>';
|
||||
const densityInput = document.createElement("input");
|
||||
densityInput.type = "range";
|
||||
densityInput.min = "1";
|
||||
densityInput.max = "10";
|
||||
densityInput.step = "1";
|
||||
densityInput.value = "10";
|
||||
densityLabel.append(densityInput);
|
||||
|
||||
optionsDiv.append(sizeLabel, densityLabel);
|
||||
|
||||
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";
|
||||
root.append(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,
|
||||
@@ -24,29 +138,24 @@ export function createSurfacePointCloudViewer(): SurfacePointCloudViewer {
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, RENDER_OPTIONS.maxPixelRatio));
|
||||
|
||||
const scene = new THREE.Scene();
|
||||
scene.background = new THREE.Color(RENDER_OPTIONS.canvasClearColorLight);
|
||||
const camera = new THREE.PerspectiveCamera(
|
||||
RENDER_OPTIONS.cameraFov,
|
||||
1,
|
||||
RENDER_OPTIONS.cameraNear,
|
||||
RENDER_OPTIONS.cameraFar,
|
||||
);
|
||||
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 grid = new THREE.GridHelper(
|
||||
200,
|
||||
20,
|
||||
RENDER_OPTIONS.gridCenterColor,
|
||||
RENDER_OPTIONS.gridLineColor,
|
||||
);
|
||||
scene.add(grid);
|
||||
const axes = new THREE.AxesHelper(45);
|
||||
axes.visible = axesCheck.checked;
|
||||
scene.add(axes);
|
||||
|
||||
let pointsObject: THREE.Points | null = null;
|
||||
let animationFrame = 0;
|
||||
let currentData: SurfacePointCloudSampleResponse | null = null;
|
||||
|
||||
function resize(): void {
|
||||
const rect = root.getBoundingClientRect();
|
||||
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);
|
||||
@@ -54,10 +163,85 @@ export function createSurfacePointCloudViewer(): SurfacePointCloudViewer {
|
||||
camera.updateProjectionMatrix();
|
||||
}
|
||||
|
||||
function setCameraView(view: "iso" | "top" | "front" | "side"): void {
|
||||
const positions: Record<string, [number, number, number]> = {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -71,45 +255,138 @@ export function createSurfacePointCloudViewer(): SurfacePointCloudViewer {
|
||||
pointsObject = null;
|
||||
}
|
||||
|
||||
function render(data: SurfacePointCloudSampleResponse | null): void {
|
||||
function renderPointCloudInternal(data: SurfacePointCloudSampleResponse): void {
|
||||
clearPoints();
|
||||
if (!data || data.points.length === 0) return;
|
||||
if (data.points.length === 0) return;
|
||||
|
||||
const bounds = data.bounds;
|
||||
const cx = ((bounds.x_min ?? 0) + (bounds.x_max ?? 0)) / 2;
|
||||
const cy = ((bounds.y_min ?? 0) + (bounds.y_max ?? 0)) / 2;
|
||||
const cz = ((bounds.z_min ?? 0) + (bounds.z_max ?? 0)) / 2;
|
||||
const positions = new Float32Array(data.points.length * 3);
|
||||
data.points.forEach((point, index) => {
|
||||
positions[index * 3] = point[0] - cx;
|
||||
positions[index * 3 + 1] = point[2] - cz;
|
||||
positions[index * 3 + 2] = point[1] - cy;
|
||||
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({
|
||||
color: RENDER_OPTIONS.pointColor,
|
||||
size: 1.6,
|
||||
sizeAttenuation: false,
|
||||
size: sizeVal,
|
||||
vertexColors: true,
|
||||
sizeAttenuation: true,
|
||||
});
|
||||
|
||||
pointsObject = new THREE.Points(geometry, material);
|
||||
scene.add(pointsObject);
|
||||
|
||||
const radius = geometry.boundingSphere?.radius ?? 100;
|
||||
camera.position.set(radius * 0.9, radius * 0.7, radius * 1.4);
|
||||
camera.near = Math.max(RENDER_OPTIONS.cameraNear, radius / 10000);
|
||||
camera.far = Math.max(RENDER_OPTIONS.cameraFar, radius * 8);
|
||||
camera.updateProjectionMatrix();
|
||||
controls.target.set(0, 0, 0);
|
||||
controls.update();
|
||||
// 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 render(data: SurfacePointCloudSampleResponse | null): void {
|
||||
currentData = data;
|
||||
clearPoints();
|
||||
if (!data) {
|
||||
statusSpan.textContent = "데이터가 존재하지 않습니다.";
|
||||
return;
|
||||
}
|
||||
renderPointCloudInternal(data);
|
||||
setCameraView("top");
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
});
|
||||
|
||||
animationFrame = requestAnimationFrame(animate);
|
||||
|
||||
return {
|
||||
root,
|
||||
controlsGroup,
|
||||
optionsGroup,
|
||||
statusSpan,
|
||||
render,
|
||||
dispose: () => {
|
||||
cancelAnimationFrame(animationFrame);
|
||||
|
||||
Reference in New Issue
Block a user