Files
Aislo/B04_PreProcess/B04_PreProcess_UI_Viewer.ts
T
eomsangdonandClaude Sonnet 5.5 58dafb1572 refactor(B04): 전처리 화면 자체 변종을 부품 상자 부품으로 — 켜기 칩 · 분할 고르기 · 고르기 · 슬라이더 · 값 목록 · 카드 · 도구줄 (PLAN 553-1)
- 지도 층 단추 열다섯 안팎 · 표시 토글 셋 · 라이다 겹침 = 켜기 칩(층 색 점) · 도엽 보간 방식 = 분할 고르기 · 도구줄 부품
- 고르기 38 → 고르기 부품 · 점 크기 · 밀도 = 슬라이더 · 입력 파일 정보 = 값 목록 · 초기화 = 위험 고스트 · 묶음 상자 = 카드 + 패널 머리
- 죽은 CSS 열여섯 걷음 · 날 색 → 공통 값 · 가이드 촬영 선택자 · 지킴 시험 test_553_1_pages 늘림

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 22:09:36 +09:00

347 lines
12 KiB
TypeScript

import { RENDER_OPTIONS } from "@config/config_frontend";
import { createBadge } from "@ui/ui_template_badge";
import { createButton } from "@ui/ui_template_elements";
import { createSlider } from "@ui/ui_template_input";
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
import { createProgressCircle } from "@ui/ui_template_progress";
import * as THREE from "three";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
import type { SurfaceBounds, SurfacePointCloudSampleResponse } from "./B04_PreProcess_Api_Fetch";
import { buildSurfaceGroup } from "./B04_PreProcess_UI_Group";
import {
bindCursorPivotControls,
bindSurfaceViewerTheme,
getReferenceCenter,
getTopFitDistance,
TOP_VIEW_TILT,
niceScaleDistance,
SURFACE_CAMERA_FOV,
targetPlaneMetersPerPixel,
type SurfaceCameraState,
} from "./B04_PreProcess_UI_Camera";
export type { SurfaceCameraState } from "./B04_PreProcess_UI_Camera";
function L(key: keyof typeof ui_locales): string {
return ui_locales[key][currentLanguageIndex];
}
export interface SurfacePointCloudViewer {
root: HTMLElement;
controlsGroup: HTMLElement;
/** 점 크기·밀도 슬라이더 묶음. 페이지가 "모델 표시 옵션" 컨테이너 맨 아래에 붙인다. */
optionsContent: HTMLElement;
statusSpan: HTMLElement;
/** 로딩 서클 표시. 문구를 주면 켜고, null이면 끈다. `render()` 시 자동으로 꺼진다. */
setLoading: (label: string | null) => void;
render: (data: SurfacePointCloudSampleResponse | null) => void;
setAxesVisible: (visible: boolean) => void;
applyCameraState: (state: SurfaceCameraState) => void;
onCameraChange: (listener: (state: SurfaceCameraState) => void) => void;
resetOptions: () => void;
dispose: () => void;
}
type CameraView = "iso" | "top" | "front" | "side";
function makeButton(label: string): HTMLButtonElement {
return createButton({ label, variant: "ghost", size: 28 });
}
export function createSurfacePointCloudViewer(): SurfacePointCloudViewer {
const root = document.createElement("div");
root.className = "ui-card point-viewer";
// 뷰어 위 상태 글 — 배지 부품 · 자리(좌상단)만 B04 쪽 클래스가 정함
const statusSpan = createBadge("포인트 데이터 로딩 중...", "neutral");
statusSpan.classList.add("b04-surface__status-info");
const controls = document.createElement("div");
controls.className = "viewer-controls";
const viewButtons: Array<[CameraView, HTMLButtonElement]> = [
["iso", makeButton("사시도")],
["top", makeButton("상단")],
["front", makeButton("정면")],
["side", makeButton("측면")],
];
controls.append(...viewButtons.map(([, button]) => button));
const controlsGroup = buildSurfaceGroup(L("B04_Surface_Group_ViewControls"), [controls]);
/** 점 크기 · 밀도 — 슬라이더 부품(이름 · 막대 · 현재값). */
const options = document.createElement("div");
options.className = "b04-surface__options";
const size = createSlider({
label: L("B04_Surface_Opt_PointSize"),
min: 0.1,
max: 2.5,
step: 0.01,
value: 0.42,
format: (value) => value.toFixed(2),
});
const density = createSlider({
label: L("B04_Surface_Opt_Density"),
min: 1,
max: 10,
step: 1,
value: 10,
format: (value) => `${value * 10}%`,
});
const sizeInput = size.input;
const densityInput = density.input;
options.append(size.root, density.root);
const viewerArea = document.createElement("div");
viewerArea.className = "three-viewer";
const canvas = document.createElement("canvas");
canvas.className = "b04-surface-viewer__canvas";
const scaleBar = document.createElement("div");
scaleBar.className = "b04-surface__scale";
const scaleText = document.createElement("span");
scaleBar.append(scaleText);
viewerArea.append(canvas, scaleBar, statusSpan);
root.append(viewerArea);
// 뷰포트 정중앙 로딩 서클 — 지도·그래프·다른 3D 뷰어와 같은 공통 컴포넌트.
const progress = createProgressCircle({ overlay: true });
progress.root.hidden = true;
viewerArea.append(progress.root);
function setLoading(label: string | null): void {
progress.root.hidden = label === null;
if (label !== null) progress.set(null, label);
}
const renderer = new THREE.WebGLRenderer({
canvas,
antialias: RENDER_OPTIONS.antialias,
});
renderer.setPixelRatio(Math.min(window.devicePixelRatio, RENDER_OPTIONS.maxPixelRatio));
const scene = new THREE.Scene();
const releaseTheme = bindSurfaceViewerTheme((color) => {
scene.background = new THREE.Color(color);
});
const camera = new THREE.PerspectiveCamera(SURFACE_CAMERA_FOV, 1, 0.1, 22000);
const orbit = new OrbitControls(camera, canvas);
orbit.enableDamping = true;
orbit.dampingFactor = 0.08;
orbit.screenSpacePanning = true;
const axes = new THREE.AxesHelper(45);
axes.visible = false;
scene.add(axes);
let pointsObject: THREE.Points | null = null;
// 회전·줌 중심을 커서 아래 지점으로 (지형 뷰어·B05와 공용 유틸).
const releaseCursorPivot = bindCursorPivotControls({
camera,
controls: orbit,
element: canvas,
pickables: () => (pointsObject ? [pointsObject] : []),
scene,
});
let currentData: SurfacePointCloudSampleResponse | null = null;
let animationFrame = 0;
let hasConnected = false;
let disposed = false;
let cameraListener: ((state: SurfaceCameraState) => void) | null = null;
let suppressCameraEvent = false;
let referenceBounds: SurfaceBounds | 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 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 setCameraView(view: CameraView): void {
const directions: Record<CameraView, [number, number, number]> = {
iso: [120, 95, 135],
// 정확히 수직이면 lookAt이 화면 방향을 못 정해 첫 드래그에 화면이 뒤집힌다.
top: [0, 1, TOP_VIEW_TILT],
front: [0, 60, 240],
side: [240, 60, 0],
};
const aspect = viewerArea.clientWidth / Math.max(viewerArea.clientHeight, 1);
const distance = referenceBounds ? getTopFitDistance(referenceBounds, aspect) : 190;
orbit.target.set(0, 0, 0);
camera.position
.set(...directions[view])
.normalize()
.multiplyScalar(distance);
camera.near = Math.max(distance / 10000, 0.01);
camera.far = distance * 100;
camera.updateProjectionMatrix();
camera.lookAt(orbit.target);
orbit.update();
}
function emitCameraState(): void {
if (suppressCameraEvent || !cameraListener) return;
const offset = camera.position.clone().sub(orbit.target);
const distance = Math.max(offset.length(), 0.001);
offset.normalize();
cameraListener({
direction: [offset.x, offset.y, offset.z],
distanceMeters: distance,
targetMeters: [orbit.target.x, orbit.target.y, orbit.target.z],
});
}
function applyCameraState(state: SurfaceCameraState): void {
suppressCameraEvent = true;
orbit.target.set(...state.targetMeters);
camera.position
.set(...state.direction)
.multiplyScalar(Math.max(state.distanceMeters, 0.001))
.add(orbit.target);
camera.lookAt(orbit.target);
orbit.update();
suppressCameraEvent = false;
}
function renderPointCloud(data: SurfacePointCloudSampleResponse): void {
clearPoints();
const bounds = data.bounds;
const zMin = bounds.z_min ?? 0;
const zMax = bounds.z_max ?? 0;
const [xMid, yMid, zMid] = getReferenceCenter(bounds);
const zSpan = Math.max(zMax - zMin, 1e-9);
referenceBounds = bounds;
const density = Number(densityInput.value);
const step = Math.max(1, Math.ceil(10 / density));
const count = Math.ceil(data.points.length / step);
const positions = new Float32Array(count * 3);
const colors = new Float32Array(count * 3);
const hasRgb = Boolean(data.rgb && data.rgb.length === data.points.length);
let outputIndex = 0;
for (let index = 0; index < data.points.length; index += step) {
const [x, y, z] = data.points[index];
positions[outputIndex * 3] = x - xMid;
positions[outputIndex * 3 + 1] = z - zMid;
positions[outputIndex * 3 + 2] = -(y - yMid);
const rgb = hasRgb ? data.rgb?.[index] : undefined;
if (rgb) {
const divisor = Math.max(...rgb) > 255 ? 65535 : 255;
colors[outputIndex * 3] = rgb[0] / divisor;
colors[outputIndex * 3 + 1] = rgb[1] / divisor;
colors[outputIndex * 3 + 2] = rgb[2] / divisor;
} else {
const ratio = Math.max(0, Math.min(1, (z - zMin) / zSpan));
colors[outputIndex * 3] = (36 + ratio * 190) / 255;
colors[outputIndex * 3 + 1] = (86 + Math.sin(ratio * Math.PI) * 95) / 255;
colors[outputIndex * 3 + 2] = (128 - ratio * 80) / 255;
}
outputIndex += 1;
}
const geometry = new THREE.BufferGeometry();
geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
geometry.setAttribute("color", new THREE.BufferAttribute(colors, 3));
geometry.computeBoundingSphere();
const material = new THREE.PointsMaterial({
size: Number(sizeInput.value),
vertexColors: true,
sizeAttenuation: true,
});
pointsObject = new THREE.Points(geometry, material);
scene.add(pointsObject);
statusSpan.textContent = `${count.toLocaleString()}개 점 표시 중`;
}
function updateScaleBar(): void {
if (!currentData) {
scaleBar.hidden = true;
return;
}
const distance = camera.position.distanceTo(orbit.target);
const metersPerPixel = targetPlaneMetersPerPixel(distance, viewerArea.clientHeight);
const meters = niceScaleDistance(100 * metersPerPixel);
scaleBar.hidden = false;
scaleBar.style.width = `${meters / metersPerPixel}px`;
scaleText.textContent = meters >= 1000 ? `${meters / 1000} km` : `${meters} m`;
}
function animate(): void {
if (!root.isConnected) {
if (!hasConnected) animationFrame = requestAnimationFrame(animate);
else disposeViewer();
return;
}
hasConnected = true;
resize();
orbit.update();
updateScaleBar();
renderer.render(scene, camera);
animationFrame = requestAnimationFrame(animate);
}
function disposeViewer(): void {
if (disposed) return;
disposed = true;
cancelAnimationFrame(animationFrame);
releaseTheme();
releaseCursorPivot();
clearPoints();
orbit.dispose();
renderer.dispose();
}
viewButtons.forEach(([view, button]) => {
button.addEventListener("click", () => setCameraView(view));
});
sizeInput.addEventListener("input", () => {
if (pointsObject)
(pointsObject.material as THREE.PointsMaterial).size = Number(sizeInput.value);
});
densityInput.addEventListener("input", () => {
if (currentData) renderPointCloud(currentData);
});
orbit.addEventListener("change", emitCameraState);
animationFrame = requestAnimationFrame(animate);
return {
root,
controlsGroup,
optionsContent: options,
statusSpan,
setLoading,
render(data) {
setLoading(null);
currentData = data;
clearPoints();
if (!data) {
statusSpan.textContent = "데이터가 존재하지 않습니다.";
return;
}
renderPointCloud(data);
setCameraView("top");
},
applyCameraState,
onCameraChange(listener) {
cameraListener = listener;
},
setAxesVisible(visible) {
axes.visible = visible;
},
resetOptions() {
axes.visible = false;
size.set(0.42);
density.set(10);
if (currentData) renderPointCloud(currentData);
setCameraView("iso");
},
dispose: disposeViewer,
};
}