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