- 계획노선 사용 범위: B02 등록에 시작·종료 누가거리 두 칸 추가, B01 수정 모달에서도 변경. projects.route_start_m·route_end_m 신설(015_route_range.sql). load_design_route 가 범위 절단 → 서피스 트림 순서로 적용. 시작 >= 종료는 화면·서버 양쪽에서 차단. 비우면 전 구간으로 종전과 같음. - 서피스 절단 여유 기본값 30m → 3m (SURFACE_ROUTE_EDGE_TRIM_M). - B04 지도·B05 배수유역도 줌 상한을 「화면 폭 20m」 기준으로 계산(고정 8배·16배 폐지). 4배를 넘으면 배경 그림 흐림 보간 해제. - 계획선 위 측점 눈금·번호 표기(측점번호+잔여거리). 관 마커와 겹치면 반대쪽으로 밀고, 되꺾임 구간에서 라벨이 겹치면 건너뜀. 그리기 코드는 두 화면 공용. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
620 lines
22 KiB
TypeScript
620 lines
22 KiB
TypeScript
import { themeColor } from "@ui/ui_template_palette";
|
||
import type { VWorldMeta } from "./B04_PreProcess_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";
|
||
|
||
/** 상류 세류망 강조 색 — 유역 판정의 기준선이라 가장 굵고 진하게 둔다. */
|
||
|
||
/** 배경 위에서 선·글자가 묻히지 않게 뒤에 까는 흰 테두리. */
|
||
export const haloColor = (): string => themeColor("--map-halo", "rgba(255, 255, 255, 0.9)");
|
||
|
||
/**
|
||
* 계획선(노선) 표기 색 — B04 2D 지도와 B05 배수유역도가 **같은 값**을 쓴다.
|
||
* 두 화면에서 같은 선을 다른 색으로 그리면 같은 것인지 알아볼 수 없다.
|
||
* 색 값 자체는 `ui_template_theme.css`의 `--map-route`가 유일한 정의처다.
|
||
*/
|
||
export const routeLineColor = (): string => themeColor("--map-route", "#f97316");
|
||
/** 계획선 굵기(px) — 다른 레이어보다 굵게 둬야 배경 위에서 바로 눈에 띈다. */
|
||
export const ROUTE_LINE_WIDTH = 2.4;
|
||
|
||
/**
|
||
* 사전 투영된 하나의 파트(선/링/점 묶음). 좌표는 정규화 맵 좌표(0~1) x,y 교차 배열.
|
||
* weights: Douglas-Peucker 가중치(정점 제거 시 발생하는 최대 오차, 종횡비 보정 좌표계).
|
||
* 렌더 시 "화면 오차 < LOD_PX가 되는 정점"만 제외해 어느 줌에서도 시각적 무손실 LOD를 얻는다.
|
||
* line 파트에만 존재하며 원본 GeoJSON은 변형하지 않는다.
|
||
*/
|
||
type PreparedPart = {
|
||
coords: Float64Array;
|
||
closed: boolean;
|
||
weights: Float64Array | null;
|
||
};
|
||
|
||
/** 사전 투영된 피처 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에만 의존한다. aspect는 지도 종횡비(w/h). */
|
||
export type Normalizer = {
|
||
lonMin: number;
|
||
latMin: number;
|
||
lonRange: number;
|
||
latRange: number;
|
||
aspect: 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,
|
||
aspect: meta.width_meters / Math.max(meta.height_meters, 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,
|
||
};
|
||
}
|
||
|
||
/** 계획도로 주변으로 보여줄 여유 거리(m). B04 하단 지도와 B05 배수유역도가 같은 값을 쓴다
|
||
* (2026-08-01 사용자 지시: 도로 중심을 화면 중앙에, 도로 전체 + 200m까지). */
|
||
export const ROUTE_VIEW_MARGIN_M = 200;
|
||
|
||
/**
|
||
* 사업지 미터 좌표를 화면 좌표로 옮기는 변환기 (B04 지도·B05 배수유역도 공용).
|
||
*
|
||
* 배수유역도에서 쓰던 것을 여기로 옮겼다 — 두 화면이 같은 자리에 측점 눈금을 찍어야 한다
|
||
* (2026-09-04). `pxPerMeter` 는 라벨 솎기·축척 계산에 쓴다.
|
||
*/
|
||
export function createMetricProjector(
|
||
meta: VWorldMeta,
|
||
view: ViewState,
|
||
): { toScreen: (x: number, y: number) => [number, number]; pxPerMeter: number } {
|
||
const spanX = meta.width_meters || 1;
|
||
const spanY = meta.height_meters || 1;
|
||
const ax = view.mapRect.width * view.scale;
|
||
const bx = view.mapRect.x * view.scale + (view.width / 2) * (1 - view.scale) + view.offsetX;
|
||
const ay = view.mapRect.height * view.scale;
|
||
const by = view.mapRect.y * view.scale + (view.height / 2) * (1 - view.scale) + view.offsetY;
|
||
return {
|
||
toScreen: (x, y) => [
|
||
((x - meta.x_min) / spanX) * ax + bx,
|
||
(1 - (y - meta.y_min) / spanY) * ay + by,
|
||
],
|
||
pxPerMeter: ax / spanX,
|
||
};
|
||
}
|
||
|
||
/** 규칙 측점 간격(m) — 종단 패널과 같은 20m 고정. 지도·배수유역도 눈금 표기 기준(2026-09-04). */
|
||
export const MAP_STATION_INTERVAL_M = 20;
|
||
|
||
/** 최대 확대에서 화면 폭에 들어올 실거리(m) — 규칙 측점 20m 기준 1~2측점
|
||
* (2026-09-04 사용자 지시). 고정 배율(8배·16배)로는 도엽 크기마다 체감이 달라진다. */
|
||
export const MAX_ZOOM_VIEW_WIDTH_M = 20;
|
||
|
||
/** 배율 상한의 안전장치 — 도엽 메타가 이상해도 여기서 멈춘다. */
|
||
export const ZOOM_SCALE_HARD_CAP = 2000;
|
||
|
||
/**
|
||
* 「화면 폭이 `MAX_ZOOM_VIEW_WIDTH_M` 가 될 때까지」에 해당하는 배율 상한을 구한다.
|
||
*
|
||
* 배율 1에서 도엽 실폭(`meta.width_meters`)이 지도 사각형 폭(px)을 채우므로,
|
||
* 화면 폭(px)에 들어오는 실거리 = width_meters × viewportWidth / (mapRect.width × scale) 이다.
|
||
* 이것을 20m 로 놓고 scale 을 푼다. 메타가 없으면 종전 고정값으로 되돌아간다.
|
||
*/
|
||
export function computeMaxScale(
|
||
meta: VWorldMeta | null,
|
||
mapRectWidth: number,
|
||
viewportWidth: number,
|
||
fallback: number,
|
||
): number {
|
||
if (!meta || mapRectWidth <= 0 || viewportWidth <= 0) return fallback;
|
||
const scale = (meta.width_meters * viewportWidth) / (mapRectWidth * MAX_ZOOM_VIEW_WIDTH_M);
|
||
return Math.min(ZOOM_SCALE_HARD_CAP, Math.max(fallback, scale));
|
||
}
|
||
|
||
/** 평면 좌표(m) 범위. */
|
||
export interface PlanBounds {
|
||
x_min: number;
|
||
x_max: number;
|
||
y_min: number;
|
||
y_max: number;
|
||
}
|
||
|
||
/**
|
||
* 도로 전체 + 여유 거리가 화면에 들어오도록 배율·이동량을 구한다(도로 중심이 화면 중앙).
|
||
*
|
||
* 지도 초기 화면의 유일한 정의처 — B04 하단 지도와 B05 배수유역도가 함께 쓴다.
|
||
* 배경 지도보다 넓은 범위를 요구하면 배경 크기에 맞춰 멈춘다(빈 여백을 만들지 않는다).
|
||
*/
|
||
export function computeRouteView(
|
||
meta: VWorldMeta | null,
|
||
route: PlanBounds | null,
|
||
viewportWidth: number,
|
||
viewportHeight: number,
|
||
marginM: number = ROUTE_VIEW_MARGIN_M,
|
||
): { scale: number; offsetX: number; offsetY: number } {
|
||
if (!meta || !route) return { scale: 1, offsetX: 0, offsetY: 0 };
|
||
const mapRect = computeMapRect(meta, viewportWidth, viewportHeight);
|
||
const wantWidth = Math.max(route.x_max - route.x_min, 1) + marginM * 2;
|
||
const wantHeight = Math.max(route.y_max - route.y_min, 1) + marginM * 2;
|
||
const scale = Math.max(
|
||
Math.min(meta.width_meters / wantWidth, meta.height_meters / wantHeight),
|
||
1,
|
||
);
|
||
const centerX = (route.x_min + route.x_max) / 2;
|
||
const centerY = (route.y_min + route.y_max) / 2;
|
||
const baseX = mapRect.x + ((centerX - meta.x_min) / meta.width_meters) * mapRect.width;
|
||
const baseY = mapRect.y + (1 - (centerY - meta.y_min) / meta.height_meters) * mapRect.height;
|
||
return {
|
||
scale,
|
||
offsetX: -(baseX - viewportWidth / 2) * scale,
|
||
offsetY: -(baseY - viewportHeight / 2) * scale,
|
||
};
|
||
}
|
||
|
||
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";
|
||
const push = (ring: unknown, closed: boolean): void => {
|
||
const projected = projectRing(ring, normalizer);
|
||
if (projected) parts.push({ coords: projected, closed, weights: null });
|
||
};
|
||
switch (geometry.type) {
|
||
case "Point":
|
||
push([coordinates], false);
|
||
return "point";
|
||
case "MultiPoint":
|
||
push(coordinates, false);
|
||
return "point";
|
||
case "LineString":
|
||
push(coordinates, false);
|
||
return "line";
|
||
case "MultiLineString":
|
||
for (const line of coordinates) push(line, false);
|
||
return "line";
|
||
case "Polygon":
|
||
for (const ring of coordinates) push(ring, true);
|
||
return "line";
|
||
case "MultiPolygon":
|
||
for (const polygon of coordinates) {
|
||
if (!Array.isArray(polygon)) continue;
|
||
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;
|
||
}
|
||
|
||
/** 등고 라벨 앵커: 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;
|
||
if (kind === "line") {
|
||
for (const part of parts) {
|
||
if (part.coords.length < 6) continue;
|
||
part.weights = computeDpWeights(part.coords, normalizer.aspect);
|
||
}
|
||
}
|
||
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 };
|
||
}
|
||
|
||
/**
|
||
* 사업지 좌표계(m) 폴리라인을 한 개 피처짜리 레이어로 사전 투영한다.
|
||
* meta의 x/y 범위와 lon/lat 범위는 같은 사각형을 가리키므로, 미터 좌표도 GeoJSON과 동일한
|
||
* 정규화 공간으로 들어간다 — 노선 선형을 도엽 레이어 위에 그대로 겹칠 수 있다.
|
||
*/
|
||
export function prepareMetricPolyline(
|
||
points: ReadonlyArray<{ x: number; y: number }>,
|
||
meta: VWorldMeta,
|
||
): PreparedLayer {
|
||
if (points.length < 2) return { features: [] };
|
||
const widthMeters = meta.width_meters || 1;
|
||
const heightMeters = meta.height_meters || 1;
|
||
const coords = new Float64Array(points.length * 2);
|
||
let minX = Infinity;
|
||
let minY = Infinity;
|
||
let maxX = -Infinity;
|
||
let maxY = -Infinity;
|
||
points.forEach((point, index) => {
|
||
const nx = (point.x - meta.x_min) / widthMeters;
|
||
const ny = 1 - (point.y - meta.y_min) / heightMeters;
|
||
coords[index * 2] = nx;
|
||
coords[index * 2 + 1] = ny;
|
||
if (nx < minX) minX = nx;
|
||
if (nx > maxX) maxX = nx;
|
||
if (ny < minY) minY = ny;
|
||
if (ny > maxY) maxY = ny;
|
||
});
|
||
return {
|
||
features: [
|
||
{
|
||
kind: "line",
|
||
parts: [{ coords, closed: false, weights: null }],
|
||
minX,
|
||
minY,
|
||
maxX,
|
||
maxY,
|
||
labelAnchorX: 0,
|
||
labelAnchorY: 0,
|
||
labelText: null,
|
||
},
|
||
],
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 프레임당 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,
|
||
};
|
||
}
|
||
|
||
/** 정규화 좌표(0~1) → 화면 px. 프레임마다 여러 번 부를 것이라면 `affineOf`를 한 번 잡아 쓴다. */
|
||
export function normalizedToScreen(
|
||
view: ViewState,
|
||
nx: number,
|
||
ny: number,
|
||
): [x: number, y: number] {
|
||
const affine = affineOf(view);
|
||
return [nx * affine.ax + affine.bx, ny * affine.ay + affine.by];
|
||
}
|
||
|
||
/** lon/lat → 화면 px. 마커를 찍거나 외곽선을 그릴 때 쓴다. */
|
||
export function lonLatToScreen(
|
||
normalizer: Normalizer,
|
||
view: ViewState,
|
||
lon: number,
|
||
lat: number,
|
||
): [x: number, y: number] {
|
||
return normalizedToScreen(
|
||
view,
|
||
(lon - normalizer.lonMin) / normalizer.lonRange,
|
||
1 - (lat - normalizer.latMin) / normalizer.latRange,
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 사업지 좌표계(m) → 화면 px. `prepareMetricPolyline`과 **같은 가정**을 쓴다 —
|
||
* meta의 x/y 범위와 lon/lat 범위가 같은 사각형을 가리킨다는 것.
|
||
*/
|
||
export function metricToScreen(
|
||
meta: VWorldMeta,
|
||
view: ViewState,
|
||
x: number,
|
||
y: number,
|
||
): [x: number, y: number] {
|
||
const spanX = meta.width_meters || 1;
|
||
const spanY = meta.height_meters || 1;
|
||
return normalizedToScreen(view, (x - meta.x_min) / spanX, 1 - (y - meta.y_min) / spanY);
|
||
}
|
||
|
||
/** 시각적 무손실 LOD 허용 오차(화면 px). 이보다 작은 오차의 정점만 생략된다. */
|
||
const LOD_PX = 0.75;
|
||
|
||
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 < 4) continue;
|
||
const weights = part.weights;
|
||
// 현재 줌에서 화면 오차 LOD_PX 미만인 정점만 생략 (끝점은 weight=∞라 항상 유지).
|
||
// 정점 사이 보간은 하지 않는다 — 원본 데이터의 형상 그대로 표시 (2026-07-28 사용자 지시).
|
||
context.beginPath();
|
||
let started = false;
|
||
for (let i = 0; i < coords.length; i += 2) {
|
||
if (weights && weights[i / 2] < tolerance) continue;
|
||
const x = coords[i] * affine.ax + affine.bx;
|
||
const y = coords[i + 1] * affine.ay + affine.by;
|
||
if (started) context.lineTo(x, y);
|
||
else {
|
||
context.moveTo(x, y);
|
||
started = true;
|
||
}
|
||
}
|
||
if (!started) continue;
|
||
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;
|
||
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;
|
||
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 = haloColor();
|
||
context.strokeText(feature.labelText, x, y);
|
||
context.fillStyle = color;
|
||
context.fillText(feature.labelText, x, y);
|
||
}
|
||
}
|
||
|
||
/** 채움 폴리곤 오버레이(배수유역 등). 좌표는 lon/lat 링 1개. */
|