- 확정 지표면이 있으면 LAS 등고선을 바탕으로 씀, 없으면 도엽 등고선 유지 (계획서 0-9 ⑥) - 등고선 가닥마다 높이값 라벨, 겹치면 건너뜀 (③) - 등고선을 누르면 그 가닥만 도드라지고 상태줄에 높이 표기 (⑦) - 화면에 드는 가닥 수로 등고선 간격을 고름 — 1m 자료가 선으로 뭉개지던 것 해소 - `B04_PreProcess_UI_MapRender.ts` 700줄 초과로 사전 투영을 `_Prepare.ts` 로 분리 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012jsXWphgRUHAG2mFupSKPX
307 lines
10 KiB
TypeScript
307 lines
10 KiB
TypeScript
/* =============================================================================
|
|
* B04_PreProcess_UI_MapRender_Prepare.ts
|
|
* 지도 레이어 **사전 투영** — GeoJSON·사업지 좌표 폴리라인을 정규화 좌표로 펴고,
|
|
* 줌 무손실 LOD 가중치(Douglas-Peucker)와 등고 라벨 앵커를 미리 잡아 둔다.
|
|
*
|
|
* `B04_PreProcess_UI_MapRender.ts` 가 700줄을 넘겨 떼어낸 조각이다(2026-09-12).
|
|
* 본문 로직과 수치는 그대로다. 그리기는 그쪽, 준비는 이쪽 — 한 방향으로만 기대어
|
|
* 순환 참조가 생기지 않는다.
|
|
* ========================================================================== */
|
|
|
|
import type { VWorldMeta } from "./B04_PreProcess_Api_Fetch";
|
|
import type {
|
|
GeoJsonCollection,
|
|
GeoJsonGeometry,
|
|
Normalizer,
|
|
PreparedFeature,
|
|
PreparedLayer,
|
|
PreparedPart,
|
|
} from "./B04_PreProcess_UI_MapRender";
|
|
|
|
function isPoint(value: unknown): value is [number, number] {
|
|
return Array.isArray(value) && typeof value[0] === "number" && typeof value[1] === "number";
|
|
}
|
|
|
|
/** lon/lat 배열 → 정규화 좌표 Float64Array. 유효 정점이 없으면 null. */
|
|
function projectRing(ring: unknown, normalizer: Normalizer): Float64Array | null {
|
|
if (!Array.isArray(ring) || ring.length === 0) return null;
|
|
const coords = new Float64Array(ring.length * 2);
|
|
let count = 0;
|
|
for (const point of ring) {
|
|
if (!isPoint(point)) continue;
|
|
coords[count * 2] = (point[0] - normalizer.lonMin) / normalizer.lonRange;
|
|
coords[count * 2 + 1] = 1 - (point[1] - normalizer.latMin) / normalizer.latRange;
|
|
count += 1;
|
|
}
|
|
if (count === 0) return null;
|
|
return count * 2 === coords.length ? coords : coords.slice(0, count * 2);
|
|
}
|
|
|
|
function collectParts(
|
|
geometry: GeoJsonGeometry,
|
|
normalizer: Normalizer,
|
|
parts: PreparedPart[],
|
|
): "line" | "point" {
|
|
const coordinates = geometry.coordinates;
|
|
if (!Array.isArray(coordinates)) return "line";
|
|
const push = (ring: unknown, closed: boolean): void => {
|
|
const projected = projectRing(ring, normalizer);
|
|
if (projected) parts.push({ coords: projected, closed, weights: null });
|
|
};
|
|
switch (geometry.type) {
|
|
case "Point":
|
|
push([coordinates], false);
|
|
return "point";
|
|
case "MultiPoint":
|
|
push(coordinates, false);
|
|
return "point";
|
|
case "LineString":
|
|
push(coordinates, false);
|
|
return "line";
|
|
case "MultiLineString":
|
|
for (const line of coordinates) push(line, false);
|
|
return "line";
|
|
case "Polygon":
|
|
for (const ring of coordinates) push(ring, true);
|
|
return "line";
|
|
case "MultiPolygon":
|
|
for (const polygon of coordinates) {
|
|
if (!Array.isArray(polygon)) continue;
|
|
for (const ring of polygon) push(ring, true);
|
|
}
|
|
return "line";
|
|
default:
|
|
return "line";
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Douglas-Peucker 가중치 계산 (반복형, 스택 오버플로 방지).
|
|
* weights[i] = "허용 오차가 이 값보다 크면 정점 i를 버려도 되는" 임계값.
|
|
* 부모 구간의 오차로 상한을 걸어(cap) 어떤 허용 오차에서도 일관된 부분집합이 나오게 한다.
|
|
* y축은 1/aspect로 보정해 화면 픽셀 거리와 비례하는 좌표계에서 계산한다.
|
|
*/
|
|
function computeDpWeights(coords: Float64Array, aspect: number): Float64Array {
|
|
const n = coords.length / 2;
|
|
const weights = new Float64Array(n);
|
|
weights[0] = Infinity;
|
|
weights[n - 1] = Infinity;
|
|
if (n <= 2) return weights;
|
|
const stack: number[] = [0, n - 1];
|
|
const caps: number[] = [Infinity];
|
|
while (stack.length) {
|
|
const last = stack.pop()!;
|
|
const first = stack.pop()!;
|
|
const cap = caps.pop()!;
|
|
if (last - first < 2) continue;
|
|
const ax = coords[first * 2];
|
|
const ay = coords[first * 2 + 1] / aspect;
|
|
const bx = coords[last * 2];
|
|
const by = coords[last * 2 + 1] / aspect;
|
|
const dx = bx - ax;
|
|
const dy = by - ay;
|
|
const len = Math.sqrt(dx * dx + dy * dy);
|
|
let maxDist = -1;
|
|
let maxIndex = -1;
|
|
for (let i = first + 1; i < last; i += 1) {
|
|
const px = coords[i * 2] - ax;
|
|
const py = coords[i * 2 + 1] / aspect - ay;
|
|
const dist = len === 0 ? Math.sqrt(px * px + py * py) : Math.abs(px * dy - py * dx) / len;
|
|
if (dist > maxDist) {
|
|
maxDist = dist;
|
|
maxIndex = i;
|
|
}
|
|
}
|
|
const weight = Math.min(maxDist, cap);
|
|
weights[maxIndex] = weight;
|
|
stack.push(first, maxIndex, maxIndex, last);
|
|
caps.push(weight, weight);
|
|
}
|
|
return weights;
|
|
}
|
|
|
|
/** 등고 라벨 앵커: LineString/MultiLineString 첫 파트의 중앙 정점 (기존 동작 유지). */
|
|
function labelAnchorOf(geometry: GeoJsonGeometry, normalizer: Normalizer): [number, number] | null {
|
|
const coords = geometry.coordinates;
|
|
if (!Array.isArray(coords)) return null;
|
|
const line =
|
|
geometry.type === "LineString"
|
|
? coords
|
|
: geometry.type === "MultiLineString"
|
|
? coords[0]
|
|
: null;
|
|
if (!Array.isArray(line) || line.length === 0) return null;
|
|
const mid = line[Math.floor(line.length / 2)];
|
|
if (!isPoint(mid)) return null;
|
|
return [
|
|
(mid[0] - normalizer.lonMin) / normalizer.lonRange,
|
|
1 - (mid[1] - normalizer.latMin) / normalizer.latRange,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* GeoJSON 컬렉션 1개를 사전 투영한다.
|
|
* labelKeys가 주어지면 계곡선(25m 배수) 피처에만 라벨 텍스트·앵커를 계산해 둔다.
|
|
*/
|
|
export function prepareLayer(
|
|
collection: GeoJsonCollection | undefined,
|
|
normalizer: Normalizer,
|
|
labelKeys?: string[],
|
|
): PreparedLayer {
|
|
const features: PreparedFeature[] = [];
|
|
for (const feature of collection?.features ?? []) {
|
|
if (!feature.geometry) continue;
|
|
const parts: PreparedPart[] = [];
|
|
const kind = collectParts(feature.geometry, normalizer, parts);
|
|
if (parts.length === 0) continue;
|
|
if (kind === "line") {
|
|
for (const part of parts) {
|
|
if (part.coords.length < 6) continue;
|
|
part.weights = computeDpWeights(part.coords, normalizer.aspect);
|
|
}
|
|
}
|
|
let minX = Infinity;
|
|
let minY = Infinity;
|
|
let maxX = -Infinity;
|
|
let maxY = -Infinity;
|
|
for (const part of parts) {
|
|
const coords = part.coords;
|
|
for (let i = 0; i < coords.length; i += 2) {
|
|
const x = coords[i];
|
|
const y = coords[i + 1];
|
|
if (x < minX) minX = x;
|
|
if (x > maxX) maxX = x;
|
|
if (y < minY) minY = y;
|
|
if (y > maxY) maxY = y;
|
|
}
|
|
}
|
|
let labelText: string | null = null;
|
|
let labelValue: number | null = null;
|
|
let labelAnchorX = 0;
|
|
let labelAnchorY = 0;
|
|
if (labelKeys && labelKeys.length > 0) {
|
|
const raw = labelKeys.map((key) => feature.properties?.[key]).find((value) => value != null);
|
|
const elevation = typeof raw === "number" ? raw : Number(raw);
|
|
// **모든 등고선**에 앵커를 잡아 둔다. 어느 줄을 실제로 낼지는 그릴 때 고른다 —
|
|
// 화면마다 솎는 눈금이 다르기 때문이다(B04 지도는 계곡선만, 계획노선 편집 모달은
|
|
// 확대에 따라 더 촘촘히). 준비 단계에서 걸러 버리면 확대해도 되살릴 수가 없다.
|
|
if (Number.isFinite(elevation)) {
|
|
const anchor = labelAnchorOf(feature.geometry, normalizer);
|
|
if (anchor) {
|
|
labelText = String(elevation);
|
|
labelValue = elevation;
|
|
labelAnchorX = anchor[0];
|
|
labelAnchorY = anchor[1];
|
|
}
|
|
}
|
|
}
|
|
features.push({
|
|
kind,
|
|
parts,
|
|
minX,
|
|
minY,
|
|
maxX,
|
|
maxY,
|
|
labelAnchorX,
|
|
labelAnchorY,
|
|
labelText,
|
|
labelValue,
|
|
});
|
|
}
|
|
return { features };
|
|
}
|
|
|
|
/**
|
|
* 사업지 좌표계(m) 폴리라인을 한 개 피처짜리 레이어로 사전 투영한다.
|
|
* meta의 x/y 범위와 lon/lat 범위는 같은 사각형을 가리키므로, 미터 좌표도 GeoJSON과 동일한
|
|
* 정규화 공간으로 들어간다 — 노선 선형을 도엽 레이어 위에 그대로 겹칠 수 있다.
|
|
*/
|
|
export function prepareMetricPolyline(
|
|
points: ReadonlyArray<{ x: number; y: number }>,
|
|
meta: VWorldMeta,
|
|
): PreparedLayer {
|
|
if (points.length < 2) return { features: [] };
|
|
const widthMeters = meta.width_meters || 1;
|
|
const heightMeters = meta.height_meters || 1;
|
|
const coords = new Float64Array(points.length * 2);
|
|
let minX = Infinity;
|
|
let minY = Infinity;
|
|
let maxX = -Infinity;
|
|
let maxY = -Infinity;
|
|
points.forEach((point, index) => {
|
|
const nx = (point.x - meta.x_min) / widthMeters;
|
|
const ny = 1 - (point.y - meta.y_min) / heightMeters;
|
|
coords[index * 2] = nx;
|
|
coords[index * 2 + 1] = ny;
|
|
if (nx < minX) minX = nx;
|
|
if (nx > maxX) maxX = nx;
|
|
if (ny < minY) minY = ny;
|
|
if (ny > maxY) maxY = ny;
|
|
});
|
|
return {
|
|
features: [
|
|
{
|
|
kind: "line",
|
|
parts: [{ coords, closed: false, weights: null }],
|
|
minX,
|
|
minY,
|
|
maxX,
|
|
maxY,
|
|
labelAnchorX: 0,
|
|
labelAnchorY: 0,
|
|
labelText: null,
|
|
labelValue: null,
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 사업지 좌표계(m) 폴리라인 **여러 개**를 한 레이어로 사전 투영한다(LAS 등고선 등).
|
|
*
|
|
* `prepareMetricPolyline` 의 여러 줄 판이다. 줄마다 `label`(표고 m)을 주면 가운데 정점을
|
|
* 앵커로 잡아 `drawPreparedLabels` 가 그대로 쓸 수 있다.
|
|
*/
|
|
export function prepareMetricPolylines(
|
|
lines: ReadonlyArray<{ points: ReadonlyArray<readonly [number, number]>; label?: number }>,
|
|
meta: VWorldMeta,
|
|
): PreparedLayer {
|
|
const widthMeters = meta.width_meters || 1;
|
|
const heightMeters = meta.height_meters || 1;
|
|
const features: PreparedFeature[] = [];
|
|
for (const line of lines) {
|
|
if (line.points.length < 2) continue;
|
|
const coords = new Float64Array(line.points.length * 2);
|
|
let minX = Infinity;
|
|
let minY = Infinity;
|
|
let maxX = -Infinity;
|
|
let maxY = -Infinity;
|
|
line.points.forEach((point, index) => {
|
|
const nx = (point[0] - meta.x_min) / widthMeters;
|
|
const ny = 1 - (point[1] - meta.y_min) / heightMeters;
|
|
coords[index * 2] = nx;
|
|
coords[index * 2 + 1] = ny;
|
|
if (nx < minX) minX = nx;
|
|
if (nx > maxX) maxX = nx;
|
|
if (ny < minY) minY = ny;
|
|
if (ny > maxY) maxY = ny;
|
|
});
|
|
const middle = Math.floor(line.points.length / 2) * 2;
|
|
features.push({
|
|
kind: "line",
|
|
parts: [
|
|
{ coords, closed: false, weights: computeDpWeights(coords, widthMeters / heightMeters) },
|
|
],
|
|
minX,
|
|
minY,
|
|
maxX,
|
|
maxY,
|
|
labelAnchorX: coords[middle],
|
|
labelAnchorY: coords[middle + 1],
|
|
labelText: line.label === undefined ? null : String(line.label),
|
|
labelValue: line.label ?? null,
|
|
});
|
|
}
|
|
return { features };
|
|
}
|