Files
Aislo/B04_PreProcess/logic/B04_PreProcess_UI_MapRender_Prepare.ts
T
eomsangdonandClaude Opus 5.5 9c2b5e151b fix(B04·B05): 위성 메타 TM 격자를 상자 둘레 한 폭씩 넓힘 — 25×25 (PLAN 589-1)
- 도엽이 위성 상자 밖 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>
2026-10-29 20:54:08 +09:00

437 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =============================================================================
* 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 };
}