363 lines
12 KiB
TypeScript
363 lines
12 KiB
TypeScript
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;
|
|
};
|
|
|
|
/** 프레임 단위 뷰 상태. overscan은 뷰포트 밖까지 미리 그려두는 여백(px) — 컬링 범위를 그만큼 넓힌다. */
|
|
export type ViewState = {
|
|
width: number;
|
|
height: number;
|
|
scale: number;
|
|
offsetX: number;
|
|
offsetY: number;
|
|
mapRect: MapRect;
|
|
overscan: number;
|
|
};
|
|
|
|
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 margin = CULL_MARGIN + view.overscan;
|
|
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 < -margin ||
|
|
minX > view.width + margin ||
|
|
maxY < -margin ||
|
|
minY > view.height + 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);
|
|
const margin = CULL_MARGIN + view.overscan;
|
|
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 < -margin || x > view.width + margin) continue;
|
|
if (y < -margin || y > view.height + 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);
|
|
}
|
|
}
|