diff --git a/B04_PreProcess/B04_PreProcess_UI_MapOverlays.ts b/B04_PreProcess/B04_PreProcess_UI_MapOverlays.ts index 4ef0f6a6..c26e2d56 100644 --- a/B04_PreProcess/B04_PreProcess_UI_MapOverlays.ts +++ b/B04_PreProcess/B04_PreProcess_UI_MapOverlays.ts @@ -202,6 +202,9 @@ export interface StationTickOptions { toScreen: (x: number, y: number) => [number, number]; /** 관 마커가 놓인 누가거리 목록 — 겹치면 라벨을 반대쪽으로 민다. */ avoidChainages?: ReadonlyArray; + /** 돌린 지도에서 **글자만 되돌려 세울** 각(라디안). 0이면 그림과 함께 돈다. + * 눈금 막대는 노선에 직각이라 함께 돌아야 맞고, 숫자만 눈높이로 세운다. */ + uprightRad?: number; } export function drawStationTicks( @@ -274,11 +277,18 @@ export function drawStationTicks( ); if (collides) continue; drawn.push({ x: lx, y: ly, half }); + context.save(); + if (options.uprightRad) { + context.translate(lx, ly); + context.rotate(options.uprightRad); + context.translate(-lx, -ly); + } // 배경을 깔아 등고선 위에서도 읽히게 한다. context.fillStyle = "rgba(255, 255, 255, 0.78)"; context.fillRect(lx - half, ly - 8, width, 16); context.fillStyle = "#222222"; context.fillText(label, lx, ly); + context.restore(); } context.restore(); } diff --git a/B04_PreProcess/B04_PreProcess_UI_MapRender.ts b/B04_PreProcess/B04_PreProcess_UI_MapRender.ts index 258b9723..1ae0211f 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,41 +438,142 @@ function isVisible(feature: PreparedFeature, affine: Affine, view: ViewState): b ); } -/** 레이어 1개를 그린다. context의 lineWidth/strokeStyle은 호출부에서 설정한다. */ +/** 레이어가 차지하는 **화면 사각형**(px). 피처가 없으면 null. + * + * LAS 등고선처럼 도엽보다 좁은 자료 위에 다른 레이어를 겹칠 때, 그 자료가 있는 데까지만 + * 그리려고 쓴다(계획서 0-9 ⑮ — 세류선이 등고선 밖까지 뻗던 자리). */ +export function layerScreenBounds( + layer: PreparedLayer, + view: ViewState, +): { x: number; y: number; width: number; height: number } | null { + if (layer.features.length === 0) return null; + const affine = affineOf(view); + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + for (const feature of layer.features) { + minX = Math.min(minX, feature.minX); + minY = Math.min(minY, feature.minY); + maxX = Math.max(maxX, feature.maxX); + maxY = Math.max(maxY, feature.maxY); + } + const x0 = minX * affine.ax + affine.bx; + const x1 = maxX * affine.ax + affine.bx; + const y0 = minY * affine.ay + affine.by; + const y1 = maxY * affine.ay + affine.by; + return { x: x0, y: y0, width: x1 - x0, height: y1 - y0 }; +} + +/** 등고선을 몇 m 마다 낼지 고를 때 훑는 배수. 성긴 쪽으로 한 칸씩 물러난다. */ +const LEVEL_STEP_MULTIPLES = [1, 2, 5, 10, 20, 50, 100]; +/** 한 화면에 둘 등고선 가닥 수의 어림 상한 — 이보다 많으면 한 칸 성글게 간다. */ +const LEVEL_BUDGET = 350; + +/** + * 지금 화면에 **몇 m 간격**으로 등고선을 낼지 고른다. + * + * 간격을 줌으로만 정하면 가파른 데서는 여전히 선이 뭉개지고 완만한 데서는 너무 성기다. + * 그래서 **지금 화면에 실제로 들어오는 가닥 수**를 세어 상한을 넘지 않는 가장 촘촘한 간격을 + * 고른다 — 확대하면 저절로 촘촘해지고 물러나면 성겨진다(2026-09-12 실화면: 1m LAS 등고선을 + * 다 그리면 지형이 선으로 덮였다). + */ +export function pickLevelStep( + layer: PreparedLayer, + view: ViewState, + intervalM: number, + budget = LEVEL_BUDGET, +): number { + const interval = intervalM > 0 ? intervalM : 1; + const affine = affineOf(view); + let step = interval * LEVEL_STEP_MULTIPLES[LEVEL_STEP_MULTIPLES.length - 1]; + for (const multiple of LEVEL_STEP_MULTIPLES) { + const candidate = interval * multiple; + let count = 0; + for (const feature of layer.features) { + if (feature.labelValue !== null && feature.labelValue % candidate !== 0) continue; + if (!isVisible(feature, affine, view)) continue; + count += 1; + if (count > budget) break; + } + if (count <= budget) return candidate; + step = candidate; + } + return step; +} + +/** 레이어 1개를 그린다. context의 lineWidth/strokeStyle은 호출부에서 설정한다. + * + * `everyM` 을 주면 **그 배수의 표고만** 그린다. 1m 간격 LAS 등고선을 멀리서 다 그리면 화면이 + * 선으로 뭉개져 지형이 안 읽힌다 — 확대에 따라 성긴 등고선부터 내보이려는 것이다. 안 주면 + * 전부 그리므로 기존 화면(B04 지도·배수유역도)의 표기는 그대로다. */ export function drawPreparedLayer( context: CanvasRenderingContext2D, layer: PreparedLayer, view: ViewState, marker: MarkerKind, + everyM?: number, ): void { const affine = affineOf(view); + const step = everyM !== undefined && everyM > 0 ? everyM : 0; for (const feature of layer.features) { + if (step && feature.labelValue !== null && feature.labelValue % step !== 0) continue; if (!isVisible(feature, affine, view)) continue; if (feature.kind === "point") drawPointParts(context, feature, affine, marker); else drawLineParts(context, feature, affine); } } -/** 사전 계산된 계곡선 라벨을 그린다. 폰트·정렬은 호출부에서 설정한다. */ +/** 사전 계산된 등고 라벨을 그린다. 폰트·정렬은 호출부에서 설정한다. + * + * `everyM` 은 **몇 m 마다 한 줄을 라벨할지**다. 기본 25m(계곡선)는 B04 지도가 쓰던 값 그대로다 + * — 전부 내면 화면이 숫자로 뒤덮인다. 확대가 큰 화면은 더 작은 값을 넘겨 촘촘히 낸다. */ export function drawPreparedLabels( context: CanvasRenderingContext2D, layer: PreparedLayer, view: ViewState, color: string, + everyM = 25, + /** 돌린 지도에서 **글자만 되돌려 세울** 각(라디안). 0이면 그림과 함께 돈다. */ + uprightRad = 0, ): void { const affine = affineOf(view); const margin = CULL_MARGIN; + const step = everyM > 0 ? everyM : 25; + // 이미 찍은 라벨과 겹치면 건너뛴다 — LAS 등고선은 **한 표고가 여러 가닥**으로 끊겨 있어 + // 가닥마다 숫자를 내면 화면이 숫자로 덮인다(2026-09-12 실화면). 도엽 계곡선은 원래 + // 드물어 이 규칙에 걸리지 않으므로 B04 지도의 표기는 그대로다. + const drawn: Array<{ x: number; y: number; half: number }> = []; for (const feature of layer.features) { if (feature.labelText === null) continue; + // 표고를 못 읽은 라벨(값 없음)은 솎지 않고 그대로 낸다. + if (feature.labelValue !== null && feature.labelValue % step !== 0) continue; const x = feature.labelAnchorX * affine.ax + affine.bx; const y = feature.labelAnchorY * affine.ay + affine.by; if (x < -margin || x > view.width + margin) continue; if (y < -margin || y > view.height + margin) continue; + const half = context.measureText(feature.labelText).width / 2 + LABEL_GAP_PX; + if ( + drawn.some( + (item) => Math.abs(item.x - x) < item.half + half && Math.abs(item.y - y) < LABEL_ROW_PX, + ) + ) { + continue; + } + drawn.push({ x, y, half }); + context.save(); + if (uprightRad) { + // 글자 **자리는 그대로** 두고 글자만 되돌린다 — 180°에서 숫자가 뒤집혀 안 읽힌다. + context.translate(x, y); + context.rotate(uprightRad); + context.translate(-x, -y); + } context.lineWidth = 3; context.strokeStyle = haloColor(); context.strokeText(feature.labelText, x, y); context.fillStyle = color; context.fillText(feature.labelText, x, y); + context.restore(); } } 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_Api_Replan.ts b/B05_Profile/B05_Profile_Api_Replan.ts index 4f6c4539..ad414804 100644 --- a/B05_Profile/B05_Profile_Api_Replan.ts +++ b/B05_Profile/B05_Profile_Api_Replan.ts @@ -56,8 +56,12 @@ export interface RoutePlanResponse { nodes: RoutePlanNode[]; /** 직선·곡선 성분 — 곡선 시작·끝점과 반지름. 화면이 이것으로 손잡이를 그린다. */ curves: RoutePlanCurve[]; - /** 이 프로젝트에 적용한 법정 최소곡선반지름(m). */ + /** 이 프로젝트에 적용한 법정 최소곡선반지름(m) — **기본값·위반 표시 기준**. */ min_radius_m: number; + /** **못 넘는** 곡선반지름 하한(m). 0이면 제한 없음(작업임도). 기본값과 다른 값이다. */ + limit_radius_m?: number; + /** **못 넘는** 곡선 길이 하한(m). 0이면 제한 없음 — 지금은 전부 0(법에 값이 없음). */ + limit_curve_length_m?: number; curve_count: number; violation_count: number; /** 사용자가 고친 계획노선이 저장돼 있으면 true. */ @@ -128,6 +132,60 @@ export async function replanRoute( ); } +/** 점 묶음의 **지반고**를 묻는다(계획서 0-9 ⑤·⑧). + * + * 새 계산이 아니라 확정된 지표면을 **읽기만** 하므로 편집 중에 불러도 된다 — 다만 끄는 동안 + * 프레임마다 부르지는 않는다(찍는 순간에만). 지표면 밖은 `null` 로 온다. */ +export async function fetchRouteElevations( + projectId: string, + points: Array<[number, number]>, +): Promise> { + const payload = await requestJson<{ z: Array }>( + `/projects/${projectId}/route/elevations`, + { method: "POST", body: JSON.stringify({ points }) }, + 60000, + ); + return payload.z; +} + +/** 횡단 미리보기 한 장 — 고치던 노선 그대로 그 측점만 서버가 셈해 준다(계획서 0-9 ⑧). */ +export interface CrossPreviewResponse { + status: string; + chainage_m: number; + label: string | null; + uphill_side: string | null; + plan_radius_m: number | null; + curve_widening_m: number | null; + /** 원지반 횡단 샘플. */ + samples: Array<{ offset_m?: number; elevation_m?: number | null; valid: boolean }>; + /** 기본 계획 횡단 — B06 `compute_cross_design` 이 낸 것. 계획고를 못 세우면 null. */ + design: { + design_line: Array<{ offset_m: number; elevation_m: number }>; + cut_area_m2: number; + fill_area_m2: number; + [key: string]: unknown; + } | null; +} + +export interface CrossPreviewRequest { + vertices: Array<{ x: number; y: number; curve: boolean; radius_m: number | null }>; + chainage_m: number; + min_radius_m: number; + station_interval_m: number; +} + +/** 한 측점 횡단을 묻는다. 종·횡단을 한 번 돌리므로 **한두 초** 걸린다(사용자 확정: 괜찮음). */ +export async function fetchCrossPreview( + projectId: string, + request: CrossPreviewRequest, +): Promise { + return requestJson( + `/projects/${projectId}/route/cross-preview`, + { method: "POST", body: JSON.stringify(request) }, + 120000, + ); +} + /** 계획노선을 예상노선으로 되돌리고 같은 재계산을 돈다(노선 초기화). */ export async function resetRoutePlan(projectId: string): Promise { return requestJson( diff --git a/B05_Profile/B05_Profile_Engine_Grade.py b/B05_Profile/B05_Profile_Engine_Grade.py index e250d8f4..706bc263 100644 --- a/B05_Profile/B05_Profile_Engine_Grade.py +++ b/B05_Profile/B05_Profile_Engine_Grade.py @@ -151,6 +151,37 @@ def legal_plan_radius_min_m(design_speed_kph: int, terrain_type: str = "normal") return float(speeds[terrain]) +def plan_radius_limit_m( + grade_class: str, + design_speed_kph: int | None = None, + terrain_type: str = "normal", +) -> float: + """계획노선 편집 화면이 **못 넘게 막을** 평면 곡선반지름 하한(m). 0이면 제한 없음. + + 위 `legal_plan_radius_min_m` 은 **기본값·위반 표시 기준**이고 이것은 **제한**이다 + (2026-09-12 사용자 확정: 「아예 못 넘게 막음」). 임도 종류별 칸이 비어 있으면(None) + 법정 표를 그대로 하한으로 쓰고, 값이 적혀 있으면 그 값을 쓴다 — 작업임도는 별표2에 + 곡선반지름 규정이 없어 0(제한 없음)으로 열려 있다. + """ + table = FOREST_ROAD_PROFILE_CRITERIA["plan_radius_limit_by_grade_m"] + override = table.get(grade_class) + if override is not None: + return float(override) + return legal_plan_radius_min_m( + resolve_design_speed(grade_class, design_speed_kph), terrain_type + ) + + +def plan_curve_length_limit_m(grade_class: str) -> float: + """평면 **곡선 길이(L)** 하한(m). 0이면 제한 없음. + + 법령·교본에 값이 없어 지금은 임도 종류 전부 0이다 — 자리만 열어 둔 칸이라 + 실무값이 정해지면 `config_system_design` 의 표만 고치면 된다(2026-09-12 사용자 확정). + """ + table = FOREST_ROAD_PROFILE_CRITERIA["plan_curve_length_limit_by_grade_m"] + return float(table.get(grade_class) or 0.0) + + def _pick(*candidates: Any) -> Any: """요청 → DB 저장값 → config 순으로 처음 나오는 유효값을 고른다.""" for value in candidates: diff --git a/B05_Profile/B05_Profile_Router_Replan.py b/B05_Profile/B05_Profile_Router_Replan.py index cfbe16d5..50bc3103 100644 --- a/B05_Profile/B05_Profile_Router_Replan.py +++ b/B05_Profile/B05_Profile_Router_Replan.py @@ -133,7 +133,19 @@ def _ensure_expected_route(project_root: Path) -> str: async def _min_plan_radius_m(project_id: UUID) -> float: - """이 프로젝트에 적용할 법정 최소곡선반지름(m) — 임도 종류·설계속도·지형으로 고른다. + """기본 반지름만 필요한 자리 — 하한까지 필요하면 `_plan_criteria` 를 쓸 것.""" + criteria = await _plan_criteria(project_id) + return criteria[0] + + +async def _plan_criteria(project_id: UUID) -> tuple[float, float, float]: + """이 프로젝트의 **기본 반지름 · 반지름 하한 · 곡선 길이 하한**(m) 세 값. + + 기본 반지름은 곡선을 만들 때 쓰는 값이고, 하한 둘은 **화면이 못 넘게 막는** 값이다 + (2026-09-12 사용자 확정). 둘을 한 값으로 묶으면 하한 0 이 곧 반지름 0 이 되어 곡선이 + 아예 안 그려지므로 반드시 갈라 둔다. + + 기본 반지름은 임도 종류·설계속도·지형으로 고른다. 값의 출처는 지식DB(`01_임도/02_상세설계/평면선형.md`, 별표2 Ⅰ.2.다)이고 산식은 이미 `B05_Profile_Engine_Grade.legal_plan_radius_min_m` 에 있다 — 여기서 다시 짜지 않는다. @@ -145,7 +157,12 @@ async def _min_plan_radius_m(project_id: UUID) -> float: """ import aiomysql - from B05_Profile.B05_Profile_Engine_Grade import legal_plan_radius_min_m, resolve_design_speed + from B05_Profile.B05_Profile_Engine_Grade import ( + legal_plan_radius_min_m, + plan_curve_length_limit_m, + plan_radius_limit_m, + resolve_design_speed, + ) from common_util.common_util_workflow_state import get_workflow_state grade_class, design_speed, terrain = "work", None, "special" @@ -172,7 +189,11 @@ async def _min_plan_radius_m(project_id: UUID) -> float: terrain = str(params["terrain_type"]) except Exception: # noqa: BLE001 — 설정을 못 읽어도 폴리라인화는 이어 간다 logger.exception("최소곡선반지름 설정을 못 읽어 기본값을 씁니다: %s", project_id) - return legal_plan_radius_min_m(resolve_design_speed(grade_class, design_speed), terrain) + return ( + legal_plan_radius_min_m(resolve_design_speed(grade_class, design_speed), terrain), + plan_radius_limit_m(grade_class, design_speed, terrain), + plan_curve_length_limit_m(grade_class), + ) def _nodes_path(path: Path) -> Path: @@ -404,7 +425,7 @@ async def read_route_plan(project_id: UUID) -> dict[str, Any] | JSONResponse: if not expected: expected = await asyncio.to_thread(_vertices_of, design_route_csv_path(project_root)) - radius_m = await _min_plan_radius_m(project_id) + radius_m, radius_limit_m, arc_limit_m = await _plan_criteria(project_id) await asyncio.to_thread(_ensure_planned_initial, project_root, radius_m) working = await asyncio.to_thread(_vertices_of, planned_route_working_path(project_root)) initial = await asyncio.to_thread(_vertices_of, planned_route_initial_path(project_root)) @@ -445,6 +466,10 @@ async def read_route_plan(project_id: UUID) -> dict[str, Any] | JSONResponse: # (2026-09-07 사용자 확정). 저장분이 있으면 그것을, 없으면 방금 뽑은 것을 준다. "curves": saved_curves or [curve.as_dict() for curve in outline.curves], "min_radius_m": round(radius_m, 2), + # **못 넘는 하한** — 기본값(`min_radius_m`)과 다른 값이다. 0이면 제한 없음 + # (작업임도는 별표2에 곡선반지름 규정이 없어 0으로 열려 있다, 2026-09-12 확정). + "limit_radius_m": round(radius_limit_m, 2), + "limit_curve_length_m": round(arc_limit_m, 2), "curve_count": len(saved_curves) if saved_curves else outline.curve_count, "violation_count": outline.violation_count, "edited": bool(working), @@ -468,7 +493,7 @@ async def replan_route( # 고치기 전에 예상노선(원본)·초기 폴리라인이 서 있는지 본다 — 초기화가 돌아갈 자리다. await asyncio.to_thread(_ensure_expected_route, project_root) - radius_m = await _min_plan_radius_m(project_id) + radius_m, radius_limit_m, arc_limit_m = await _plan_criteria(project_id) await asyncio.to_thread(_ensure_planned_initial, project_root, radius_m) # 화면이 보낸 것은 **노드(꺾임점)** 다 — 같은 R 규칙으로 다시 폴리라인을 만든다. # 노드만 옮기면 선이 저절로 규칙을 지키는 것이 이 구조의 목적이다(2026-09-06 사용자). @@ -527,7 +552,7 @@ async def reset_route_plan(project_id: UUID) -> dict[str, Any] | JSONResponse: return JSONResponse(status_code=404, content=_PROJECT_PATH_MISSING) project_root, stored_path = paths await asyncio.to_thread(_ensure_expected_route, project_root) - radius_m = await _min_plan_radius_m(project_id) + radius_m, radius_limit_m, arc_limit_m = await _plan_criteria(project_id) await asyncio.to_thread(_ensure_planned_initial, project_root, radius_m) working_path = planned_route_working_path(project_root) if working_path.is_file(): diff --git a/B05_Profile/B05_Profile_Router_Terrain.py b/B05_Profile/B05_Profile_Router_Terrain.py new file mode 100644 index 00000000..db032f93 --- /dev/null +++ b/B05_Profile/B05_Profile_Router_Terrain.py @@ -0,0 +1,221 @@ +"""계획노선 편집 중 **지반고만** 묻는 가벼운 통로. + +편집 모달은 [확인] 전까지 아무 계산도 내보내지 않는다(계획서 0-2 확정 7). 다만 두 점을 찍어 +**구간 길이와 종단기울기**를 볼 때(0-9 ⑤)와 한 측점의 **횡단도 미리보기**(⑧)는 지반고가 +있어야 한다. 새 계산이 아니라 **이미 확정된 지표면을 읽기만** 하는 통로라 그 규칙과 부딪히지 +않는다 — 노선을 갈아 끼우지도, 정본을 건드리지도 않는다. + +표고 조회는 종·횡단 생성기가 쓰는 것과 **같은 sampler**(`build_surface_sampler`)를 연다. +두 화면이 다른 표고를 보면 같은 자리의 기울기가 갈린다. + + POST /api/projects/{id}/route/elevations → 점 묶음의 지반고 + POST /api/projects/{id}/route/cross-preview → 고치던 노선의 한 측점 횡단 미리보기 +""" + +import asyncio +import logging +from pathlib import Path +from uuid import UUID + +import numpy as np +from fastapi import APIRouter +from fastapi.responses import JSONResponse +from pydantic import BaseModel, Field + +from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path +from B05_Profile.B05_Profile_Engine_Sections_Core import ( + SectionGenerationOptions, + generate_sections, +) +from B06_Section.B06_Section_Engine_Design import compute_cross_design, curve_widening_args +from common_util.common_util_route_polyline import build_planned_polyline +from common_util.common_util_storage import resolve_stored_project_path +from common_util.common_util_surface_confirmation import get_surface_confirmation_params +from common_util.common_util_surface_sampler import build_surface_sampler +from config.config_db import get_db_pool + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/api/projects", tags=["B05 Route Terrain"]) + +_MODELS_SUBDIR = Path("B04_PreProcess") / "models" +#: 한 번에 물을 수 있는 점 수. 구간 재기는 수십 점, 횡단 한 장은 수백 점이면 넉넉하다 — +#: 상한을 두어 실수로 노선 전체를 밀어 넣는 일을 막는다. +MAX_POINTS = 4000 + + +class ElevationRequest(BaseModel): + """사업지 좌표계(m) 점 묶음 [[x, y], …].""" + + points: list[tuple[float, float]] = Field(..., min_length=1, max_length=MAX_POINTS) + + +def _sample(project_root: Path, params: dict, points: list[tuple[float, float]]): + """확정 지표면에서 표고를 읽는다. 모델을 못 열면 None.""" + try: + sampler = build_surface_sampler( + project_root / _MODELS_SUBDIR, + str(params["source_filter"]), + str(params["method"]), + bool(params["smooth"]), + ) + except (FileNotFoundError, KeyError, OSError, ValueError) as exc: + logger.warning("계획노선 편집: 지표면을 열지 못했습니다 — %s", exc) + return None + z, valid = sampler.sample_xy(np.asarray(points, dtype=np.float64)) + return z, valid + + +@router.post("/{project_id}/route/elevations", response_model=None) +async def read_route_elevations(project_id: UUID, request: ElevationRequest) -> dict | JSONResponse: + """점 묶음의 지반고(m)와 유효 여부를 돌려준다. + + 지표면 밖이거나 자료가 없는 자리는 `valid=false` 로 나가고 표고는 `null` 이다 — + **임의 표고로 메우지 않는다**(sampler 규칙 그대로). 화면은 그 자리를 「모름」으로 낸다. + """ + pool = get_db_pool() + async with pool.acquire() as connection: + stored = await get_project_storage_relative_path(connection, project_id) + if not stored: + return JSONResponse( + status_code=404, + content={"status": "error", "message": "프로젝트 저장 경로를 찾을 수 없습니다."}, + ) + params = await get_surface_confirmation_params(connection, str(project_id)) + + project_root = Path(resolve_stored_project_path(stored)) + sampled = await asyncio.to_thread(_sample, project_root, params, request.points) + if sampled is None: + return JSONResponse( + status_code=409, + content={ + "status": "error", + "message": "확정된 지표면이 없어 지반고를 읽을 수 없습니다.", + }, + ) + z, valid = sampled + return { + "status": "success", + "project_id": str(project_id), + "z": [None if not ok else round(float(value), 3) for value, ok in zip(z, valid)], + "valid": [bool(ok) for ok in valid], + } + + +class PreviewVertex(BaseModel): + """편집 중인 꺾임점 하나 — `RouteVertexInput` 과 같은 꼴.""" + + x: float + y: float + curve: bool = True + radius_m: float | None = None + + +class CrossPreviewRequest(BaseModel): + """고치던 노선 그대로 한 측점의 횡단을 미리 본다.""" + + vertices: list[PreviewVertex] = Field(..., min_length=2) + chainage_m: float = Field(..., ge=0) + #: 법정 최소곡선반지름(m) — 화면이 `/route/plan` 에서 받은 값을 그대로 돌려준다. + min_radius_m: float = Field(12.0, gt=0) + station_interval_m: float | None = None + + +def _cross_preview( + project_root: Path, + params: dict, + request: CrossPreviewRequest, +) -> dict | None: + """고치던 노선으로 종·횡단을 한 번 돌려 그 측점 한 장을 뽑는다. + + **B05·B06 의 정본 로직을 그대로 재사용한다**(2026-09-12 사용자 확정 「기본 로직은 B06에 + 존재함. 재사용」) — `generate_sections` 가 측점·접선·지반 샘플을, `compute_cross_design` + 이 설계선을 만든다. 여기서 기하를 새로 짜지 않는다. + + ⚠ **계획고는 아직 없다.** 계획고는 [확인] 뒤 전 체인이 낳는 값이라 편집 중에는 존재하지 + 않는다. 그래서 그 측점의 **지반고를 그대로 계획고로 놓는다**(지반 추종) — 절·성토가 사면 + 기울기만으로 서는 「기본 계획 횡단」이며, 사용자가 보기로 한 것도 그것이다. + """ + try: + sampler = build_surface_sampler( + project_root / _MODELS_SUBDIR, + str(params["source_filter"]), + str(params["method"]), + bool(params["smooth"]), + ) + except (FileNotFoundError, KeyError, OSError, ValueError) as exc: + logger.warning("횡단 미리보기: 지표면을 열지 못했습니다 — %s", exc) + return None + + built = build_planned_polyline( + [(vertex.x, vertex.y) for vertex in request.vertices], + min_radius_m=request.min_radius_m, + # 화면이 준 노드는 이미 꺾임점이다 — 다시 뽑으면 선이 깎인다(`_write_planned_polyline`). + simplify=False, + curve_flags=[vertex.curve for vertex in request.vertices], + radii=[vertex.radius_m for vertex in request.vertices], + ) + interval = request.station_interval_m + options = ( + SectionGenerationOptions(station_interval_m=float(interval)) + if interval and interval > 0 + else SectionGenerationOptions() + ) + result = generate_sections(built.vertices, sampler, options) + sections = result["cross_sections"] + if not sections: + return None + section = min(sections, key=lambda row: abs(float(row["chainage_m"]) - request.chainage_m)) + + design = None + center_z = section.get("center_z") + if center_z is not None: + # 단면유형 기본값은 B06 화면과 같다 — 등고가 높은 쪽을 절토로 본다. + section_mode = "right_cut" if section.get("uphill_side") == "right" else "left_cut" + design = compute_cross_design( + section["samples"], + float(center_z), + ground_type="soil", + section_mode=section_mode, + **curve_widening_args(section), + ) + return { + "chainage_m": round(float(section["chainage_m"]), 3), + "label": section.get("label"), + "uphill_side": section.get("uphill_side"), + "plan_radius_m": section.get("plan_radius_m"), + "curve_widening_m": section.get("curve_widening_m"), + "samples": section["samples"], + "design": design, + "total_length_m": round(float(result["longitudinal"]["total_length_m"]), 3) + if result.get("longitudinal", {}).get("total_length_m") is not None + else None, + } + + +@router.post("/{project_id}/route/cross-preview", response_model=None) +async def read_cross_preview(project_id: UUID, request: CrossPreviewRequest) -> dict | JSONResponse: + """고치던 계획노선의 **한 측점 횡단**을 돌려준다(계획서 0-9 ⑧). + + 정본을 건드리지 않는다 — 파일도 DB 도 쓰지 않고 그 자리에서 셈해 돌려주기만 한다. + """ + pool = get_db_pool() + async with pool.acquire() as connection: + stored = await get_project_storage_relative_path(connection, project_id) + if not stored: + return JSONResponse( + status_code=404, + content={"status": "error", "message": "프로젝트 저장 경로를 찾을 수 없습니다."}, + ) + params = await get_surface_confirmation_params(connection, str(project_id)) + + project_root = Path(resolve_stored_project_path(stored)) + preview = await asyncio.to_thread(_cross_preview, project_root, params, request) + if preview is None: + return JSONResponse( + status_code=409, + content={ + "status": "error", + "message": "확정된 지표면이 없어 횡단을 미리 볼 수 없습니다.", + }, + ) + return {"status": "success", "project_id": str(project_id), **preview} 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 bdc2df3b..4f93666d 100644 --- a/B05_Profile/B05_Profile_UI_Page.ts +++ b/B05_Profile/B05_Profile_UI_Page.ts @@ -281,9 +281,20 @@ export async function renderB05Route(root: HTMLElement): Promise { // 계획노선 편집 — 모달 [확인]에서 서버가 배수유역부터 다시 계산하므로, 끝나면 // 옛 노선 기준 캐시를 버리고 페이지를 새로 세운다([초기화]와 같은 뒷정리). onEditPlannedRoute: () => - void openRouteEditModal(activeProjectId, () => { - navigateTo(ROUTES.B05_PROFILE); - }), + void openRouteEditModal( + activeProjectId, + () => { + navigateTo(ROUTES.B05_PROFILE); + }, + { + // 측점 눈금 간격은 좌측 패널이 쥔 값을 그대로 넘긴다 — 모달이 따로 굳히지 않는다. + 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: () => { // 페이지 이동 = 코리도 영구저장 시점(2026-08-23 사용자 확정) — 이동은 막지 않는다. diff --git a/B05_Profile/B05_Profile_UI_RouteEdit.ts b/B05_Profile/B05_Profile_UI_RouteEdit.ts index 23f76c1e..87521bd7 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit.ts @@ -16,36 +16,46 @@ import { computeMapRect, computeRouteView, - drawPreparedLayer, 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 type { RoutePlanCurve } from "./B05_Profile_Api_Replan"; -import { buildEditedPolyline, dragHandleTo as curveDragTo } from "./B05_Profile_UI_RouteEdit_Curve"; +import { fetchRoutePlan } from "./B05_Profile_Api_Replan"; +import { bindRouteApply } from "./B05_Profile_UI_RouteEdit_Apply"; +import { + buildEditedPolyline, + dragHandleTo as curveDragTo, + type EditedCurve, + type EditedNode, +} from "./B05_Profile_UI_RouteEdit_Curve"; +import { drawRouteEditScene, polylineLengthM } from "./B05_Profile_UI_RouteEdit_Render"; import { bindRouteEditNavigation, - contourBandRect, handleAtScreen, nodeAtScreen, segmentAtScreen, + stationAtScreen, } from "./B05_Profile_UI_RouteEdit_Input"; -import { - centerDirectionOf, - createCurveLabel, - deflectionRad, -} from "./B05_Profile_UI_RouteEdit_Label"; +import { createCrossPreview } from "./B05_Profile_UI_RouteEdit_Cross"; +import { createMapRotation } from "./B05_Profile_UI_RouteEdit_Rotate"; +import { createRouteEditChrome } from "./B05_Profile_UI_RouteEdit_Chrome"; +import { createMeasureTool } from "./B05_Profile_UI_RouteEdit_Measure"; +import { createCurveBar } from "./B05_Profile_UI_RouteEdit_CurveBar"; import { applyArcLocks, + applyCurveLimits, + curveShortfalls, curveSummary, flattenServerPlan, + shortfallCrossed, type CurveLock, } from "./B05_Profile_UI_RouteEdit_Edits"; import { @@ -58,63 +68,34 @@ import "./B05_Profile_UI_Style_RouteEdit.css"; /** 노드를 잡았다고 볼 거리(px). 손가락·마우스 모두 무리 없는 크기. */ const NODE_HIT_PX = 9; -/** 노드 반지름(px). */ -const NODE_R = 4; /** 선을 두 번 눌러 노드를 끼울 때, 선에서 이만큼(px) 안쪽이면 그 선으로 본다. */ const SEGMENT_HIT_PX = 12; -/** 등고선을 보일 **노선 둘레 띠**(m) — 사용자 지시 ⑥(2026-09-07). - * - * 노선에서 이만큼 밖의 등고선은 안 그린다. **창 크기와 무관한 고정 띠**라 창을 늘리거나 - * 줄여도 띠가 흔들리지 않는다(사용자가 「다이나믹 창이라 조심」이라 한 자리). 화면 밖을 - * 걸러내는 일은 `drawPreparedLayer` 가 이미 하므로 여기서는 띠만 덧씌운다. */ -const CONTOUR_BAND_M = 300; -/** 곡선 시작·끝점 손잡이 크기(px) — 노드 동그라미와 구별되게 **속 빈 네모**로 그린다. - * 처음엔 3.5px 였는데 선과 색이 같아 눈에도 안 띄고 집기도 어려웠다(2026-09-07 실화면). */ -const CURVE_HANDLE_PX = 5; +/** 등고선을 집었다고 볼 거리(px) — 노드·손잡이보다 **좁게** 둔다(노선 편집이 먼저). */ +const CONTOUR_HIT_PX = 6; +/** 측점 눈금을 집었다고 볼 거리(px) — 눈금이 보이는 자리를 누르면 잡히게 넉넉히. */ +const STATION_HIT_PX = 11; 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, + options: RouteEditOptions = {}, ): Promise { - const overlay = document.createElement("div"); - overlay.className = "b05-routeedit"; - overlay.innerHTML = ` - `; - document.body.append(overlay); - - const canvas = overlay.querySelector(".b05-routeedit__canvas")!; - const status = overlay.querySelector(".b05-routeedit__status")!; - const busy = overlay.querySelector(".b05-routeedit__busy")!; + const stationIntervalM = options.stationIntervalM ?? 20; + const chrome = createRouteEditChrome(); + const { overlay, canvas, status, busy, measureBox, measureText, measureButton } = chrome; const context = canvas.getContext("2d")!; let expected: Vertex[] = []; @@ -123,14 +104,13 @@ export async function openRouteEditModal( /** 사용자가 잡아 옮기는 **노드**(꺾임점). 서버가 이 노드로 폴리라인을 다시 만든다. */ let planned: Vertex[] = []; /** 노드마다의 반지름·내각·법정 위반 — 서버가 함께 내려 준다(표시용). */ - let nodeInfo: Array<{ - radius_m: number | null; - inner_angle_deg: number | null; - violations: string[]; - }> = []; + let nodeInfo: EditedNode[] = []; let minRadiusM = 0; + /** **못 넘는** 하한 — 0이면 제한 없음. 기본 반지름(`minRadiusM`)과 다른 값이다(계획서 0-9 ④). */ + let limitRadiusM = 0; + let limitArcM = 0; /** 서버가 준 곡선 성분 — 손잡이(곡선 시작·끝점)를 그리는 재료. 편집하면 비운다. */ - let curveInfo: RoutePlanCurve[] = []; + let curveInfo: EditedCurve[] = []; /** 꺾임점마다의 편집값 — 곡선을 둘지, 반지름을 못박을지(2026-09-07 사용자 지시). */ let curveOn: boolean[] = []; let curveRadius: Array = []; @@ -143,7 +123,66 @@ 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; + /** 측점 횡단 미리보기 창 — 측점 눈금을 누르면 뜬다(계획서 0-9 ⑧). */ + const crossPreview = createCrossPreview({ + projectId, + side: overlay.querySelector(".b05-routeedit__side")!, + request: () => ({ + vertices: planned.map(([x, y], index) => ({ + x, + y, + curve: curveOn[index] !== false, + radius_m: curveRadius[index] ?? null, + })), + min_radius_m: minRadiusM || 12, + station_interval_m: stationIntervalM, + }), + }); + /** 구간 재기 — Shift+클릭으로 두 점을 찍는다. 셈·서버 묻기는 `_Measure` 몫(계획서 0-9 ⑤). */ + const measure = createMeasureTool({ + projectId, + stationIntervalM, + line: () => (plannedLine.length ? plannedLine : planned), + // 아래에 선언된 것을 감싸 넘긴다 — 부르는 시점은 늘 그 뒤다. + toScreen: (vertex) => toScreen(vertex), + isClosed: () => closed, + onChange: () => { + syncMeasureBox(); + draw(); + }, + }); + + /** 재고 있으면 작은 창을 띄우고, 아니면 닫는다. **곡선 패널과 같이 뜨지 않는다**(㉔). */ + function syncMeasureBox(): void { + const on = measure.active(); + measureBox.hidden = !on; + measureText.textContent = measure.hint(); + if (on && picked >= 0) { + picked = -1; // 둘이 같이 뜨면 어느 쪽을 만지는지 헷갈린다. + syncCurveBar(); + } + } + + /** 재기 모드 — 켜면 그냥 눌러도 재진다(Shift 는 지름길로 남긴다, ㉓). */ + let measureMode = false; + measureButton.addEventListener("click", () => { + measureMode = !measureMode; + measureButton.classList.toggle("is-active", measureMode); + if (!measureMode) measure.clear(); + }); + overlay.querySelector(".b05-routeedit__measure-close")!.addEventListener("click", () => { + measure.clear(); // 닫으면 잰 것이 지워진다(㉔). + measureMode = false; + measureButton.classList.remove("is-active"); + syncMeasureBox(); + draw(); + }); let view: ViewState = { width: 0, height: 0, @@ -153,6 +192,15 @@ export async function openRouteEditModal( mapRect: computeMapRect(null, 0, 0), }; let closed = false; + /** 지도 회전 — 단추 배선과 좌표 되돌리기는 `_Rotate` 몫(계획서 0-9 ⑯). */ + const rotation = createMapRotation({ + overlay, + size: () => view, + onChange: () => { + syncCurveBar(); // 떠 있는 패널도 돌아간 노드 옆으로 따라가야 한다. + draw(); + }, + }); const close = (): void => { closed = true; @@ -199,111 +247,42 @@ export async function openRouteEditModal( return [meta.x_min + (px - x0) / (sx || 1), meta.y_min + (py - y0) / (sy || 1)]; } - function strokePolyline(points: Vertex[], dash: number[], color: string, width: number): void { - if (points.length < 2) return; - context.save(); - context.setLineDash(dash); - context.strokeStyle = color; - context.lineWidth = width; - context.beginPath(); - points.forEach((vertex, index) => { - const [x, y] = toScreen(vertex); - if (index === 0) context.moveTo(x, y); - else context.lineTo(x, y); - }); - context.stroke(); - context.restore(); + /** 화면 1m 당 픽셀 — 측점 라벨 솎기 단계를 여기서 정한다. `metricToScreen` 이 선형이라 + * 100m 떨어진 두 점으로 잰다(1m 로 재면 반올림 오차가 그대로 비율에 실린다). */ + function pxPerMeter(): number { + if (!meta) return 1; + const [x0] = metricToScreen(meta, view, meta.x_min, meta.y_min); + const [x1] = metricToScreen(meta, view, meta.x_min + 100, meta.y_min); + return Math.abs(x1 - x0) / 100; } + /** 지금 화면에 낼 등고선 간격(m) — 그리기와 집기가 같은 값을 보게 한 자리에서 셈한다. */ + const contourStepM = (): number => + contours ? pickLevelStep(contours.layer, view, contours.intervalM) : 0; + function draw(): void { if (closed) return; - const style = getComputedStyle(document.documentElement); - context.clearRect(0, 0, view.width, view.height); - context.fillStyle = style.getPropertyValue("--color-surface") || "#111"; - context.fillRect(0, 0, view.width, view.height); - - context.save(); - // 등고선은 **노선 둘레 300m 안**에서만 그린다 — 노선과 상관없는 산줄기까지 다 그리면 - // 화면이 등고선으로 덮여 노선이 안 보인다(2026-09-07 사용자 지시 ⑥). - const band = meta - ? contourBandRect(plannedLine.length ? plannedLine : planned, toScreen, CONTOUR_BAND_M) - : null; - if (band) { - context.beginPath(); - 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 sheets) drawPreparedLayer(context, layer, view, "dot"); - context.restore(); - - strokePolyline( + drawRouteEditScene(context, { + view, + toScreen, + pxPerMeter: pxPerMeter(), + hasMeta: meta !== null, + contours, + otherSheets, + pickedContour, + contourStepM: contourStepM(), + rotationRad: rotation.radians(), + uprightRad: rotation.uprightRad(), + measure: measure.marks(), expected, - [6, 5], - style.getPropertyValue("--color-text-secondary") || "#9ca3af", - 1.6, - ); - // 선은 **폴리라인**(원호 포함)을 그리고, 잡는 동그라미는 **노드**에만 찍는다. - // 노드를 옮기는 동안에는 폴리라인이 없으므로 노드를 곧바로 이어 미리 보인다. - strokePolyline( - plannedLine.length ? plannedLine : planned, - [], - style.getPropertyValue("--map-route") || "#f97316", - 2.4, - ); - - context.save(); - context.fillStyle = style.getPropertyValue("--map-route") || "#f97316"; - context.strokeStyle = style.getPropertyValue("--map-halo") || "rgba(255,255,255,0.9)"; - context.lineWidth = 1; - planned.forEach((vertex, index) => { - const [x, y] = toScreen(vertex); - // 법정 기준을 못 맞춘 자리는 붉게 — 막지는 않고 보이기만 한다(2026-09-06 사용자 확정). - const bad = (nodeInfo[index]?.violations?.length ?? 0) > 0; - context.fillStyle = bad - ? style.getPropertyValue("--color-danger") || "#dc2626" - : style.getPropertyValue("--map-route") || "#f97316"; - context.beginPath(); - context.arc(x, y, index === picked ? NODE_R + 2 : NODE_R, 0, Math.PI * 2); - context.fill(); - context.stroke(); - // 곡선을 지운 자리는 가운데를 비워 「여기는 곡선이 없다」를 보인다. - if (curveOn.length && !curveOn[index] && index > 0 && index < planned.length - 1) { - context.save(); - context.fillStyle = style.getPropertyValue("--map-halo") || "rgba(255,255,255,0.9)"; - context.beginPath(); - context.arc(x, y, NODE_R - 2, 0, Math.PI * 2); - context.fill(); - context.restore(); - } + plannedLine, + planned, + nodeInfo, + curveInfo, + curveOn, + picked, + stationIntervalM, }); - - // 곡선 시작·끝점 — 잡아서 직선 각도와 R 을 함께 바꾸는 손잡이(2026-09-07 사용자 지시). - // **속을 비우고 테두리를 굵게** 그린다 — 선·노드와 색이 같으면 눈에도 안 띄고 집기도 어렵다. - context.lineWidth = 2; - curveInfo.forEach((curve) => { - // **늘 보인다**(2026-09-07 사용자 지시) — 직선이 곡선에 닿는 자리는 손잡이이기 이전에 - // **읽을 정보**다. 한때 고른 곡선만 내보였더니 「표기가 다 사라졌다」는 지적을 받았다. - // 노드를 못 집던 문제는 집기 우선순위(노드가 먼저)로 따로 풀었으므로 다 내놓아도 된다. - const on = curveOn[curve.node_first] !== false; - if (!on) return; // 곡선을 지운 자리에는 접선점도 없다. - // 고른 곡선은 속을 채워 도드라지게 — 지금 끌 수 있는 것이 무엇인지 보이게. - const isPicked = curve.node_first === picked; - [curve.start, curve.end].forEach((point) => { - const [x, y] = toScreen([point[0], point[1]]); - context.beginPath(); - const size = isPicked ? CURVE_HANDLE_PX + 1 : CURVE_HANDLE_PX; - context.rect(x - size, y - size, size * 2, size * 2); - context.fillStyle = isPicked - ? style.getPropertyValue("--map-route") || "#f97316" - : style.getPropertyValue("--map-halo") || "rgba(255,255,255,0.95)"; - context.fill(); - context.strokeStyle = style.getPropertyValue("--map-route") || "#f97316"; - context.stroke(); - }); - }); - context.restore(); // 라벨은 **그린 뒤** 자리를 맞춘다 — 확대·이동·창 크기가 바뀌어도 고른 노드에 붙어 있게. syncCurveBar(); } @@ -346,12 +325,28 @@ export async function openRouteEditModal( function markEdited(): void { // 길이를 붙든 자리는 교각이 바뀌었을 수 있다 — 그리기 전에 R 부터 다시 잡는다. applyArcLocks(planned, curveLock, curveArc, curveRadius); + // 지정해 둔 값이 하한을 밑돌면 하한까지 끌어올린다(계획서 0-9 ④). + applyCurveLimits(planned, curveOn, curveRadius, limitRadiusM); const built = buildEditedPolyline(planned, curveOn, curveRadius, minRadiusM); plannedLine = built.vertices; curveInfo = built.curves; nodeInfo = built.nodes; } + /** 상태줄 머리 — 지금 그려진 계획노선 길이와 노드 수(계획서 0-9 ①). 원호가 정점으로 + * 펴져 있어 브라우저에서 바로 잴 수 있다 — 서버에 묻지 않는다. */ + const routeHead = (): string => + `예상노선 ${polylineLengthM(expected).toFixed(1)}m · ` + + `계획노선 ${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({ @@ -365,84 +360,37 @@ export async function openRouteEditModal( fresh: nodeInfo.length === 0, }); - // ── 곡선 라벨 — 고른 꺾임점 옆(곡선 중심 반대쪽)에 뜬다. 그리기는 `_Label` 몫 ── - const curveLabelBox = createCurveLabel({ - onRadius: (value) => { - if (picked < 0) return; - curveRadius[picked] = value; - // 반지름만 바꾼 것이라 노드 자리는 그대로지만, 그려진 선은 낡았다. - applyEdit(value === null ? "반지름을 자동으로 되돌렸습니다." : "반지름을 바꿨습니다."); - }, - onArcLength: (value) => { - if (picked < 0) return; - // 곡선 길이 L 과 반지름 R 은 L = R·Δ 로 묶여 있다(Δ = 교각, 앞뒤 직선이 정함). - // 그래서 길이를 받으면 반지름으로 바꿔 **한 값만** 들고 간다 — 두 벌로 두면 어긋난다. - const deflection = deflectionRad(nodeInfo[picked]?.inner_angle_deg); - curveArc[picked] = value; - curveRadius[picked] = value !== null && deflection > 1e-9 ? value / deflection : null; - applyEdit(value === null ? "반지름을 자동으로 되돌렸습니다." : "곡선 길이를 바꿨습니다."); - }, - onLock: (lock) => { - if (picked < 0) return; - curveLock[picked] = lock; - const deflection = deflectionRad(nodeInfo[picked]?.inner_angle_deg); - const shown = curveRadius[picked] ?? nodeInfo[picked]?.radius_m ?? null; - // 길이를 붙들려면 지금 길이를 적어 둬야 한다 — 뒤에 교각이 바뀌면 이 값으로 R 을 다시 잡는다. - if (lock === "arc") { - curveArc[picked] = shown !== null && deflection > 1e-9 ? shown * deflection : null; - } - // R 을 붙들 때 칸이 비어 있으면 지금 그려진 R 을 적어 둔다(자동 상태를 그대로 못 박음). - if (lock === "radius" && curveRadius[picked] === null) curveRadius[picked] = shown; - applyEdit( - lock === "radius" - ? "반지름을 고정했습니다." - : lock === "arc" - ? "곡선 길이를 고정했습니다." - : "고정을 풀었습니다.", - ); - }, - onCurveOn: (on) => { - if (picked < 0) return; - curveOn[picked] = on; - applyEdit(on ? "곡선을 넣었습니다." : "곡선을 지웠습니다."); + // ── 곡선 라벨 — 고른 꺾임점 옆에 뜨는 조작 패널. 배선은 `_CurveBar` 몫 ── + const curveBar = createCurveBar({ + canvas, + state: () => ({ + picked, + planned, + nodeInfo, + curveInfo, + curveOn, + curveRadius, + curveLock, + curveArc, + limitRadiusM, + limitArcM, + }), + toScreen: (vertex) => rotation.rerotate(...toScreen(vertex)), + applyEdit: (message) => applyEdit(message), + onUnselect: () => { + picked = -1; + syncCurveBar(); + draw(); }, }); - - /** 고른 자리에 맞춰 라벨을 옮겨 그린다. 끝점은 곡선이 없으므로 라벨을 숨긴다. */ - function syncCurveBar(): void { - if (!(picked > 0 && picked < planned.length - 1)) { - curveLabelBox.hide(); - return; - } - const pickedCurve = curveInfo.find((entry) => entry.node_first === picked); - const shown = curveRadius[picked] ?? pickedCurve?.radius_m ?? null; - const deflection = deflectionRad(nodeInfo[picked]?.inner_angle_deg); - const rect = canvas.getBoundingClientRect(); - const [screenX, screenY] = toScreen(planned[picked]); - curveLabelBox.show({ - seat: picked, - // 패널은 `position: fixed` 라 **화면 좌표**로 넘긴다 — 모달 밖으로 넘어가도 안 잘린다. - at: [screenX + rect.left, screenY + rect.top], - centerDirection: pickedCurve - ? centerDirectionOf( - toScreen([pickedCurve.apex[0], pickedCurve.apex[1]]), - toScreen(pickedCurve.start), - toScreen(pickedCurve.end), - ) - : null, - curveOn: curveOn[picked] !== false, - radiusShown: shown, - arcLengthShown: shown === null || deflection <= 1e-9 ? null : shown * deflection, - lock: curveLock[picked] ?? null, - innerAngleDeg: nodeInfo[picked]?.inner_angle_deg ?? null, - }); - } + const curveLabelBox = curveBar.label; + const syncCurveBar = curveBar.sync; /** 한 번의 편집을 마무리한다 — 다시 그리고, 라벨·상태줄을 맞추고, 되돌리기에 쌓는다. */ function applyEdit(message: string, record = true): void { markEdited(); syncCurveBar(); - status.textContent = `노드 ${planned.length}개 — ${message} ${curveHint()}`; + status.textContent = `${routeHead()} — ${message} ${curveHint()}`; draw(); if (record) history?.commit(snapshotNow()); historyControls.sync(); @@ -479,8 +427,12 @@ export async function openRouteEditModal( canvas.addEventListener("pointerdown", (event) => { if (event.button !== 0) return; const rect = canvas.getBoundingClientRect(); - const px = event.clientX - rect.left; - const py = event.clientY - rect.top; + const [px, py] = rotation.unrotate(event.clientX - rect.left, event.clientY - rect.top); + if (event.shiftKey || measureMode) { + // 구간 재기가 먼저다 — 노드 위에서도 재려는 뜻으로 본다(계획서 0-9 ⑤). + void measure.pick(px, py); + return; + } // **노드가 손잡이보다 먼저다**(2026-09-07 사용자 지적 ④). 반대로 두었더니 헤어핀처럼 // 곡선이 몰린 데서는 손잡이가 늘 먼저 잡혀 **노드를 아예 못 집었다**(실화면에서 격자로 // 훑어 보니 잡히는 것이 전부 손잡이였음). 손잡이는 고른 곡선에만 나오므로 겹침도 적다. @@ -489,27 +441,67 @@ export async function openRouteEditModal( dragMoved = false; if (dragNode >= 0) { picked = dragNode; // 누른 자리를 고른다 — R 라벨이 그 곡선을 만진다. + measure.clear(); // 잰 창과 곡선 패널은 같이 뜨지 않는다(㉔). syncCurveBar(); draw(); } else if (dragHandle) { picked = dragHandle.node; + measure.clear(); syncCurveBar(); draw(); + } else if ( + // 노드도 손잡이도 아니면 **고른 꺾임점을 푼다**(계획서 0-9 ㉖) — 고른 자리를 벗어나 + // 눌렀는데 패널이 그대로 떠 있으면 무엇을 만지고 있는지 헷갈린다. + ((): boolean => { + if (picked >= 0) { + picked = -1; + syncCurveBar(); + } + return false; + })() + ) { + /* 여기로는 안 온다 — 위 갈래는 선택만 풀고 다음 갈래로 넘긴다. */ + } else if ( + // 측점 눈금을 누르면 그 측점 횡단을 따로 띄운다(계획서 0-9 ⑧). 노드·손잡이 다음이다. + (() => { + const chainage = stationAtScreen( + plannedLine.length ? plannedLine : planned, + toScreen, + stationIntervalM, + px, + py, + STATION_HIT_PX, + ); + if (chainage === null) return false; + void crossPreview.open(chainage); + return true; + })() + ) { + /* 횡단 창이 떴다 — 더 집지 않는다. */ + } 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); }); canvas.addEventListener("pointermove", (event) => { const rect = canvas.getBoundingClientRect(); - const px = event.clientX - rect.left; - const py = event.clientY - rect.top; + const [px, py] = rotation.unrotate(event.clientX - rect.left, event.clientY - rect.top); if (dragHandle) { // 곡선 시작·끝점을 끈다 — 그쪽 직선 각도와 반지름이 함께 바뀐다(2026-09-07 사용자 확정). const node = dragHandle.node; const moved = dragHandleTo(node, dragHandle.end, toMetric(px, py)); if (moved) { planned[node] = moved.apex; - curveRadius[node] = Math.round(moved.radius * 100) / 100; + // 손으로 끌어도 하한 아래로는 안 내려간다 — 거기서 멈춘다(계획서 0-9 ④). + curveRadius[node] = Math.max(limitRadiusM, Math.round(moved.radius * 100) / 100); curveOn[node] = true; picked = node; // 손잡이 자리는 다시 셈한 곡선에서 나온다 — 접선 자리가 모자라 R 이 눌리면 손이 @@ -517,18 +509,32 @@ export async function openRouteEditModal( dragMoved = true; markEdited(); syncCurveBar(); - status.textContent = `노드 ${planned.length}개 — 곡선을 잡는 중. ${curveHint()}`; + status.textContent = `${routeHead()} — 곡선을 잡는 중. ${curveHint()}`; draw(); } return; } if (dragNode >= 0) { + // 옮기기 **전**에 하한을 지키던 자리 — 이미 밑돌던 자리는 그대로 고칠 수 있어야 하므로 + // **지키던 자리가 넘어가는 것만** 막는다(계획서 0-9 ④). + const before = curveShortfalls(nodeInfo, limitRadiusM); + const previous = planned[dragNode]; dragMoved = true; planned[dragNode] = toMetric(px, py); markEdited(); // 곡선을 그 자리에서 다시 그린다 — 나머지 곡선은 그대로 남는다. + if (shortfallCrossed(before, curveShortfalls(nodeInfo, limitRadiusM))) { + // 접선 자리가 모자라 R 이 하한 아래로 눌리는 자리다 — 그 걸음만 되돌린다. + planned[dragNode] = previous; + markEdited(); + status.textContent = + `${routeHead()} — 하한에 걸려 더 못 옮깁니다` + + `(곡선반지름 ${limitRadiusM}m${limitArcM > 0 ? ` · 곡선 길이 ${limitArcM}m` : ""}).`; + draw(); + return; + } // 끄는 동안에도 상태줄이 살아 있어야 한다 — 예전에는 여기서 아무 말이 없어 // 「곡선이 사라졌다」는 인상만 남았다(2026-09-07 사용자 지적 ②). - status.textContent = `노드 ${planned.length}개 — 옮기는 중. ${curveHint()}`; + status.textContent = `${routeHead()} — 옮기는 중. ${curveHint()}`; draw(); return; } @@ -542,6 +548,9 @@ export async function openRouteEditModal( if (dragMoved) { history?.commit(snapshotNow()); historyControls.sync(); + // 노선이 바뀌었다 — 보던 측점 횡단을 다시 셈해 **전후로** 늘어놓는다(계획서 0-9 ⑲). + // 끄는 동안에는 한 번도 안 부른다(한 장에 0.7초). + void crossPreview.refresh(); } dragNode = -1; dragHandle = null; @@ -552,8 +561,7 @@ export async function openRouteEditModal( canvas.addEventListener("dblclick", (event) => { const rect = canvas.getBoundingClientRect(); - const px = event.clientX - rect.left; - const py = event.clientY - rect.top; + const [px, py] = rotation.unrotate(event.clientX - rect.left, event.clientY - rect.top); const segment = segmentAt(px, py); if (segment < 0) return; planned.splice(segment + 1, 0, toMetric(px, py)); @@ -569,7 +577,7 @@ export async function openRouteEditModal( canvas.addEventListener("contextmenu", (event) => { event.preventDefault(); const rect = canvas.getBoundingClientRect(); - const index = nodeAt(event.clientX - rect.left, event.clientY - rect.top); + const index = nodeAt(...rotation.unrotate(event.clientX - rect.left, event.clientY - rect.top)); if (index < 0) return; if (planned.length <= 2) { showToast("노선은 노드가 2개 이상이어야 합니다.", "error"); @@ -592,57 +600,18 @@ export async function openRouteEditModal( view = next; }, getMeta: () => meta, + // 돌린 지도에서는 손이 민 방향과 그림이 움직일 방향이 다르다 — 거꾸로 돌려 넘긴다. + unrotateDelta: rotation.unrotateDelta, draw, }); - async function runHeavy(label: string, task: () => Promise): Promise { - busy.hidden = false; - // ⚠ 「몇 분」은 옛 값이었다 — 0-11 로 **약 90초**가 됐다(2026-09-09 실측 네 번: - // 87.3 · 90.0 · 93.9 · 95.4초). 중간 취소를 안 만드는 대신, **얼마나 지났는지**를 - // 보여 사람이 멈춘 것인지 도는 것인지 알 수 있게 한다(계획서 0-2). - const message = busy.querySelector("span")!; - const started = Date.now(); - const tick = (): void => { - const seconds = Math.round((Date.now() - started) / 1000); - message.textContent = `${label} — 배수유역부터 다시 계산 중입니다. 1분 반쯤 걸립니다 (${seconds}초 지남).`; - }; - tick(); - const timer = window.setInterval(tick, 1000); - try { - await task(); - // 노선이 바뀌면 세션 초안·조회 캐시는 옛 노선 것이라 남기지 않는다(PLAN 0-7 확정 5). - clearDrafts(projectId); - clearResults(projectId); - showToast("노선을 다시 계산했습니다.", "success"); - close(); - await onApplied(); - } catch (error) { - busy.hidden = true; - showToast(error instanceof Error ? error.message : "노선 재계산에 실패했습니다.", "error"); - } finally { - window.clearInterval(timer); // 성공·실패·닫힘 어느 쪽이든 멈춘다 - } - } - - overlay.querySelector('[data-act="apply"]')!.addEventListener("click", () => { - if (planned.length < 2) { - showToast("노선은 노드가 2개 이상이어야 합니다.", "error"); - return; - } - void runHeavy("계획노선 반영", () => - replanRoute( - projectId, - planned.map(([x, y], index) => ({ - x, - y, - curve: curveOn[index] !== false, - radius_m: curveRadius[index] ?? null, - })), - ), - ); - }); - overlay.querySelector('[data-act="reset"]')!.addEventListener("click", () => { - void runHeavy("예상노선으로 되돌리기", () => resetRoutePlan(projectId)); + bindRouteApply({ + overlay, + busy, + projectId, + nodes: () => ({ planned, curveOn, curveRadius }), + close, + onApplied, }); // ── 자료 읽기 — 노선 두 벌 + 등고선 도엽(배수유역도와 같은 것) ── @@ -658,6 +627,8 @@ export async function openRouteEditModal( // (2026-09-06 사용자 지시: 노드를 제어해 계획노선을 고친다). const nodes = plan.nodes ?? []; minRadiusM = plan.min_radius_m ?? 0; + limitRadiusM = plan.limit_radius_m ?? 0; + limitArcM = plan.limit_curve_length_m ?? 0; // 곡선 성분을 편집할 수 있는 꼴로 편다 — 셈은 `_Edits` 몫(까닭도 그쪽에 적었다). const flat = flattenServerPlan(nodes, plan.curves ?? []); planned = flat.planned; @@ -675,9 +646,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]); @@ -694,7 +679,8 @@ export async function openRouteEditModal( ); view = { ...view, ...fitted }; status.textContent = - `노드 ${planned.length}개 · ${plan.edited ? "고친 계획노선" : "초기 폴리라인"} · ` + + `${routeHead()} · ${plan.edited ? "고친 계획노선" : "초기 폴리라인"} · ` + + `${contours?.source === "las" ? "LAS 등고선" : "도엽 등고선"} · ` + curveHint(); draw(); } catch (error) { diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts new file mode 100644 index 00000000..853cb56d --- /dev/null +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts @@ -0,0 +1,81 @@ +/* ============================================================================= + * B05_Profile_UI_RouteEdit_Apply.ts + * 계획노선 편집 모달의 **[확인]·[예상노선으로]** — 무거운 재계산과 대기 표시. + * + * 누르면 서버가 배수유역부터 종·횡단·유토곡선까지 전 단계를 다시 돈다(약 90초). 중간 취소는 + * 만들지 않기로 했으므로(계획서 0-2, 2026-09-09) **얼마나 지났는지**를 초로 보여 사람이 + * 멈춘 것인지 도는 것인지 알 수 있게 한다. + * ========================================================================== */ + +import { clearDrafts, clearResults } from "../A00_Common/b_page_state"; +import { showToast } from "@ui/ui_template_elements"; +import { replanRoute, resetRoutePlan } from "./B05_Profile_Api_Replan"; + +type Vertex = [number, number]; + +export interface RouteApplyParams { + overlay: HTMLElement; + /** 화면 전체를 덮는 대기 막. 안에 `` 한 개가 글을 받는다. */ + busy: HTMLElement; + projectId: string; + /** 지금 편집값 — 누른 순간에 읽는다. */ + nodes: () => { planned: Vertex[]; curveOn: boolean[]; curveRadius: Array }; + /** 성공하면 모달을 닫고 화면을 다시 읽는다. */ + close: () => void; + onApplied: () => void | Promise; +} + +/** [확인]·[예상노선으로]를 붙인다. 리스너는 모달과 수명이 같다. */ +export function bindRouteApply(params: RouteApplyParams): void { + const { overlay, busy, projectId } = params; + + async function runHeavy(label: string, task: () => Promise): Promise { + busy.hidden = false; + // ⚠ 「몇 분」은 옛 값이었다 — 0-11 로 **약 90초**가 됐다(2026-09-09 실측 네 번: + // 87.3 · 90.0 · 93.9 · 95.4초). + const message = busy.querySelector("span")!; + const started = Date.now(); + const tick = (): void => { + const seconds = Math.round((Date.now() - started) / 1000); + message.textContent = `${label} — 배수유역부터 다시 계산 중입니다. 1분 반쯤 걸립니다 (${seconds}초 지남).`; + }; + tick(); + const timer = window.setInterval(tick, 1000); + try { + await task(); + // 노선이 바뀌면 세션 초안·조회 캐시는 옛 노선 것이라 남기지 않는다(PLAN 0-7 확정 5). + clearDrafts(projectId); + clearResults(projectId); + showToast("노선을 다시 계산했습니다.", "success"); + params.close(); + await params.onApplied(); + } catch (error) { + busy.hidden = true; + showToast(error instanceof Error ? error.message : "노선 재계산에 실패했습니다.", "error"); + } finally { + window.clearInterval(timer); // 성공·실패·닫힘 어느 쪽이든 멈춘다 + } + } + + overlay.querySelector('[data-act="apply"]')!.addEventListener("click", () => { + const { planned, curveOn, curveRadius } = params.nodes(); + if (planned.length < 2) { + showToast("노선은 노드가 2개 이상이어야 합니다.", "error"); + return; + } + void runHeavy("계획노선 반영", () => + replanRoute( + projectId, + planned.map(([x, y], index) => ({ + x, + y, + curve: curveOn[index] !== false, + radius_m: curveRadius[index] ?? null, + })), + ), + ); + }); + overlay.querySelector('[data-act="reset"]')!.addEventListener("click", () => { + void runHeavy("예상노선으로 되돌리기", () => resetRoutePlan(projectId)); + }); +} diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts new file mode 100644 index 00000000..11db2f50 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts @@ -0,0 +1,98 @@ +/* ============================================================================= + * B05_Profile_UI_RouteEdit_Chrome.ts + * 계획노선 편집 모달의 **뼈대** — 창·단추·오버레이 판을 만들고 자주 쓰는 요소를 집어 준다. + * + * `B05_Profile_UI_RouteEdit.ts` 가 700줄을 넘겨 떼어낸 조각이다(2026-09-12). 생김새만 있고 + * 동작은 없다 — 배선은 본체와 각 조각(`_Apply`·`_Rotate`·`_History`…)이 한다. + * + * **배치**(2026-09-12 사용자 지시 ⑩~⑬·⑱) — 아래 정보행을 없애고 지도 위 오버레이로 옮겼다. + * 단추는 제목행 오른쪽, 조작 설명은 지도 왼쪽 위 2열, 상태·범례는 왼쪽 아래, 잰 값은 오른쪽 + * 아래. 메인 창은 왼쪽으로 밀고 오른쪽 세로 칸에 횡단 두 판이 앉는다. + * ========================================================================== */ + +/** 회전 단추 아이콘 — **반만 도는 화살표**(2026-09-12 사용자 지시 ㉙). 한 바퀴를 다 그린 + * 기호(`↺`·`↻`)는 「한 바퀴 돈다」로 읽혀 한 칸씩 도는 동작과 안 맞았다. */ +const HALF_TURN_ICON = { + ccw: ``, + cw: ``, +}; + +export interface RouteEditChrome { + overlay: HTMLElement; + canvas: HTMLCanvasElement; + status: HTMLElement; + busy: HTMLElement; + measureBox: HTMLElement; + measureText: HTMLElement; + measureButton: HTMLButtonElement; +} + +/** 모달을 만들어 `document.body` 에 붙이고, 자주 쓰는 요소를 집어 돌려준다. */ +export function createRouteEditChrome(): RouteEditChrome { + const overlay = document.createElement("div"); + overlay.className = "b05-routeedit"; + overlay.innerHTML = ` + +
`; + document.body.append(overlay); + + const canvas = overlay.querySelector(".b05-routeedit__canvas")!; + const status = overlay.querySelector(".b05-routeedit__status")!; + const measureBox = overlay.querySelector(".b05-routeedit__measure")!; + const measureText = overlay.querySelector(".b05-routeedit__measure-text")!; + const measureButton = overlay.querySelector('[data-act="measure"]')!; + const busy = overlay.querySelector(".b05-routeedit__busy")!; + + return { overlay, canvas, status, busy, measureBox, measureText, measureButton }; +} 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..d60e6372 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Contour.ts @@ -0,0 +1,111 @@ +/* ============================================================================= + * 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; +/** LAS 등고선을 **몇 m 단위로** 낼지 — 도엽 등고선과 같은 눈금(2026-09-12 사용자 지시 ⑭). + * + * LAS 자료는 1m 간격으로 뽑혀 있어 그대로 쓰면 확대할 때 1m·2m 짜리까지 나온다. 서버에 5m + * 로 다시 뽑아 달라고 하면 첫 한 번이 오래 걸리므로 **받아 둔 1m 자료에서 5의 배수만 고른다** + * — 그림도 라벨도 도엽 쪽과 같은 눈금이 된다. */ +const LAS_CONTOUR_UNIT_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) { + // 받아 오는 간격은 프로젝트 설정 그대로(보관함에 이미 있는 파일을 쓰려는 것) — + // **보이는 눈금**은 아래에서 5m 로 맞춘다. + 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 ?? []) + // 5m 단위만 남긴다 — 1m 자료를 다 들고 있으면 그리기·집기가 다섯 배로 무겁다. + .filter((contour) => Math.abs(contour.level % LAS_CONTOUR_UNIT_M) < 1e-6) + .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: LAS_CONTOUR_UNIT_M, + 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_Cross.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Cross.ts new file mode 100644 index 00000000..f990ada5 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Cross.ts @@ -0,0 +1,131 @@ +/* ============================================================================= + * B05_Profile_UI_RouteEdit_Cross.ts + * 계획노선 편집 중 **측점 횡단** — 메인 창 오른쪽 세로 칸에 **붙박이 두 판**(계획서 0-9 ⑱⑲). + * + * · 위 판 = **지금 횡단**. 측점 눈금을 누르면 그 측점을 셈해 여기에 낸다. + * · 아래 판 = **이전 횡단**. 평소에는 빈 화면이고, 노선을 고쳐 **노드를 놓는 순간** + * 위 판의 것이 이리로 내려오고 새로 셈한 것이 위로 올라간다 — 전후를 나란히 본다. + * + * 보이는 것은 셋뿐이다(2026-09-12 사용자 확정) — **원지반 횡단선 · 기본 계획 횡단선 · + * 계획 횡단의 성토사면 길이**. 구조물은 그리지 않는다. + * + * ⚠ **계획고는 편집 중에 없다** — [확인] 뒤 전 체인이 낳는 값이다. 그래서 서버가 그 측점의 + * 지반고를 그대로 계획고로 놓고(지반 추종) 사면만 세운 「기본 계획 횡단」을 낸다. + * + * 셈은 **B05·B06 정본을 그대로 재사용**한다 — 측점·지반 샘플은 `generate_sections`, 설계선은 + * `compute_cross_design`(서버), 성토사면 길이는 B06 화면이 쓰는 `fillSlopeLengths`. + * ========================================================================== */ + +import { fetchCrossPreview, type CrossPreviewResponse } from "./B05_Profile_Api_Replan"; +import { drawCross, summarizeCross } from "./B05_Profile_UI_RouteEdit_Cross_Draw"; +import { formatStation } from "./B05_Profile_Util_Station"; + +export interface CrossPreviewParams { + projectId: string; + /** 두 판이 들어앉을 오른쪽 세로 칸. */ + side: HTMLElement; + /** 지금 편집값 — 셈을 부르는 순간에 읽는다. */ + request: () => { + vertices: Array<{ x: number; y: number; curve: boolean; radius_m: number | null }>; + min_radius_m: number; + station_interval_m: number; + }; +} + +export interface CrossPreviewWindow { + /** 그 측점의 횡단을 위 판에 낸다. 같은 측점을 다시 누르면 보던 것을 아래로 내린다. */ + open: (chainageM: number) => Promise; + /** 노선을 고쳤다 — 보던 측점을 **다시 셈해** 전후로 늘어놓는다. 보던 것이 없으면 아무 일도 없다. */ + refresh: () => Promise; +} + +interface CrossPane { + root: HTMLElement; + /** 셈해 온 횡단을 그린다. `null` 이면 빈 화면으로 되돌린다. */ + show: (preview: CrossPreviewResponse | null, intervalM: number) => void; + /** 기다리는 중임을 알린다. */ + wait: (text: string) => void; +} + +function createPane(title: string, empty: string): CrossPane { + const root = document.createElement("section"); + root.className = "b05-routeedit__cross"; + root.innerHTML = ` +
+ ${title} + +
+ +
${empty}
`; + const station = root.querySelector(".b05-routeedit__cross-station")!; + const foot = root.querySelector(".b05-routeedit__cross-foot")!; + const canvas = root.querySelector(".b05-routeedit__cross-canvas")!; + const context = canvas.getContext("2d")!; + return { + root, + show(preview, intervalM) { + context.clearRect(0, 0, canvas.width, canvas.height); + if (!preview) { + station.textContent = ""; + foot.textContent = empty; + return; + } + // 측점은 **누가거리가 아니라 측점 표기**로 낸다(계획서 0-9 ㉑) — B05 왼쪽 아래 구조물 + // 목록이 쓰는 그 규칙이다. 서버가 주는 `STA.0+100.000` 을 그대로 쓰면 표기가 갈린다. + station.textContent = formatStation(preview.chainage_m, intervalM); + drawCross(context, canvas, preview); + foot.textContent = summarizeCross(preview); + }, + wait(text) { + context.clearRect(0, 0, canvas.width, canvas.height); + foot.textContent = text; + }, + }; +} + +export function createCrossPreview(params: CrossPreviewParams): CrossPreviewWindow { + const current = createPane("횡단", "측점 눈금을 누르면 그 측점 횡단이 뜹니다."); + const previous = createPane("이전 횡단", "노선을 고치면 고치기 전 횡단이 여기 남습니다."); + params.side.append(current.root, previous.root); + + /** 지금 보고 있는 측점(누가거리). 아직 없으면 null. */ + let watching: number | null = null; + /** 위 판에 그려 둔 것 — 다음 번에 아래로 내릴 재료. */ + let shown: CrossPreviewResponse | null = null; + /** 지금 부른 셈 — 늦게 온 응답을 새 자리에 적지 않으려고 든다. */ + let ticket = 0; + + async function load(chainageM: number, keepPrevious: boolean): Promise { + const mine = ++ticket; + const request = params.request(); + if (keepPrevious && shown) previous.show(shown, request.station_interval_m); + current.wait("읽는 중…"); + try { + const preview = await fetchCrossPreview(params.projectId, { + ...request, + chainage_m: chainageM, + }); + if (mine !== ticket) return; // 그 사이 다른 측점을 눌렀다. + shown = preview; + current.show(preview, request.station_interval_m); + } catch (error) { + if (mine !== ticket) return; + shown = null; + current.wait(error instanceof Error ? error.message : "횡단을 읽지 못했습니다."); + } + } + + return { + async open(chainageM) { + // 다른 측점을 고른 것이라면 전후 비교가 아니다 — 아래 판을 비운다. + const sameStation = watching !== null && Math.abs(watching - chainageM) < 1e-6; + if (!sameStation) previous.show(null, params.request().station_interval_m); + watching = chainageM; + await load(chainageM, sameStation); + }, + async refresh() { + if (watching === null) return; + await load(watching, true); + }, + }; +} diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Cross_Draw.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Cross_Draw.ts new file mode 100644 index 00000000..825e2aa1 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Cross_Draw.ts @@ -0,0 +1,120 @@ +/* ============================================================================= + * B05_Profile_UI_RouteEdit_Cross_Draw.ts + * 횡단 한 장을 캔버스에 그린다 — **원지반선·기본 계획 횡단선**과 아래 한 줄 요약. + * + * `B05_Profile_UI_RouteEdit_Cross.ts` 에서 떼어낸 조각이다(2026-09-12, 700줄 규정). + * 값은 서버가 B05·B06 정본으로 낸 것을 그대로 그린다 — 여기서 기하를 만들지 않는다. + * ========================================================================== */ + +import type { CrossSection } from "./../B06_Section/B06_Section_Api_Fetch"; +import { fillSlopeLengths } from "./../B06_Section/B06_Section_UI_Cross_Fit"; +import type { CrossPreviewResponse } from "./B05_Profile_Api_Replan"; + +/** 그림 가장자리 여백(px). */ +const PAD = 24; + +/** 성토사면 길이·절성토 면적 한 줄. */ +export function summarizeCross(preview: CrossPreviewResponse): string { + const design = preview.design; + if (!design) return "계획고를 못 세워 계획 횡단을 그리지 못했습니다."; + // 성토사면 길이는 **B06 화면이 쓰는 그 함수**를 그대로 부른다 — 두 화면이 다른 길이를 + // 말하면 안 된다. 필요한 것은 `samples` 와 `design` 둘뿐이라 그만 담아 넘긴다. + const lengths = fillSlopeLengths({ + samples: preview.samples, + design, + } as unknown as CrossSection); + const sides = (["left", "right"] as const) + .filter((side) => lengths[side] !== null) + .map((side) => { + const value = lengths[side]!; + // 계산 반폭 안에서 원지반을 못 만난 사면은 거기까지만 잰 하한값이라 「≥」로 구분한다. + return `${side === "left" ? "좌" : "우"} ${value.open ? "≥" : ""}${value.lengthM.toFixed(2)}m`; + }); + const slope = sides.length ? `성토사면 ${sides.join(" · ")}` : "성토측 없음"; + return `${slope} · 절토 ${design.cut_area_m2.toFixed(2)}㎡ · 성토 ${design.fill_area_m2.toFixed(2)}㎡`; +} + +/** + * 원지반선과 기본 계획 횡단선을 한 판에 그린다. 좌(+offset)가 화면 왼쪽이다 + * (`generate_sections` cad_exchange 규약과 같은 방향). + * + * **가로·세로를 같은 배율로** 둔다 — 따로 늘리면 사면 기울기가 거짓으로 보인다. 횡단도는 + * 기울기를 눈으로 읽는 그림이라 왜곡하면 안 된다(2026-09-12 실화면: 노면이 안 보였다). + */ +export function drawCross( + context: CanvasRenderingContext2D, + canvas: HTMLCanvasElement, + preview: CrossPreviewResponse, +): void { + const ground = preview.samples + .filter((sample) => sample.valid && sample.elevation_m !== null) + .map((sample) => [Number(sample.offset_m), Number(sample.elevation_m)] as [number, number]); + const design = (preview.design?.design_line ?? []).map( + (point) => [point.offset_m, point.elevation_m] as [number, number], + ); + const all = [...ground, ...design]; + context.clearRect(0, 0, canvas.width, canvas.height); + if (all.length < 2) return; + + const offsets = all.map((point) => point[0]); + const heights = all.map((point) => point[1]); + const minOffset = Math.min(...offsets); + const maxOffset = Math.max(...offsets); + const minZ = Math.min(...heights); + const maxZ = Math.max(...heights); + const spanX = maxOffset - minOffset || 1; + const spanZ = maxZ - minZ || 1; + const scale = Math.min((canvas.width - PAD * 2) / spanX, (canvas.height - PAD * 2) / spanZ); + const centerOffset = (minOffset + maxOffset) / 2; + const centerZ = (minZ + maxZ) / 2; + const toScreen = (point: [number, number]): [number, number] => [ + canvas.width / 2 + (centerOffset - point[0]) * scale, + canvas.height / 2 + (centerZ - point[1]) * scale, + ]; + + const stroke = (points: Array<[number, number]>, color: string, width: number): void => { + if (points.length < 2) return; + context.beginPath(); + points.forEach((point, index) => { + const [x, y] = toScreen(point); + if (index === 0) context.moveTo(x, y); + else context.lineTo(x, y); + }); + context.strokeStyle = color; + context.lineWidth = width; + context.stroke(); + }; + + // 중심선 — 어디가 노선 가운데인지 먼저 보이게. + const [centerX] = toScreen([0, centerZ]); + context.save(); + context.setLineDash([4, 4]); + context.strokeStyle = "rgba(148,163,184,0.7)"; + context.lineWidth = 1; + context.beginPath(); + context.moveTo(centerX, PAD / 2); + context.lineTo(centerX, canvas.height - PAD / 2); + context.stroke(); + context.restore(); + + stroke(ground, "#94a3b8", 1.6); // 원지반 + stroke(design, "#f97316", 2.2); // 기본 계획 횡단 + + context.font = "11px system-ui, sans-serif"; + context.textBaseline = "top"; + context.fillStyle = "#94a3b8"; + context.textAlign = "left"; + context.fillText("원지반", PAD, 4); + context.fillStyle = "#f97316"; + context.textAlign = "right"; + context.fillText("기본 계획 횡단", canvas.width - PAD, 4); + context.fillStyle = "#94a3b8"; + context.textAlign = "center"; + context.textBaseline = "bottom"; + context.fillText( + `좌 ${maxOffset.toFixed(0)}m ← 중심 → 우 ${Math.abs(minOffset).toFixed(0)}m` + + ` · 표고 ${minZ.toFixed(1)}~${maxZ.toFixed(1)}m`, + canvas.width / 2, + canvas.height - 2, + ); +} diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_CurveBar.ts b/B05_Profile/B05_Profile_UI_RouteEdit_CurveBar.ts new file mode 100644 index 00000000..5e4ec28c --- /dev/null +++ b/B05_Profile/B05_Profile_UI_RouteEdit_CurveBar.ts @@ -0,0 +1,156 @@ +/* ============================================================================= + * B05_Profile_UI_RouteEdit_CurveBar.ts + * 곡선 조작 패널의 **배선** — 어느 꺾임점을 만질지 정하고, 칸에서 들어온 값을 편집값에 + * 옮겨 적는다. 패널을 그리고 자리를 잡는 일은 `_Label` 몫이다. + * + * `B05_Profile_UI_RouteEdit.ts` 가 700줄을 넘겨 떼어낸 조각이다(2026-09-12). 본문 로직과 + * 수치는 그대로이고, 모달 클로저가 쥐고 있던 값만 `state()` 로 받는다. + * + * **R 과 곡선 길이는 한 쌍**(L = R·Δ) — 어느 쪽으로 들어와도 **반지름 한 값**으로 바꿔 + * 들고 간다. 두 벌로 두면 교각이 바뀔 때 서로 어긋난다(`_Edits.ts` 설명 참고). + * ========================================================================== */ + +import type { EditedCurve, EditedNode, Vertex } from "./B05_Profile_UI_RouteEdit_Curve"; +import type { CurveLock } from "./B05_Profile_UI_RouteEdit_Edits"; +import { + centerDirectionOf, + createCurveLabel, + deflectionRad, + type CurveLabel, +} from "./B05_Profile_UI_RouteEdit_Label"; + +/** 패널이 만지는 편집값 한 벌 — 모달이 쥔 배열을 그대로 건네받는다. */ +export interface CurveBarState { + picked: number; + planned: Vertex[]; + nodeInfo: EditedNode[]; + curveInfo: EditedCurve[]; + curveOn: boolean[]; + curveRadius: Array; + curveLock: CurveLock[]; + curveArc: Array; + /** 못 넘는 하한(m). 0이면 제한 없음(계획서 0-9 ④). */ + limitRadiusM: number; + limitArcM: number; +} + +export interface CurveBarParams { + canvas: HTMLCanvasElement; + state: () => CurveBarState; + /** 그리기 좌표로 옮긴다. 돌린 지도에서는 **돌린 뒤 자리**를 줘야 패널이 노드 옆에 붙는다. */ + toScreen: (vertex: Vertex) => [number, number]; + /** 한 번의 편집을 마무리한다 — 다시 그리고 되돌리기에 쌓는다. */ + applyEdit: (message: string) => void; + /** 고른 꺾임점을 푼다 — 닫기 단추와 「빈 곳 누르기」가 부른다(계획서 0-9 ㉖). */ + onUnselect: () => void; +} + +export interface CurveBar { + label: CurveLabel; + /** 고른 자리에 맞춰 패널을 옮겨 그린다. */ + sync: () => void; +} + +export function createCurveBar(params: CurveBarParams): CurveBar { + const label = createCurveLabel({ + onClose: () => params.onUnselect(), + onRadius: (value) => { + const { picked, curveRadius } = params.state(); + if (picked < 0) return; + curveRadius[picked] = value; + // 반지름만 바꾼 것이라 노드 자리는 그대로지만, 그려진 선은 낡았다. + params.applyEdit(value === null ? "반지름을 자동으로 되돌렸습니다." : "반지름을 바꿨습니다."); + }, + onArcLength: (value) => { + const { picked, nodeInfo, curveArc, curveRadius } = params.state(); + if (picked < 0) return; + // 곡선 길이 L 과 반지름 R 은 L = R·Δ 로 묶여 있다(Δ = 교각, 앞뒤 직선이 정함). + // 그래서 길이를 받으면 반지름으로 바꿔 **한 값만** 들고 간다 — 두 벌로 두면 어긋난다. + const deflection = deflectionRad(nodeInfo[picked]?.inner_angle_deg); + curveArc[picked] = value; + curveRadius[picked] = value !== null && deflection > 1e-9 ? value / deflection : null; + params.applyEdit( + value === null ? "반지름을 자동으로 되돌렸습니다." : "곡선 길이를 바꿨습니다.", + ); + }, + onLock: (lock) => { + const { picked, nodeInfo, curveArc, curveRadius, curveLock } = params.state(); + if (picked < 0) return; + curveLock[picked] = lock; + const deflection = deflectionRad(nodeInfo[picked]?.inner_angle_deg); + const shown = curveRadius[picked] ?? nodeInfo[picked]?.radius_m ?? null; + // 길이를 붙들려면 지금 길이를 적어 둬야 한다 — 뒤에 교각이 바뀌면 이 값으로 R 을 다시 잡는다. + if (lock === "arc") { + curveArc[picked] = shown !== null && deflection > 1e-9 ? shown * deflection : null; + } + // R 을 붙들 때 칸이 비어 있으면 지금 그려진 R 을 적어 둔다(자동 상태를 그대로 못 박음). + if (lock === "radius" && curveRadius[picked] === null) curveRadius[picked] = shown; + params.applyEdit( + lock === "radius" + ? "반지름을 고정했습니다." + : lock === "arc" + ? "곡선 길이를 고정했습니다." + : "고정을 풀었습니다.", + ); + }, + onCurveOn: (on) => { + const { picked, curveOn } = params.state(); + if (picked < 0) return; + curveOn[picked] = on; + params.applyEdit(on ? "곡선을 넣었습니다." : "곡선을 지웠습니다."); + }, + }); + + /** 고른 자리에 맞춰 라벨을 옮겨 그린다. 끝점은 곡선이 없으므로 라벨을 숨긴다. */ + function sync(): void { + const { + picked, + planned, + nodeInfo, + curveInfo, + curveOn, + curveRadius, + curveLock, + limitRadiusM, + limitArcM, + } = params.state(); + if (!(picked > 0 && picked < planned.length - 1)) { + label.hide(); + return; + } + const pickedCurve = curveInfo.find((entry) => entry.node_first === picked); + const shown = curveRadius[picked] ?? pickedCurve?.radius_m ?? null; + const deflection = deflectionRad(nodeInfo[picked]?.inner_angle_deg); + const rect = params.canvas.getBoundingClientRect(); + const [screenX, screenY] = params.toScreen(planned[picked]); + label.show({ + seat: picked, + // 패널은 `position: fixed` 라 **화면 좌표**로 넘긴다. + at: [screenX + rect.left, screenY + rect.top], + // 넘어가도 되는 테두리 = **지도 칸**(하단 정보행 위까지). 밖으로 나가면 지금 무엇을 + // 고치는지 모달 안에서 안 보인다(2026-09-12 사용자 지적 ⑨). + bounds: { + left: rect.left + 8, + top: rect.top + 8, + right: rect.right - 8, + bottom: rect.bottom - 8, + }, + centerDirection: pickedCurve + ? centerDirectionOf( + params.toScreen([pickedCurve.apex[0], pickedCurve.apex[1]]), + params.toScreen(pickedCurve.start), + params.toScreen(pickedCurve.end), + ) + : null, + curveOn: curveOn[picked] !== false, + radiusShown: shown, + arcLengthShown: shown === null || deflection <= 1e-9 ? null : shown * deflection, + lock: curveLock[picked] ?? null, + innerAngleDeg: nodeInfo[picked]?.inner_angle_deg ?? null, + limitRadiusM, + limitArcM, + }); + } + + return { label, sync }; +} diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Edits.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Edits.ts index a21e6987..1ff96453 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Edits.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Edits.ts @@ -48,6 +48,67 @@ export function applyArcLocks( } } +/** + * **반지름 하한을 지키도록 지정값을 끌어올린다**(계획서 0-9 ④, 2026-09-12 사용자 확정). + * + * **비워 둔(자동) 자리는 건드리지 않는다** — 자동은 이미 기본 반지름을 쓰고 있고, 여기서 + * 값을 적어 넣으면 아무것도 안 고쳤는데 「R 지정」이 늘어난다. + * + * ⚠ **곡선 길이(L) 하한은 여기서 안 건다.** L = R·Δ 라, 내각이 179° 처럼 거의 곧은 자리는 + * L 5m 를 채우려면 R 이 286m 로 부풀어 **아무것도 안 고쳤는데 노선이 바뀐다**(2026-09-12 + * 실화면: 길이 1017.5m → 1017.2m). 별표2 도 내각 155° 이상은 곡선을 안 둘 수 있다고 한다. + * L 하한은 **사용자가 칸에 적을 때만** 막고(`_Label`), 패널에 「L ≥ 5m」으로 보이기만 한다. + */ +export function applyCurveLimits( + planned: Vertex[], + curveOn: ReadonlyArray, + curveRadius: Array, + limitRadiusM: number, +): void { + if (limitRadiusM <= 0) return; + for (let seat = 1; seat < planned.length - 1; seat += 1) { + if (curveOn[seat] === false) continue; + const current = curveRadius[seat]; + if (current === null || current === undefined) continue; + if (current < limitRadiusM) curveRadius[seat] = limitRadiusM; + } +} + +/** + * 노드마다 **반지름 하한**을 얼마나 밑돌고 있나(m). 다 지키고 있으면 0. + * + * 곡선 길이(L) 하한은 여기서도 안 본다 — 까닭은 `applyCurveLimits` 설명에 있다. 거의 곧은 + * 자리에서 L 을 채우라고 막으면 **노드를 한 뼘도 못 옮긴다**. + * + * 노드를 옮기면 접선 자리가 모자라 그리기 단계에서 R 이 눌릴 수 있다. 그 눌림까지 막으려면 + * 옮기기 자체를 되돌려야 하므로, **옮기기 전보다 나빠진 자리가 있는지**만 견준다 — 이미 + * 하한을 밑돌던 옛 노선도 그대로 고칠 수 있어야 하기 때문이다(2026-09-12). + * + * ⚠ 가장 큰 값 하나로 견주면 안 된다. 크게 밑도는 자리가 이미 있으면 **다른 자리가 새로 + * 무너져도 최댓값이 안 움직여** 그냥 통과한다(실화면에서 「기준 미달 1곳 → 2곳」이 그대로 + * 지나갔다). 자리마다 따로 견준다. + */ +export function curveShortfalls(nodes: ReadonlyArray, limitRadiusM: number): number[] { + return nodes.map((node) => { + if (node.radius_m === null || limitRadiusM <= 0) return 0; + return Math.max(0, limitRadiusM - node.radius_m); + }); +} + +/** + * 하한을 지키던 자리가 **이번 걸음에 처음으로 무너졌나**. 자리 수가 달라지면(넣기·지우기) + * 안 따진다. + * + * ⚠ 「조금이라도 나빠졌으면 막기」로 두면 **이미 하한을 밑돌던 옛 노선을 아예 못 고친다** — + * 그 옆 노드를 1px 만 건드려도 밑돌던 값이 미세하게 더 내려가 첫 걸음부터 막혔다(2026-09-12 + * 실화면). 이미 무너진 자리는 그대로 두고(붉은 표시는 남는다), **지키고 있던 자리가 넘어가는 + * 것만** 막는다. + */ +export function shortfallCrossed(before: readonly number[], after: readonly number[]): boolean { + if (before.length !== after.length) return false; + return after.some((value, index) => value > 1e-6 && before[index] <= 1e-6); +} + export interface CurveSummaryInput { nodeCount: number; curveOn: boolean[]; diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Input.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Input.ts index 89555c43..e4ab7f24 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Input.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Input.ts @@ -29,12 +29,16 @@ export interface RouteEditNavigationParams { getView: () => ViewState; setView: (next: ViewState) => void; getMeta: () => VWorldMeta | null; + /** 돌린 지도 보정 — 화면에서 민 만큼(dx, dy)을 **그림 좌표의 만큼**으로 바꾼다. + * 안 주면 안 돌린 것으로 본다(배수유역도는 회전이 없다). */ + unrotateDelta?: (dx: number, dy: number) => [number, number]; draw: () => void; } /** 캔버스에 휠 확대·가운데 버튼 팬을 붙인다. 리스너는 캔버스와 수명이 같다. */ export function bindRouteEditNavigation(params: RouteEditNavigationParams): void { const { canvas, getView, setView, getMeta, draw } = params; + const unrotateDelta = params.unrotateDelta ?? ((dx: number, dy: number) => [dx, dy]); let dragStart: { x: number; y: number; offsetX: number; offsetY: number } | null = null; canvas.addEventListener( @@ -53,8 +57,10 @@ export function bindRouteEditNavigation(params: RouteEditNavigationParams): void const ratio = scale / view.scale; const rect = canvas.getBoundingClientRect(); // 커서 자리를 **화면 중심 기준**으로 잡는다 — 그래야 그 지점이 제자리에 남는다. - const cursorX = event.clientX - rect.left - rect.width / 2; - const cursorY = event.clientY - rect.top - rect.height / 2; + const [cursorX, cursorY] = unrotateDelta( + event.clientX - rect.left - rect.width / 2, + event.clientY - rect.top - rect.height / 2, + ); setView({ ...view, scale, @@ -84,11 +90,8 @@ export function bindRouteEditNavigation(params: RouteEditNavigationParams): void canvas.addEventListener("pointermove", (event) => { if (!dragStart) return; - setView({ - ...getView(), - offsetX: dragStart.offsetX + event.clientX - dragStart.x, - offsetY: dragStart.offsetY + event.clientY - dragStart.y, - }); + const [dx, dy] = unrotateDelta(event.clientX - dragStart.x, event.clientY - dragStart.y); + setView({ ...getView(), offsetX: dragStart.offsetX + dx, offsetY: dragStart.offsetY + dy }); draw(); }); @@ -181,6 +184,98 @@ export function segmentAtScreen( return best; } +/** 노선 위 한 점 — 어디를 짚었나와 그 자리의 누가거리. */ +export interface RoutePointHit { + /** 사업지 좌표(m). */ + point: [number, number]; + /** 시점에서 노선을 따라간 거리(m). */ + chainageM: number; +} + +/** + * 노선(그려지는 폴리라인) 위에서 **클릭에 가장 가까운 점**과 그 누가거리. 멀면 null. + * + * 직선·곡선을 가리지 않는다(계획서 0-9 ⑤) — 원호도 이미 정점으로 펴져 있어 같은 선분 훑기로 + * 잡힌다. 누가거리는 선분 길이를 누적해 구하므로 노선 길이 표기와 같은 값을 본다. + */ +export function routePointAtScreen( + line: Array<[number, number]>, + toScreen: ScreenOf, + px: number, + py: number, + maxPx: number, +): RoutePointHit | null { + let best: RoutePointHit | null = null; + let bestDistance = maxPx; + let travelled = 0; + for (let index = 0; index < line.length - 1; index += 1) { + const from = line[index]; + const to = line[index + 1]; + const segmentM = Math.hypot(to[0] - from[0], to[1] - from[1]); + const [ax, ay] = toScreen(from); + const [bx, by] = toScreen(to); + const dx = bx - ax; + const dy = by - ay; + const lengthSquared = dx * dx + dy * dy || 1; + const t = Math.max(0, Math.min(1, ((px - ax) * dx + (py - ay) * dy) / lengthSquared)); + const distance = Math.hypot(ax + t * dx - px, ay + t * dy - py); + if (distance < bestDistance) { + bestDistance = distance; + best = { + point: [from[0] + (to[0] - from[0]) * t, from[1] + (to[1] - from[1]) * t], + chainageM: travelled + segmentM * t, + }; + } + travelled += segmentM; + } + return best; +} + +/** + * 클릭에 가장 가까운 **규칙 측점**의 누가거리(m). 그만큼 안에 없으면 null(계획서 0-9 ⑧). + * + * 눈금을 그리는 `drawStationTicks` 와 **같은 자리**를 짚는다 — 선분 길이를 누적해 측점 간격 + * 마다 한 점씩 보간한다. 눈금이 보이는 자리를 눌렀는데 안 잡히면 안 되기 때문이다. + */ +export function stationAtScreen( + line: Array<[number, number]>, + toScreen: ScreenOf, + intervalM: number, + px: number, + py: number, + maxPx: number, +): number | null { + if (line.length < 2 || !(intervalM > 0)) return null; + const cumulative: number[] = [0]; + for (let index = 1; index < line.length; index += 1) { + cumulative.push( + cumulative[index - 1] + + Math.hypot(line[index][0] - line[index - 1][0], line[index][1] - line[index - 1][1]), + ); + } + const total = cumulative[cumulative.length - 1]; + let best: number | null = null; + let bestDistance = maxPx; + let cursor = 1; + for (let chainage = 0; chainage <= total; chainage += intervalM) { + while (cursor < cumulative.length - 1 && cumulative[cursor] < chainage) cursor += 1; + const back = line[cursor - 1]; + const front = line[cursor]; + const segment = cumulative[cursor] - cumulative[cursor - 1] || 1; + const ratio = Math.min(1, Math.max(0, (chainage - cumulative[cursor - 1]) / segment)); + const [x, y] = toScreen([ + back[0] + (front[0] - back[0]) * ratio, + back[1] + (front[1] - back[1]) * ratio, + ]); + const distance = Math.hypot(x - px, y - py); + if (distance < bestDistance) { + bestDistance = distance; + best = chainage; + } + } + return best; +} + /** 등고선을 보일 화면 사각형 — 노선 경계에 `bandM` 를 두른 것. 노선이 없으면 null. * * **매 프레임 다시 잰다** — 창 크기·배율·이동이 바뀌어도 띠가 노선을 따라간다. 띠 자체는 diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Label.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Label.ts index b1a37a79..0f0703b1 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Label.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Label.ts @@ -11,7 +11,10 @@ * * **자리**(2026-09-07 사용자 지시) * · 몸통은 `document.body` 에 `position: fixed` 로 띄운다 — 모달이 `overflow: hidden` 이라 - * 안에 두면 가장자리에서 **잘린다**. 화면 밖으로도 넘어갈 수 있어야 한다. + * 안에 두면 가장자리에서 **잘린다**. + * · 다만 **지도 칸 밖으로는 안 나간다**(2026-09-12 사용자 지적 ⑨) — 상자 밖이나 하단 + * 정보행 위로 넘어가면 지금 무엇을 고치는지 모달 안에서 안 보인다. 「잘리지 않게」와 + * 「상자 밖으로 나가게」는 다른 문제여서 자리 계산에서만 가둔다. * · 자동 자리는 **곡선 중심의 반대쪽**, **16방위**로 잡는다(4방위는 대각 자리에서 곡선을 물었다). * · 머리를 잡아 **손으로 옮길 수 있다**. 옮긴 자리는 그 꺾임점을 보는 동안 유지되고, * 다른 꺾임점을 고르면 자동 자리로 돌아간다. @@ -60,15 +63,22 @@ export interface CurveLabelState { at: [number, number]; /** **곡선 중심이 있는 쪽**(화면 기준 방향벡터). 패널은 이 반대쪽에 붙는다. */ centerDirection: [number, number] | null; + /** 패널이 넘어가면 안 되는 테두리(화면 좌표) — 보통 모달의 지도 칸. 없으면 안 가둔다. */ + bounds?: { left: number; top: number; right: number; bottom: number }; curveOn: boolean; radiusShown: number | null; /** 곡선 길이(m) = R·Δ. 곡선이 없으면 null. */ arcLengthShown: number | null; lock: CurveLock; innerAngleDeg: number | null; + /** **못 넘는** 반지름·곡선 길이 하한(m). 0이면 제한 없음(계획서 0-9 ④). */ + limitRadiusM?: number; + limitArcM?: number; } export interface CurveLabelHandlers { + /** 닫기 단추 — 고른 꺾임점을 푼다(계획서 0-9 ㉖). */ + onClose: () => void; onRadius: (value: number | null) => void; onArcLength: (value: number | null) => void; onCurveOn: (on: boolean) => void; @@ -106,6 +116,8 @@ export function createCurveLabel(handlers: CurveLabelHandlers): CurveLabel {
+
- - 칸을 비우면 자동`; + `; document.body.append(root); const head = root.querySelector(".b05-routeedit__label-head")!; @@ -143,14 +154,29 @@ export function createCurveLabel(handlers: CurveLabelHandlers): CurveLabel { /** 손으로 옮긴 자리 — 꺾임점 기준 어긋남(px). 다른 꺾임점을 고르면 지운다. */ let manual: [number, number] | null = null; let anchor: [number, number] = [0, 0]; + /** 지금 자리의 하한 — 칸이 여기서 멈춘다. 0이면 제한 없음. */ + let limitRadius = 0; + let limitArc = 0; + /** 마지막으로 받은 테두리 — 손으로 끌 때도 같은 자리를 지키려고 들고 있는다. */ + let limit: CurveLabelState["bounds"]; - const numberOf = (input: HTMLInputElement): number | null => { + /** 칸에서 읽은 값. **하한 아래는 하한에서 멈추고, 멈춘 값을 칸에 되적어 보인다** + * (2026-09-12 사용자 확정 「아예 못 넘게 막음」) — 조용히 바꾸면 왜 안 먹었는지 모른다. */ + const numberOf = (input: HTMLInputElement, floor: number): number | null => { const value = Number(input.value); - return input.value.trim() !== "" && Number.isFinite(value) && value > 0 ? value : null; + if (input.value.trim() === "" || !Number.isFinite(value) || value <= 0) return null; + if (floor > 0 && value < floor) { + input.value = String(floor); + return floor; + } + return value; }; - radius.addEventListener("change", () => handlers.onRadius(numberOf(radius))); - arc.addEventListener("change", () => handlers.onArcLength(numberOf(arc))); + radius.addEventListener("change", () => handlers.onRadius(numberOf(radius, limitRadius))); + arc.addEventListener("change", () => handlers.onArcLength(numberOf(arc, limitArc))); toggle.addEventListener("click", () => handlers.onCurveOn(!curveOn)); + root + .querySelector('[data-act="curve-close"]')! + .addEventListener("click", () => handlers.onClose()); lockRadius.addEventListener("click", () => handlers.onLock(lock === "radius" ? null : "radius")); lockArc.addEventListener("click", () => handlers.onLock(lock === "arc" ? null : "arc")); @@ -169,8 +195,14 @@ export function createCurveLabel(handlers: CurveLabelHandlers): CurveLabel { }); head.addEventListener("pointermove", (event) => { if (!dragFrom) return; - const left = dragFrom.left + event.clientX - dragFrom.x; - const top = dragFrom.top + event.clientY - dragFrom.y; + // 끄는 동안에도 테두리를 지킨다 — 놓은 뒤에만 가두면 손이 간 자리에서 패널이 튄다. + const [left, top] = clamp( + dragFrom.left + event.clientX - dragFrom.x, + dragFrom.top + event.clientY - dragFrom.y, + root.offsetWidth, + root.offsetHeight, + limit, + ); root.style.left = `${Math.round(left)}px`; root.style.top = `${Math.round(top)}px`; // 꺾임점 기준으로 기억한다 — 지도를 옮기거나 확대해도 같은 자리에 따라온다. @@ -183,7 +215,8 @@ export function createCurveLabel(handlers: CurveLabelHandlers): CurveLabel { head.addEventListener("pointerup", stopDrag); head.addEventListener("pointercancel", stopDrag); - /** 자동 자리 — 곡선 중심의 반대쪽, 16방위. 손으로 옮겼으면 그 어긋남을 얹는다. */ + /** 자동 자리 — 곡선 중심의 반대쪽, 16방위. 손으로 옮겼으면 그 어긋남을 얹는다. + * 마지막에 **테두리 안으로 가둔다** — 자동 자리든 손으로 옮긴 자리든 같이 갇힌다. */ function place(state: CurveLabelState): void { const width = root.offsetWidth; const height = root.offsetHeight; @@ -193,12 +226,32 @@ export function createCurveLabel(handlers: CurveLabelHandlers): CurveLabel { : ([1, 0] as [number, number]); const distance = GAP_PX + boxReach(away[0], away[1], width, height); anchor = [nx + away[0] * distance - width / 2, ny + away[1] * distance - height / 2]; - const left = anchor[0] + (manual ? manual[0] : 0); - const top = anchor[1] + (manual ? manual[1] : 0); + const [left, top] = clamp( + anchor[0] + (manual ? manual[0] : 0), + anchor[1] + (manual ? manual[1] : 0), + width, + height, + state.bounds, + ); root.style.left = `${Math.round(left)}px`; root.style.top = `${Math.round(top)}px`; } + /** 테두리 안으로 민다. 패널이 테두리보다 크면 왼쪽·위를 맞춰 **머리가 먼저 보이게** 한다. */ + function clamp( + left: number, + top: number, + width: number, + height: number, + bounds: CurveLabelState["bounds"], + ): [number, number] { + if (!bounds) return [left, top]; + return [ + Math.max(bounds.left, Math.min(left, bounds.right - width)), + Math.max(bounds.top, Math.min(top, bounds.bottom - height)), + ]; + } + return { show(state) { if (state.seat !== seat) { @@ -207,6 +260,12 @@ export function createCurveLabel(handlers: CurveLabelHandlers): CurveLabel { } curveOn = state.curveOn; lock = state.lock; + limit = state.bounds; + limitRadius = state.limitRadiusM ?? 0; + limitArc = state.limitArcM ?? 0; + // 칸 자체에도 하한을 박아 화살표·스피너가 그 아래로 안 내려가게 한다. + radius.min = limitRadius > 0 ? String(limitRadius) : "1"; + arc.min = limitArc > 0 ? String(limitArc) : "1"; root.hidden = false; seatText.textContent = `${state.seat + 1}번째 꺾임점`; toggle.textContent = state.curveOn ? "곡선 지우기" : "곡선 넣기"; @@ -221,10 +280,12 @@ export function createCurveLabel(handlers: CurveLabelHandlers): CurveLabel { arc.value = state.arcLengthShown === null ? "" : String(Math.round(state.arcLengthShown * 10) / 10); const inner = state.innerAngleDeg; - const held = - lock === "radius" ? "반지름 고정" : lock === "arc" ? "곡선 길이 고정" : "고정 없음"; + // 하단에는 **내각만** 남긴다(2026-09-12 사용자 지시 ㉗) — 고정 여부는 단추 색으로, + // 하한은 칸이 이미 막으므로 글로 또 적을 까닭이 없다. info.textContent = state.curveOn - ? `${held}${inner ? ` · 내각 ${Math.round(inner)}°` : ""}` + ? inner + ? `내각 ${Math.round(inner)}°` + : "" : "곡선 없음 — 직선이 그대로 꺾입니다"; place(state); // 글자가 바뀌면 상자 높이가 한 박자 늦게 자란다 — 다음 그림 직전에 한 번 더 맞춘다. diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Measure.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Measure.ts new file mode 100644 index 00000000..6ec35fb9 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Measure.ts @@ -0,0 +1,120 @@ +/* ============================================================================= + * B05_Profile_UI_RouteEdit_Measure.ts + * 계획노선 위 **두 점 사이 구간 재기** — 길이와 종단기울기(계획서 0-9 ⑤). + * + * Shift+클릭으로 a·b 를 찍는다. 직선·곡선을 가리지 않는다 — 그려지는 폴리라인 위라면 어디든 + * 짚을 수 있고, 누가거리는 노선 길이 표기와 같은 방식으로 잰다. + * + * 지반고는 **찍는 순간에만** 서버에 묻는다(`/route/elevations`). 확정된 지표면을 읽기만 하는 + * 통로라 「편집 중에는 계산이 안 나간다」(계획서 0-2 확정 7)와 부딪히지 않는다 — 다만 노드를 + * 끄는 동안에는 한 번도 부르지 않는다. + * ========================================================================== */ + +import { fetchRouteElevations } from "./B05_Profile_Api_Replan"; +import { routePointAtScreen, type RoutePointHit } from "./B05_Profile_UI_RouteEdit_Input"; +import { formatStation } from "./B05_Profile_Util_Station"; + +type Vertex = [number, number]; + +/** 구간 재기로 노선을 짚었다고 볼 거리(px). */ +const MEASURE_HIT_PX = 14; + +interface MeasurePoint extends RoutePointHit { + /** 그 자리의 지반고(m). 아직 못 물었거나 지표면 밖이면 null. */ + z: number | null; +} + +export interface MeasureToolParams { + projectId: string; + /** 규칙 측점 간격(m) — 측점 표기에 쓴다. */ + stationIntervalM: number; + /** 지금 그려지는 노선(원호 포함). 편집으로 바뀌므로 함수로 받는다. */ + line: () => Vertex[]; + toScreen: (vertex: Vertex) => [number, number]; + /** 창이 닫혔나 — 늦게 온 응답을 죽은 화면에 적지 않으려고. */ + isClosed: () => boolean; + /** 상태가 바뀌었다 — 호출부가 상태줄을 다시 적고 다시 그린다. */ + onChange: () => void; +} + +export interface MeasureMark { + point: Vertex; + /** 시점에서 노선을 따라간 거리(m) — 그리기가 **이 값으로** 구간을 자른다(계획서 0-9 ㉕). */ + chainageM: number; +} + +export interface MeasureTool { + /** 찍힌 자리(0~2개) — 그리기가 쓴다. */ + marks: () => MeasureMark[]; + /** 잰 값 한 줄. 찍은 것이 없으면 빈 문자열. */ + hint: () => string; + /** 재고 있나 — 작은 창을 띄울지 정하는 값. */ + active: () => boolean; + /** 한 번 찍기. 두 점이 차면 지반고를 한 번만 물어 온다. */ + pick: (px: number, py: number) => Promise; + /** 잰 것을 지운다 — 작은 창을 닫을 때(계획서 0-9 ㉔). */ + clear: () => void; +} + +export function createMeasureTool(params: MeasureToolParams): MeasureTool { + /** 찍은 두 점. 셋째를 찍으면 새 구간의 시작이 된다. */ + let picked: MeasurePoint[] = []; + + const hint = (): string => { + if (picked.length === 0) return ""; + const first = picked[0]; + if (picked.length === 1) { + return `구간 재기 — 시작 ${formatStation(first.chainageM, params.stationIntervalM)}. 한 점 더.`; + } + const second = picked[1]; + const span = Math.abs(second.chainageM - first.chainageM); + const head = + `구간 ${formatStation(first.chainageM, params.stationIntervalM)} → ` + + `${formatStation(second.chainageM, params.stationIntervalM)} · 길이 ${span.toFixed(1)}m`; + if (first.z === null || second.z === null || span <= 1e-6) { + return `${head} · 지반고를 못 읽어 기울기는 못 냅니다.`; + } + // 기울기는 **노선을 따라간 길이** 기준이다 — 직선거리로 나누면 곡선부에서 과대평가된다. + const rise = second.z - first.z; + return ( + `${head} · 지반고 ${first.z.toFixed(1)} → ${second.z.toFixed(1)}m` + + ` · 종단기울기 ${((rise / span) * 100).toFixed(1)}%` + ); + }; + + return { + marks: () => picked.map((entry) => ({ point: entry.point, chainageM: entry.chainageM })), + hint, + active: () => picked.length > 0, + clear() { + if (picked.length === 0) return; + picked = []; + params.onChange(); + }, + async pick(px, py) { + const hit = routePointAtScreen(params.line(), params.toScreen, px, py, MEASURE_HIT_PX); + if (!hit) { + picked = []; // 노선을 빗나가면 재던 것을 접는다. + params.onChange(); + return; + } + picked = picked.length >= 2 ? [{ ...hit, z: null }] : [...picked, { ...hit, z: null }]; + params.onChange(); + if (picked.length < 2) return; + const asked = picked; + try { + const heights = await fetchRouteElevations( + params.projectId, + asked.map((entry) => entry.point), + ); + if (params.isClosed() || picked !== asked) return; // 그 사이 다시 찍었으면 버린다. + asked.forEach((entry, index) => { + entry.z = heights[index] ?? null; + }); + } catch { + /* 지반고를 못 읽으면 길이만 낸다 — `hint` 가 그렇게 말한다. */ + } + params.onChange(); + }, + }; +} diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Render.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Render.ts new file mode 100644 index 00000000..9bfa6af0 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Render.ts @@ -0,0 +1,442 @@ +/* ============================================================================= + * B05_Profile_UI_RouteEdit_Render.ts + * 계획노선 편집 모달의 **그리기** — 등고선·예상노선·계획노선·노드·곡선 손잡이, + * 그 위에 시점·종점·규칙측점 눈금. + * + * `B05_Profile_UI_RouteEdit.ts` 가 700줄을 넘겨 떼어낸 조각이다(2026-09-12). 본문 로직과 + * 수치는 그대로이고, 모달 클로저가 쥐고 있던 값만 `scene` 으로 받는다. + * + * 측점 눈금은 **B04 지도·배수유역도와 같은 한 곳**(`drawStationTicks`)을 부른다 — 표기가 + * 화면마다 갈리면 같은 자리를 두 이름으로 부르게 된다(계획서 0-9 ②). + * ========================================================================== */ + +import { + drawPreparedFeature, + drawPreparedLabels, + drawPreparedLayer, + layerScreenBounds, + normalizedToScreen, + 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"; +import { formatStation } from "./B05_Profile_Util_Station"; + +/** 노드 반지름(px). */ +const NODE_R = 4; +/** 등고선을 보일 **노선 둘레 띠**(m) — 사용자 지시 ⑥(2026-09-07). + * + * 노선에서 이만큼 밖의 등고선은 안 그린다. **창 크기와 무관한 고정 띠**라 창을 늘리거나 + * 줄여도 띠가 흔들리지 않는다(사용자가 「다이나믹 창이라 조심」이라 한 자리). 화면 밖을 + * 걸러내는 일은 `drawPreparedLayer` 가 이미 하므로 여기서는 띠만 덧씌운다. */ +const CONTOUR_BAND_M = 300; +/** 곡선 시작·끝점 손잡이 크기(px) — 노드 동그라미와 구별되게 **속 빈 네모**로 그린다. + * 처음엔 3.5px 였는데 선과 색이 같아 눈에도 안 띄고 집기도 어려웠다(2026-09-07 실화면). */ +const CURVE_HANDLE_PX = 5; +/** 시점·종점 이름표를 끝점에서 **노선 바깥으로** 밀어내는 거리(px). */ +const OUTWARD_PX = 26; +/** 구간 재기 표시 색 — 노선(주황)·등고선(연보라)·고른 등고선(보라)과 겹치지 않는 초록. */ +const MEASURE_COLOR = "#22c55e"; + +/** 노선을 따라간 길이(m) — 원호가 이미 정점으로 펴져 있어 정점 간 거리의 합이 곧 길이다. */ +export function polylineLengthM(points: ReadonlyArray): number { + let total = 0; + for (let index = 1; index < points.length; index += 1) { + total += Math.hypot( + points[index][0] - points[index - 1][0], + points[index][1] - points[index - 1][1], + ); + } + return total; +} + +export interface RouteEditScene { + view: ViewState; + /** 사업지 좌표(m) → 캔버스 px. */ + toScreen: (vertex: Vertex) => [number, number]; + /** 화면 1m 당 픽셀 — 측점 라벨 솎기 단계를 이 값으로 정한다. */ + pxPerMeter: number; + /** 도엽 메타를 읽었나 — 못 읽었으면 등고선 띠를 씌우지 않는다. */ + hasMeta: boolean; + /** 바탕 등고선 한 벌 — LAS 것이거나 도엽 것(`_Contour` 가 고른다). */ + contours: RouteEditContours | null; + /** 등고선 말고 함께 깔 도엽 레이어(하천중심선). */ + otherSheets: ReadonlyArray; + /** 고른 등고선 가닥 — 없으면 -1(계획서 0-9 ⑦). */ + pickedContour: number; + /** 지금 화면에 낼 등고선 간격(m) — 그리기와 집기가 **같은 값**을 봐야 한다. */ + contourStepM: number; + expected: ReadonlyArray; + /** 그려 보이는 계획노선(원호 포함). */ + plannedLine: ReadonlyArray; + /** 잡아 옮기는 노드(꺾임점). */ + planned: ReadonlyArray; + nodeInfo: ReadonlyArray; + curveInfo: ReadonlyArray; + curveOn: ReadonlyArray; + /** 지금 고른 꺾임점. 없으면 -1. */ + picked: number; + /** 규칙 측점 간격(m). */ + stationIntervalM: number; + /** 구간 재기로 찍은 점(0~2개) — 노선 위 자리와 누가거리(계획서 0-9 ⑤). */ + measure: ReadonlyArray<{ point: Vertex; chainageM: number }>; + /** 지도를 돌린 각(라디안) — 캔버스 한가운데를 축으로 **그림 전체**가 돈다(계획서 0-9 ⑯). */ + rotationRad: number; + /** 글자만 되돌려 세울 각(라디안) — 0이면 글자도 그림과 함께 돈다(계획서 0-9 ㉚). */ + uprightRad: number; +} + +/** 글자 자리는 그대로 두고 **글자만** 되돌려 세운 채로 그린다. */ +function upright( + context: CanvasRenderingContext2D, + radians: number, + x: number, + y: number, + paint: () => void, +): void { + if (!radians) { + paint(); + return; + } + context.save(); + context.translate(x, y); + context.rotate(radians); + context.translate(-x, -y); + paint(); + context.restore(); +} + +export function drawRouteEditScene(context: CanvasRenderingContext2D, scene: RouteEditScene): void { + const { view, toScreen } = scene; + const style = getComputedStyle(document.documentElement); + const line = scene.plannedLine.length ? scene.plannedLine : scene.planned; + context.clearRect(0, 0, view.width, view.height); + context.fillStyle = style.getPropertyValue("--color-surface") || "#111"; + context.fillRect(0, 0, view.width, view.height); + + // 여기서부터 **그림 전체**가 돈다 — 글자도 함께 돈다(CAD 도면과 같은 방식, 사용자 지시 ⑯). + // 바탕칠은 돌리기 **전에** 해 두었다 — 돌린 뒤에 칠하면 모서리에 빈 곳이 생긴다. + context.save(); + if (scene.rotationRad) { + context.translate(view.width / 2, view.height / 2); + context.rotate(scene.rotationRad); + context.translate(-view.width / 2, -view.height / 2); + } + + context.save(); + // 등고선은 **노선 둘레 300m 안**에서만 그린다 — 노선과 상관없는 산줄기까지 다 그리면 + // 화면이 등고선으로 덮여 노선이 안 보인다(2026-09-07 사용자 지시 ⑥). + const band = scene.hasMeta ? contourBandRect(line as Vertex[], toScreen, CONTOUR_BAND_M) : null; + if (band) { + context.beginPath(); + context.rect(band.x, band.y, band.width, band.height); + context.clip(); + } + context.strokeStyle = style.getPropertyValue("--map-sheet-stream") || "#2563eb"; + context.lineWidth = 1.2; + // 바탕이 LAS 면 세류선도 **등고선이 있는 데까지만** 그린다(2026-09-12 사용자 지시 ⑮) — + // 도엽 하천중심선은 도엽 전체를 덮어 LAS 자료 밖까지 길게 뻗는다. + const lasBox = + scene.contours?.source === "las" ? layerScreenBounds(scene.contours.layer, view) : null; + context.save(); + if (lasBox) { + context.beginPath(); + context.rect(lasBox.x, lasBox.y, lasBox.width, lasBox.height); + context.clip(); + } + for (const layer of scene.otherSheets) drawPreparedLayer(context, layer, view, "dot"); + context.restore(); + 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); + drawPickedContourLabel(context, scene, 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, + scene.uprightRad, + ); + } + context.restore(); + + strokePolyline( + context, + toScreen, + scene.expected, + [6, 5], + style.getPropertyValue("--color-text-secondary") || "#9ca3af", + 1.6, + ); + // 선은 **폴리라인**(원호 포함)을 그리고, 잡는 동그라미는 **노드**에만 찍는다. + // 노드를 옮기는 동안에는 폴리라인이 없으므로 노드를 곧바로 이어 미리 보인다. + strokePolyline( + context, + toScreen, + line, + [], + style.getPropertyValue("--map-route") || "#f97316", + 2.4, + ); + + context.save(); + context.fillStyle = style.getPropertyValue("--map-route") || "#f97316"; + context.strokeStyle = style.getPropertyValue("--map-halo") || "rgba(255,255,255,0.9)"; + context.lineWidth = 1; + scene.planned.forEach((vertex, index) => { + const [x, y] = toScreen(vertex); + // 법정 기준을 못 맞춘 자리는 붉게 — 막지는 않고 보이기만 한다(2026-09-06 사용자 확정). + const bad = (scene.nodeInfo[index]?.violations?.length ?? 0) > 0; + context.fillStyle = bad + ? style.getPropertyValue("--color-danger") || "#dc2626" + : style.getPropertyValue("--map-route") || "#f97316"; + context.beginPath(); + context.arc(x, y, index === scene.picked ? NODE_R + 2 : NODE_R, 0, Math.PI * 2); + context.fill(); + context.stroke(); + // 곡선을 지운 자리는 가운데를 비워 「여기는 곡선이 없다」를 보인다. + if ( + scene.curveOn.length && + !scene.curveOn[index] && + index > 0 && + index < scene.planned.length - 1 + ) { + context.save(); + context.fillStyle = style.getPropertyValue("--map-halo") || "rgba(255,255,255,0.9)"; + context.beginPath(); + context.arc(x, y, NODE_R - 2, 0, Math.PI * 2); + context.fill(); + context.restore(); + } + }); + + // 곡선 시작·끝점 — 잡아서 직선 각도와 R 을 함께 바꾸는 손잡이(2026-09-07 사용자 지시). + // **속을 비우고 테두리를 굵게** 그린다 — 선·노드와 색이 같으면 눈에도 안 띄고 집기도 어렵다. + context.lineWidth = 2; + scene.curveInfo.forEach((curve) => { + // **늘 보인다**(2026-09-07 사용자 지시) — 직선이 곡선에 닿는 자리는 손잡이이기 이전에 + // **읽을 정보**다. 한때 고른 곡선만 내보였더니 「표기가 다 사라졌다」는 지적을 받았다. + // 노드를 못 집던 문제는 집기 우선순위(노드가 먼저)로 따로 풀었으므로 다 내놓아도 된다. + if (scene.curveOn[curve.node_first] === false) return; // 곡선을 지운 자리에는 접선점도 없다. + // 고른 곡선은 속을 채워 도드라지게 — 지금 끌 수 있는 것이 무엇인지 보이게. + const isPicked = curve.node_first === scene.picked; + [curve.start, curve.end].forEach((point) => { + const [x, y] = toScreen([point[0], point[1]]); + context.beginPath(); + const size = isPicked ? CURVE_HANDLE_PX + 1 : CURVE_HANDLE_PX; + context.rect(x - size, y - size, size * 2, size * 2); + context.fillStyle = isPicked + ? style.getPropertyValue("--map-route") || "#f97316" + : style.getPropertyValue("--map-halo") || "rgba(255,255,255,0.95)"; + context.fill(); + context.strokeStyle = style.getPropertyValue("--map-route") || "#f97316"; + context.stroke(); + }); + }); + context.restore(); + + drawStationMarks(context, scene, line); + drawMeasureMarks(context, scene, line); + context.restore(); // 회전 끝 +} + +/** 구간 재기로 찍은 자리 — a·b 를 동그라미로 찍고 그 사이 노선을 굵게 덧그린다(계획서 0-9 ⑤). */ +function drawMeasureMarks( + context: CanvasRenderingContext2D, + scene: RouteEditScene, + line: ReadonlyArray, +): void { + if (scene.measure.length === 0) return; + context.save(); + if (scene.measure.length >= 2) { + const span = spanBetween(line, scene.measure[0], scene.measure[1]); + if (span.length >= 2) { + context.strokeStyle = MEASURE_COLOR; + context.lineWidth = 4; + context.beginPath(); + span.forEach((vertex, index) => { + const [x, y] = scene.toScreen(vertex); + if (index === 0) context.moveTo(x, y); + else context.lineTo(x, y); + }); + context.stroke(); + } + } + context.lineWidth = 2.4; + context.strokeStyle = MEASURE_COLOR; + context.font = "bold 11px system-ui, sans-serif"; + context.textAlign = "center"; + context.textBaseline = "middle"; + scene.measure.forEach((mark, index) => { + const [x, y] = scene.toScreen(mark.point); + context.fillStyle = "rgba(255,255,255,0.95)"; + context.beginPath(); + context.arc(x, y, 7, 0, Math.PI * 2); + context.fill(); + context.stroke(); + context.fillStyle = "#14532d"; + upright(context, scene.uprightRad, x, y, () => context.fillText(index === 0 ? "a" : "b", x, y)); + }); + context.restore(); +} + +/** + * 두 점 사이의 노선 조각 — **누가거리로** 자른다(계획서 0-9 ㉕). + * + * ⚠ 예전에는 **가장 가까운 정점**으로 잘랐다. 노선이 되꺾이는 자리에서는 a 옆에 b 쪽 정점이 + * 더 가까이 붙어 있어 엉뚱한 자리를 골랐고, 그 결과 초록 띠가 노선을 벗어나 **삼각형으로 + * 얽혔다**(2026-09-12 사용자 화면). 찍을 때 이미 누가거리를 알고 있으므로 그것으로 자른다. + */ +function spanBetween( + line: ReadonlyArray, + from: { point: Vertex; chainageM: number }, + to: { point: Vertex; chainageM: number }, +): Vertex[] { + const low = Math.min(from.chainageM, to.chainageM); + const high = Math.max(from.chainageM, to.chainageM); + const head = from.chainageM <= to.chainageM ? from.point : to.point; + const tail = from.chainageM <= to.chainageM ? to.point : from.point; + const inside: Vertex[] = []; + let travelled = 0; + for (let index = 1; index < line.length; index += 1) { + const step = Math.hypot( + line[index][0] - line[index - 1][0], + line[index][1] - line[index - 1][1], + ); + // 정점의 누가거리가 두 점 사이면 그대로 잇는다 — 사이에 없는 정점은 건너뛴다. + if (travelled > low && travelled < high) inside.push(line[index - 1]); + travelled += step; + } + return [head, ...inside, tail]; +} + +/** 고른 등고선의 **높이값을 크게** 붙인다(계획서 0-9 ㉘) — 색만 바뀌면 몇 m 인지 안 보인다. */ +function drawPickedContourLabel( + context: CanvasRenderingContext2D, + scene: RouteEditScene, + view: ViewState, +): void { + const feature = scene.contours?.layer.features[scene.pickedContour]; + if (!feature || feature.labelValue === null) return; + const [x, y] = normalizedToScreen(view, feature.labelAnchorX, feature.labelAnchorY); + const text = `${feature.labelValue}m`; + upright(context, scene.uprightRad, x, y, () => { + context.save(); + context.font = "bold 13px system-ui, sans-serif"; + context.textAlign = "center"; + context.textBaseline = "middle"; + const width = context.measureText(text).width + 10; + context.fillStyle = "#7c3aed"; + context.fillRect(x - width / 2, y - 9, width, 18); + context.fillStyle = "#ffffff"; + context.fillText(text, x, y); + context.restore(); + }); +} + +/** 규칙 측점 눈금·번호와 시점·종점 이름표(계획서 0-9 ②). */ +function drawStationMarks( + context: CanvasRenderingContext2D, + scene: RouteEditScene, + line: ReadonlyArray, +): void { + if (line.length < 2) return; + // 눈금은 B04 지도·배수유역도와 같은 한 곳이 그린다 — 표기가 화면마다 갈리지 않게. + drawStationTicks( + context, + line.map(([x, y]) => ({ x, y })), + { + intervalM: scene.stationIntervalM, + pxPerMeter: scene.pxPerMeter, + toScreen: (x, y) => scene.toScreen([x, y]), + uprightRad: scene.uprightRad, + }, + ); + const total = polylineLengthM(line); + const last = line.length - 1; + endLabel(context, scene, line[0], line[1], `시점 ${formatStation(0, scene.stationIntervalM)}`); + endLabel( + context, + scene, + line[last], + line[last - 1], + `종점 ${formatStation(total, scene.stationIntervalM)}`, + ); +} + +/** 시점·종점 이름표 — 측점 라벨보다 크고 짙게 찍어 양 끝을 한눈에 알게 한다. + * + * 자리는 **노선 바깥쪽**(끝점에서 노선을 등진 방향)이다. 위로만 띄웠더니 같은 자리의 측점 + * 라벨(0+0.0 · 50+0.0)과 겹쳐 두 글자가 포개졌다 — 측점 라벨은 노선에 **직각**으로 나가므로 + * 노선을 따라 밀면 서로 안 물린다(2026-09-12 실화면). */ +function endLabel( + context: CanvasRenderingContext2D, + scene: RouteEditScene, + at: Vertex, + inward: Vertex, + text: string, +): void { + const [x0, y0] = scene.toScreen(at); + const [x1, y1] = scene.toScreen(inward); + const length = Math.hypot(x0 - x1, y0 - y1) || 1; + const x = x0 + ((x0 - x1) / length) * OUTWARD_PX; + const y = y0 + ((y0 - y1) / length) * OUTWARD_PX; + context.save(); + if (scene.uprightRad) { + context.translate(x, y); + context.rotate(scene.uprightRad); + context.translate(-x, -y); + } + context.font = "bold 12px system-ui, sans-serif"; + context.textAlign = "center"; + context.textBaseline = "middle"; + const width = context.measureText(text).width + 10; + context.fillStyle = "rgba(255, 255, 255, 0.9)"; + context.fillRect(x - width / 2, y - 26, width, 17); + context.strokeStyle = "#f97316"; + context.lineWidth = 1; + context.strokeRect(x - width / 2, y - 26, width, 17); + context.fillStyle = "#111111"; + context.fillText(text, x, y - 17.5); + context.restore(); +} + +function strokePolyline( + context: CanvasRenderingContext2D, + toScreen: (vertex: Vertex) => [number, number], + points: ReadonlyArray, + dash: number[], + color: string, + width: number, +): void { + if (points.length < 2) return; + context.save(); + context.setLineDash(dash); + context.strokeStyle = color; + context.lineWidth = width; + context.beginPath(); + points.forEach((vertex, index) => { + const [x, y] = toScreen(vertex); + if (index === 0) context.moveTo(x, y); + else context.lineTo(x, y); + }); + context.stroke(); + context.restore(); +} diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Rotate.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Rotate.ts new file mode 100644 index 00000000..beca4515 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Rotate.ts @@ -0,0 +1,83 @@ +/* ============================================================================= + * B05_Profile_UI_RouteEdit_Rotate.ts + * 지도 회전 — 반시계·시계 단추 하나씩, 누를 때마다 **그림 전체**가 한 칸 돈다 + * (계획서 0-9 ⑯, 2026-09-12 사용자 지시). + * + * 축은 **캔버스 한가운데**다. 그리기는 캔버스 변환으로 한 번에 돌리고(글자도 함께 돈다 — + * CAD 도면과 같은 방식), 집기는 들어온 화면 자리를 **거꾸로 돌려** 안 돌린 좌표로 바꿔 쓴다. + * 그래야 눈에 보이는 자리와 잡히는 자리가 같다. + * ========================================================================== */ + +/** 단추 한 번에 도는 각(도) — 15°씩 스물넷이면 한 바퀴. 90°씩이면 맞출 자리가 넷뿐이고, + * 5°씩이면 한 바퀴에 일흔두 번이라 성가시다. */ +const ROTATE_STEP_DEG = 15; + +/** **글자를 눈높이로 세울지**(계획서 0-9 ㉚, 2026-09-12 사용자 지시). + * + * 그림만 돌고 숫자는 늘 바로 서게 한다 — 180° 로 돌리면 글자가 뒤집혀 안 읽히기 때문이다. + * 비용은 라벨 하나에 변환 한 번뿐이라 그림을 다시 그리는 값에 묻힌다. + * + * ⚠ **되돌리려면 이 값을 `false` 로만 바꾸면 된다** — 그러면 글자도 그림과 함께 돈다 + * (CAD 도면과 같은 방식). 사용자가 화면을 보고 판단할 수 있게 한 자리에 모아 두었다. */ +export const UPRIGHT_LABELS = true; + +export interface MapRotationParams { + /** 단추가 들어 있는 모달 — `[data-act="rotate-ccw"]`·`rotate-cw` 를 찾는다. */ + overlay: HTMLElement; + /** 지금 캔버스 크기 — 회전축(한가운데)을 잡는 데 쓴다. */ + size: () => { width: number; height: number }; + /** 각이 바뀌었다 — 호출부가 다시 그린다. */ + onChange: () => void; +} + +export interface MapRotation { + /** 지금 돌린 각(라디안). 그리기가 캔버스 변환에 그대로 쓴다. */ + radians: () => number; + /** 화면에 보이는 자리 → 그리기 좌표(돌리기 전). */ + unrotate: (px: number, py: number) => [number, number]; + /** 그리기 좌표 → 화면에 보이는 자리. 떠 있는 패널을 노드 옆에 붙일 때 쓴다. */ + rerotate: (px: number, py: number) => [number, number]; + /** 화면에서 민 만큼(dx, dy) → 그림 좌표의 만큼. 팬·휠 확대 보정용. */ + unrotateDelta: (dx: number, dy: number) => [number, number]; + /** 글자를 세울 각(라디안) — 그리기가 라벨마다 이만큼 되돌린다. 안 세우면 0. */ + uprightRad: () => number; +} + +export function createMapRotation(params: MapRotationParams): MapRotation { + let radians = 0; + + const spin = (px: number, py: number, angle: number): [number, number] => { + if (!angle) return [px, py]; + const { width, height } = params.size(); + const cx = width / 2; + const cy = height / 2; + const cos = Math.cos(angle); + const sin = Math.sin(angle); + const dx = px - cx; + const dy = py - cy; + return [cx + dx * cos - dy * sin, cy + dx * sin + dy * cos]; + }; + + for (const [act, sign] of [ + ["rotate-ccw", -1], + ["rotate-cw", 1], + ] as const) { + params.overlay.querySelector(`[data-act="${act}"]`)?.addEventListener("click", () => { + radians += (sign * ROTATE_STEP_DEG * Math.PI) / 180; + params.onChange(); + }); + } + + return { + radians: () => radians, + unrotate: (px, py) => spin(px, py, -radians), + rerotate: (px, py) => spin(px, py, radians), + uprightRad: () => (UPRIGHT_LABELS ? -radians : 0), + unrotateDelta: (dx, dy) => { + if (!radians) return [dx, dy]; + const cos = Math.cos(-radians); + const sin = Math.sin(-radians); + return [dx * cos - dy * sin, dx * sin + dy * cos]; + }, + }; +} diff --git a/B05_Profile/B05_Profile_UI_Style_RouteEdit.css b/B05_Profile/B05_Profile_UI_Style_RouteEdit.css index 789350c7..a02701a2 100644 --- a/B05_Profile/B05_Profile_UI_Style_RouteEdit.css +++ b/B05_Profile/B05_Profile_UI_Style_RouteEdit.css @@ -6,16 +6,21 @@ inset: 0; z-index: var(--z-modal, 1000); display: flex; + gap: var(--spacing-12); align-items: center; justify-content: center; + padding: var(--spacing-12); background: rgb(0 0 0 / 55%); } +/* 메인 창은 **왼쪽**, 횡단 두 판은 오른쪽 세로 칸(2026-09-12 사용자 지시 ⑱). + 좁은 화면에서는 오른쪽 칸이 접히고 메인이 폭을 다 가진다. */ .b05-routeedit__box { position: relative; display: flex; + flex: 1 1 auto; flex-direction: column; - width: min(1200px, 94vw); + max-width: 1200px; height: min(820px, 92vh); overflow: hidden; border: 1px solid var(--color-border); @@ -33,10 +38,15 @@ border-bottom: 1px solid var(--color-border); } -.b05-routeedit__hint { +/* 제목행 — 왼쪽에 이름, **오른쪽 끝에 단추 묶음**(2026-09-12 사용자 지시 ⑪). + 단추는 공용 규격(`.ui-btn`)을 그대로 쓴다 — 모달이 따로 만든 크기를 걷어냈다. */ +.b05-routeedit__actions { + display: flex; flex: 1 1 auto; - color: var(--color-text-secondary); - font-size: var(--text-caption); + flex-wrap: wrap; + align-items: center; + justify-content: flex-end; + gap: var(--spacing-8); } .b05-routeedit__close { @@ -62,27 +72,63 @@ touch-action: none; } -.b05-routeedit__foot { - display: flex; - flex: none; - align-items: center; - gap: var(--spacing-8); - padding: var(--spacing-12) var(--spacing-16); - border-top: 1px solid var(--color-border); -} - -.b05-routeedit__status { - flex: 1 1 auto; +/* 지도 위에 얹는 판들 — 아래 정보행을 없애고 여기로 옮겼다(2026-09-12 사용자 지시 ⑩·⑫·⑬). + 글판은 **클릭을 통과시킨다** — 지도 조작을 가리면 안 된다. */ +.b05-routeedit__hint, +.b05-routeedit__info { + position: absolute; + z-index: 1; + padding: var(--spacing-8) var(--spacing-12); + border: 1px solid color-mix(in srgb, var(--color-border) 65%, transparent); + border-radius: var(--radius-8, 6px); + background: color-mix(in srgb, var(--color-surface-raised) 78%, transparent); + backdrop-filter: blur(6px); + -webkit-backdrop-filter: blur(6px); color: var(--color-text-secondary); font-size: var(--text-caption); + pointer-events: none; +} + +/* 조작 설명 — **2열**로 묶는다(사용자 지시 ⑩). 한 줄로 늘어놓으면 창이 좁을 때 접힌다. */ +.b05-routeedit__hint { + top: var(--spacing-12); + left: var(--spacing-12); + display: grid; + grid-template-columns: auto auto; + gap: 2px var(--spacing-16); + max-width: 52%; +} + +/* 상태·범례 — 지도 왼쪽 아래(사용자 지시 ⑫). */ +.b05-routeedit__info { + bottom: var(--spacing-12); + left: var(--spacing-12); + display: flex; + flex-direction: column; + gap: 4px; + max-width: 62%; +} + +/* 회전 단추 — 지도 오른쪽 위(사용자 지시 ⑯). 여기만 클릭을 받는다. */ +.b05-routeedit__spin { + position: absolute; + top: var(--spacing-12); + right: var(--spacing-12); + z-index: 1; + display: flex; + gap: var(--spacing-8); +} + +.b05-routeedit__spin .ui-btn { + padding: var(--spacing-8) var(--spacing-12); + font-size: var(--text-body); + line-height: 1; } .b05-routeedit__legend { display: inline-flex; align-items: center; gap: var(--spacing-8); - color: var(--color-text-secondary); - font-size: var(--text-caption); } .b05-routeedit__legend i { @@ -101,20 +147,105 @@ border-top: 2px solid var(--map-route, #f97316); } -.b05-routeedit__btn { +/* 오른쪽 세로 칸 — 위아래 반씩 나눠 **지금 횡단**과 **이전 횡단**이 앉는다. */ +.b05-routeedit__side { + display: flex; flex: none; - padding: var(--spacing-8) var(--spacing-16); + flex-direction: column; + gap: var(--spacing-12); + width: 452px; + height: min(820px, 92vh); +} + +@media (width < 1500px) { + /* 자리가 모자라면 오른쪽 칸을 접는다 — 지도가 먼저다. */ + .b05-routeedit__side { + display: none; + } +} + +.b05-routeedit__cross { + display: flex; + flex: 1 1 0; + min-height: 0; + flex-direction: column; + gap: var(--spacing-8); + padding: var(--spacing-12); + overflow: hidden; + border: 1px solid var(--color-border); + border-radius: var(--radius-16, 12px); + background: var(--color-surface-raised); + box-shadow: 0 12px 40px rgb(0 0 0 / 45%); +} + +.b05-routeedit__cross-head { + display: flex; + flex: none; + align-items: baseline; + gap: var(--spacing-8); +} + +.b05-routeedit__cross-station { + color: var(--color-text-secondary); + font-size: var(--text-caption); +} + +.b05-routeedit__cross-canvas { + flex: 1 1 auto; + min-height: 0; + width: 100%; border: 1px solid var(--color-border); border-radius: var(--radius-8, 6px); background: var(--color-surface); - color: var(--color-text-body); - cursor: pointer; } -.b05-routeedit__btn.is-primary { - border-color: transparent; - background: var(--color-primary, #7c3aed); - color: #fff; +.b05-routeedit__cross-foot { + flex: none; + color: var(--color-text-secondary); + font-size: var(--text-caption); + line-height: 1.5; +} + +/* ㉓ 거리 재기와 되돌리기 사이 구분선. */ +.b05-routeedit__divider { + width: 1px; + height: 20px; + margin: 0 var(--spacing-4, 4px); + background: var(--color-border); +} + +/* ㉔ 잰 값 — 지도 오른쪽 아래 작은 창. 닫으면 잰 것이 지워진다. */ +.b05-routeedit__measure { + position: absolute; + right: var(--spacing-12); + bottom: var(--spacing-12); + z-index: 1; + display: flex; + align-items: flex-start; + gap: var(--spacing-8); + max-width: 52%; + padding: var(--spacing-8) var(--spacing-12); + border: 1px solid color-mix(in srgb, #22c55e 60%, transparent); + border-radius: var(--radius-8, 6px); + background: color-mix(in srgb, var(--color-surface-raised) 82%, transparent); + backdrop-filter: blur(6px); + -webkit-backdrop-filter: blur(6px); + color: var(--color-text-body); + font-size: var(--text-caption); +} + +/* 글이 길면 접힌다 — flex 자식은 기본으로 안 줄어들어 왼쪽으로 넘쳐 잘렸다(2026-09-12). */ +.b05-routeedit__measure-text { + min-width: 0; + line-height: 1.5; +} + +.b05-routeedit__measure-close { + flex: none; + border: none; + background: none; + color: var(--color-text-secondary); + cursor: pointer; } /* 재계산 중에는 화면 전체를 덮는다 — 결과를 기다릴 수밖에 없는 조작(CLAUDE.md 5장). */ @@ -164,6 +295,15 @@ touch-action: none; } +.b05-routeedit__label-close { + border: none; + background: none; + color: var(--color-text-secondary); + font-size: 13px; + line-height: 1; + cursor: pointer; +} + /* 고정 단추 — 켜지면 색이 찬다. 켠 값은 노드를 옮겨도 안 바뀐다. */ .b05-routeedit__lock { padding: 1px 6px; diff --git a/config/config_system_design.py b/config/config_system_design.py index 201716b1..7f6d6fa6 100644 --- a/config/config_system_design.py +++ b/config/config_system_design.py @@ -372,6 +372,26 @@ EARTHWORK_CONVERSION_FACTORS = { "blasting_rock": {"loose": 1.60, "compacted": 1.30}, } +# 다짐 계수 `C` 를 설계자가 고를 때 보이는 **품셈 범위**. 위 기본값이 선 근거와 같은 표다. +# ⚠ **막는 값이 아니다.** 품셈이 「토질 시험하여 적용함을 원칙」이라 하므로 범위 밖 값도 +# 받되 **사유를 적게** 한다(프로젝트 설정 `conversion_factors_override` 의 `reason`). +# ⚠ 기본값을 여기서 다시 적지 않는다 — 기본값의 정의처는 위 상수 한 곳뿐이다. +EARTHWORK_CONVERSION_C_RANGES = { + "soil": (0.75, 0.90), # 풍화토 0.80~0.90 ~ 점토 0.75~0.90 + "ripping_rock": (1.00, 1.30), # 풍화암 1.00~1.15 ~ 연암 1.00~1.30 + "blasting_rock": (1.20, 1.40), # 보통암 1.20~1.40 +} + +# 품셈 체적변화율표 암종별 `C` 원문 — **화면 안내용**이다. +# 우리 3갈래와 1:1 이 아니라(풍화암·연암이 리핑암 하나로 접힌다) 계산에 쓰지 않는다. +# 경쟁사(오솔길)가 전 구간 1.0 을 쓰는 것도 풍화암·연암 범위 하한이라 범위 안이다. +EARTHWORK_CONVERSION_PUMSEM_C_RANGES = ( + ("풍화암", 1.00, 1.15), + ("연암", 1.00, 1.30), + ("보통암", 1.20, 1.40), + ("경암", 1.30, 1.50), +) + # ───────────────────────────────────────────────────────────────────────── # 5-4-5. 토공 운반장비 선정 거리 경계 (B06 유토곡선 운반계획) @@ -496,6 +516,35 @@ FOREST_ROAD_PROFILE_CRITERIA = { # 배향곡선(Hair Pin) 중심선 반지름 하한(m, 별표2 Ⅰ.2.다.(2)). 이보다 급하면 **경고만** # 낸다 — 자동 보정·차단은 하지 않는다(2026-09-06 사용자 확정). "hairpin_min_radius_m": 10.0, + # 임도 종류별 **못 넘는 하한**(m) — 계획노선 편집 화면이 값을 막는 기준이다 + # (2026-09-12 사용자 확정). 위 `min_plan_radius_m` 은 **기본값·위반 표시 기준**이고 + # 여기는 **제한**이라 서로 다르다. 둘을 한 값으로 묶으면 하한 0 이 곧 반지름 0 이 되어 + # 곡선이 아예 안 그려진다. + # · None = 위 표(설계속도 × 지형)를 그대로 하한으로 쓴다. + # · 0.0 = 제한 없음. + # 작업임도는 별표2에 곡선반지름 규정이 없고 **실무값도 없다** — 자유도가 높은 공사라 + # 사용자가 그때그때 정한다(2026-09-12 사용자 확정). 다만 화면에 하한을 적어 보여야 해서 + # **5m** 를 둔다 — 작은 값으로 고칠 때 걸리적거리지 않는 수준으로 사용자가 고른 값이다. + # **이 칸만** 고치면 서버·화면이 함께 따라간다. + # ⚠ `projects.road_type` 은 main|fire|work 로 들어온다(B02 스키마) — 계획선 등급 코드 + # trunk 와 같은 뜻이라 둘 다 적어 둔다. 없는 키는 None 과 같게(법정 표) 다뤄진다. + "plan_radius_limit_by_grade_m": { + "main": None, + "trunk": None, + "fire": None, + "work": 5.0, + "branch": None, + }, + # 평면 **곡선 길이(L)** 하한(m). 법령·교본에 값이 없고 실무값도 없다 — 자유도가 높은 + # 공사라 사용자가 정한다. 화면에 적어 보일 값으로 **5m** 를 둔다(2026-09-12 사용자 확정, + # R 하한과 같은 까닭). 여기만 고치면 서버·화면이 함께 따라간다. + "plan_curve_length_limit_by_grade_m": { + "main": 5.0, + "trunk": 5.0, + "fire": 5.0, + "work": 5.0, + "branch": 5.0, + }, # 임도 종류 → **기본** 설계속도(km/h). 임도는 속도를 낼 수 없는 노선이라 20이 # 기본이다(2026-08-19 사용자 확정). 별표2상 간선·산불진화는 20~40 범위에서 # 설계자가 고르고, 작업임도는 20 이하이므로 20 고정이다. 사용자가 화면에서 고른 diff --git a/main.py b/main.py index a3967f44..47d298ec 100644 --- a/main.py +++ b/main.py @@ -48,6 +48,7 @@ from B05_Profile.B05_Profile_Router import router as b05_route_router from B05_Profile.B05_Profile_Router_Corridor import router as b05_corridor_router from B05_Profile.B05_Profile_Router_Lifecycle import router as b05_route_lifecycle_router from B05_Profile.B05_Profile_Router_Replan import router as b05_route_replan_router +from B05_Profile.B05_Profile_Router_Terrain import router as b05_route_terrain_router from B05_Profile.B05_Profile_Structures_Router import router as b05_structures_router from B06_Section.B06_Section_Router import router as b06_section_router from B06_Section.B06_Section_Router_Stations import router as b06_section_stations_router @@ -168,6 +169,27 @@ def _kill_process_tree(pid: int) -> None: logger.debug("[Frontend] 프로세스 정리 중 무시된 예외: %s", exc) +def _dev_port_listening(port: int) -> bool: + """개발 서버(Vite)가 이미 그 포트를 물고 있나. + + **리로드인지 첫 기동인지를 가르는 하나뿐인 표식**이다. uvicorn 리로더는 코드가 바뀌면 + 앱 프로세스만 다시 띄우고 Vite 자식은 그대로 두므로, 포트가 살아 있으면 리로드다. + 환경변수 같은 별도 깃발을 두지 않는 까닭은 그것이 **실제와 어긋날 수 있기** 때문이다 — + 포트는 어긋나지 않는다. + """ + import socket + + probe = socket.socket() + probe.settimeout(0.5) + try: + probe.connect(("127.0.0.1", port)) + return True + except OSError: + return False + finally: + probe.close() + + def _free_dev_port(port: int) -> None: """개발 서버 포트를 물고 있는 잔여 프로세스를 정리한다. @@ -234,8 +256,22 @@ def serve_frontend_dev() -> None: def stop_frontend_dev() -> None: - """앱이 내려갈 때 개발 서버도 같이 내린다 — 유령 인스턴스를 남기지 않는다.""" + """앱이 내려갈 때 개발 서버도 같이 내린다 — 유령 인스턴스를 남기지 않는다. + + ⚠⚠ **개발 중에는 내리지 않는다** (2026-09-12 실측으로 드러난 고리). + uvicorn 리로더는 `*.py` 가 바뀔 때마다 앱 프로세스를 갈아 끼운다. 그때 여기서 + Vite 를 죽이면 **새 프로세스는 「Vite 가 없다」고 보고 `npm run build` 를 처음부터 + 다시 돌린다**(최대 300초 + B07 CAD). 그 사이 화면은 뜨지 않고 `/api/health` 는 + `stale: true` 인 채로 옛 코드를 계속 내준다 — 그날 리로드가 두 번 멈췄고 한 번은 + 두 시간 반 동안 안 끝났다. **옛 화면을 보고 헛검증하게 만드는 자리다.** + ⇒ Vite 는 **한 앱 프로세스가 아니라 개발 세션의 것**이다. 살려 두고 다음 기동이 + 그대로 물려쓴다(`_dev_port_listening`). 남은 유령은 다음 기동의 + `_free_dev_port` 가 포트로 잡아 정리하므로 쌓이지 않는다. + """ global _frontend_dev_process + if ENVIRONMENT == "development": + logger.info("[Frontend] 개발 서버는 살려 둔다 — 다음 기동이 그대로 물려쓴다") + return if _frontend_dev_process is None: return logger.info("[Frontend] 개발 서버 종료 (pid %s)", _frontend_dev_process.pid) @@ -293,12 +329,23 @@ async def lifespan(app: FastAPI): datetime.fromtimestamp(_CODE_MTIME_AT_START).isoformat(timespec="seconds"), ) - # 프론트엔드 빌드 (필수) - build_frontend() - - # 개발 환경에서 개발 서버 실행 (선택사항) - if ENVIRONMENT == "development": - serve_frontend_dev() + # ⚠⚠ **리로드마다 프론트를 다시 빌드하지 않는다** (2026-09-12 실측으로 드러난 자리). + # uvicorn 리로더는 `*.py` 가 바뀔 때마다 이 lifespan 을 다시 탄다. 그때마다 + # `npm run build`(최대 300초, B07 CAD 빌드까지 딸림)가 돌고 Vite 를 죽였다 살리느라 + # **리로드가 몇 분씩 걸리거나 아예 멈춘다** — 그날 두 번 멈췄고 한 번은 두 시간 반 동안 + # 안 끝나, `/api/health` 가 `stale: true` 인 채로 옛 코드를 계속 내주었다. + # ⇒ **옛 화면을 보고 헛검증하게 만드는 자리라 그냥 느린 것이 아니다.** + # 개발 중에는 Vite 가 HMR 로 이미 최신을 내주므로 다시 빌드할 까닭이 없다. + if ENVIRONMENT == "development" and _dev_port_listening(FRONTEND_DEV_PORT): + logger.info( + "[Frontend] 개발 서버가 %d 포트에 이미 살아 있음 — 빌드·재기동 건너뜀(리로드)", + FRONTEND_DEV_PORT, + ) + else: + # 첫 기동(또는 프로덕션) — 여기서만 빌드한다. + build_frontend() + if ENVIRONMENT == "development": + serve_frontend_dev() # DB 풀 초기화 await init_db_pool() @@ -537,6 +584,7 @@ app.include_router(b05_route_router, dependencies=protected_with_company) app.include_router(b05_route_lifecycle_router, dependencies=protected_with_company) app.include_router(b05_corridor_router, dependencies=protected_with_company) app.include_router(b05_route_replan_router, dependencies=protected_with_company) +app.include_router(b05_route_terrain_router, dependencies=protected_with_company) app.include_router(b05_structures_router, dependencies=protected_with_company) app.include_router(b06_section_router, dependencies=protected_with_company) app.include_router(b06_section_stations_router, dependencies=protected_with_company) diff --git a/resources/tester/test_b08_conversion_factor_choice.py b/resources/tester/test_b08_conversion_factor_choice.py new file mode 100644 index 00000000..742ba415 --- /dev/null +++ b/resources/tester/test_b08_conversion_factor_choice.py @@ -0,0 +1,110 @@ +"""토량환산계수를 「고를 수 있는 값」으로 연 자리 검사 (오솔길 대조 06절 3번). + +못 박는 것 셋 + ① **기본값이 안 바뀐다** — 안 고른 프로젝트는 정본 그대로 선다. + ② 고른 값은 **토적표·운반표가 같이** 읽는다(계수 정의처는 여전히 한 곳). + ③ 품셈 범위 **밖도 막지 않는다** — 사유와 함께 선다. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) + +from B08_Quantity.B08_Quantity_Engine_EarthworkTable import ( # noqa: E402 + StationArea, + build_rows, +) +from B08_Quantity.B08_Quantity_Engine_HaulSummary import natural_m3 # noqa: E402 +from common_util.common_util_project_settings import ( # noqa: E402 + default_settings, + earthwork_conversion_choices, + earthwork_conversion_factors, +) +from config.config_system_design import ( # noqa: E402 + EARTHWORK_CONVERSION_C_RANGES, + EARTHWORK_CONVERSION_FACTORS, +) + + +def test_안_고르면_기본값이_그대로() -> None: + """빈 설정·기본 설정 둘 다 정본과 한 글자도 달라지지 않아야 한다.""" + assert earthwork_conversion_factors({}) == EARTHWORK_CONVERSION_FACTORS + assert earthwork_conversion_factors(default_settings()["quantity"]) == ( + EARTHWORK_CONVERSION_FACTORS + ) + + +def test_고른_갈래만_갈아_끼움() -> None: + settings = {"conversion_factors_override": {"ripping_rock": {"compacted": 1.0}}} + resolved = earthwork_conversion_factors(settings) + assert resolved["ripping_rock"]["compacted"] == pytest.approx(1.0) + # 나머지 갈래는 정본 그대로다. + assert resolved["soil"] == EARTHWORK_CONVERSION_FACTORS["soil"] + assert resolved["blasting_rock"] == EARTHWORK_CONVERSION_FACTORS["blasting_rock"] + # ⚠ 정본 dict 를 건드리지 않았는가 — 얕은 복사였다면 여기서 걸린다. + assert EARTHWORK_CONVERSION_FACTORS["ripping_rock"]["compacted"] == pytest.approx(1.15) + + +def test_모르는_갈래와_말이_안_되는_값은_버림() -> None: + settings = { + "conversion_factors_override": { + "unknown_rock": {"compacted": 2.0}, + "soil": {"compacted": 0}, + "blasting_rock": {"compacted": "많이"}, + } + } + assert earthwork_conversion_factors(settings) == EARTHWORK_CONVERSION_FACTORS + + +def test_토적표가_고른_계수로_섬() -> None: + """오솔길처럼 암 계수 1.0 을 고르면 보정량이 그 값으로 선다.""" + stations = [ + StationArea(chainage_m=0.0), + StationArea(chainage_m=10.0, cut_rock_area_m2=2.0, cut_rock_kind="ripping_rock"), + ] + factors = earthwork_conversion_factors( + {"conversion_factors_override": {"ripping_rock": {"compacted": 1.0}}} + ) + row = build_rows(stations, factors)[1] + assert row.cut_rock_volume_m3 == pytest.approx(10.0) + assert row.cut_rock_adjusted_m3 == pytest.approx(10.0) # 기본값 1.15 였다면 11.5 + # 안 주면 기본값 — 같은 측점이 11.5 로 선다. + assert build_rows(stations)[1].cut_rock_adjusted_m3 == pytest.approx(11.5) + + +def test_운반표도_같은_계수를_씀() -> None: + """다짐 → 자연 되돌리기(÷C)도 고른 값으로 돌아야 표끼리 안 갈린다.""" + factors = earthwork_conversion_factors( + {"conversion_factors_override": {"ripping_rock": {"compacted": 1.0}}} + ) + assert natural_m3(11.5, "리핑암", factors) == pytest.approx(11.5) + assert natural_m3(11.5, "리핑암") == pytest.approx(11.5 / 1.15) + + +def test_범위_밖도_막지_않고_사유와_함께_섬() -> None: + low, _high = EARTHWORK_CONVERSION_C_RANGES["blasting_rock"] + settings = { + "conversion_factors_override": { + "blasting_rock": {"compacted": low - 0.5, "reason": "토질시험 값"} + } + } + resolved = earthwork_conversion_factors(settings) + assert resolved["blasting_rock"]["compacted"] == pytest.approx(low - 0.5) + choice = earthwork_conversion_choices(settings)["blasting_rock"] + assert choice["chosen"] is True + assert choice["in_range"] is False # 밖이라고 말은 하되 값은 그대로 선다. + assert choice["reason"] == "토질시험 값" + + +def test_선택_상태는_기본값과_범위를_함께_냄() -> None: + choice = earthwork_conversion_choices({})["ripping_rock"] + assert choice["chosen"] is False + assert choice["in_range"] is True + assert choice["default"] == pytest.approx(1.15) + assert choice["range"] == [1.00, 1.30] diff --git a/resources/tester/test_b08_provenance.py b/resources/tester/test_b08_provenance.py new file mode 100644 index 00000000..597f3fc3 --- /dev/null +++ b/resources/tester/test_b08_provenance.py @@ -0,0 +1,132 @@ +"""B08 근거 사전 — 사전이 **엔진과 어긋나지 않는지** 지키는 시험 (PLAN 8-36 ④). + +이 시험의 값어치는 마지막 것 하나에 있다: **사전에 적은 열 이름이 실제 토적표 줄에 있는가.** +엔진이 열을 바꾸거나 이름을 갈면 사전만 옛것으로 남아, 맞는 값 옆에 틀린 근거가 붙는다. +그 어긋남은 화면에서 눈에 안 띄므로(카드가 그냥 안 뜬다) 여기서 잡는다. +""" + +from __future__ import annotations + +import dataclasses + +import pytest + +from B08_Quantity.B08_Quantity_Engine_EarthworkSummary import SummaryRow +from B08_Quantity.B08_Quantity_Engine_EarthworkTable import EarthworkRow +from B08_Quantity.B08_Quantity_Engine_HaulSummary import HaulSummaryRow +from B08_Quantity.B08_Quantity_Provenance import ( + earthwork_sheet, + haul_sheet, + quantity_provenance, + summary_sheet, +) +from common_util import common_util_provenance as provenance_module +from common_util.common_util_provenance import ( + TIERS, + ColumnProvenance, + provenance_payload, + sheet_provenance, +) + + +def test_토적표_사전이_엔진_열과_같은_이름을_쓴다(): + """사전 열 키가 전부 `EarthworkRow` 에 있어야 한다 — **이 시험이 사전의 존재 이유다.**""" + row_fields = {field.name for field in dataclasses.fields(EarthworkRow)} + dictionary = earthwork_sheet()["columns"] + 낯선_키 = sorted(set(dictionary) - row_fields) + assert not 낯선_키, f"사전에 있는데 토적표 줄에 없는 열: {낯선_키}" + + +def _모든_열(): + """개발환경에서 실리는 여섯 장을 한 줄로 펜다 — (장 이름, 열 키, 몸통).""" + payload = quantity_provenance() + assert payload is not None + for sheet_name, sheet in payload["sheets"].items(): + for key, body in sheet["columns"].items(): + yield sheet_name, key, body + + +def test_여섯_장이_다_실린다(): + payload = quantity_provenance() + assert payload is not None + assert set(payload["sheets"]) == { + "earthwork", + "summary", + "haul", + "preparation", + "material", + "unit_quantity", + } + + +def test_사전_등급이_전부_아는_값이다(): + for sheet_name, key, body in _모든_열(): + assert body["tier"] in TIERS, f"{sheet_name}.{key} 등급이 모르는 값: {body['tier']}" + + +def test_사전_열마다_이름과_식이_비어_있지_않다(): + """빈 카드는 「설명이 있다」는 거짓만 남긴다 — 적을 것이 없으면 열을 아예 안 넣는다.""" + for sheet_name, key, body in _모든_열(): + assert body.get("label"), f"{sheet_name}.{key} 에 이름이 없음" + assert body.get("formula"), f"{sheet_name}.{key} 에 식이 없음" + + +def test_같은_열을_두_번_적으면_막는다(): + with pytest.raises(ValueError): + sheet_provenance( + [ + ColumnProvenance(key="a", label="가", tier="calc", formula="x"), + ColumnProvenance(key="a", label="나", tier="calc", formula="y"), + ] + ) + + +def test_모르는_등급을_적으면_막는다(): + with pytest.raises(ValueError): + sheet_provenance([ColumnProvenance(key="a", label="가", tier="없는등급")]) + + +def test_배포환경에서는_사전을_아예_안_보낸다(monkeypatch): + """⚠ 로직 보안 — 화면에서 숨기는 것이 아니라 **응답에 안 싣는 것**이 문이다.""" + monkeypatch.setattr(provenance_module, "is_dev_environment", lambda: False) + assert provenance_payload({"earthwork": earthwork_sheet()}) is None + assert quantity_provenance() is None + + +def test_개발환경에서는_시트가_실린다(monkeypatch): + monkeypatch.setattr(provenance_module, "is_dev_environment", lambda: True) + payload = quantity_provenance() + assert payload is not None + assert "earthwork" in payload["sheets"] + + +def test_고르는_자리의_채택_규칙은_적었을_때만_실린다(): + """`rule` 은 안전관리비처럼 **값 안에 선택이 숨은** 열에만 붙는다(B09 조사 ㉯).""" + 없는_것 = ColumnProvenance(key="a", label="가", tier="calc", formula="x").as_dict() + assert "rule" not in 없는_것 + 있는_것 = ColumnProvenance( + key="b", label="나", tier="calc", formula="x", rule="A·B 중 작은 쪽" + ).as_dict() + assert 있는_것["rule"] == "A·B 중 작은 쪽" + + +def test_집계표_사전이_엔진_열과_같은_이름을_쓴다(): + row_fields = {field.name for field in dataclasses.fields(SummaryRow)} + 낯선_키 = sorted(set(summary_sheet()["columns"]) - row_fields) + assert not 낯선_키, f"사전에 있는데 집계표 줄에 없는 열: {낯선_키}" + + +def test_운반표_사전이_엔진_열과_같은_이름을_쓴다(): + """⚠ `average_distance_m` 은 필드가 아니라 property 라 필드 목록만 보면 놓친다.""" + names = {field.name for field in dataclasses.fields(HaulSummaryRow)} + names |= {n for n in dir(HaulSummaryRow) if not n.startswith("_")} + 낯선_키 = sorted(set(haul_sheet()["columns"]) - names) + assert not 낯선_키, f"사전에 있는데 운반표 줄에 없는 열: {낯선_키}" + + +def test_집계표에_최종이_서고_토적표에는_없다(): + """등급 여섯은 한 장이 아니라 **두 장을 합쳐야** 다 쓰인다 — 그 갈림을 시험으로 박는다.""" + 토적표 = {body["tier"] for body in earthwork_sheet()["columns"].values()} + 집계표 = {body["tier"] for body in summary_sheet()["columns"].values()} + assert "final" not in 토적표, "토적표는 중간 장부라 최종 열이 없어야 함" + assert "final" in 집계표, "내역서로 나가는 값은 집계표 「계」에서 서야 함" diff --git a/resources/tester/test_plan_curve_limits.py b/resources/tester/test_plan_curve_limits.py new file mode 100644 index 00000000..f335c267 --- /dev/null +++ b/resources/tester/test_plan_curve_limits.py @@ -0,0 +1,47 @@ +"""계획노선 곡선 **하한**(반지름·곡선 길이) 해석 시험. + +기본값(`legal_plan_radius_min_m`)과 **못 넘는 하한**(`plan_radius_limit_m`)은 다른 값이다 +(2026-09-12 사용자 확정). 둘을 한 값으로 묶으면 작업임도의 하한 0 이 곧 반지름 0 이 되어 +곡선이 아예 안 그려지므로, 갈라져 있다는 것 자체를 시험으로 못박는다. +""" + +from B05_Profile.B05_Profile_Engine_Grade import ( + legal_plan_radius_min_m, + plan_curve_length_limit_m, + plan_radius_limit_m, + resolve_design_speed, +) + + +def _default(grade_class: str, terrain: str) -> float: + return legal_plan_radius_min_m(resolve_design_speed(grade_class, None), terrain) + + +def test_작업임도_하한은_사용자가_고른_값이다(): + """별표2에 작업임도 곡선반지름 규정이 없고 실무값도 없다 — 사용자가 5m 로 정했다 + (2026-09-12). 지형과 무관한 한 값이라는 것까지 못박는다.""" + assert plan_radius_limit_m("work", None, "normal") == 5.0 + assert plan_radius_limit_m("work", None, "special") == 5.0 + + +def test_기본_반지름과_하한은_다른_값이다(): + """**곡선을 만들 때 쓰는 기본값**은 하한과 따로 산다 — 묶으면 하한이 곧 반지름이 된다.""" + assert _default("work", "normal") > plan_radius_limit_m("work", None, "normal") + + +def test_간선_산불진화는_법정표가_곧_하한이다(): + for grade_class in ("main", "trunk", "fire"): + for terrain in ("normal", "special"): + assert plan_radius_limit_m(grade_class, None, terrain) == _default(grade_class, terrain) + + +def test_모르는_임도종류는_법정표로_떨어진다(): + """칸이 없는 값이 와도 막지 않고 법정표를 하한으로 쓴다(가장 보수적인 쪽).""" + assert plan_radius_limit_m("알 수 없는 종류", None, "normal") == _default("work", "normal") + + +def test_곡선길이_하한은_임도_종류와_무관하게_같다(): + """법령·교본에 값이 없어 사용자가 5m 로 정했다(2026-09-12). 없는 키는 0으로 떨어진다.""" + for grade_class in ("main", "trunk", "fire", "work", "branch"): + assert plan_curve_length_limit_m(grade_class) == 5.0 + assert plan_curve_length_limit_m("없는종류") == 0.0