B04_PreProcess_UI_TerrainViewer.ts 958 -> 698줄. - _TerrainViewer_Chrome.ts(209줄) — 상태줄·표시 토글·등고선 간격 폼·뷰포트·축척·컴파스· 표고 범례·로딩 서클 DOM 생성. 만드는 순서·클래스·인라인 스타일 그대로. - _TerrainViewer_Contours.ts(189줄) — 등고선 적재(fetch·라인 생성·라벨·범례). 뷰어 클로저가 쥐던 값만 ctx 로 받고 본문 수치·로직은 그대로. 호출부는 얇은 래퍼로 유지. 검증: export 2개(SurfaceTerrainViewer · createSurfaceTerrainViewer) 분리 전과 동일, tsc --noEmit 통과, prettier 적용. 공용 브라우저(5174) B04 실조작 — 뷰어 2개·캔버스 2개 렌더, 상태줄 CLASSIFICATION · DTM · 등고선 1m, 등고 라벨 40개(상한), 범례 940m/760m, 등고선 토글을 끄면 라벨 0 → 켜면 40 복귀, 간격 5m 적용 시 상태가 5m 로 바뀌고 1m 로 되돌리면 원상 복귀. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
190 lines
7.1 KiB
TypeScript
190 lines
7.1 KiB
TypeScript
/* =============================================================================
|
|
* B04_PreProcess_UI_TerrainViewer_Contours.ts
|
|
* 지표면 3D 뷰어의 **등고선 적재** — 서버(또는 보관함)에서 등고선을 받아 THREE 라인으로
|
|
* 얹고, 표고 범례(최고·최저)를 갱신한다.
|
|
*
|
|
* `B04_PreProcess_UI_TerrainViewer` 가 700줄을 넘겨 떼어낸 조각이다(2026-09-04).
|
|
* 본문 로직·수치는 그대로이고, 뷰어 클로저가 쥐고 있던 값만 `ctx` 로 받는다.
|
|
* ========================================================================== */
|
|
|
|
import * as THREE from "three";
|
|
|
|
import { API_BASE_URL } from "@config/config_frontend";
|
|
import { fetchCachedJson } from "../A00_Common/b_asset_cache";
|
|
|
|
/** 화면에 띄우는 등고 라벨 상한 — 긴 등고선부터 채운다(분리 전 상수 그대로). */
|
|
const MAX_CONTOUR_LABELS = 40;
|
|
|
|
/** 뷰어 본체가 쥔 값 중 등고선 적재에 필요한 것들. */
|
|
export interface ContourLoadContext {
|
|
viewerArea: HTMLElement;
|
|
legendBar: HTMLElement;
|
|
maxValSpan: HTMLElement;
|
|
minValSpan: HTMLElement;
|
|
intervalInput: HTMLInputElement;
|
|
camera: THREE.PerspectiveCamera;
|
|
contourGroup: THREE.Group;
|
|
contourCheck: HTMLInputElement;
|
|
/** 지금 보고 있는 프로젝트·모델 — 응답이 늦게 와도 최신 요청만 반영하려고 함수로 받는다. */
|
|
projectId: () => string;
|
|
currentModelId: () => number | null;
|
|
currentModelSmooth: () => boolean;
|
|
/** 등고 라벨 DOM 목록(뷰어와 **같은 배열**을 공유한다)과 다시 배치하라는 표시. */
|
|
labelElements: HTMLDivElement[];
|
|
setLabelsDirty: (dirty: boolean) => void;
|
|
clearContours: () => void;
|
|
}
|
|
|
|
export async function loadContourLinesInto(
|
|
ctx: ContourLoadContext,
|
|
modelId: number,
|
|
isSmooth: boolean,
|
|
recalculate = false,
|
|
): Promise<boolean> {
|
|
const interval = parseFloat(ctx.intervalInput.value) || 1.0;
|
|
const projectId = ctx.projectId();
|
|
const contourUrl = `${API_BASE_URL}/projects/${projectId}/surface/models/${modelId}/contour?interval=${interval}&smooth=${isSmooth}&recalculate=${recalculate}`;
|
|
|
|
try {
|
|
// 등고선도 보관함에서 먼저 찾는다 — 같은 파일을 새로고침마다 다시 내려받지 않는다.
|
|
const data = await fetchCachedJson<any>(projectId, contourUrl);
|
|
if (
|
|
ctx.projectId() !== projectId ||
|
|
ctx.currentModelId() !== modelId ||
|
|
ctx.currentModelSmooth() !== isSmooth ||
|
|
(parseFloat(ctx.intervalInput.value) || 1.0) !== interval
|
|
) {
|
|
return false;
|
|
}
|
|
|
|
ctx.clearContours();
|
|
|
|
const bounds = data.bounds;
|
|
if (!bounds) return false;
|
|
|
|
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;
|
|
// 등고선 한 가닥마다 3D 객체를 만들면 수백 개가 되어 그리기가 느려진다.
|
|
// 주곡선·보조곡선 두 덩어리로 합쳐 객체 2개만 만든다(2026-08-01).
|
|
const majorPoints: THREE.Vector3[] = [];
|
|
const minorPoints: THREE.Vector3[] = [];
|
|
const labelCandidates: {
|
|
level: number;
|
|
position: THREE.Vector3;
|
|
length: number;
|
|
}[] = [];
|
|
|
|
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 isMajor = c.level % (interval * 5) === 0;
|
|
const bucket = isMajor ? majorPoints : minorPoints;
|
|
for (let i = 0; i < points.length - 1; i++) {
|
|
bucket.push(points[i], points[i + 1]);
|
|
}
|
|
|
|
// 라벨은 여기서 만들지 않고 후보만 모은다 — 등고선이 잘게 쪼개지면 조각마다
|
|
// 라벨이 붙어 수백 개가 되고, 매 프레임 위치 재계산이 화면을 멈춰 세운다
|
|
// (2026-08-30 사용자 보고). 아래에서 긴 것부터 상한만큼만 만든다.
|
|
if (isMajor && points.length > 4) {
|
|
let length = 0;
|
|
for (let i = 0; i < points.length - 1; i++) {
|
|
length += points[i].distanceTo(points[i + 1]);
|
|
}
|
|
labelCandidates.push({
|
|
level: c.level,
|
|
position: points[Math.floor(points.length / 2)],
|
|
length,
|
|
});
|
|
}
|
|
});
|
|
|
|
labelCandidates.sort((a, b) => b.length - a.length);
|
|
for (const candidate of labelCandidates.slice(0, MAX_CONTOUR_LABELS)) {
|
|
const labelPos = candidate.position;
|
|
const labelDiv = document.createElement("div");
|
|
labelDiv.className = "contour-label";
|
|
labelDiv.innerText = `${Math.round(candidate.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 (!ctx.contourCheck.checked) {
|
|
labelDiv.style.display = "none";
|
|
return;
|
|
}
|
|
const proj = labelPos.clone().project(ctx.camera);
|
|
const x = (proj.x * 0.5 + 0.5) * ctx.viewerArea.clientWidth;
|
|
const y = (-(proj.y * 0.5) + 0.5) * ctx.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`;
|
|
}
|
|
};
|
|
|
|
ctx.viewerArea.appendChild(labelDiv);
|
|
ctx.labelElements.push(labelDiv);
|
|
ctx.setLabelsDirty(true);
|
|
}
|
|
|
|
// 합쳐 둔 점들을 주곡선·보조곡선 각각 한 덩어리로 올린다.
|
|
[
|
|
{ points: minorPoints, color: 0xf59e0b },
|
|
{ points: majorPoints, color: 0xd97706 },
|
|
].forEach(({ points, color }) => {
|
|
if (points.length === 0) return;
|
|
const geometry = new THREE.BufferGeometry().setFromPoints(points);
|
|
const material = new THREE.LineBasicMaterial({
|
|
color,
|
|
transparent: true,
|
|
opacity: 0.8,
|
|
});
|
|
ctx.contourGroup.add(new THREE.LineSegments(geometry, material));
|
|
});
|
|
|
|
if (minH !== Infinity && maxH !== -Infinity) {
|
|
const nearestMin10 = Math.round(minH / 10) * 10;
|
|
const nearestMax10 = Math.round(maxH / 10) * 10;
|
|
ctx.maxValSpan.textContent = `${nearestMax10}m`;
|
|
ctx.minValSpan.textContent = `${nearestMin10}m`;
|
|
ctx.legendBar.style.display = "flex";
|
|
} else {
|
|
ctx.legendBar.style.display = "none";
|
|
}
|
|
return true;
|
|
} catch (e) {
|
|
ctx.legendBar.style.display = "none";
|
|
return false;
|
|
}
|
|
}
|