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

This commit is contained in:
2026-07-28 18:07:38 +09:00
parent eb5bdfebad
commit 14724f4154
2 changed files with 443 additions and 207 deletions
@@ -0,0 +1,359 @@
import type { VWorldMeta } from "./B04_wf1_Surface_Api_Fetch";
// 2D 지도 벡터 레이어 렌더 엔진.
// GeoJSON 좌표를 로드 시 1회만 정규화 맵 좌표(0~1)로 사전 투영해 두고,
// 매 프레임에는 뷰포트·scale·offset을 합친 어파인 변환만 적용한다.
// 정규화 좌표라 뷰포트 리사이즈 시에도 재투영이 필요 없다. 원본 GeoJSON은 변형하지 않는다.
export type GeoJsonGeometry = {
type: string;
coordinates: unknown;
};
export type GeoJsonFeature = {
geometry?: GeoJsonGeometry | null;
properties?: Record<string, unknown> | null;
};
export type GeoJsonCollection = {
features?: GeoJsonFeature[];
};
export type MarkerKind = "dot" | "x";
/** 사전 투영된 하나의 파트(선/링/점 묶음). 좌표는 정규화 맵 좌표(0~1) x,y 교차 배열. */
type PreparedPart = {
coords: Float64Array;
closed: boolean;
};
/** 사전 투영된 피처 1개. bbox는 정규화 좌표 기준이며 컬링에 사용한다. */
type PreparedFeature = {
kind: "line" | "point";
parts: PreparedPart[];
minX: number;
minY: number;
maxX: number;
maxY: number;
/** 등고 라벨 앵커(정규화 좌표). 라벨 대상이 아니면 labelText가 null. */
labelAnchorX: number;
labelAnchorY: number;
labelText: string | null;
};
export type PreparedLayer = {
features: PreparedFeature[];
};
/** lon/lat → 정규화 맵 좌표 변환 계수. meta에만 의존한다. */
export type Normalizer = {
lonMin: number;
latMin: number;
lonRange: number;
latRange: number;
};
/** 뷰포트 안에서 지도 이미지가 차지하는 사각형(기존 getMapRect와 동일 계산). */
export type MapRect = {
x: number;
y: number;
width: number;
height: number;
};
/** 프레임 단위 뷰 상태. */
export type ViewState = {
width: number;
height: number;
scale: number;
offsetX: number;
offsetY: number;
mapRect: MapRect;
};
export function createNormalizer(meta: VWorldMeta): Normalizer {
return {
lonMin: meta.lon_min,
latMin: meta.lat_min,
lonRange: meta.lon_max - meta.lon_min || 1,
latRange: meta.lat_max - meta.lat_min || 1,
};
}
export function computeMapRect(meta: VWorldMeta | null, width: number, height: number): MapRect {
if (!meta) return { x: 0, y: 0, width, height };
const mapRatio = meta.width_meters / Math.max(meta.height_meters, 1);
const viewportRatio = width / Math.max(height, 1);
const mapWidth = mapRatio > viewportRatio ? width : height * mapRatio;
const mapHeight = mapRatio > viewportRatio ? width / mapRatio : height;
return {
x: (width - mapWidth) / 2,
y: (height - mapHeight) / 2,
width: mapWidth,
height: mapHeight,
};
}
function isPoint(value: unknown): value is [number, number] {
return Array.isArray(value) && typeof value[0] === "number" && typeof value[1] === "number";
}
/** lon/lat 배열 → 정규화 좌표 Float64Array. 유효 정점이 없으면 null. */
function projectRing(ring: unknown, normalizer: Normalizer): Float64Array | null {
if (!Array.isArray(ring) || ring.length === 0) return null;
const coords = new Float64Array(ring.length * 2);
let count = 0;
for (const point of ring) {
if (!isPoint(point)) continue;
coords[count * 2] = (point[0] - normalizer.lonMin) / normalizer.lonRange;
coords[count * 2 + 1] = 1 - (point[1] - normalizer.latMin) / normalizer.latRange;
count += 1;
}
if (count === 0) return null;
return count * 2 === coords.length ? coords : coords.slice(0, count * 2);
}
function collectParts(
geometry: GeoJsonGeometry,
normalizer: Normalizer,
parts: PreparedPart[],
): "line" | "point" {
const coordinates = geometry.coordinates;
if (!Array.isArray(coordinates)) return "line";
switch (geometry.type) {
case "Point": {
const projected = projectRing([coordinates], normalizer);
if (projected) parts.push({ coords: projected, closed: false });
return "point";
}
case "MultiPoint": {
const projected = projectRing(coordinates, normalizer);
if (projected) parts.push({ coords: projected, closed: false });
return "point";
}
case "LineString": {
const projected = projectRing(coordinates, normalizer);
if (projected) parts.push({ coords: projected, closed: false });
return "line";
}
case "MultiLineString": {
for (const line of coordinates) {
const projected = projectRing(line, normalizer);
if (projected) parts.push({ coords: projected, closed: false });
}
return "line";
}
case "Polygon": {
for (const ring of coordinates) {
const projected = projectRing(ring, normalizer);
if (projected) parts.push({ coords: projected, closed: true });
}
return "line";
}
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 });
}
}
return "line";
}
default:
return "line";
}
}
/** 등고 라벨 앵커: LineString/MultiLineString 첫 파트의 중앙 정점 (기존 동작 유지). */
function labelAnchorOf(geometry: GeoJsonGeometry, normalizer: Normalizer): [number, number] | null {
const coords = geometry.coordinates;
if (!Array.isArray(coords)) return null;
const line =
geometry.type === "LineString"
? coords
: geometry.type === "MultiLineString"
? coords[0]
: null;
if (!Array.isArray(line) || line.length === 0) return null;
const mid = line[Math.floor(line.length / 2)];
if (!isPoint(mid)) return null;
return [
(mid[0] - normalizer.lonMin) / normalizer.lonRange,
1 - (mid[1] - normalizer.latMin) / normalizer.latRange,
];
}
/**
* GeoJSON 컬렉션 1개를 사전 투영한다.
* labelKeys가 주어지면 계곡선(25m 배수) 피처에만 라벨 텍스트·앵커를 계산해 둔다.
*/
export function prepareLayer(
collection: GeoJsonCollection | undefined,
normalizer: Normalizer,
labelKeys?: string[],
): PreparedLayer {
const features: PreparedFeature[] = [];
for (const feature of collection?.features ?? []) {
if (!feature.geometry) continue;
const parts: PreparedPart[] = [];
const kind = collectParts(feature.geometry, normalizer, parts);
if (parts.length === 0) continue;
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
for (const part of parts) {
const coords = part.coords;
for (let i = 0; i < coords.length; i += 2) {
const x = coords[i];
const y = coords[i + 1];
if (x < minX) minX = x;
if (x > maxX) maxX = x;
if (y < minY) minY = y;
if (y > maxY) maxY = y;
}
}
let labelText: string | null = null;
let labelAnchorX = 0;
let labelAnchorY = 0;
if (labelKeys && labelKeys.length > 0) {
const raw = labelKeys.map((key) => feature.properties?.[key]).find((value) => value != null);
const elevation = typeof raw === "number" ? raw : Number(raw);
// 계곡선(25m 배수)만 라벨 — 전체 표기 시 화면이 숫자로 뒤덮이는 것 방지
if (Number.isFinite(elevation) && elevation % 25 === 0) {
const anchor = labelAnchorOf(feature.geometry, normalizer);
if (anchor) {
labelText = String(elevation);
labelAnchorX = anchor[0];
labelAnchorY = anchor[1];
}
}
}
features.push({ kind, parts, minX, minY, maxX, maxY, labelAnchorX, labelAnchorY, labelText });
}
return { features };
}
/**
* 프레임당 1회 계산하는 어파인 계수.
* base = mapRect.x + norm * mapRect.width
* screen = center + (base - center) * scale + offset
* = norm * (mapRect.width * scale) + (mapRect.x * scale + center * (1 - scale) + offset)
*/
type Affine = { ax: number; bx: number; ay: number; by: number };
function affineOf(view: ViewState): Affine {
const centerX = view.width / 2;
const centerY = view.height / 2;
return {
ax: view.mapRect.width * view.scale,
bx: view.mapRect.x * view.scale + centerX * (1 - view.scale) + view.offsetX,
ay: view.mapRect.height * view.scale,
by: view.mapRect.y * view.scale + centerY * (1 - view.scale) + view.offsetY,
};
}
function drawLineParts(
context: CanvasRenderingContext2D,
feature: PreparedFeature,
affine: Affine,
): void {
for (const part of feature.parts) {
const coords = part.coords;
if (coords.length < 2) 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);
}
if (part.closed) context.closePath();
context.stroke();
}
}
function drawPointParts(
context: CanvasRenderingContext2D,
feature: PreparedFeature,
affine: Affine,
marker: MarkerKind,
): void {
for (const part of feature.parts) {
const coords = part.coords;
for (let i = 0; i < coords.length; i += 2) {
const x = coords[i] * affine.ax + affine.bx;
const y = coords[i + 1] * affine.ay + affine.by;
if (marker === "x") {
// 표고점: 조금 굵고 큰 X 마커
const arm = 4;
const prevWidth = context.lineWidth;
context.lineWidth = 2;
context.beginPath();
context.moveTo(x - arm, y - arm);
context.lineTo(x + arm, y + arm);
context.moveTo(x - arm, y + arm);
context.lineTo(x + arm, y - arm);
context.stroke();
context.lineWidth = prevWidth;
continue;
}
context.beginPath();
context.arc(x, y, 2, 0, Math.PI * 2);
context.fillStyle = context.strokeStyle;
context.fill();
}
}
}
/** 컬링 여백: 선 굵기·X 마커 팔 길이·라벨 폭을 감안한 화면 밖 판정 마진(px). */
const CULL_MARGIN = 32;
function isVisible(feature: PreparedFeature, affine: Affine, view: ViewState): boolean {
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
);
}
/** 레이어 1개를 그린다. context의 lineWidth/strokeStyle은 호출부에서 설정한다. */
export function drawPreparedLayer(
context: CanvasRenderingContext2D,
layer: PreparedLayer,
view: ViewState,
marker: MarkerKind,
): void {
const affine = affineOf(view);
for (const feature of layer.features) {
if (!isVisible(feature, affine, view)) continue;
if (feature.kind === "point") drawPointParts(context, feature, affine, marker);
else drawLineParts(context, feature, affine);
}
}
/** 사전 계산된 계곡선 라벨을 그린다. 폰트·정렬은 호출부에서 설정한다. */
export function drawPreparedLabels(
context: CanvasRenderingContext2D,
layer: PreparedLayer,
view: ViewState,
color: string,
): void {
const affine = affineOf(view);
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;
context.lineWidth = 3;
context.strokeStyle = "rgba(255, 255, 255, 0.9)";
context.strokeText(feature.labelText, x, y);
context.fillStyle = color;
context.fillText(feature.labelText, x, y);
}
}
+84 -207
View File
@@ -7,6 +7,17 @@ import {
type VWorldMeta,
} from "./B04_wf1_Surface_Api_Fetch";
import { niceScaleDistance } from "./B04_wf1_Surface_UI_Camera";
import {
computeMapRect,
createNormalizer,
drawPreparedLabels,
drawPreparedLayer,
prepareLayer,
type GeoJsonCollection,
type MapRect,
type PreparedLayer,
type ViewState,
} from "./B04_wf1_Surface_UI_MapRender";
export interface SurfaceMapViewer {
root: HTMLElement;
@@ -14,20 +25,6 @@ export interface SurfaceMapViewer {
dispose: () => void;
}
type GeoJsonGeometry = {
type: string;
coordinates: unknown;
};
type GeoJsonFeature = {
geometry?: GeoJsonGeometry | null;
properties?: Record<string, unknown> | null;
};
type GeoJsonCollection = {
features?: GeoJsonFeature[];
};
const BACKGROUND_LAYERS = ["white", "satellite", "hybrid"] as const;
const GIS_LAYERS = [
"지적도",
@@ -133,7 +130,8 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
let currentProjectId: string | null = null;
let referenceBounds: SurfaceBounds | null = null;
let meta: VWorldMeta | null = null;
const geoJsonLayers = new Map<GisLayer, GeoJsonCollection>();
// 사전 투영된 렌더용 레이어. 원본 GeoJSON은 변형하지 않으며 투영 후에는 참조를 잡아두지 않는다.
const preparedLayers = new Map<GisLayer, PreparedLayer>();
const activeBackgrounds = new Set<BackgroundLayer>(BACKGROUND_LAYERS);
// gpkg 등고선은 기본 꺼짐(도엽 등고선이 기본 표기), 등고 라벨은 기본 켜짐 (2026-07-26 사용자 지시)
const activeGisLayers = new Set<GisLayer>(GIS_LAYERS.filter((layer) => layer !== "등고선"));
@@ -143,6 +141,12 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
let offsetY = 0;
let dragStart: { x: number; y: number; offsetX: number; offsetY: number } | null = null;
let loadSequence = 0;
// rAF 스로틀: 팬/줌 이벤트는 상태만 갱신하고 프레임당 1회만 드로잉한다.
let frameHandle = 0;
// 캔버스 버퍼는 크기가 실제로 변할 때만 재할당한다(재할당 시 내용이 지워지므로 매 프레임 금지).
let canvasWidth = 0;
let canvasHeight = 0;
let canvasDpr = 0;
function makeLayerButton<T extends string>(
label: string,
@@ -208,7 +212,7 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
showContourLabels = !showContourLabels;
contourLabelButton.classList.toggle("is-active", showContourLabels);
contourLabelButton.setAttribute("aria-pressed", String(showContourLabels));
drawVectorLayer();
scheduleDraw();
});
gisButtons.append(contourLabelButton);
@@ -223,7 +227,7 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
image.hidden = !activeBackgrounds.has(layer);
});
empty.hidden = activeBackgrounds.size > 0 || activeGisLayers.size > 0;
drawVectorLayer();
scheduleDraw();
}
function fitReferenceBounds(): void {
@@ -231,7 +235,7 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
const rect = viewport.getBoundingClientRect();
const width = Math.max(rect.width, 1);
const height = Math.max(rect.height, 1);
const mapRect = getMapRect(width, height);
const mapRect = computeMapRect(meta, width, height);
const referenceWidth = Math.max(referenceBounds.x_max - referenceBounds.x_min, 1);
const referenceHeight = Math.max(referenceBounds.y_max - referenceBounds.y_min, 1);
scale =
@@ -250,176 +254,15 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
offsetY = 0;
fitReferenceBounds();
updateImageTransform();
drawVectorLayer();
scheduleDraw();
}
function getMapRect(width: number, height: number): DOMRect {
if (!meta) return new DOMRect(0, 0, width, height);
const mapRatio = meta.width_meters / Math.max(meta.height_meters, 1);
const viewportRatio = width / Math.max(height, 1);
const mapWidth = mapRatio > viewportRatio ? width : height * mapRatio;
const mapHeight = mapRatio > viewportRatio ? width / mapRatio : height;
return new DOMRect((width - mapWidth) / 2, (height - mapHeight) / 2, mapWidth, mapHeight);
}
function toCanvasPoint(
lon: number,
lat: number,
width: number,
height: number,
): [number, number] {
if (!meta) return [0, 0];
const mapRect = getMapRect(width, height);
const lonRange = meta.lon_max - meta.lon_min || 1;
const latRange = meta.lat_max - meta.lat_min || 1;
const baseX = mapRect.x + ((lon - meta.lon_min) / lonRange) * mapRect.width;
const baseY = mapRect.y + mapRect.height * (1 - (lat - meta.lat_min) / latRange);
const centerX = width / 2;
const centerY = height / 2;
return [
centerX + (baseX - centerX) * scale + offsetX,
centerY + (baseY - centerY) * scale + offsetY,
];
}
function drawRing(
context: CanvasRenderingContext2D,
ring: unknown,
width: number,
height: number,
closed: boolean,
): void {
if (!Array.isArray(ring) || ring.length === 0) return;
const points = ring.filter(
(point): point is [number, number] =>
Array.isArray(point) && typeof point[0] === "number" && typeof point[1] === "number",
);
if (points.length === 0) return;
context.beginPath();
points.forEach(([lon, lat], index) => {
const [x, y] = toCanvasPoint(lon, lat, width, height);
if (index === 0) context.moveTo(x, y);
else context.lineTo(x, y);
});
if (closed) context.closePath();
context.stroke();
}
function drawPoint(
context: CanvasRenderingContext2D,
coordinates: unknown,
width: number,
height: number,
marker: "dot" | "x" = "dot",
): void {
if (
!Array.isArray(coordinates) ||
typeof coordinates[0] !== "number" ||
typeof coordinates[1] !== "number"
) {
return;
}
const [x, y] = toCanvasPoint(coordinates[0], coordinates[1], width, height);
if (marker === "x") {
// 표고점: 조금 굵고 큰 X 마커
const arm = 4;
const prevWidth = context.lineWidth;
context.lineWidth = 2;
context.beginPath();
context.moveTo(x - arm, y - arm);
context.lineTo(x + arm, y + arm);
context.moveTo(x - arm, y + arm);
context.lineTo(x + arm, y - arm);
context.stroke();
context.lineWidth = prevWidth;
return;
}
context.beginPath();
context.arc(x, y, 2, 0, Math.PI * 2);
context.fillStyle = context.strokeStyle;
context.fill();
}
function contourLabelAnchor(geometry: GeoJsonGeometry): [number, number] | null {
const coords = geometry.coordinates;
if (!Array.isArray(coords)) return null;
const line =
geometry.type === "LineString"
? coords
: geometry.type === "MultiLineString"
? coords[0]
: null;
if (!Array.isArray(line) || line.length === 0) return null;
const mid = line[Math.floor(line.length / 2)];
if (!Array.isArray(mid) || typeof mid[0] !== "number" || typeof mid[1] !== "number") {
return null;
}
return [mid[0], mid[1]];
}
function drawContourLabels(
context: CanvasRenderingContext2D,
width: number,
height: number,
): void {
context.font = "600 13px sans-serif";
context.textAlign = "center";
context.textBaseline = "middle";
(Object.keys(CONTOUR_LABEL_KEYS) as GisLayer[]).forEach((layer) => {
if (!activeGisLayers.has(layer)) return;
const keys = CONTOUR_LABEL_KEYS[layer] ?? [];
geoJsonLayers.get(layer)?.features?.forEach((feature) => {
if (!feature.geometry) return;
const raw = keys.map((key) => feature.properties?.[key]).find((value) => value != null);
const elevation = typeof raw === "number" ? raw : Number(raw);
// 계곡선(25m 배수)만 라벨 — 전체 표기 시 화면이 숫자로 뒤덮이는 것 방지
if (!Number.isFinite(elevation) || elevation % 25 !== 0) return;
const anchor = contourLabelAnchor(feature.geometry);
if (!anchor) return;
const [x, y] = toCanvasPoint(anchor[0], anchor[1], width, height);
context.lineWidth = 3;
context.strokeStyle = "rgba(255, 255, 255, 0.9)";
context.strokeText(String(elevation), x, y);
context.fillStyle = GIS_LAYER_COLORS[layer];
context.fillText(String(elevation), x, y);
});
});
}
function drawGeometry(
context: CanvasRenderingContext2D,
geometry: GeoJsonGeometry,
width: number,
height: number,
marker: "dot" | "x" = "dot",
): void {
const coordinates = geometry.coordinates;
if (!Array.isArray(coordinates)) return;
if (geometry.type === "Point") {
drawPoint(context, coordinates, width, height, marker);
} else if (geometry.type === "MultiPoint") {
coordinates.forEach((point) => drawPoint(context, point, width, height, marker));
} else if (geometry.type === "LineString") {
drawRing(context, coordinates, width, height, false);
} else if (geometry.type === "MultiLineString") {
coordinates.forEach((line) => drawRing(context, line, width, height, false));
} else if (geometry.type === "Polygon") {
coordinates.forEach((ring) => drawRing(context, ring, width, height, true));
} else if (geometry.type === "MultiPolygon") {
coordinates.forEach((polygon) => {
if (Array.isArray(polygon)) {
polygon.forEach((ring) => drawRing(context, ring, width, height, true));
}
});
}
}
function drawScaleBar(width: number, height: number): void {
if (!meta || width <= 0) {
function drawScaleBar(mapRect: MapRect): void {
if (!meta || mapRect.width <= 0) {
scaleBar.hidden = true;
return;
}
const metersPerPixel = meta.width_meters / getMapRect(width, height).width / scale;
const metersPerPixel = meta.width_meters / mapRect.width / scale;
const meters = niceScaleDistance(100 * metersPerPixel);
const pixels = meters / metersPerPixel;
scaleBar.hidden = false;
@@ -427,34 +270,63 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
scaleText.textContent = meters >= 1000 ? `${meters / 1000} km` : `${meters} m`;
}
// 등고선(전국 gpkg·도엽)은 선 수가 많아 가장 아래에 얇게 깔아 다른 레이어 판독을 방해하지 않게 한다.
const isContourLayer = (layer: GisLayer): boolean =>
layer === "등고선" || layer === "도엽_등고선";
const DRAW_ORDER = [...GIS_LAYERS].sort((a, b) =>
isContourLayer(a) ? -1 : isContourLayer(b) ? 1 : 0,
);
function drawVectorLayer(): void {
const rect = viewport.getBoundingClientRect();
const width = Math.max(1, Math.floor(rect.width));
const height = Math.max(1, Math.floor(rect.height));
const dpr = window.devicePixelRatio || 1;
canvas.width = Math.floor(width * dpr);
canvas.height = Math.floor(height * dpr);
canvas.style.width = `${width}px`;
canvas.style.height = `${height}px`;
// 버퍼 재할당은 캔버스 내용을 지우므로 크기가 실제로 변할 때만 수행한다.
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`;
}
const context = canvas.getContext("2d");
if (!context) return;
context.setTransform(dpr, 0, 0, dpr, 0, 0);
context.clearRect(0, 0, width, height);
// 등고선(전국 gpkg·도엽)은 선 수가 많아 가장 아래에 얇게 깔아 다른 레이어 판독을 방해하지 않게 한다.
const isContour = (layer: GisLayer): boolean => layer === "등고선" || layer === "도엽_등고선";
const drawOrder = [...GIS_LAYERS].sort((a, b) => (isContour(a) ? -1 : isContour(b) ? 1 : 0));
drawOrder.forEach((layer) => {
const mapRect = computeMapRect(meta, width, height);
const view: ViewState = { width, height, scale, offsetX, offsetY, mapRect };
DRAW_ORDER.forEach((layer) => {
if (!activeGisLayers.has(layer)) return;
context.lineWidth = isContour(layer) ? 0.7 : 1.5;
const prepared = preparedLayers.get(layer);
if (!prepared) return;
context.lineWidth = isContourLayer(layer) ? 0.7 : 1.5;
context.strokeStyle = GIS_LAYER_COLORS[layer];
const marker = layer === "도엽_표고점" ? "x" : "dot";
geoJsonLayers.get(layer)?.features?.forEach((feature) => {
if (feature.geometry) drawGeometry(context, feature.geometry, width, height, marker);
});
drawPreparedLayer(context, prepared, view, layer === "도엽_표고점" ? "x" : "dot");
});
if (showContourLabels) drawContourLabels(context, width, height);
if (showContourLabels) {
context.font = "600 13px sans-serif";
context.textAlign = "center";
context.textBaseline = "middle";
(Object.keys(CONTOUR_LABEL_KEYS) as GisLayer[]).forEach((layer) => {
if (!activeGisLayers.has(layer)) return;
const prepared = preparedLayers.get(layer);
if (prepared) drawPreparedLabels(context, prepared, view, GIS_LAYER_COLORS[layer]);
});
}
updateImageTransform();
drawScaleBar(width, height);
drawScaleBar(mapRect);
}
/** 팬/줌 등 연속 이벤트에서는 프레임당 1회만 실제 드로잉이 일어나게 한다. */
function scheduleDraw(): void {
if (frameHandle) return;
frameHandle = window.requestAnimationFrame(() => {
frameHandle = 0;
drawVectorLayer();
});
}
async function loadLayers(): Promise<void> {
@@ -463,7 +335,7 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
const sequence = ++loadSequence;
backgroundImages.forEach((image) => image.removeAttribute("src"));
meta = null;
geoJsonLayers.clear();
preparedLayers.clear();
resetView();
status.textContent = L("B04_Surface_Map_Loading");
try {
@@ -480,16 +352,17 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
);
if (sequence !== loadSequence) return;
meta = nextMeta;
// 좌표 변환은 여기서 1회만 수행하고, 이후 프레임은 사전 투영 결과만 사용한다.
const normalizer = createNormalizer(nextMeta);
let featureCount = 0;
loadedLayers.forEach(([layer, data]) => {
if (data) geoJsonLayers.set(layer, data);
if (!data) return;
featureCount += data.features?.length ?? 0;
preparedLayers.set(layer, prepareLayer(data, normalizer, CONTOUR_LABEL_KEYS[layer]));
});
BACKGROUND_LAYERS.forEach((layer) => {
backgroundImages.get(layer)!.src = `${getVWorldMapUrl(projectId, layer)}&_t=${Date.now()}`;
});
const featureCount = [...geoJsonLayers.values()].reduce(
(sum, collection) => sum + (collection.features?.length ?? 0),
0,
);
status.textContent = L("B04_Surface_Map_Features").replace(
"{count}",
featureCount.toLocaleString(),
@@ -508,7 +381,7 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
(event) => {
event.preventDefault();
scale = Math.min(8, Math.max(0.5, scale * (event.deltaY < 0 ? 1.15 : 0.87)));
drawVectorLayer();
scheduleDraw();
},
{ passive: false },
);
@@ -520,7 +393,7 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
if (!dragStart) return;
offsetX = dragStart.offsetX + event.clientX - dragStart.x;
offsetY = dragStart.offsetY + event.clientY - dragStart.y;
drawVectorLayer();
scheduleDraw();
});
const stopDragging = (): void => {
dragStart = null;
@@ -528,7 +401,7 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
viewport.addEventListener("pointerup", stopDragging);
viewport.addEventListener("pointercancel", stopDragging);
const resizeObserver = new ResizeObserver(drawVectorLayer);
const resizeObserver = new ResizeObserver(scheduleDraw);
resizeObserver.observe(viewport);
return {
@@ -540,6 +413,10 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
},
dispose() {
loadSequence += 1;
if (frameHandle) {
window.cancelAnimationFrame(frameHandle);
frameHandle = 0;
}
resizeObserver.disconnect();
},
};