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:
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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>(
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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}
|
||||
@@ -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";
|
||||
|
||||
@@ -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 사용자 확정) — 이동은 막지 않는다.
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
* **매 프레임 다시 잰다** — 창 크기·배율·이동이 바뀌어도 띠가 노선을 따라간다. 띠 자체는
|
||||
|
||||
@@ -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];
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
)
|
||||
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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": []}
|
||||
),
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
/** 두 표를 아직 못 받아왔을 때 — 화면을 비우지 않는다. */
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)}
|
||||
@@ -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]
|
||||
@@ -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
|
||||
@@ -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"],
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user