diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Longitudinal.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Longitudinal.ts index 53d57907..6a2d3e56 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Longitudinal.ts +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Longitudinal.ts @@ -252,6 +252,9 @@ export function createLongitudinalProfile( } } + // 구조물 이름은 **맨 마지막에** 붙인다 — 측점 마커 안에 넣으면 뒤이어 그리는 지반선· + // 계획선이 그 위를 덮는다(2026-08-02 사용자 지시: 오버레이 순서 최상위). + const structureLabels: SVGTextElement[] = []; for (const station of data.stations) { const stationX = x(station.chainage_m); const selected = station.station_id === selectedStationId; @@ -293,13 +296,14 @@ export function createLongitudinalProfile( class: "b06-chart__station-label", }), ); - // 구조물(비정규) 측점: 구조물 이름을 측점선 상단에 표기해 위치를 식별할 수 있게 한다. - // 그래프 이름표가 플롯 안 상단으로 들어왔으므로 그 아래로 내려 겹치지 않게 한다. + // 구조물(비정규) 측점: 구조물 이름으로 위치를 식별할 수 있게 한다. + // 플롯 **하단**(측점 라벨 바로 위)에 둔다 — 상단은 그래프 이름표와 절·성토 표기가 + // 이미 쓰고 있어 서로 겹쳤다(2026-08-02 사용자 지시). if (station.structure) { - marker.append( + structureLabels.push( svgText(station.structure, { x: stationX, - y: LONG_PAD.top + 26, + y: heightPx - LONG_PAD.bottom - 5, "text-anchor": "middle", class: "b06-chart__structure-label", }), @@ -357,6 +361,8 @@ export function createLongitudinalProfile( class: "b06-chart__axis-label", }), ); + // 구조물 이름표는 지반선·계획선·축까지 다 그린 **뒤에** 얹는다 — 무엇에도 가리지 않는다. + if (structureLabels.length) svg.append(...structureLabels); wrapper.append(svg); return wrapper; } diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul_Balance.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul_Balance.ts index 20cfbb92..b4909346 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul_Balance.ts +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul_Balance.ts @@ -1,24 +1,24 @@ /* ============================================================================= * B06_wf3_ProfileCross_UI_MassHaul_Balance.ts - * 토량 분배(평형선) 계산 엔진 — 순수 함수, DOM 의존 없음. + * 토량 분배(평형선 · 운반 블록 · 장비 띠) 계산 엔진 — 순수 함수, DOM 의존 없음. * * 누가토량 곡선(`_UI_MassHaul`)은 "얼마가 남는가"만 말한다. 이 파일은 그 곡선에서 - * **어디 흙을 어디로 얼마나 옮기는가**를 뽑아 낸다: 평형선 → 운반 블록 → 운반토량 `Q` → - * 평균운반거리 `L` → 지반유형 안분 → 장비 선정. + * **어디 흙을 어디로 얼마나 옮기는가**를 뽑아 낸다: 평형선 → 운반 블록 → 장비 띠 → + * 띠별 운반토량 `Q` · 평균운반거리 `L` · 지반유형 안분. * * ── 작도 규칙(참고자료) ───────────────────────────────────────────── * * - 상승 = 절토, 하강 = 성토. 극대·극소점 = 절·성토 경계. * - 산 모양 = 좌→우 운반, 골 모양 = 우→좌 운반. * - 평형선(수평선)이 곡선을 자르는 두 점 사이는 절토량 = 성토량. - * - 운반토량 `Q` = 평형선에서 곡선 정점까지의 종거. - * - 평균운반거리 `L` = **종거의 1/2 지점을 지나는 수평선 B′C′의 길이**(반종거 수평선). + * - 운반토량 = 평형선에서 곡선 정점까지의 종거. + * - 평균운반거리 = **종거의 1/2 지점을 지나는 수평선 B′C′의 길이**(반종거 수평선). * - 배분 3원칙 = ① 운반거리 최소 ② 높은 곳 → 낮은 곳 ③ 토량을 모아 운반. * * ── 평형선을 어떻게 자동으로 잡는가 ───────────────────────────────── - * 참고자료는 "가장 유리한 평형점을 도상에서 구한다"까지만 말한다. 이를 결정론으로 옮길 때 - * **0선 하나로 고정하면 안 된다** — 임도는 절토가 크게 우세해 사토가 전부 종점 한 덩어리로 - * 몰린다. 그래서 곡선을 왼쪽부터 훑으며 평형선 높이를 **계단으로 올린다**: + * 참고자료는 "가장 유리한 평형점을 도상에서 구한다"까지만 말한다. **0선 하나로 고정하면 + * 안 된다** — 임도는 절토가 크게 우세해 사토가 전부 종점 한 덩어리로 몰린다. 그래서 곡선을 + * 왼쪽부터 훑으며 평형선 높이를 **계단으로 올린다**: * * 커서(곡선 위, 높이 = 현재 평형선)에서 다음 극값을 본다. * - 올라갔다가(절토) 다시 현재 높이 **아래**까지 내려오면 → 성토가 절토를 다 받는다. @@ -30,6 +30,18 @@ * 이렇게 하면 ① 구간이 겹치거나 비지 않고 ② 사토·토취가 발생 지점에 남으며 * ③ 단차의 합이 최종 누가토량과 정확히 일치한다. * + * ── 블록 하나를 장비 띠로 가르는 이유 ─────────────────────────────── + * 참고 도면의 토량배분도에는 수평선이 블록마다 여러 개 그려져 있고(A–A′ / B–B′ / C–C′), + * 그 사이 띠마다 번호가 붙어 종단면의 해칭 구간과 1:1로 대응한다. 하단 브래킷 + * (무대운반거리 / 도자 평균운반거리 / 덤프 평균운반거리)의 폭이 각 수평선의 길이와 같다. + * + * 즉 **수평선의 높이는 그 현(弦)의 길이가 장비 경계거리와 같아지는 높이**다. 누가토량 + * 그래프에서 세로축이 곧 토량이므로, 두 수평선 사이 띠의 두께가 그 장비가 옮기는 토량이다. + * 현 길이 20m 되는 높이 위쪽 → 종무대 + * 거기서 70m 되는 높이까지 → 도쟈 + * 그 아래 ~ 평형선 → 덤프 + * 경계값 정의처는 `config_system.EARTHWORK_HAUL_EQUIPMENT_LIMITS_M` 한 곳뿐이다. + * * ── 단위 ───────────────────────────────────────────────────────── * 곡선이 다짐상태 기준이므로 `Q`·EA/RR/BR 안분도 **다짐상태**다. 내역서용 자연상태 수량은 * `MassHaulResult.cut_natural_m3` / 측점별 `cut_soil_m3`·`cut_rr_m3`·`cut_br_m3`가 따로 든다. @@ -38,7 +50,7 @@ import type { EarthworkConversion, GroundType } from "./B06_wf3_ProfileCross_Api_Fetch"; import type { MassHaulPoint, MassHaulResult } from "./B06_wf3_ProfileCross_UI_MassHaul"; -/** 운반거리 상한 하나(㎥ 아님, m). `max_distance_m: null`이면 상한 없음(최종 후보). */ +/** 운반거리 상한 하나(m). `max_distance_m: null`이면 상한 없음(나머지를 전부 받는다). */ export interface HaulEquipmentLimit { key: string; max_distance_m: number | null; @@ -47,35 +59,59 @@ export interface HaulEquipmentLimit { /** 운반 방향. 산 모양이면 좌→우(`forward`), 골 모양이면 우→좌(`backward`). */ export type HaulDirection = "forward" | "backward"; +/** 곡선 위 한 점(누가거리 m, 누가토량 ㎥). 렌더러가 그대로 좌표로 옮긴다. */ +export interface CurvePoint { + m: number; + v: number; +} + +/** 블록 하나를 장비 경계현으로 가른 띠. 띠 두께가 곧 그 장비의 운반토량이다. */ +export interface HaulBand { + /** 전체 통번호(1부터). 도면의 ①②③에 해당한다. */ + index: number; + /** config 경계로 고른 장비 키. 경계값을 못 받으면 null(프론트에 사본을 두지 않는다). */ + equipment: string | null; + /** 띠의 평형선 쪽 / 정점 쪽 경계 높이(누가토량 ㎥). */ + level_base_m3: number; + level_apex_m3: number; + /** 이 띠의 운반토량(㎥, 다짐상태) = 두 경계의 차. */ + volume_m3: number; + /** 평형선 쪽 경계현(= 이 장비의 경계거리와 길이가 같은 수평선)의 양 끝. */ + boundary_from_m: number; + boundary_to_m: number; + /** 평균운반거리 `L`(m) = 띠 중간 높이 현의 길이. */ + haul_distance_m: number; + haul_from_m: number; + haul_to_m: number; + /** `Q`를 절토 구간의 지반유형 구성비로 안분한 값(㎥). 합 = `volume_m3`. */ + ea_m3: number; + rr_m3: number; + br_m3: number; + /** 띠를 감싸는 다각형(곡선 두 변 + 위아래 현). 클릭 대상이자 해칭 면이다. */ + outline: CurvePoint[]; +} + export interface HaulBlock { - /** 1부터. 도면 balloon의 블록 번호. */ + /** 1부터. */ index: number; /** 이 블록의 평형선 높이(누가토량 축, ㎥). */ base_m3: number; /** 평형선이 곡선을 자르는 두 점(누가거리 m). */ from_m: number; to_m: number; - /** 정점(극대/극소) 위치와 값 — balloon 지시선 앵커. */ + /** 정점(극대/극소) 위치와 값. */ apex_m: number; apex_volume_m3: number; - /** 운반토량 `Q`(㎥, 다짐상태) = 종거. */ + /** 블록 전체 운반토량 `Q`(㎥) = 종거. 띠 물량의 합과 같다. */ volume_m3: number; - /** 평균운반거리 `L`(m) = 반종거 수평선 길이. */ - haul_distance_m: number; - /** 반종거 수평선의 양 끝(누가거리 m) — 렌더러가 그 선을 그대로 그린다. */ - haul_from_m: number; - haul_to_m: number; direction: HaulDirection; - /** `Q`를 절토 구간의 지반유형 구성비로 안분한 값(㎥, 다짐상태). 합 = `volume_m3`. */ - ea_m3: number; - rr_m3: number; - br_m3: number; - /** config 경계로 고른 장비 키. 경계값을 못 받으면 null(프론트에 사본을 두지 않는다). */ - equipment: string | null; + /** 장비 경계현으로 가른 띠. 정점 쪽(가까운 장비)부터 평형선 쪽 순으로 담긴다. */ + bands: HaulBand[]; } /** 받아 줄 짝이 없어 남은 토량. `spoil` = 사토(잉여), `borrow` = 토취(부족). */ export interface HaulResidual { + index: number; kind: "spoil" | "borrow"; from_m: number; to_m: number; @@ -110,6 +146,11 @@ const EPSILON = 1e-9; */ const MIN_SWING_RATIO = 0.02; +/** 경계현 높이를 훑을 때 쪼개는 칸 수. 잔요철에 속지 않을 만큼 촘촘해야 한다. */ +const CHORD_SCAN_STEPS = 64; +/** 훑어서 찾은 칸 안에서 좁히는 이분법 반복 수. 24회면 칸 폭의 1/1600만 남는다. */ +const CHORD_SOLVE_STEPS = 24; + function factorFor(conversion: EarthworkConversion, ground: GroundType): number { const factor = conversion?.[ground]?.compacted; return Number.isFinite(factor) && factor > 0 ? factor : 1; @@ -134,7 +175,7 @@ function valueAt(points: MassHaulPoint[], x: number): number { /** * `[fromX, toX]`를 왼쪽부터 훑어 곡선이 `level`을 **처음** 지나는 x. 없으면 null. - * 평형선·반종거 수평선의 교점이 전부 이 함수 하나로 나온다. + * 평형선·경계현·반종거 수평선의 교점이 전부 이 함수 하나로 나온다. */ function crossFrom( points: MassHaulPoint[], @@ -204,7 +245,7 @@ interface CutMix { /** * `[fromX, toX]` 절토 구간의 지반유형 구성(다짐상태 가중치). 구간이 측점 사이를 반만 - * 물면 그 비율만큼만 센다 — 평형선 교점은 측점에 딱 떨어지지 않는다. + * 물면 그 비율만큼만 센다 — 경계현 교점은 측점에 딱 떨어지지 않는다. */ function cutMix( points: MassHaulPoint[], @@ -228,8 +269,8 @@ function cutMix( return mix; } -/** 구성비로 `Q`를 안분한다. 구간에 절토 기록이 전혀 없으면 지반유형 미상 = 토사로 둔다. */ -function apportion(mix: CutMix, volume: number): Pick { +/** 구성비로 물량을 안분한다. 구간에 절토 기록이 전혀 없으면 지반유형 미상 = 토사로 둔다. */ +function apportion(mix: CutMix, volume: number): Pick { const total = mix.ea + mix.rr + mix.br; if (!(total > 0)) return { ea_m3: volume, rr_m3: 0, br_m3: 0 }; return { @@ -239,20 +280,96 @@ function apportion(mix: CutMix, volume: number): Pick { + if (a.max_distance_m === null) return 1; + if (b.max_distance_m === null) return -1; + return a.max_distance_m - b.max_distance_m; + }); +} + +/** 한 블록을 장비 띠로 가르는 데 필요한 기하 계산 묶음. */ +interface BlockGeometry { + /** `ratio`(0 = 평형선, 1 = 정점)에 해당하는 누가토량 높이. */ + levelAt: (ratio: number) => number; + /** 그 높이 수평선이 블록 안에서 곡선을 자르는 두 점. */ + chordAt: (ratio: number) => { from: number; to: number; length: number }; +} + +function blockGeometry( + points: MassHaulPoint[], + fromM: number, + toM: number, + apexM: number, + base: number, + apex: number, +): BlockGeometry { + const levelAt = (ratio: number): number => base + (apex - base) * ratio; + const chordAt = (ratio: number): { from: number; to: number; length: number } => { + const level = levelAt(ratio); + const from = crossFrom(points, fromM, apexM, level) ?? fromM; + const to = crossFrom(points, apexM, toM, level) ?? toM; + return { from, to, length: Math.max(to - from, 0) }; + }; + return { levelAt, chordAt }; } /** - * 누가토량 곡선에서 토량 분배(평형선·운반 블록·사토/토취)를 뽑는다. + * 현의 길이가 `target` **이하로 확정되는** 가장 낮은 높이를 찾는다(반환값은 `ratio`). + * + * 현 길이는 평형선(가장 김)에서 정점(0)으로 갈수록 줄지만 **단조가 아니다** — 가지치기로 + * 블록을 하나로 묶어도 그 안의 원래 폴리라인에는 잔요철이 남아, 높이를 올리는 도중 현이 + * 잠깐 다시 길어질 수 있다. 이분법만 쓰면 그 지점에서 엉뚱한 근으로 수렴한다(무작위 검증에서 + * 종무대 경계현이 45m로 잡히는 사례를 잡았다). + * + * 그래서 **훑기 → 이분법** 두 단계로 간다: 먼저 전 구간을 성기게 훑어 현이 `target`을 넘는 + * **가장 높은** 자리를 찾고, 그 바로 위 칸에서만 이분법으로 좁힌다. 이렇게 잡은 경계 위쪽은 + * 전부 `target` 이하가 보장된다 — "이 띠는 이 장비로 옮길 수 있다"가 성립해야 하기 때문이다. + * 평형선에서 이미 `target` 이하면 그 장비가 블록 전체를 맡는다 → 0. + */ +function ratioForChord(geometry: BlockGeometry, target: number): number { + let lastOver = -1; + for (let step = 0; step <= CHORD_SCAN_STEPS; step += 1) { + if (geometry.chordAt(step / CHORD_SCAN_STEPS).length > target) lastOver = step; + } + if (lastOver < 0) return 0; + if (lastOver >= CHORD_SCAN_STEPS) return 1; + let low = lastOver / CHORD_SCAN_STEPS; + let high = (lastOver + 1) / CHORD_SCAN_STEPS; + for (let step = 0; step < CHORD_SOLVE_STEPS; step += 1) { + const middle = (low + high) / 2; + if (geometry.chordAt(middle).length > target) low = middle; + else high = middle; + } + return high; +} + +/** 띠를 감싸는 다각형 — 아래 현 → 왼쪽 곡선 → 위 현 → 오른쪽 곡선. */ +function bandOutline( + points: MassHaulPoint[], + low: { from: number; to: number }, + high: { from: number; to: number }, + lowLevel: number, + highLevel: number, +): CurvePoint[] { + const between = (a: number, b: number): CurvePoint[] => + points + .filter((point) => point.chainage_m > a && point.chainage_m < b) + .map((point) => ({ m: point.chainage_m, v: point.cumulative_volume_m3 })); + return [ + { m: low.from, v: lowLevel }, + ...between(low.from, high.from), + { m: high.from, v: highLevel }, + { m: high.to, v: highLevel }, + ...between(high.to, low.to).reverse(), + { m: low.to, v: lowLevel }, + ]; +} + +/** + * 누가토량 곡선에서 토량 분배(평형선·운반 블록·장비 띠·사토/토취)를 뽑는다. * 블록도 잔량도 안 나오면(평탄한 곡선) null. */ export function computeHaulPlan( @@ -265,42 +382,76 @@ export function computeHaulPlan( const range = Math.max(result.max_cumulative_m3 - result.min_cumulative_m3, 0); const minSwing = Math.max(range * MIN_SWING_RATIO, 1); const extrema = pruneExtrema(points, extremaIndices(points), minSwing); + const tiers = sortedLimits(limits); const blocks: HaulBlock[] = []; const residuals: HaulResidual[] = []; const steps: BalanceStep[] = []; + let bandCounter = 0; const pushBlock = ( fromM: number, toM: number, base: number, - apexX: number, - apexY: number, + apexM: number, + apex: number, direction: HaulDirection, - cutFrom: number, - cutTo: number, ): void => { - const volume = Math.abs(apexY - base); + const volume = Math.abs(apex - base); if (!(volume > EPSILON) || !(toM > fromM)) return; - // 반종거 수평선 — 종거의 절반 높이에서 곡선을 자른 두 점 사이가 평균운반거리다. - const half = direction === "forward" ? base + volume / 2 : base - volume / 2; - const haulFrom = crossFrom(points, fromM, apexX, half) ?? fromM; - const haulTo = crossFrom(points, apexX, toM, half) ?? toM; - const distance = Math.max(haulTo - haulFrom, 0); + const geometry = blockGeometry(points, fromM, toM, apexM, base, apex); + + // 정점 쪽(가까운 장비)부터 평형선 쪽으로 내려오며 띠를 자른다. + const bands: HaulBand[] = []; + let upper = 1; + for (const tier of tiers) { + if (upper <= EPSILON) break; + const lower = tier.max_distance_m === null ? 0 : ratioForChord(geometry, tier.max_distance_m); + if (upper - lower <= 1e-6) { + upper = lower; + continue; + } + const lowChord = geometry.chordAt(lower); + const highChord = geometry.chordAt(upper); + const haulChord = geometry.chordAt((lower + upper) / 2); + // 절토 쪽 변에서 잘라 낸 구간이 이 띠가 퍼오는 흙이다(산이면 왼쪽 변, 골이면 오른쪽 변). + const cutFrom = direction === "forward" ? lowChord.from : highChord.to; + const cutTo = direction === "forward" ? highChord.from : lowChord.to; + const bandVolume = (upper - lower) * volume; + bandCounter += 1; + bands.push({ + index: bandCounter, + equipment: tier.key || null, + level_base_m3: geometry.levelAt(lower), + level_apex_m3: geometry.levelAt(upper), + volume_m3: bandVolume, + boundary_from_m: lowChord.from, + boundary_to_m: lowChord.to, + haul_distance_m: haulChord.length, + haul_from_m: haulChord.from, + haul_to_m: haulChord.to, + outline: bandOutline( + points, + lowChord, + highChord, + geometry.levelAt(lower), + geometry.levelAt(upper), + ), + ...apportion(cutMix(points, cutFrom, cutTo, result.conversion), bandVolume), + }); + upper = lower; + } + blocks.push({ index: blocks.length + 1, base_m3: base, from_m: fromM, to_m: toM, - apex_m: apexX, - apex_volume_m3: apexY, + apex_m: apexM, + apex_volume_m3: apex, volume_m3: volume, - haul_distance_m: distance, - haul_from_m: haulFrom, - haul_to_m: haulTo, direction, - equipment: pickEquipment(limits, distance), - ...apportion(cutMix(points, cutFrom, cutTo, result.conversion), volume), + bands, }); steps.push({ from_m: fromM, to_m: toM, level_m3: base }); }; @@ -309,6 +460,7 @@ export function computeHaulPlan( const delta = to - from; if (Math.abs(delta) < EPSILON) return; residuals.push({ + index: residuals.length + 1, kind: delta > 0 ? "spoil" : "borrow", from_m: fromM, to_m: toM, @@ -353,7 +505,7 @@ export function computeHaulPlan( if (nextY <= level + EPSILON) { // 성토가 절토를 다 받는다 — 평형선 그대로, 곡선이 되돌아온 점에서 블록을 닫는다. const end = crossFrom(points, apexX, nextX, level) ?? nextX; - pushBlock(cursor, end, level, apexX, apexY, "forward", cursor, apexX); + pushBlock(cursor, end, level, apexX, apexY, "forward"); cursor = end; k += 1; } else { @@ -361,7 +513,7 @@ export function computeHaulPlan( const start = crossFrom(points, cursor, apexX, nextY) ?? cursor; steps.push({ from_m: cursor, to_m: start, level_m3: level }); pushResidual(cursor, start, level, nextY); - pushBlock(start, nextX, nextY, apexX, apexY, "forward", start, apexX); + pushBlock(start, nextX, nextY, apexX, apexY, "forward"); level = nextY; cursor = nextX; k += 2; @@ -369,7 +521,7 @@ export function computeHaulPlan( } else if (nextY >= level - EPSILON) { // 골 — 오른쪽 절토가 왼쪽 성토를 채운다(우→좌 운반). const end = crossFrom(points, apexX, nextX, level) ?? nextX; - pushBlock(cursor, end, level, apexX, apexY, "backward", apexX, end); + pushBlock(cursor, end, level, apexX, apexY, "backward"); cursor = end; k += 1; } else { @@ -377,7 +529,7 @@ export function computeHaulPlan( const start = crossFrom(points, cursor, apexX, nextY) ?? cursor; steps.push({ from_m: cursor, to_m: start, level_m3: level }); pushResidual(cursor, start, level, nextY); - pushBlock(start, nextX, nextY, apexX, apexY, "backward", apexX, nextX); + pushBlock(start, nextX, nextY, apexX, apexY, "backward"); level = nextY; cursor = nextX; k += 2; @@ -422,12 +574,17 @@ export function haulPlanPayload(plan: HaulPlan): Record { to_m: round(block.to_m), base_m3: round(block.base_m3), volume_m3: round(block.volume_m3), - haul_distance_m: round(block.haul_distance_m), direction: block.direction, - equipment: block.equipment, - ea_m3: round(block.ea_m3), - rr_m3: round(block.rr_m3), - br_m3: round(block.br_m3), + // 장비 띠 — B08 내역서는 이 단위로 운반 항목을 세운다. + bands: block.bands.map((band) => ({ + index: band.index, + equipment: band.equipment, + volume_m3: round(band.volume_m3), + haul_distance_m: round(band.haul_distance_m), + ea_m3: round(band.ea_m3), + rr_m3: round(band.rr_m3), + br_m3: round(band.br_m3), + })), })), residuals: plan.residuals.map((residual) => ({ kind: residual.kind, diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul_Balance_View.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul_Balance_View.ts index 426ed976..8297f784 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul_Balance_View.ts +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul_Balance_View.ts @@ -1,20 +1,26 @@ /* ============================================================================= * B06_wf3_ProfileCross_UI_MassHaul_Balance_View.ts - * 토량 분배(평형선) SVG 렌더러 — 유토곡선 위에 겹치는 레이어. + * 토량 분배(평형선 · 장비 띠) SVG 렌더러 — 유토곡선 위에 겹치는 레이어. * - * 그리는 것은 넷이다: - * 1. 계단형 평형선 — 블록마다 수평, 사토·토취가 나는 자리에서 계단으로 옮겨 간다. - * 2. 종거 표시선 — 평형선에서 곡선 정점까지. 이 길이가 곧 운반토량 `Q`. - * 3. 반종거 수평선 — 종거의 1/2 높이에서 곡선을 자른 두 점 사이. 이 길이가 `L`이고 - * 끝에 방향 화살촉을 단다(산 = 좌→우, 골 = 우→좌). - * 4. 육각 balloon — `Q` / `L`·장비 / EA·RR·BR. 사토·토취는 단차 화살표 + 라벨. + * 그리는 것: + * 1. 계단형 평형선 — 블록마다 수평(실선). 사토·토취가 나는 자리에서 계단으로 옮겨 간다. + * 2. 장비 띠 면 — 경계현 두 개와 곡선 두 변으로 둘러싸인 다각형. 클릭 대상이다. + * 3. 경계현(파선) — 그 현의 길이가 장비 경계거리와 같아지는 높이의 수평선. + * 4. 평균운반거리(점선 + 화살촉) — 띠 중간 높이의 현. 방향은 산=좌→우, 골=우→좌. + * 5. balloon — 도형이 곧 운반수단이다: 종무대 육각 / 도쟈 원 / 덤프 사각. + * 사토·토취는 도형 없이 **언더바만**(2026-08-02 사용자 지시). * * 좌표 변환(x/y)과 그릴 상자는 유토곡선 렌더러가 넘겨준다 — 축을 두 번 정의하지 않는다. * 계산은 전부 `_UI_MassHaul_Balance`가 끝내 두므로 여기서는 배치만 판단한다. * 700줄 제한 대응으로 `_UI_MassHaul_View`에서 분리했다. * ========================================================================== */ -import type { HaulBlock, HaulPlan, HaulResidual } from "./B06_wf3_ProfileCross_UI_MassHaul_Balance"; +import type { + HaulBand, + HaulBlock, + HaulPlan, + HaulResidual, +} from "./B06_wf3_ProfileCross_UI_MassHaul_Balance"; import type { LocaleKey } from "@ui/ui_template_locale"; import { L, svgElement, svgText } from "./B06_wf3_ProfileCross_UI_Section_Common"; @@ -34,15 +40,23 @@ const EQUIPMENT_LABEL: Record = { dump_truck: "B06_MassHaul_Equip_Dump", }; +/** balloon 도형 = 운반수단. 모르는 장비는 사각으로 떨어뜨린다. */ +type BalloonShape = "hexagon" | "ellipse" | "rect"; +const EQUIPMENT_SHAPE: Record = { + free_haul: "hexagon", + dozer: "ellipse", + dump_truck: "rect", +}; + const LINE_HEIGHT = 11; const PAD_X = 8; const PAD_Y = 5; /** 육각형 좌우 꼭짓점이 파고드는 깊이(px). */ const NOTCH = 8; -/** balloon과 곡선 사이 최소 틈. */ -const GAP = 7; -/** 이보다 좁은 블록에는 balloon을 달지 않는다 — 서로 겹쳐 도면을 못 읽는다. */ -const MIN_BLOCK_WIDTH_PX = 34; +/** 이보다 좁은 띠에는 balloon을 달지 않는다 — 서로 겹쳐 도면을 못 읽는다. */ +const MIN_BAND_WIDTH_PX = 34; +/** 이보다 얇은 띠에는 평균운반거리 선을 긋지 않는다(경계현과 붙어 한 줄로 보인다). */ +const MIN_BAND_THICKNESS_PX = 9; /** SVG는 자동 크기가 없어 폭을 어림해야 한다. 한글은 라틴 글자보다 넓다. */ function textWidth(value: string): number { @@ -66,9 +80,7 @@ function equipmentLabel(key: string | null): string { return locale ? L(locale) : key; } -/** - * 육각형 경로. 좌우 끝이 뾰족한 도면용 balloon 모양이라 폭이 좁아도 꼬리를 달기 쉽다. - */ +/** 육각형 — 좌우 끝이 뾰족한 도면용 모양. */ function hexagonPoints(cx: number, cy: number, width: number, height: number): string { const halfW = width / 2; const halfH = height / 2; @@ -83,6 +95,40 @@ function hexagonPoints(cx: number, cy: number, width: number, height: number): s ].join(" "); } +function balloonShape( + shape: BalloonShape, + cx: number, + cy: number, + width: number, + height: number, +): SVGElement { + if (shape === "ellipse") { + // 원형은 같은 글자를 담으려면 사각보다 커야 한다 — 모서리가 비기 때문이다. + return svgElement("ellipse", { + cx, + cy, + rx: width / 2 + 5, + ry: height / 2 + 3, + class: "b06-balance__balloon-shape", + }); + } + if (shape === "rect") { + return svgElement("rect", { + x: cx - width / 2, + y: cy - height / 2, + width, + height, + rx: 2, + ry: 2, + class: "b06-balance__balloon-shape", + }); + } + return svgElement("polygon", { + points: hexagonPoints(cx, cy, width, height), + class: "b06-balance__balloon-shape", + }); +} + interface PlacedBox { left: number; right: number; @@ -95,8 +141,8 @@ function overlaps(a: PlacedBox, b: PlacedBox): boolean { } /** - * 겹치지 않는 세로 자리를 찾는다. 선호 방향(위/아래)으로 한 칸씩 밀어 보고, 상자를 벗어나면 - * 반대쪽을 시도한다. 끝내 못 찾으면 처음 자리에 그대로 둔다 — 안 그리는 것보다 낫다. + * 겹치지 않는 세로 자리를 찾는다. 선호 방향으로 한 칸씩 밀어 보고, 상자를 벗어나면 반대쪽을 + * 시도한다. 끝내 못 찾으면 처음 자리에 그대로 둔다 — 안 그리는 것보다 낫다. */ function findSlot( placed: PlacedBox[], @@ -125,7 +171,7 @@ function findSlot( return preferredY; } -/** 계단형 평형선 — 칸 사이는 수직선으로 이어 계단을 만든다. */ +/** 계단형 평형선(기선) — 칸 사이는 수직선으로 이어 계단을 만든다. */ function appendBalanceLine(group: SVGGElement, plan: HaulPlan, box: BalanceLayerBox): void { const steps = [...plan.steps].sort((a, b) => a.from_m - b.from_m); let previous: { x: number; y: number } | null = null; @@ -149,72 +195,93 @@ function appendBalanceLine(group: SVGGElement, plan: HaulPlan, box: BalanceLayer } } -/** 종거(수직) + 반종거 수평선(방향 화살촉). 두 선이 곧 `Q`와 `L`의 눈금이다. */ -function appendMeasures(group: SVGGElement, block: HaulBlock, box: BalanceLayerBox): void { - const apexX = box.x(block.apex_m); - const baseY = box.y(block.base_m3); - const apexY = box.y(block.apex_volume_m3); - group.append( - svgElement("line", { - x1: apexX, - y1: baseY, - x2: apexX, - y2: apexY, - class: "b06-balance__ordinate", - }), - ); - - const half = box.y( - block.direction === "forward" - ? block.base_m3 + block.volume_m3 / 2 - : block.base_m3 - block.volume_m3 / 2, - ); - const from = box.x(block.haul_from_m); - const to = box.x(block.haul_to_m); - if (to - from < 2) return; - const tipX = block.direction === "forward" ? to : from; - const backX = block.direction === "forward" ? to - 6 : from + 6; - group.append( - svgElement("line", { x1: from, y1: half, x2: to, y2: half, class: "b06-balance__haul" }), - svgElement("polygon", { - points: `${tipX},${half} ${backX},${half - 3.5} ${backX},${half + 3.5}`, - class: "b06-balance__haul-arrow", - }), - ); -} - -/** 블록 balloon. 자리가 좁으면 줄을 줄여서라도 `Q`는 반드시 남긴다. */ -function appendBlockBalloon( +/** 띠 하나의 면·경계현·평균운반거리 선. 면은 클릭 대상이라 핸들을 돌려준다. */ +function appendBandGeometry( group: SVGGElement, block: HaulBlock, + band: HaulBand, + box: BalanceLayerBox, +): SVGGElement { + const bandGroup = svgElement("g", { + class: `b06-balance__band b06-balance__band--${band.equipment ?? "unknown"}`, + }); + bandGroup.append( + svgElement("polygon", { + points: band.outline.map((point) => `${box.x(point.m)},${box.y(point.v)}`).join(" "), + class: "b06-balance__band-face", + }), + ); + + const baseY = box.y(band.level_base_m3); + const apexY = box.y(band.level_apex_m3); + // 평형선과 겹치는 아래 경계현은 다시 긋지 않는다 — 기선은 이미 실선으로 깔려 있다. + if (Math.abs(band.level_base_m3 - block.base_m3) > 1e-6) { + bandGroup.append( + svgElement("line", { + x1: box.x(band.boundary_from_m), + y1: baseY, + x2: box.x(band.boundary_to_m), + y2: baseY, + class: "b06-balance__chord", + }), + ); + } + + // 평균운반거리 — 띠 중간 높이의 현. 띠가 얇으면 경계현과 붙어 버려 생략한다. + const from = box.x(band.haul_from_m); + const to = box.x(band.haul_to_m); + if (Math.abs(baseY - apexY) >= MIN_BAND_THICKNESS_PX && to - from >= 3) { + const midY = (baseY + apexY) / 2; + const tipX = block.direction === "forward" ? to : from; + const backX = block.direction === "forward" ? to - 6 : from + 6; + bandGroup.append( + svgElement("line", { x1: from, y1: midY, x2: to, y2: midY, class: "b06-balance__haul" }), + svgElement("polygon", { + points: `${tipX},${midY} ${backX},${midY - 3.5} ${backX},${midY + 3.5}`, + class: "b06-balance__haul-arrow", + }), + ); + } + group.append(bandGroup); + return bandGroup; +} + +/** 띠 balloon. 도형이 운반수단이고, 자리가 좁으면 줄을 줄여서라도 물량은 남긴다. */ +function appendBandBalloon( + group: SVGGElement, + block: HaulBlock, + band: HaulBand, box: BalanceLayerBox, placed: PlacedBox[], -): void { - const apexX = box.x(block.apex_m); - const apexY = box.y(block.apex_volume_m3); - const baseY = box.y(block.base_m3); - if (box.x(block.to_m) - box.x(block.from_m) < MIN_BLOCK_WIDTH_PX) return; +): SVGGElement | null { + if (box.x(block.to_m) - box.x(block.from_m) < MIN_BAND_WIDTH_PX) return null; - const equipment = equipmentLabel(block.equipment); + const equipment = equipmentLabel(band.equipment); const room = box.bottom - box.top; - const lines = [`${block.index} · ${compactVolume(block.volume_m3)}㎥`]; + const lines = [`${band.index} · ${compactVolume(band.volume_m3)}㎥`]; if (room >= 110) { - lines.push(`L ${compactDistance(block.haul_distance_m)}m${equipment ? ` · ${equipment}` : ""}`); + lines.push(`L ${compactDistance(band.haul_distance_m)}m${equipment ? ` · ${equipment}` : ""}`); } if (room >= 150) { lines.push( - `EA ${compactVolume(block.ea_m3)} · RR ${compactVolume(block.rr_m3)} · BR ${compactVolume(block.br_m3)}`, + `EA ${compactVolume(band.ea_m3)} · RR ${compactVolume(band.rr_m3)} · BR ${compactVolume(band.br_m3)}`, ); } const width = Math.max(...lines.map(textWidth)) + PAD_X * 2 + NOTCH; const height = lines.length * LINE_HEIGHT + PAD_Y * 2; - // 산이면 정점 위, 골이면 정점 아래 — 곡선 바깥이라 곡선을 가리지 않는다. + const baseY = box.y(band.level_base_m3); + const apexY = box.y(band.level_apex_m3); const upward = block.direction === "forward"; - const preferred = apexY + (upward ? -(height / 2 + GAP) : height / 2 + GAP); - const clamped = Math.min(Math.max(preferred, box.top + height / 2), box.bottom - height / 2); - const centerX = Math.min(Math.max(apexX, box.left + width / 2), box.right - width / 2); - const centerY = findSlot(placed, centerX, clamped, width, height, box, upward); + const preferred = Math.min( + Math.max((baseY + apexY) / 2, box.top + height / 2), + box.bottom - height / 2, + ); + const centerX = Math.min( + Math.max(box.x(block.apex_m), box.left + width / 2), + box.right - width / 2, + ); + const centerY = findSlot(placed, centerX, preferred, width, height, box, upward); placed.push({ left: centerX - width / 2, right: centerX + width / 2, @@ -225,25 +292,21 @@ function appendBlockBalloon( const balloon = svgElement("g", { class: "b06-balance__balloon" }); const title = svgElement("title"); title.textContent = - `${L("B06_MassHaul_Block")} ${block.index} · ` + - `${L("B06_MassHaul_HaulVolume")} ${compactVolume(block.volume_m3)}㎥ · ` + - `${L("B06_MassHaul_HaulDistance")} ${compactDistance(block.haul_distance_m)}m` + - (equipment ? ` · ${equipment}` : "") + - ` · EA ${compactVolume(block.ea_m3)} / RR ${compactVolume(block.rr_m3)} / BR ${compactVolume(block.br_m3)}㎥`; - // 지시선 — balloon이 밀려났을 때 어느 정점의 것인지 잇는다. + `${L("B06_MassHaul_Block")} ${block.index} · ${equipment || "-"} · ` + + `${L("B06_MassHaul_HaulVolume")} ${compactVolume(band.volume_m3)}㎥ · ` + + `${L("B06_MassHaul_HaulDistance")} ${compactDistance(band.haul_distance_m)}m · ` + + `EA ${compactVolume(band.ea_m3)} / RR ${compactVolume(band.rr_m3)} / BR ${compactVolume(band.br_m3)}㎥`; + // 지시선 — balloon이 밀려났을 때 어느 띠의 것인지 잇는다. balloon.append( title, svgElement("line", { - x1: apexX, - y1: upward ? Math.min(apexY, baseY) : Math.max(apexY, baseY), + x1: box.x(block.apex_m), + y1: (baseY + apexY) / 2, x2: centerX, y2: centerY, class: "b06-balance__leader", }), - svgElement("polygon", { - points: hexagonPoints(centerX, centerY, width, height), - class: "b06-balance__balloon-shape", - }), + balloonShape(EQUIPMENT_SHAPE[band.equipment ?? ""] ?? "rect", centerX, centerY, width, height), ...lines.map((line, index) => svgText(line, { x: centerX, @@ -254,9 +317,13 @@ function appendBlockBalloon( ), ); group.append(balloon); + return balloon; } -/** 사토·토취 — 평형선 단차를 세로 양방향 화살표로 찍고 그 옆에 수량을 적는다. */ +/** + * 사토·토취 — 평형선 단차를 세로 화살표로 찍고 수량은 **언더바만** 두른 라벨로 적는다 + * (2026-08-02 사용자 지시: 도형은 운반수단 표시 전용). + */ function appendResidual( group: SVGGElement, residual: HaulResidual, @@ -285,11 +352,16 @@ function appendResidual( ); // 좁은 그래프에서는 화살표만 남기고 수량은 툴팁으로 돌린다. if (!compact) { + const textY = (y1 + y2) / 2 + 3; + const width = textWidth(label); marker.append( - svgText(label, { - x: centerX + 5, - y: (y1 + y2) / 2 + 3, - class: "b06-balance__residual-text", + svgText(label, { x: centerX + 5, y: textY, class: "b06-balance__residual-text" }), + svgElement("line", { + x1: centerX + 5, + y1: textY + 3, + x2: centerX + 5 + width, + y2: textY + 3, + class: "b06-balance__residual-underline", }), ); } @@ -299,16 +371,45 @@ function appendResidual( /** * 토량 분배 레이어를 유토곡선 SVG에 얹는다. 곡선·측점선 위, 선택 말풍선 아래에 놓아야 * 읽는 순서가 맞으므로 호출 위치를 옮기지 말 것. + * + * 띠 면을 누르면 그 balloon이 강조된다. 다른 띠나 빈 곳을 누르면 풀린다 + * (2026-08-02 사용자 지시). 강조는 이 SVG 안에서만 사는 상태라 다시 그리면 초기화된다. */ export function appendBalanceLayer(svg: SVGSVGElement, plan: HaulPlan, box: BalanceLayerBox): void { const group = svgElement("g", { class: "b06-balance" }); appendBalanceLine(group, plan, box); - for (const block of plan.blocks) appendMeasures(group, block, box); const compact = box.bottom - box.top < 110; for (const residual of plan.residuals) appendResidual(group, residual, box, compact); + + const faces: SVGGElement[] = []; + const balloons: Array = []; + for (const block of plan.blocks) { + for (const band of block.bands) faces.push(appendBandGeometry(group, block, band, box)); + } const placed: PlacedBox[] = []; - for (const block of plan.blocks) appendBlockBalloon(group, block, box, placed); + for (const block of plan.blocks) { + for (const band of block.bands) + balloons.push(appendBandBalloon(group, block, band, box, placed)); + } + + const clearAll = (): void => { + for (const face of faces) face.classList.remove("is-active"); + for (const balloon of balloons) balloon?.classList.remove("is-active"); + }; + faces.forEach((face, index) => { + face.addEventListener("click", (event) => { + // 카드·측점 선택까지 올라가면 그래프가 다시 그려져 방금 켠 강조가 사라진다. + event.stopPropagation(); + const already = face.classList.contains("is-active"); + clearAll(); + if (already) return; + face.classList.add("is-active"); + balloons[index]?.classList.add("is-active"); + }); + }); + // 띠 밖(그래프 빈 곳)을 누르면 강조를 푼다. + svg.addEventListener("click", clearAll); svg.append(group); } @@ -319,8 +420,9 @@ export function appendBalanceLayer(svg: SVGSVGElement, plan: HaulPlan, box: Bala */ export function haulPlanChips(plan: HaulPlan | null): Array<[string, string]> { if (!plan) return []; + const bands = plan.blocks.reduce((sum, block) => sum + block.bands.length, 0); return [ [L("B06_MassHaul_HaulTotal"), `${compactVolume(plan.hauled_m3)}㎥`], - [L("B06_MassHaul_BlockCount"), `${plan.blocks.length}`], + [L("B06_MassHaul_BlockCount"), `${plan.blocks.length} / ${bands}`], ]; } diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Page.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Page.ts index 60161f1d..ffb646db 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Page.ts +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Page.ts @@ -235,6 +235,12 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { * 기본 오프셋·스텝은 context(config) 값으로 갱신된다. */ let rockBoundaryDefault = -0.5; let rockBoundaryStep = 0.1; + /** + * 암 경계선 오프셋은 **0을 넘을 수 없다**(2026-08-02 사용자 지시). 오프셋은 지면선에서 + * 아래로 파고든 깊이라, 양수가 되면 경계선이 지표면 위로 떠올라 토사층이 음수가 된다. + * DB에 옛 양수값이 남아 있어도 읽는 즉시 0으로 눌러 계산이 뒤집히지 않게 한다. + */ + const clampRockOffset = (value: number): number => Math.min(value, 0); const rockOffsets = new Map(); const rockKey = (chainageM: number): string => chainageM.toFixed(2); const rockSessionKey = (): string | null => @@ -274,16 +280,18 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { return rockBoundaryDefault; }, offsetFor: (section) => - rockOffsets.get(rockKey(section.chainage_m)) ?? - section.design?.rock_boundary_offset_m ?? - rockBoundaryDefault, + clampRockOffset( + rockOffsets.get(rockKey(section.chainage_m)) ?? + section.design?.rock_boundary_offset_m ?? + rockBoundaryDefault, + ), adjust: (chainageM, deltaM) => { const key = rockKey(chainageM); const stored = sectionDetail?.cross_sections.find( (section) => Math.abs(section.chainage_m - chainageM) < 0.01, )?.design?.rock_boundary_offset_m; - const current = rockOffsets.get(key) ?? stored ?? rockBoundaryDefault; - rockOffsets.set(key, Math.round((current + deltaM) * 100) / 100); + const current = clampRockOffset(rockOffsets.get(key) ?? stored ?? rockBoundaryDefault); + rockOffsets.set(key, clampRockOffset(Math.round((current + deltaM) * 100) / 100)); persistRockOffsets(); sectionView.refreshCard(chainageM); recomputeIfRock(chainageM); // 경계 이동 → 2단계 무릎·단면적 재계산 diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style_Cross.css b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style_Cross.css index a42cf701..610cfea2 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style_Cross.css +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style_Cross.css @@ -525,10 +525,13 @@ font-family: var(--font-mono); } -/* 암 경계선 제어(E-7) — 그래프 X축 제목 행 우측 맞춤. */ +/* 암 경계선 제어(E-7) — 그래프 영역 **하단 행 가운데**(2026-08-02 사용자 지시). + 우측 맞춤이던 것을 가운데로 옮겼다. `left: 50%` + 음수 translate가 폭을 모르고도 + 가운데를 잡는 유일한 방법이다(절대 위치라 flex 정렬이 안 먹는다). */ .b06-cross-card__rockb { position: absolute; - right: var(--spacing-8); + left: 50%; + transform: translateX(-50%); bottom: 2px; background: color-mix(in srgb, var(--color-surface) 82%, transparent); border-radius: var(--radius-inputs); diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style_MassHaul.css b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style_MassHaul.css index 133fd2f5..5aca301c 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style_MassHaul.css +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style_MassHaul.css @@ -87,11 +87,12 @@ fill: var(--color-success); } -/* 0선: 절토 우세와 성토 우세의 경계라 격자보다 진하게. 어떤 노선에서도 반드시 보인다. */ +/* 0선: 절토 우세와 성토 우세의 경계라 격자보다 진하게. 어떤 노선에서도 반드시 보인다. + **실선**이다(2026-08-02 사용자 지시) — 도면에서 파선은 장비 경계현의 몫이라, + 0선까지 파선이면 둘이 구분되지 않는다. */ .b06-masshaul__zero { stroke: var(--color-text-secondary); stroke-width: 1.2; - stroke-dasharray: 4 3; } .b06-masshaul__zero-tick { @@ -177,12 +178,41 @@ * 사토는 위험색, 토취는 성공색. 곡선 위에 겹치므로 선을 얇게 두고 balloon만 불투명하다. * ========================================================================== */ -/* 평형선 — 블록마다 수평. 곡선보다 얇지만 실선이라 격자와 구분된다. */ +/* 평형선(기선) — 블록마다 수평. **실선**이 기선의 표식이다(도면 A–A′). */ .b06-balance__line { stroke: var(--color-warning); stroke-width: 1.6; } +/* 장비 경계현 — 그 현의 길이가 장비 경계거리와 같아지는 높이(도면 B–B′ / C–C′). + 기선과 구분되도록 **파선**으로 둔다. 선타입이 곧 의미다. */ +.b06-balance__chord { + stroke: var(--color-warning); + stroke-width: 1.2; + stroke-dasharray: 6 4; +} + +/* 장비 띠 면 — 평소엔 클릭 대상으로만 남고, 눌렀을 때만 옅게 칠해진다. */ +.b06-balance__band-face { + fill: transparent; + stroke: none; + cursor: pointer; +} + +.b06-balance__band.is-active > .b06-balance__band-face { + fill: color-mix(in srgb, var(--color-warning) 22%, transparent); +} + +/* 눌린 띠의 balloon은 테두리를 굵혀 어느 띠의 값인지 바로 짚이게 한다. */ +.b06-balance__balloon.is-active > .b06-balance__balloon-shape { + fill: color-mix(in srgb, var(--color-warning) 20%, var(--color-surface-raised)); + stroke-width: 2; +} + +.b06-balance__balloon.is-active > .b06-balance__leader { + opacity: 1; +} + /* 계단의 수직 연결선 — 사토·토취가 나는 자리라 파선으로 "옮겨 갔음"을 드러낸다. */ .b06-balance__riser { stroke: var(--color-warning); @@ -190,18 +220,12 @@ stroke-dasharray: 3 2; } -/* 종거(평형선 → 정점) = 운반토량 Q의 눈금. */ -.b06-balance__ordinate { +/* 평균운반거리 = 띠 중간 높이의 현. 경계현(파선)과 헷갈리지 않게 **점선**으로 가늘게. + 화살촉이 운반 방향이다(산 = 좌→우, 골 = 우→좌). */ +.b06-balance__haul { stroke: var(--color-warning); stroke-width: 1; stroke-dasharray: 2 3; - opacity: 0.85; -} - -/* 반종거 수평선 = 평균운반거리 L의 눈금. 화살촉이 운반 방향이다. */ -.b06-balance__haul { - stroke: var(--color-warning); - stroke-width: 1.6; } .b06-balance__haul-arrow { @@ -216,7 +240,8 @@ opacity: 0.7; } -/* 육각 balloon — 곡선 위에 놓이므로 배경을 거의 불투명하게 깐다. */ +/* balloon 도형 = 운반수단(종무대 육각 / 도쟈 원 / 덤프 사각). 곡선 위에 놓이므로 + 배경을 거의 불투명하게 깐다. 도형별로 색을 나누지 않는다 — 모양이 이미 구분자다. */ .b06-balance__balloon-shape { fill: color-mix(in srgb, var(--color-surface-raised) 94%, transparent); stroke: var(--color-warning); @@ -262,6 +287,17 @@ font-variant-numeric: tabular-nums; } +/* 사토·토취는 도형 없이 언더바만 두른다(2026-08-02 사용자 지시). */ +.b06-balance__residual--spoil .b06-balance__residual-underline { + stroke: var(--color-danger); + stroke-width: 1.2; +} + +.b06-balance__residual--borrow .b06-balance__residual-underline { + stroke: var(--color-success); + stroke-width: 1.2; +} + /* 범례에서 분배 레이어는 곡선이 아니라 평형선 계열임을 스와치로 알린다. */ .b06-masshaul__legend-item--balance .b06-masshaul__legend-swatch { border-top-color: var(--color-warning); diff --git a/config/config_system.py b/config/config_system.py index 82e9b4b7..83cfee83 100644 --- a/config/config_system.py +++ b/config/config_system.py @@ -449,10 +449,12 @@ EARTHWORK_CONVERSION_FACTORS = { # 평균운반거리(m)로 운반장비를 고른다. 위에서부터 순서대로 검사해 `max_distance_m` # **이하**면 그 장비를 쓰고, 마지막 항목(None)이 나머지를 전부 받는다. # -# 종무대(무대운반) ≤ 20m < 도쟈(불도저 압토) ≤ 50m < 덤프트럭 +# 종무대(무대운반) ≤ 20m < 도쟈(불도저 압토) ≤ 70m < 덤프트럭 # -# 2026-08-02 사용자 확정값. 참고 도면의 관측 운반거리(종무대 14~16m, 도쟈 20m, -# 덤프 67.8m)와 모순되지 않는다. 현장·발주처 기준에 따라 달라질 수 있으므로 +# 2026-08-02 사용자 확정값(도쟈 50 → 70m). 유토곡선의 장비 경계현은 이 값을 +# **수평 현의 길이**로 읽는다 — 현 길이가 20m가 되는 높이 위쪽이 종무대 몫, +# 70m가 되는 높이까지가 도쟈 몫, 그 아래가 덤프 몫이다. +# 현장·발주처 기준에 따라 달라질 수 있으므로 # **이 상수가 거리 경계의 유일한 정의처**이며 여기만 고치면 된다. # # key는 도면 표기와 다음과 같이 대응한다: free_haul=종무대, dozer=도쟈, dump_truck=덤프. @@ -461,7 +463,7 @@ EARTHWORK_CONVERSION_FACTORS = { # ───────────────────────────────────────────────────────────────────────── EARTHWORK_HAUL_EQUIPMENT_LIMITS_M = ( ("free_haul", 20.0), - ("dozer", 50.0), + ("dozer", 70.0), ("dump_truck", None), )