- 메인 창을 왼쪽으로, 오른쪽 세로 칸에 「횡단」·「이전 횡단」 두 판 (계획서 0-9 ⑱) - 노드를 놓으면 보던 측점을 다시 셈해 새것은 위, 옛것은 아래로 (⑲) - 측점 표기를 구조물 목록과 같은 규칙으로, 긴 안내문 삭제 (⑳㉑) - 좌하단에 예상노선·계획노선 길이 둘 (㉒) - [거리 재기] 단추와 구분선, 잰 값은 작은 창에 내고 닫으면 지움 (㉓㉔) - 잰 구간을 누가거리로 잘라 꼬임 해소 (㉕) - 곡선 패널 닫기 단추와 빈 곳 누르기 해제, 하단 정보는 내각만 (㉖㉗) - 고른 등고선에 높이 라벨, 회전 아이콘을 반원 화살표로 (㉘㉙) - 돌려도 글자는 바로 세움 — `UPRIGHT_LABELS` 한 값으로 되돌릴 수 있음 (㉚) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012jsXWphgRUHAG2mFupSKPX
581 lines
22 KiB
TypeScript
581 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은 변형하지 않는다.
|
||
*/
|
||
export type PreparedPart = {
|
||
coords: Float64Array;
|
||
closed: boolean;
|
||
weights: Float64Array | null;
|
||
};
|
||
|
||
/** 사전 투영된 피처 1개. bbox는 정규화 좌표 기준이며 컬링에 사용한다. */
|
||
export type PreparedFeature = {
|
||
kind: "line" | "point";
|
||
parts: PreparedPart[];
|
||
minX: number;
|
||
minY: number;
|
||
maxX: number;
|
||
maxY: number;
|
||
/** 등고 라벨 앵커(정규화 좌표). 라벨 대상이 아니면 labelText가 null. */
|
||
labelAnchorX: number;
|
||
labelAnchorY: number;
|
||
labelText: string | null;
|
||
/** 그 라벨의 표고(m). 어느 줄을 실제로 낼지는 `drawPreparedLabels` 가 줌을 보고 고른다. */
|
||
labelValue: number | 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,
|
||
};
|
||
}
|
||
|
||
/** 화면 px 에 가장 가까운 선 피처의 자리. 그만큼 안에 없으면 -1(계획서 0-9 ⑦). */
|
||
export function hitPreparedLayer(
|
||
layer: PreparedLayer,
|
||
view: ViewState,
|
||
px: number,
|
||
py: number,
|
||
tolerancePx: number,
|
||
everyM?: number,
|
||
): number {
|
||
const affine = affineOf(view);
|
||
const step = everyM !== undefined && everyM > 0 ? everyM : 0;
|
||
let best = -1;
|
||
let bestDistance = tolerancePx;
|
||
layer.features.forEach((feature, index) => {
|
||
if (feature.kind !== "line") return;
|
||
// **그리지 않은 줄은 집히지도 않는다** — 안 보이는 등고선이 골라지면 없던 선이 튀어나온다.
|
||
if (step && feature.labelValue !== null && feature.labelValue % step !== 0) return;
|
||
// 화면 밖·멀리 있는 피처는 바운딩박스에서 먼저 떨군다 — 도엽 등고선은 수천 가닥이다.
|
||
const x0 = feature.minX * affine.ax + affine.bx - tolerancePx;
|
||
const x1 = feature.maxX * affine.ax + affine.bx + tolerancePx;
|
||
const y0 = feature.minY * affine.ay + affine.by - tolerancePx;
|
||
const y1 = feature.maxY * affine.ay + affine.by + tolerancePx;
|
||
if (px < x0 || px > x1 || py < y0 || py > y1) return;
|
||
for (const part of feature.parts) {
|
||
const coords = part.coords;
|
||
let lastX = NaN;
|
||
let lastY = NaN;
|
||
// 그릴 때와 **같은 LOD** 로 훑는다 — 화면에 없는 정점에 걸리면 눈과 손이 어긋난다.
|
||
const tolerance = LOD_PX / affine.ax;
|
||
for (let i = 0; i < coords.length; i += 2) {
|
||
if (part.weights && part.weights[i / 2] < tolerance) continue;
|
||
const x = coords[i] * affine.ax + affine.bx;
|
||
const y = coords[i + 1] * affine.ay + affine.by;
|
||
if (Number.isFinite(lastX)) {
|
||
const distance = pointSegmentDistance(px, py, lastX, lastY, x, y);
|
||
if (distance < bestDistance) {
|
||
bestDistance = distance;
|
||
best = index;
|
||
}
|
||
}
|
||
lastX = x;
|
||
lastY = y;
|
||
}
|
||
}
|
||
});
|
||
return best;
|
||
}
|
||
|
||
/** 레이어 안의 피처 하나만 다시 그린다 — 고른 등고선을 도드라지게 할 때 쓴다. */
|
||
export function drawPreparedFeature(
|
||
context: CanvasRenderingContext2D,
|
||
layer: PreparedLayer,
|
||
index: number,
|
||
view: ViewState,
|
||
): void {
|
||
const feature = layer.features[index];
|
||
if (!feature || feature.kind !== "line") return;
|
||
drawLineParts(context, feature, affineOf(view));
|
||
}
|
||
|
||
/** 점과 선분 사이 거리(px). */
|
||
function pointSegmentDistance(
|
||
px: number,
|
||
py: number,
|
||
ax: number,
|
||
ay: number,
|
||
bx: number,
|
||
by: number,
|
||
): number {
|
||
const dx = bx - ax;
|
||
const dy = by - ay;
|
||
const lengthSquared = dx * dx + dy * dy;
|
||
if (lengthSquared <= 1e-9) return Math.hypot(px - ax, py - ay);
|
||
const ratio = Math.max(0, Math.min(1, ((px - ax) * dx + (py - ay) * dy) / lengthSquared));
|
||
return Math.hypot(px - (ax + dx * ratio), py - (ay + dy * ratio));
|
||
}
|
||
|
||
/**
|
||
* 프레임당 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;
|
||
/** 등고 라벨끼리 이만큼(px)은 떨어져야 둘 다 낸다 — 가로 여백과 줄 높이. */
|
||
const LABEL_GAP_PX = 10;
|
||
const LABEL_ROW_PX = 14;
|
||
|
||
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
|
||
);
|
||
}
|
||
|
||
/** 레이어가 차지하는 **화면 사각형**(px). 피처가 없으면 null.
|
||
*
|
||
* LAS 등고선처럼 도엽보다 좁은 자료 위에 다른 레이어를 겹칠 때, 그 자료가 있는 데까지만
|
||
* 그리려고 쓴다(계획서 0-9 ⑮ — 세류선이 등고선 밖까지 뻗던 자리). */
|
||
export function layerScreenBounds(
|
||
layer: PreparedLayer,
|
||
view: ViewState,
|
||
): { x: number; y: number; width: number; height: number } | null {
|
||
if (layer.features.length === 0) return null;
|
||
const affine = affineOf(view);
|
||
let minX = Infinity;
|
||
let minY = Infinity;
|
||
let maxX = -Infinity;
|
||
let maxY = -Infinity;
|
||
for (const feature of layer.features) {
|
||
minX = Math.min(minX, feature.minX);
|
||
minY = Math.min(minY, feature.minY);
|
||
maxX = Math.max(maxX, feature.maxX);
|
||
maxY = Math.max(maxY, feature.maxY);
|
||
}
|
||
const x0 = minX * affine.ax + affine.bx;
|
||
const x1 = maxX * affine.ax + affine.bx;
|
||
const y0 = minY * affine.ay + affine.by;
|
||
const y1 = maxY * affine.ay + affine.by;
|
||
return { x: x0, y: y0, width: x1 - x0, height: y1 - y0 };
|
||
}
|
||
|
||
/** 등고선을 몇 m 마다 낼지 고를 때 훑는 배수. 성긴 쪽으로 한 칸씩 물러난다. */
|
||
const LEVEL_STEP_MULTIPLES = [1, 2, 5, 10, 20, 50, 100];
|
||
/** 한 화면에 둘 등고선 가닥 수의 어림 상한 — 이보다 많으면 한 칸 성글게 간다. */
|
||
const LEVEL_BUDGET = 350;
|
||
|
||
/**
|
||
* 지금 화면에 **몇 m 간격**으로 등고선을 낼지 고른다.
|
||
*
|
||
* 간격을 줌으로만 정하면 가파른 데서는 여전히 선이 뭉개지고 완만한 데서는 너무 성기다.
|
||
* 그래서 **지금 화면에 실제로 들어오는 가닥 수**를 세어 상한을 넘지 않는 가장 촘촘한 간격을
|
||
* 고른다 — 확대하면 저절로 촘촘해지고 물러나면 성겨진다(2026-09-12 실화면: 1m LAS 등고선을
|
||
* 다 그리면 지형이 선으로 덮였다).
|
||
*/
|
||
export function pickLevelStep(
|
||
layer: PreparedLayer,
|
||
view: ViewState,
|
||
intervalM: number,
|
||
budget = LEVEL_BUDGET,
|
||
): number {
|
||
const interval = intervalM > 0 ? intervalM : 1;
|
||
const affine = affineOf(view);
|
||
let step = interval * LEVEL_STEP_MULTIPLES[LEVEL_STEP_MULTIPLES.length - 1];
|
||
for (const multiple of LEVEL_STEP_MULTIPLES) {
|
||
const candidate = interval * multiple;
|
||
let count = 0;
|
||
for (const feature of layer.features) {
|
||
if (feature.labelValue !== null && feature.labelValue % candidate !== 0) continue;
|
||
if (!isVisible(feature, affine, view)) continue;
|
||
count += 1;
|
||
if (count > budget) break;
|
||
}
|
||
if (count <= budget) return candidate;
|
||
step = candidate;
|
||
}
|
||
return step;
|
||
}
|
||
|
||
/** 레이어 1개를 그린다. context의 lineWidth/strokeStyle은 호출부에서 설정한다.
|
||
*
|
||
* `everyM` 을 주면 **그 배수의 표고만** 그린다. 1m 간격 LAS 등고선을 멀리서 다 그리면 화면이
|
||
* 선으로 뭉개져 지형이 안 읽힌다 — 확대에 따라 성긴 등고선부터 내보이려는 것이다. 안 주면
|
||
* 전부 그리므로 기존 화면(B04 지도·배수유역도)의 표기는 그대로다. */
|
||
export function drawPreparedLayer(
|
||
context: CanvasRenderingContext2D,
|
||
layer: PreparedLayer,
|
||
view: ViewState,
|
||
marker: MarkerKind,
|
||
everyM?: number,
|
||
): void {
|
||
const affine = affineOf(view);
|
||
const step = everyM !== undefined && everyM > 0 ? everyM : 0;
|
||
for (const feature of layer.features) {
|
||
if (step && feature.labelValue !== null && feature.labelValue % step !== 0) continue;
|
||
if (!isVisible(feature, affine, view)) continue;
|
||
if (feature.kind === "point") drawPointParts(context, feature, affine, marker);
|
||
else drawLineParts(context, feature, affine);
|
||
}
|
||
}
|
||
|
||
/** 사전 계산된 등고 라벨을 그린다. 폰트·정렬은 호출부에서 설정한다.
|
||
*
|
||
* `everyM` 은 **몇 m 마다 한 줄을 라벨할지**다. 기본 25m(계곡선)는 B04 지도가 쓰던 값 그대로다
|
||
* — 전부 내면 화면이 숫자로 뒤덮인다. 확대가 큰 화면은 더 작은 값을 넘겨 촘촘히 낸다. */
|
||
export function drawPreparedLabels(
|
||
context: CanvasRenderingContext2D,
|
||
layer: PreparedLayer,
|
||
view: ViewState,
|
||
color: string,
|
||
everyM = 25,
|
||
/** 돌린 지도에서 **글자만 되돌려 세울** 각(라디안). 0이면 그림과 함께 돈다. */
|
||
uprightRad = 0,
|
||
): void {
|
||
const affine = affineOf(view);
|
||
const margin = CULL_MARGIN;
|
||
const step = everyM > 0 ? everyM : 25;
|
||
// 이미 찍은 라벨과 겹치면 건너뛴다 — LAS 등고선은 **한 표고가 여러 가닥**으로 끊겨 있어
|
||
// 가닥마다 숫자를 내면 화면이 숫자로 덮인다(2026-09-12 실화면). 도엽 계곡선은 원래
|
||
// 드물어 이 규칙에 걸리지 않으므로 B04 지도의 표기는 그대로다.
|
||
const drawn: Array<{ x: number; y: number; half: number }> = [];
|
||
for (const feature of layer.features) {
|
||
if (feature.labelText === null) continue;
|
||
// 표고를 못 읽은 라벨(값 없음)은 솎지 않고 그대로 낸다.
|
||
if (feature.labelValue !== null && feature.labelValue % step !== 0) 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;
|
||
const half = context.measureText(feature.labelText).width / 2 + LABEL_GAP_PX;
|
||
if (
|
||
drawn.some(
|
||
(item) => Math.abs(item.x - x) < item.half + half && Math.abs(item.y - y) < LABEL_ROW_PX,
|
||
)
|
||
) {
|
||
continue;
|
||
}
|
||
drawn.push({ x, y, half });
|
||
context.save();
|
||
if (uprightRad) {
|
||
// 글자 **자리는 그대로** 두고 글자만 되돌린다 — 180°에서 숫자가 뒤집혀 안 읽힌다.
|
||
context.translate(x, y);
|
||
context.rotate(uprightRad);
|
||
context.translate(-x, -y);
|
||
}
|
||
context.lineWidth = 3;
|
||
context.strokeStyle = haloColor();
|
||
context.strokeText(feature.labelText, x, y);
|
||
context.fillStyle = color;
|
||
context.fillText(feature.labelText, x, y);
|
||
context.restore();
|
||
}
|
||
}
|
||
|
||
/** 채움 폴리곤 오버레이(배수유역 등). 좌표는 lon/lat 링 1개. */
|