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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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"],
|
||||
|
||||
Reference in New Issue
Block a user