- 폴더·내부 파일 51개 접두사 개명 (git mv, 이력 보존) - 저장소 전체 참조 치환 67파일: import 경로, 라우트 슬러그(b04-preprocess), 라우트 키(B04_PREPROCESS), storage 경로 상수, locale, SQL 주석 - 로직 변경 없음 (기계적 치환). typecheck·백엔드 import 검증 통과 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
567 lines
19 KiB
TypeScript
567 lines
19 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;
|
|
|
|
/** 평면 좌표(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개. */
|