diff --git a/B04_PreProcess/B04_PreProcess_UI_MapRender.ts b/B04_PreProcess/B04_PreProcess_UI_MapRender.ts index 258b9723..00aa2b95 100644 --- a/B04_PreProcess/B04_PreProcess_UI_MapRender.ts +++ b/B04_PreProcess/B04_PreProcess_UI_MapRender.ts @@ -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); diff --git a/B04_PreProcess/B04_PreProcess_UI_MapRender_Prepare.ts b/B04_PreProcess/B04_PreProcess_UI_MapRender_Prepare.ts new file mode 100644 index 00000000..149cfeea --- /dev/null +++ b/B04_PreProcess/B04_PreProcess_UI_MapRender_Prepare.ts @@ -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; 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 }; +} diff --git a/B04_PreProcess/B04_PreProcess_UI_MapViewer.ts b/B04_PreProcess/B04_PreProcess_UI_MapViewer.ts index 61782992..2409f70b 100644 --- a/B04_PreProcess/B04_PreProcess_UI_MapViewer.ts +++ b/B04_PreProcess/B04_PreProcess_UI_MapViewer.ts @@ -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"; diff --git a/B05_Profile/B05_Profile_UI_Drainage_Panel.ts b/B05_Profile/B05_Profile_UI_Drainage_Panel.ts index 58827809..e8c1f183 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Panel.ts @@ -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"; diff --git a/B05_Profile/B05_Profile_UI_Page.ts b/B05_Profile/B05_Profile_UI_Page.ts index 862ac1bc..4f93666d 100644 --- a/B05_Profile/B05_Profile_UI_Page.ts +++ b/B05_Profile/B05_Profile_UI_Page.ts @@ -286,8 +286,14 @@ export async function renderB05Route(root: HTMLElement): Promise { () => { 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: () => { diff --git a/B05_Profile/B05_Profile_UI_RouteEdit.ts b/B05_Profile/B05_Profile_UI_RouteEdit.ts index a9524df5..444833cf 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit.ts @@ -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, - /** 규칙 측점 간격(m) — 좌측 패널이 쥔 값을 그대로 받는다(코드에 굳히지 않는다). */ - stationIntervalM = 20, + options: RouteEditOptions = {}, ): Promise { + 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 : "노선을 읽지 못했습니다."; diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Contour.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Contour.ts new file mode 100644 index 00000000..d5050907 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Contour.ts @@ -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 { + if (options.surfaceModelId !== null) { + const interval = options.intervalM > 0 ? options.intervalM : 1; + try { + // 3D 뷰어가 쓰는 것과 **같은 파일**이다 — 보관함에 있으면 다시 내려받지 않는다. + const data = await fetchCachedJson( + 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; +} diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Render.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Render.ts index d40c27ba..afddf7a4 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Render.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Render.ts @@ -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; + /** 바탕 등고선 한 벌 — LAS 것이거나 도엽 것(`_Contour` 가 고른다). */ + contours: RouteEditContours | null; + /** 등고선 말고 함께 깔 도엽 레이어(하천중심선). */ + otherSheets: ReadonlyArray; + /** 고른 등고선 가닥 — 없으면 -1(계획서 0-9 ⑦). */ + pickedContour: number; + /** 지금 화면에 낼 등고선 간격(m) — 그리기와 집기가 **같은 값**을 봐야 한다. */ + contourStepM: number; expected: ReadonlyArray; /** 그려 보이는 계획노선(원호 포함). */ plannedLine: ReadonlyArray; @@ -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(