auto: 2026-07-28 18:25 (EOMSANGDON-HOME)
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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<T extends string>(
|
||||
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<void> {
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user