- 도엽이 위성 상자 밖 0.8 폭까지 나가 9×9 격자 늘려 씀 자리에서 0.33 m 어긋남(화면 실측) - 격자 범위 = 상자 ± 한 폭(3 × 3 폭) · 상자 안 칸 크기 그대로(8 등분) · 격자 자기 경위도 범위 칸 더함 - 지킴 시험 = 상자 밖 0.9 폭까지 pyproj 0.02 m 안 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
437 lines
16 KiB
TypeScript
437 lines
16 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,
|
||
PartLod,
|
||
PreparedFeature,
|
||
PreparedLayer,
|
||
PreparedPart,
|
||
} from "../B04_PreProcess_UI_MapRender";
|
||
|
||
/** 줌 단계 무게(정규화 좌표 · 큰 → 작은 · 4 배 간격) — 화면 1 폭 ≈ 1,400 px 이면 다 줌아웃 ~ 2,000 배. */
|
||
const LOD_THRESHOLDS = [4 ** -5, 4 ** -6, 4 ** -7, 4 ** -8, 4 ** -9, 4 ** -10];
|
||
/** 이보다 짧은 조각은 색인 없이 전부 훑음(훑기가 이미 짧음). */
|
||
const LOD_MIN_VERTICES = 64;
|
||
|
||
/** 조각 하나의 줌 단계 색인. 짧거나 무게가 없으면 undefined. */
|
||
export function buildPartLod(weights: Float64Array | null): PartLod | undefined {
|
||
if (!weights || weights.length < LOD_MIN_VERTICES) return undefined;
|
||
const lists = LOD_THRESHOLDS.map((threshold) => {
|
||
const kept: number[] = [];
|
||
for (let v = 0; v < weights.length; v += 1) if (weights[v] >= threshold) kept.push(v);
|
||
return Uint32Array.from(kept);
|
||
});
|
||
return { thresholds: LOD_THRESHOLDS, lists };
|
||
}
|
||
|
||
/** 허용 오차에 맞는 가장 성긴 단 — 「무게 ≥ 오차」 정점을 모두 담음. 맞는 단이 없으면 null(전부 훑음). */
|
||
export function pickLodList(lod: PartLod, tolerance: number): Uint32Array | null {
|
||
for (let k = 0; k < lod.thresholds.length; k += 1)
|
||
if (lod.thresholds[k] <= tolerance) return lod.lists[k];
|
||
return null;
|
||
}
|
||
|
||
/** 레이어 선 조각마다 줌 단계 색인을 붙임(제자리 · 582 — 지금 D03 만 부름). */
|
||
export function indexLayerLod(layer: PreparedLayer): PreparedLayer {
|
||
for (const feature of layer.features) {
|
||
if (feature.kind !== "line") continue;
|
||
for (const part of feature.parts) part.lod = buildPartLod(part.weights);
|
||
}
|
||
return layer;
|
||
}
|
||
|
||
/** 경위도 → 정규화(0~1) — 고를 칸 `toNorm` 이 있으면 그것(TM 자리) · 없으면 같은 네모 식. */
|
||
export function normOfLonLat(normalizer: Normalizer, lon: number, lat: number): [number, number] {
|
||
if (normalizer.toNorm) return normalizer.toNorm(lon, lat);
|
||
return [
|
||
(lon - normalizer.lonMin) / normalizer.lonRange,
|
||
1 - (lat - normalizer.latMin) / normalizer.latRange,
|
||
];
|
||
}
|
||
|
||
/**
|
||
* 경위도 → TM 정규화(589-1) — 서버 `tm_grid`(그림 상자 둘레 경위도 고른 마디의 TM 값) 쌍선형 →
|
||
* `((x − x_min)/W, 1 − (y − y_min)/H)` = `metricToScreen` 과 같은 자. 격자 밖은 가장자리 칸을 늘려 씀.
|
||
* 격자가 없거나 모양이 틀리면 null.
|
||
*/
|
||
export function tmGridNormOf(
|
||
meta: VWorldMeta,
|
||
): ((lon: number, lat: number) => [number, number]) | null {
|
||
const grid = meta.tm_grid;
|
||
const n = grid?.n ?? 0;
|
||
if (!grid || n < 2 || grid.x.length !== n * n || grid.y.length !== n * n) return null;
|
||
const { x: gx, y: gy } = grid;
|
||
const cells = n - 1;
|
||
const lonRange = grid.lon_max - grid.lon_min || 1;
|
||
const latRange = grid.lat_max - grid.lat_min || 1;
|
||
const spanX = meta.width_meters || 1;
|
||
const spanY = meta.height_meters || 1;
|
||
return (lon, lat) => {
|
||
const u = ((lon - grid.lon_min) / lonRange) * cells;
|
||
const v = ((lat - grid.lat_min) / latRange) * cells;
|
||
const i = Math.min(Math.max(Math.floor(u), 0), cells - 1);
|
||
const j = Math.min(Math.max(Math.floor(v), 0), cells - 1);
|
||
const fu = u - i;
|
||
const fv = v - j;
|
||
const k = j * n + i;
|
||
const w00 = (1 - fu) * (1 - fv);
|
||
const w10 = fu * (1 - fv);
|
||
const w01 = (1 - fu) * fv;
|
||
const w11 = fu * fv;
|
||
const x = w00 * gx[k] + w10 * gx[k + 1] + w01 * gx[k + n] + w11 * gx[k + n + 1];
|
||
const y = w00 * gy[k] + w10 * gy[k + 1] + w01 * gy[k + n] + w11 * gy[k + n + 1];
|
||
return [(x - meta.x_min) / spanX, 1 - (y - meta.y_min) / spanY];
|
||
};
|
||
}
|
||
|
||
const mercatorOf = (lat: number): number => Math.log(Math.tan(Math.PI / 4 + (lat * Math.PI) / 360));
|
||
const latOfMercator = (m: number): number => (Math.atan(Math.exp(m)) * 360) / Math.PI - 90;
|
||
|
||
/**
|
||
* 위성 그림 px → 화면 CSS 행렬 `[a, b, c, d, e, f]`(원점 그림 왼위 · 589-1).
|
||
* 그림 = 웹 메르카토르 타일 이음(가로 경도 선형 · 세로 메르카토르 y 선형) → 경위도 → `normalizer` →
|
||
* `toScreen`. 이 길을 화면 (`centerX`, `centerY`) 에 닿는 그림 px 에서 1차로 맞춤 — 옮김 · 확대마다 다시 셈.
|
||
*/
|
||
export function imageScreenMatrix(
|
||
meta: VWorldMeta,
|
||
normalizer: Normalizer,
|
||
imageWidth: number,
|
||
imageHeight: number,
|
||
toScreen: (nx: number, ny: number) => [number, number],
|
||
centerX: number,
|
||
centerY: number,
|
||
): [number, number, number, number, number, number] {
|
||
const lonRange = meta.lon_max - meta.lon_min;
|
||
const mTop = mercatorOf(meta.lat_max);
|
||
const mSpan = mTop - mercatorOf(meta.lat_min);
|
||
const screenOf = (u: number, v: number): [number, number] => {
|
||
const lon = meta.lon_min + (u / imageWidth) * lonRange;
|
||
const lat = latOfMercator(mTop - (v / imageHeight) * mSpan);
|
||
const [nx, ny] = normOfLonLat(normalizer, lon, lat);
|
||
return toScreen(nx, ny);
|
||
};
|
||
let u = imageWidth / 2;
|
||
let v = imageHeight / 2;
|
||
let a = 1;
|
||
let b = 0;
|
||
let c = 0;
|
||
let d = 1;
|
||
let at = screenOf(u, v);
|
||
// 뉴턴 셋 — 길이 거의 1차라 한두 번에 화면 가운데 그림 px 가 잡힘.
|
||
for (let step = 0; step < 3; step += 1) {
|
||
const h = 0.5;
|
||
const du0 = screenOf(u - h, v);
|
||
const du1 = screenOf(u + h, v);
|
||
const dv0 = screenOf(u, v - h);
|
||
const dv1 = screenOf(u, v + h);
|
||
a = (du1[0] - du0[0]) / (2 * h);
|
||
b = (du1[1] - du0[1]) / (2 * h);
|
||
c = (dv1[0] - dv0[0]) / (2 * h);
|
||
d = (dv1[1] - dv0[1]) / (2 * h);
|
||
const det = a * d - b * c;
|
||
if (!Number.isFinite(det) || Math.abs(det) < 1e-12) break;
|
||
const ex = centerX - at[0];
|
||
const ey = centerY - at[1];
|
||
u += (d * ex - c * ey) / det;
|
||
v += (a * ey - b * ex) / det;
|
||
at = screenOf(u, v);
|
||
}
|
||
return [a, b, c, d, at[0] - a * u - c * v, at[1] - b * u - d * v];
|
||
}
|
||
|
||
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;
|
||
const [nx, ny] = normOfLonLat(normalizer, point[0], point[1]);
|
||
coords[count * 2] = nx;
|
||
coords[count * 2 + 1] = ny;
|
||
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 normOfLonLat(normalizer, mid[0], mid[1]);
|
||
}
|
||
|
||
/**
|
||
* 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 };
|
||
}
|