auto: 2026-07-28 18:13 (EOMSANGDON-HOME)

This commit is contained in:
2026-07-28 18:13:07 +09:00
parent 14724f4154
commit b55d10ddd6
2 changed files with 95 additions and 23 deletions
@@ -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);
+85 -16
View File
@@ -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<T extends string>(
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<void> {
@@ -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();
},
};