/* ============================================================================= * 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; 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 }; }