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