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