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