feat(B06): 유토곡선 토량 분배(평형선·운반 블록·평균운반거리) 엔진

누가토량 곡선은 "얼마가 남는가"만 말한다. 어디 흙을 어디로 얼마나 옮기는지를
뽑아내는 계층을 얹었다 — 평형선, 운반 블록, 운반토량 Q, 평균운반거리 L,
EA/RR/BR 안분, 장비 선정.

작도 규칙 근거는 clouds-daily.tistory.com/249 와 교과서 표준 작도법이다
(참고 도면 3공구의 수치·작도는 근거로 삼지 않았다).

평형선을 0선 하나로 고정하면 임도처럼 절토가 우세한 노선에서 사토가 전부
종점 한 덩어리로 몰린다. 그래서 곡선을 왼쪽부터 훑으며 커서를 옮기고, 성토가
절토를 다 못 받는 자리에서 평형선을 계단으로 옮긴다. 구간이 겹치지도 비지도
않고 단차의 합이 최종 누가토량과 정확히 맞는다.

- 신규 _UI_MassHaul_Balance.ts: computeHaulPlan() 순수 계산기
- 신규 _UI_MassHaul_Balance_View.ts: 계단형 평형선 + 반종거 수평선 + 육각 balloon
- MassHaulPoint에 cut_rr_m3/cut_br_m3 추가 (블록별 암종 안분 입력)
- EARTHWORK_HAUL_EQUIPMENT_LIMITS_M을 context 응답으로 노출 (프론트 사본 없음)
- massHaulPayload에 haul_plan 직렬화 (B08 인계)

검증: 수동 6종 + 무작위 100건에서 보존·안분합·L 범위·구간 무결성 성립.
렌더 스모크 4개 크기 NaN 0건. 영구저장소 프로젝트(26측점) 실측 시 블록 6개,
토취 75.4㎥가 최종 누가토량 -75.4㎥와 일치.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-02 16:12:07 +09:00
co-authored by Claude Opus 5
parent dcf5da8ba7
commit ac4e22f623
11 changed files with 1013 additions and 19 deletions
@@ -63,6 +63,15 @@ export interface EarthworkConversionFactor {
export type EarthworkConversion = Record<GroundType, EarthworkConversionFactor>;
/**
* 평균운반거리 상한 하나(m). `max_distance_m: null`이면 상한 없음(나머지를 전부 받는다).
* 정의처는 config_system.EARTHWORK_HAUL_EQUIPMENT_LIMITS_M 한 곳뿐이라 프론트는 받아만 쓴다.
*/
export interface HaulEquipmentLimit {
key: string;
max_distance_m: number | null;
}
export interface SectionContextResponse {
project_id: string;
route_id: number | null;
@@ -78,6 +87,8 @@ export interface SectionContextResponse {
rock_boundary_step_m: number;
/** 지반유형별 토량환산계수. 유토곡선은 프론트가 이 값으로 계산한다. */
earthwork_conversion: EarthworkConversion;
/** 평균운반거리별 운반장비 경계. 유토곡선의 토량 분배가 이 값으로 장비를 고른다. */
haul_equipment_limits?: HaulEquipmentLimit[];
}
/** 종단 요약 조회 결과 (SectionSummaryResponse) */
@@ -45,6 +45,7 @@ from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Schema import (
CompanyStandardResponse,
CrossDesignRequest,
CrossDesignResponse,
HaulEquipmentLimit,
SectionConfirmRequest,
SectionConfirmResponse,
SectionContextResponse,
@@ -61,6 +62,7 @@ from common_util.common_util_workflow_state import complete_stage, get_workflow_
from config.config_db import get_db_pool
from config.config_system import (
EARTHWORK_CONVERSION_FACTORS,
EARTHWORK_HAUL_EQUIPMENT_LIMITS_M,
FOREST_ROAD_MIN_WIDTH_M,
SECTION_VERTICAL_EXAGGERATION,
STANDARD_CROSS_SECTION,
@@ -100,6 +102,10 @@ async def get_section_context(project_id: UUID) -> SectionContextResponse | JSON
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,
haul_equipment_limits=[
HaulEquipmentLimit(key=key, max_distance_m=limit)
for key, limit in EARTHWORK_HAUL_EQUIPMENT_LIMITS_M
],
)
except Exception:
logger.exception("B06 종횡단 컨텍스트 조회 실패: project_id=%s", project_id)
@@ -116,6 +116,17 @@ class EarthworkConversionFactor(BaseModel):
compacted: float = Field(..., gt=0)
class HaulEquipmentLimit(BaseModel):
"""평균운반거리 상한 하나(m). max_distance_m=None이면 상한 없음(나머지를 전부 받는다).
정의처는 config.config_system.EARTHWORK_HAUL_EQUIPMENT_LIMITS_M 한 곳뿐이다.
프론트(유토곡선 토량 분배)는 이 값을 받아 쓰고 사본을 두지 않는다.
"""
key: str
max_distance_m: float | None = None
class SectionContextResponse(BaseModel):
"""B06 진입 시 필요한 확정 경로 컨텍스트와 기본 옵션."""
@@ -133,6 +144,8 @@ class SectionContextResponse(BaseModel):
rock_boundary_step_m: float = 0.1
# 지반유형별 토량환산계수. 유토곡선 계산은 프론트가 이 값으로 수행한다.
earthwork_conversion: dict[str, EarthworkConversionFactor] = Field(default_factory=dict)
# 평균운반거리별 운반장비 경계. 유토곡선의 토량 분배가 이 값으로 장비를 고른다.
haul_equipment_limits: list[HaulEquipmentLimit] = Field(default_factory=list)
class SectionSummaryResponse(BaseModel):
@@ -40,6 +40,8 @@ import type {
GroundType,
LongitudinalSection,
} from "./B06_wf3_ProfileCross_Api_Fetch";
import type { HaulPlan } from "./B06_wf3_ProfileCross_UI_MassHaul_Balance";
import { haulPlanPayload } from "./B06_wf3_ProfileCross_UI_MassHaul_Balance";
/** 지반유형별 토량(㎥). 도면 표기 EA(토사)/RR(리핑암)/BR(발파암)에 대응한다. */
export interface GroundVolumes {
@@ -59,6 +61,13 @@ export interface MassHaulPoint {
/** 구간 절토량(자연상태) — 토사분/암반분. 측점 선택 시 물량 표기에 그대로 쓴다. */
cut_soil_m3: number;
cut_rock_m3: number;
/**
* 암반분을 암종까지 가른 값(자연상태, `cut_rr_m3 + cut_br_m3 === cut_rock_m3`).
* 운반 블록의 EA/RR/BR 안분(`_UI_MassHaul_Balance`)이 이 구간 단위를 되받아 쓴다 —
* 한 구간의 양 끝 측점이 서로 다른 암종일 수 있어 합계만으로는 되돌릴 수 없다.
*/
cut_rr_m3: number;
cut_br_m3: number;
/** 구간 절토량(환산 후) — 곡선에 실제로 들어간 값. */
cut_compacted_m3: number;
/** 구간 성토량(다짐상태 = 설계 물량). */
@@ -106,6 +115,12 @@ const SERIES_BASES: MassHaulBasis[] = ["cross", "longitudinal"];
*/
export const MASS_HAUL_DEFAULT_VISIBLE: MassHaulBasis[] = ["cross"];
/**
* 토량 분배(평형선) 레이어의 표시 토글 키. 곡선 기준(`MassHaulBasis`)이 아니라 곡선 **위에
* 얹는 레이어**라 별도 키로 둔다 — 같은 범례 줄에서 함께 켜고 끈다.
*/
export const MASS_HAUL_BALANCE_KEY = "balance";
/** 환산계수가 비어 있거나 값이 이상하면 1.0으로 떨어뜨려 계산이 멈추지 않게 한다. */
function factorFor(conversion: EarthworkConversion, ground: GroundType): number {
const factor = conversion?.[ground]?.compacted;
@@ -179,6 +194,8 @@ function integrate(samples: AreaSample[], conversion: EarthworkConversion): Mass
cumulative_volume_m3: 0,
cut_soil_m3: 0,
cut_rock_m3: 0,
cut_rr_m3: 0,
cut_br_m3: 0,
cut_compacted_m3: 0,
fill_m3: 0,
},
@@ -201,7 +218,8 @@ function integrate(samples: AreaSample[], conversion: EarthworkConversion): Mass
// 절토: 양 끝 점이 각자 절반씩 가져가고, 각 점 안에서 토사분·암반분이 따로 환산된다.
let segmentCut = 0;
let segmentCutSoil = 0;
let segmentCutRock = 0;
let segmentCutRipping = 0;
let segmentCutBlasting = 0;
for (const end of [previous, current]) {
const soilM3 = end.cut_soil_area_m2 * halfSpan;
const rockM3 = end.cut_rock_area_m2 * halfSpan;
@@ -212,7 +230,8 @@ function integrate(samples: AreaSample[], conversion: EarthworkConversion): Mass
}
if (rockM3 > 0 && end.rock_kind) {
cutNatural[end.rock_kind] += rockM3;
segmentCutRock += rockM3;
if (end.rock_kind === "blasting_rock") segmentCutBlasting += rockM3;
else segmentCutRipping += rockM3;
segmentCut += rockM3 * factorFor(conversion, end.rock_kind);
}
}
@@ -232,7 +251,9 @@ function integrate(samples: AreaSample[], conversion: EarthworkConversion): Mass
net_volume_m3: net,
cumulative_volume_m3: cumulative,
cut_soil_m3: segmentCutSoil,
cut_rock_m3: segmentCutRock,
cut_rock_m3: segmentCutRipping + segmentCutBlasting,
cut_rr_m3: segmentCutRipping,
cut_br_m3: segmentCutBlasting,
cut_compacted_m3: segmentCut,
fill_m3: segmentFill,
});
@@ -359,12 +380,19 @@ export function computeMassHaulSeries(
return series;
}
/** 확정 시 DB(`longitudinal_sections.data.mass_haul`)에 넣을 직렬화 형태로 정리한다. */
export function massHaulPayload(result: MassHaulResult): Record<string, unknown> {
/**
* 확정 시 DB(`longitudinal_sections.data.mass_haul`)에 넣을 직렬화 형태로 정리한다.
* 토량 분배(평형선)까지 냈으면 `haul_plan`으로 함께 실어 B08 수량·B09 견적이 되받게 한다.
*/
export function massHaulPayload(
result: MassHaulResult,
haulPlan?: HaulPlan | null,
): Record<string, unknown> {
const round = (value: number): number => Math.round(value * 100) / 100;
return {
basis: "compacted",
conversion: result.conversion,
...(haulPlan ? { haul_plan: haulPlanPayload(haulPlan) } : {}),
cut_natural_m3: {
soil: round(result.cut_natural_m3.soil),
ripping_rock: round(result.cut_natural_m3.ripping_rock),
@@ -383,6 +411,8 @@ export function massHaulPayload(result: MassHaulResult): Record<string, unknown>
// 구간 절토 내역(자연상태) — B08 내역서가 EA/RR/BR로 되받는 단위.
cut_soil_m3: round(point.cut_soil_m3),
cut_rock_m3: round(point.cut_rock_m3),
cut_rr_m3: round(point.cut_rr_m3),
cut_br_m3: round(point.cut_br_m3),
fill_m3: round(point.fill_m3),
})),
};
@@ -0,0 +1,439 @@
/* =============================================================================
* B06_wf3_ProfileCross_UI_MassHaul_Balance.ts
* 토량 분배(평형선) 계산 엔진 — 순수 함수, DOM 의존 없음.
*
* 누가토량 곡선(`_UI_MassHaul`)은 "얼마가 남는가"만 말한다. 이 파일은 그 곡선에서
* **어디 흙을 어디로 얼마나 옮기는가**를 뽑아 낸다: 평형선 → 운반 블록 → 운반토량 `Q` →
* 평균운반거리 `L` → 지반유형 안분 → 장비 선정.
*
* ── 작도 규칙(참고자료) ─────────────────────────────────────────────
* <https://clouds-daily.tistory.com/249>
* - 상승 = 절토, 하강 = 성토. 극대·극소점 = 절·성토 경계.
* - 산 모양 = 좌→우 운반, 골 모양 = 우→좌 운반.
* - 평형선(수평선)이 곡선을 자르는 두 점 사이는 절토량 = 성토량.
* - 운반토량 `Q` = 평형선에서 곡선 정점까지의 종거.
* - 평균운반거리 `L` = **종거의 1/2 지점을 지나는 수평선 B′C′의 길이**(반종거 수평선).
* - 배분 3원칙 = ① 운반거리 최소 ② 높은 곳 → 낮은 곳 ③ 토량을 모아 운반.
*
* ── 평형선을 어떻게 자동으로 잡는가 ─────────────────────────────────
* 참고자료는 "가장 유리한 평형점을 도상에서 구한다"까지만 말한다. 이를 결정론으로 옮길 때
* **0선 하나로 고정하면 안 된다** — 임도는 절토가 크게 우세해 사토가 전부 종점 한 덩어리로
* 몰린다. 그래서 곡선을 왼쪽부터 훑으며 평형선 높이를 **계단으로 올린다**:
*
* 커서(곡선 위, 높이 = 현재 평형선)에서 다음 극값을 본다.
* - 올라갔다가(절토) 다시 현재 높이 **아래**까지 내려오면 → 성토가 절토를 다 받는다.
* 평형선은 그대로, 블록은 [커서 … 곡선이 평형선으로 되돌아온 점].
* - 올라갔다가 현재 높이 **위**에서 멈추면 → 남는 절토가 생긴다. 평형선을 그 골 높이로
* 올리고, 못 받은 만큼을 **그 자리에서** 사토로 계상한다.
* - 내려가는 경우는 정확히 대칭(토취).
*
* 이렇게 하면 ① 구간이 겹치거나 비지 않고 ② 사토·토취가 발생 지점에 남으며
* ③ 단차의 합이 최종 누가토량과 정확히 일치한다.
*
* ── 단위 ─────────────────────────────────────────────────────────
* 곡선이 다짐상태 기준이므로 `Q`·EA/RR/BR 안분도 **다짐상태**다. 내역서용 자연상태 수량은
* `MassHaulResult.cut_natural_m3` / 측점별 `cut_soil_m3`·`cut_rr_m3`·`cut_br_m3`가 따로 든다.
* ========================================================================== */
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`이면 상한 없음(최종 후보). */
export interface HaulEquipmentLimit {
key: string;
max_distance_m: number | null;
}
/** 운반 방향. 산 모양이면 좌→우(`forward`), 골 모양이면 우→좌(`backward`). */
export type HaulDirection = "forward" | "backward";
export interface HaulBlock {
/** 1부터. 도면 balloon의 블록 번호. */
index: number;
/** 이 블록의 평형선 높이(누가토량 축, ㎥). */
base_m3: number;
/** 평형선이 곡선을 자르는 두 점(누가거리 m). */
from_m: number;
to_m: number;
/** 정점(극대/극소) 위치와 값 — balloon 지시선 앵커. */
apex_m: number;
apex_volume_m3: number;
/** 운반토량 `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;
}
/** 받아 줄 짝이 없어 남은 토량. `spoil` = 사토(잉여), `borrow` = 토취(부족). */
export interface HaulResidual {
kind: "spoil" | "borrow";
from_m: number;
to_m: number;
volume_m3: number;
/** 평형선이 이 잔량만큼 계단으로 옮겨 간다 — 렌더러가 단차를 그대로 그린다. */
level_from_m3: number;
level_to_m3: number;
}
/** 계단형 평형선 한 칸. 렌더러가 칸 사이를 수직선으로 이어 계단을 만든다. */
export interface BalanceStep {
from_m: number;
to_m: number;
level_m3: number;
}
export interface HaulPlan {
blocks: HaulBlock[];
residuals: HaulResidual[];
steps: BalanceStep[];
spoil_m3: number;
borrow_m3: number;
/** 운반 블록 토량 합계(㎥) — 실제로 장비가 옮기는 양. */
hauled_m3: number;
}
const EPSILON = 1e-9;
/**
* 이보다 작은 진동은 블록으로 세지 않는다. 측점 하나짜리 요철까지 블록을 만들면 balloon이
* 수십 개 깔려 도면을 못 읽는다. 곡선 진폭 대비 비율이라 노선 규모에 자동으로 맞는다.
*/
const MIN_SWING_RATIO = 0.02;
function factorFor(conversion: EarthworkConversion, ground: GroundType): number {
const factor = conversion?.[ground]?.compacted;
return Number.isFinite(factor) && factor > 0 ? factor : 1;
}
/** 곡선을 선형보간해 임의 위치의 누가토량을 읽는다(양 끝은 클램프). */
function valueAt(points: MassHaulPoint[], x: number): number {
const last = points[points.length - 1];
if (x <= points[0].chainage_m) return points[0].cumulative_volume_m3;
if (x >= last.chainage_m) return last.cumulative_volume_m3;
for (let index = 1; index < points.length; index += 1) {
const a = points[index - 1];
const b = points[index];
if (x > b.chainage_m) continue;
const span = b.chainage_m - a.chainage_m;
if (span <= 0) return b.cumulative_volume_m3;
const ratio = (x - a.chainage_m) / span;
return a.cumulative_volume_m3 + (b.cumulative_volume_m3 - a.cumulative_volume_m3) * ratio;
}
return last.cumulative_volume_m3;
}
/**
* `[fromX, toX]`를 왼쪽부터 훑어 곡선이 `level`을 **처음** 지나는 x. 없으면 null.
* 평형선·반종거 수평선의 교점이 전부 이 함수 하나로 나온다.
*/
function crossFrom(
points: MassHaulPoint[],
fromX: number,
toX: number,
level: number,
): number | null {
if (!(toX > fromX)) return null;
for (let index = 1; index < points.length; index += 1) {
const x0 = Math.max(points[index - 1].chainage_m, fromX);
const x1 = Math.min(points[index].chainage_m, toX);
if (!(x1 > x0)) continue;
const y0 = valueAt(points, x0);
const y1 = valueAt(points, x1);
if ((y0 - level) * (y1 - level) > 0) continue;
if (Math.abs(y1 - y0) < EPSILON) return x0;
return x0 + ((level - y0) / (y1 - y0)) * (x1 - x0);
}
return null;
}
/** 곡선의 극값 인덱스 수열(양 끝점 포함). 기울기 부호가 바뀌는 자리가 극값이다. */
function extremaIndices(points: MassHaulPoint[]): number[] {
const list = [0];
let sign = 0;
for (let index = 1; index < points.length; index += 1) {
const delta = points[index].cumulative_volume_m3 - points[index - 1].cumulative_volume_m3;
if (Math.abs(delta) < EPSILON) continue;
const next = delta > 0 ? 1 : -1;
if (sign !== 0 && next !== sign && list[list.length - 1] !== index - 1) list.push(index - 1);
sign = next;
}
if (list[list.length - 1] !== points.length - 1) list.push(points.length - 1);
return list;
}
/**
* 잔진동 제거. 진폭이 `minSwing` 미만인 극값은 **짝이 되는 이웃과 함께** 지운다 —
* 하나만 지우면 극대·극소 교대가 깨져 뒤 계산이 방향을 잃는다. 양 끝점은 남긴다.
*/
function pruneExtrema(points: MassHaulPoint[], indices: number[], minSwing: number): number[] {
const valueOf = (index: number): number => points[index].cumulative_volume_m3;
let list = indices.slice();
let changed = true;
while (changed && list.length > 2) {
changed = false;
for (let i = 1; i < list.length - 1; i += 1) {
const left = Math.abs(valueOf(list[i]) - valueOf(list[i - 1]));
const right = Math.abs(valueOf(list[i]) - valueOf(list[i + 1]));
if (Math.min(left, right) >= minSwing) continue;
const partner = left <= right ? i - 1 : i + 1;
const drop = new Set<number>([i]);
if (partner > 0 && partner < list.length - 1) drop.add(partner);
list = list.filter((_, position) => !drop.has(position));
changed = true;
break;
}
}
return list;
}
interface CutMix {
ea: number;
rr: number;
br: number;
}
/**
* `[fromX, toX]` 절토 구간의 지반유형 구성(다짐상태 가중치). 구간이 측점 사이를 반만
* 물면 그 비율만큼만 센다 — 평형선 교점은 측점에 딱 떨어지지 않는다.
*/
function cutMix(
points: MassHaulPoint[],
fromX: number,
toX: number,
conversion: EarthworkConversion,
): CutMix {
const mix: CutMix = { ea: 0, rr: 0, br: 0 };
for (let index = 1; index < points.length; index += 1) {
const a = points[index - 1].chainage_m;
const b = points[index].chainage_m;
const span = b - a;
if (span <= 0) continue;
const overlap = Math.min(b, toX) - Math.max(a, fromX);
if (overlap <= 0) continue;
const ratio = overlap / span;
mix.ea += points[index].cut_soil_m3 * ratio * factorFor(conversion, "soil");
mix.rr += points[index].cut_rr_m3 * ratio * factorFor(conversion, "ripping_rock");
mix.br += points[index].cut_br_m3 * ratio * factorFor(conversion, "blasting_rock");
}
return mix;
}
/** 구성비로 `Q`를 안분한다. 구간에 절토 기록이 전혀 없으면 지반유형 미상 = 토사로 둔다. */
function apportion(mix: CutMix, volume: number): Pick<HaulBlock, "ea_m3" | "rr_m3" | "br_m3"> {
const total = mix.ea + mix.rr + mix.br;
if (!(total > 0)) return { ea_m3: volume, rr_m3: 0, br_m3: 0 };
return {
ea_m3: (mix.ea / total) * volume,
rr_m3: (mix.rr / total) * volume,
br_m3: (mix.br / total) * volume,
};
}
/**
* 평균운반거리에 맞는 장비를 고른다. 경계 정의처는 `config_system.EARTHWORK_HAUL_EQUIPMENT_LIMITS_M`
* **한 곳뿐**이라, 값을 못 받았으면 프론트에 사본을 두지 않고 그냥 비운다.
*/
function pickEquipment(limits: HaulEquipmentLimit[] | undefined, distanceM: number): string | null {
if (!limits?.length) return null;
for (const limit of limits) {
if (limit.max_distance_m === null || distanceM <= limit.max_distance_m) return limit.key;
}
return limits[limits.length - 1].key;
}
/**
* 누가토량 곡선에서 토량 분배(평형선·운반 블록·사토/토취)를 뽑는다.
* 블록도 잔량도 안 나오면(평탄한 곡선) null.
*/
export function computeHaulPlan(
result: MassHaulResult,
limits: HaulEquipmentLimit[] | undefined,
): HaulPlan | null {
const points = result.points;
if (points.length < 2) return null;
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 blocks: HaulBlock[] = [];
const residuals: HaulResidual[] = [];
const steps: BalanceStep[] = [];
const pushBlock = (
fromM: number,
toM: number,
base: number,
apexX: number,
apexY: number,
direction: HaulDirection,
cutFrom: number,
cutTo: number,
): void => {
const volume = Math.abs(apexY - 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);
blocks.push({
index: blocks.length + 1,
base_m3: base,
from_m: fromM,
to_m: toM,
apex_m: apexX,
apex_volume_m3: apexY,
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),
});
steps.push({ from_m: fromM, to_m: toM, level_m3: base });
};
const pushResidual = (fromM: number, toM: number, from: number, to: number): void => {
const delta = to - from;
if (Math.abs(delta) < EPSILON) return;
residuals.push({
kind: delta > 0 ? "spoil" : "borrow",
from_m: fromM,
to_m: toM,
volume_m3: Math.abs(delta),
level_from_m3: from,
level_to_m3: to,
});
};
let level = points[0].cumulative_volume_m3;
let cursor = points[0].chainage_m;
let k = 1;
while (k < extrema.length) {
const apexX = points[extrema[k]].chainage_m;
const apexY = points[extrema[k]].cumulative_volume_m3;
if (apexX <= cursor + EPSILON) {
k += 1;
continue;
}
const rising = apexY > level + EPSILON;
const falling = apexY < level - EPSILON;
if (!rising && !falling) {
cursor = apexX;
k += 1;
continue;
}
// 짝이 되는 다음 극값이 없으면 받아 줄 성토(또는 절토)가 없다 — 전량 사토/토취.
if (k + 1 >= extrema.length) {
steps.push({ from_m: cursor, to_m: apexX, level_m3: level });
pushResidual(cursor, apexX, level, apexY);
level = apexY;
cursor = apexX;
k += 1;
continue;
}
const nextX = points[extrema[k + 1]].chainage_m;
const nextY = points[extrema[k + 1]].cumulative_volume_m3;
if (rising) {
if (nextY <= level + EPSILON) {
// 성토가 절토를 다 받는다 — 평형선 그대로, 곡선이 되돌아온 점에서 블록을 닫는다.
const end = crossFrom(points, apexX, nextX, level) ?? nextX;
pushBlock(cursor, end, level, apexX, apexY, "forward", cursor, apexX);
cursor = end;
k += 1;
} else {
// 못 받은 절토가 남는다 — 평형선을 그 골 높이로 올리고 차액을 사토로 계상한다.
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);
level = nextY;
cursor = nextX;
k += 2;
}
} else if (nextY >= level - EPSILON) {
// 골 — 오른쪽 절토가 왼쪽 성토를 채운다(우→좌 운반).
const end = crossFrom(points, apexX, nextX, level) ?? nextX;
pushBlock(cursor, end, level, apexX, apexY, "backward", apexX, end);
cursor = end;
k += 1;
} else {
// 절토가 모자라 성토를 못 채운다 — 평형선을 내리고 차액을 토취로 계상한다.
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);
level = nextY;
cursor = nextX;
k += 2;
}
}
// 극값 가지치기로 잘려 나간 꼬리가 있으면 총량이 어긋난다 — 종점 잔량으로 흡수한다.
const endX = points[points.length - 1].chainage_m;
const endY = points[points.length - 1].cumulative_volume_m3;
if (Math.abs(endY - level) > EPSILON) {
steps.push({ from_m: cursor, to_m: endX, level_m3: level });
pushResidual(cursor, endX, level, endY);
}
if (!blocks.length && !residuals.length) return null;
let spoil = 0;
let borrow = 0;
for (const residual of residuals) {
if (residual.kind === "spoil") spoil += residual.volume_m3;
else borrow += residual.volume_m3;
}
return {
blocks,
residuals,
steps,
spoil_m3: spoil,
borrow_m3: borrow,
hauled_m3: blocks.reduce((sum, block) => sum + block.volume_m3, 0),
};
}
/** 확정 저장·B08 인계용 직렬화. 값은 소수 둘째 자리에서 끊는다. */
export function haulPlanPayload(plan: HaulPlan): Record<string, unknown> {
const round = (value: number): number => Math.round(value * 100) / 100;
return {
spoil_m3: round(plan.spoil_m3),
borrow_m3: round(plan.borrow_m3),
hauled_m3: round(plan.hauled_m3),
blocks: plan.blocks.map((block) => ({
index: block.index,
from_m: round(block.from_m),
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),
})),
residuals: plan.residuals.map((residual) => ({
kind: residual.kind,
from_m: round(residual.from_m),
to_m: round(residual.to_m),
volume_m3: round(residual.volume_m3),
})),
};
}
@@ -0,0 +1,326 @@
/* =============================================================================
* B06_wf3_ProfileCross_UI_MassHaul_Balance_View.ts
* 토량 분배(평형선) SVG 렌더러 — 유토곡선 위에 겹치는 레이어.
*
* 그리는 것은 넷이다:
* 1. 계단형 평형선 — 블록마다 수평, 사토·토취가 나는 자리에서 계단으로 옮겨 간다.
* 2. 종거 표시선 — 평형선에서 곡선 정점까지. 이 길이가 곧 운반토량 `Q`.
* 3. 반종거 수평선 — 종거의 1/2 높이에서 곡선을 자른 두 점 사이. 이 길이가 `L`이고
* 끝에 방향 화살촉을 단다(산 = 좌→우, 골 = 우→좌).
* 4. 육각 balloon — `Q` / `L`·장비 / EA·RR·BR. 사토·토취는 단차 화살표 + 라벨.
*
* 좌표 변환(x/y)과 그릴 상자는 유토곡선 렌더러가 넘겨준다 — 축을 두 번 정의하지 않는다.
* 계산은 전부 `_UI_MassHaul_Balance`가 끝내 두므로 여기서는 배치만 판단한다.
* 700줄 제한 대응으로 `_UI_MassHaul_View`에서 분리했다.
* ========================================================================== */
import type { 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";
/** 유토곡선 렌더러가 넘겨주는 좌표계와 그릴 수 있는 상자(px). */
export interface BalanceLayerBox {
x: (chainageM: number) => number;
y: (volumeM3: number) => number;
left: number;
right: number;
top: number;
bottom: number;
}
const EQUIPMENT_LABEL: Record<string, LocaleKey> = {
free_haul: "B06_MassHaul_Equip_FreeHaul",
dozer: "B06_MassHaul_Equip_Dozer",
dump_truck: "B06_MassHaul_Equip_Dump",
};
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;
/** SVG는 자동 크기가 없어 폭을 어림해야 한다. 한글은 라틴 글자보다 넓다. */
function textWidth(value: string): number {
let width = 0;
for (const char of value) width += /[가-힣ㄱ-ㅎㅏ-ㅣ]/.test(char) ? 9.5 : 5.2;
return width;
}
/** balloon 안 수치는 자리를 아껴야 한다 — 천 단위 구분만 두고 소수점은 버린다. */
function compactVolume(value: number): string {
return Math.round(value).toLocaleString();
}
function compactDistance(value: number): string {
return value >= 100 ? `${Math.round(value)}` : value.toFixed(1);
}
function equipmentLabel(key: string | null): string {
if (!key) return "";
const locale = EQUIPMENT_LABEL[key];
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;
const notch = Math.min(NOTCH, width * 0.2);
return [
`${cx - halfW},${cy}`,
`${cx - halfW + notch},${cy - halfH}`,
`${cx + halfW - notch},${cy - halfH}`,
`${cx + halfW},${cy}`,
`${cx + halfW - notch},${cy + halfH}`,
`${cx - halfW + notch},${cy + halfH}`,
].join(" ");
}
interface PlacedBox {
left: number;
right: number;
top: number;
bottom: number;
}
function overlaps(a: PlacedBox, b: PlacedBox): boolean {
return !(a.right <= b.left || a.left >= b.right || a.bottom <= b.top || a.top >= b.bottom);
}
/**
* 겹치지 않는 세로 자리를 찾는다. 선호 방향(위/아래)으로 한 칸씩 밀어 보고, 상자를 벗어나면
* 반대쪽을 시도한다. 끝내 못 찾으면 처음 자리에 그대로 둔다 — 안 그리는 것보다 낫다.
*/
function findSlot(
placed: PlacedBox[],
centerX: number,
preferredY: number,
width: number,
height: number,
box: BalanceLayerBox,
upward: boolean,
): number {
const step = height + 4;
const half = height / 2;
for (const direction of upward ? [-1, 1] : [1, -1]) {
for (let attempt = 0; attempt < 6; attempt += 1) {
const centerY = preferredY + direction * step * attempt;
if (centerY - half < box.top || centerY + half > box.bottom) break;
const candidate: PlacedBox = {
left: centerX - width / 2,
right: centerX + width / 2,
top: centerY - half,
bottom: centerY + half,
};
if (!placed.some((entry) => overlaps(entry, candidate))) return centerY;
}
}
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;
for (const step of steps) {
const y = box.y(step.level_m3);
const x1 = box.x(step.from_m);
const x2 = box.x(step.to_m);
if (previous && Math.abs(previous.y - y) > 0.5) {
group.append(
svgElement("line", {
x1: previous.x,
y1: previous.y,
x2: previous.x,
y2: y,
class: "b06-balance__riser",
}),
);
}
group.append(svgElement("line", { x1, y1: y, x2, y2: y, class: "b06-balance__line" }));
previous = { x: x2, y };
}
}
/** 종거(수직) + 반종거 수평선(방향 화살촉). 두 선이 곧 `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(
group: SVGGElement,
block: HaulBlock,
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;
const equipment = equipmentLabel(block.equipment);
const room = box.bottom - box.top;
const lines = [`${block.index} · ${compactVolume(block.volume_m3)}`];
if (room >= 110) {
lines.push(`L ${compactDistance(block.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)}`,
);
}
const width = Math.max(...lines.map(textWidth)) + PAD_X * 2 + NOTCH;
const height = lines.length * LINE_HEIGHT + PAD_Y * 2;
// 산이면 정점 위, 골이면 정점 아래 — 곡선 바깥이라 곡선을 가리지 않는다.
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);
placed.push({
left: centerX - width / 2,
right: centerX + width / 2,
top: centerY - height / 2,
bottom: centerY + height / 2,
});
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이 밀려났을 때 어느 정점의 것인지 잇는다.
balloon.append(
title,
svgElement("line", {
x1: apexX,
y1: upward ? Math.min(apexY, baseY) : Math.max(apexY, baseY),
x2: centerX,
y2: centerY,
class: "b06-balance__leader",
}),
svgElement("polygon", {
points: hexagonPoints(centerX, centerY, width, height),
class: "b06-balance__balloon-shape",
}),
...lines.map((line, index) =>
svgText(line, {
x: centerX,
y: centerY - height / 2 + PAD_Y + LINE_HEIGHT * (index + 0.8),
"text-anchor": "middle",
class: "b06-balance__balloon-text",
}),
),
);
group.append(balloon);
}
/** 사토·토취 — 평형선 단차를 세로 양방향 화살표로 찍고 그 옆에 수량을 적는다. */
function appendResidual(
group: SVGGElement,
residual: HaulResidual,
box: BalanceLayerBox,
compact: boolean,
): void {
const centerX = Math.min(
Math.max((box.x(residual.from_m) + box.x(residual.to_m)) / 2, box.left + 4),
box.right - 4,
);
const y1 = box.y(residual.level_from_m3);
const y2 = box.y(residual.level_to_m3);
const label = `${L(residual.kind === "spoil" ? "B06_MassHaul_Surplus" : "B06_MassHaul_Shortage")} ${compactVolume(residual.volume_m3)}`;
const marker = svgElement("g", {
class: `b06-balance__residual b06-balance__residual--${residual.kind}`,
});
const title = svgElement("title");
title.textContent = label;
marker.append(
title,
svgElement("line", { x1: centerX, y1, x2: centerX, y2, class: "b06-balance__step-arrow" }),
svgElement("polygon", {
points: `${centerX},${y2} ${centerX - 3.5},${y2 + (y2 > y1 ? -6 : 6)} ${centerX + 3.5},${y2 + (y2 > y1 ? -6 : 6)}`,
class: "b06-balance__step-head",
}),
);
// 좁은 그래프에서는 화살표만 남기고 수량은 툴팁으로 돌린다.
if (!compact) {
marker.append(
svgText(label, {
x: centerX + 5,
y: (y1 + y2) / 2 + 3,
class: "b06-balance__residual-text",
}),
);
}
group.append(marker);
}
/**
* 토량 분배 레이어를 유토곡선 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 placed: PlacedBox[] = [];
for (const block of plan.blocks) appendBlockBalloon(group, block, box, placed);
svg.append(group);
}
/**
* 곡선 아래 요약줄에 덧붙일 토량 분배 수치.
* 사토·토취는 요약줄이 이미 곡선 기준으로 적고 있고 계단 단차의 합이 그 값과 같으므로
* 여기서 또 적지 않는다(같은 수치가 두 번 뜨면 서로 다른 값으로 오해한다).
*/
export function haulPlanChips(plan: HaulPlan | null): Array<[string, string]> {
if (!plan) return [];
return [
[L("B06_MassHaul_HaulTotal"), `${compactVolume(plan.hauled_m3)}`],
[L("B06_MassHaul_BlockCount"), `${plan.blocks.length}`],
];
}
@@ -21,7 +21,10 @@ import type {
MassHaulPoint,
MassHaulSeries,
} from "./B06_wf3_ProfileCross_UI_MassHaul";
import type { HaulPlan } from "./B06_wf3_ProfileCross_UI_MassHaul_Balance";
import type { LocaleKey } from "@ui/ui_template_locale";
import { MASS_HAUL_BALANCE_KEY } from "./B06_wf3_ProfileCross_UI_MassHaul";
import { appendBalanceLayer, haulPlanChips } from "./B06_wf3_ProfileCross_UI_MassHaul_Balance_View";
import {
L,
LONG_PAD,
@@ -232,6 +235,7 @@ export function createMassHaulChart(
heightPx: number,
minimumWidthPx: number,
onSelectStation: (stationId: string) => void,
haulPlan: HaulPlan | null,
): SVGSVGElement {
const svg = svgElement("svg", {
class: "b06-section__chart b06-masshaul",
@@ -361,6 +365,19 @@ export function createMassHaulChart(
);
}
// 토량 분배 레이어 — 곡선 **위**, 선택 말풍선 **아래**. 순서를 바꾸면 평형선이 곡선을
// 가리거나(위로 올리면) 선택 말풍선이 balloon에 묻힌다(아래로 내리면).
if (haulPlan) {
appendBalanceLayer(svg, haulPlan, {
x,
y,
left: LONG_PAD.left,
right: widthPx - LONG_PAD.right,
top: MASS_PAD_TOP,
bottom: heightPx - MASS_PAD_BOTTOM,
});
}
// 선택 측점 강조 — 곡선 위 점 + 구간 물량 말풍선.
const focus = banded ? massHaulPointAt(banded, longitudinal, selectedStationId) : null;
if (focus) {
@@ -457,14 +474,13 @@ export function createMassHaulLegend(
legend.className = "b06-masshaul__legend";
legend.setAttribute("role", "group");
legend.setAttribute("aria-label", L("B06_MassHaul_Legend_Title"));
for (const entry of series) {
const shown = visibleKeys.has(entry.key);
const addItem = (key: string, label: string, variantClass: string): void => {
const shown = visibleKeys.has(key);
const button = document.createElement("button");
button.type = "button";
button.className = seriesClass(entry, "b06-masshaul__legend-item");
button.className = variantClass;
button.classList.toggle("is-off", !shown);
button.setAttribute("aria-pressed", String(shown));
const label = massHaulSeriesLabel(entry);
button.title = `${label}${L(shown ? "B06_MassHaul_Legend_Hide" : "B06_MassHaul_Legend_Show")}`;
const swatch = document.createElement("span");
swatch.className = "b06-masshaul__legend-swatch";
@@ -472,9 +488,18 @@ export function createMassHaulLegend(
const text = document.createElement("span");
text.textContent = label;
button.append(swatch, text);
button.addEventListener("click", () => onToggle(entry.key));
button.addEventListener("click", () => onToggle(key));
legend.append(button);
};
for (const entry of series) {
addItem(entry.key, massHaulSeriesLabel(entry), seriesClass(entry, "b06-masshaul__legend-item"));
}
// 토량 분배는 곡선이 아니라 곡선 위에 얹는 레이어라 스와치 모양을 따로 준다.
addItem(
MASS_HAUL_BALANCE_KEY,
L("B06_MassHaul_Balance_Layer"),
"b06-masshaul__legend-item b06-masshaul__legend-item--balance",
);
return legend;
}
@@ -482,7 +507,10 @@ export function createMassHaulLegend(
* 곡선 아래 총괄 요약 줄. 절토는 자연상태(내역 기준)와 환산 후(곡선 기준)를 함께 적는다.
* 곡선이 여러 개라 수치가 어느 곡선의 것인지 끝에 반드시 밝힌다.
*/
export function createMassHaulSummary(series: MassHaulSeries): HTMLElement {
export function createMassHaulSummary(
series: MassHaulSeries,
haulPlan: HaulPlan | null,
): HTMLElement {
const result = series.result;
const row = document.createElement("div");
row.className = "b06-masshaul__summary";
@@ -504,6 +532,8 @@ export function createMassHaulSummary(series: MassHaulSeries): HTMLElement {
? [L("B06_MassHaul_Shortage"), `${formatVolume(result.shortage_m3)}`]
: [L("B06_MassHaul_Surplus"), `${formatVolume(result.surplus_m3)}`],
);
// 토량 분배를 켜 두면 운반 총량·블록 수가 뒤에 붙는다(사토·토취는 발생 지점 기준 합계).
chips.push(...haulPlanChips(haulPlan));
for (const [label, value] of chips) {
const chip = document.createElement("span");
chip.className = "b06-masshaul__chip";
@@ -34,6 +34,7 @@ import {
type RockBoundaryControl,
} from "./B06_wf3_ProfileCross_UI_Section_View";
import { computeMassHaul, massHaulPayload } from "./B06_wf3_ProfileCross_UI_MassHaul";
import { computeHaulPlan } from "./B06_wf3_ProfileCross_UI_MassHaul_Balance";
import { designElevationAt } from "./B06_wf3_ProfileCross_UI_Section_Common";
import {
createStandardPanel,
@@ -344,6 +345,7 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
crossHalfWidth(),
stationInterval,
context?.earthwork_conversion,
context?.haul_equipment_limits,
);
}
}
@@ -390,7 +392,9 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
currentRouteId,
standardPanel?.getValues(),
crossPatches.length ? crossPatches : undefined,
massHaul ? massHaulPayload(massHaul) : undefined,
massHaul
? massHaulPayload(massHaul, computeHaulPlan(massHaul, context?.haul_equipment_limits))
: undefined,
);
showToast(L("B06_Profile_Confirm_Success"), "success");
goToWorkflowStage(projectId, WORKFLOW_STEP_ROUTES[4]);
@@ -18,6 +18,7 @@ import { createWorkflowPanelHandle } from "@ui/ui_template_overlay";
import type {
CrossSection,
EarthworkConversion,
HaulEquipmentLimit,
SectionDetailResponse,
} from "./B06_wf3_ProfileCross_Api_Fetch";
import type { RockBoundaryControl } from "./B06_wf3_ProfileCross_UI_Cross_Design";
@@ -33,9 +34,11 @@ import {
} from "./B06_wf3_ProfileCross_UI_Longitudinal";
import {
computeMassHaulSeries,
MASS_HAUL_BALANCE_KEY,
MASS_HAUL_DEFAULT_VISIBLE,
type MassHaulSeries,
} from "./B06_wf3_ProfileCross_UI_MassHaul";
import { computeHaulPlan } from "./B06_wf3_ProfileCross_UI_MassHaul_Balance";
import {
createMassHaulChart,
createMassHaulLegend,
@@ -77,18 +80,22 @@ const PANEL_HEIGHT_KEY = "b06:profile-panel-height";
const PANEL_COLLAPSED_KEY = "b06:profile-panel-collapsed";
// 곡선 키·기본값이 바뀔 때마다 저장 키의 판을 올려 갈아탄다(옛 세션값을 읽으면 의도와 다른
// 곡선이 켜진다). v2: 키가 `${기준}_${환산}` → `${기준}`. v3: 기본 표시가 횡단 기준 하나로.
const MASS_HAUL_VISIBLE_KEY = "b06:masshaul-visible-v3";
// v4: 토량 분배 레이어(`balance`)가 범례에 합류.
const MASS_HAUL_VISIBLE_KEY = "b06:masshaul-visible-v4";
/** 곡선 기준 + 토량 분배 레이어. 저장값이 없을 때 켜 두는 항목. */
const DEFAULT_VISIBLE_KEYS: string[] = [...MASS_HAUL_DEFAULT_VISIBLE, MASS_HAUL_BALANCE_KEY];
/** 켜 둔 곡선 목록은 세션에만 남긴다(패널 높이·접힘과 같은 규칙). */
function readVisibleSeries(): Set<string> {
try {
const raw = sessionStorage.getItem(MASS_HAUL_VISIBLE_KEY);
if (!raw) return new Set(MASS_HAUL_DEFAULT_VISIBLE);
if (!raw) return new Set(DEFAULT_VISIBLE_KEYS);
const parsed: unknown = JSON.parse(raw);
// 전부 끈 상태(빈 배열)도 사용자의 선택이므로 기본값으로 되돌리지 않는다.
return Array.isArray(parsed) ? new Set(parsed.map(String)) : new Set(MASS_HAUL_DEFAULT_VISIBLE);
return Array.isArray(parsed) ? new Set(parsed.map(String)) : new Set(DEFAULT_VISIBLE_KEYS);
} catch {
return new Set(MASS_HAUL_DEFAULT_VISIBLE);
return new Set(DEFAULT_VISIBLE_KEYS);
}
}
@@ -136,6 +143,7 @@ export interface SectionViewController {
crossHalfWidth?: number,
stationInterval?: number,
earthworkConversion?: EarthworkConversion,
haulEquipmentLimits?: HaulEquipmentLimit[],
) => void;
/** 측점 하나의 카드만 새로 만들어 교체한다 (전체 재렌더 없이 설계 변경 반영). */
refreshCard: (chainageM: number) => void;
@@ -157,6 +165,7 @@ export function createSectionView(
let currentCrossHalfWidth: number | undefined;
let currentStationInterval: number | undefined;
let currentConversion: EarthworkConversion | undefined;
let currentHaulLimits: HaulEquipmentLimit[] | undefined;
let renderWidth = 0;
let resizeTimer = 0;
let panelResizeTimer = 0;
@@ -450,6 +459,13 @@ export function createSectionView(
const series: MassHaulSeries[] = currentConversion
? computeMassHaulSeries(detail.longitudinal, detail.cross_sections, currentConversion)
: [];
// 토량 분배는 **면을 깐 곡선 하나**(= 켜 둔 첫 곡선)에만 얹는다 — 곡선마다 평형선을
// 그리면 계단이 서로 엇갈려 어느 쪽 배분인지 읽히지 않는다.
const bandedSeries = series.find((entry) => visibleSeries.has(entry.key));
const haulPlan =
bandedSeries && visibleSeries.has(MASS_HAUL_BALANCE_KEY)
? computeHaulPlan(bandedSeries.result, currentHaulLimits)
: null;
if (series.length) {
// 유토곡선 SVG는 종단면도와 **같은 부모의 형제**여야 한다. 감싸는 상자를 하나라도 끼우면
// 그 상자가 스크롤 컨테이너 폭 계산에 끼어들어 두 그래프의 측점 세로선이 어긋난다.
@@ -464,6 +480,7 @@ export function createSectionView(
heights.mass,
minWidth,
(stationId) => selectStation(stationId, true),
haulPlan,
),
);
}
@@ -481,10 +498,10 @@ export function createSectionView(
}
// 요약 수치는 켜 둔 곡선 중 첫 번째 것 — 곡선이 여러 개라 어느 것인지 요약 끝에 밝힌다.
// (선택 측점의 구간 물량은 곡선 위 말풍선이 맡는다.)
const summarySeries = series.find((entry) => visibleSeries.has(entry.key));
const summarySeries = bandedSeries;
if (summarySeries) {
panelCount.textContent = "";
panelBody.append(createMassHaulSummary(summarySeries));
panelBody.append(createMassHaulSummary(summarySeries, haulPlan));
} else {
panelCount.textContent = L(series.length ? "B06_MassHaul_AllHidden" : "B06_MassHaul_Empty");
}
@@ -592,7 +609,14 @@ export function createSectionView(
return {
root,
render(detail, verticalExaggeration, crossHalfWidth, stationInterval, earthworkConversion) {
render(
detail,
verticalExaggeration,
crossHalfWidth,
stationInterval,
earthworkConversion,
haulEquipmentLimits,
) {
currentDetail = detail;
currentExaggeration = Math.max(verticalExaggeration, 0.1);
currentCrossHalfWidth =
@@ -600,6 +624,7 @@ export function createSectionView(
currentStationInterval =
stationInterval !== undefined && stationInterval > 0 ? stationInterval : undefined;
if (earthworkConversion) currentConversion = earthworkConversion;
if (haulEquipmentLimits?.length) currentHaulLimits = haulEquipmentLimits;
// 진입 시 측점을 자동으로 고르지 않는다(2026-08-02 사용자 지시) — 0측점이 선택된 채로
// 시작하면 사용자가 고르지도 않은 카드가 강조돼 있고 유토곡선 말풍선도 떠 있다.
renderWidth = contentWidth();
@@ -169,3 +169,101 @@
font-size: 11px;
font-variant-numeric: tabular-nums;
}
/* =============================================================================
* 토량 분배(평형선) 레이어
*
* 곡선(보라)과 구분되도록 계열을 통째로 바꾼다: 평형선·운반 표시는 경고색(황토),
* 사토는 위험색, 토취는 성공색. 곡선 위에 겹치므로 선을 얇게 두고 balloon만 불투명하다.
* ========================================================================== */
/* 평형선 — 블록마다 수평. 곡선보다 얇지만 실선이라 격자와 구분된다. */
.b06-balance__line {
stroke: var(--color-warning);
stroke-width: 1.6;
}
/* 계단의 수직 연결선 — 사토·토취가 나는 자리라 파선으로 "옮겨 갔음"을 드러낸다. */
.b06-balance__riser {
stroke: var(--color-warning);
stroke-width: 1.2;
stroke-dasharray: 3 2;
}
/* 종거(평형선 → 정점) = 운반토량 Q의 눈금. */
.b06-balance__ordinate {
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 {
fill: var(--color-warning);
stroke: none;
}
.b06-balance__leader {
stroke: var(--color-warning);
stroke-width: 1;
stroke-dasharray: 3 2;
opacity: 0.7;
}
/* 육각 balloon — 곡선 위에 놓이므로 배경을 거의 불투명하게 깐다. */
.b06-balance__balloon-shape {
fill: color-mix(in srgb, var(--color-surface-raised) 94%, transparent);
stroke: var(--color-warning);
stroke-width: 1.2;
}
.b06-balance__balloon-text {
fill: var(--color-text-body);
font-size: 9px;
font-variant-numeric: tabular-nums;
}
/* 사토·토취 단차. 남는 흙(사토)과 모자란 흙(토취)을 색으로 갈라 둔다. */
.b06-balance__step-arrow {
stroke-width: 1.4;
}
.b06-balance__residual--spoil .b06-balance__step-arrow,
.b06-balance__residual--spoil .b06-balance__residual-text {
stroke: var(--color-danger);
fill: var(--color-danger);
}
.b06-balance__residual--borrow .b06-balance__step-arrow,
.b06-balance__residual--borrow .b06-balance__residual-text {
stroke: var(--color-success);
fill: var(--color-success);
}
.b06-balance__residual--spoil .b06-balance__step-head {
fill: var(--color-danger);
stroke: none;
}
.b06-balance__residual--borrow .b06-balance__step-head {
fill: var(--color-success);
stroke: none;
}
.b06-balance__residual-text {
stroke: none;
font-size: 9px;
font-variant-numeric: tabular-nums;
}
/* 범례에서 분배 레이어는 곡선이 아니라 평형선 계열임을 스와치로 알린다. */
.b06-masshaul__legend-item--balance .b06-masshaul__legend-swatch {
border-top-color: var(--color-warning);
border-top-style: dashed;
}
+12
View File
@@ -237,6 +237,18 @@ export const ui_locales_b2 = {
B06_MassHaul_Station_Net: ["순토량", "Net volume"],
B06_MassHaul_Station_Cumulative: ["누가토량", "Cumulative"],
/* --- B06 토량 분배(평형선·운반 블록) --- */
B06_MassHaul_Balance_Layer: ["토량 분배", "Haul plan"],
B06_MassHaul_Block: ["운반 블록", "Haul block"],
B06_MassHaul_HaulVolume: ["운반토량", "Haul volume"],
B06_MassHaul_HaulDistance: ["평균운반거리", "Average haul distance"],
B06_MassHaul_HaulTotal: ["운반토량 계", "Hauled total"],
B06_MassHaul_BlockCount: ["운반 블록 수", "Haul blocks"],
/* 운반장비 이름 — 거리 경계값은 config_system.EARTHWORK_HAUL_EQUIPMENT_LIMITS_M에만 둔다. */
B06_MassHaul_Equip_FreeHaul: ["종무대", "Free haul"],
B06_MassHaul_Equip_Dozer: ["도쟈", "Dozer"],
B06_MassHaul_Equip_Dump: ["덤프", "Dump truck"],
/* --- B06 측점 표준횡단 설계 지정 --- */
B06_Design_Ground_Legend: ["지반유형", "Ground type"],
B06_Design_Ground_Soil: ["토사", "Soil"],