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:
@@ -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";
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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: () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user