diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_MapRender.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_MapRender.ts new file mode 100644 index 00000000..f54ad0c3 --- /dev/null +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_MapRender.ts @@ -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 | 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); + } +} diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts index a5dac98e..5d48cf02 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts @@ -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 | 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(); + // 사전 투영된 렌더용 레이어. 원본 GeoJSON은 변형하지 않으며 투영 후에는 참조를 잡아두지 않는다. + const preparedLayers = new Map(); const activeBackgrounds = new Set(BACKGROUND_LAYERS); // gpkg 등고선은 기본 꺼짐(도엽 등고선이 기본 표기), 등고 라벨은 기본 켜짐 (2026-07-26 사용자 지시) const activeGisLayers = new Set(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( 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 { @@ -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(); }, };