feat(B05): 계획노선 편집 모달 바탕을 LAS 등고선으로 바꾸고 높이 라벨·선택 추가

- 확정 지표면이 있으면 LAS 등고선을 바탕으로 씀, 없으면 도엽 등고선 유지 (계획서 0-9 ⑥)
- 등고선 가닥마다 높이값 라벨, 겹치면 건너뜀 (③)
- 등고선을 누르면 그 가닥만 도드라지고 상태줄에 높이 표기 (⑦)
- 화면에 드는 가닥 수로 등고선 간격을 고름 — 1m 자료가 선으로 뭉개지던 것 해소
- `B04_PreProcess_UI_MapRender.ts` 700줄 초과로 사전 투영을 `_Prepare.ts` 로 분리

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012jsXWphgRUHAG2mFupSKPX
This commit is contained in:
2026-09-12 13:49:37 +09:00
co-authored by Claude Opus 5
parent 18c276c6af
commit 082211462f
8 changed files with 661 additions and 234 deletions
+141 -217
View File
@@ -42,14 +42,14 @@ export const ROUTE_LINE_WIDTH = 2.4;
* 렌더 시 "화면 오차 < LOD_PX가 되는 정점"만 제외해 어느 줌에서도 시각적 무손실 LOD를 얻는다.
* line 파트에만 존재하며 원본 GeoJSON은 변형하지 않는다.
*/
type PreparedPart = {
export type PreparedPart = {
coords: Float64Array;
closed: boolean;
weights: Float64Array | null;
};
/** 사전 투영된 피처 1개. bbox는 정규화 좌표 기준이며 컬링에 사용한다. */
type PreparedFeature = {
export type PreparedFeature = {
kind: "line" | "point";
parts: PreparedPart[];
minX: number;
@@ -60,6 +60,8 @@ type PreparedFeature = {
labelAnchorX: number;
labelAnchorY: number;
labelText: string | null;
/** 그 라벨의 표고(m). 어느 줄을 실제로 낼지는 `drawPreparedLabels` 가 줌을 보고 고른다. */
labelValue: number | null;
};
export type PreparedLayer = {
@@ -214,226 +216,81 @@ export function computeRouteView(
};
}
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) {
/** 화면 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) {
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];
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;
}
}
features.push({ kind, parts, minX, minY, maxX, maxY, labelAnchorX, labelAnchorY, labelText });
}
return { features };
});
return best;
}
/**
* 사업지 좌표계(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,
},
],
};
/** 레이어 안의 피처 하나만 다시 그린다 — 고른 등고선을 도드라지게 할 때 쓴다. */
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));
}
/**
@@ -563,6 +420,9 @@ function drawPointParts(
/** 컬링 여백: 선 굵기·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;
@@ -578,36 +438,100 @@ function isVisible(feature: PreparedFeature, affine: Affine, view: ViewState): b
);
}
/** 레이어 1개를 그린다. context의 lineWidth/strokeStyle은 호출부에서 설정한다. */
/** 등고선을 몇 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,
): 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.lineWidth = 3;
context.strokeStyle = haloColor();
context.strokeText(feature.labelText, x, y);
@@ -0,0 +1,306 @@
/* =============================================================================
* B04_PreProcess_UI_MapRender_Prepare.ts
* 지도 레이어 **사전 투영** — GeoJSON·사업지 좌표 폴리라인을 정규화 좌표로 펴고,
* 줌 무손실 LOD 가중치(Douglas-Peucker)와 등고 라벨 앵커를 미리 잡아 둔다.
*
* `B04_PreProcess_UI_MapRender.ts` 가 700줄을 넘겨 떼어낸 조각이다(2026-09-12).
* 본문 로직과 수치는 그대로다. 그리기는 그쪽, 준비는 이쪽 — 한 방향으로만 기대어
* 순환 참조가 생기지 않는다.
* ========================================================================== */
import type { VWorldMeta } from "./B04_PreProcess_Api_Fetch";
import type {
GeoJsonCollection,
GeoJsonGeometry,
Normalizer,
PreparedFeature,
PreparedLayer,
PreparedPart,
} from "./B04_PreProcess_UI_MapRender";
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 labelValue: number | 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);
// **모든 등고선**에 앵커를 잡아 둔다. 어느 줄을 실제로 낼지는 그릴 때 고른다 —
// 화면마다 솎는 눈금이 다르기 때문이다(B04 지도는 계곡선만, 계획노선 편집 모달은
// 확대에 따라 더 촘촘히). 준비 단계에서 걸러 버리면 확대해도 되살릴 수가 없다.
if (Number.isFinite(elevation)) {
const anchor = labelAnchorOf(feature.geometry, normalizer);
if (anchor) {
labelText = String(elevation);
labelValue = elevation;
labelAnchorX = anchor[0];
labelAnchorY = anchor[1];
}
}
}
features.push({
kind,
parts,
minX,
minY,
maxX,
maxY,
labelAnchorX,
labelAnchorY,
labelText,
labelValue,
});
}
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,
labelValue: null,
},
],
};
}
/**
* 사업지 좌표계(m) 폴리라인 **여러 개**를 한 레이어로 사전 투영한다(LAS 등고선 등).
*
* `prepareMetricPolyline` 의 여러 줄 판이다. 줄마다 `label`(표고 m)을 주면 가운데 정점을
* 앵커로 잡아 `drawPreparedLabels` 가 그대로 쓸 수 있다.
*/
export function prepareMetricPolylines(
lines: ReadonlyArray<{ points: ReadonlyArray<readonly [number, number]>; label?: number }>,
meta: VWorldMeta,
): PreparedLayer {
const widthMeters = meta.width_meters || 1;
const heightMeters = meta.height_meters || 1;
const features: PreparedFeature[] = [];
for (const line of lines) {
if (line.points.length < 2) continue;
const coords = new Float64Array(line.points.length * 2);
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
line.points.forEach((point, index) => {
const nx = (point[0] - meta.x_min) / widthMeters;
const ny = 1 - (point[1] - 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;
});
const middle = Math.floor(line.points.length / 2) * 2;
features.push({
kind: "line",
parts: [
{ coords, closed: false, weights: computeDpWeights(coords, widthMeters / heightMeters) },
],
minX,
minY,
maxX,
maxY,
labelAnchorX: coords[middle],
labelAnchorY: coords[middle + 1],
labelText: line.label === undefined ? null : String(line.label),
labelValue: line.label ?? null,
});
}
return { features };
}
@@ -33,8 +33,6 @@ import {
createNormalizer,
drawPreparedLabels,
drawPreparedLayer,
prepareLayer,
prepareMetricPolyline,
routeLineColor,
ROUTE_LINE_WIDTH,
type GeoJsonCollection,
@@ -44,6 +42,7 @@ import {
type PreparedLayer,
type ViewState,
} from "./B04_PreProcess_UI_MapRender";
import { prepareLayer, prepareMetricPolyline } from "./B04_PreProcess_UI_MapRender_Prepare";
import { drawStationTicks } from "./B04_PreProcess_UI_MapOverlays";
import type { WatershedAnalysis } from "./B04_PreProcess_Api_Fetch";
+4 -2
View File
@@ -15,13 +15,15 @@ import {
computeMapRect,
MAP_STATION_INTERVAL_M,
createNormalizer,
prepareLayer,
prepareMetricPolyline,
type MapRect,
type Normalizer,
type PreparedLayer,
type ViewState,
} from "../B04_PreProcess/B04_PreProcess_UI_MapRender";
import {
prepareLayer,
prepareMetricPolyline,
} from "../B04_PreProcess/B04_PreProcess_UI_MapRender_Prepare";
import { type RoutePoint } from "./B05_Profile_Api_Fetch";
import type { FlowArrow } from "../B04_PreProcess/B04_PreProcess_UI_FlowArrows";
import { buildStrengthArray } from "../B04_PreProcess/B04_PreProcess_UI_FlowRamp";
+8 -2
View File
@@ -286,8 +286,14 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
() => {
navigateTo(ROUTES.B05_PROFILE);
},
// 측점 눈금 간격은 좌측 패널이 쥔 값을 그대로 넘긴다 — 모달이 따로 굳히지 않는다.
panel.values().stationInterval ?? undefined,
{
// 측점 눈금 간격은 좌측 패널이 쥔 값을 그대로 넘긴다 — 모달이 따로 굳히지 않는다.
stationIntervalM: panel.values().stationInterval ?? undefined,
// 바탕 등고선 — 확정 지표면이 있으면 모달이 LAS 등고선을 쓴다(계획서 0-9 ⑥).
surfaceModelId: confirmedSurface?.model_id ?? null,
contourIntervalM: latest?.surface_params.contour_interval_m,
smooth: latest?.surface_params.smooth,
},
),
onTempSave: () => void tempSaveAction(actionContext),
onGoCross: () => {
+66 -7
View File
@@ -17,14 +17,17 @@ import {
computeMapRect,
computeRouteView,
createNormalizer,
hitPreparedLayer,
metricToScreen,
prepareLayer,
pickLevelStep,
type PreparedLayer,
type ViewState,
} from "../B04_PreProcess/B04_PreProcess_UI_MapRender";
import { prepareLayer } from "../B04_PreProcess/B04_PreProcess_UI_MapRender_Prepare";
import type { VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
import { clearDrafts, clearResults } from "../A00_Common/b_page_state";
import { showToast } from "@ui/ui_template_elements";
import { loadRouteEditContours, type RouteEditContours } from "./B05_Profile_UI_RouteEdit_Contour";
import { fetchDrainageLayers } from "./B05_Profile_UI_Drainage_Parts";
import { fetchRoutePlan, replanRoute, resetRoutePlan } from "./B05_Profile_Api_Replan";
import {
@@ -63,16 +66,28 @@ import "./B05_Profile_UI_Style_RouteEdit.css";
const NODE_HIT_PX = 9;
/** 선을 두 번 눌러 노드를 끼울 때, 선에서 이만큼(px) 안쪽이면 그 선으로 본다. */
const SEGMENT_HIT_PX = 12;
/** 등고선을 집었다고 볼 거리(px) — 노드·손잡이보다 **좁게** 둔다(노선 편집이 먼저). */
const CONTOUR_HIT_PX = 6;
type Vertex = [number, number];
/** 모달을 연다. [확인]·[예상노선으로]가 끝나면 `onApplied`를 부른다(화면 다시 읽기). */
export interface RouteEditOptions {
/** 규칙 측점 간격(m) — 좌측 패널이 쥔 값을 그대로 받는다(코드에 굳히지 않는다). */
stationIntervalM?: number;
/** 확정 지표면 모델 id — 있으면 바탕 등고선을 **LAS 것**으로 쓴다(계획서 0-9 ⑥). */
surfaceModelId?: number | null;
/** 등고선 간격(m)·평활 여부 — 3D 뷰어가 쓰는 값 그대로. */
contourIntervalM?: number;
smooth?: boolean;
}
export async function openRouteEditModal(
projectId: string,
onApplied: () => void | Promise<void>,
/** 규칙 측점 간격(m) — 좌측 패널이 쥔 값을 그대로 받는다(코드에 굳히지 않는다). */
stationIntervalM = 20,
options: RouteEditOptions = {},
): Promise<void> {
const stationIntervalM = options.stationIntervalM ?? 20;
const overlay = document.createElement("div");
overlay.className = "b05-routeedit";
overlay.innerHTML = `
@@ -133,7 +148,12 @@ export async function openRouteEditModal(
/** 되돌리기 사진첩 — 노선을 읽은 뒤에 선다(그전에는 되돌릴 것이 없다). */
let history: RouteEditHistory | null = null;
let meta: VWorldMeta | null = null;
let sheets: PreparedLayer[] = [];
/** 바탕 등고선 한 벌 — LAS 것이거나 도엽 것. 고르기는 `_Contour` 몫. */
let contours: RouteEditContours | null = null;
/** 등고선 말고 함께 깔 도엽 레이어(하천중심선). */
let otherSheets: PreparedLayer[] = [];
/** 고른 등고선 가닥 — 없으면 -1(계획서 0-9 ⑦). */
let pickedContour = -1;
let view: ViewState = {
width: 0,
height: 0,
@@ -198,6 +218,10 @@ export async function openRouteEditModal(
return Math.abs(x1 - x0) / 100;
}
/** 지금 화면에 낼 등고선 간격(m) — 그리기와 집기가 같은 값을 보게 한 자리에서 셈한다. */
const contourStepM = (): number =>
contours ? pickLevelStep(contours.layer, view, contours.intervalM) : 0;
function draw(): void {
if (closed) return;
drawRouteEditScene(context, {
@@ -205,7 +229,10 @@ export async function openRouteEditModal(
toScreen,
pxPerMeter: pxPerMeter(),
hasMeta: meta !== null,
sheets,
contours,
otherSheets,
pickedContour,
contourStepM: contourStepM(),
expected,
plannedLine,
planned,
@@ -269,6 +296,13 @@ export async function openRouteEditModal(
`길이 ${polylineLengthM(plannedLine.length ? plannedLine : planned).toFixed(1)}m · ` +
`노드 ${planned.length}`;
/** 고른 등고선의 높이 — 못 읽었으면 높이 없이 「고른 등고선」만(계획서 0-9 ⑦). */
const contourHint = (): string => {
if (pickedContour < 0) return "등고선을 누르면 그 줄의 높이가 보입니다.";
const level = contours?.layer.features[pickedContour]?.labelValue ?? null;
return level === null ? "등고선 한 줄을 골랐습니다." : `고른 등고선 ${level}m.`;
};
/** 상태줄 꼬리 — 셈은 `_Edits` 몫. */
const curveHint = (): string =>
curveSummary({
@@ -420,6 +454,15 @@ export async function openRouteEditModal(
picked = dragHandle.node;
syncCurveBar();
draw();
} else if (contours) {
// 노드도 손잡이도 아니면 **등고선**을 집는다 — 노선 편집이 늘 먼저다(계획서 0-9 ⑦).
// 빈 자리를 누르면 -1 이 되어 고른 것이 풀린다.
const hit = hitPreparedLayer(contours.layer, view, px, py, CONTOUR_HIT_PX, contourStepM());
if (hit !== pickedContour) {
pickedContour = hit;
status.textContent = `${routeHead()}${contourHint()} ${curveHint()}`;
draw();
}
}
canvas.setPointerCapture(event.pointerId);
});
@@ -600,9 +643,23 @@ export async function openRouteEditModal(
if (!planned.length) planned = plannedLine.map((vertex) => [vertex[0], vertex[1]]);
meta = drainage.meta;
const normalizer = createNormalizer(drainage.meta);
sheets = drainage.layers
// 등고선은 따로 고른다(LAS 우선). 나머지 도엽 레이어(하천중심선)만 배경으로 깐다.
otherSheets = drainage.layers
.filter(([layer]) => layer !== "도엽_등고선")
.map(([, collection]) => (collection ? prepareLayer(collection, normalizer) : null))
.filter((layer): layer is PreparedLayer => layer !== null);
contours = await loadRouteEditContours(
projectId,
drainage.meta,
normalizer,
drainage.layers.find(([layer]) => layer === "도엽_등고선")?.[1] ?? null,
{
surfaceModelId: options.surfaceModelId ?? null,
intervalM: options.contourIntervalM ?? 1,
smooth: options.smooth ?? false,
},
);
if (closed) return;
resize();
const xs = planned.map((vertex) => vertex[0]);
const ys = planned.map((vertex) => vertex[1]);
@@ -619,7 +676,9 @@ export async function openRouteEditModal(
);
view = { ...view, ...fitted };
status.textContent =
`${routeHead()} · ${plan.edited ? "고친 계획노선" : "초기 폴리라인"} · ` + curveHint();
`${routeHead()} · ${plan.edited ? "고친 계획노선" : "초기 폴리라인"} · ` +
`${contours?.source === "las" ? "LAS 등고선" : "도엽 등고선"} · ` +
curveHint();
draw();
} catch (error) {
status.textContent = error instanceof Error ? error.message : "노선을 읽지 못했습니다.";
@@ -0,0 +1,97 @@
/* =============================================================================
* B05_Profile_UI_RouteEdit_Contour.ts
* 계획노선 편집 모달이 바탕에 깔 **등고선 한 벌**을 고른다.
*
* **어느 등고선을 쓰나**(2026-09-12 사용자 지시 ⑥) — 도엽 등고선과 LAS 로 만든 등고선은
* 서로 어긋난다. 노선은 실제 지형 위에 놓여야 하므로 **확정 지표면 모델이 있으면 LAS 쪽**을
* 쓰고, 없는 프로젝트에서만 지금까지처럼 도엽 등고선을 쓴다.
*
* 둘은 생김새가 다르다 — 도엽은 위경도 GeoJSON(표고는 `등고수치` 속성), LAS 는 사업지
* 좌표(m) 점렬(표고는 `level`)이다. 여기서 **같은 `PreparedLayer` 한 꼴로 맞춰** 내보내
* 그리기·라벨·집기가 출처를 안 가리게 한다.
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
import { fetchCachedJson } from "../A00_Common/b_asset_cache";
import {
type GeoJsonCollection,
type Normalizer,
type PreparedLayer,
} from "../B04_PreProcess/B04_PreProcess_UI_MapRender";
import {
prepareLayer,
prepareMetricPolylines,
} from "../B04_PreProcess/B04_PreProcess_UI_MapRender_Prepare";
import type { VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
/** 도엽 등고선의 표고 속성 이름 — B04 지도가 쓰는 것과 같은 키. */
const SHEET_ELEVATION_KEYS = ["등고수치"];
/** 표고를 못 읽었을 때 라벨 솎기에 쓸 간격(m). */
const FALLBACK_INTERVAL_M = 5;
export interface RouteEditContours {
layer: PreparedLayer;
/** 등고선 간격(m) — 라벨을 몇 줄마다 낼지 정하는 기준. */
intervalM: number;
source: "las" | "sheet";
}
interface ContourResponse {
contours: Array<{ level: number; coordinates: Array<[number, number, number]> }>;
}
/**
* 바탕 등고선을 읽는다. 확정 지표면 모델이 있으면 LAS, 없으면 이미 받아 둔 도엽 컬렉션.
*
* LAS 쪽을 못 읽으면 **조용히 도엽으로 내려앉는다** — 등고선이 아예 없는 화면보다 낫고,
* 어느 쪽을 쓰고 있는지는 `source` 로 나가 상태줄에 적힌다.
*/
export async function loadRouteEditContours(
projectId: string,
meta: VWorldMeta,
normalizer: Normalizer,
sheet: GeoJsonCollection | null,
options: { surfaceModelId: number | null; intervalM: number; smooth: boolean },
): Promise<RouteEditContours> {
if (options.surfaceModelId !== null) {
const interval = options.intervalM > 0 ? options.intervalM : 1;
try {
// 3D 뷰어가 쓰는 것과 **같은 파일**이다 — 보관함에 있으면 다시 내려받지 않는다.
const data = await fetchCachedJson<ContourResponse>(
projectId,
`${API_BASE_URL}/projects/${projectId}/surface/models/${options.surfaceModelId}` +
`/contour?interval=${interval}&smooth=${options.smooth}`,
);
const lines = (data.contours ?? [])
.map((contour) => ({
points: contour.coordinates.map(([x, y]) => [x, y] as const),
label: contour.level,
}))
.filter((line) => line.points.length >= 2);
if (lines.length > 0) {
return { layer: prepareMetricPolylines(lines, meta), intervalM: interval, source: "las" };
}
} catch {
/* 내려앉는다 — 아래 도엽 갈래로 이어 간다. */
}
}
const layer = prepareLayer(sheet ?? undefined, normalizer, SHEET_ELEVATION_KEYS);
return { layer, intervalM: inferIntervalM(layer), source: "sheet" };
}
/** 도엽 등고선의 간격(m) — 표고 값들의 **가장 좁은 칸**을 간격으로 본다. */
function inferIntervalM(layer: PreparedLayer): number {
const levels = [
...new Set(
layer.features
.map((feature) => feature.labelValue)
.filter((value): value is number => value !== null),
),
].sort((a, b) => a - b);
let smallest = Infinity;
for (let index = 1; index < levels.length; index += 1) {
const gap = levels[index] - levels[index - 1];
if (gap > 0 && gap < smallest) smallest = gap;
}
return Number.isFinite(smallest) ? smallest : FALLBACK_INTERVAL_M;
}
+38 -4
View File
@@ -11,10 +11,13 @@
* ========================================================================== */
import {
drawPreparedFeature,
drawPreparedLabels,
drawPreparedLayer,
type PreparedLayer,
type ViewState,
} from "../B04_PreProcess/B04_PreProcess_UI_MapRender";
import type { RouteEditContours } from "./B05_Profile_UI_RouteEdit_Contour";
import { drawStationTicks } from "../B04_PreProcess/B04_PreProcess_UI_MapOverlays";
import type { EditedCurve, EditedNode, Vertex } from "./B05_Profile_UI_RouteEdit_Curve";
import { contourBandRect } from "./B05_Profile_UI_RouteEdit_Input";
@@ -54,7 +57,14 @@ export interface RouteEditScene {
pxPerMeter: number;
/** 도엽 메타를 읽었나 — 못 읽었으면 등고선 띠를 씌우지 않는다. */
hasMeta: boolean;
sheets: ReadonlyArray<PreparedLayer>;
/** 바탕 등고선 한 벌 — LAS 것이거나 도엽 것(`_Contour` 가 고른다). */
contours: RouteEditContours | null;
/** 등고선 말고 함께 깔 도엽 레이어(하천중심선). */
otherSheets: ReadonlyArray<PreparedLayer>;
/** 고른 등고선 가닥 — 없으면 -1(계획서 0-9 ⑦). */
pickedContour: number;
/** 지금 화면에 낼 등고선 간격(m) — 그리기와 집기가 **같은 값**을 봐야 한다. */
contourStepM: number;
expected: ReadonlyArray<Vertex>;
/** 그려 보이는 계획노선(원호 포함). */
plannedLine: ReadonlyArray<Vertex>;
@@ -86,9 +96,33 @@ export function drawRouteEditScene(context: CanvasRenderingContext2D, scene: Rou
context.rect(band.x, band.y, band.width, band.height);
context.clip();
}
context.strokeStyle = style.getPropertyValue("--map-sheet-contour") || "#a5b4fc";
context.lineWidth = 0.8;
for (const layer of scene.sheets) drawPreparedLayer(context, layer, view, "dot");
context.strokeStyle = style.getPropertyValue("--map-sheet-stream") || "#2563eb";
context.lineWidth = 1.2;
for (const layer of scene.otherSheets) drawPreparedLayer(context, layer, view, "dot");
if (scene.contours) {
// 그리는 줄과 라벨을 **같은 눈금**으로 솎는다 — 그린 줄에만 숫자가 붙어야 짝이 맞는다.
const everyM = scene.contourStepM;
context.strokeStyle = style.getPropertyValue("--map-sheet-contour") || "#a5b4fc";
context.lineWidth = 0.8;
drawPreparedLayer(context, scene.contours.layer, view, "dot", everyM);
// 고른 가닥은 굵고 다른 색으로 덧그린다 — 지우고 다시 그리지 않고 위에 얹는다.
if (scene.pickedContour >= 0) {
context.strokeStyle = style.getPropertyValue("--map-flow-arrow") || "#7c3aed";
context.lineWidth = 2.6;
drawPreparedFeature(context, scene.contours.layer, scene.pickedContour, view);
}
// 등고 높이값 — 확대가 클수록 촘촘히 낸다(계획서 0-9 ③).
context.font = "10px system-ui, sans-serif";
context.textAlign = "center";
context.textBaseline = "middle";
drawPreparedLabels(
context,
scene.contours.layer,
view,
style.getPropertyValue("--map-sheet-contour") || "#a5b4fc",
everyM,
);
}
context.restore();
strokePolyline(