auto: 2026-09-12 16:57 (ESD_LAPTOP)

This commit is contained in:
2026-09-12 16:57:48 +09:00
parent 15085c2011
commit 57c1ce77c1
29 changed files with 3276 additions and 581 deletions
@@ -202,6 +202,9 @@ export interface StationTickOptions {
toScreen: (x: number, y: number) => [number, number];
/** 관 마커가 놓인 누가거리 목록 — 겹치면 라벨을 반대쪽으로 민다. */
avoidChainages?: ReadonlyArray<number>;
/** 돌린 지도에서 **글자만 되돌려 세울** 각(라디안). 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();
}
+178 -217
View File
@@ -42,14 +42,14 @@ export const ROUTE_LINE_WIDTH = 2.4;
* 렌더 시 "화면 오차 < LOD_PX가 되는 정점"만 제외해 어느 줌에서도 시각적 무손실 LOD를 얻는다.
* line 파트에만 존재하며 원본 GeoJSON은 변형하지 않는다.
*/
type PreparedPart = {
export type PreparedPart = {
coords: Float64Array;
closed: boolean;
weights: Float64Array | null;
};
/** 사전 투영된 피처 1개. bbox는 정규화 좌표 기준이며 컬링에 사용한다. */
type PreparedFeature = {
export type PreparedFeature = {
kind: "line" | "point";
parts: PreparedPart[];
minX: number;
@@ -60,6 +60,8 @@ type PreparedFeature = {
labelAnchorX: number;
labelAnchorY: number;
labelText: string | null;
/** 그 라벨의 표고(m). 어느 줄을 실제로 낼지는 `drawPreparedLabels` 가 줌을 보고 고른다. */
labelValue: number | null;
};
export type PreparedLayer = {
@@ -214,226 +216,81 @@ export function computeRouteView(
};
}
function isPoint(value: unknown): value is [number, number] {
return Array.isArray(value) && typeof value[0] === "number" && typeof value[1] === "number";
}
/** lon/lat 배열 → 정규화 좌표 Float64Array. 유효 정점이 없으면 null. */
function projectRing(ring: unknown, normalizer: Normalizer): Float64Array | null {
if (!Array.isArray(ring) || ring.length === 0) return null;
const coords = new Float64Array(ring.length * 2);
let count = 0;
for (const point of ring) {
if (!isPoint(point)) continue;
coords[count * 2] = (point[0] - normalizer.lonMin) / normalizer.lonRange;
coords[count * 2 + 1] = 1 - (point[1] - normalizer.latMin) / normalizer.latRange;
count += 1;
}
if (count === 0) return null;
return count * 2 === coords.length ? coords : coords.slice(0, count * 2);
}
function collectParts(
geometry: GeoJsonGeometry,
normalizer: Normalizer,
parts: PreparedPart[],
): "line" | "point" {
const coordinates = geometry.coordinates;
if (!Array.isArray(coordinates)) return "line";
const push = (ring: unknown, closed: boolean): void => {
const projected = projectRing(ring, normalizer);
if (projected) parts.push({ coords: projected, closed, weights: null });
};
switch (geometry.type) {
case "Point":
push([coordinates], false);
return "point";
case "MultiPoint":
push(coordinates, false);
return "point";
case "LineString":
push(coordinates, false);
return "line";
case "MultiLineString":
for (const line of coordinates) push(line, false);
return "line";
case "Polygon":
for (const ring of coordinates) push(ring, true);
return "line";
case "MultiPolygon":
for (const polygon of coordinates) {
if (!Array.isArray(polygon)) continue;
for (const ring of polygon) push(ring, true);
}
return "line";
default:
return "line";
}
}
/**
* Douglas-Peucker 가중치 계산 (반복형, 스택 오버플로 방지).
* weights[i] = "허용 오차가 이 값보다 크면 정점 i를 버려도 되는" 임계값.
* 부모 구간의 오차로 상한을 걸어(cap) 어떤 허용 오차에서도 일관된 부분집합이 나오게 한다.
* y축은 1/aspect로 보정해 화면 픽셀 거리와 비례하는 좌표계에서 계산한다.
*/
function computeDpWeights(coords: Float64Array, aspect: number): Float64Array {
const n = coords.length / 2;
const weights = new Float64Array(n);
weights[0] = Infinity;
weights[n - 1] = Infinity;
if (n <= 2) return weights;
const stack: number[] = [0, n - 1];
const caps: number[] = [Infinity];
while (stack.length) {
const last = stack.pop()!;
const first = stack.pop()!;
const cap = caps.pop()!;
if (last - first < 2) continue;
const ax = coords[first * 2];
const ay = coords[first * 2 + 1] / aspect;
const bx = coords[last * 2];
const by = coords[last * 2 + 1] / aspect;
const dx = bx - ax;
const dy = by - ay;
const len = Math.sqrt(dx * dx + dy * dy);
let maxDist = -1;
let maxIndex = -1;
for (let i = first + 1; i < last; i += 1) {
const px = coords[i * 2] - ax;
const py = coords[i * 2 + 1] / aspect - ay;
const dist = len === 0 ? Math.sqrt(px * px + py * py) : Math.abs(px * dy - py * dx) / len;
if (dist > maxDist) {
maxDist = dist;
maxIndex = i;
}
}
const weight = Math.min(maxDist, cap);
weights[maxIndex] = weight;
stack.push(first, maxIndex, maxIndex, last);
caps.push(weight, weight);
}
return weights;
}
/** 등고 라벨 앵커: LineString/MultiLineString 첫 파트의 중앙 정점 (기존 동작 유지). */
function labelAnchorOf(geometry: GeoJsonGeometry, normalizer: Normalizer): [number, number] | null {
const coords = geometry.coordinates;
if (!Array.isArray(coords)) return null;
const line =
geometry.type === "LineString"
? coords
: geometry.type === "MultiLineString"
? coords[0]
: null;
if (!Array.isArray(line) || line.length === 0) return null;
const mid = line[Math.floor(line.length / 2)];
if (!isPoint(mid)) return null;
return [
(mid[0] - normalizer.lonMin) / normalizer.lonRange,
1 - (mid[1] - normalizer.latMin) / normalizer.latRange,
];
}
/**
* GeoJSON 컬렉션 1개를 사전 투영한다.
* labelKeys가 주어지면 계곡선(25m 배수) 피처에만 라벨 텍스트·앵커를 계산해 둔다.
*/
export function prepareLayer(
collection: GeoJsonCollection | undefined,
normalizer: Normalizer,
labelKeys?: string[],
): PreparedLayer {
const features: PreparedFeature[] = [];
for (const feature of collection?.features ?? []) {
if (!feature.geometry) continue;
const parts: PreparedPart[] = [];
const kind = collectParts(feature.geometry, normalizer, parts);
if (parts.length === 0) continue;
if (kind === "line") {
for (const part of parts) {
if (part.coords.length < 6) continue;
part.weights = computeDpWeights(part.coords, normalizer.aspect);
}
}
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
for (const part of parts) {
/** 화면 px 에 가장 가까운 선 피처의 자리. 그만큼 안에 없으면 -1(계획서 0-9 ⑦). */
export function hitPreparedLayer(
layer: PreparedLayer,
view: ViewState,
px: number,
py: number,
tolerancePx: number,
everyM?: number,
): number {
const affine = affineOf(view);
const step = everyM !== undefined && everyM > 0 ? everyM : 0;
let best = -1;
let bestDistance = tolerancePx;
layer.features.forEach((feature, index) => {
if (feature.kind !== "line") return;
// **그리지 않은 줄은 집히지도 않는다** — 안 보이는 등고선이 골라지면 없던 선이 튀어나온다.
if (step && feature.labelValue !== null && feature.labelValue % step !== 0) return;
// 화면 밖·멀리 있는 피처는 바운딩박스에서 먼저 떨군다 — 도엽 등고선은 수천 가닥이다.
const x0 = feature.minX * affine.ax + affine.bx - tolerancePx;
const x1 = feature.maxX * affine.ax + affine.bx + tolerancePx;
const y0 = feature.minY * affine.ay + affine.by - tolerancePx;
const y1 = feature.maxY * affine.ay + affine.by + tolerancePx;
if (px < x0 || px > x1 || py < y0 || py > y1) return;
for (const part of feature.parts) {
const coords = part.coords;
let lastX = NaN;
let lastY = NaN;
// 그릴 때와 **같은 LOD** 로 훑는다 — 화면에 없는 정점에 걸리면 눈과 손이 어긋난다.
const tolerance = LOD_PX / affine.ax;
for (let i = 0; i < coords.length; i += 2) {
const x = coords[i];
const y = coords[i + 1];
if (x < minX) minX = x;
if (x > maxX) maxX = x;
if (y < minY) minY = y;
if (y > maxY) maxY = y;
}
}
let labelText: string | null = null;
let labelAnchorX = 0;
let labelAnchorY = 0;
if (labelKeys && labelKeys.length > 0) {
const raw = labelKeys.map((key) => feature.properties?.[key]).find((value) => value != null);
const elevation = typeof raw === "number" ? raw : Number(raw);
// 계곡선(25m 배수)만 라벨 — 전체 표기 시 화면이 숫자로 뒤덮이는 것 방지
if (Number.isFinite(elevation) && elevation % 25 === 0) {
const anchor = labelAnchorOf(feature.geometry, normalizer);
if (anchor) {
labelText = String(elevation);
labelAnchorX = anchor[0];
labelAnchorY = anchor[1];
if (part.weights && part.weights[i / 2] < tolerance) continue;
const x = coords[i] * affine.ax + affine.bx;
const y = coords[i + 1] * affine.ay + affine.by;
if (Number.isFinite(lastX)) {
const distance = pointSegmentDistance(px, py, lastX, lastY, x, y);
if (distance < bestDistance) {
bestDistance = distance;
best = index;
}
}
lastX = x;
lastY = y;
}
}
features.push({ kind, parts, minX, minY, maxX, maxY, labelAnchorX, labelAnchorY, labelText });
}
return { features };
});
return best;
}
/**
* 사업지 좌표계(m) 폴리라인을 한 개 피처짜리 레이어로 사전 투영한다.
* meta의 x/y 범위와 lon/lat 범위는 같은 사각형을 가리키므로, 미터 좌표도 GeoJSON과 동일한
* 정규화 공간으로 들어간다 — 노선 선형을 도엽 레이어 위에 그대로 겹칠 수 있다.
*/
export function prepareMetricPolyline(
points: ReadonlyArray<{ x: number; y: number }>,
meta: VWorldMeta,
): PreparedLayer {
if (points.length < 2) return { features: [] };
const widthMeters = meta.width_meters || 1;
const heightMeters = meta.height_meters || 1;
const coords = new Float64Array(points.length * 2);
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
points.forEach((point, index) => {
const nx = (point.x - meta.x_min) / widthMeters;
const ny = 1 - (point.y - meta.y_min) / heightMeters;
coords[index * 2] = nx;
coords[index * 2 + 1] = ny;
if (nx < minX) minX = nx;
if (nx > maxX) maxX = nx;
if (ny < minY) minY = ny;
if (ny > maxY) maxY = ny;
});
return {
features: [
{
kind: "line",
parts: [{ coords, closed: false, weights: null }],
minX,
minY,
maxX,
maxY,
labelAnchorX: 0,
labelAnchorY: 0,
labelText: null,
},
],
};
/** 레이어 안의 피처 하나만 다시 그린다 — 고른 등고선을 도드라지게 할 때 쓴다. */
export function drawPreparedFeature(
context: CanvasRenderingContext2D,
layer: PreparedLayer,
index: number,
view: ViewState,
): void {
const feature = layer.features[index];
if (!feature || feature.kind !== "line") return;
drawLineParts(context, feature, affineOf(view));
}
/** 점과 선분 사이 거리(px). */
function pointSegmentDistance(
px: number,
py: number,
ax: number,
ay: number,
bx: number,
by: number,
): number {
const dx = bx - ax;
const dy = by - ay;
const lengthSquared = dx * dx + dy * dy;
if (lengthSquared <= 1e-9) return Math.hypot(px - ax, py - ay);
const ratio = Math.max(0, Math.min(1, ((px - ax) * dx + (py - ay) * dy) / lengthSquared));
return Math.hypot(px - (ax + dx * ratio), py - (ay + dy * ratio));
}
/**
@@ -563,6 +420,9 @@ function drawPointParts(
/** 컬링 여백: 선 굵기·X 마커 팔 길이·라벨 폭을 감안한 화면 밖 판정 마진(px). */
const CULL_MARGIN = 32;
/** 등고 라벨끼리 이만큼(px)은 떨어져야 둘 다 낸다 — 가로 여백과 줄 높이. */
const LABEL_GAP_PX = 10;
const LABEL_ROW_PX = 14;
function isVisible(feature: PreparedFeature, affine: Affine, view: ViewState): boolean {
const margin = CULL_MARGIN;
@@ -578,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();
}
}
@@ -0,0 +1,306 @@
/* =============================================================================
* B04_PreProcess_UI_MapRender_Prepare.ts
* 지도 레이어 **사전 투영** — GeoJSON·사업지 좌표 폴리라인을 정규화 좌표로 펴고,
* 줌 무손실 LOD 가중치(Douglas-Peucker)와 등고 라벨 앵커를 미리 잡아 둔다.
*
* `B04_PreProcess_UI_MapRender.ts` 가 700줄을 넘겨 떼어낸 조각이다(2026-09-12).
* 본문 로직과 수치는 그대로다. 그리기는 그쪽, 준비는 이쪽 — 한 방향으로만 기대어
* 순환 참조가 생기지 않는다.
* ========================================================================== */
import type { VWorldMeta } from "./B04_PreProcess_Api_Fetch";
import type {
GeoJsonCollection,
GeoJsonGeometry,
Normalizer,
PreparedFeature,
PreparedLayer,
PreparedPart,
} from "./B04_PreProcess_UI_MapRender";
function isPoint(value: unknown): value is [number, number] {
return Array.isArray(value) && typeof value[0] === "number" && typeof value[1] === "number";
}
/** lon/lat 배열 → 정규화 좌표 Float64Array. 유효 정점이 없으면 null. */
function projectRing(ring: unknown, normalizer: Normalizer): Float64Array | null {
if (!Array.isArray(ring) || ring.length === 0) return null;
const coords = new Float64Array(ring.length * 2);
let count = 0;
for (const point of ring) {
if (!isPoint(point)) continue;
coords[count * 2] = (point[0] - normalizer.lonMin) / normalizer.lonRange;
coords[count * 2 + 1] = 1 - (point[1] - normalizer.latMin) / normalizer.latRange;
count += 1;
}
if (count === 0) return null;
return count * 2 === coords.length ? coords : coords.slice(0, count * 2);
}
function collectParts(
geometry: GeoJsonGeometry,
normalizer: Normalizer,
parts: PreparedPart[],
): "line" | "point" {
const coordinates = geometry.coordinates;
if (!Array.isArray(coordinates)) return "line";
const push = (ring: unknown, closed: boolean): void => {
const projected = projectRing(ring, normalizer);
if (projected) parts.push({ coords: projected, closed, weights: null });
};
switch (geometry.type) {
case "Point":
push([coordinates], false);
return "point";
case "MultiPoint":
push(coordinates, false);
return "point";
case "LineString":
push(coordinates, false);
return "line";
case "MultiLineString":
for (const line of coordinates) push(line, false);
return "line";
case "Polygon":
for (const ring of coordinates) push(ring, true);
return "line";
case "MultiPolygon":
for (const polygon of coordinates) {
if (!Array.isArray(polygon)) continue;
for (const ring of polygon) push(ring, true);
}
return "line";
default:
return "line";
}
}
/**
* Douglas-Peucker 가중치 계산 (반복형, 스택 오버플로 방지).
* weights[i] = "허용 오차가 이 값보다 크면 정점 i를 버려도 되는" 임계값.
* 부모 구간의 오차로 상한을 걸어(cap) 어떤 허용 오차에서도 일관된 부분집합이 나오게 한다.
* y축은 1/aspect로 보정해 화면 픽셀 거리와 비례하는 좌표계에서 계산한다.
*/
function computeDpWeights(coords: Float64Array, aspect: number): Float64Array {
const n = coords.length / 2;
const weights = new Float64Array(n);
weights[0] = Infinity;
weights[n - 1] = Infinity;
if (n <= 2) return weights;
const stack: number[] = [0, n - 1];
const caps: number[] = [Infinity];
while (stack.length) {
const last = stack.pop()!;
const first = stack.pop()!;
const cap = caps.pop()!;
if (last - first < 2) continue;
const ax = coords[first * 2];
const ay = coords[first * 2 + 1] / aspect;
const bx = coords[last * 2];
const by = coords[last * 2 + 1] / aspect;
const dx = bx - ax;
const dy = by - ay;
const len = Math.sqrt(dx * dx + dy * dy);
let maxDist = -1;
let maxIndex = -1;
for (let i = first + 1; i < last; i += 1) {
const px = coords[i * 2] - ax;
const py = coords[i * 2 + 1] / aspect - ay;
const dist = len === 0 ? Math.sqrt(px * px + py * py) : Math.abs(px * dy - py * dx) / len;
if (dist > maxDist) {
maxDist = dist;
maxIndex = i;
}
}
const weight = Math.min(maxDist, cap);
weights[maxIndex] = weight;
stack.push(first, maxIndex, maxIndex, last);
caps.push(weight, weight);
}
return weights;
}
/** 등고 라벨 앵커: LineString/MultiLineString 첫 파트의 중앙 정점 (기존 동작 유지). */
function labelAnchorOf(geometry: GeoJsonGeometry, normalizer: Normalizer): [number, number] | null {
const coords = geometry.coordinates;
if (!Array.isArray(coords)) return null;
const line =
geometry.type === "LineString"
? coords
: geometry.type === "MultiLineString"
? coords[0]
: null;
if (!Array.isArray(line) || line.length === 0) return null;
const mid = line[Math.floor(line.length / 2)];
if (!isPoint(mid)) return null;
return [
(mid[0] - normalizer.lonMin) / normalizer.lonRange,
1 - (mid[1] - normalizer.latMin) / normalizer.latRange,
];
}
/**
* GeoJSON 컬렉션 1개를 사전 투영한다.
* labelKeys가 주어지면 계곡선(25m 배수) 피처에만 라벨 텍스트·앵커를 계산해 둔다.
*/
export function prepareLayer(
collection: GeoJsonCollection | undefined,
normalizer: Normalizer,
labelKeys?: string[],
): PreparedLayer {
const features: PreparedFeature[] = [];
for (const feature of collection?.features ?? []) {
if (!feature.geometry) continue;
const parts: PreparedPart[] = [];
const kind = collectParts(feature.geometry, normalizer, parts);
if (parts.length === 0) continue;
if (kind === "line") {
for (const part of parts) {
if (part.coords.length < 6) continue;
part.weights = computeDpWeights(part.coords, normalizer.aspect);
}
}
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
for (const part of parts) {
const coords = part.coords;
for (let i = 0; i < coords.length; i += 2) {
const x = coords[i];
const y = coords[i + 1];
if (x < minX) minX = x;
if (x > maxX) maxX = x;
if (y < minY) minY = y;
if (y > maxY) maxY = y;
}
}
let labelText: string | null = null;
let labelValue: number | null = null;
let labelAnchorX = 0;
let labelAnchorY = 0;
if (labelKeys && labelKeys.length > 0) {
const raw = labelKeys.map((key) => feature.properties?.[key]).find((value) => value != null);
const elevation = typeof raw === "number" ? raw : Number(raw);
// **모든 등고선**에 앵커를 잡아 둔다. 어느 줄을 실제로 낼지는 그릴 때 고른다 —
// 화면마다 솎는 눈금이 다르기 때문이다(B04 지도는 계곡선만, 계획노선 편집 모달은
// 확대에 따라 더 촘촘히). 준비 단계에서 걸러 버리면 확대해도 되살릴 수가 없다.
if (Number.isFinite(elevation)) {
const anchor = labelAnchorOf(feature.geometry, normalizer);
if (anchor) {
labelText = String(elevation);
labelValue = elevation;
labelAnchorX = anchor[0];
labelAnchorY = anchor[1];
}
}
}
features.push({
kind,
parts,
minX,
minY,
maxX,
maxY,
labelAnchorX,
labelAnchorY,
labelText,
labelValue,
});
}
return { features };
}
/**
* 사업지 좌표계(m) 폴리라인을 한 개 피처짜리 레이어로 사전 투영한다.
* meta의 x/y 범위와 lon/lat 범위는 같은 사각형을 가리키므로, 미터 좌표도 GeoJSON과 동일한
* 정규화 공간으로 들어간다 — 노선 선형을 도엽 레이어 위에 그대로 겹칠 수 있다.
*/
export function prepareMetricPolyline(
points: ReadonlyArray<{ x: number; y: number }>,
meta: VWorldMeta,
): PreparedLayer {
if (points.length < 2) return { features: [] };
const widthMeters = meta.width_meters || 1;
const heightMeters = meta.height_meters || 1;
const coords = new Float64Array(points.length * 2);
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
points.forEach((point, index) => {
const nx = (point.x - meta.x_min) / widthMeters;
const ny = 1 - (point.y - meta.y_min) / heightMeters;
coords[index * 2] = nx;
coords[index * 2 + 1] = ny;
if (nx < minX) minX = nx;
if (nx > maxX) maxX = nx;
if (ny < minY) minY = ny;
if (ny > maxY) maxY = ny;
});
return {
features: [
{
kind: "line",
parts: [{ coords, closed: false, weights: null }],
minX,
minY,
maxX,
maxY,
labelAnchorX: 0,
labelAnchorY: 0,
labelText: null,
labelValue: null,
},
],
};
}
/**
* 사업지 좌표계(m) 폴리라인 **여러 개**를 한 레이어로 사전 투영한다(LAS 등고선 등).
*
* `prepareMetricPolyline` 의 여러 줄 판이다. 줄마다 `label`(표고 m)을 주면 가운데 정점을
* 앵커로 잡아 `drawPreparedLabels` 가 그대로 쓸 수 있다.
*/
export function prepareMetricPolylines(
lines: ReadonlyArray<{ points: ReadonlyArray<readonly [number, number]>; label?: number }>,
meta: VWorldMeta,
): PreparedLayer {
const widthMeters = meta.width_meters || 1;
const heightMeters = meta.height_meters || 1;
const features: PreparedFeature[] = [];
for (const line of lines) {
if (line.points.length < 2) continue;
const coords = new Float64Array(line.points.length * 2);
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
line.points.forEach((point, index) => {
const nx = (point[0] - meta.x_min) / widthMeters;
const ny = 1 - (point[1] - meta.y_min) / heightMeters;
coords[index * 2] = nx;
coords[index * 2 + 1] = ny;
if (nx < minX) minX = nx;
if (nx > maxX) maxX = nx;
if (ny < minY) minY = ny;
if (ny > maxY) maxY = ny;
});
const middle = Math.floor(line.points.length / 2) * 2;
features.push({
kind: "line",
parts: [
{ coords, closed: false, weights: computeDpWeights(coords, widthMeters / heightMeters) },
],
minX,
minY,
maxX,
maxY,
labelAnchorX: coords[middle],
labelAnchorY: coords[middle + 1],
labelText: line.label === undefined ? null : String(line.label),
labelValue: line.label ?? null,
});
}
return { features };
}
@@ -33,8 +33,6 @@ import {
createNormalizer,
drawPreparedLabels,
drawPreparedLayer,
prepareLayer,
prepareMetricPolyline,
routeLineColor,
ROUTE_LINE_WIDTH,
type GeoJsonCollection,
@@ -44,6 +42,7 @@ import {
type PreparedLayer,
type ViewState,
} from "./B04_PreProcess_UI_MapRender";
import { prepareLayer, prepareMetricPolyline } from "./B04_PreProcess_UI_MapRender_Prepare";
import { drawStationTicks } from "./B04_PreProcess_UI_MapOverlays";
import type { WatershedAnalysis } from "./B04_PreProcess_Api_Fetch";
+59 -1
View File
@@ -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<Array<number | null>> {
const payload = await requestJson<{ z: Array<number | null> }>(
`/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<CrossPreviewResponse> {
return requestJson<CrossPreviewResponse>(
`/projects/${projectId}/route/cross-preview`,
{ method: "POST", body: JSON.stringify(request) },
120000,
);
}
/** 계획노선을 예상노선으로 되돌리고 같은 재계산을 돈다(노선 초기화). */
export async function resetRoutePlan(projectId: string): Promise<RouteReplanResponse> {
return requestJson<RouteReplanResponse>(
+31
View File
@@ -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:
+31 -6
View File
@@ -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():
+221
View File
@@ -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}
+4 -2
View File
@@ -15,13 +15,15 @@ import {
computeMapRect,
MAP_STATION_INTERVAL_M,
createNormalizer,
prepareLayer,
prepareMetricPolyline,
type MapRect,
type Normalizer,
type PreparedLayer,
type ViewState,
} from "../B04_PreProcess/B04_PreProcess_UI_MapRender";
import {
prepareLayer,
prepareMetricPolyline,
} from "../B04_PreProcess/B04_PreProcess_UI_MapRender_Prepare";
import { type RoutePoint } from "./B05_Profile_Api_Fetch";
import type { FlowArrow } from "../B04_PreProcess/B04_PreProcess_UI_FlowArrows";
import { buildStrengthArray } from "../B04_PreProcess/B04_PreProcess_UI_FlowRamp";
+14 -3
View File
@@ -281,9 +281,20 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
// 계획노선 편집 — 모달 [확인]에서 서버가 배수유역부터 다시 계산하므로, 끝나면
// 옛 노선 기준 캐시를 버리고 페이지를 새로 세운다([초기화]와 같은 뒷정리).
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 사용자 확정) — 이동은 막지 않는다.
+282 -296
View File
@@ -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<void>,
options: RouteEditOptions = {},
): Promise<void> {
const overlay = document.createElement("div");
overlay.className = "b05-routeedit";
overlay.innerHTML = `
<div class="b05-routeedit__box" role="dialog" aria-label="계획노선 편집">
<div class="b05-routeedit__head">
<strong> </strong>
<span class="b05-routeedit__hint">
= · = R · = ·
= · () = · =
</span>
<button type="button" class="b05-routeedit__close" aria-label="닫기"></button>
</div>
<div class="b05-routeedit__canvas-wrap"><canvas class="b05-routeedit__canvas"></canvas></div>
<div class="b05-routeedit__foot">
<span class="b05-routeedit__status"> </span>
<span class="b05-routeedit__legend">
<i class="is-expected"></i> ()
<i class="is-planned"></i>
</span>
<button type="button" class="b05-routeedit__btn" data-act="undo" title="되돌리기 (Ctrl+Z)"
disabled> </button>
<button type="button" class="b05-routeedit__btn" data-act="redo" title="다시하기 (Ctrl+Y)"
disabled> </button>
<button type="button" class="b05-routeedit__btn" data-act="history-reset"
title="이 창을 연 상태로 되돌립니다 (재계산 없음)" disabled></button>
<button type="button" class="b05-routeedit__btn" data-act="reset"></button>
<button type="button" class="b05-routeedit__btn" data-act="cancel"></button>
<button type="button" class="b05-routeedit__btn is-primary" data-act="apply"></button>
</div>
<div class="b05-routeedit__busy" hidden><span></span></div>
</div>`;
document.body.append(overlay);
const canvas = overlay.querySelector<HTMLCanvasElement>(".b05-routeedit__canvas")!;
const status = overlay.querySelector<HTMLElement>(".b05-routeedit__status")!;
const busy = overlay.querySelector<HTMLElement>(".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<number | null> = [];
@@ -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<HTMLElement>(".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<unknown>): Promise<void> {
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) {
@@ -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;
/** 화면 전체를 덮는 대기 막. 안에 `<span>` 한 개가 글을 받는다. */
busy: HTMLElement;
projectId: string;
/** 지금 편집값 — 누른 순간에 읽는다. */
nodes: () => { planned: Vertex[]; curveOn: boolean[]; curveRadius: Array<number | null> };
/** 성공하면 모달을 닫고 화면을 다시 읽는다. */
close: () => void;
onApplied: () => void | Promise<void>;
}
/** [확인]·[예상노선으로]를 붙인다. 리스너는 모달과 수명이 같다. */
export function bindRouteApply(params: RouteApplyParams): void {
const { overlay, busy, projectId } = params;
async function runHeavy(label: string, task: () => Promise<unknown>): Promise<void> {
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));
});
}
@@ -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: `<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true" fill="none"
stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
<path d="M13 8a5 5 0 0 0-10 0" /><path d="M3 8 1.2 5.6" /><path d="M3 8 5.4 6.6" /></svg>`,
cw: `<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true" fill="none"
stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 8a5 5 0 0 1 10 0" /><path d="M13 8 14.8 5.6" /><path d="M13 8 10.6 6.6" /></svg>`,
};
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 = `
<div class="b05-routeedit__box" role="dialog" aria-label="계획노선 편집">
<div class="b05-routeedit__head">
<strong> </strong>
<span class="b05-routeedit__actions">
<button type="button" class="ui-btn ui-btn--ghost" data-act="measure"
title="노선 위 두 점을 눌러 거리·기울기를 잽니다 (Shift+클릭도 같음)"> </button>
<i class="b05-routeedit__divider" aria-hidden="true"></i>
<button type="button" class="ui-btn ui-btn--ghost" data-act="undo"
title="되돌리기 (Ctrl+Z)" disabled> </button>
<button type="button" class="ui-btn ui-btn--ghost" data-act="redo"
title="다시하기 (Ctrl+Y)" disabled> </button>
<button type="button" class="ui-btn ui-btn--ghost" data-act="history-reset"
title="이 창을 연 상태로 되돌립니다 (재계산 없음)" disabled></button>
<button type="button" class="ui-btn ui-btn--ghost" data-act="reset"></button>
<button type="button" class="ui-btn ui-btn--ghost" data-act="cancel"></button>
<button type="button" class="ui-btn ui-btn--filled" data-act="apply"></button>
</span>
<button type="button" class="b05-routeedit__close" aria-label="닫기"></button>
</div>
<div class="b05-routeedit__canvas-wrap">
<canvas class="b05-routeedit__canvas"></canvas>
<div class="b05-routeedit__hint">
<span> = </span><span> = R </span>
<span> = </span><span> = </span>
<span> = </span><span>Shift+ = ·</span>
<span>() = </span><span> = </span>
</div>
<div class="b05-routeedit__spin">
<button type="button" class="ui-btn ui-btn--glass" data-act="rotate-ccw"
title="반시계로 돌리기" aria-label="반시계로 돌리기">${HALF_TURN_ICON.ccw}</button>
<button type="button" class="ui-btn ui-btn--glass" data-act="rotate-cw"
title="시계로 돌리기" aria-label="시계로 돌리기">${HALF_TURN_ICON.cw}</button>
</div>
<div class="b05-routeedit__measure" hidden>
<span class="b05-routeedit__measure-text"></span>
<button type="button" class="b05-routeedit__measure-close" aria-label="닫기"
title="닫기"></button>
</div>
<div class="b05-routeedit__info">
<span class="b05-routeedit__status"> </span>
<span class="b05-routeedit__legend">
<i class="is-expected"></i> ()
<i class="is-planned"></i>
</span>
</div>
</div>
<div class="b05-routeedit__busy" hidden><span></span></div>
</div>
<div class="b05-routeedit__side"></div>`;
document.body.append(overlay);
const canvas = overlay.querySelector<HTMLCanvasElement>(".b05-routeedit__canvas")!;
const status = overlay.querySelector<HTMLElement>(".b05-routeedit__status")!;
const measureBox = overlay.querySelector<HTMLElement>(".b05-routeedit__measure")!;
const measureText = overlay.querySelector<HTMLElement>(".b05-routeedit__measure-text")!;
const measureButton = overlay.querySelector<HTMLButtonElement>('[data-act="measure"]')!;
const busy = overlay.querySelector<HTMLElement>(".b05-routeedit__busy")!;
return { overlay, canvas, status, busy, measureBox, measureText, measureButton };
}
@@ -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<RouteEditContours> {
if (options.surfaceModelId !== null) {
// 받아 오는 간격은 프로젝트 설정 그대로(보관함에 이미 있는 파일을 쓰려는 것) —
// **보이는 눈금**은 아래에서 5m 로 맞춘다.
const interval = options.intervalM > 0 ? options.intervalM : 1;
try {
// 3D 뷰어가 쓰는 것과 **같은 파일**이다 — 보관함에 있으면 다시 내려받지 않는다.
const data = await fetchCachedJson<ContourResponse>(
projectId,
`${API_BASE_URL}/projects/${projectId}/surface/models/${options.surfaceModelId}` +
`/contour?interval=${interval}&smooth=${options.smooth}`,
);
const lines = (data.contours ?? [])
// 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;
}
@@ -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<void>;
/** 노선을 고쳤다 — 보던 측점을 **다시 셈해** 전후로 늘어놓는다. 보던 것이 없으면 아무 일도 없다. */
refresh: () => Promise<void>;
}
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 = `
<div class="b05-routeedit__cross-head">
<strong class="b05-routeedit__cross-title">${title}</strong>
<span class="b05-routeedit__cross-station"></span>
</div>
<canvas class="b05-routeedit__cross-canvas" width="420" height="240"></canvas>
<div class="b05-routeedit__cross-foot">${empty}</div>`;
const station = root.querySelector<HTMLElement>(".b05-routeedit__cross-station")!;
const foot = root.querySelector<HTMLElement>(".b05-routeedit__cross-foot")!;
const canvas = root.querySelector<HTMLCanvasElement>(".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<void> {
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);
},
};
}
@@ -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,
);
}
@@ -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<number | null>;
curveLock: CurveLock[];
curveArc: Array<number | null>;
/** 못 넘는 하한(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 };
}
@@ -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<boolean>,
curveRadius: Array<number | null>,
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<EditedNode>, 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[];
+102 -7
View File
@@ -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.
*
* ** ** ·· .
+76 -15
View File
@@ -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 {
<div class="b05-routeedit__label-head">
<span class="b05-routeedit__curve-label"></span>
<button type="button" class="b05-routeedit__label-toggle" data-act="curve-toggle"></button>
<button type="button" class="b05-routeedit__label-close" data-act="curve-close"
aria-label="닫기" title="닫기"></button>
</div>
<label class="b05-routeedit__curve-field">
<input type="number" class="b05-routeedit__curve-radius" min="1" step="0.5" />
@@ -119,8 +131,7 @@ export function createCurveLabel(handlers: CurveLabelHandlers): CurveLabel {
<button type="button" class="b05-routeedit__lock" data-act="lock-arc"
title="곡선 길이 고정 — 노드를 옮겨도 안 바뀝니다"></button>
</label>
<span class="b05-routeedit__curve-info"></span>
<span class="b05-routeedit__curve-note"> </span>`;
<span class="b05-routeedit__curve-info"></span>`;
document.body.append(root);
const head = root.querySelector<HTMLElement>(".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);
// 글자가 바뀌면 상자 높이가 한 박자 늦게 자란다 — 다음 그림 직전에 한 번 더 맞춘다.
@@ -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<void>;
/** 잰 것을 지운다 — 작은 창을 닫을 때(계획서 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();
},
};
}
@@ -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<Vertex>): 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<PreparedLayer>;
/** 고른 등고선 가닥 — 없으면 -1(계획서 0-9 ⑦). */
pickedContour: number;
/** 지금 화면에 낼 등고선 간격(m) — 그리기와 집기가 **같은 값**을 봐야 한다. */
contourStepM: number;
expected: ReadonlyArray<Vertex>;
/** 그려 보이는 계획노선(원호 포함). */
plannedLine: ReadonlyArray<Vertex>;
/** 잡아 옮기는 노드(꺾임점). */
planned: ReadonlyArray<Vertex>;
nodeInfo: ReadonlyArray<EditedNode>;
curveInfo: ReadonlyArray<EditedCurve>;
curveOn: ReadonlyArray<boolean>;
/** 지금 고른 꺾임점. 없으면 -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<Vertex>,
): 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<Vertex>,
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<Vertex>,
): 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<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();
}
@@ -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];
},
};
}
+165 -25
View File
@@ -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;
+49
View File
@@ -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 고정이다. 사용자가 화면에서 고른
+55 -7
View File
@@ -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)
@@ -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]
+132
View File
@@ -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 집계표, "내역서로 나가는 값은 집계표 「계」에서 서야 함"
@@ -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