diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_MapRender.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_MapRender.ts index f54ad0c3..7ca62ffe 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_MapRender.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_MapRender.ts @@ -61,7 +61,7 @@ export type MapRect = { height: number; }; -/** 프레임 단위 뷰 상태. */ +/** 프레임 단위 뷰 상태. overscan은 뷰포트 밖까지 미리 그려두는 여백(px) — 컬링 범위를 그만큼 넓힌다. */ export type ViewState = { width: number; height: number; @@ -69,6 +69,7 @@ export type ViewState = { offsetX: number; offsetY: number; mapRect: MapRect; + overscan: number; }; export function createNormalizer(meta: VWorldMeta): Normalizer { @@ -309,15 +310,16 @@ function drawPointParts( const CULL_MARGIN = 32; function isVisible(feature: PreparedFeature, affine: Affine, view: ViewState): boolean { + const margin = CULL_MARGIN + view.overscan; 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 < -CULL_MARGIN || - minX > view.width + CULL_MARGIN || - maxY < -CULL_MARGIN || - minY > view.height + CULL_MARGIN + maxX < -margin || + minX > view.width + margin || + maxY < -margin || + minY > view.height + margin ); } @@ -344,12 +346,13 @@ export function drawPreparedLabels( color: string, ): void { const affine = affineOf(view); + const margin = CULL_MARGIN + view.overscan; 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 < -CULL_MARGIN || x > view.width + CULL_MARGIN) continue; - if (y < -CULL_MARGIN || y > view.height + CULL_MARGIN) continue; + if (x < -margin || x > view.width + margin) continue; + if (y < -margin || y > view.height + margin) continue; context.lineWidth = 3; context.strokeStyle = "rgba(255, 255, 255, 0.9)"; context.strokeText(feature.labelText, x, y); diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts index 5d48cf02..8efdf267 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts @@ -141,12 +141,23 @@ 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회만 처리한다. 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, @@ -283,21 +294,25 @@ 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 * dpr); - canvas.height = Math.floor(height * dpr); - canvas.style.width = `${width}px`; - canvas.style.height = `${height}px`; + 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`; } const context = canvas.getContext("2d"); if (!context) return; - context.setTransform(dpr, 0, 0, dpr, 0, 0); - context.clearRect(0, 0, width, height); + // 뷰포트 좌표계로 그리되 버퍼 원점을 OVERSCAN만큼 밀어 여유분까지 채운다. + context.setTransform(dpr, 0, 0, dpr, OVERSCAN * dpr, OVERSCAN * dpr); + context.clearRect(-OVERSCAN, -OVERSCAN, width + OVERSCAN * 2, height + OVERSCAN * 2); const mapRect = computeMapRect(meta, width, height); - const view: ViewState = { width, height, scale, offsetX, offsetY, mapRect }; + const view: ViewState = { width, height, scale, offsetX, offsetY, mapRect, overscan: OVERSCAN }; DRAW_ORDER.forEach((layer) => { if (!activeGisLayers.has(layer)) return; const prepared = preparedLayers.get(layer); @@ -316,17 +331,64 @@ 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); } - /** 팬/줌 등 연속 이벤트에서는 프레임당 1회만 실제 드로잉이 일어나게 한다. */ - function scheduleDraw(): void { - if (frameHandle) return; - frameHandle = window.requestAnimationFrame(() => { - frameHandle = 0; + /** + * 제스처 중 프레임: 재드로잉 없이 캐시된 비트맵에 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회 수행 (토글·리셋·로드·제스처 종료 등). */ + 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; + drawVectorLayer(); + } else { + applyInteractiveTransform(); + } } async function loadLayers(): Promise { @@ -381,7 +443,7 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { (event) => { event.preventDefault(); scale = Math.min(8, Math.max(0.5, scale * (event.deltaY < 0 ? 1.15 : 0.87))); - scheduleDraw(); + scheduleInteractive(); }, { passive: false }, ); @@ -393,10 +455,13 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { if (!dragStart) return; offsetX = dragStart.offsetX + event.clientX - dragStart.x; offsetY = dragStart.offsetY + event.clientY - dragStart.y; - scheduleDraw(); + scheduleInteractive(); }); const stopDragging = (): void => { + if (!dragStart) return; dragStart = null; + // 팬 종료 즉시 현재 뷰로 풀 렌더 — 가장자리 여유분을 채우고 선명도를 복원한다. + scheduleDraw(); }; viewport.addEventListener("pointerup", stopDragging); viewport.addEventListener("pointercancel", stopDragging); @@ -417,6 +482,10 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { window.cancelAnimationFrame(frameHandle); frameHandle = 0; } + if (settleTimer) { + window.clearTimeout(settleTimer); + settleTimer = 0; + } resizeObserver.disconnect(); }, };