diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_MapRender.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_MapRender.ts index 3255d59b..4fbdf35e 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_MapRender.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_MapRender.ts @@ -25,14 +25,12 @@ export type MarkerKind = "dot" | "x"; * 사전 투영된 하나의 파트(선/링/점 묶음). 좌표는 정규화 맵 좌표(0~1) x,y 교차 배열. * weights: Douglas-Peucker 가중치(정점 제거 시 발생하는 최대 오차, 종횡비 보정 좌표계). * 렌더 시 "화면 오차 < LOD_PX가 되는 정점"만 제외해 어느 줌에서도 시각적 무손실 LOD를 얻는다. - * avgSeg: 평균 정점 간격(종횡비 보정 좌표계) — 줌인 시 곡선 스무딩 발동 판단용. - * 둘 다 line 파트에만 존재하며 원본 GeoJSON은 변형하지 않는다. + * line 파트에만 존재하며 원본 GeoJSON은 변형하지 않는다. */ type PreparedPart = { coords: Float64Array; closed: boolean; weights: Float64Array | null; - avgSeg: number; }; /** 사전 투영된 피처 1개. bbox는 정규화 좌표 기준이며 컬링에 사용한다. */ @@ -70,7 +68,7 @@ export type MapRect = { height: number; }; -/** 프레임 단위 뷰 상태. overscan은 뷰포트 밖까지 미리 그려두는 여백(px) — 컬링 범위를 그만큼 넓힌다. */ +/** 프레임 단위 뷰 상태. */ export type ViewState = { width: number; height: number; @@ -78,7 +76,6 @@ export type ViewState = { offsetX: number; offsetY: number; mapRect: MapRect; - overscan: number; }; export function createNormalizer(meta: VWorldMeta): Normalizer { @@ -133,7 +130,7 @@ function collectParts( 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 }); + if (projected) parts.push({ coords: projected, closed, weights: null }); }; switch (geometry.type) { case "Point": @@ -207,19 +204,6 @@ function computeDpWeights(coords: Float64Array, aspect: number): Float64Array { 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; @@ -258,7 +242,6 @@ export function prepareLayer( 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; @@ -318,11 +301,6 @@ 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, @@ -333,37 +311,24 @@ function drawLineParts( const tolerance = LOD_PX / affine.ax; for (const part of feature.parts) { const coords = part.coords; - if (coords.length < 2) continue; + if (coords.length < 4) continue; const weights = part.weights; - if (filtered.length < coords.length) filtered = new Float64Array(coords.length); - // 1) 현재 줌에서 화면 오차 LOD_PX 미만인 정점 생략 (끝점은 weight=∞라 항상 유지) - let count = 0; + // 현재 줌에서 화면 오차 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; - 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(); - // 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); + 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; } - 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 (!started) continue; + if (part.closed) context.closePath(); context.stroke(); } } @@ -405,7 +370,7 @@ function drawPointParts( const CULL_MARGIN = 32; function isVisible(feature: PreparedFeature, affine: Affine, view: ViewState): boolean { - const margin = CULL_MARGIN + view.overscan; + 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; @@ -441,7 +406,7 @@ export function drawPreparedLabels( color: string, ): void { const affine = affineOf(view); - const margin = CULL_MARGIN + view.overscan; + const margin = CULL_MARGIN; for (const feature of layer.features) { if (feature.labelText === null) continue; const x = feature.labelAnchorX * affine.ax + affine.bx; diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts index 624daaee..89843297 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts @@ -141,23 +141,13 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { let offsetY = 0; let dragStart: { x: number; y: number; offsetX: number; offsetY: number } | null = null; let loadSequence = 0; - // rAF 스로틀: 팬/줌 이벤트는 상태만 갱신하고 프레임당 1회만 처리한다. + // rAF 스로틀: 팬/줌 이벤트는 상태만 갱신하고 프레임당 1회만 드로잉한다. + // LOD(MapRender) 덕에 프레임 렌더 비용이 낮아 매 프레임 직접 렌더가 항상 완전한 화면을 보장한다. let frameHandle = 0; - let framePendingFull = false; // 캔버스 버퍼는 크기가 실제로 변할 때만 재할당한다(재할당 시 내용이 지워지므로 매 프레임 금지). let canvasWidth = 0; let canvasHeight = 0; let canvasDpr = 0; - // 비트맵 캐시: 캔버스에 마지막으로 풀 렌더한 시점의 뷰 상태. - // 팬/줌 제스처 중에는 재드로잉 대신 이 상태 대비 CSS transform만 적용하고, - // 제스처가 끝나면(settle) 현재 뷰로 다시 풀 렌더해 선명도를 복원한다. - let cachedScale = 0; - let cachedOffsetX = 0; - let cachedOffsetY = 0; - let hasCachedFrame = false; - let settleTimer = 0; - // 팬 여유분: 뷰포트 밖까지 미리 그려 두는 폭(px). 이 범위 안의 팬은 가장자리 공백 없이 즉시 표시된다. - const OVERSCAN = 256; function makeLayerButton( label: string, @@ -294,25 +284,21 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { const height = Math.max(1, Math.floor(rect.height)); const dpr = window.devicePixelRatio || 1; // 버퍼 재할당은 캔버스 내용을 지우므로 크기가 실제로 변할 때만 수행한다. - // 버퍼는 뷰포트보다 OVERSCAN만큼 사방으로 크게 잡아, 제스처 중 팬 시 가장자리 공백을 막는다. if (width !== canvasWidth || height !== canvasHeight || dpr !== canvasDpr) { canvasWidth = width; canvasHeight = height; canvasDpr = dpr; - canvas.width = Math.floor((width + OVERSCAN * 2) * dpr); - canvas.height = Math.floor((height + OVERSCAN * 2) * dpr); - canvas.style.width = `${width + OVERSCAN * 2}px`; - canvas.style.height = `${height + OVERSCAN * 2}px`; - canvas.style.left = `${-OVERSCAN}px`; - canvas.style.top = `${-OVERSCAN}px`; + canvas.width = Math.floor(width * dpr); + canvas.height = Math.floor(height * dpr); + canvas.style.width = `${width}px`; + canvas.style.height = `${height}px`; } const context = canvas.getContext("2d"); if (!context) return; - // 뷰포트 좌표계로 그리되 버퍼 원점을 OVERSCAN만큼 밀어 여유분까지 채운다. - context.setTransform(dpr, 0, 0, dpr, OVERSCAN * dpr, OVERSCAN * dpr); - context.clearRect(-OVERSCAN, -OVERSCAN, width + OVERSCAN * 2, height + OVERSCAN * 2); + context.setTransform(dpr, 0, 0, dpr, 0, 0); + context.clearRect(0, 0, width, height); const mapRect = computeMapRect(meta, width, height); - const view: ViewState = { width, height, scale, offsetX, offsetY, mapRect, overscan: OVERSCAN }; + const view: ViewState = { width, height, scale, offsetX, offsetY, mapRect }; DRAW_ORDER.forEach((layer) => { if (!activeGisLayers.has(layer)) return; const prepared = preparedLayers.get(layer); @@ -331,64 +317,17 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { if (prepared) drawPreparedLabels(context, prepared, view, GIS_LAYER_COLORS[layer]); }); } - // 풀 렌더 완료 — 이 시점의 뷰 상태를 캐시 기준으로 삼고 제스처용 transform을 초기화한다. - canvas.style.transform = ""; - cachedScale = scale; - cachedOffsetX = offsetX; - cachedOffsetY = offsetY; - hasCachedFrame = true; updateImageTransform(); drawScaleBar(mapRect); } - /** - * 제스처 중 프레임: 재드로잉 없이 캐시된 비트맵에 CSS transform만 적용한다. - * 캐시 시점 뷰(k0, o0)와 현재 뷰(k1, o1)의 관계는 뷰포트 중심 기준 - * scale(r = k1/k0) + translate(o1 - o0·r)와 정확히 일치한다. - */ - function applyInteractiveTransform(): void { - if (!hasCachedFrame || !cachedScale) { - drawVectorLayer(); - return; - } - const ratio = scale / cachedScale; - const tx = offsetX - cachedOffsetX * ratio; - const ty = offsetY - cachedOffsetY * ratio; - canvas.style.transform = `translate(${tx}px, ${ty}px) scale(${ratio})`; - updateImageTransform(); - const rect = viewport.getBoundingClientRect(); - drawScaleBar(computeMapRect(meta, Math.max(1, rect.width), Math.max(1, rect.height))); - } - - /** 다음 프레임에 풀 렌더 1회 수행 (토글·리셋·로드·제스처 종료 등). */ + /** 팬/줌 등 연속 이벤트에서는 프레임당 1회만 실제 드로잉이 일어나게 한다. */ function scheduleDraw(): void { - if (settleTimer) { - window.clearTimeout(settleTimer); - settleTimer = 0; - } - framePendingFull = true; if (frameHandle) return; - frameHandle = window.requestAnimationFrame(runFrame); - } - - /** 제스처 중: 다음 프레임에 transform만 갱신하고, 잠잠해지면 풀 렌더로 선명도를 복원한다. */ - function scheduleInteractive(): void { - if (!frameHandle) frameHandle = window.requestAnimationFrame(runFrame); - if (settleTimer) window.clearTimeout(settleTimer); - settleTimer = window.setTimeout(() => { - settleTimer = 0; - scheduleDraw(); - }, 150); - } - - function runFrame(): void { - frameHandle = 0; - if (framePendingFull) { - framePendingFull = false; + frameHandle = window.requestAnimationFrame(() => { + frameHandle = 0; drawVectorLayer(); - } else { - applyInteractiveTransform(); - } + }); } async function loadLayers(): Promise { @@ -443,7 +382,7 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { (event) => { event.preventDefault(); scale = Math.min(8, Math.max(0.5, scale * (event.deltaY < 0 ? 1.15 : 0.87))); - scheduleInteractive(); + scheduleDraw(); }, { passive: false }, ); @@ -455,32 +394,10 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { if (!dragStart) return; offsetX = dragStart.offsetX + event.clientX - dragStart.x; offsetY = dragStart.offsetY + event.clientY - dragStart.y; - scheduleInteractive(); + scheduleDraw(); }); 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); viewport.addEventListener("pointercancel", stopDragging); @@ -501,10 +418,6 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { window.cancelAnimationFrame(frameHandle); frameHandle = 0; } - if (settleTimer) { - window.clearTimeout(settleTimer); - settleTimer = 0; - } resizeObserver.disconnect(); }, };