diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_MapRender.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_MapRender.ts index 7ca62ffe..3255d59b 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_MapRender.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_MapRender.ts @@ -21,10 +21,18 @@ export type GeoJsonCollection = { export type MarkerKind = "dot" | "x"; -/** 사전 투영된 하나의 파트(선/링/점 묶음). 좌표는 정규화 맵 좌표(0~1) x,y 교차 배열. */ +/** + * 사전 투영된 하나의 파트(선/링/점 묶음). 좌표는 정규화 맵 좌표(0~1) x,y 교차 배열. + * weights: Douglas-Peucker 가중치(정점 제거 시 발생하는 최대 오차, 종횡비 보정 좌표계). + * 렌더 시 "화면 오차 < LOD_PX가 되는 정점"만 제외해 어느 줌에서도 시각적 무손실 LOD를 얻는다. + * avgSeg: 평균 정점 간격(종횡비 보정 좌표계) — 줌인 시 곡선 스무딩 발동 판단용. + * 둘 다 line 파트에만 존재하며 원본 GeoJSON은 변형하지 않는다. + */ type PreparedPart = { coords: Float64Array; closed: boolean; + weights: Float64Array | null; + avgSeg: number; }; /** 사전 투영된 피처 1개. bbox는 정규화 좌표 기준이며 컬링에 사용한다. */ @@ -45,12 +53,13 @@ export type PreparedLayer = { features: PreparedFeature[]; }; -/** lon/lat → 정규화 맵 좌표 변환 계수. meta에만 의존한다. */ +/** lon/lat → 정규화 맵 좌표 변환 계수. meta에만 의존한다. aspect는 지도 종횡비(w/h). */ export type Normalizer = { lonMin: number; latMin: number; lonRange: number; latRange: number; + aspect: number; }; /** 뷰포트 안에서 지도 이미지가 차지하는 사각형(기존 getMapRect와 동일 계산). */ @@ -78,6 +87,7 @@ export function createNormalizer(meta: VWorldMeta): Normalizer { 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), }; } @@ -121,51 +131,95 @@ function collectParts( ): "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, avgSeg: 0 }); + }; switch (geometry.type) { - case "Point": { - const projected = projectRing([coordinates], normalizer); - if (projected) parts.push({ coords: projected, closed: false }); + case "Point": + push([coordinates], false); return "point"; - } - case "MultiPoint": { - const projected = projectRing(coordinates, normalizer); - if (projected) parts.push({ coords: projected, closed: false }); + case "MultiPoint": + push(coordinates, false); return "point"; - } - case "LineString": { - const projected = projectRing(coordinates, normalizer); - if (projected) parts.push({ coords: projected, closed: false }); + case "LineString": + push(coordinates, false); return "line"; - } - case "MultiLineString": { - for (const line of coordinates) { - const projected = projectRing(line, normalizer); - if (projected) parts.push({ coords: projected, closed: false }); - } + case "MultiLineString": + for (const line of coordinates) push(line, false); return "line"; - } - case "Polygon": { - for (const ring of coordinates) { - const projected = projectRing(ring, normalizer); - if (projected) parts.push({ coords: projected, closed: true }); - } + case "Polygon": + for (const ring of coordinates) push(ring, true); return "line"; - } - case "MultiPolygon": { + case "MultiPolygon": for (const polygon of coordinates) { if (!Array.isArray(polygon)) continue; - for (const ring of polygon) { - const projected = projectRing(ring, normalizer); - if (projected) parts.push({ coords: projected, closed: true }); - } + 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; +} + +/** 평균 정점 간격(종횡비 보정 좌표계). 스무딩 발동 판단용. */ +function averageSegment(coords: Float64Array, aspect: number): number { + const n = coords.length / 2; + if (n < 2) return 0; + let total = 0; + for (let i = 1; i < n; i += 1) { + const dx = coords[i * 2] - coords[(i - 1) * 2]; + const dy = (coords[i * 2 + 1] - coords[(i - 1) * 2 + 1]) / aspect; + total += Math.sqrt(dx * dx + dy * dy); + } + return total / (n - 1); +} + /** 등고 라벨 앵커: LineString/MultiLineString 첫 파트의 중앙 정점 (기존 동작 유지). */ function labelAnchorOf(geometry: GeoJsonGeometry, normalizer: Normalizer): [number, number] | null { const coords = geometry.coordinates; @@ -200,6 +254,13 @@ export function prepareLayer( 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); + part.avgSeg = averageSegment(part.coords, normalizer.aspect); + } + } let minX = Infinity; let minY = Infinity; let maxX = -Infinity; @@ -255,20 +316,54 @@ function affineOf(view: ViewState): Affine { }; } +/** 시각적 무손실 LOD 허용 오차(화면 px). 이보다 작은 오차의 정점만 생략된다. */ +const LOD_PX = 0.75; +/** 평균 정점 간격이 이 화면 px를 넘으면(고배율 줌인) 곡선 스무딩을 켠다. */ +const SMOOTH_SEG_PX = 10; + +/** LOD 필터를 통과한 화면 좌표를 담는 재사용 버퍼 (settle 렌더에서만 쓰여 할당 부담 없음). */ +let filtered = new Float64Array(4096); + 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 < 2) continue; + const weights = part.weights; + if (filtered.length < coords.length) filtered = new Float64Array(coords.length); + // 1) 현재 줌에서 화면 오차 LOD_PX 미만인 정점 생략 (끝점은 weight=∞라 항상 유지) + let count = 0; + for (let i = 0; i < coords.length; i += 2) { + if (weights && weights[i / 2] < tolerance) continue; + filtered[count] = coords[i] * affine.ax + affine.bx; + filtered[count + 1] = coords[i + 1] * affine.ay + affine.by; + count += 2; + } + if (count < 4) continue; context.beginPath(); - context.moveTo(coords[0] * affine.ax + affine.bx, coords[1] * affine.ay + affine.by); - for (let i = 2; i < coords.length; i += 2) { - context.lineTo(coords[i] * affine.ax + affine.bx, coords[i + 1] * affine.ay + affine.by); + // 2) 줌인으로 정점 간격이 벌어져 각이 보이는 상태면 중점 quadratic 스플라인으로 곡선 표현 + const smooth = + !part.closed && count >= 6 && part.avgSeg > 0 && part.avgSeg * affine.ax > SMOOTH_SEG_PX; + if (smooth) { + context.moveTo(filtered[0], filtered[1]); + for (let i = 2; i < count - 2; i += 2) { + const midX = (filtered[i] + filtered[i + 2]) / 2; + const midY = (filtered[i + 1] + filtered[i + 3]) / 2; + context.quadraticCurveTo(filtered[i], filtered[i + 1], midX, midY); + } + context.lineTo(filtered[count - 2], filtered[count - 1]); + } else { + context.moveTo(filtered[0], filtered[1]); + for (let i = 2; i < count; i += 2) { + context.lineTo(filtered[i], filtered[i + 1]); + } + if (part.closed) context.closePath(); } - if (part.closed) context.closePath(); context.stroke(); } } diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts index 8efdf267..624daaee 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts @@ -460,7 +460,26 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { const stopDragging = (): void => { if (!dragStart) return; dragStart = null; - // 팬 종료 즉시 현재 뷰로 풀 렌더 — 가장자리 여유분을 채우고 선명도를 복원한다. + // 순수 팬(줌 비율 1)이고 이동량이 여유분 안이며 정수 디바이스 픽셀 이동이면 + // 캐시 transform 유지가 풀 렌더와 픽셀 단위 동일하므로 재렌더를 생략한다. + const dpr = window.devicePixelRatio || 1; + const tx = offsetX - cachedOffsetX; + const ty = offsetY - cachedOffsetY; + if ( + hasCachedFrame && + scale === cachedScale && + Number.isInteger(tx * dpr) && + Number.isInteger(ty * dpr) && + Math.abs(tx) < OVERSCAN / 2 && + Math.abs(ty) < OVERSCAN / 2 + ) { + if (settleTimer) { + window.clearTimeout(settleTimer); + settleTimer = 0; + } + return; + } + // 그 외에는 풀 렌더로 여유분을 다시 채우고 선명도를 복원한다. scheduleDraw(); }; viewport.addEventListener("pointerup", stopDragging);