fix(git): 병합이 떨군 파일 22개와 되돌아간 파일 35개를 되살림

무슨 일이 있었나
랩탑 줄의 병합 `20ba886c`(Merge origin/main_desktop_1·main_laptop_1·sub_desktop_1 into
sub_laptop_1)가 우리 파일 22개를 떨구고 35개 파일의 내용을 옛것으로 되돌림. 손으로 지운
커밋은 없고 **병합 자체가 떨군 것**임. 그것이 `origin/dev`·`main_laptop_1`·`sub_laptop_1`·
`CODEX` 까지 퍼졌고(데스크탑 둘만 무사), 이 창의 병합 `d92c1f2b` 로 들어옴.

잃었던 것
- 공용 — `common_util_provenance.py` · `ui_template_provenance.ts`
- B08 — 근거 사전 · 좌측 패널 상자 모듈 · 토량환산계수 칸
- B09 — 근거 사전 셋
- B05 — 계획노선 편집 모듈 아홉 · 지형 라우터 · B04 지도 모듈
- 시험 셋과, 35개 파일 안의 최근 작업(환산계수 고르기 · 근거 호버 배선 등)

어떻게 되살렸나
`611a2b40`(병합 직전, 전부 온전)에서 `git show <커밋>:<경로>` 로 내용만 꺼내 되돌림.
이력은 안 건드림. ⚠ HEAD 에만 있던 「추가 816줄」은 랩탑의 새 작업이 아니라 **되살아난
옛 코드**였음(B05 편집은 모듈로 쪼개기 전 덩어리 · B08 라우터는 환산계수 고르기 전 옛
상수판). 되돌릴 시점 이후의 **진짜 새 커밋은 둘뿐**이라 그 둘만 패치로 다시 얹음 —
`9f827bf6`(리로드 빌드 고리 끊기, 데스크탑 보조) · `b9bca6b3`(B06 조정창 1px, 랩탑).
위키 여덟은 코덱스 몫이라 손대지 않음.

자체검증 — 양쪽 작업이 다 살아 있음을 짚어 확인: `main.py` 의 「개발 서버는 살려 둔다」 ·
`B05_Profile_Engine_Grade.py` 의 `plan_curve_length_limit_m` · `B08_..._EarthworkGrid.ts` 의
`attachProvenance`. `tsc --noEmit` 통과 · `pytest -q` **1317 passed, 28 skipped**
(되살리기 전에는 시험 둘이 수집 단계에서 깨져 있었음).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017RANEBHns1S4tkmsYwewtk
This commit is contained in:
2026-09-12 18:18:57 +09:00
co-authored by Claude Opus 5
parent 216d027e9c
commit f952ac7ffd
55 changed files with 6030 additions and 764 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;
+4 -2
View File
@@ -73,13 +73,13 @@ from B06_Section.B06_Section_Schema import (
SectionRegenerateRequest,
SectionSummaryResponse,
)
from B06_Section.B06_Section_Server_Calc_Prebuild import conversion_factors_for
from common_util.common_util_auth import verify_session
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_workflow_state import get_workflow_state
from config.config_db import get_db_pool, run_with_connection
from config.config_system import (
EARTHWORK_CONVERSION_FACTORS,
EARTHWORK_HAUL_EQUIPMENT_LIMITS_M,
FOREST_ROAD_MIN_WIDTH_M,
NATURAL_SPOIL_MIN_GROUND_SLOPE,
@@ -144,7 +144,9 @@ async def get_section_context(project_id: UUID) -> SectionContextResponse | JSON
stored_standard_cross_section=stored_standard,
rock_boundary_default_offset_m=STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M,
rock_boundary_step_m=STANDARD_ROCK_BOUNDARY_STEP_M,
earthwork_conversion=EARTHWORK_CONVERSION_FACTORS,
# ⚠ 상수를 직접 들지 않는다 — 프로젝트가 고른 계수가 있으면 화면도 그 값으로
# 그려야 서버가 뒤에 다시 셈한 값과 갈리지 않는다(CLAUDE.md 5장).
earthwork_conversion=await conversion_factors_for(project_id),
haul_equipment_limits=[
HaulEquipmentLimit(key=key, max_distance_m=limit)
for key, limit in EARTHWORK_HAUL_EQUIPMENT_LIMITS_M
+4 -1
View File
@@ -25,6 +25,7 @@ from fastapi.responses import JSONResponse
from B06_Section.B06_Section_Server_Calc_Prebuild import (
BUNDLE,
_mass_haul_context,
conversion_factors_for,
haul_inputs_for,
)
from common_util.common_util_node_bundle import run_bundle_json
@@ -63,6 +64,8 @@ async def compute_haul_plan(
# 구조물 몫(공제·잔토)을 **넘겨야** 사토가 줄고 는다 — 인자 없이 부르면 늘 `None` 이라
# 통로만 있고 값이 안 흐른다(2026-09-09 실측으로 드러난 자리).
haul_inputs = await haul_inputs_for(project_id)
# 곡선이 쓰는 계수도 프로젝트가 고른 값으로 — 토적표·운반표와 같은 값이어야 한다.
factors = await conversion_factors_for(project_id)
try:
output = await asyncio.to_thread(
run_bundle_json,
@@ -70,7 +73,7 @@ async def compute_haul_plan(
_NPM_SCRIPT,
{
"haul_plan_for": result,
"context": _mass_haul_context(haul_inputs),
"context": _mass_haul_context(haul_inputs, factors),
},
)
except Exception:
@@ -38,6 +38,10 @@ from B06_Section.B06_Section_Repository import (
)
from B06_Section.B06_Section_Repository_Bulk import merge_cross_section_designs
from common_util.common_util_node_bundle import run_bundle_json
from common_util.common_util_project_settings import (
earthwork_conversion_factors,
quantity_settings,
)
from common_util.common_util_storage import resolve_stored_project_path
from config.config_db import get_db_pool, run_with_connection
from config.config_system import (
@@ -79,7 +83,25 @@ async def haul_inputs_for(project_id: Any) -> dict[str, Any]:
return {}
def _mass_haul_context(haul_inputs: dict[str, Any] | None = None) -> dict[str, Any]:
async def conversion_factors_for(project_id: Any) -> dict[str, dict[str, float]]:
"""이 프로젝트가 쓸 토량환산계수. 못 읽으면 정본 기본값 — 화면은 그대로 선다.
곡선·운반·토적표가 **같은 계수** 서야 한다. 그래서 상수를 직접 들지 않고 함수를
거친다(고른 값은 프로젝트 설정 `conversion_factors_override` 산다).
"""
try:
stored_path = await run_with_connection(get_project_storage_relative_path, project_id)
root = resolve_stored_project_path(stored_path)
except Exception:
logger.warning("B06 프로젝트 경로를 못 찾음 — 기본 계수로 진행: project_id=%s", project_id)
return {kind: dict(entry) for kind, entry in EARTHWORK_CONVERSION_FACTORS.items()}
return earthwork_conversion_factors(quantity_settings(root))
def _mass_haul_context(
haul_inputs: dict[str, Any] | None = None,
factors: dict[str, dict[str, float]] | None = None,
) -> dict[str, Any]:
"""유토곡선 계산에 필요한 값 — 화면이 `sections/context`로 받는 것과 같은 상수다.
채집석 공제(`collected_stone_deduction_m3`) 상수가 아니라 **B08 내는 **이다.
@@ -93,7 +115,8 @@ def _mass_haul_context(haul_inputs: dict[str, Any] | None = None) -> dict[str, A
"""
inputs = haul_inputs or {}
return {
"earthwork_conversion": EARTHWORK_CONVERSION_FACTORS,
# 프로젝트가 고른 계수가 있으면 그것, 없으면 정본 기본값.
"earthwork_conversion": factors or EARTHWORK_CONVERSION_FACTORS,
"natural_spoil_min_ground_slope": NATURAL_SPOIL_MIN_GROUND_SLOPE,
"haul_equipment_limits": [
{"key": key, "max_distance_m": limit}
@@ -196,7 +219,9 @@ async def _recompute(project_id: UUID | str, route_id: int) -> int:
_NPM_SCRIPT,
{
"detail": detail,
"context": _mass_haul_context(haul_inputs),
"context": _mass_haul_context(
haul_inputs, earthwork_conversion_factors(quantity_settings(project_root))
),
},
)
marks.append(("Node 번들(면적·유토곡선)", time.perf_counter()))
@@ -16,7 +16,8 @@
보정량 = 체적 × 토량환산계수(다짐)
절취한 흙이 다져지면 줄거나 부푼다. 성토에 있는 양으로 환산한 것이 보정량이다.
계수의 유일한 정의처는 `config.config_system_design.EARTHWORK_CONVERSION_FACTORS` 이며
여기서 값을 다시 적지 않는다.
여기서 값을 다시 적지 않는다. 프로젝트가 고른 값이 있으면 라우터가
`earthwork_conversion_factors(settings)` 풀어 `factors` 넘긴다 기본값은 그대로다.
측구터파기 토사· 설계가 가른 값을 그대로 읽는다
B06 지반 유형 + 암반 경계선으로 이미 갈라 냈다(`ditch_soil_area_m2`·
@@ -49,9 +50,13 @@ _FALLBACK_BASIS = "cut_area_ratio_fallback"
_FALLBACK_NOTE = "측구 가름값이 설계에 없어 절토 토사:암 면적비로 안분함"
def _factor(kind: str) -> float:
#: 계수 묶음의 모양 — `{지반유형: {"compacted": C}}`.
Factors = dict[str, dict[str, float]]
def _factor(kind: str, factors: Factors) -> float:
"""지반유형 → 다짐 환산계수. 모르는 유형이면 토사로 본다."""
entry = EARTHWORK_CONVERSION_FACTORS.get(kind) or EARTHWORK_CONVERSION_FACTORS["soil"]
entry = factors.get(kind) or factors["soil"]
return float(entry["compacted"])
@@ -155,8 +160,15 @@ def _split_ditch(area: StationArea) -> tuple[float, float, str]:
return ditch * soil / total, ditch * rock / total, _FALLBACK_BASIS
def build_rows(stations: Iterable[StationArea]) -> list[EarthworkRow]:
"""측점 목록 → 토적표 줄 목록. 측점은 이정 순으로 정렬해 받는다."""
def build_rows(
stations: Iterable[StationArea], factors: Factors | None = None
) -> list[EarthworkRow]:
"""측점 목록 → 토적표 줄 목록. 측점은 이정 순으로 정렬해 받는다.
`factors` 프로젝트가 고른 토량환산계수다(`earthwork_conversion_factors`).
주면 정본 기본값이 선다 설정을 읽는 자리(시험·되짚기) 위한 것이다.
"""
factors = factors or EARTHWORK_CONVERSION_FACTORS
ordered = sorted(stations, key=lambda s: s.chainage_m)
rows: list[EarthworkRow] = []
previous: StationArea | None = None
@@ -165,8 +177,8 @@ def build_rows(stations: Iterable[StationArea]) -> list[EarthworkRow]:
for station in ordered:
ditch_soil, ditch_rock, ditch_basis = _split_ditch(station)
soil_factor = _factor("soil")
rock_factor = _factor(station.cut_rock_kind or _DEFAULT_ROCK_KIND)
soil_factor = _factor("soil", factors)
rock_factor = _factor(station.cut_rock_kind or _DEFAULT_ROCK_KIND, factors)
row = EarthworkRow(
chainage_m=station.chainage_m,
cut_soil_area_m2=station.cut_soil_area_m2,
@@ -238,12 +250,17 @@ def totals(rows: list[EarthworkRow]) -> dict[str, float]:
return {key: sum(getattr(row, key) for row in rows) for key in keys}
def build_table(stations: Iterable[StationArea]) -> dict[str, Any]:
"""화면·API 가 그대로 쓰는 모양. 값은 자르지 않는다(PLAN 8-16)."""
rows = build_rows(stations)
def build_table(stations: Iterable[StationArea], factors: Factors | None = None) -> dict[str, Any]:
"""화면·API 가 그대로 쓰는 모양. 값은 자르지 않는다(PLAN 8-16).
`conversion_factors` **실제로 계수** 되싣는다 프로젝트가 고른 값이면
그것이 나가야 화면이 무엇으로 셌나 그대로 보인다.
"""
factors = factors or EARTHWORK_CONVERSION_FACTORS
rows = build_rows(stations, factors)
return {
"method": "average_end_area",
"conversion_factors": EARTHWORK_CONVERSION_FACTORS,
"conversion_factors": factors,
"rows": [row.__dict__ if not hasattr(row, "__slots__") else _as_dict(row) for row in rows],
"totals": totals(rows),
"station_count": len(rows),
@@ -43,14 +43,24 @@ GROUND_LABELS = {"ea_m3": "토사", "rr_m3": "리핑암", "br_m3": "발파암"}
GROUND_KIND_OF = {"토사": "soil", "리핑암": "ripping_rock", "발파암": "blasting_rock"}
def _factor_of(ground: str) -> float | None:
"""그 갈래의 다짐 환산계수 `C`. 모르면 `None`(받는 쪽이 환산했는지 되짚는 데 쓴다)."""
#: 계수 묶음의 모양 — `{지반유형: {"compacted": C}}`. 안 주면 정본 기본값이 선다.
Factors = dict[str, dict[str, float]]
def _factor_of(ground: str, factors: Factors | None = None) -> float | None:
"""그 갈래의 다짐 환산계수 `C`. 모르면 `None`(받는 쪽이 환산했는지 되짚는 데 쓴다).
`factors` 프로젝트가 고른 계수다(`earthwork_conversion_factors`) 주면 정본.
"""
kind = GROUND_KIND_OF.get(ground)
entry = EARTHWORK_CONVERSION_FACTORS.get(kind) if kind else None
table = factors or EARTHWORK_CONVERSION_FACTORS
entry = table.get(kind) if kind else None
return float(entry["compacted"]) if entry else None
def natural_m3(compacted_volume_m3: float, ground: str) -> float | None:
def natural_m3(
compacted_volume_m3: float, ground: str, factors: Factors | None = None
) -> float | None:
"""**다짐상태 → 자연상태**(÷ C). 내역서에 오르는 수량은 자연상태다.
근거 `config_system_design` 5-4-3 이미 적혀 있던 문장이다.
@@ -69,7 +79,8 @@ def natural_m3(compacted_volume_m3: float, ground: str) -> float | None:
갈래를 모르면 `None` 이다 토사 계수로 눅이면 근거 없이 금액이 움직인다.
"""
kind = GROUND_KIND_OF.get(ground)
entry = EARTHWORK_CONVERSION_FACTORS.get(kind) if kind else None
table = factors or EARTHWORK_CONVERSION_FACTORS
entry = table.get(kind) if kind else None
if not entry:
return None
factor = float(entry["compacted"])
@@ -189,8 +200,11 @@ def summarize(legs: Iterable[HaulLeg]) -> list[HaulSummaryRow]:
)
def build_table(plan: dict[str, Any] | None) -> dict[str, Any]:
"""화면·API 가 그대로 쓰는 모양. 내역 줄과 근거 줄을 함께 낸다."""
def build_table(plan: dict[str, Any] | None, factors: Factors | None = None) -> dict[str, Any]:
"""화면·API 가 그대로 쓰는 모양. 내역 줄과 근거 줄을 함께 낸다.
`factors` 프로젝트가 고른 토량환산계수다 다짐 자연 되돌리기가 값에 걸린다.
"""
legs = _legs_of(plan or {})
rows = summarize(legs)
return {
@@ -203,9 +217,9 @@ def build_table(plan: dict[str, Any] | None) -> dict[str, Any]:
"volume_m3": row.volume_m3,
"volume_basis": "compacted",
# 내역서에 오르는 수량 = **자연상태**(÷C). 갈래를 모르면 `None`.
"natural_m3": natural_m3(row.volume_m3, row.ground),
"natural_m3": natural_m3(row.volume_m3, row.ground, factors),
"natural_volume_basis": "natural",
"conversion_c": _factor_of(row.ground),
"conversion_c": _factor_of(row.ground, factors),
"average_distance_m": row.average_distance_m,
"work_m3m": row.work_m3m,
"legs": row.legs,
+473
View File
@@ -0,0 +1,473 @@
"""B08 수량 화면의 **근거 사전** — 어느 숫자가 어디서 와서 어떻게 나왔나 (PLAN 8-36 ④).
**개발 전용.** 사전은 `provenance_payload()` 거쳐 나가고, 개발환경이 아니면 `None`
이라 응답에 자체가 생긴다. 화면에서 숨기는 것이 아니라 ** 보내는 **이다.
파일인가
원천 정답은 값을 낳는 엔진이 안다. 화면 TS 손으로 적어 두면 엔진을
고칠 설명만 옛것으로 남는다. 엔진 (같은 폴더) 두어 같이 눈에 들어오게 한다.
** 단위로 적는다.** 토적표 장이 30 × 200 = 6 칸이라 칸마다 지으면 응답이
붐는다. 줄마다 갈리는 (측구 안분 폴백 사유 ) 줄이 이미 `notes` 들고 있고,
화면이 그것을 카드에 덧붙인다.
**토적표에는 `final`(최종) 열이 없다 억지로 붙이지 않았다.**
표는 중간 장부다. 내역서로 나가는 값은 **토공집계표**에서 선다. 여섯 등급을
장에 채우려고 아무 열에나 `final` 붙이면 분류가 있다 거짓만 남는다.
어긋남은 등급을 고칠 근거이므로 PLAN 8-36 그대로 남긴다.
"""
from __future__ import annotations
from typing import Any
from common_util.common_util_provenance import (
TIER_CALC,
TIER_FINAL,
TIER_INPUT,
TIER_STANDARD,
TIER_SURVEY,
ColumnProvenance,
provenance_payload,
sheet_provenance,
)
#: 토량환산계수가 어디서 오는지 — 여러 열이 같은 문장을 쓰므로 한 벌로 둔다.
_FACTOR_SOURCE = (
"토량환산계수(다짐) — 기본값 `config_system_design.EARTHWORK_CONVERSION_FACTORS`, "
"프로젝트가 고른 값이 있으면 산출 조건 패널의 값"
)
#: 단면적 넷의 공통 원천. B06 이 낸 설계 단면을 **그대로** 읽는다(여기서 다시 안 짓는다).
_SECTION_SOURCE = "B06 횡단 설계가 낸 측점별 단면적"
#: 평균단면적법 한 줄. 신규 문서 5장 「다. 공사수량의 산출」.
_MEAN_AREA = "(앞 측점 단면적 + 이 측점 단면적) ÷ 2 × 두 측점 사이 거리"
def _area(key: str, label: str, extra: str = "") -> ColumnProvenance:
"""단면적 열 — B06 설계값을 그대로 옮긴 자리라 식이 없다."""
return ColumnProvenance(
key=key,
label=label,
tier=TIER_SURVEY,
formula="설계가 낸 값을 그대로 읽음 (여기서 다시 계산하지 않음)",
source=_SECTION_SOURCE + (f" · {extra}" if extra else ""),
code="B08_Quantity_Engine_EarthworkTable.py:StationArea.from_design",
)
def _volume(key: str, label: str, area_label: str) -> ColumnProvenance:
return ColumnProvenance(
key=key,
label=label,
tier=TIER_CALC,
formula=_MEAN_AREA.replace("단면적", area_label),
source="첫 측점은 앞이 없어 비어 있음 (실무 토적표도 첫 줄 체적이 빈칸)",
code="B08_Quantity_Engine_EarthworkTable.py:200 mean_volume",
)
def _adjusted(key: str, label: str, volume_label: str) -> ColumnProvenance:
return ColumnProvenance(
key=key,
label=label,
tier=TIER_CALC,
formula=f"{volume_label} × 토량환산계수(다짐)",
source=_FACTOR_SOURCE,
code="B08_Quantity_Engine_EarthworkTable.py:210",
)
def earthwork_sheet() -> dict[str, Any]:
"""토적표 한 장의 사전. 열 키는 화면 `EarthworkRow` 와 같은 낱말이라야 한다."""
return sheet_provenance(
[
ColumnProvenance(
key="chainage_m",
label="측점",
tier=TIER_SURVEY,
formula="노선 시점에서 잰 이정(m). 화면은 NO.n+m 으로 적음",
source="B05 종단이 놓은 측점 배치",
code="B08_Quantity_Engine_EarthworkTable.py:StationArea.chainage_m",
),
ColumnProvenance(
key="distance_m",
label="거리",
tier=TIER_CALC,
formula="이 측점 이정 앞 측점 이정",
source="B05 종단 측점 배치. 첫 줄은 앞이 없어 0",
code="B08_Quantity_Engine_EarthworkTable.py:195",
),
_area("cut_soil_area_m2", "절토 토사 단면적"),
_volume("cut_soil_volume_m3", "절토 토사 입적", "절토 토사 단면적"),
_adjusted("cut_soil_adjusted_m3", "절토 토사 보정량", "절토 토사 입적"),
_area("cut_rock_area_m2", "절토 암석 단면적", "암 갈래는 측점의 `cut_rock_kind`"),
_volume("cut_rock_volume_m3", "절토 암석 입적", "절토 암석 단면적"),
_adjusted("cut_rock_adjusted_m3", "절토 암석 보정량", "절토 암석 입적"),
_area(
"ditch_soil_area_m2",
"측구터파기 토사 단면적",
"지반 유형·암반 경계선으로 B06 이 가른 값. 가름이 없는 옛 저장분만 "
"절토 토사:암 면적비로 안분하고 그 줄에 사유가 남음",
),
_volume("ditch_soil_volume_m3", "측구터파기 토사 입적", "측구 토사 단면적"),
_adjusted("ditch_soil_adjusted_m3", "측구터파기 토사 보정량", "측구 토사 입적"),
_area(
"ditch_rock_area_m2",
"측구터파기 암석 단면적",
"위와 같은 가름값. 0.0 은 설계가 낸 「없음」이고 값 없음과 다름",
),
_volume("ditch_rock_volume_m3", "측구터파기 암석 입적", "측구 암석 단면적"),
_adjusted("ditch_rock_adjusted_m3", "측구터파기 암석 보정량", "측구 암석 입적"),
ColumnProvenance(
key="adjusted_total_m3",
label="보정량계",
tier=TIER_CALC,
formula="절토 토사 보정량 + 절토 암석 보정량 + 측구 토사 보정량 + 측구 암석 보정량",
source="네 보정량의 합. 성토에 쓸 수 있는 양으로 환산한 뒤의 값",
code="B08_Quantity_Engine_EarthworkTable.py:215",
),
_area("fill_area_m2", "성토 단면적"),
_volume("fill_volume_m3", "성토 입적", "성토 단면적"),
ColumnProvenance(
key="diverted_m3",
label="유용토",
tier=TIER_CALC,
formula="min(보정량계, 성토 입적)",
source="그 측점에서 절취분과 성토분이 서로 만나는 몫",
code="B08_Quantity_Engine_EarthworkTable.py:222",
),
ColumnProvenance(
key="balance_m3",
label="차인토량",
tier=TIER_CALC,
formula="보정량계 성토 입적",
source="양수면 남는 흙(사토), 음수면 모자란 흙(객토)",
code="B08_Quantity_Engine_EarthworkTable.py:223",
),
ColumnProvenance(
key="cumulative_m3",
label="누가토량",
tier=TIER_CALC,
formula="첫 줄부터 이 줄까지 차인토량을 더해 온 값",
source="유토곡선(mass haul)의 세로축이 되는 값",
code="B08_Quantity_Engine_EarthworkTable.py:225",
),
]
)
def summary_sheet() -> dict[str, Any]:
"""토공집계표 — 토적표·사면표를 공종별 총량으로 모은 장.
**B08 에서 `final`(최종) 처음 서는 자리다.** 토적표는 중간 장부였고, 내역서로
나가는 값은 여기 . 다만 무대(소운반 20m)처럼 **집계에는 오르되 내역 줄이
되지 않는** 줄이 있어, 줄의 등급 `excluded` 덮어쓴다(화면 배선).
"""
return sheet_provenance(
[
ColumnProvenance(
key="group",
label="구분",
tier=TIER_STANDARD,
formula="품셈 공종 갈래 이름을 그대로 씀 (흙깎기·성토·측구터파기…)",
source="거창 실무 토공집계표 시트의 열 문구를 그대로 옮김",
code="B08_Quantity_Engine_EarthworkSummary.py:SummaryRow",
),
ColumnProvenance(
key="item",
label="공종",
tier=TIER_STANDARD,
formula="지반 갈래 이름 (토사·연암·발파암…)",
source="갈래 수는 프로젝트 설정의 암 갈래 세트가 정함 — 코드에 안 박음",
rule="암 총량을 설계자가 넣은 갈래 비율(%)로 나눠 줄을 만듦",
code="B08_Quantity_Engine_EarthworkSummary.py:_rock_split",
),
ColumnProvenance(
key="spec",
label="규격",
tier=TIER_STANDARD,
formula="시공 방법 표기 (기계(굴삭기)·백호우…)",
source="품셈 공종이 요구하는 규격. 암은 시공법(긁어내기/터뜨리기)이 갈림",
code="B08_Quantity_Engine_EarthworkSummary.py:SummaryRow",
),
ColumnProvenance(
key="unit",
label="단위",
tier=TIER_STANDARD,
formula="품셈 공종이 정한 단위 (㎥·㎡·주…)",
source="단위가 다르면 내역 단가와 안 맞음 — 여기서 정하지 않고 품셈을 따름",
code="B08_Quantity_Engine_EarthworkSummary.py:SummaryRow",
),
ColumnProvenance(
key="amount",
label="",
tier=TIER_FINAL,
formula="토적표·사면표 총량 × 반영률(%)",
source=(
"토공은 토적표 합계, 사면은 사면표 합계. ⚠ 반영률은 법정값이 아니라 "
"설계자가 넣는 값이고 기본 100 %"
),
rule="무대(소운반 20m)는 집계에는 오르되 내역 줄이 아님 — 그 줄은 「제외」로 섬",
code="B08_Quantity_Engine_EarthworkSummary.py:build_table",
),
]
)
def haul_sheet() -> dict[str, Any]:
"""운반거리 — (운반수단 × 지반유형)별 가중평균 줄.
**상태가 둘이다.** 거리는 다짐상태로 재고, 내역에 오르는 수량만 자연상태(÷C) 낸다
(설계실무 요령 5-4-3). 표의 토량 **다짐상태**이므로 내역서 수량과 숫자가 다르다
어긋남이 정상이라는 것을 카드가 말해 주어야 헛걸음을 한다.
"""
return sheet_provenance(
[
ColumnProvenance(
key="equipment",
label="운반수단",
tier=TIER_CALC,
formula="유토곡선이 띠마다 고른 수단 (무대·도자운반·덤프운반)",
source="B06 운반계획(HaulPlan)의 띠. 여기서 다시 고르지 않음",
code="B08_Quantity_Engine_HaulSummary.py:_legs_of",
),
ColumnProvenance(
key="ground",
label="지반유형",
tier=TIER_CALC,
formula="띠의 토량을 절토 구간 구성비로 안분한 세 갈래 (토사·리핑암·발파암)",
source="B06 운반계획이 이미 안분해 둔 값",
code="B08_Quantity_Engine_HaulSummary.py:_legs_of",
),
ColumnProvenance(
key="volume_m3",
label="토량",
tier=TIER_CALC,
formula="그 갈래에 속한 근거 구간들의 토량 합",
source=(
"⚠ **다짐상태**임. 내역서에 오르는 수량은 자연상태(÷토량환산계수)라 "
"숫자가 다름 — 어긋난 것이 아님"
),
code="B08_Quantity_Engine_HaulSummary.py:summarize",
),
ColumnProvenance(
key="average_distance_m",
label="평균운반거리",
tier=TIER_CALC,
formula="Σ(토량 × 거리) ÷ Σ(토량) — 단순평균이 아님",
source="실무 산출서가 「토량 × 거리」를 쌓아 나누는 그 식",
code="B08_Quantity_Engine_HaulSummary.py:119 average_distance_m",
),
ColumnProvenance(
key="legs",
label="근거 구간",
tier=TIER_CALC,
formula="이 평균을 만든 구간의 개수",
source="구간 줄은 버리지 않고 표 아래 근거로 함께 냄 — 되짚을 수 있어야 함",
code="B08_Quantity_Engine_HaulSummary.py:190",
),
]
)
def preparation_sheet() -> dict[str, Any]:
"""준비공·사방공 — **못 서는 줄도 서는 장.**
표를 내면 빠뜨린 원래 없는 구별되지 않는다. 그래서 값이 없는 줄도
상태와 사유를 달아 그대로 세운다. 값이 비어 있는 줄의 수량 등급 `blocked`
덮어쓴다 **근거가 오면 채워질 자리**이지 일부러 비운 자리가 아니다.
"""
return sheet_provenance(
[
ColumnProvenance(
key="group",
label="구분",
tier=TIER_STANDARD,
formula="준비공·사방공의 갈래 이름",
source="품셈 9장(준비공)과 배치된 구조물 종류가 줄을 만듦",
code="B08_Quantity_Engine_Preparation.py:build_table",
),
ColumnProvenance(
key="item",
label="공종",
tier=TIER_STANDARD,
formula="품셈 공종 이름 (표토제거·제근·임목파쇄…)",
source="공종이 없으면 줄도 없음 — 화면에서 이름을 짓지 않음",
code="B08_Quantity_Engine_Preparation.py:build_table",
),
ColumnProvenance(
key="unit",
label="단위",
tier=TIER_STANDARD,
formula="품셈 공종이 정한 단위",
source="단위가 다르면 내역 단가와 안 맞음",
code="B08_Quantity_Engine_Preparation.py:build_table",
),
ColumnProvenance(
key="amount",
label="수량",
tier=TIER_CALC,
formula="공종마다 다름 — 표토제거는 면적 × 표토 두께(T), 제근은 임목축적 등급",
source=(
"밑수는 사면표·구조물 목록이 내고, 두께·등급·개소는 산출 조건 패널에서 "
"설계자가 넣음"
),
rule="넣어야 할 값이 비면 줄은 서되 수량이 「-」로 남고 사유가 붙음",
code="B08_Quantity_Engine_Preparation.py:build_table",
),
ColumnProvenance(
key="status",
label="상태",
tier=TIER_CALC,
formula="값을 세웠나 못 세웠나",
source="못 세운 줄은 옆 칸에 사유가 붙음 — 사유가 곧 무엇을 넣어야 하는지임",
code="B08_Quantity_Engine_Preparation_Status.py",
),
]
)
def material_sheet() -> dict[str, Any]:
"""자재총괄 — 구조물 원단위에서 자재별로 모은 장. 관급/사급을 줄마다 고른다."""
return sheet_provenance(
[
ColumnProvenance(
key="name",
label="자재",
tier=TIER_STANDARD,
formula="품셈·카탈로그의 자재 이름",
source="구조물 원단위의 성분 이름을 그대로 모음 — 여기서 이름을 짓지 않음",
code="B08_Quantity_Engine_MaterialSummary.py",
),
ColumnProvenance(
key="unit",
label="단위",
tier=TIER_STANDARD,
formula="자재가 팔리는 단위 (㎥·본·kg…)",
source="단가가 붙는 단위와 같아야 함",
code="B08_Quantity_Engine_MaterialSummary.py",
),
ColumnProvenance(
key="net_amount",
label="순수량",
tier=TIER_CALC,
formula="구조물마다 낸 성분 수량의 합 (할증 전)",
source="구조물 원단위 표의 「수량」을 자재 이름으로 모은 값",
code="B08_Quantity_Engine_MaterialSummary.py",
),
ColumnProvenance(
key="surcharge_pct",
label="할증률",
tier=TIER_STANDARD,
formula="자재마다 정해진 할증률(%)",
source="할증 판(dataset)이 정함. 판에 없는 자재는 「-」로 두고 지어내지 않음",
code="B08_Quantity_Engine_MaterialSummary.py",
),
ColumnProvenance(
key="total_amount",
label="총수량",
tier=TIER_FINAL,
formula="순수량 × (1 + 할증률)",
source="내역서·자재대로 나가는 값. 할증률이 없으면 순수량 그대로",
code="B08_Quantity_Engine_MaterialSummary.py",
),
ColumnProvenance(
key="supply",
label="관급/사급",
tier=TIER_INPUT,
formula="설계자가 줄마다 고름",
source="자재마다 갈리는 발주 결정이라 표 안에서 고름 (2026-09-07 확정)",
code="B08_Quantity_UI_MaterialGrid.ts",
),
ColumnProvenance(
key="install_by",
label="설치 주체",
tier=TIER_INPUT,
formula="설계자가 줄마다 고름",
source="⚠ **관급 줄에만 뜻이 있음** — 사급으로 되돌리면 값이 비워짐",
code="B08_Quantity_UI_MaterialGrid.ts",
),
]
)
def unit_quantity_sheet() -> dict[str, Any]:
"""구조물 원단위 — 치수에서 성분까지. 성분마다 갈 곳을 적는다.
장은 **근거·출처 열을 이미 화면에 들고 있다**(2026-09-09 부터). 사전은 열이
무엇을 뜻하는지 설명하는 자리이지, 있는 값을 다시 만드는 자리가 아니다.
"""
return sheet_provenance(
[
ColumnProvenance(
key="structure",
label="구조물",
tier=TIER_SURVEY,
formula="B05 노선에 놓인 구조물의 이름과 놓인 측점",
source="측점 표기(NO.4 ~ NO.4+10)는 화면이 만듦 — 서버는 이정만 냄",
code="B08_Quantity_Engine_Handoff_Rows_Prep.py:182",
),
ColumnProvenance(
key="spec",
label="규격",
tier=TIER_SURVEY,
formula="구조물 제원 (길이 × 높이)",
source="B05·B06 이 배치할 때 정한 치수. 여기서 다시 정하지 않음",
code="B08_Quantity_Engine_UnitQuantity.py",
),
ColumnProvenance(
key="component",
label="성분",
tier=TIER_STANDARD,
formula="그 구조물이 쓰는 재료·공종 이름",
source="품셈 표 또는 실무 관측 원단위표가 정함",
code="B08_Quantity_Engine_UnitQuantity.py",
),
ColumnProvenance(
key="unit",
label="단위",
tier=TIER_STANDARD,
formula="성분이 세어지는 단위",
source="단가가 붙는 단위와 같아야 함",
code="B08_Quantity_Engine_UnitQuantity.py",
),
ColumnProvenance(
key="amount",
label="수량",
tier=TIER_CALC,
formula="치수 전개(길이·높이로 편 식) 또는 실무 관측 원단위 × 개소",
source="어느 쪽인지는 같은 줄의 「출처」 칸이 말해 줌 (치수 전개 / 실무 관측)",
rule="치수 전개는 식이 있고, 실무 관측은 관측값이라 식이 없음 — 둘을 섞지 않음",
code="B08_Quantity_Engine_UnitQuantity.py",
),
ColumnProvenance(
key="destination",
label="갈 곳",
tier=TIER_STANDARD,
formula="이 성분이 어느 표로 가는가 (자재총괄·공종 내역·양쪽)",
source=(
"⚠ 갈 곳이 겹치면 이중계상임 — 그것을 막으려고 성분마다 갈 곳을 적음 (PLAN 8-7)"
),
code="B08_Quantity_Engine_Handoff_Mapping.py",
),
]
)
def quantity_provenance() -> dict[str, Any] | None:
"""B08 응답에 실을 사전 — **개발환경이 아니면 `None`.**
시트를 늘릴 때는 여기 줄만 더한다. 화면은 시트 이름으로 찾아 쓴다.
"""
return provenance_payload(
{
"earthwork": earthwork_sheet(),
"summary": summary_sheet(),
"haul": haul_sheet(),
"preparation": preparation_sheet(),
"material": material_sheet(),
"unit_quantity": unit_quantity_sheet(),
}
)
+63 -13
View File
@@ -40,18 +40,24 @@ from B08_Quantity.B08_Quantity_Engine_Preparation import build_table as build_pr
from B08_Quantity.B08_Quantity_Engine_HaulSummary import summary_input_rows
from B08_Quantity.B08_Quantity_Engine_SlopeArea import build_table as build_slope_table
from B08_Quantity.B08_Quantity_Engine_SlopeLength import station_slopes
from B08_Quantity.B08_Quantity_Provenance import quantity_provenance
from common_util.common_util_project_settings import (
CONCRETE_PLACING_METHODS,
ROCK_METHODS,
application_ratio,
concrete_placing_method,
earthwork_conversion_choices,
earthwork_conversion_factors,
quantity_settings,
rock_classes,
save_section,
)
from common_util.common_util_storage import resolve_stored_project_path
from config.config_db import run_with_connection
from config.config_system_design import EARTHWORK_CONVERSION_FACTORS
from config.config_system_design import (
EARTHWORK_CONVERSION_FACTORS,
EARTHWORK_CONVERSION_PUMSEM_C_RANGES,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/projects", tags=["B08 Quantity"])
@@ -78,14 +84,24 @@ async def get_earthwork_table(project_id: UUID, route_id: int) -> JSONResponse:
status_code=500,
content={"status": "error", "message": "토적표를 만들지 못했습니다."},
)
table = build_table(_stations(designs))
# ⚠ 설정을 **먼저** 읽는다 — 토량환산계수를 프로젝트가 골랐으면 표가 그 값으로 서야 한다.
settings, project_root = await _project_settings(project_id)
factors = earthwork_conversion_factors(settings)
table = build_table(_stations(designs), factors)
# 화면이 「무엇을 골랐나 · 품셈 범위 안인가」를 보이는 데 쓴다. 계산에는 안 들어간다.
table["conversion_factor_choices"] = earthwork_conversion_choices(settings)
# 품셈 암종별 범위 — **화면 안내용**이다. 정의처가 서버 한 곳이라 내려보내 쓴다
# (프론트에 다시 적으면 두 벌이 되어 갈린다).
table["conversion_factor_pumsem_ranges"] = [
{"name": name, "min": low, "max": high}
for name, low, high in EARTHWORK_CONVERSION_PUMSEM_C_RANGES
]
# 사면 계열은 저장된 설계선에서 유도한다.
slope = build_slope_table(station_slopes(designs))
table["slope"] = slope
settings, project_root = await _project_settings(project_id)
plan = await _stored_haul_plan(project_id, route_id)
haul = build_haul_table(plan)
haul = build_haul_table(plan, factors)
# 사토 — **운반 줄이 되는 값**인데 유토곡선의 띠·이동에는 안 들어 있다(잔량으로 남는다).
# 여기서 그 값을 운반표에 실어 인계가 「사토 운반」 한 줄을 세우게 한다.
# ⚠ 거리는 품셈이 정하지 않는다 — 설계 입력(`spoil_site_distance_m`)이고 없으면 막힌다.
@@ -161,15 +177,22 @@ async def get_earthwork_table(project_id: UUID, route_id: int) -> JSONResponse:
table["settings"] = settings
table["project_root_known"] = project_root is not None
table["route_id"] = route_id
# 근거 사전(PLAN 8-36 ④) — ⚠ **개발환경에서만** 실린다. 운영에서는 `None` 이라
# 칸 자체가 안 생긴다 — 화면에서 숨기는 것이 아니라 안 보내는 것이 요점이다.
provenance = quantity_provenance()
if provenance is not None:
table["provenance"] = provenance
return JSONResponse(content=table)
#: 갈래 칸 ↔ 다짐 환산계수 `C`. 정의처는 `config_system_design` 한 곳뿐이다.
_COMPACTED_FACTOR = {
"ea_m3": float(EARTHWORK_CONVERSION_FACTORS["soil"]["compacted"]),
"rr_m3": float(EARTHWORK_CONVERSION_FACTORS["ripping_rock"]["compacted"]),
"br_m3": float(EARTHWORK_CONVERSION_FACTORS["blasting_rock"]["compacted"]),
}
#: 갈래 칸 ↔ 지반유형 이름. 계수의 정의처는 `config_system_design` 한 곳뿐이다.
_GROUND_KIND_OF = {"ea_m3": "soil", "rr_m3": "ripping_rock", "br_m3": "blasting_rock"}
def _compacted_factor(settings: dict[str, Any]) -> dict[str, float]:
"""갈래 칸 ↔ 다짐 환산계수 `C` — 프로젝트가 고른 값이 있으면 그것이 선다."""
factors = earthwork_conversion_factors(settings)
return {key: float(factors[kind]["compacted"]) for key, kind in _GROUND_KIND_OF.items()}
def _spoil_sites(designs: list[dict[str, Any]]) -> list[dict[str, Any]]:
@@ -295,10 +318,11 @@ def _spoil_of(
# 여기서 ÷C 한 값을 함께 내 받는 쪽이 **또 환산하지 않게** 한다.
# ⚠ 갈래를 못 붙인 몫은 계수가 없어 **환산하지 않는다** — 토사 계수로 눅이면 근거 없이
# 금액이 움직인다. 그 사실을 사유로 낸다.
compacted_factor = _compacted_factor(settings)
natural_by_ground = {
key: round(value / _COMPACTED_FACTOR[key], 3)
key: round(value / compacted_factor[key], 3)
for key, value in grounds.items()
if key in _COMPACTED_FACTOR
if key in compacted_factor
}
if unknown > 0:
note_parts.append(f"⚠ 갈래를 못 붙인 {unknown:,.2f}㎥ 는 상태도 못 되돌림")
@@ -417,6 +441,10 @@ class QuantitySettingsBody(BaseModel):
# 임목파쇄 — 기본 꺼짐(확정 5차 5번). 켜면 줄이 서고, 부피를 넣으면 값이 선다.
wood_chipping_enabled: bool | None = None
wood_chipping_volume_m3: float | None = None
# 토량환산계수(다짐) — `{갈래: {"compacted": C, "reason": 사유}}`.
# ⚠ **기본값을 복사해 넣지 않는다** — 안 고른 갈래는 키가 없어야 정본이 선다.
# 빈 dict 는 「전부 기본값으로 되돌림」이라 통째로 갈아 끼운다.
conversion_factors_override: dict[str, Any] | None = None
#: `None` 이 「안 정함」을 뜻하는 칸 — 저장에서 **버리지 않고 그대로 덮어쓴다**.
@@ -458,6 +486,21 @@ async def save_quantity_settings(project_id: UUID, body: QuantitySettingsBody) -
method = values["concrete_placing_method"]
# 「안 정함」으로 되돌릴 수 있어야 한다 — 빈 값이면 지운다(8-22 ② 와 같은 자리).
values["concrete_placing_method"] = method if method in CONCRETE_PLACING_METHODS else None
if "conversion_factors_override" in values:
# 아는 갈래·양수만 남긴다. 사유는 값이 있을 때만 따라간다(계산에는 안 쓴다).
cleaned: dict[str, Any] = {}
for kind, entry in (values["conversion_factors_override"] or {}).items():
if kind not in EARTHWORK_CONVERSION_FACTORS or not isinstance(entry, dict):
continue
value = entry.get("compacted")
if not isinstance(value, (int, float)) or isinstance(value, bool) or float(value) <= 0:
continue
kept: dict[str, Any] = {"compacted": float(value)}
reason = entry.get("reason")
if isinstance(reason, str) and reason.strip():
kept["reason"] = reason.strip()
cleaned[kind] = kept
values["conversion_factors_override"] = cleaned
if "rock_methods" in values:
# 「안 정함」(빈 값)은 저장하지 않는다 — 정한 것과 구별이 안 된다. 통째로 갈아 끼우므로
# 여기서 버리면 그 갈래는 미지정으로 돌아간다.
@@ -473,7 +516,14 @@ async def save_quantity_settings(project_id: UUID, body: QuantitySettingsBody) -
_save_quantity,
root,
values,
("rock_methods", "material_supply", "concrete_placing_method", "ancillary_counts")
(
"rock_methods",
"material_supply",
"concrete_placing_method",
"ancillary_counts",
# 고른 계수를 **기본값으로 되돌릴 길**이 있어야 한다 — 병합이면 못 지운다.
"conversion_factors_override",
)
+ NULLABLE_SETTING_KEYS,
)
except Exception:
+14 -9
View File
@@ -31,6 +31,7 @@ from B05_Profile.B05_Profile_Structures_Repository import load_structures
from B05_Profile.B05_Profile_Structures_Schema import structure_type_map
from B08_Quantity.B08_Quantity_Engine_Handoff import build_handoff, load_mapping, summarize
from B08_Quantity.B08_Quantity_Engine_MaterialSummary import build_table as build_material_table
from B08_Quantity.B08_Quantity_Provenance import quantity_provenance
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table as build_unit_table
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import (
ground_types_from_designs,
@@ -173,15 +174,19 @@ async def get_material_summary(project_id: UUID) -> JSONResponse:
# 조각이 없어도(원단위 자체가 없어 못 세운 경우) 사유는 보여야 한다.
if row.get("composite_parts") or row.get("composite_not_ready")
]
return JSONResponse(
content={
"unit_quantity": unit_table,
"material": material_table,
"composite": composite,
"skipped_structures": skipped,
"structure_count": len(structures),
}
)
body: dict[str, Any] = {
"unit_quantity": unit_table,
"material": material_table,
"composite": composite,
"skipped_structures": skipped,
"structure_count": len(structures),
}
# 근거 사전(PLAN 8-36 ④) — ⚠ **개발환경에서만** 실린다. 운영에서는 `None` 이라
# 칸 자체가 안 생긴다 — 화면에서 숨기는 것이 아니라 안 보내는 것이 요점이다.
provenance = quantity_provenance()
if provenance is not None:
body["provenance"] = provenance
return JSONResponse(content=body)
async def project_haul_inputs(project_id: UUID) -> dict[str, Any]:
@@ -0,0 +1,203 @@
/* =============================================================================
* B08_Quantity_UI_ConversionFactors.ts
* () .
*
* ( 06 3)
* **** .
* . () 1.0 ·
* . ** ** .
*
*
* `config_system_design.EARTHWORK_CONVERSION_FACTORS` .
* `conversion_factors_override` , ** **
* .
*
*
* . ** ** , .
*
*
* (B06) · · .
* .
* ========================================================================== */
import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale";
import type { ConversionFactorChoice, PumsemRange } from "./B08_Quantity_UI_EarthworkGrid";
/** locale 헬퍼 — 페이지 쪽과 같은 모양으로 둔다(문구는 `ui_template_locale_b2`). */
function L(key: keyof typeof ui_locales): string {
return ui_locales[key][currentLanguageIndex];
}
/** 화면이 들고 있는 고른 값 — `compacted` 가 `null` 이면 「안 고름」이라 저장에서 빠진다. */
export interface FactorDraft {
compacted: number | null;
reason: string;
}
/** 서버 키 → 사람이 읽는 이름. 모르는 키는 **지어내지 않고** 그대로 보인다. */
const GROUND_LABELS: Record<string, string> = {
soil: "토사",
ripping_rock: "리핑암",
blasting_rock: "발파암",
};
function groundLabel(kind: string): string {
return GROUND_LABELS[kind] ?? kind;
}
function hint(text: string): HTMLElement {
const row = document.createElement("p");
row.className = "b08-quantity__hint";
row.textContent = text;
return row;
}
/** 범위 밖인가 — 서버가 준 범위로 판정한다. 범위를 모르면 **밖이라고 하지 않는다.** */
function outOfRange(value: number, range: [number, number] | null): boolean {
if (!range) return false;
return value < range[0] || value > range[1];
}
/**
* + · + ( ) .
*
* .
* .
*/
function factorRow(
kind: string,
choice: ConversionFactorChoice,
draft: Record<string, FactorDraft>,
onChange: () => void,
): HTMLElement {
const box = document.createElement("div");
box.className = "b08-quantity__factor";
const row = document.createElement("label");
row.className = "b08-quantity__field";
const name = document.createElement("span");
name.textContent = groundLabel(kind);
const input = document.createElement("input");
input.type = "number";
input.className = "b08-quantity__input";
input.min = "0";
input.step = "0.01";
input.placeholder = String(choice.default);
const current = draft[kind]?.compacted;
input.value = current === null || current === undefined ? "" : String(current);
row.append(name, input);
box.append(row);
const range = (choice.range ?? null) as [number, number] | null;
box.append(
hint(
`${L("B08_Quantity_Factor_Default")} ${choice.default}` +
(range
? ` · ${L("B08_Quantity_Factor_Range")} ${range[0].toFixed(2)}~${range[1].toFixed(2)}`
: ""),
),
);
// 사유 칸은 **범위 밖일 때만** 선다 — 늘 띄우면 채우지 않아도 되는 칸으로 읽힌다.
const reasonRow = document.createElement("label");
reasonRow.className = "b08-quantity__field";
const reasonName = document.createElement("span");
reasonName.textContent = L("B08_Quantity_Factor_Reason");
const reasonInput = document.createElement("input");
reasonInput.type = "text";
reasonInput.className = "b08-quantity__input";
reasonInput.value = draft[kind]?.reason ?? "";
reasonRow.append(reasonName, reasonInput);
const warning = hint(L("B08_Quantity_Factor_OutOfRange"));
warning.classList.add("b08-quantity__hint--warn");
const sync = (): void => {
const value = input.value.trim() === "" ? null : Number(input.value);
const outside = value !== null && Number.isFinite(value) && outOfRange(value, range);
warning.hidden = !outside;
reasonRow.hidden = !outside;
};
input.addEventListener("input", () => {
const raw = input.value.trim();
const value = raw === "" ? null : Number(raw);
draft[kind] = {
compacted: value !== null && Number.isFinite(value) ? value : null,
reason: draft[kind]?.reason ?? "",
};
sync();
onChange();
});
reasonInput.addEventListener("input", () => {
draft[kind] = {
compacted: draft[kind]?.compacted ?? null,
reason: reasonInput.value,
};
onChange();
});
box.append(warning, reasonRow);
sync();
return box;
}
/**
* () . .
*
* `choices` ( ) ** ** .
*/
export function renderConversionFactorFields(
choices: Record<string, ConversionFactorChoice> | undefined,
pumsem: PumsemRange[] | undefined,
draft: Record<string, FactorDraft>,
onChange: () => void,
): HTMLElement | null {
const entries = Object.entries(choices ?? {});
if (!entries.length) return null;
const box = document.createElement("div");
// 제 제목을 제 안에 들고 있어 이 구획 자신이 공용 접기 컨테이너가 된다(B03~B07 과 같은 틀).
box.className = "b08-quantity__factors ui-collapsible";
const title = document.createElement("div");
title.className = "b08-quantity__field ui-collapsible__title";
const titleName = document.createElement("span");
titleName.textContent = L("B08_Quantity_Side_Factors");
title.append(titleName);
box.append(title);
// 어디까지 닿는 값인지 먼저 보인다 — 토적표만 바뀌는 줄 알면 함부로 고친다.
box.append(hint(L("B08_Quantity_Factor_Reach")));
for (const [kind, choice] of entries) {
box.append(factorRow(kind, choice, draft, onChange));
}
// 품셈 암종별 범위 — 서버가 내려 준 값을 그대로 보인다(화면에 다시 적지 않는다).
if (pumsem?.length) {
box.append(
hint(
`${L("B08_Quantity_Factor_Pumsem")}` +
pumsem
.map((item) => `${item.name} ${item.min.toFixed(2)}~${item.max.toFixed(2)}`)
.join(" · "),
),
);
}
return box;
}
/** 저장 몸통에 실을 모양 — **고른 갈래만** 담는다. 빈 dict 는 「전부 기본값」이다. */
export function conversionOverridePayload(
draft: Record<string, FactorDraft>,
): Record<string, { compacted: number; reason?: string }> {
const payload: Record<string, { compacted: number; reason?: string }> = {};
for (const [kind, entry] of Object.entries(draft)) {
if (entry.compacted === null || !Number.isFinite(entry.compacted) || entry.compacted <= 0) {
continue;
}
payload[kind] = entry.reason.trim()
? { compacted: entry.compacted, reason: entry.reason.trim() }
: { compacted: entry.compacted };
}
return payload;
}
+56 -2
View File
@@ -14,6 +14,13 @@
* ( ) .
* ========================================================================== */
import {
attachProvenance,
markProvenanceCell,
type ProvenancePayload,
type ProvenanceSheet,
} from "@ui/ui_template_provenance";
/** 서버가 주는 토적표 한 줄. 이름은 엔진(`B08_Quantity_Engine_EarthworkTable.py`)과 같다. */
export interface EarthworkRow {
chainage_m: number;
@@ -61,6 +68,8 @@ export interface SlopeTable {
/** 산출 조건 — `project_settings.json` 의 `quantity` 구획. */
export interface QuantitySettings {
/** 고른 토량환산계수 — `{갈래: {compacted, reason?}}`. 안 고르면 키가 없다. */
conversion_factors_override?: Record<string, { compacted?: number; reason?: string }> | null;
rock_class_set?: string;
rock_classes?: string[];
rock_ratios_pct?: Record<string, number>;
@@ -95,6 +104,25 @@ export interface QuantitySettings {
wood_chipping_volume_m3?: number | null;
}
/** 갈래 하나의 「무엇을 골랐나」. 서버 `earthwork_conversion_choices` 와 짝이다. */
export interface ConversionFactorChoice {
compacted: number;
default: number;
/** 기본값과 다른 값을 골랐나. */
chosen: boolean;
/** 품셈 범위 안인가. 밖이어도 **막지 않고** 사유를 받는다. */
in_range: boolean;
range: [number, number] | null;
reason: string | null;
}
/** 품셈 암종별 체적변화율 범위 — **화면 안내용**이고 계산에 안 쓴다. */
export interface PumsemRange {
name: string;
min: number;
max: number;
}
export interface EarthworkTable {
method: string;
station_count: number;
@@ -109,6 +137,12 @@ export interface EarthworkTable {
/** 운반계획은 [저장]·[확정]에서 정본에 남는 값 — 아직 없으면 false. */
haul_available?: boolean;
settings?: QuantitySettings;
/** 갈래별 토량환산계수 선택 상태 — 산출 조건 패널이 그린다. */
conversion_factor_choices?: Record<string, ConversionFactorChoice>;
/** 품셈 암종별 범위(안내용). 정의처가 서버라 내려받아 보인다. */
conversion_factor_pumsem_ranges?: PumsemRange[];
/** 근거 사전 — ⚠ **개발환경에서만** 실려 온다. 운영에서는 칸 자체가 없다. */
provenance?: ProvenancePayload;
}
/** 표 칸에 들어갈 수 있는 열 — 숫자 칸만 고른다(사유·주기는 표 밖이다). */
@@ -309,7 +343,11 @@ function buildHead(): HTMLTableSectionElement {
return head;
}
function buildBody(rows: EarthworkRow[], slope?: SlopeTable): HTMLTableSectionElement {
function buildBody(
rows: EarthworkRow[],
slope?: SlopeTable,
sheet?: ProvenanceSheet,
): HTMLTableSectionElement {
const body = document.createElement("tbody");
const columns = flatColumns();
const slopeByChainage = new Map((slope?.rows ?? []).map((row) => [row.chainage_m, row]));
@@ -321,9 +359,15 @@ function buildBody(rows: EarthworkRow[], slope?: SlopeTable): HTMLTableSectionEl
td.textContent =
index === 0 ? stationLabel(row.chainage_m) : cell(row[column.key], column.digits);
if (index === 0) td.className = "b08-grid__station";
// 근거 호버·등급색은 **사전이 왔을 때만** 붙는다(개발환경).
const columnProvenance = sheet?.columns[column.key];
if (columnProvenance) markProvenanceCell(td, column.key, columnProvenance.tier);
tr.append(td);
});
// 줄마다 갈리는 사유(측구 안분 폴백 등)는 열 사전이 못 든다 — 줄에 실어 카드가 덧붙게 한다.
if (row.notes?.length) tr.dataset.provNotes = row.notes.join("\n");
const slopeRow = slopeByChainage.get(row.chainage_m);
// 사면이 원지반을 못 만난 측점은 값이 잘려 있다 — 줄에 표시를 남긴다(PLAN 8-4b).
if (slopeRow?.unclosed) tr.classList.add("is-unclosed");
@@ -433,11 +477,21 @@ export function renderEarthworkGrid(table: EarthworkTable): HTMLElement {
scroller.className = "b08-grid__scroll";
const element = document.createElement("table");
element.className = "b08-grid__table";
const sheet = table.provenance?.sheets?.earthwork;
element.append(
buildHead(),
buildBody(table.rows, table.slope),
buildBody(table.rows, table.slope, sheet),
buildFoot(table.totals, table.slope),
);
// 사전이 없으면 아무 일도 안 한다 — 빈 카드를 띄우면 「설명이 있다」는 거짓만 남는다.
// 줄 사유는 **그 사유가 닿는 열에만** 붙인다. 줄에 달렸다고 십몇 칸에 다 띄우면
// 「절토 보정량」 카드에 「측구 가름값…」 이 떠서 읽는 사람을 속인다(2026-09-12 실측).
// ⚠ 지금 줄 사유는 **측구 안분 폴백 하나뿐**이라 여기서 열 이름으로 가른다.
// 사유가 늘면 엔진이 「어느 열에 닿는 사유인가」를 같이 내는 쪽이 맞다.
attachProvenance(element, sheet, (cell, columnKey) => {
if (!columnKey.startsWith("ditch_")) return [];
return (cell.closest("tr")?.dataset.provNotes ?? "").split("\n").filter(Boolean);
});
if (table.slope) {
const notice = buildUnclosedNotice(table.slope, element);
@@ -186,6 +186,38 @@ const CSS = `
.b08-quantity__field-value { color: var(--color-text-secondary); font-variant-numeric: tabular-nums; }
/* 칸 밑 근거 한 줄 — 왜 그 값인지 화면에서 보이게 한다(2026-09-09 사용자 지시). */
.b08-quantity__hint { margin: 0 0 6px; font-size: 11px; line-height: 1.4; color: var(--color-text-secondary); }
/* 품셈 범위 밖을 고른 칸 — **막지 않고** 사유를 받는 자리라 경고 색만 준다. */
.b08-quantity__hint--warn { color: var(--color-warning, #b45309); }
/* B03~B07 ( ui-collapsible
+ ui-sidebar-section + ui-sidebar-actions).
.ui-sidebar-section(ui_template_overlay.css)
border (B04 ). */
.b08-quantity__panel { display: flex; flex-direction: column; gap: 8px; }
.b08-quantity__section {
display: flex;
flex-direction: column;
gap: 2px;
margin: 0;
padding: 10px 12px;
border-radius: var(--radius-cards);
background-color: var(--color-surface-raised);
}
.b08-quantity__section-title {
margin: 0 0 2px;
font-size: 12px;
font-weight: 600;
color: var(--color-text-secondary);
}
/* ui-sidebar-actions ** **
( )
(2026-09-12 ). . */
.b08-quantity__dev { display: flex; flex-direction: column; gap: 4px; }
.b08-quantity__dev-actions { display: flex; gap: 8px; }
.b08-quantity__dev-actions > * { flex: 1 1 0; min-width: 0; }
.b08-quantity__note { margin: 0; font-size: 11px; line-height: 1.4; color: var(--color-text-secondary); }
/* 토량환산계수 구획 — 갈래마다 (값 · 안내 · 사유)가 한 덩어리로 붙는다. */
.b08-quantity__factors { margin: 4px 0 10px; }
.b08-quantity__factor { margin-bottom: 6px; }
`;
/** 스타일을 한 번만 넣는다 — 페이지를 다시 그려도 중복되지 않는다. */
@@ -12,6 +12,12 @@
* ========================================================================== */
import { stationLabel } from "./B08_Quantity_UI_EarthworkGrid";
import {
attachProvenance,
markProvenanceCell,
type ProvenancePayload,
type ProvenanceSheet,
} from "@ui/ui_template_provenance";
export interface MaterialRow {
name: string;
@@ -27,6 +33,38 @@ export interface MaterialRow {
sources: string[];
}
/** 줄 하나의 칸에 열 키를 차례대로 심는다 (집계표 쪽과 같은 틀). */
function markRow(tr: HTMLTableRowElement, keys: readonly (string | null)[]): void {
[...tr.children].forEach((cell, index) => {
const key = keys[index];
if (key) markProvenanceCell(cell as HTMLElement, key);
});
}
/** 자재총괄 열 차례 — 비고는 사전을 안 붙인다. */
const MATERIAL_KEYS = [
"name",
"unit",
"net_amount",
"surcharge_pct",
"total_amount",
"supply",
"install_by",
null,
] as const;
/** 구조물 원단위 열 차례 — 근거·출처는 **이미 설명 글**이라 카드를 거듭 안 띄운다. */
const UNIT_QUANTITY_KEYS = [
"structure",
"spec",
"component",
"unit",
"amount",
"destination",
null,
null,
] as const;
export interface MaterialTable {
columns: string[];
rows: MaterialRow[];
@@ -95,6 +133,8 @@ export interface MaterialResponse {
skipped_structures: string[];
structure_count: number;
/** 인계에서 온 묶음 조각 — 화면이 「무엇으로 나뉘어 서는지」를 보인다. */
/** 근거 사전 — ⚠ **개발환경에서만** 실려 온다. 운영에서는 칸 자체가 없다. */
provenance?: ProvenancePayload;
composite?: {
name: string;
parts: CompositePart[];
@@ -244,6 +284,7 @@ function spreadLine(spread: MaterialTable["amount_spread"], title: string): HTML
export function renderMaterialGrid(
table: MaterialTable,
options?: MaterialGridOptions,
sheet?: ProvenanceSheet,
): HTMLElement {
const wrap = document.createElement("div");
wrap.className = "b08-grid";
@@ -330,10 +371,12 @@ export function renderMaterialGrid(
tr.append(textCell(row.install_by_label));
}
tr.append(textCell(row.note, "b08-grid__note"));
markRow(tr, MATERIAL_KEYS);
body.append(tr);
}
element.append(body);
attachProvenance(element, sheet);
scroller.append(element);
wrap.append(scroller);
return wrap;
@@ -465,11 +508,15 @@ export function renderUnitQuantityGrid(response: MaterialResponse): HTMLElement
// 나중에 「이 값이 왜 이런가」를 되짚을 수 있다.
const kind = component.basis_kind === "observed" ? "실무 관측" : "치수 전개";
tr.append(textCell(component.reuse_count ? `${kind} · ${component.reuse_count}` : kind));
markRow(tr, UNIT_QUANTITY_KEYS);
body.append(tr);
}
}
element.append(body);
// 성분이 없는 줄은 칸을 붙여 쌀으므로(`colSpan`) 짚지 않았다 — 차례가 어긋나면
// 엉뚱한 열의 설명이 뜼다.
attachProvenance(element, response.provenance?.sheets?.unit_quantity);
scroller.append(element);
wrap.append(scroller);
return wrap;
+71 -32
View File
@@ -11,6 +11,9 @@ import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale";
import { createButton, showToast } from "@ui/ui_template_elements";
import { API_BASE_URL, CURRENT_PROJECT_ID_KEY } from "@config/config_frontend";
import { createWorkflowLayout } from "@ui/ui_template_workflow_layout";
import { attachCollapsible } from "@ui/ui_template_collapsible";
import { groupPanelSections } from "./B08_Quantity_UI_SidePanel_Sections";
import { createProvenanceToggle } from "@ui/ui_template_provenance";
import { workflowSteps } from "../A00_Common/b_page_scaffold";
import {
fetchWorkflowState,
@@ -18,6 +21,11 @@ import {
WORKFLOW_STEP_ROUTES,
} from "../A00_Common/b_workflow_nav";
import { renderEarthworkGrid, type EarthworkTable } from "./B08_Quantity_UI_EarthworkGrid";
import {
conversionOverridePayload,
renderConversionFactorFields,
type FactorDraft,
} from "./B08_Quantity_UI_ConversionFactors";
import { injectEarthworkGridStyles } from "./B08_Quantity_UI_EarthworkGrid_Style";
import {
renderHaulGrid,
@@ -98,6 +106,8 @@ async function saveQuantitySettings(projectId: string, draft: DraftSettings): Pr
wood_chipping_volume_m3: draft.wood_chipping_volume_m3,
// 개소는 **통째로** 보낸다 — 지운 항목까지 그대로 가야 되돌릴 길이 있다.
ancillary_counts: draft.ancillary_counts,
// 토량환산계수 — 고른 갈래만 담긴다. 빈 dict 는 「전부 기본값으로 되돌림」이다.
conversion_factors_override: conversionOverridePayload(draft.conversion_factors),
}),
},
);
@@ -243,16 +253,6 @@ function selectField(
}
/** 지반 종류 표기 — 서버 키가 화면에 새지 않게. 모르는 키는 그대로 보인다. */
const GROUND_LABELS: Record<string, string> = {
soil: "토사",
ripping_rock: "리핑암",
blasting_rock: "발파암",
};
function groundLabel(kind: string): string {
return GROUND_LABELS[kind] ?? kind;
}
/** 자재 한 줄의 관급/사급. `install_by` 는 **관급 줄에만** 뜻이 있다. */
export interface SupplyChoice {
supply: string;
@@ -291,6 +291,8 @@ interface DraftSettings {
wood_chipping_volume_m3: number | null;
// 자재별 관급/사급 — 표 안에서 줄마다 고른 값.
material_supply: Record<string, SupplyChoice>;
// 갈래별 토량환산계수(다짐) — `compacted` 가 `null` 이면 「안 고름」이라 기본값이 선다.
conversion_factors: Record<string, FactorDraft>;
dirty: boolean;
}
@@ -354,7 +356,10 @@ function devUnlockRow(projectId: string, reload: () => void): HTMLElement {
onClick: () => call("DELETE", relock),
});
const buttons = document.createElement("div");
buttons.className = "b08-quantity__actions ui-sidebar-actions";
// ⚠ 공용 `ui-sidebar-actions` 를 쓰지 않는다 — 공용 코드가 **첫 번째** 그 클래스를
// 패널 바닥 액션 줄로 집어(`ui_template_overlay.ts` splitSidebarActions),
// 스크롤 영역이 이 줄 안에 갇히고 아래 칸들이 통째로 잘린다(2026-09-12).
buttons.className = "b08-quantity__dev-actions";
buttons.append(unlock, relock);
row.append(buttons);
return row;
@@ -383,18 +388,18 @@ function buildQuantitySidePanel(
panel.append(devUnlockRow(projectId, reload));
}
// 계수는 서버 상수가 유일한 정의처다 — 화면은 보여 주기만 하고 값을 다시 적지 않는다.
panel.append(field(L("B08_Quantity_Side_Method"), L("B08_Quantity_Side_Method_Value")));
const entries = Object.entries(table?.conversion_factors ?? {});
if (entries.length) {
panel.append(field(L("B08_Quantity_Side_Factors"), ""));
for (const [kind, value] of entries) {
// ⚠ 서버 키(`soil`·`ripping_rock`·`blasting_rock`)를 그대로 내보내지 않는다 —
// 2026-09-08 ㉕ 화면 통과에서 좌측 세 줄이 개발자 키로 떠 있었다(`soil_guard` 와 같은 병).
// 모르는 키는 **지어내지 않고** 그대로 보인다.
panel.append(field(groundLabel(kind), String((value as { compacted: number }).compacted)));
}
}
// 토량환산계수 — **고를 수 있는 값**이다(오솔길 대조 06절 3번). 기본값 정의처는 서버 한 곳이고,
// 화면은 고른 값만 보낸다. 유토곡선·운반표·기초단가가 같이 읽는다는 안내도 그 칸이 낸다.
const factorFields = renderConversionFactorFields(
table?.conversion_factor_choices,
table?.conversion_factor_pumsem_ranges,
draft.conversion_factors,
() => {
draft.dirty = true;
},
);
if (factorFields) panel.append(factorFields);
// ── 지반 구성비 — 갈래 수는 프로젝트 세트가 정한다(코드에 안 박음, PLAN 8-13) ──
const classes = [...(table?.summary?.rock_classes ?? [])];
@@ -641,6 +646,8 @@ function buildQuantitySidePanel(
},
),
);
// ⚠ `?.` 이 빠지면 표를 못 받은 때(`table === null`) 여기서 터져 **페이지가 통째로
// 백지**가 된다 — 정작 보여야 할 「표를 못 불렀다」 안내까지 같이 사라진다(2026-09-12 실측).
const placing = (
table as unknown as {
concrete_placing?: {
@@ -648,8 +655,8 @@ function buildQuantitySidePanel(
is_default: boolean;
price_hint?: { basis?: string; values?: Record<string, number> };
};
}
).concrete_placing;
} | null
)?.concrete_placing;
if (placing) {
// ⚠ 방식 이름을 **늘** 값 옆에 보인다 — 코드(`12-01-01`)만으로는 무엇을 쓰는지 모른다.
const label = PLACING_LABELS[placing.method] ?? placing.method;
@@ -744,6 +751,10 @@ function buildQuantitySidePanel(
// TODO(미결) — 설정의 초기값을 무엇으로 볼지 사용자 확인 뒤에 붙인다.
actions.append(saveButton, confirmButton);
panel.append(actions);
// 조건 칸을 B03~B07 공통 상자로 묶고 제목 클릭으로 접히게 한다.
groupPanelSections(panel);
attachCollapsible(panel);
return panel;
}
@@ -769,6 +780,14 @@ function buildQuantityBody(
return element;
};
// 등급색 토글 — ⚠ **사전이 왔을 때만** 만든다(개발환경). 배포 빌드에서는 단추 자체가 없다.
if (
(table as unknown as { provenance?: unknown } | null)?.provenance ||
(material as unknown as { provenance?: unknown } | null)?.provenance
) {
tabs.append(createProvenanceToggle(body));
}
if (failed) {
body.append(tabs, message(L("B08_Quantity_Grid_Failed")));
return body;
@@ -783,13 +802,19 @@ function buildQuantityBody(
{
label: L("B08_Quantity_Tab_Summary"),
build: () =>
table.summary ? renderSummaryGrid(table.summary) : message(L("B08_Quantity_Grid_Empty")),
table.summary
? renderSummaryGrid(table.summary, table.provenance?.sheets?.summary)
: message(L("B08_Quantity_Grid_Empty")),
},
{
label: L("B08_Quantity_Tab_Haul"),
build: () =>
table.haul
? renderHaulGrid(table.haul, Boolean(table.haul_available))
? renderHaulGrid(
table.haul,
Boolean(table.haul_available),
table.provenance?.sheets?.haul,
)
: message(L("B08_Quantity_Haul_Missing")),
},
{
@@ -797,7 +822,7 @@ function buildQuantityBody(
build: () => {
const preparation = (table as unknown as { preparation?: PreparationTable }).preparation;
return preparation
? renderPreparationGrid(preparation)
? renderPreparationGrid(preparation, table.provenance?.sheets?.preparation)
: message(L("B08_Quantity_Grid_Empty"));
},
},
@@ -810,12 +835,16 @@ function buildQuantityBody(
label: L("B08_Quantity_Tab_Material"),
build: () =>
material
? renderMaterialGrid(material.material, {
choices: draft.material_supply,
onChange: () => {
draft.dirty = true;
? renderMaterialGrid(
material.material,
{
choices: draft.material_supply,
onChange: () => {
draft.dirty = true;
},
},
})
material.provenance?.sheets?.material,
)
: message(L("B08_Quantity_Material_Failed")),
},
];
@@ -887,6 +916,16 @@ export async function renderB08Quantity(root: HTMLElement): Promise<void> {
...((stored.ancillary_counts ?? {}) as Record<string, number | null>),
},
material_supply: { ...((stored.material_supply ?? {}) as Record<string, SupplyChoice>) },
// ⚠ 저장분에 있는 갈래만 담는다 — 기본값을 복사해 넣으면 「안 고름」이 사라진다.
conversion_factors: Object.fromEntries(
Object.entries(stored.conversion_factors_override ?? {}).map(([kind, entry]) => [
kind,
{
compacted: typeof entry?.compacted === "number" ? entry.compacted : null,
reason: typeof entry?.reason === "string" ? entry.reason : "",
},
]),
),
dirty: false,
};
const reload = (): void => {
@@ -0,0 +1,64 @@
/* =============================================================================
* B08_Quantity_UI_SidePanel_Sections.ts
* **B03~B07 ** .
*
* (`B08_Quantity_UI_Page.ts`) 700
* (CLAUDE.md 4 700 ).
* ========================================================================== */
/**
* + **B03~B07 ** .
*
*
* 400 append .
* `field(이름, "")` ** **, .
* · B04·B06 .
*
* ** .**
* .
* ( · ) ** **
* .
*/
export function groupPanelSections(panel: HTMLElement): void {
const isHeading = (node: Element): boolean => {
if (node.tagName !== "DIV" || !node.classList.contains("b08-quantity__field")) return false;
const value = node.querySelector(".b08-quantity__field-value");
return !value || !value.textContent;
};
const newSection = (title: string | null): HTMLElement => {
const section = document.createElement("section");
section.className = title
? "b08-quantity__section ui-collapsible ui-sidebar-section"
: "b08-quantity__section ui-sidebar-section";
if (title) {
const heading = document.createElement("p");
heading.className = "b08-quantity__section-title ui-collapsible__title";
heading.textContent = title;
section.append(heading);
}
return section;
};
let box: HTMLElement | null = null;
for (const node of [...panel.children]) {
// 개발용 줄·바닥 액션 줄은 건너뛰고 상자도 끊는다.
if (
node.classList.contains("b08-quantity__dev") ||
node.classList.contains("ui-sidebar-actions")
) {
box = null;
continue;
}
if (isHeading(node)) {
box = newSection(node.firstElementChild?.textContent ?? "");
panel.insertBefore(box, node);
node.remove();
continue;
}
if (!box) {
box = newSection(null);
panel.insertBefore(box, node);
}
box.append(node);
}
}
+47 -3
View File
@@ -11,11 +11,38 @@
* ========================================================================== */
import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale";
import {
attachProvenance,
markProvenanceCell,
type ProvenanceSheet,
} from "@ui/ui_template_provenance";
function L(key: keyof typeof ui_locales): string {
return ui_locales[key][currentLanguageIndex];
}
/** .
* `textCell()` ** **
* . `override` ** **
* ( · ) .
*/
function markRow(
tr: HTMLTableRowElement,
keys: readonly (string | null)[],
override?: Record<number, string | undefined>,
): void {
[...tr.children].forEach((cell, index) => {
const key = keys[index];
if (key) markProvenanceCell(cell as HTMLElement, key, override?.[index]);
});
}
/** 열 차례 — 비고는 사유 글이라 사전을 안 붙인다(`null`). */
const SUMMARY_KEYS = ["group", "item", "spec", "unit", "amount", null] as const;
const HAUL_KEYS = ["equipment", "ground", "volume_m3", "average_distance_m", "legs", null] as const;
const PREPARATION_KEYS = ["group", "item", "unit", "amount", "status", null] as const;
export interface SummaryRow {
group: string;
item: string;
@@ -77,7 +104,7 @@ function textCell(text: string, className?: string): HTMLTableCellElement {
}
/** 토공집계표 — 실무 시트와 같은 여섯 열. */
export function renderSummaryGrid(table: SummaryTable): HTMLElement {
export function renderSummaryGrid(table: SummaryTable, sheet?: ProvenanceSheet): HTMLElement {
const wrap = document.createElement("div");
wrap.className = "b08-grid";
@@ -114,17 +141,25 @@ export function renderSummaryGrid(table: SummaryTable): HTMLElement {
note.prepend(tag);
}
tr.append(note);
// ⚠ 무대(소운반 20m)처럼 **집계에는 오르되 내역 줄이 아닌** 줄은 「계」가
// 최종이 아니라 **제외**임(품셀 1-2-7). 못 세운 것과 뜻이 정반대라 칸 등급을 갈라 준다.
markRow(tr, SUMMARY_KEYS, row.in_bill ? undefined : { 4: "excluded" });
body.append(tr);
}
element.append(head, body);
attachProvenance(element, sheet);
scroller.append(element);
wrap.append(scroller);
return wrap;
}
/** 운반거리 — 내역 줄(가중평균)과 근거 줄을 나눠 보인다. */
export function renderHaulGrid(table: HaulTable, available: boolean): HTMLElement {
export function renderHaulGrid(
table: HaulTable,
available: boolean,
sheet?: ProvenanceSheet,
): HTMLElement {
const wrap = document.createElement("div");
wrap.className = "b08-grid";
@@ -180,10 +215,13 @@ export function renderHaulGrid(table: HaulTable, available: boolean): HTMLElemen
note.append(document.createTextNode(" 품셈 1-2-7 소운반 20m 이내는 품에 포함"));
}
tr.append(note);
// 내역 줄이 안 되는 줄은 토량·거리 둘 다 「제외」임 — 값은 검산에만 쓴다.
markRow(tr, HAUL_KEYS, row.in_bill ? undefined : { 2: "excluded", 3: "excluded" });
body.append(tr);
}
element.append(head, body);
attachProvenance(element, sheet);
scroller.append(element);
wrap.append(scroller);
return wrap;
@@ -212,7 +250,10 @@ export interface PreparationTable {
* .
* .
*/
export function renderPreparationGrid(table: PreparationTable): HTMLElement {
export function renderPreparationGrid(
table: PreparationTable,
sheet?: ProvenanceSheet,
): HTMLElement {
const wrap = document.createElement("div");
wrap.className = "b08-grid";
@@ -251,10 +292,13 @@ export function renderPreparationGrid(table: PreparationTable): HTMLElement {
note.append(document.createTextNode(` (참고 면적 ${num(row.reference_amount, 1)})`));
}
tr.append(note);
// 값을 못 세운 줄의 「수량」은 **막힘** — 근거가 오면 채워질 자리라 제외과 갈라 보인다.
markRow(tr, PREPARATION_KEYS, row.amount === null ? { 3: "blocked" } : undefined);
body.append(tr);
}
element.append(head, body);
attachProvenance(element, sheet);
scroller.append(element);
wrap.append(scroller);
return wrap;
@@ -152,7 +152,22 @@ class BillRow:
expense_krw: Decimal = _ZERO
is_group: bool = False
in_bill: bool = True
note: str = ""
#: 줄 사유 **조각** — `(닿는 열 키, 글)`. 화면 「비고」는 이것을 이어 붙인 것이고,
#: 근거 호버는 열 키로 걸러 **그 사유가 닿는 칸에만** 띄운다(PLAN 8-36 ㉮).
#: ⚠ 종전엔 `note` 한 칸에 덮어썼다 — 한 줄에 사유가 둘이면 **하나가 조용히 사라졌다**
#: (막힘 사유가 먼저 적힌 반영률 문구를 지웠다, 2026-09-12 데스크탑 보조 조사 ㉮).
#: 그래서 **덮지 않고 쌓는다.** 열을 못 짚는 줄 전체 사유는 키를 빈 글로 둔다.
notes: list[tuple[str, str]] = field(default_factory=list)
@property
def note(self) -> str:
"""화면 「비고」 칸 — 조각을 종전과 **같은 꼴**로 이어 붙인다."""
return " / ".join(text for _, text in self.notes if text)
def add_note(self, column: str, text: str) -> None:
"""사유 한 조각을 **쌓는다**. `column` 은 그 사유가 닿는 열 키(줄 전체면 빈 글)."""
if text:
self.notes.append((column, text))
def as_dict(self) -> dict[str, Any]:
def money(value: Decimal | None) -> str | None:
@@ -185,6 +200,9 @@ class BillRow:
"is_group": self.is_group,
"in_bill": self.in_bill,
"note": self.note,
#: 근거 호버용 — 어느 사유가 **어느 열**에 닿는지까지 실어 보낸다.
#: 화면 「비고」 칸은 위 `note` 그대로라 토글을 끈 사용자도 사유를 그대로 본다.
"notes": [{"column": column, "text": text} for column, text in self.notes],
}
@@ -514,7 +532,8 @@ def build_bill(
continue
entry = sheet.by_unit_price(f"B-{row.code}")
if entry is not None:
row.note = " / ".join(part for part in (entry.label, row.note) if part)
# 종전처럼 **맨 앞**에 놓는다 — 실무 참조번호(「단산 46」)가 먼저 읽혀야 한다.
row.notes.insert(0, ("unit_price_krw", entry.label))
result.price_basis = sheet
if any(m.surcharge_pct is None for m in materials):
@@ -83,7 +83,7 @@ def _composite_row(
if missing_parts or money is None:
detail_text = "; ".join(reasons[:3]) or ", ".join(missing_parts[:4])
row.note = f"묶음 조각이 덜 찼습니다 — {detail_text}"
row.add_note("quantity", f"묶음 조각이 덜 찼습니다 — {detail_text}")
result.missing.append(
{
"name": row.name,
@@ -103,7 +103,7 @@ def _composite_row(
row.material_krw = line.material
row.labor_krw = line.labor
row.expense_krw = line.expense
row.note = f"묶음 {len(item.composite_parts)}조각 합계"
row.add_note("quantity", f"묶음 {len(item.composite_parts)}조각 합계")
return row
@@ -116,7 +116,7 @@ def _excluded_row(item: HandoffWorkItem) -> BillRow:
· **여기서 세지 않는 **(`blocked_kind` 없음) 다른 표에서 이미 ·
노선엔 없음. **이것을 목록에 얹으면 결국 이중계상이 된다.**
"""
return BillRow(
row = BillRow(
item_no="",
level=1,
code=item.work_item_code,
@@ -125,10 +125,13 @@ def _excluded_row(item: HandoffWorkItem) -> BillRow:
unit=item.unit,
quantity=item.quantity,
in_bill=False,
note=item.blocked_reason
or item.in_bill_reason
or "합계 검산용 줄 — 금액을 매기지 않습니다.",
)
# 줄 하나가 통째로 빠지는 사유라 닿는 열이 없다 — 키를 비워 **모든 칸**에 따라붙게 둔다.
row.add_note(
"",
item.blocked_reason or item.in_bill_reason or "합계 검산용 줄 — 금액을 매기지 않습니다.",
)
return row
def _leaf_row(
@@ -151,10 +154,10 @@ def _leaf_row(
)
if item.spec_class_basis:
# 갈래 판정 근거는 **B08 문구를 그대로** 쓴다(두 벌로 짜지 않는다).
row.note = " / ".join(part for part in (row.note, item.spec_class_basis) if part)
row.add_note("spec", item.spec_class_basis)
if item.application_ratio_pct is not None:
# ⚠ 곱하지 않는다 — B08 이 이미 곱한 값이다. 산출근거로만 적는다.
row.note = f"반영률 {item.application_ratio_pct}% 적용 후 수량"
row.add_note("quantity", f"반영률 {item.application_ratio_pct}% 적용 후 수량")
elif item.application_ratio_breakdown:
# ⚠ **「율 없음」이 아니라 「갈래마다 다름」이다.** 율이 갈리는 줄은 B08 이 `pct` 를
# 비우고 갈래로만 보낸다. 그 사실을 안 적으면 **값은 맞는데 왜 그 수량인지**를
@@ -162,12 +165,12 @@ def _leaf_row(
parts = ", ".join(
f"{name} {value}%" for name, value in item.application_ratio_breakdown.items()
)
row.note = f"반영률이 갈래마다 다릅니다 — {parts} (적용 후 수량)"
row.add_note("quantity", f"반영률이 갈래마다 다릅니다 — {parts} (적용 후 수량)")
if not item.in_bill:
# 검산용 줄 — 수량은 보이되 **단가를 안 붙인다**(PLAN 8-7 ㉡ 와 같은 성격).
row.quantity = item.quantity
row.note = item.in_bill_reason or "합계 검산용 줄 — 금액을 매기지 않습니다."
row.add_note("", item.in_bill_reason or "합계 검산용 줄 — 금액을 매기지 않습니다.")
result.excluded.append(row)
return row
@@ -177,13 +180,16 @@ def _leaf_row(
# **주의 문구**였다 — 「관종을 안 정해 기본값(파형강관)으로 섰습니다」.
# ⇒ 사유만 온 줄은 **금액을 세우고 그 문구를 곁말로** 단다.
if item.blocked_reason and not item.blocked_kind:
row.note = " / ".join(part for part in (row.note, f"{item.blocked_reason}") if part)
row.add_note("spec", f"{item.blocked_reason}")
if item.blocked_reason and item.blocked_kind:
# B08 이 「왜 못 골랐는지」를 적어 보냈다 — **그 문구를 그대로** 보인다.
# 사용자가 입력하면 풀리는 것(`input_missing`)과 우리가 만들어야 하는 것을
# 가르지 않으면, 사용자가 「후보를 고르면 되나」로 잘못 읽는다.
row.note = f"{_BLOCKED_LABELS.get(item.blocked_kind, '막힘')}{item.blocked_reason}"
row.add_note(
"unit_price_krw",
f"{_BLOCKED_LABELS.get(item.blocked_kind, '막힘')}{item.blocked_reason}",
)
result.missing.append(
{
"name": row.name,
@@ -221,11 +227,14 @@ def _leaf_row(
)
if children:
names = ", ".join(f"{c[2:]} {unit_prices.book.title(c).name}" for c in children)
row.note = f"이 공종엔 일위대가가 없고 한 층 아래에 있습니다 — 후보: {names}"
row.add_note(
"unit_price_krw",
f"이 공종엔 일위대가가 없고 한 층 아래에 있습니다 — 후보: {names}",
)
# 관경이 표 밖이면 **무엇을 정해야 하는지**까지 가리킨다.
diameter_note = pipe_diameter_note(node.code, item.variant_value)
if diameter_note:
row.note = f"{row.note} / {diameter_note}"
row.add_note("spec", diameter_note)
reason = f"일위대가가 하위 공종에 있음(후보 {len(children)}건)"
else:
# ⚠ 「아직 안 만든 것」과 「성분이 빠져 못 세운 것」은 **할 일이 다르다**.
@@ -233,10 +242,12 @@ def _leaf_row(
# 표에 수량 칸이 비어 있고 「철근가공조립(간단)의 30 %」처럼 참조로만 적힌 자리).
gap = unit_prices.component_gaps.get(node.code)
if gap:
row.note = f"성분이 빠져 단가를 못 세웠습니다 — {gap}"
row.add_note("unit_price_krw", f"성분이 빠져 단가를 못 세웠습니다 — {gap}")
reason = f"성분 미확보 — {gap}"
else:
row.note = "일위대가가 아직 없습니다 — 금액을 0 으로 때우지 않습니다."
row.add_note(
"unit_price_krw", "일위대가가 아직 없습니다 — 금액을 0 으로 때우지 않습니다."
)
reason = "일위대가 없음"
result.missing.append(
{
@@ -254,7 +265,10 @@ def _leaf_row(
if missing_basis:
# ⚠ 밑수를 모르는 표다 — 「10㎡당」인지 「1㎡당」인지 모른 채 곱하면 10배·100배
# 틀린다(떼채취가 실제로 100배였다). **곱하지 않고 드러낸다.**
row.note = f"밑수(기준 수량)를 못 찾은 표입니다 — 곱하지 않았습니다. 원문 {missing_basis}"
row.add_note(
"unit_price_krw",
f"밑수(기준 수량)를 못 찾은 표입니다 — 곱하지 않았습니다. 원문 {missing_basis}",
)
result.missing.append(
{
"name": row.name,
@@ -275,8 +289,9 @@ def _leaf_row(
missing_rows = unit_prices.unattached.get(node.code) or []
if not why and missing_rows:
why = f"{', '.join(missing_rows[:3])} 줄이 아직 안 붙었습니다"
row.note = (
f"단가가 일부만 섰습니다 — 붙은 몫 {covered}%" + (f" · {why}" if why else "") + "."
row.add_note(
"unit_price_krw",
f"단가가 일부만 섰습니다 — 붙은 몫 {covered}%" + (f" · {why}" if why else "") + ".",
)
result.missing.append(
{
@@ -294,14 +309,10 @@ def _leaf_row(
# ⚠ 품셈 표가 기준 단위를 안 준 단가다 — 「10㎡당」 같은 묶음 기준일 수 있다.
# 값을 막지는 않되(막으면 대부분이 멈춘다) **모르는 채 곱했다는 사실을 적는다**.
# 비고를 **덮지 않고 잇는다** — 반영률 문구가 먼저 적혀 있을 수 있다.
row.note = " / ".join(
part
for part in (
row.note,
f"단가의 기준 단위가 표에 없습니다 — B08 수량 단위({row.unit})와 같다고 "
"보고 곱했습니다. 확인 필요.",
)
if part
row.add_note(
"unit_price_krw",
f"단가의 기준 단위가 표에 없습니다 — B08 수량 단위({row.unit})와 같다고 "
"보고 곱했습니다. 확인 필요.",
)
if title.unit and row.unit and not _same_unit(title.unit, row.unit):
@@ -311,9 +322,10 @@ def _leaf_row(
# 52,938.9 = 1,381,753원이라 **2.6 배 적은 금액**이 내역서에 든 셈이다.
# 어느 쪽이 맞는지는 우리가 정할 일이 아니다 — **B08 이 면적을 보내거나 묶음
# 조각으로 보내야** 풀린다. 그때까지 **금액을 만들지 않고 드러낸다.**
row.note = (
row.add_note(
"amount_krw",
f"단위가 안 맞습니다 — 수량은 {row.unit}, 단가는 {title.unit}당입니다. "
"곱하면 금액이 틀리므로 비워 둡니다."
"곱하면 금액이 틀리므로 비워 둡니다.",
)
result.missing.append(
{
@@ -332,13 +344,13 @@ def _leaf_row(
# 그 밑수가 사용자 확정을 기다리고 있다(계획서 4-12 3단계).
pending = pending_formula_note(node.code)
if pending:
row.note = " / ".join(part for part in (row.note, pending) if part)
row.add_note("quantity", pending)
# ⚠ **원문에는 있는데 단가에 못 실린 몫**도 같은 자리에서 말한다. 금액이 서 있는 줄이라
# 표시가 없으면 완성된 값으로 읽힌다(규준틀 둘이 인력만으로 492만원이었다).
gap = known_gap_note(node.code)
if gap:
row.note = " / ".join(part for part in (row.note, gap) if part)
row.add_note("unit_price_krw", gap)
# 쓰인 차례를 기억한다 — 실무 참조번호(「단산 46」)가 그 차례다.
if price_code not in result.used_unit_prices:
@@ -392,10 +404,13 @@ def _material_row(material: HandoffMaterial, result: BillResult) -> BillRow:
spec=material.spec,
unit=material.unit,
quantity=material.total_amount,
note=material.surcharge_note,
)
# 할증 사유는 **수량**에 닿는다 — 할증이 곱해진 뒤의 수량이기 때문이다.
row.add_note("quantity", material.surcharge_note)
if material.supply_type == SUPPLY_UNKNOWN:
row.note = "관급·사급이 안 갈렸습니다 — 관급자재대에도, 도급 재료비에도 넣지 않습니다."
row.add_note(
"", "관급·사급이 안 갈렸습니다 — 관급자재대에도, 도급 재료비에도 넣지 않습니다."
)
result.missing.append(
{
"name": material.display_name,
@@ -409,14 +424,16 @@ def _material_row(material: HandoffMaterial, result: BillResult) -> BillRow:
# `owner_supplied` 인데 「사급 자재 단가 미확보」로 뜨고 있었다). 갈래마다 **가는 자리도
# 원천도 다르다** — 관급은 총원가 밖 관급자재대(나라장터), 사급은 도급 재료비(물가지).
if material.supply_type == SUPPLY_OWNER:
row.note = (
row.note
or "관급 자재 단가 미확보 — 나라장터 목록에 이 품목이 없습니다. "
"관급자재대(총원가 밖 별도 표기)로 갑니다."
row.add_note(
"unit_price_krw",
"관급 자재 단가 미확보 — 나라장터 목록에 이 품목이 없습니다. "
"관급자재대(총원가 밖 별도 표기)로 갑니다.",
)
reason = "관급 자재 단가 없음"
else:
row.note = row.note or "사급 자재 단가 미확보 — 6번 슬롯(적용 단가) 수동 입력 대기."
row.add_note(
"unit_price_krw", "사급 자재 단가 미확보 — 6번 슬롯(적용 단가) 수동 입력 대기."
)
reason = "사급 자재 단가 없음(미결 No.18)"
result.missing.append(
+457
View File
@@ -0,0 +1,457 @@
"""B09 원가 화면의 **근거 사전** — 어느 숫자가 어디서 와서 어떻게 나왔나 (PLAN 8-36 ④).
**개발 전용.** 사전은 `provenance_payload()` 거쳐 나가고, 개발환경이 아니면 `None`
이라 응답에 자체가 생긴다. 화면에서 숨기는 것이 아니라 ** 보내는 **이다.
파일인가
원천 정답은 값을 낳는 엔진이 안다. 화면 TS 손으로 적어 두면 엔진을
고칠 설명만 옛것으로 남는다. 엔진 옆에 두어 같이 눈에 들어오게 한다.
`B09_Estimation_UI_Page.ts`(1415)·`B09_Estimation_UnitPrice.py`(1173) 이미
700줄을 크게 넘어 ** 파일로 뺐다**(PLAN 8-36 ).
** 단위로 적는다.** 줄마다 갈리는 사유는 줄이 `notes` 들고 오고(내역서는 사유가
**닿는 **까지 함께 들고 온다 `BillRow.notes`), 화면이 열의 칸에만 덧붙인다.
토적표와 맞대 (데스크탑 메인 요청)
· B08 토적표에는 `final` ** 열도 없었다** 중간 장부이기 때문이다.
· B09 반대로 `final` 분명히 있다 내역서 금액·자재대 금액이 자리다.
등급 여섯은 ** 장이 아니라 장을 합쳐야** 쓰인다.
· 그래도 **원가계산서 금액 단위로 `final` 붙였다.** 같은 안에서
중간줄(간접노무비 따위) 마지막줄(총원가·도급금액·총계) 성격이 갈리는데 등급은
**열에 하나**뿐이라서다. `calc` 두고 `rule` 어느 줄이 `final` 인지 적었다.
어긋남은 PLAN 8-36 남긴다( 단위 등급이 필요한 자리).
"""
from __future__ import annotations
from typing import Any
from common_util.common_util_provenance import (
TIER_CALC,
TIER_EXCLUDED,
TIER_FINAL,
TIER_STANDARD,
TIER_SURVEY,
ColumnProvenance,
provenance_payload,
sheet_provenance,
)
from B09_Estimation.B09_Estimation_Provenance_Common import _label, _note_column
from B09_Estimation.B09_Estimation_Provenance_Sources import (
base_reference_fuel_sheet,
base_reference_labor_sheet,
basis_sheet_tables,
material_comparison_sheet,
price_basis_sheet,
)
#: 요율이 어디서 오는지 — 원가계산서 여러 열이 같은 문장을 쓴다.
_RATE_SOURCE = (
"요율 판 `resources/data_cost_input_value/rates_2026.json` — "
"공사금액·공사기간 구간으로 골라 씀(`B09_Estimation_Rates.py:180 select_bracket`). "
"어느 판으로 섰는지는 좌측 패널 「요율 판」과 산출기초 ① 에 지문까지 남음"
)
# =============================================================================
# ① 공사원가계산서
# =============================================================================
def cost_sheet() -> dict[str, Any]:
"""열 키는 화면 `buildCostSheetTable` 이 심는 낱말과 같아야 한다."""
return sheet_provenance(
[
ColumnProvenance(
key="name",
label="비목",
tier=TIER_STANDARD,
formula="법이 정한 비목 이름 (차례도 법이 정함)",
source="법정경비 14 비목은 `B09_Estimation_Statutory.py:58 STATUTORY_ITEMS` "
"— 그 차례가 곧 원가계산서 줄 차례",
code="B09_Estimation_Engine_Cost.py:115 CostLine.name",
),
ColumnProvenance(
key="amount_krw",
label="금액",
tier=TIER_CALC,
formula="밑수 × 요율% (+ 정액) 을 원 단위로 버림",
source="밑수는 비목마다 다름 — 「산출근거」 칸에 그 줄의 실제 밑수가 적힘. "
"버림은 `B09_Estimation_Engine_Cost.py:44 floor_won`",
rule="⚠ **총원가·도급금액·총계 줄은 `final`** — 계약으로 나가는 값이다. "
"등급이 열에 하나뿐이라 그 셋을 따로 못 적었다(PLAN 8-36 ①). "
"도급금액만 천원 단위 **올림**(`:49 ceil_thousand`)이라 끝자리가 다르다",
code="B09_Estimation_Engine_Cost.py:175 _emitter",
),
ColumnProvenance(
key="rate_percent",
label="요율",
tier=TIER_STANDARD,
formula="공사금액·공사기간이 든 구간의 요율을 그대로 씀",
source=_RATE_SOURCE,
code="B09_Estimation_Rates.py:180 select_bracket",
),
ColumnProvenance(
key="formula_text",
label="산출근거",
tier=TIER_CALC,
formula="그 줄이 실제로 쓴 밑수와 요율을 사람이 읽게 적은 한 줄",
source="엔진이 셈하면서 같이 지음 — 화면이 따로 짓지 않는다",
rule="⚠ **산업안전보건관리비만 「× %」 꼴이 아니다** — A(요율식)·B(대상액×1.2) "
"중 **작은 쪽**을 쓰므로 값 안에 고름이 숨어 있다"
"(`B09_Estimation_Statutory.py:154 safety_management_cost`)",
code="B09_Estimation_Engine_Cost.py:128 CostLine.formula_text",
),
_note_column("B09_Estimation_Engine_Cost.py:125 CostLine.note"),
]
)
# =============================================================================
# ② 설계내역서
# =============================================================================
def boq_sheet() -> dict[str, Any]:
return sheet_provenance(
[
_label("item_no", "No.", "마스터 목차가 매긴 번호"),
_label("name", "공종", "B08 이 보낸 이름, 없으면 마스터 이름"),
ColumnProvenance(
key="spec",
label="규격",
tier=TIER_STANDARD,
formula="마스터 규격 (갈래가 정해진 줄은 갈래 이름을 뒤에 이음)",
source="갈래를 어떻게 골랐는지는 그 줄의 사유에 적힘 — B08 문구를 그대로 옮김",
code="B09_Estimation_BillOfQuantities_Rows.py:149",
),
_label("unit", "단위"),
ColumnProvenance(
key="quantity",
label="수량",
tier=TIER_SURVEY,
formula="B08 이 보낸 값을 그대로 씀 (여기서 다시 곱하지 않음)",
source="⚠ **반영률은 B08 이 이미 곱했다** — 여기서 또 곱하면 두 번 곱해진다. "
"찍는 자리수는 품셈 1-2-2 종목별(`B09_Estimation_QuantityDigits.py`)이고 "
"값 자체는 전정밀로 남는다",
code="B09_Estimation_BillOfQuantities_Rows.py:151",
),
ColumnProvenance(
key="unit_price_krw",
label="단가",
tier=TIER_CALC,
formula="그 공종의 일위대가 본표 합계 (1단위 값)",
source="일위대가 탭에서 같은 표를 그대로 봄. 묶음 줄은 조각들의 "
"`단가 × 조각수량` 을 더한 값",
rule="⚠ **못 세우면 0 으로 때우지 않고 비운다.** 일위대가가 없음·성분이 빠짐·"
"밑수를 모름·일부만 섬·단위가 안 맞음 — 사유는 그 줄의 사유에 적히고 "
"「금액을 못 세운 줄」 목록에도 오른다",
code="B09_Estimation_BillOfQuantities_Rows.py:134 _leaf_row",
),
ColumnProvenance(
key="amount_krw",
label="금액",
tier=TIER_FINAL,
formula="수량 × 단가",
source="이 값들의 합이 공사원가계산서의 직접비로 나간다 — 화면 밖으로 나가는 값",
rule="단가가 안 선 줄은 **금액도 안 세운다**. 단위가 안 맞는 줄도 비운다 — "
"곱하면 조용히 틀린 금액이 내역서에 든다",
code="B09_Estimation_BillOfQuantities_Rows.py:134 _leaf_row",
),
_note_column("B09_Estimation_BillOfQuantities.py:BillRow.note"),
]
)
# =============================================================================
# ③ 일위대가
# =============================================================================
def unit_price_list_sheet() -> dict[str, Any]:
"""목록표 — 「무엇이 있나」."""
common = "단가판(`PriceBook`)이 성분을 풀어 낸 값 — 본표를 열면 줄마다 보인다"
return sheet_provenance(
[
_label("name", "명칭", "단가판 제목"),
_label("unit", "단위", "단가판 기준 단위"),
ColumnProvenance(
key="material",
label="재료비",
tier=TIER_CALC,
formula="본표 재료비 줄의 합",
source=common,
code="B09_Estimation_UnitPrice_View.py:149 list_unit_prices",
),
ColumnProvenance(
key="labor",
label="노무비",
tier=TIER_CALC,
formula="본표 노무비 줄의 합",
source=common,
code="B09_Estimation_UnitPrice_View.py:149 list_unit_prices",
),
ColumnProvenance(
key="expense",
label="경비",
tier=TIER_CALC,
formula="본표 경비 줄의 합",
source=common,
code="B09_Estimation_UnitPrice_View.py:149 list_unit_prices",
),
ColumnProvenance(
key="total",
label="합계",
tier=TIER_CALC,
formula="재료비 + 노무비 + 경비",
source=common,
code="B09_Estimation_UnitPrice_View.py:149 list_unit_prices",
),
]
)
def unit_price_detail_sheet() -> dict[str, Any]:
"""본표 — 「무엇으로 이루어졌나」."""
money = (
"성분 단위값 × 수량을 성분별로 자른 값. ⚠ 행마다 자르므로 **전정밀 합과 끝자리가 "
"어긋난다 — 정상이다.** 숨기면 나중에 「합계가 안 맞는다」며 계산을 고치려 든다"
)
return sheet_provenance(
[
_label("name", "명칭", "성분 이름. 제잡비·공구손료는 품셈 [주]가 만든 줄"),
_label("spec", "규격", "성분 규격. 비율 줄은 「노무비의 N%」 꼴"),
ColumnProvenance(
key="source",
label="원천",
tier=TIER_STANDARD,
formula="그 성분이 어느 판에서 왔는지 + 그 판에서의 순번",
source="자재 · 노임 · 기계경비 · 일위대가 · 단가산출 · 일식견적 여섯 중 하나"
"(`B09_Estimation_UnitPrice.py:1026 SOURCE_LABEL`)",
rule="기계경비·일위대가·단가산출 줄은 **눌러서 한 층 아래로 내려갈 수 있다**"
"(`:1037 DRILLABLE_KINDS`)",
code="B09_Estimation_UnitPrice_View.py:257",
),
_label("unit", "단위"),
ColumnProvenance(
key="quantity",
label="수량",
tier=TIER_STANDARD,
formula="품셈 표가 정한 1단위당 소요량",
source="비율 줄(제잡비·공구손료)은 수량 칸에 **퍼센트**가 들어간다",
code="B09_Estimation_UnitPrice_View.py:259",
),
ColumnProvenance(
key="material",
label="재료비",
tier=TIER_CALC,
formula="성분 단위 재료비 × 수량",
source=money,
code="B09_Estimation_UnitPrice_View.py:264",
),
ColumnProvenance(
key="labor",
label="노무비",
tier=TIER_CALC,
formula="성분 단위 노무비 × 수량",
source=money,
code="B09_Estimation_UnitPrice_View.py:265",
),
ColumnProvenance(
key="expense",
label="경비",
tier=TIER_CALC,
formula="성분 단위 경비 × 수량",
source=money,
code="B09_Estimation_UnitPrice_View.py:266",
),
ColumnProvenance(
key="total",
label="합계",
tier=TIER_CALC,
formula="자른 성분 셋을 더한 값 (표에서 합계 = 재료비+노무비+경비 가 서게)",
source=money,
code="B09_Estimation_UnitPrice_View.py:267",
),
]
)
# =============================================================================
# ④ 관급·사급 자재대
# =============================================================================
def _material_columns(*, excluded: bool) -> list[ColumnProvenance]:
"""자재대 열 일곱. 「안 갈린 것」 표만 통째로 `excluded` 로 선다."""
if excluded:
why = (
"⚠ **관급·사급이 안 갈린 줄** — 어느 합계에도 넣지 않는다. 못 세운 것이 아니라 "
"**세면 안 되는** 자리다. 관급자재대에도 도급 재료비에도 넣으면 이중계상이 된다"
)
return [
ColumnProvenance(
key=key,
label=label,
tier=TIER_EXCLUDED,
source=why,
code="B09_Estimation_BillOfQuantities_Rows.py:398",
)
for key, label in (
("name", "자재"),
("spec", "규격"),
("unit", "단위"),
("total_amount", "수량"),
("unit_price_krw", "단가"),
("amount_krw", "금액"),
("note", "비고"),
)
]
return [
_label("name", "자재"),
_label("spec", "규격"),
_label("unit", "단위"),
ColumnProvenance(
key="total_amount",
label="수량",
tier=TIER_SURVEY,
formula="B08 이 낸 자재 수량 × 할증률",
source="할증 사유는 그 줄의 사유에 적힘. 할증률이 아직 없는 자재는 "
"**할증 전 값**으로 서고 그 사실이 표 밑에 뜬다",
code="B09_Estimation_MaterialSheet.py:47 MaterialSheetRow",
),
ColumnProvenance(
key="unit_price_krw",
label="단가",
tier=TIER_STANDARD,
formula="단가판에서 찾은 값",
source="⚠ **관급과 사급은 원천이 다르다** — 관급은 나라장터, 사급은 물가지·견적. "
"관급을 「사급 단가 없음」으로 적으면 안 된다",
rule="못 찾으면 **0 으로 때우지 않고 비운다** — 사유가 그 줄에 적힌다",
code="B09_Estimation_MaterialSheet.py:117 build_material_sheet",
),
ColumnProvenance(
key="amount_krw",
label="금액",
tier=TIER_FINAL,
formula="수량 × 단가",
source="⚠ **사급만 도급 재료비로 든다.** 관급은 총원가 **밖** 별도 표기라 "
"여기 합계가 원가계산서 재료비와 같지 않다",
code="B09_Estimation_MaterialSheet.py:117 build_material_sheet",
),
_note_column("B09_Estimation_BillOfQuantities.py:BillRow.note"),
]
# =============================================================================
# ⑤ 중기목록표 · 기초자료 목록표
# =============================================================================
def machine_sheet() -> dict[str, Any]:
hourly = "시간당 사용료 — 「각종 중기경비계산서」에 셈 과정이 그대로 펼쳐진다"
return sheet_provenance(
[
_label("code", "코드번호"),
_label("name", "명 칭"),
_label("spec", "규 격"),
_label("unit", "단위"),
ColumnProvenance(
key="total_krw",
label="합 계",
tier=TIER_CALC,
formula="노무비 + 재료비 + 경비",
source=hourly,
code="B09_Estimation_Lists.py:105 machine_list",
),
ColumnProvenance(
key="labor_krw",
label="노 무 비",
tier=TIER_CALC,
formula="조종원 노임 ÷ 8시간 × 16/12 × 25/20 (약 1.667배)",
source="공표 노임은 기본급여액뿐이라 제수당·상여금·퇴직급여충당금을 따로 "
"계상함(건협 임금적용요령 4-나 · 기재부 집행기준 제76조의3). "
"⚠ 계수 자체의 예규 원문은 아직 못 봐 실무 관행을 따름",
code="B09_Estimation_MachineCost.py:74 OPERATOR_ALLOWANCE_FACTOR",
),
ColumnProvenance(
key="material_krw",
label="재 료 비",
tier=TIER_CALC,
formula="주연료(L/hr) × 유가 + 잡재료(주연료의 %)",
source="유가는 전국 또는 고른 시도의 공시가 — 기초자료 탭에서 고른다. "
"잡재료는 연료 소요량에 포함되어 있어 따로 세지 않는다",
rule="같은 기종이라도 **조합 사용이면 잡재료가 16% 로 줄어** 재료비가 달라진다 "
"— 그래서 층이 따로 선다(건설품셈 제8장 [주]⑤)",
code="B09_Estimation_MachineExpenseSheet.py:81 machine_expense_sheets",
),
ColumnProvenance(
key="expense_krw",
label="경 비",
tier=TIER_CALC,
formula="취득가격 × 손료계수(상각비 + 정비비 + 관리비, 10⁻⁷)",
source="취득가격·내용시간·연간표준가동시간·계수 셋은 모두 품셈 표 값",
code="B09_Estimation_MachineExpenseSheet.py:81 machine_expense_sheets",
),
_note_column("B09_Estimation_Lists.py:105 machine_list"),
]
)
def catalog_sheet() -> dict[str, Any]:
"""기초자료 탭의 목록표 셋(노무비·재료비·경비)이 같이 쓰는 사전."""
return sheet_provenance(
[
_label("code", "코드번호"),
_label("name", "명 칭"),
_label("spec", "규 격"),
_label("unit", "단위"),
ColumnProvenance(
key="unit_price_krw",
label="단 가",
tier=TIER_STANDARD,
formula="단가판 값을 그대로 옮김 (여기서 셈하지 않음)",
source="어느 판·어느 기준일인지는 산출기초 ① 에 지문까지 남음. "
"⚠ 경비목록표의 값은 **기계 취득가격(천원)** 이고 시간당 사용료가 아니다",
rule="자재단가대비표에서 **원천 다섯 중 하나를 골라** 적용 단가가 선다 — "
"값 안에 고름이 숨은 자리(PLAN 8-36 ㉯)",
code="B09_Estimation_Lists.py:50 catalog_list",
),
_note_column("B09_Estimation_Lists.py:50 catalog_list"),
]
)
# =============================================================================
# 응답에 싣기
# =============================================================================
def estimation_provenance() -> dict[str, Any] | None:
"""B09 응답에 실을 사전 — **개발환경이 아니면 `None`.**
시트를 늘릴 때는 여기 줄만 더한다. 화면은 시트 이름으로 찾아 쓴다.
**일부러 붙인 ** (2026-09-12 합의)
· **설계서 구성표** 프로젝트가 낳은 값이 아니라 무슨 문서를 것인가 목록이라
원천도 식도 없다. 없는 것을 지어 붙이면 분류가 있다 거짓만 남는다(조사표 ).
· **산출 조건(좌측 패널)** 표가 아니라 입력 칸이고, 근거 줄이 이미 같은
일을 한다. 벌이 되면 어긋난다.
"""
return provenance_payload(
{
"cost_sheet": cost_sheet(),
"boq": boq_sheet(),
"unit_price_list": unit_price_list_sheet(),
"unit_price_detail": unit_price_detail_sheet(),
"material": sheet_provenance(_material_columns(excluded=False)),
"material_unknown": sheet_provenance(_material_columns(excluded=True)),
"machine": machine_sheet(),
"catalog": catalog_sheet(),
"material_comparison": material_comparison_sheet(),
"base_reference_labor": base_reference_labor_sheet(),
"base_reference_fuel": base_reference_fuel_sheet(),
"price_basis": price_basis_sheet(),
**basis_sheet_tables(),
}
)
@@ -0,0 +1,53 @@
"""B09 근거 사전이 **여러 장에서 같이 쓰는 조각** (PLAN 8-36 ④).
따로 두나
사전이 파일로 갈리면서(값을 낳는 / 바깥에서 오거나 모으는 ) 쪽이 같은
이름표 열과 같은 비고 설명을 쓴다. 한쪽에 두고 다른 쪽이 가져가면 **고리가 생겨**
(`Provenance` `Provenance_Sources` `Provenance`) 불러들이지 못한다.
그래서 쪽이 함께 바라보는 자리를 따로 뒀다.
여기에는 **여러 장이 실제로 같이 쓰는 것만** 둔다. 장만 쓰는 문장을 여기로 올리면
사전을 읽을 장에서 눈이 떠나 버린다.
"""
from __future__ import annotations
from common_util.common_util_provenance import (
TIER_STANDARD,
TIER_UNCLASSIFIED,
ColumnProvenance,
)
#: 이름표 열(코드·명칭·규격·단위)이 공통으로 쓰는 문장.
CATALOG_SOURCE = "단가판·품셈 표의 이름을 그대로 옮긴 자리 — 여기서 짓지 않음"
#: 「비고」가 왜 미분류인지 — 표마다 같은 말을 쓴다.
NOTE_RULE = (
"이 칸은 등급을 붙일 열이 아니라 **다른 열의 사유를 담는 그릇**이다. "
"안에 든 조각마다 닿는 열이 다르므로(갈래 근거는 규격, 반영률은 수량, "
"막힘 사유는 단가) 호버는 조각을 그 열의 칸에만 띄운다"
)
def _label(key: str, label: str, extra: str = "") -> ColumnProvenance:
"""이름표 열 — 값을 낳은 것이 아니라 옮겨 적은 자리."""
return ColumnProvenance(
key=key,
label=label,
tier=TIER_STANDARD,
formula="옮겨 적은 값 (여기서 계산하지 않음)",
source=CATALOG_SOURCE + (f" · {extra}" if extra else ""),
)
def _note_column(code: str) -> ColumnProvenance:
"""「비고」 열 — 여덟 어디에도 안 맞아 `unclassified` 로 둔다(PLAN 8-36 ㉮)."""
return ColumnProvenance(
key="note",
label="비고",
tier=TIER_UNCLASSIFIED,
formula="줄에 달린 사유 조각을 차례로 이어 붙인 글",
source="조각마다 원천이 다름 — 조각별 원천은 그 조각이 닿는 열의 카드에 뜸",
rule=NOTE_RULE,
code=code,
)
@@ -0,0 +1,268 @@
"""B09 **바깥에서 온 값**과 **모으는 표**의 근거 사전 (PLAN 8-36 ④).
파일을 갈랐나
`B09_Estimation_Provenance.py` 700줄을 넘었다(CLAUDE.md 4). 가르는 금은
**값을 낳는 ** **값이 바깥에서 오거나 모으기만 하는 **이다
· 저쪽: 원가계산서·내역서·일위대가·자재대·중기·기초자료 목록표
· 이쪽: 자재단가대비표·환율및기초자료·단가산출근거·산출기초
이쪽 넷은 식이 얇거나 아예 없고, 대신 **어느 판에서 왔나** 알맹이다.
"""
from __future__ import annotations
from typing import Any
from common_util.common_util_provenance import (
TIER_BLOCKED,
TIER_CALC,
TIER_INPUT,
TIER_STANDARD,
TIER_UNCLASSIFIED,
ColumnProvenance,
sheet_provenance,
)
from B09_Estimation.B09_Estimation_Provenance_Common import _label, _note_column
# =============================================================================
# ⑥ 자재단가대비표 — **원천 다섯 중 하나를 고르는** 자리
# =============================================================================
def material_comparison_sheet() -> dict[str, Any]:
"""실무 시트의 「기.가 · 유.물 · 견적」 약호가 곧 채택 근거다.
원천 칸이 **여럿이라도 뜻은 하나**(어느 판이 얼마라 했나). 슬롯마다 키를
따로 두면 사전이 다섯 벌이 되고 판이 때마다 어긋난다 키를 나눠 쓴다.
"""
return sheet_provenance(
[
_label("code", "코드번호"),
_label("name", "명 칭"),
_label("spec", "규 격"),
_label("unit", "단위"),
ColumnProvenance(
key="slot_price",
label="원천 단가",
tier=TIER_STANDARD,
formula="그 판이 적어 놓은 값을 그대로 옮김",
source="물가지·거래가격·업체 견적 등 판마다 다름 — 오른쪽 「페이지」 칸이 "
"어느 쪽에서 왔는지 가리킨다",
rule="⚠ **빈 칸은 「0원」이 아니라 「그 판에 그 품목이 없다」** — "
"0 으로 때우지 않는다",
code="B09_Estimation_Lists_Sources.py:79 material_price_comparison",
),
ColumnProvenance(
key="slot_page",
label="페이지",
tier=TIER_STANDARD,
formula="그 값이 실린 쪽수·출처 표기",
source="실무 시트의 「기.가 1,024」 같은 약호 — 사람이 원문을 찾아갈 열쇠",
code="B09_Estimation_Lists_Sources.py:79 material_price_comparison",
),
ColumnProvenance(
key="adopted_price_krw",
label="적 용",
tier=TIER_CALC,
formula="원천 다섯 중 **채택한 하나**의 값",
source="채택한 칸은 표에서 굵게 선다 — 표가 스스로 「어느 값을 썼나」를 밝힌다",
rule="⚠ **값 안에 고름이 숨은 열**(PLAN 8-36 ㉯). 실무 약호가 곧 채택 규칙이다 "
"— 「기.가」(정부 기준가격) · 「유.물」(물가정보) · 「견적」(업체). "
"슬롯 6 이 곧 적용 단가라 그 자리에 값이 서면 「적용」 칸을 따로 안 세운다",
code="B09_Estimation_Lists_Sources.py:79 material_price_comparison",
),
_note_column("B09_Estimation_Lists_Sources.py:79"),
]
)
# =============================================================================
# ⑦ 환율및기초자료 — **바깥에서 온 값**과 **우리가 고른 것**이 갈리는 장
# =============================================================================
def base_reference_labor_sheet() -> dict[str, Any]:
"""② 인건비 — 공표 노임을 시간당으로 푼 표."""
return sheet_provenance(
[
_label("code", "코드번호"),
_label("name", "직 종"),
ColumnProvenance(
key="day_wage_krw",
label="일 당",
tier=TIER_STANDARD,
formula="공표 노임을 그대로 옮김",
source="대한건설협회 시중노임 공표값. ⚠ **기본급여액뿐**이라 제수당·상여금·"
"퇴직급여충당금은 따로 계상해야 한다",
rule="표본이 얇은 직종(조사현장 5곳 미만 `*` · 미조사 `**`)은 금액은 서되 "
"그 사실을 화면이 따로 알린다",
code="B09_Estimation_Lists_Sources.py:137 base_reference_data",
),
ColumnProvenance(
key="hourly_krw",
label="시간당",
tier=TIER_CALC,
formula="일당 ÷ 8시간",
source="⚠ **나눈 값을 자르지 않고 그대로 둔다** — 여기서 원 단위로 자르면 "
"기계 시간당 사용료가 조금씩 어긋난다. 자르는 자리는 일위대가·내역서 쪽",
code="B09_Estimation_Lists_Sources.py:137 base_reference_data",
),
ColumnProvenance(
key="formula",
label="산 식",
tier=TIER_CALC,
formula="그 줄이 실제로 쓴 셈을 사람이 읽게 적은 한 줄",
source="서버가 값과 같이 지음 — 화면이 따로 짓지 않는다",
code="B09_Estimation_Lists_Sources.py:137 base_reference_data",
),
]
)
def base_reference_fuel_sheet() -> dict[str, Any]:
"""③ 단가 및 재료비 — 유가 한 줄. 지역을 고르면 기계 연료비가 다시 선다."""
return sheet_provenance(
[
_label("item", "항 목"),
ColumnProvenance(
key="price_krw",
label="단 가",
tier=TIER_STANDARD,
formula="공시가를 그대로 옮김 (전국 또는 고른 시도)",
source="한국석유공사 공시가. 이 값이 바뀌면 **기계 연료비가 통째로 다시 선다**",
code="B09_Estimation_Lists_Sources.py:137 base_reference_data",
),
ColumnProvenance(
key="scope",
label="적용 범위",
tier=TIER_INPUT,
formula="사용자가 고른 유가 범위 (전국 공시가 또는 현장 시도)",
source="안 고르면 전국 공시가로 선다 — 고른 적이 없다는 뜻이지 "
"「전국이 맞다」는 뜻이 아니다",
rule="⚠ **자료가 없는 시도는 고를 수 없게 둔다.** 고르게만 해 두고 값이 없으면 "
"조용히 틀린 값이 선다",
code="B09_Estimation_Lists_Sources.py:44 fuel_scopes",
),
ColumnProvenance(
key="effective_date",
label="기준일",
tier=TIER_STANDARD,
formula="그 공시가의 기준일",
source="어느 판으로 섰는지는 산출기초 ① 에 지문까지 남는다",
code="B09_Estimation_Lists_Sources.py:137 base_reference_data",
),
_label("dataset_id", "자료", "판 이름 — 산출기초 ① 의 같은 낱말"),
]
)
# =============================================================================
# ⑧ 모으는 표 둘 — **값을 낳지 않는다.** 식은 비우고 「어디서 왔나」만 적는다
# =============================================================================
def price_basis_sheet() -> dict[str, Any]:
"""단가산출근거 목록 — 내역서 줄의 단가가 어느 일위대가에서 왔는지.
**여기서 값을 다시 셈하지 않는다.** 그래서 `formula` 비웠다 없는 식을
지어 적으면 읽는 사람이 여기서 계산한다 잘못 읽는다.
"""
return sheet_provenance(
[
ColumnProvenance(
key="number",
label="번호",
tier=TIER_STANDARD,
source="내역서에 **쓰인 차례**대로 매긴 번호 — 실무 참조번호(「단산 46」)가 "
"곧 이 번호다",
code="B09_Estimation_PriceBasis.py:74 build_price_basis",
),
_label("name", "공종", "공종 이름 + 규격"),
_label("unit", "단위"),
ColumnProvenance(
key="unit_price_krw",
label="단가",
tier=TIER_CALC,
source="한 층 아래 일위대가 본표의 합계를 **그대로 옮긴 값** — 그 표는 "
"일위대가 탭에서 본다(본문 「참조」가 그 코드를 가리킨다)",
code="B09_Estimation_PriceBasis.py:74 build_price_basis",
),
]
)
def basis_sheet_tables() -> dict[str, dict[str, Any]]:
"""산출기초 네 표. **모으기만 하는 장**이라 식이 없다.
장이 답하는 물음은 값이 얼마인가 아니라 **어느 ·어느 근거로
섰나** . 그래서 `source` 등급만 채운다.
"""
versions = sheet_provenance(
[
_label("dataset_id", "자료", "판 이름"),
_label("file", "파일", "그 판의 파일 이름"),
ColumnProvenance(
key="effective_date",
label="기준일",
tier=TIER_STANDARD,
source="그 판이 언제 것인가 — 값이 달라졌을 때 가장 먼저 보는 자리",
code="B09_Estimation_BasisSheet.py:44 dataset_versions",
),
ColumnProvenance(
key="sha256",
label="지문(앞 12)",
tier=TIER_UNCLASSIFIED,
source="파일 해시. 값도 근거도 아니고 **재현성 표식**이라 여섯 어디에도 "
"안 맞는다(PLAN 8-36 ㉰). 「같은 판으로 다시 세웠나」를 가르는 데만 쓴다",
code="B09_Estimation_BasisSheet.py:44 dataset_versions",
),
]
)
chosen = sheet_provenance(
[
_label("item", "항 목", "고를 수 있는 자리의 이름"),
ColumnProvenance(
key="value",
label="고른 값",
tier=TIER_INPUT,
source="사용자가 산출 조건에서 고른 값 — 비어 있으면 **고른 적이 없어 "
"확정 기본값으로 돌고 있다**는 뜻이다",
code="B09_Estimation_BasisSheet.py:77 chosen_conditions",
),
]
)
items = sheet_provenance(
[
_label("code", "코드"),
_label("name", "공 종"),
_label("unit", "단위"),
ColumnProvenance(
key="notes",
label="근 거",
tier=TIER_STANDARD,
source="그 공종의 단가가 **어느 품셈 표·어느 [주]** 를 따랐는지. 줄에 달린 "
"근거를 모아 온 것이라 여기서 새로 짓지 않는다",
code="B09_Estimation_BasisSheet.py:106 work_item_basis",
),
]
)
gaps = sheet_provenance(
[
_label("kind", "갈 래", "못 채운 사유의 갈래"),
_label("code", "코드"),
ColumnProvenance(
key="reason",
label="사 유",
tier=TIER_BLOCKED,
source="⚠ **0 으로 때우지 않고 남겨 둔 자리.** 근거가 오면 채워질 자리이지 "
"「세면 안 되는 자리」가 아니다",
code="B09_Estimation_BasisSheet.py:134 open_gaps",
),
]
)
return {
"basis_versions": versions,
"basis_chosen": chosen,
"basis_items": items,
"basis_gaps": gaps,
}
+51 -24
View File
@@ -28,6 +28,7 @@ from B09_Estimation.B09_Estimation_Engine_Cost import (
proposed_profit_adjustment,
)
from B09_Estimation.B09_Estimation_PriceBook import PriceBookError
from B09_Estimation.B09_Estimation_Provenance import estimation_provenance
from B09_Estimation.B09_Estimation_BillOfQuantities import bill_summary, build_bill
from B09_Estimation.B09_Estimation_Guards import DoubleCountError
from B09_Estimation.B09_Estimation_Rates import RateLookupError
@@ -47,6 +48,18 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/projects", tags=["B09 Estimation"])
def _with_provenance(body: dict[str, Any]) -> dict[str, Any]:
"""근거 사전을 응답에 얹는다 — **개발환경이 아니면 칸 자체를 안 만든다.**
dict 실으면 화면이 사전이 있는데 비었다 읽어 카드를 띄운다.
그래서 `None` 이면 **키를 넣지 않는다**(로직 보안의 문은 서버 하나뿐이다).
"""
provenance = estimation_provenance()
if provenance is not None:
body["provenance"] = provenance
return body
class CostRequest(BaseModel):
"""원가계산 입력 — 금액은 원 단위."""
@@ -176,7 +189,7 @@ async def compute_cost(project_id: UUID, payload: CostRequest) -> JSONResponse:
body["suggested_profit_adjustment_krw"] = str(
proposed_profit_adjustment(result, payload.target_contract_amount_krw)
)
return JSONResponse(content={"status": "success", **body})
return JSONResponse(content=_with_provenance({"status": "success", **body}))
@router.get("/{project_id}/estimation/items")
@@ -203,11 +216,13 @@ async def list_unit_price_titles(project_id: UUID) -> JSONResponse:
try:
build = await _build_for(project_id)
return JSONResponse(
content={
"status": "success",
"summary": build_summary(build),
"rows": list_unit_prices(build),
}
content=_with_provenance(
{
"status": "success",
"summary": build_summary(build),
"rows": list_unit_prices(build),
}
)
)
except Exception:
logger.exception("B09 일위대가 목록 실패: project_id=%s", project_id)
@@ -274,7 +289,9 @@ async def get_base_data_lists(project_id: UUID) -> JSONResponse:
try:
return JSONResponse(
content={"status": "success", **all_lists(await _build_for(project_id))}
content=_with_provenance(
{"status": "success", **all_lists(await _build_for(project_id))}
)
)
except Exception:
logger.exception("B09 기초자료 목록 실패: project_id=%s", project_id)
@@ -303,13 +320,15 @@ async def get_price_sources(project_id: UUID) -> JSONResponse:
root = await _project_root_of(project_id)
settings = estimation_settings(root) if root else {}
return JSONResponse(
content={
"status": "success",
"material_comparison": material_price_comparison(build),
"base_reference": base_reference_data(
build, str(settings.get("fuel_region") or "") or None
),
}
content=_with_provenance(
{
"status": "success",
"material_comparison": material_price_comparison(build),
"base_reference": base_reference_data(
build, str(settings.get("fuel_region") or "") or None
),
}
)
)
except Exception:
logger.exception("B09 단가 원천 표 실패: project_id=%s", project_id)
@@ -332,7 +351,9 @@ async def get_basis_sheet(project_id: UUID) -> JSONResponse:
build = await _build_for(project_id)
root = await _project_root_of(project_id)
settings = estimation_settings(root) if root else {}
return JSONResponse(content={"status": "success", **basis_sheet(build, settings)})
return JSONResponse(
content=_with_provenance({"status": "success", **basis_sheet(build, settings)})
)
except Exception:
logger.exception("B09 산출기초 실패: project_id=%s", project_id)
return JSONResponse(
@@ -689,7 +710,9 @@ async def get_unit_price_detail(project_id: UUID, code: str) -> JSONResponse:
"""일위대가 **본표** — 「무엇으로 이루어졌나」. 줄마다 원천·파고들기 표시가 붙는다."""
try:
return JSONResponse(
content={"status": "success", **detail_of(await _build_for(project_id), code)}
content=_with_provenance(
{"status": "success", **detail_of(await _build_for(project_id), code)}
)
)
except PriceBookError as error:
return JSONResponse(status_code=404, content={"status": "error", "message": str(error)})
@@ -759,14 +782,18 @@ async def get_bill(project_id: UUID) -> JSONResponse:
)
return JSONResponse(
content={
"status": "success",
"rows": [row.as_dict() for row in result.rows],
"excluded": [row.as_dict() for row in result.excluded],
"materials": [row.as_dict() for row in result.material_rows],
"summary": bill_summary(result),
"price_basis": result.price_basis.as_dict() if result.price_basis else {"entries": []},
}
content=_with_provenance(
{
"status": "success",
"rows": [row.as_dict() for row in result.rows],
"excluded": [row.as_dict() for row in result.excluded],
"materials": [row.as_dict() for row in result.material_rows],
"summary": bill_summary(result),
"price_basis": (
result.price_basis.as_dict() if result.price_basis else {"entries": []}
),
}
)
)
+94 -13
View File
@@ -13,6 +13,12 @@
* ========================================================================== */
import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale";
import {
attachProvenance,
markProvenanceCell,
type ProvenancePayload,
type ProvenanceSheet,
} from "@ui/ui_template_provenance";
import { API_BASE_URL } from "@config/config_frontend";
function L(key: keyof typeof ui_locales): string {
@@ -48,6 +54,8 @@ export interface BaseDataDto {
material: BaseDataRow[];
expense: BaseDataRow[];
machine: MachineRow[];
/** 근거 사전 — **개발환경에서만** 온다. 없으면 호버·등급색이 통째로 안 붙는다. */
provenance?: ProvenancePayload;
}
export async function fetchBaseData(projectId: string): Promise<BaseDataDto> {
@@ -80,7 +88,19 @@ function note(text: string): HTMLElement {
return el;
}
function table(headers: string[], rows: string[][], leftCols: number[]): HTMLElement {
/**
* .
*
* `keys`·`sheet` **
* **( ). .
*/
function table(
headers: string[],
rows: string[][],
leftCols: number[],
keys?: string[],
sheet?: ProvenanceSheet,
): HTMLElement {
const el = document.createElement("table");
el.className = "b09-sheet";
const thead = document.createElement("thead");
@@ -99,16 +119,20 @@ function table(headers: string[], rows: string[][], leftCols: number[]): HTMLEle
const td = document.createElement("td");
td.textContent = text;
if (leftCols.includes(index)) td.className = "b09-left";
const key = keys?.[index];
const column = key ? sheet?.columns[key] : undefined;
if (key && column) markProvenanceCell(td, key, column.tier);
tr.append(td);
});
tbody.append(tr);
}
el.append(thead, tbody);
attachProvenance(el, sheet);
return el;
}
/** 목록표 한 장 — 코드·명칭·규격·단위·단가·비고 (실무 시트와 같은 칸). */
function catalogTable(rows: BaseDataRow[]): HTMLElement {
function catalogTable(rows: BaseDataRow[], sheet?: ProvenanceSheet): HTMLElement {
return table(
["코드번호", "명 칭", "규 격", "단위", "단 가", "비 고"],
rows.map((row) => [
@@ -120,6 +144,8 @@ function catalogTable(rows: BaseDataRow[]): HTMLElement {
row.note,
]),
[0, 1, 2, 5],
["code", "name", "spec", "unit", "unit_price_krw", "note"],
sheet,
);
}
@@ -148,7 +174,7 @@ export function drawBaseDataTab(body: HTMLElement, data: BaseDataDto): void {
body.append(note("아직 선 줄이 없습니다 — 값을 0 으로 채우지 않습니다."));
continue;
}
body.append(catalogTable(rows));
body.append(catalogTable(rows, data.provenance?.sheets?.catalog));
}
}
@@ -174,6 +200,18 @@ export function drawMachineTab(body: HTMLElement, data: BaseDataDto): void {
row.note,
]),
[0, 1, 2, 8],
[
"code",
"name",
"spec",
"unit",
"total_krw",
"labor_krw",
"material_krw",
"expense_krw",
"note",
],
data.provenance?.sheets?.machine,
),
);
// ⚠ 계산 과정을 감추지 않는다(PLAN 8-13). 조종원 환산이 실무와 다른 것을 여기서 밝힌다.
@@ -364,6 +402,7 @@ export function drawMachineExpense(body: HTMLElement, data: MachineExpenseDto):
export interface BasisSheetDto {
status: string;
provenance?: ProvenancePayload;
note: string;
summary: string;
dataset_versions: Array<{
@@ -402,6 +441,8 @@ export function drawBasisSheet(body: HTMLElement, data: BasisSheetDto): void {
row.sha256 || "—",
]),
[0, 1, 2, 3],
["dataset_id", "file", "effective_date", "sha256"],
data.provenance?.sheets?.basis_versions,
),
);
@@ -414,6 +455,8 @@ export function drawBasisSheet(body: HTMLElement, data: BasisSheetDto): void {
["항 목", "고른 값"],
data.chosen_conditions.map((row) => [row.item, row.value]),
[0, 1],
["item", "value"],
data.provenance?.sheets?.basis_chosen,
),
);
}
@@ -424,6 +467,8 @@ export function drawBasisSheet(body: HTMLElement, data: BasisSheetDto): void {
["코드", "공 종", "단위", "근 거"],
data.work_items.map((row) => [row.code, row.name, row.unit, row.notes.join(" · ")]),
[0, 1, 2, 3],
["code", "name", "unit", "notes"],
data.provenance?.sheets?.basis_items,
),
);
@@ -436,6 +481,8 @@ export function drawBasisSheet(body: HTMLElement, data: BasisSheetDto): void {
["갈 래", "코드", "사 유"],
data.gaps.map((row) => [row.kind, row.code, row.reason]),
[0, 1, 2],
["kind", "code", "reason"],
data.provenance?.sheets?.basis_gaps,
),
);
body.append(note("⚠ 여기 있는 것은 0 으로 때우지 않고 남겨 둔 자리입니다."));
@@ -484,6 +531,7 @@ export interface FuelScope {
export interface PriceSourcesDto {
status: string;
provenance?: ProvenancePayload;
material_comparison: {
slot_names: string[];
rows: MaterialComparisonRow[];
@@ -530,7 +578,11 @@ export async function fetchPriceSources(projectId: string): Promise<PriceSources
* **·** .
* .
*/
function comparisonTable(slotNames: string[], rows: MaterialComparisonRow[]): HTMLElement {
function comparisonTable(
slotNames: string[],
rows: MaterialComparisonRow[],
sheet?: ProvenanceSheet,
): HTMLElement {
const el = document.createElement("table");
el.className = "b09-sheet";
@@ -571,35 +623,45 @@ function comparisonTable(slotNames: string[], rows: MaterialComparisonRow[]): HT
const tbody = document.createElement("tbody");
for (const row of rows) {
const tr = document.createElement("tr");
const put = (text: string, left = false): void => {
/** 칸 하나 — `key` 를 주면 근거 호버가 붙는다(사전에 없는 열은 아무 일도 안 한다). */
const put2 = (td: HTMLElement, key: string, tier?: string): void => {
const column = sheet?.columns[key];
if (column) markProvenanceCell(td, key, tier ?? column.tier);
};
const put = (text: string, left = false, key?: string): void => {
const td = document.createElement("td");
td.textContent = text;
if (left) td.className = "b09-left";
if (key) put2(td, key);
tr.append(td);
};
put(row.code, true);
put(row.name, true);
put(row.spec, true);
put(row.unit);
put(row.code, true, "code");
put(row.name, true, "name");
put(row.spec, true, "spec");
put(row.unit, false, "unit");
for (const slot of row.slots) {
const td = document.createElement("td");
td.textContent = money(slot.price_krw);
// 채택한 원천을 굵게 — 「어느 값을 썼나」를 표가 스스로 밝힌다.
if (slot.adopted) td.style.fontWeight = "700";
// ⚠ **빈 칸은 「0원」이 아니라 「그 판에 그 품목이 없다」** — 막힌 자리로 표시한다.
put2(td, "slot_price", slot.price_krw === null ? "blocked" : undefined);
tr.append(td);
const page = document.createElement("td");
page.textContent = slot.source_note;
page.className = "b09-left";
put2(page, "slot_page");
tr.append(page);
}
if (!appliedIsLastSlot) {
put(money(row.adopted_price_krw));
put(money(row.adopted_price_krw), false, "adopted_price_krw");
put(row.adopted_slot ? (row.slots[row.adopted_slot - 1]?.name ?? "") : "", true);
}
put(row.note, true);
put(row.note, true, "note");
tbody.append(tr);
}
el.append(thead, tbody);
attachProvenance(el, sheet);
return el;
}
@@ -609,6 +671,8 @@ function baseReferenceSections(
data: PriceSourcesDto["base_reference"],
projectId: string,
reload: () => void,
laborSheet?: ProvenanceSheet,
fuelSheet?: ProvenanceSheet,
): void {
body.append(head("환율및기초자료 — ① 환율"));
body.append(note(data.exchange.note));
@@ -628,6 +692,8 @@ function baseReferenceSections(
row.formula,
]),
[0, 1, 4],
["code", "name", "day_wage_krw", "hourly_krw", "formula"],
laborSheet,
),
);
}
@@ -655,6 +721,8 @@ function baseReferenceSections(
],
],
[0, 2, 3, 4],
["item", "price_krw", "scope", "effective_date", "dataset_id"],
fuelSheet,
),
);
@@ -727,11 +795,24 @@ export function drawPriceSourcesSections(
if (comparison.rows.length === 0) {
body.append(note("아직 선 줄이 없습니다 — 값을 0 으로 채우지 않습니다."));
} else {
body.append(comparisonTable(comparison.slot_names, comparison.rows));
body.append(
comparisonTable(
comparison.slot_names,
comparison.rows,
data.provenance?.sheets?.material_comparison,
),
);
}
for (const text of comparison.notes) body.append(note(text));
baseReferenceSections(body, data.base_reference, projectId, reload);
baseReferenceSections(
body,
data.base_reference,
projectId,
reload,
data.provenance?.sheets?.base_reference_labor,
data.provenance?.sheets?.base_reference_fuel,
);
}
/** 두 표를 아직 못 받아왔을 때 — 화면을 비우지 않는다. */
+174 -28
View File
@@ -16,6 +16,14 @@
import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale";
import { createButton, createInputField, showToast } from "@ui/ui_template_elements";
import { createWorkflowLayout } from "@ui/ui_template_workflow_layout";
import { attachCollapsible } from "@ui/ui_template_collapsible";
import {
attachProvenance,
createProvenanceToggle,
markProvenanceCell,
type ProvenancePayload,
type ProvenanceSheet,
} from "@ui/ui_template_provenance";
import {
drawBaseDataTab,
drawFactorChoices,
@@ -71,6 +79,8 @@ interface CostSheetDto {
rate_version: { dataset_id: string; effective_date: string; sha256: string };
notes: string[];
suggested_profit_adjustment_krw?: string;
/** 근거 사전 — **개발환경에서만** 온다. 없으면 호버·등급색이 통째로 안 붙는다. */
provenance?: ProvenancePayload;
}
interface UnitPriceRow {
@@ -95,6 +105,7 @@ interface UnitPriceListDto {
labor_reliability: Array<{ code: string; name: string; flag: string; why: string }>;
};
rows: UnitPriceRow[];
provenance?: ProvenancePayload;
}
interface UnitPriceDetailRow extends UnitPriceRow {
@@ -119,6 +130,7 @@ interface UnitPriceDetailDto {
expense: string;
total: string;
sum_matches: boolean;
provenance?: ProvenancePayload;
/** 품셈 표에 있는데 아직 안 붙은 줄 — 있으면 이 단가는 **붙은 줄만의 값**이다. */
unattached: string[];
unattached_note: string;
@@ -175,6 +187,14 @@ function injectStyles(): void {
style.textContent = `
.b09-panel { display: flex; flex-direction: column; gap: var(--space-md, 12px); }
.b09-panel__group { display: flex; flex-direction: column; gap: var(--space-xs, 4px); }
/* B04~B07 ( .ui-sidebar-section ).
.ui-sidebar-section . */
.b09-panel__group.ui-sidebar-section {
margin: 0;
padding: calc(var(--spacing-8) + var(--spacing-4));
border-radius: var(--radius-cards);
background-color: var(--color-surface-raised);
}
.b09-panel__legend {
font-size: var(--font-size-xs, 12px); letter-spacing: .06em;
color: var(--color-text-secondary); text-transform: uppercase;
@@ -184,7 +204,6 @@ function injectStyles(): void {
display: flex; justify-content: space-between; gap: var(--space-sm, 8px);
border-bottom: 1px solid var(--color-border); padding: 2px 0;
}
.b09-panel__actions { display: flex; gap: var(--space-xs, 4px); margin-top: var(--space-sm, 8px); }
.b09-hint { font-size: var(--font-size-xs, 12px); color: var(--color-text-secondary); }
/* 표본이 얇은 노임 — 막는 것이 아니라 눈에 띄기만 하면 된다. */
.b09-hint--warn { color: var(--color-warning-text, #8a5a00); }
@@ -239,7 +258,37 @@ function formatWon(value: string): string {
return n.toLocaleString("ko-KR");
}
/**
* .
*
* ** ** .
* (2026-09-12 B08 ).
*/
function stashRowNotes(tr: HTMLElement, notes?: Array<{ column: string; text: string }>): void {
if (notes?.length) tr.dataset.provNotes = JSON.stringify(notes);
}
/** 그 칸에 **닿는** 줄 사유만 돌려준다. 열 키가 빈 조각은 줄 전체에 걸리는 사유다. */
function rowNotesFor(cell: HTMLElement, columnKey: string): string[] {
const raw = cell.closest("tr")?.dataset.provNotes;
if (!raw) return [];
try {
return (JSON.parse(raw) as Array<{ column: string; text: string }>)
.filter((note) => note.column === "" || note.column === columnKey)
.map((note) => note.text);
} catch {
return [];
}
}
/** 칸에 열 키·등급을 심는다 — **사전에 없는 열은 아무 일도 안 한다**(빈 카드 방지). */
function mark(cell: HTMLElement, sheet: ProvenanceSheet | undefined, columnKey: string): void {
const column = sheet?.columns[columnKey];
if (column) markProvenanceCell(cell, columnKey, column.tier);
}
function buildCostSheetTable(sheet: CostSheetDto): HTMLElement {
const prov = sheet.provenance?.sheets?.cost_sheet;
const wrap = document.createElement("div");
wrap.className = "b09-sheet";
@@ -287,11 +336,26 @@ function buildCostSheetTable(sheet: CostSheetDto): HTMLElement {
note.className = "b09-left";
note.textContent = line.note;
mark(name, prov, "name");
// ⚠ **같은 열 안에서 줄마다 등급이 갈리는 첫 자리.** 중간줄(간접노무비 따위)은
// `calc` 인데 마지막줄 셋은 계약으로 나가는 `final` 이다. 열 사전은 등급이 하나뿐이라
// 여기서 칸에 덮어 심는다 — 나머지 칸은 생략해 열 등급을 그대로 물려받는다.
const isFinalLine =
line.key === "total_cost" || line.key === "contract_amount" || line.key === "grand_total";
const amountColumn = prov?.columns.amount_krw;
// 등급을 빼면 공용 쪽이 열 등급으로 채워 주지만, **여기서 명시**해 두면 그 채움이
// 없는 판에서도 띠 색이 제대로 붙는다.
if (amountColumn)
markProvenanceCell(amount, "amount_krw", isFinalLine ? "final" : amountColumn.tier);
mark(rate, prov, "rate_percent");
mark(basis, prov, "formula_text");
mark(note, prov, "note");
tr.append(name, amount, rate, basis, note);
tbody.append(tr);
}
table.append(tbody);
wrap.append(table);
attachProvenance(wrap, prov);
return wrap;
}
@@ -303,6 +367,7 @@ function buildUnitPriceList(
): HTMLElement {
const wrap = document.createElement("div");
wrap.className = "b09-sheet b09-up-list";
const prov = list.provenance?.sheets?.unit_price_list;
const caption = document.createElement("div");
caption.className = "b09-hint";
@@ -341,16 +406,21 @@ function buildUnitPriceList(
const unit = document.createElement("td");
unit.className = "b09-left";
unit.textContent = row.unit;
mark(name, prov, "name");
mark(unit, prov, "unit");
tr.append(name, unit);
for (const value of [row.material, row.labor, row.expense, row.total]) {
const moneyKeys = ["material", "labor", "expense", "total"];
[row.material, row.labor, row.expense, row.total].forEach((value, index) => {
const cell = document.createElement("td");
cell.textContent = formatWon(value);
mark(cell, prov, moneyKeys[index]);
tr.append(cell);
}
});
body.append(tr);
}
table.append(body);
wrap.append(table);
attachProvenance(wrap, prov);
return wrap;
}
@@ -361,6 +431,7 @@ function buildUnitPriceDetail(
): HTMLElement {
const wrap = document.createElement("div");
wrap.className = "b09-sheet b09-up-detail";
const prov = detail.provenance?.sheets?.unit_price_detail;
const caption = document.createElement("div");
caption.className = "b09-hint";
@@ -433,12 +504,18 @@ function buildUnitPriceDetail(
const unit = document.createElement("td");
unit.className = "b09-left";
unit.textContent = row.unit;
mark(name, prov, "name");
mark(spec, prov, "spec");
mark(source, prov, "source");
mark(unit, prov, "unit");
tr.append(name, spec, source, unit);
for (const value of [row.quantity, row.material, row.labor, row.expense, row.total]) {
const detailKeys = ["quantity", "material", "labor", "expense", "total"];
[row.quantity, row.material, row.labor, row.expense, row.total].forEach((value, index) => {
const cell = document.createElement("td");
cell.textContent = formatWon(value);
mark(cell, prov, detailKeys[index]);
tr.append(cell);
}
});
body.append(tr);
}
@@ -458,6 +535,7 @@ function buildUnitPriceDetail(
table.append(body);
wrap.append(table);
attachProvenance(wrap, prov);
return wrap;
}
@@ -483,10 +561,10 @@ function buildSidePanel(
legendKey: keyof typeof ui_locales,
fields: Array<[keyof CostFormState, keyof typeof ui_locales]>,
): void => {
const group = document.createElement("div");
group.className = "b09-panel__group";
const group = document.createElement("section");
group.className = "b09-panel__group ui-collapsible ui-sidebar-section";
const legend = document.createElement("span");
legend.className = "b09-panel__legend";
legend.className = "b09-panel__legend ui-collapsible__title";
legend.textContent = L(legendKey);
group.append(legend);
for (const [field, labelKey] of fields) {
@@ -512,10 +590,10 @@ function buildSidePanel(
]);
// 요율 판 — 읽기 전용. 「어느 판으로 계산했나」가 화면에 남아야 재현성이 선다.
const rateGroup = document.createElement("div");
rateGroup.className = "b09-panel__group";
const rateGroup = document.createElement("section");
rateGroup.className = "b09-panel__group ui-collapsible ui-sidebar-section";
const rateLegend = document.createElement("span");
rateLegend.className = "b09-panel__legend";
rateLegend.className = "b09-panel__legend ui-collapsible__title";
rateLegend.textContent = L("B09_Estimation_Group_RateVersion");
const rateVersionBox = document.createElement("div");
rateGroup.append(rateLegend, rateVersionBox);
@@ -532,10 +610,10 @@ function buildSidePanel(
]);
// 수량 — 여러 줄이라 텍스트 영역으로. 비어 있으면 위 직접비 3칸을 그대로 쓴다.
const quantityGroup = document.createElement("div");
quantityGroup.className = "b09-panel__group";
const quantityGroup = document.createElement("section");
quantityGroup.className = "b09-panel__group ui-collapsible ui-sidebar-section";
const quantityLegend = document.createElement("span");
quantityLegend.className = "b09-panel__legend";
quantityLegend.className = "b09-panel__legend ui-collapsible__title";
quantityLegend.textContent = L("B09_Estimation_Group_Quantity");
const quantityLabel = document.createElement("label");
quantityLabel.className = "ui-field__label";
@@ -555,7 +633,8 @@ function buildSidePanel(
root.append(hintBox);
const actions = document.createElement("div");
actions.className = "b09-panel__actions";
// 바닥 고정 액션 줄(공용) — ui_template_overlay 가 이 줄을 스크롤 밖으로 빼낸다.
actions.className = "ui-sidebar-actions";
actions.append(
createButton({
label: L("B09_Estimation_Btn_Recalc"),
@@ -569,6 +648,9 @@ function buildSidePanel(
);
root.append(actions);
// 그룹 제목 행 클릭 시 접기/펼치기(B04~B07 공통). 액션 줄은 collapsible 이 아니다.
attachCollapsible(root);
return { root, rateVersionBox, hintBox };
}
@@ -713,6 +795,12 @@ interface BillRowDto {
is_group: boolean;
in_bill: boolean;
note: string;
/**
* **** .
*
* (2026-09-12 B08 ). `column` .
*/
notes?: Array<{ column: string; text: string }>;
}
interface PriceBasisEntryDto {
@@ -745,6 +833,7 @@ interface BillDto {
material_sheet: MaterialSheetDto | null;
};
price_basis: { entries: PriceBasisEntryDto[] };
provenance?: ProvenancePayload;
}
interface MaterialSheetRowDto {
@@ -755,6 +844,7 @@ interface MaterialSheetRowDto {
unit_price_krw: string | null;
amount_krw: string | null;
note: string;
notes?: Array<{ column: string; text: string }>;
}
interface MaterialSheetDto {
@@ -827,6 +917,9 @@ export async function renderB09Estimation(root: HTMLElement): Promise<void> {
let selectedUnitPrice: string | null = null;
let bill: BillDto | null = null;
let priceBasis: string | null = null;
// 근거 사전이 **한 번이라도** 왔는지 — 개발환경에서만 온다. 안 오면 토글도 안 세운다
// (없는 기능의 단추가 떠 있으면 눌러 보고 「고장났다」고 읽는다).
let hasProvenance = false;
const main = document.createElement("div");
main.className = "b09-main";
@@ -840,11 +933,19 @@ export async function renderB09Estimation(root: HTMLElement): Promise<void> {
body.style.display = "flex";
body.style.flexDirection = "column";
/** 사전이 **처음 온 순간에만** 탭 줄을 다시 세운다 — 토글이 그때 생긴다. */
const noteProvenance = (payload?: ProvenancePayload): void => {
if (!payload || hasProvenance) return;
hasProvenance = true;
drawTabs();
};
/** 일위대가 본표를 불러 다시 그린다 — 기계 줄을 누르면 그 층으로 파고든다. */
const openUnitPrice = async (code: string): Promise<void> => {
if (!projectId) return;
try {
unitPriceDetail = await fetchUnitPriceDetail(projectId, code);
noteProvenance(unitPriceDetail.provenance);
selectedUnitPrice = code;
drawBody();
} catch {
@@ -913,6 +1014,7 @@ export async function renderB09Estimation(root: HTMLElement): Promise<void> {
void (async () => {
try {
bill = await fetchBill(projectId);
noteProvenance(bill.provenance);
} catch {
bill = null;
window.alert(L("B09_Estimation_Boq_Failed"));
@@ -930,6 +1032,18 @@ export async function renderB09Estimation(root: HTMLElement): Promise<void> {
head.innerHTML =
"<tr><th>No.</th><th>공종</th><th>규격</th><th>단위</th>" +
"<th>수량</th><th>단가</th><th>금액</th><th>비고</th></tr>";
// 열 키는 서버 `BillRow.as_dict()` 낱말과 같아야 사전이 붙는다.
const boqKeys = [
"item_no",
"name",
"spec",
"unit",
"quantity",
"unit_price_krw",
"amount_krw",
"note",
];
const prov = bill.provenance?.sheets?.boq;
const tbody = document.createElement("tbody");
for (const row of bill.rows) {
const tr = document.createElement("tr");
@@ -947,16 +1061,20 @@ export async function renderB09Estimation(root: HTMLElement): Promise<void> {
row.amount_krw ?? "",
row.note,
];
for (const text of cells) {
cells.forEach((text, index) => {
const td = document.createElement("td");
td.textContent = text;
// 머리(그룹)줄은 값이 없다 — 빈 칸에 카드를 띄우면 「설명이 있다」는 거짓이 남는다.
if (!row.is_group) mark(td, prov, boqKeys[index]);
tr.append(td);
}
});
if (row.is_group) tr.style.fontWeight = "600";
else stashRowNotes(tr, row.notes);
tbody.append(tr);
}
table.append(head, tbody);
body.append(table);
attachProvenance(table, prov, rowNotesFor);
const total = document.createElement("div");
total.className = "b09-hint";
@@ -1054,19 +1172,23 @@ export async function renderB09Estimation(root: HTMLElement): Promise<void> {
const list = document.createElement("table");
list.className = "b09-sheet b09-up-list";
list.innerHTML = "<thead><tr><th>번호</th><th>공종</th><th>단위</th><th>단가</th></tr></thead>";
// 모으기만 하는 표라 사전에 **식이 없다** — 「어느 표에서 왔나」만 카드에 뜬다.
const prov = bill.provenance?.sheets?.price_basis;
const pbKeys = ["number", "name", "unit", "unit_price_krw"];
const tbody = document.createElement("tbody");
for (const entry of entries) {
const tr = document.createElement("tr");
for (const text of [
[
String(entry.number),
`${entry.name} ${entry.spec}`.trim(),
entry.unit,
entry.unit_price_krw,
]) {
].forEach((text, index) => {
const td = document.createElement("td");
td.textContent = text;
mark(td, prov, pbKeys[index]);
tr.append(td);
}
});
tr.style.cursor = "pointer";
if (entry.code === priceBasis) tr.style.fontWeight = "600";
tr.addEventListener("click", () => {
@@ -1076,6 +1198,7 @@ export async function renderB09Estimation(root: HTMLElement): Promise<void> {
tbody.append(tr);
}
list.append(tbody);
attachProvenance(list, prov);
split.append(list);
const picked = entries.find((entry) => entry.code === priceBasis) ?? null;
@@ -1118,11 +1241,13 @@ export async function renderB09Estimation(root: HTMLElement): Promise<void> {
return;
}
for (const [labelKey, rows, total] of [
["B09_Estimation_Mat_Contractor", sheet.contractor, sheet.contractor_total_krw],
["B09_Estimation_Mat_Owner", sheet.owner, sheet.owner_total_krw],
["B09_Estimation_Mat_Unknown", sheet.unknown, null],
] as Array<[keyof typeof ui_locales, MaterialSheetRowDto[], string | null]>) {
// ⚠ 「안 갈린 것」은 **못 세운 것이 아니라 세면 안 되는 것**이라 사전을 따로 쓴다
// (`excluded` — 채우면 이중계상, PLAN 8-36 ㉱).
for (const [labelKey, rows, total, sheetName] of [
["B09_Estimation_Mat_Contractor", sheet.contractor, sheet.contractor_total_krw, "material"],
["B09_Estimation_Mat_Owner", sheet.owner, sheet.owner_total_krw, "material"],
["B09_Estimation_Mat_Unknown", sheet.unknown, null, "material_unknown"],
] as Array<[keyof typeof ui_locales, MaterialSheetRowDto[], string | null, string]>) {
const head = document.createElement("div");
head.className = "b09-hint";
head.textContent = `${L(labelKey)} (${rows.length})` + (total === null ? "" : `${total}`);
@@ -1134,10 +1259,20 @@ export async function renderB09Estimation(root: HTMLElement): Promise<void> {
table.innerHTML =
"<thead><tr><th>자재</th><th>규격</th><th>단위</th><th>수량</th>" +
"<th>단가</th><th>금액</th><th>비고</th></tr></thead>";
const matKeys = [
"name",
"spec",
"unit",
"total_amount",
"unit_price_krw",
"amount_krw",
"note",
];
const prov = bill?.provenance?.sheets?.[sheetName];
const tbody = document.createElement("tbody");
for (const row of rows) {
const tr = document.createElement("tr");
for (const text of [
[
row.name,
row.spec,
row.unit,
@@ -1145,15 +1280,18 @@ export async function renderB09Estimation(root: HTMLElement): Promise<void> {
row.unit_price_krw ?? "",
row.amount_krw ?? "",
row.note,
]) {
].forEach((text, index) => {
const td = document.createElement("td");
td.textContent = text;
mark(td, prov, matKeys[index]);
tr.append(td);
}
});
stashRowNotes(tr, row.notes);
tbody.append(tr);
}
table.append(tbody);
body.append(table);
attachProvenance(table, prov, rowNotesFor);
}
for (const note of sheet.notes) {
@@ -1196,6 +1334,7 @@ export async function renderB09Estimation(root: HTMLElement): Promise<void> {
void fetchBasisSheet(projectId)
.then((data) => {
basisSheet = data;
noteProvenance(data.provenance);
drawBody();
})
.catch(() => {
@@ -1237,6 +1376,7 @@ export async function renderB09Estimation(root: HTMLElement): Promise<void> {
void fetchBaseData(projectId)
.then((data) => {
baseData = data;
noteProvenance(data.provenance);
drawBody();
})
.catch(() => {
@@ -1301,6 +1441,7 @@ export async function renderB09Estimation(root: HTMLElement): Promise<void> {
void fetchPriceSources(projectId)
.then((data) => {
priceSources = data;
noteProvenance(data.provenance);
drawBody();
})
.catch(() => {
@@ -1358,11 +1499,15 @@ export async function renderB09Estimation(root: HTMLElement): Promise<void> {
void fetchUnitPriceList(projectId)
.then((data) => {
unitPriceList = data;
noteProvenance(data.provenance);
drawBody();
})
.catch(() => showToast(L("B09_Estimation_UP_Load_Failed"), "error"));
}
});
// ⚠ 등급색은 **평소엔 꺼 둔다** — 여덟 색이 늘 켜져 있으면 표가 알록달록해
// 실무 시트와 눈으로 대조를 못 한다(PLAN 8-36 ②). 단추는 탭 줄 끝에 둔다.
if (hasProvenance) bar.append(createProvenanceToggle(root));
const old = main.querySelector(".b09-tabs");
if (old) old.replaceWith(bar);
else main.prepend(bar);
@@ -1374,6 +1519,7 @@ export async function renderB09Estimation(root: HTMLElement): Promise<void> {
if (!projectId) return;
try {
sheet = await fetchCostSheet(projectId, form);
noteProvenance(sheet.provenance);
renderRateVersion(panel.rateVersionBox, sheet);
panel.hintBox.textContent =
sheet.suggested_profit_adjustment_krw && sheet.suggested_profit_adjustment_krw !== "0"
@@ -36,6 +36,10 @@ from pathlib import Path
from typing import Any, Iterable
from common_util.common_util_json import atomic_write_json
from config.config_system_design import (
EARTHWORK_CONVERSION_C_RANGES,
EARTHWORK_CONVERSION_FACTORS,
)
SETTINGS_FILENAME = "project_settings.json"
SCHEMA_VERSION = 1
@@ -258,6 +262,62 @@ def concrete_placing_method(settings: dict[str, Any]) -> tuple[str, bool]:
return DEFAULT_CONCRETE_PLACING_METHOD, True
def earthwork_conversion_factors(settings: dict[str, Any]) -> dict[str, dict[str, float]]:
"""이 프로젝트가 쓸 토량환산계수 — **기본값 위에 고른 값만 얹는다.**
정의처는 여전히 `config_system_design.EARTHWORK_CONVERSION_FACTORS` 곳이다.
여기서 값을 새로 적지 않고, 설계자가 고른 갈래만 갈아 끼운다. 고른 갈래는
자체가 없어 정본이 그대로 선다 기본값을 복사해 넣지 않는 까닭은 파일
머리글 `*_override` 규칙과 같다.
값은 토적표만 쓰는 것이 아니다 유토곡선(B06)·운반표·기초단가가 같이 읽는다.
그래서 읽는 자리마다 상수를 직접 들지 말고 ** 함수를 거친다.**
고른 값의 모양 `conversion_factors_override`
`{"ripping_rock": {"compacted": 1.0, "reason": "토질시험 값"}}`
`reason` 품셈 범위 밖을 골랐을 남기는 사유이고 계산에 쓴다.
"""
resolved = {kind: dict(entry) for kind, entry in EARTHWORK_CONVERSION_FACTORS.items()}
override = settings.get("conversion_factors_override")
if not isinstance(override, dict):
return resolved
for kind, entry in override.items():
if kind not in resolved or not isinstance(entry, dict):
continue
value = entry.get("compacted")
if isinstance(value, (int, float)) and not isinstance(value, bool) and float(value) > 0:
resolved[kind]["compacted"] = float(value)
return resolved
def earthwork_conversion_choices(settings: dict[str, Any]) -> dict[str, dict[str, Any]]:
"""갈래별 「무엇을 골랐나」 — 화면이 기본값과 고른 값을 갈라 보이는 데 쓴다.
`{갈래: {"compacted", "default", "chosen", "in_range", "range", "reason"}}`.
`chosen` 거짓이면 기본값이 것이고, `in_range` 거짓이면 품셈 범위 밖이라
사유가 있어야 하는 자리다. **범위 밖이라고 막지 않는다**(품셈 원칙이 토질시험이다).
"""
override = settings.get("conversion_factors_override")
override = override if isinstance(override, dict) else {}
resolved = earthwork_conversion_factors(settings)
choices: dict[str, dict[str, Any]] = {}
for kind, entry in resolved.items():
default = float(EARTHWORK_CONVERSION_FACTORS[kind]["compacted"])
value = float(entry["compacted"])
low, high = EARTHWORK_CONVERSION_C_RANGES.get(kind, (None, None))
entry_override = override.get(kind)
reason = entry_override.get("reason") if isinstance(entry_override, dict) else None
choices[kind] = {
"compacted": value,
"default": default,
"chosen": value != default,
"in_range": low is None or low <= value <= high,
"range": [low, high] if low is not None else None,
"reason": str(reason) if reason else None,
}
return choices
def application_ratio(settings: dict[str, Any], key: str) -> float:
"""반영률을 0~1 로. 없으면 100 %(=1.0) — 실무 관측치를 기본값으로 쓰지 않는다."""
raw = (settings.get("application_ratios_pct") or {}).get(key, 100)
+120
View File
@@ -0,0 +1,120 @@
"""화면에 뜬 숫자가 **어디서 와서 어떻게 계산됐는지**를 적어 두는 한 벌.
서버가 드나 (CLAUDE.md 5 · PLAN 8-36 )
어디서 와서 어떻게 계산됐나 정답은 **엔진이 안다.** 설명을 화면 TS 손으로
적어 두면 엔진을 고칠 설명만 옛것으로 남아, 맞는 옆에 틀린 근거가 붙는다.
그래서 사전은 값을 낳는 (서버) 들고, 화면은 **그리기만** 한다.
**칸마다 만들지 않는다 단위다.**
토적표 장이 30 × 200 = 6 칸이다. 칸마다 설명을 지으면 응답이 수십 배로 붐는데,
정작 설명이 갈리는 것은 ****이지 칸이 아니다. 줄마다 갈리는 (폴백 안분 사유 )
이미 줄이 `notes` 들고 있으니 화면이 그것만 덧붙인다. 보는 사람 눈에는 그대로
** 단위** 뜬다.
**로직 보안 배포에서는 아예 실어 보낸다.**
화면에서 숨기는 것만으로는 막히지 않는다. API 직접 부르면 그대로 나온다.
그래서 `provenance_payload()` **개발환경이 아니면 `None`** 돌려주고, 라우터는
`None` 응답에서 통째로 뺀다. 화면 `import.meta.env.DEV` 보조일 뿐이다.
문의 정본은 `common_util_dev_unlock.is_dev_environment()` 하나로 통일한다
개발용 문이 벌이 되면 한쪽만 닫히는 날이 온다.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Iterable, Mapping
from common_util.common_util_dev_unlock import is_dev_environment
#: 출처 등급 (PLAN 8-36 ①) — 사람이 고르는 여섯 + 사전이 쓰는 둘(`excluded`·`unclassified`). **키는 영문 고정** — 화면·서버가 같은 낱말을 써야 하고,
#: 사람이 읽는 이름은 화면 locale 이 맡는다(번역이 서버 값을 흔들면 안 된다).
#:
#: ⚠ 등급에 **안 맞는 열이 나오면 억지로 끼우지 말 것.** 그 어긋남이 등급을 고칠 근거다.
#: 맞는 등급이 없으면 `UNCLASSIFIED` 로 두고 계획서에 남긴다 — 조용히 아무 등급이나
#: 붙이면 「분류가 있다」는 거짓만 남는다.
TIER_INPUT = "input" # 사용자가 화면에 직접 넣은 값
TIER_SURVEY = "survey" # 앞 단계(B05 종단·B06 횡단)가 낳은 값
TIER_STANDARD = "standard" # 법·품셈·단가판이 정한 고정값
TIER_CALC = "calc" # 위 셋으로 만든 중간값
TIER_FINAL = "final" # 내역서·원가계산서로 나가는 값
TIER_BLOCKED = "blocked" # 근거가 없어 값을 **못** 세운 자리 — 근거가 오면 채워질 자리
#: ⚠ `EXCLUDED` 는 `BLOCKED` 와 **뜻이 정반대**다(2026-09-12 데스크탑 보조 B09 조사 ㉱).
#: 내역서의 「우리 줄이 아닌 것」·검산용 제외 줄·이중계상이 되는 자리는 **못 세운 것이 아니라
#: 세면 안 되는 것**이다. 둘을 같은 등급으로 두면 사용자가 「빈 칸을 채워야 겠다」고 움직이고,
#: 그것이 곧 이중계상이다(PLAN 8-7).
TIER_EXCLUDED = "excluded" # 일부러 안 세는 자리 — 채우면 이중계상
TIER_UNCLASSIFIED = "unclassified" # 어느 등급에도 안 맞아 **판단을 미룬** 자리
TIERS: tuple[str, ...] = (
TIER_INPUT,
TIER_SURVEY,
TIER_STANDARD,
TIER_CALC,
TIER_FINAL,
TIER_BLOCKED,
TIER_EXCLUDED,
TIER_UNCLASSIFIED,
)
@dataclass(frozen=True)
class ColumnProvenance:
"""열 하나의 「무엇이고 · 어디서 왔고 · 어떻게 나왔나」.
`formula` **사람이 읽는 **이지 실행되는 식이 아니다 코드를 그대로 베끼면
읽는 사람이 읽고, 코드가 바뀌면 어긋난다. 입적 × 토량환산계수처럼 적는다.
`code` `파일:` 이고 **개발환경에서만 화면에 뜬다.** 번호는 쉽게 밀리므로
함수 이름을 같이 적어 두면 밀려도 찾을 있다.
"""
key: str
label: str
tier: str
formula: str = ""
source: str = ""
#: 고르는 자리의 **채택 규칙**. 안전관리비 A·B 중 작은 쪽·자재단가 다섯 중 적용처럼
#: **값 안에 선택이 숨은** 열이 있다(2026-09-12 B09 조사 ㉰). 그 열은 `calc` 로만 적으면
#: 「왜 그것을 골랐나」가 사라진다. 후보값은 줄마다 달라지므로 여기엔 **규칙만** 적고
#: 실제 후보값은 줄 쪽으로 내려보낸다.
rule: str = ""
code: str = ""
def as_dict(self) -> dict[str, str]:
body: dict[str, str] = {"label": self.label, "tier": self.tier}
if self.formula:
body["formula"] = self.formula
if self.source:
body["source"] = self.source
if self.rule:
body["rule"] = self.rule
if self.code:
body["code"] = self.code
return body
def sheet_provenance(columns: Iterable[ColumnProvenance]) -> dict[str, Any]:
"""한 장(시트)의 사전. 열 키로 찾아 쓰게 dict 로 편다.
같은 키를 적으면 **뒤엣것이 앞엣것을 조용히 덮는다.** 열이 실수하기
쉬운 자리라 여기서 막고 이름을 알려 준다.
"""
body: dict[str, dict[str, str]] = {}
for column in columns:
if column.key in body:
raise ValueError(f"사전에 같은 열 키가 둘 있습니다: {column.key}")
if column.tier not in TIERS:
raise ValueError(f"모르는 등급입니다: {column.key}{column.tier}")
body[column.key] = column.as_dict()
return {"columns": body}
def provenance_payload(sheets: Mapping[str, dict[str, Any]]) -> dict[str, Any] | None:
"""응답에 실을 사전 — **개발환경이 아니면 `None`.**
라우터는 `None` 이면 칸을 응답에서 아예 뺀다( dict 실으면 사전이 있는데
비었다 읽혀 화면이 카드를 띄운다).
"""
if not is_dev_environment():
return None
return {"sheets": dict(sheets)}
+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 고정이다. 사용자가 화면에서 고른
@@ -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
+12
View File
@@ -758,6 +758,18 @@ export const ui_locales_b2 = {
B08_Quantity_Side_Method: ["산출법", "Method"],
B08_Quantity_Side_Method_Value: ["평균단면적법", "Average end area"],
B08_Quantity_Side_Factors: ["토량환산계수(다짐)", "Conversion factors (compacted)"],
B08_Quantity_Factor_Reach: [
"이 계수는 유토곡선·운반표·기초단가에도 같이 닿습니다.",
"These factors also feed the mass-haul curve, haul table and basis units.",
],
B08_Quantity_Factor_Default: ["기본값", "Default"],
B08_Quantity_Factor_Range: ["품셈 범위", "Standard range"],
B08_Quantity_Factor_OutOfRange: [
"품셈 범위 밖입니다 — 사유를 적어 주세요(토질시험 값일 수 있습니다).",
"Outside the standard range — please note why (it may be a soil-test value).",
],
B08_Quantity_Factor_Reason: ["사유", "Reason"],
B08_Quantity_Factor_Pumsem: ["품셈 체적변화율(C)", "Standard volume-change (C)"],
/* --- B09_Estimation 원가계산 --- */
B09_Estimation_Title: ["원가계산", "Cost Estimate"],
+287
View File
@@ -0,0 +1,287 @@
/* =============================================================================
* ui_template_provenance.ts
* ** ** .
* B08 ·B09 (PLAN 8-36 ).
*
* ** ( ).**
* (`common_util_provenance.provenance_payload`).
* ** **
* . ( ).
*
* ** ** ** **.
* 6 . ( )
* `resolveExtra` .
* ========================================================================== */
/** 열 하나의 사전. 서버 `ColumnProvenance.as_dict()` 와 1:1. */
export interface ProvenanceColumn {
label: string;
tier: string;
formula?: string;
source?: string;
/** 고르는 자리의 채택 규칙(「안전관리비 A·B 중 작은 쪽」 따위). 없으면 칸이 안 뜨다. */
rule?: string;
code?: string;
}
/** 한 장(시트)의 사전. */
export interface ProvenanceSheet {
columns: Record<string, ProvenanceColumn>;
}
/** 응답에 실려 오는 사전 전체. 개발환경이 아니면 **칸 자체가 없다**(`undefined`). */
export interface ProvenancePayload {
sheets: Record<string, ProvenanceSheet>;
}
/** 등급 여섯(+미분류). 키는 서버와 같은 낱말이라야 한다 — 어긋나면 색도 카드도 빈다. */
const TIER_LABELS: Record<string, string> = {
input: "입력",
survey: "측량",
standard: "기준",
calc: "계산",
final: "최종",
blocked: "막힘",
excluded: "제외",
unclassified: "미분류",
};
/** 색칠을 켤지 — **포트별로 갈리는 sessionStorage** 에 둔다(창마다 취향이 다르다). */
const TINT_KEY = "aislo.provenance.tint";
const STYLE_ID = "ui-provenance-style";
const CARD_ID = "ui-provenance-card";
/**
* . .
*
* `tier` ** .**
* ( ) `calc`
* (··) `final` (2026-09-12 B09 ).
* .
*/
export function markProvenanceCell(cell: HTMLElement, columnKey: string, tier?: string): void {
cell.dataset.provCol = columnKey;
if (tier) cell.dataset.provTier = tier;
}
export function isProvenanceTinted(): boolean {
try {
return sessionStorage.getItem(TINT_KEY) === "on";
} catch {
return false;
}
}
function setTinted(root: HTMLElement, on: boolean): void {
root.classList.toggle("is-prov-tinted", on);
try {
sessionStorage.setItem(TINT_KEY, on ? "on" : "off");
} catch {
/* 저장이 막힌 창에서도 화면은 돌아야 한다 — 이번 화면에서만 켜진다. */
}
}
/**
* . ** **( `payload` ).
*
* .
* ** ** .
*/
export function createProvenanceToggle(root: HTMLElement): HTMLElement {
const button = document.createElement("button");
button.type = "button";
button.className = "ui-prov-toggle";
const paint = (): void => {
const on = root.classList.contains("is-prov-tinted");
button.textContent = on ? "등급색 끄기" : "등급색 켜기";
button.classList.toggle("is-on", on);
};
setTinted(root, isProvenanceTinted());
paint();
button.addEventListener("click", () => {
setTinted(root, !root.classList.contains("is-prov-tinted"));
paint();
});
return button;
}
function line(card: HTMLElement, name: string, value: string): void {
if (!value) return;
const row = document.createElement("div");
row.className = "ui-prov-card__row";
const key = document.createElement("span");
key.className = "ui-prov-card__key";
key.textContent = name;
const body = document.createElement("span");
body.className = "ui-prov-card__value";
body.textContent = value;
row.append(key, body);
card.append(row);
}
function card(): HTMLElement {
let element = document.getElementById(CARD_ID);
if (!element) {
element = document.createElement("div");
element.id = CARD_ID;
element.className = "ui-prov-card";
document.body.append(element);
}
return element;
}
/** 카드를 마우스 옆에 둔다 — 화면 밖으로 나가면 반대쪽으로 접는다. */
function place(element: HTMLElement, x: number, y: number): void {
element.style.visibility = "hidden";
element.style.display = "block";
const box = element.getBoundingClientRect();
const left = x + 16 + box.width > window.innerWidth ? x - 16 - box.width : x + 16;
const top = y + 16 + box.height > window.innerHeight ? y - 16 - box.height : y + 16;
element.style.left = `${Math.max(4, left)}px`;
element.style.top = `${Math.max(4, top)}px`;
element.style.visibility = "visible";
}
/** 카드 한 장을 채운다. 값은 **화면에 적힌 글자 그대로** 보인다 — 자리수까지 같은 것이 요점. */
/** . ** **
* 믿 . */
function fill(
target: HTMLElement,
cell: HTMLElement,
column: ProvenanceColumn,
value: string,
extra: string[],
): void {
target.replaceChildren();
const head = document.createElement("div");
head.className = "ui-prov-card__head";
const title = document.createElement("span");
title.textContent = column.label;
const badge = document.createElement("span");
badge.className = "ui-prov-card__badge";
const tier = cell.dataset.provTier || column.tier;
badge.dataset.provTier = tier;
badge.textContent = TIER_LABELS[tier] ?? tier;
head.append(title, badge);
target.append(head);
line(target, "값", value);
line(target, "식", column.formula ?? "");
line(target, "원천", column.source ?? "");
line(target, "채택", column.rule ?? "");
line(target, "자리", column.code ?? "");
for (const note of extra) line(target, "줄 사유", note);
}
/**
* . `data-prov-col` .
*
* `resolveExtra` ( ) .
* ** ** .
*/
export function attachProvenance(
root: HTMLElement,
sheet: ProvenanceSheet | undefined,
resolveExtra?: (cell: HTMLElement, columnKey: string) => string[],
): void {
if (!sheet) return;
injectProvenanceStyles();
// 등급을 안 심은 칸은 **여기서 열 등급으로 채운다.**
// 색칠은 CSS 가 `data-prov-tier` 를 보고 하므로, 그 칸은 배지만 띄고 띄는 안 붙어
// 「색이 안 붙는 칸」이 생긴다. 부르는 쪽이 등급을 빼먹는 것은 흔한 일이라 여기서 맞춘다.
for (const cell of root.querySelectorAll<HTMLElement>("[data-prov-col]")) {
if (cell.dataset.provTier) continue;
const tier = sheet.columns[cell.dataset.provCol ?? ""]?.tier;
if (tier) cell.dataset.provTier = tier;
}
const hide = (): void => {
const element = document.getElementById(CARD_ID);
if (element) element.style.display = "none";
};
root.addEventListener("mouseover", (event) => {
const cell = (event.target as HTMLElement).closest<HTMLElement>("[data-prov-col]");
if (!cell || !root.contains(cell)) return;
const column = sheet.columns[cell.dataset.provCol ?? ""];
if (!column) return hide();
const target = card();
fill(
target,
cell,
column,
cell.textContent?.trim() ?? "",
resolveExtra?.(cell, cell.dataset.provCol ?? "") ?? [],
);
place(target, (event as MouseEvent).clientX, (event as MouseEvent).clientY);
});
root.addEventListener("mousemove", (event) => {
const element = document.getElementById(CARD_ID);
if (!element || element.style.display === "none") return;
place(element, (event as MouseEvent).clientX, (event as MouseEvent).clientY);
});
root.addEventListener("mouseleave", hide);
root.addEventListener("mouseout", (event) => {
const next = (event as MouseEvent).relatedTarget as HTMLElement | null;
if (!next || !next.closest?.("[data-prov-col]")) hide();
});
}
const CSS = `
/* . +
. . */
.is-prov-tinted [data-prov-tier="input"] { box-shadow: inset 3px 0 0 var(--color-accent); background: color-mix(in srgb, var(--color-accent) 7%, transparent); }
.is-prov-tinted [data-prov-tier="survey"] { box-shadow: inset 3px 0 0 var(--color-info, #3b82f6); background: color-mix(in srgb, var(--color-info, #3b82f6) 7%, transparent); }
.is-prov-tinted [data-prov-tier="standard"] { box-shadow: inset 3px 0 0 var(--color-text-secondary); background: color-mix(in srgb, var(--color-text-secondary) 7%, transparent); }
.is-prov-tinted [data-prov-tier="calc"] { box-shadow: inset 3px 0 0 var(--color-success, #16a34a); background: color-mix(in srgb, var(--color-success, #16a34a) 7%, transparent); }
.is-prov-tinted [data-prov-tier="final"] { box-shadow: inset 3px 0 0 var(--color-warning, #c08a3e); background: color-mix(in srgb, var(--color-warning, #c08a3e) 10%, transparent); }
.is-prov-tinted [data-prov-tier="blocked"] { box-shadow: inset 3px 0 0 var(--color-danger, #dc2626); background: color-mix(in srgb, var(--color-danger, #dc2626) 8%, transparent); }
.is-prov-tinted [data-prov-tier="excluded"] { box-shadow: inset 3px 0 0 var(--color-muted, #9ca3af); background: repeating-linear-gradient(135deg, transparent, transparent 5px, color-mix(in srgb, var(--color-muted, #9ca3af) 12%, transparent) 5px, color-mix(in srgb, var(--color-muted, #9ca3af) 12%, transparent) 10px); }
.is-prov-tinted [data-prov-tier="unclassified"] { box-shadow: inset 3px 0 0 var(--color-border); }
.ui-prov-toggle {
font-size: 11px;
padding: 2px 8px;
color: var(--color-text-secondary);
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-pills, 999px);
cursor: pointer;
}
.ui-prov-toggle.is-on { color: var(--color-accent); border-color: var(--color-accent); }
.ui-prov-card {
display: none;
position: fixed;
z-index: 9999;
max-width: 26rem;
padding: 8px 10px;
font-size: 11.5px;
line-height: 1.5;
color: var(--color-text);
background: var(--color-surface-raised);
border: 1px solid var(--color-border);
border-radius: var(--radius-cards, 8px);
box-shadow: 0 6px 18px rgb(0 0 0 / 18%);
pointer-events: none;
}
.ui-prov-card__head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 4px; font-weight: 600; }
.ui-prov-card__badge { flex: 0 0 auto; padding: 0 6px; font-size: 10.5px; font-weight: 500; border-radius: var(--radius-pills, 999px); border: 1px solid currentColor; }
.ui-prov-card__badge[data-prov-tier="input"] { color: var(--color-accent); }
.ui-prov-card__badge[data-prov-tier="survey"] { color: var(--color-info, #3b82f6); }
.ui-prov-card__badge[data-prov-tier="standard"] { color: var(--color-text-secondary); }
.ui-prov-card__badge[data-prov-tier="calc"] { color: var(--color-success, #16a34a); }
.ui-prov-card__badge[data-prov-tier="final"] { color: var(--color-warning, #c08a3e); }
.ui-prov-card__badge[data-prov-tier="blocked"] { color: var(--color-danger, #dc2626); }
.ui-prov-card__badge[data-prov-tier="excluded"] { color: var(--color-muted, #9ca3af); }
.ui-prov-card__row { display: flex; gap: 8px; }
.ui-prov-card__key { flex: 0 0 2.4rem; color: var(--color-text-secondary); }
.ui-prov-card__value { min-width: 0; white-space: pre-wrap; }
`;
export function injectProvenanceStyles(): void {
if (document.getElementById(STYLE_ID)) return;
const style = document.createElement("style");
style.id = STYLE_ID;
style.textContent = CSS;
document.head.append(style);
}