import { themeColor } from "@ui/ui_template_palette"; import type { VWorldMeta } from "./B04_PreProcess_Api_Fetch"; // 2D 지도 벡터 레이어 렌더 엔진. // GeoJSON 좌표를 로드 시 1회만 정규화 맵 좌표(0~1)로 사전 투영해 두고, // 매 프레임에는 뷰포트·scale·offset을 합친 어파인 변환만 적용한다. // 정규화 좌표라 뷰포트 리사이즈 시에도 재투영이 필요 없다. 원본 GeoJSON은 변형하지 않는다. export type GeoJsonGeometry = { type: string; coordinates: unknown; }; export type GeoJsonFeature = { geometry?: GeoJsonGeometry | null; properties?: Record | null; }; export type GeoJsonCollection = { features?: GeoJsonFeature[]; }; export type MarkerKind = "dot" | "x"; /** 상류 세류망 강조 색 — 유역 판정의 기준선이라 가장 굵고 진하게 둔다. */ /** 배경 위에서 선·글자가 묻히지 않게 뒤에 까는 흰 테두리. */ export const haloColor = (): string => themeColor("--map-halo", "rgba(255, 255, 255, 0.9)"); /** * 계획선(노선) 표기 색 — B04 2D 지도와 B05 배수유역도가 **같은 값**을 쓴다. * 두 화면에서 같은 선을 다른 색으로 그리면 같은 것인지 알아볼 수 없다. * 색 값 자체는 `ui_template_theme.css`의 `--map-route`가 유일한 정의처다. */ export const routeLineColor = (): string => themeColor("--map-route", "#f97316"); /** 계획선 굵기(px) — 다른 레이어보다 굵게 둬야 배경 위에서 바로 눈에 띈다. */ export const ROUTE_LINE_WIDTH = 2.4; /** * 사전 투영된 하나의 파트(선/링/점 묶음). 좌표는 정규화 맵 좌표(0~1) x,y 교차 배열. * weights: Douglas-Peucker 가중치(정점 제거 시 발생하는 최대 오차, 종횡비 보정 좌표계). * 렌더 시 "화면 오차 < LOD_PX가 되는 정점"만 제외해 어느 줌에서도 시각적 무손실 LOD를 얻는다. * line 파트에만 존재하며 원본 GeoJSON은 변형하지 않는다. */ type PreparedPart = { coords: Float64Array; closed: boolean; weights: Float64Array | null; }; /** 사전 투영된 피처 1개. bbox는 정규화 좌표 기준이며 컬링에 사용한다. */ type PreparedFeature = { kind: "line" | "point"; parts: PreparedPart[]; minX: number; minY: number; maxX: number; maxY: number; /** 등고 라벨 앵커(정규화 좌표). 라벨 대상이 아니면 labelText가 null. */ labelAnchorX: number; labelAnchorY: number; labelText: string | null; }; export type PreparedLayer = { features: PreparedFeature[]; }; /** lon/lat → 정규화 맵 좌표 변환 계수. meta에만 의존한다. aspect는 지도 종횡비(w/h). */ export type Normalizer = { lonMin: number; latMin: number; lonRange: number; latRange: number; aspect: number; }; /** 뷰포트 안에서 지도 이미지가 차지하는 사각형(기존 getMapRect와 동일 계산). */ export type MapRect = { x: number; y: number; width: number; height: number; }; /** 프레임 단위 뷰 상태. */ export type ViewState = { width: number; height: number; scale: number; offsetX: number; offsetY: number; mapRect: MapRect; }; export function createNormalizer(meta: VWorldMeta): Normalizer { return { lonMin: meta.lon_min, latMin: meta.lat_min, lonRange: meta.lon_max - meta.lon_min || 1, latRange: meta.lat_max - meta.lat_min || 1, aspect: meta.width_meters / Math.max(meta.height_meters, 1), }; } export function computeMapRect(meta: VWorldMeta | null, width: number, height: number): MapRect { if (!meta) return { x: 0, y: 0, width, height }; const mapRatio = meta.width_meters / Math.max(meta.height_meters, 1); const viewportRatio = width / Math.max(height, 1); const mapWidth = mapRatio > viewportRatio ? width : height * mapRatio; const mapHeight = mapRatio > viewportRatio ? width / mapRatio : height; return { x: (width - mapWidth) / 2, y: (height - mapHeight) / 2, width: mapWidth, height: mapHeight, }; } /** 계획도로 주변으로 보여줄 여유 거리(m). B04 하단 지도와 B05 배수유역도가 같은 값을 쓴다 * (2026-08-01 사용자 지시: 도로 중심을 화면 중앙에, 도로 전체 + 200m까지). */ export const ROUTE_VIEW_MARGIN_M = 200; /** * 사업지 미터 좌표를 화면 좌표로 옮기는 변환기 (B04 지도·B05 배수유역도 공용). * * 배수유역도에서 쓰던 것을 여기로 옮겼다 — 두 화면이 같은 자리에 측점 눈금을 찍어야 한다 * (2026-09-04). `pxPerMeter` 는 라벨 솎기·축척 계산에 쓴다. */ export function createMetricProjector( meta: VWorldMeta, view: ViewState, ): { toScreen: (x: number, y: number) => [number, number]; pxPerMeter: number } { const spanX = meta.width_meters || 1; const spanY = meta.height_meters || 1; const ax = view.mapRect.width * view.scale; const bx = view.mapRect.x * view.scale + (view.width / 2) * (1 - view.scale) + view.offsetX; const ay = view.mapRect.height * view.scale; const by = view.mapRect.y * view.scale + (view.height / 2) * (1 - view.scale) + view.offsetY; return { toScreen: (x, y) => [ ((x - meta.x_min) / spanX) * ax + bx, (1 - (y - meta.y_min) / spanY) * ay + by, ], pxPerMeter: ax / spanX, }; } /** 규칙 측점 간격(m) — 종단 패널과 같은 20m 고정. 지도·배수유역도 눈금 표기 기준(2026-09-04). */ export const MAP_STATION_INTERVAL_M = 20; /** 최대 확대에서 화면 폭에 들어올 실거리(m) — 규칙 측점 20m 기준 1~2측점 * (2026-09-04 사용자 지시). 고정 배율(8배·16배)로는 도엽 크기마다 체감이 달라진다. */ export const MAX_ZOOM_VIEW_WIDTH_M = 20; /** 배율 상한의 안전장치 — 도엽 메타가 이상해도 여기서 멈춘다. */ export const ZOOM_SCALE_HARD_CAP = 2000; /** * 「화면 폭이 `MAX_ZOOM_VIEW_WIDTH_M` 가 될 때까지」에 해당하는 배율 상한을 구한다. * * 배율 1에서 도엽 실폭(`meta.width_meters`)이 지도 사각형 폭(px)을 채우므로, * 화면 폭(px)에 들어오는 실거리 = width_meters × viewportWidth / (mapRect.width × scale) 이다. * 이것을 20m 로 놓고 scale 을 푼다. 메타가 없으면 종전 고정값으로 되돌아간다. */ export function computeMaxScale( meta: VWorldMeta | null, mapRectWidth: number, viewportWidth: number, fallback: number, ): number { if (!meta || mapRectWidth <= 0 || viewportWidth <= 0) return fallback; const scale = (meta.width_meters * viewportWidth) / (mapRectWidth * MAX_ZOOM_VIEW_WIDTH_M); return Math.min(ZOOM_SCALE_HARD_CAP, Math.max(fallback, scale)); } /** 평면 좌표(m) 범위. */ export interface PlanBounds { x_min: number; x_max: number; y_min: number; y_max: number; } /** * 도로 전체 + 여유 거리가 화면에 들어오도록 배율·이동량을 구한다(도로 중심이 화면 중앙). * * 지도 초기 화면의 유일한 정의처 — B04 하단 지도와 B05 배수유역도가 함께 쓴다. * 배경 지도보다 넓은 범위를 요구하면 배경 크기에 맞춰 멈춘다(빈 여백을 만들지 않는다). */ export function computeRouteView( meta: VWorldMeta | null, route: PlanBounds | null, viewportWidth: number, viewportHeight: number, marginM: number = ROUTE_VIEW_MARGIN_M, ): { scale: number; offsetX: number; offsetY: number } { if (!meta || !route) return { scale: 1, offsetX: 0, offsetY: 0 }; const mapRect = computeMapRect(meta, viewportWidth, viewportHeight); const wantWidth = Math.max(route.x_max - route.x_min, 1) + marginM * 2; const wantHeight = Math.max(route.y_max - route.y_min, 1) + marginM * 2; const scale = Math.max( Math.min(meta.width_meters / wantWidth, meta.height_meters / wantHeight), 1, ); const centerX = (route.x_min + route.x_max) / 2; const centerY = (route.y_min + route.y_max) / 2; const baseX = mapRect.x + ((centerX - meta.x_min) / meta.width_meters) * mapRect.width; const baseY = mapRect.y + (1 - (centerY - meta.y_min) / meta.height_meters) * mapRect.height; return { scale, offsetX: -(baseX - viewportWidth / 2) * scale, offsetY: -(baseY - viewportHeight / 2) * scale, }; } 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 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); // 계곡선(25m 배수)만 라벨 — 전체 표기 시 화면이 숫자로 뒤덮이는 것 방지 if (Number.isFinite(elevation) && elevation % 25 === 0) { const anchor = labelAnchorOf(feature.geometry, normalizer); if (anchor) { labelText = String(elevation); labelAnchorX = anchor[0]; labelAnchorY = anchor[1]; } } } features.push({ kind, parts, minX, minY, maxX, maxY, labelAnchorX, labelAnchorY, labelText }); } 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, }, ], }; } /** * 프레임당 1회 계산하는 어파인 계수. * base = mapRect.x + norm * mapRect.width * screen = center + (base - center) * scale + offset * = norm * (mapRect.width * scale) + (mapRect.x * scale + center * (1 - scale) + offset) */ type Affine = { ax: number; bx: number; ay: number; by: number }; function affineOf(view: ViewState): Affine { const centerX = view.width / 2; const centerY = view.height / 2; return { ax: view.mapRect.width * view.scale, bx: view.mapRect.x * view.scale + centerX * (1 - view.scale) + view.offsetX, ay: view.mapRect.height * view.scale, by: view.mapRect.y * view.scale + centerY * (1 - view.scale) + view.offsetY, }; } /** 정규화 좌표(0~1) → 화면 px. 프레임마다 여러 번 부를 것이라면 `affineOf`를 한 번 잡아 쓴다. */ export function normalizedToScreen( view: ViewState, nx: number, ny: number, ): [x: number, y: number] { const affine = affineOf(view); return [nx * affine.ax + affine.bx, ny * affine.ay + affine.by]; } /** lon/lat → 화면 px. 마커를 찍거나 외곽선을 그릴 때 쓴다. */ export function lonLatToScreen( normalizer: Normalizer, view: ViewState, lon: number, lat: number, ): [x: number, y: number] { return normalizedToScreen( view, (lon - normalizer.lonMin) / normalizer.lonRange, 1 - (lat - normalizer.latMin) / normalizer.latRange, ); } /** * 사업지 좌표계(m) → 화면 px. `prepareMetricPolyline`과 **같은 가정**을 쓴다 — * meta의 x/y 범위와 lon/lat 범위가 같은 사각형을 가리킨다는 것. */ export function metricToScreen( meta: VWorldMeta, view: ViewState, x: number, y: number, ): [x: number, y: number] { const spanX = meta.width_meters || 1; const spanY = meta.height_meters || 1; return normalizedToScreen(view, (x - meta.x_min) / spanX, 1 - (y - meta.y_min) / spanY); } /** 시각적 무손실 LOD 허용 오차(화면 px). 이보다 작은 오차의 정점만 생략된다. */ const LOD_PX = 0.75; function drawLineParts( context: CanvasRenderingContext2D, feature: PreparedFeature, affine: Affine, ): void { // affine.ax = 정규화 1.0당 화면 px — 종횡비 보정 좌표계의 거리를 px로 바꾸는 계수. const tolerance = LOD_PX / affine.ax; for (const part of feature.parts) { const coords = part.coords; if (coords.length < 4) continue; const weights = part.weights; // 현재 줌에서 화면 오차 LOD_PX 미만인 정점만 생략 (끝점은 weight=∞라 항상 유지). // 정점 사이 보간은 하지 않는다 — 원본 데이터의 형상 그대로 표시 (2026-07-28 사용자 지시). context.beginPath(); let started = false; for (let i = 0; i < coords.length; i += 2) { if (weights && weights[i / 2] < tolerance) continue; const x = coords[i] * affine.ax + affine.bx; const y = coords[i + 1] * affine.ay + affine.by; if (started) context.lineTo(x, y); else { context.moveTo(x, y); started = true; } } if (!started) continue; if (part.closed) context.closePath(); context.stroke(); } } function drawPointParts( context: CanvasRenderingContext2D, feature: PreparedFeature, affine: Affine, marker: MarkerKind, ): void { for (const part of feature.parts) { const coords = part.coords; for (let i = 0; i < coords.length; i += 2) { const x = coords[i] * affine.ax + affine.bx; const y = coords[i + 1] * affine.ay + affine.by; if (marker === "x") { // 표고점: 조금 굵고 큰 X 마커 const arm = 4; const prevWidth = context.lineWidth; context.lineWidth = 2; context.beginPath(); context.moveTo(x - arm, y - arm); context.lineTo(x + arm, y + arm); context.moveTo(x - arm, y + arm); context.lineTo(x + arm, y - arm); context.stroke(); context.lineWidth = prevWidth; continue; } context.beginPath(); context.arc(x, y, 2, 0, Math.PI * 2); context.fillStyle = context.strokeStyle; context.fill(); } } } /** 컬링 여백: 선 굵기·X 마커 팔 길이·라벨 폭을 감안한 화면 밖 판정 마진(px). */ const CULL_MARGIN = 32; function isVisible(feature: PreparedFeature, affine: Affine, view: ViewState): boolean { const margin = CULL_MARGIN; const minX = feature.minX * affine.ax + affine.bx; const maxX = feature.maxX * affine.ax + affine.bx; const minY = feature.minY * affine.ay + affine.by; const maxY = feature.maxY * affine.ay + affine.by; return !( maxX < -margin || minX > view.width + margin || maxY < -margin || minY > view.height + margin ); } /** 레이어 1개를 그린다. context의 lineWidth/strokeStyle은 호출부에서 설정한다. */ export function drawPreparedLayer( context: CanvasRenderingContext2D, layer: PreparedLayer, view: ViewState, marker: MarkerKind, ): void { const affine = affineOf(view); for (const feature of layer.features) { if (!isVisible(feature, affine, view)) continue; if (feature.kind === "point") drawPointParts(context, feature, affine, marker); else drawLineParts(context, feature, affine); } } /** 사전 계산된 계곡선 라벨을 그린다. 폰트·정렬은 호출부에서 설정한다. */ export function drawPreparedLabels( context: CanvasRenderingContext2D, layer: PreparedLayer, view: ViewState, color: string, ): void { const affine = affineOf(view); const margin = CULL_MARGIN; for (const feature of layer.features) { if (feature.labelText === null) continue; const x = feature.labelAnchorX * affine.ax + affine.bx; const y = feature.labelAnchorY * affine.ay + affine.by; if (x < -margin || x > view.width + margin) continue; if (y < -margin || y > view.height + margin) continue; context.lineWidth = 3; context.strokeStyle = haloColor(); context.strokeText(feature.labelText, x, y); context.fillStyle = color; context.fillText(feature.labelText, x, y); } } /** 채움 폴리곤 오버레이(배수유역 등). 좌표는 lon/lat 링 1개. */