refactor(common): 유토곡선 엔진·렌더러를 common_util로 공용화

B05 계획 유토곡선과 B06 정식 유토곡선이 같은 엔진을 쓰도록 모듈을 옮겼다.

- B06_wf3_ProfileCross_UI_MassHaul{,_Balance,_Balance_View,_Balloon,_Curve,
  _Settle,_View}.ts + Style_MassHaul.css → common_util/common_util_mass_haul*.
- common_util_mass_haul_types.ts 신설: GroundType/EarthworkConversion/
  HaulEquipmentLimit/BalloonOffsets 정의처를 한 곳으로 모으고 B06 Api_Fetch가
  재수출. 엔진 입력은 페이지 API 타입 대신 구조적 부분집합으로 받는다.
- common_util_svg.ts 신설: svgElement/svgText/L/stationLabel/
  inferStationInterval 이관, B06 _UI_Section_Common은 재수출로 경로 유지.
- createMassHaulChart에 MassHaulAxis 인자 추가 — 종단 렌더러 상수 의존을
  걷어내고 호출한 쪽이 X축(누가거리 최댓값·좌우 여백)을 주입한다.

동작 무변경. npm run typecheck 통과.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-03 18:35:02 +09:00
co-authored by Claude Opus 5
parent e4700546bb
commit fceec5fb24
14 changed files with 273 additions and 167 deletions
+355
View File
@@ -0,0 +1,355 @@
/* =============================================================================
* B06_wf3_ProfileCross_UI_Style_MassHaul.css
* 유토곡선(Mass Haul Diagram) 스타일 — 곡선·오버레이 범례·요약줄.
* 700줄 제한 대응으로 _UI_Style_Cross.css에서 분리(종·횡단 도면 본체는 원본 유지).
*
* 곡선이 여러 개라 읽는 규칙을 둘로 나눈다:
* 색 = 산출 기준 (횡단 = 브랜드 보라 / 종단 = 성공색 초록)
* 선모양 = 환산 방식 (실선 = 지반유형별 / 파선 = 토사 단일 / 점선 = 환산 없음)
* 색상은 theme.css 변수만 쓴다. 횡단 기준의 보라는 참고 도면의 자주색에 대응한다.
* ========================================================================== */
/* 범례는 유토곡선 **우측 상단에 겹친다**. 단 가로 스크롤 컨테이너 **밖**에서 절대 위치로
띄운다 — 안에 넣으면 컨테이너 폭 계산에 끼어들어 종단면도와 측점 세로선이 어긋난다.
세로 위치(top)는 종단면도 높이에 맞춰 렌더러가 인라인으로 넣는다. */
.b06-masshaul__legend {
position: absolute;
right: var(--spacing-8);
z-index: 2;
display: flex;
flex-wrap: nowrap;
align-items: center;
gap: var(--spacing-4);
}
.b06-masshaul__legend-item {
display: inline-flex;
flex: none;
align-items: center;
gap: var(--spacing-8);
padding: var(--spacing-4) var(--spacing-8);
border: 1px solid var(--color-border);
border-radius: var(--radius-pills);
background: var(--color-surface-raised);
color: var(--color-text-body);
font-size: var(--text-caption);
line-height: 1.2;
cursor: pointer;
}
/* 도형 위치 초기화 — 곡선 토글이 아니라 동작 버튼이라 스와치를 두지 않는다. */
.b06-masshaul__legend-item--reset {
color: var(--color-text-secondary);
}
.b06-masshaul__legend-item.is-off {
color: var(--color-text-muted);
}
/* 스와치는 곡선을 그대로 축소한 것 — 색과 선모양이 그래프와 같아야 범례 구실을 한다. */
.b06-masshaul__legend-swatch {
width: 18px;
height: 0;
border-top: 2px solid var(--color-royal-amethyst);
}
.b06-masshaul__legend-item--longitudinal .b06-masshaul__legend-swatch {
border-top-color: var(--color-success);
}
.b06-masshaul__legend-item--soil_only .b06-masshaul__legend-swatch {
border-top-style: dashed;
}
.b06-masshaul__legend-item--natural .b06-masshaul__legend-swatch {
border-top-style: dotted;
}
.b06-masshaul__legend-item.is-off .b06-masshaul__legend-swatch {
border-top-color: var(--color-smoke);
}
.b06-masshaul__curve {
fill: none;
stroke: var(--color-royal-amethyst);
stroke-width: 2;
stroke-linejoin: round;
}
.b06-masshaul__curve--longitudinal {
stroke: var(--color-success);
}
/* 곡선과 0선 사이 — 절토 우세/성토 우세 구간을 한눈에 가르는 옅은 면.
여러 곡선에 겹쳐 칠하면 서로 가리므로 렌더러가 맨 앞 곡선 하나에만 붙인다. */
.b06-masshaul__band {
fill: var(--color-royal-amethyst);
opacity: 0.12;
stroke: none;
}
.b06-masshaul__band--longitudinal {
fill: var(--color-success);
}
/* 0선: 절토 우세와 성토 우세의 경계라 격자보다 진하게. 어떤 노선에서도 반드시 보인다.
**실선**이다(2026-08-02 사용자 지시) — 도면에서 파선은 장비 경계현의 몫이라,
0선까지 파선이면 둘이 구분되지 않는다. */
.b06-masshaul__zero {
stroke: var(--color-text-secondary);
stroke-width: 1.2;
}
.b06-masshaul__zero-tick {
fill: var(--color-text-secondary);
font-weight: var(--font-weight-medium);
}
/* 그래프 이름표 배경 — 이름이 곡선 위에 놓이므로 불투명하게 깐다. */
.b06-masshaul__name-plate {
fill: var(--color-surface);
stroke: none;
}
.b06-masshaul__summary {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--spacing-8) var(--spacing-16);
padding: var(--spacing-8) var(--spacing-16);
border-top: 1px solid var(--color-border);
color: var(--color-text-secondary);
font-size: var(--text-caption);
}
.b06-masshaul__chip {
display: inline-flex;
align-items: baseline;
gap: var(--spacing-8);
}
.b06-masshaul__chip em {
font-style: normal;
}
.b06-masshaul__chip strong {
color: var(--color-text-body);
font-variant-numeric: tabular-nums;
}
/* 요약 수치가 어느 곡선의 것인지 밝히는 꼬리표. */
.b06-masshaul__basis {
margin-inline-start: auto;
color: var(--color-text-muted);
}
/* 선택 측점이 곡선의 어디인지 찍는 점 — 말풍선과 시선을 잇는다. */
.b06-masshaul__focus {
fill: var(--color-royal-amethyst);
stroke: var(--color-surface);
stroke-width: 1.5;
}
/* 선택 측점 구간 물량 말풍선. 렌더러가 곡선이 지나지 않는 자리를 골라 놓지만, 배치가
빡빡한 화면에서도 곡선이 비쳐 보이도록 배경을 완전 불투명으로 두지 않는다. */
.b06-masshaul__callout > rect {
fill: color-mix(in srgb, var(--color-surface-raised) 94%, transparent);
stroke: var(--color-royal-amethyst);
stroke-width: 1;
}
.b06-masshaul__callout-tail {
fill: color-mix(in srgb, var(--color-surface-raised) 94%, transparent);
stroke: var(--color-royal-amethyst);
stroke-width: 1;
}
.b06-masshaul__callout-leader {
stroke: var(--color-royal-amethyst);
stroke-width: 1;
stroke-dasharray: 3 2;
}
.b06-masshaul__callout-text {
fill: var(--color-text-body);
font-size: 11px;
font-variant-numeric: tabular-nums;
}
/* =============================================================================
* 토량 분배(평형선) 레이어
*
* 곡선(보라)과 구분되도록 계열을 통째로 바꾼다: 평형선·운반 표시는 경고색(황토),
* 사토는 위험색, 토취는 성공색. 곡선 위에 겹치므로 선을 얇게 두고 balloon만 불투명하다.
* ========================================================================== */
/* 평형선(기선) — 블록마다 수평. **실선**이 기선의 표식이다(도면 A–A′). */
.b06-balance__line {
stroke: var(--color-warning);
stroke-width: 1.6;
}
/* 장비 경계현 — 그 현의 길이가 장비 경계거리와 같아지는 높이(도면 B–B′ / C–C′).
기선과 구분되도록 **파선**으로 둔다. 선타입이 곧 의미다. */
.b06-balance__chord {
stroke: var(--color-warning);
stroke-width: 1.2;
stroke-dasharray: 6 4;
}
/* 장비 띠 면 — 평소엔 클릭 대상으로만 남고, 눌렀을 때만 옅게 칠해진다. */
.b06-balance__band-face {
fill: transparent;
stroke: none;
cursor: pointer;
}
.b06-balance__band.is-active > .b06-balance__band-face {
fill: color-mix(in srgb, var(--color-warning) 22%, transparent);
}
/* balloon은 끌어 옮길 수 있다(더블클릭이면 자동 배치로 복귀) — 커서로 그 사실을 알린다. */
.b06-balance__balloon {
cursor: move;
}
.b06-balance__balloon.is-dragging {
cursor: grabbing;
}
.b06-balance__balloon.is-dragging > .b06-balance__balloon-shape {
stroke-width: 2;
}
/* 눌린 띠의 balloon은 테두리를 굵혀 어느 띠의 값인지 바로 짚이게 한다. */
.b06-balance__balloon.is-active > .b06-balance__balloon-shape {
fill: color-mix(in srgb, var(--color-warning) 20%, var(--color-surface-raised));
stroke-width: 2;
}
/* 계단의 수직 연결선 — 사토·토취가 나는 자리라 파선으로 "옮겨 갔음"을 드러낸다. */
.b06-balance__riser {
stroke: var(--color-warning);
stroke-width: 1.2;
stroke-dasharray: 3 2;
}
/* 평균운반거리 = 띠 중간 높이의 현. 경계현(파선)과 헷갈리지 않게 **점선**으로 가늘게.
화살촉이 운반 방향이다(산 = 좌→우, 골 = 우→좌). */
.b06-balance__haul {
stroke: var(--color-warning);
stroke-width: 1;
stroke-dasharray: 2 3;
}
.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;
}
/* 장거리 운반선 — 한 산의 현이 아니라 떨어진 두 지역을 잇는 선이라 **1점쇄선**으로 구분한다
(기선 실선 / 경계현 파선 / 평균운반거리 점선과 겹치지 않는 네 번째 선타입). */
.b06-balance__transfer-line {
stroke: var(--color-accent);
stroke-width: 1.4;
stroke-dasharray: 9 3 2 3;
}
.b06-balance__transfer-head {
fill: var(--color-accent);
stroke: none;
}
.b06-balance__transfer-text {
fill: var(--color-accent);
font-size: 9px;
font-variant-numeric: tabular-nums;
}
/* 사토·토취 단차. 남는 흙(사토)과 모자란 흙(토취)을 색으로 갈라 둔다. */
.b06-balance__step-arrow {
stroke-width: 1.4;
}
/* 색은 **선에는 stroke, 글자에는 fill**로만 준다 — 글자의 stroke는 아래 가독성 테두리
전용이라, 여기서 함께 칠하면 글자가 제 색으로 굵게 번진다. */
.b06-balance__residual--spoil .b06-balance__step-arrow {
stroke: var(--color-danger);
}
.b06-balance__residual--spoil .b06-balance__residual-text {
fill: var(--color-danger);
}
/* 토취는 **주황**이다(2026-08-02 사용자 확정: D안). 초록은 "정상"으로 읽혀,
흙이 모자라 사와야 한다는 경고 성격과 맞지 않았다. */
.b06-balance__residual--borrow .b06-balance__step-arrow {
stroke: var(--color-chart-2);
}
.b06-balance__residual--borrow .b06-balance__residual-text {
fill: var(--color-chart-2);
}
.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-chart-2);
stroke: none;
}
/* 사토·토취 라벨도 balloon과 같이 끌어 옮길 수 있다(도형은 두르지 않고 언더바만).
도형 배경이 없으므로 글자에 표면색 테두리를 둘러 곡선 위에서도 읽히게 한다. */
.b06-balance__residual.b06-balance__balloon {
cursor: move;
}
.b06-balance__residual-text {
paint-order: stroke;
stroke: var(--color-surface-raised);
stroke-width: 3;
stroke-linejoin: round;
font-size: 9px;
font-variant-numeric: tabular-nums;
}
/* 사토·토취는 도형 없이 언더바만 두른다(2026-08-02 사용자 지시). */
.b06-balance__residual--spoil .b06-balance__residual-underline {
stroke: var(--color-danger);
stroke-width: 1.2;
}
.b06-balance__residual--borrow .b06-balance__residual-underline {
stroke: var(--color-chart-2);
stroke-width: 1.2;
}
/* 범례에서 분배 레이어는 곡선이 아니라 평형선 계열임을 스와치로 알린다. */
.b06-masshaul__legend-item--balance .b06-masshaul__legend-swatch {
border-top-color: var(--color-warning);
border-top-style: dashed;
}
+473
View File
@@ -0,0 +1,473 @@
/* =============================================================================
* common_util_mass_haul.ts
* 유토곡선(Mass Haul Diagram) 계산 엔진 — 순수 함수, DOM 의존 없음.
*
* 구간 토량을 평균단면법으로 만들고 토량환산계수로 기준을 통일해 누가토량을 누적한다.
* 결과는 B08 수량산출·B09 견적이 그대로 재사용할 수 있도록 계산과 렌더링을 분리해
* 둔다(렌더러는 `common_util_mass_haul_view`).
*
* ── 계산 기준: 다짐상태 ──────────────────────────────────────────────
* "운반거리의 산정 시에 모든 수량은 다짐상태로 환산하여 계산하고, 내역서에 적용하는
* 수량은 자연상태로 한다." (2021년도 국도건설공사 설계실무 요령 / 표준품셈 계열)
* 유토곡선은 운반계획 도면이므로 다짐상태를 기준상태로 삼는다. 내역서용 자연상태
* 수량은 `cut_natural_m3`로 함께 보존해 B08이 되돌려 쓸 수 있게 한다.
*
* ── 두 기준(종단/횡단)을 함께 내는 이유 ──────────────────────────────
* [임도의 설계 및 시설기준] 2.다.(3)(나)는 "시공계획고는 절토량과 성토량이 균형을
* 이루게 하되"라고 정해 **종단 계획고**를 균형 판정 대상으로 삼는다. 반면 실제 물량은
* 같은 기준 2.다.(4)(마)의 측점별 횡단 단면적에서 나온다. 임도는 산복 사면을 따라가
* 편절·편성이 지배적이므로 두 기준이 크게 어긋날 수 있다. 어느 쪽을 정식 기준으로 삼을지
* 사용자가 그래프를 보고 판단할 수 있게, 두 기준을 같은 축에 겹쳐 그린다.
*
* ── 절토를 토사/암반으로 나눠 환산하는 이유 ──────────────────────────
* 같은 기준 2.다.(4)(다)는 "절토부분은 토사·암반으로 구분하되, 암반부분은 추정선으로
* 기입한다"고 정한다. 절토면적은 계수로 나누는 것이 아니라 **횡단도 기하로 갈린다** —
* 지표면~암반 경계선이 토사, 그 아래가 암이다. 엔진(`compute_cross_design()`)이 그 경계로
* 나눈 `cut_soil_area_m2` / `cut_rock_area_m2`를 각각 자기 환산계수로 곱해 합산한다.
* 참고 도면(3공구)의 작업별 EA:RR 비율이 34:66~71:29로 흩어지는 것이 이 구조의 근거다.
*
* 예전에 있던 비교군 2종(토사 단일 환산 / 환산 없음)은 걷어냈다(2026-08-02 사용자 지시).
* 절토면적이 암반 경계선으로 갈리기 전에는 환산계수 차이를 눈으로 대조할 필요가 있었지만,
* 이제 지형이 면적으로 갈려 나오므로 **부피비를 단일값이나 1로 밀어 넣은 곡선은 정보가 없다.**
*
* 분석 근거: docs/raw/2026-08-02_유토곡선_3공구_분석.md
* ========================================================================== */
import type {
EarthworkConversion,
GroundType,
MassHaulDesign,
MassHaulLongitudinal,
MassHaulSection,
} from "./common_util_mass_haul_types";
import type { HaulPlan } from "./common_util_mass_haul_balance";
import { haulPlanPayload } from "./common_util_mass_haul_balance";
/** 지반유형별 토량(㎥). 도면 표기 EA(토사)/RR(리핑암)/BR(발파암)에 대응한다. */
export interface GroundVolumes {
soil: number;
ripping_rock: number;
blasting_rock: number;
}
/** 측점 하나의 유토곡선 좌표. 구간 물량은 **직전 측점부터 이 측점까지**의 몫이다(첫 측점은 0). */
export interface MassHaulPoint {
station_id: string;
chainage_m: number;
/** 직전 측점부터 이 측점까지 구간의 순토량(㎥). 첫 측점은 0. */
net_volume_m3: number;
/** 시점부터의 누가토량(㎥). */
cumulative_volume_m3: number;
/** 구간 절토량(자연상태) — 토사분/암반분. 측점 선택 시 물량 표기에 그대로 쓴다. */
cut_soil_m3: number;
cut_rock_m3: number;
/**
* 암반분을 암종까지 가른 값(자연상태, `cut_rr_m3 + cut_br_m3 === cut_rock_m3`).
* 운반 블록의 EA/RR/BR 안분(`common_util_mass_haul_balance`)이 이 구간 단위를 되받아 쓴다 —
* 한 구간의 양 끝 측점이 서로 다른 암종일 수 있어 합계만으로는 되돌릴 수 없다.
*/
cut_rr_m3: number;
cut_br_m3: number;
/** 구간 절토량(환산 후) — 곡선에 실제로 들어간 값. */
cut_compacted_m3: number;
/** 구간 성토량(다짐상태 = 설계 물량). */
fill_m3: number;
/**
* 이 구간에서 자연방토가 가능한가 — 양 끝 측점이 **둘 다** 가능할 때만 참(보수적).
* 남는 흙을 성토사면으로 흘려보내 운반비를 세지 않을 수 있는지 판단하는 입력이다.
*/
natural_spoil: boolean;
/**
* 이 측점의 **순단면적**(㎡, 다짐환산) = 절토환산단면적 − 성토단면적.
*
* 유토곡선은 측점 사이에서 직선이 아니라 **포물선**이다 — 단면적이 선형으로 변하면
* 그 적분인 누가토량은 2차식이 된다. 평균운반거리(반종거 현)를 직선으로 재면 참고 도면 대비
* 약 40% 짧게 나와 장비 경계 판정이 어긋난다(도면의 20m 구간 반종거가 20/√2 = 14.14m).
* 이 값이 있어야 `common_util_mass_haul_balance`가 구간마다 2차식을 세울 수 있다.
*/
net_area_m2: number;
}
export interface MassHaulResult {
points: MassHaulPoint[];
/** 총 절토량(자연상태, 지반유형별) — 내역서 기준값. 환산 방식과 무관하게 실제 지반유형으로 쌓는다. */
cut_natural_m3: GroundVolumes;
/** 총 절토량(환산 후 합계) — 곡선 기준값. */
cut_compacted_m3: number;
/** 총 성토량(설계 성토량 = 다짐상태). */
fill_compacted_m3: number;
/** 최종 누가토량. 양수면 잉여(사토), 음수면 부족(토취). */
final_cumulative_m3: number;
/** 최종 잉여 토량(사토 대상, ㎥). 부족하면 0. */
surplus_m3: number;
/** 최종 부족 토량(토취 대상, ㎥). 잉여면 0. */
shortage_m3: number;
min_cumulative_m3: number;
max_cumulative_m3: number;
/** 계산에 실제 적용한 환산계수 — 나중에 검산할 수 있도록 함께 보존한다. */
conversion: EarthworkConversion;
}
/** 토량을 어디서 뽑았는지. `cross`가 정식(실측 단면적), `longitudinal`은 계획고 기반 개략값. */
export type MassHaulBasis = "cross" | "longitudinal";
export interface MassHaulSeries {
/** 표시 토글 저장 키이자 CSS 변형 클래스 접미사(= `basis`). */
key: MassHaulBasis;
basis: MassHaulBasis;
result: MassHaulResult;
}
const GROUND_TYPES: GroundType[] = ["soil", "ripping_rock", "blasting_rock"];
/** 곡선을 그릴 산출 기준. 배열 순서가 곧 범례 순서다. */
const SERIES_BASES: MassHaulBasis[] = ["cross", "longitudinal"];
/**
* 기본으로 켜 두는 곡선 — 횡단 기준만. 종단 기준은 계획고 기반 개략값이라 참고용이므로
* 사용자가 필요할 때 범례에서 켠다(2026-08-02 사용자 지시).
*/
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;
return Number.isFinite(factor) && factor > 0 ? factor : 1;
}
function emptyVolumes(): GroundVolumes {
return { soil: 0, ripping_rock: 0, blasting_rock: 0 };
}
function finiteArea(value: number | undefined): number {
return Number.isFinite(value) && (value as number) > 0 ? (value as number) : 0;
}
function normalizeGround(value: GroundType | undefined): GroundType {
return value && GROUND_TYPES.includes(value) ? value : "soil";
}
/**
* 적분 입력 한 점. 기준이 달라도 여기까지 오면 같은 코드를 탄다.
* 절토는 암반 경계선으로 이미 갈린 상태로 들어온다(토사분 / 암반분).
*/
interface AreaSample {
station_id: string;
chainage_m: number;
cut_soil_area_m2: number;
cut_rock_area_m2: number;
/** 암반분에 물릴 지반유형. 토사 측점은 null(암반분 0). */
rock_kind: GroundType | null;
fill_area_m2: number;
/** 이 측점에서 자연방토가 가능한가(성토측 지반이 판정 경사보다 가파른가). */
natural_spoil: boolean;
}
/**
* 엔진이 낸 절토 분리값을 읽는다. 구 데이터(분리 필드 없음)는 절토 전량을 측점
* 지반유형으로 돌려 **기존 단일 지반유형 환산과 같은 결과**를 유지한다.
*/
function splitCut(design: MassHaulDesign | undefined): {
soil: number;
rock: number;
rock_kind: GroundType | null;
} {
const ground = normalizeGround(design?.ground_type);
const total = finiteArea(design?.cut_area_m2);
if (design?.cut_soil_area_m2 === undefined || design?.cut_rock_area_m2 === undefined) {
return ground === "soil"
? { soil: total, rock: 0, rock_kind: null }
: { soil: 0, rock: total, rock_kind: ground };
}
return {
soil: finiteArea(design.cut_soil_area_m2),
rock: finiteArea(design.cut_rock_area_m2),
rock_kind: design.cut_rock_kind ?? (ground === "soil" ? null : ground),
};
}
/**
* 측점 하나의 순단면적(다짐환산 절토 − 성토). 구간 토량 `(a₀ + a₁)Δ/2`가 평균단면법과
* 정확히 같으므로, 이 값으로 세운 2차식은 지금 곡선을 그대로 통과한다.
*/
function sampleNetArea(sample: AreaSample, conversion: EarthworkConversion): number {
const soil = sample.cut_soil_area_m2 * factorFor(conversion, "soil");
const rock = sample.rock_kind
? sample.cut_rock_area_m2 * factorFor(conversion, sample.rock_kind)
: 0;
return soil + rock - sample.fill_area_m2;
}
/**
* 단면적 시퀀스를 누가토량으로 적분한다.
*
* 구간 토량은 평균단면법으로 구한다. 지반유형은 점마다 다를 수 있으므로, 각 점이 자기 쪽
* 절반(`단면적 × Δd / 2`)을 자기 지반유형으로 가져간다. 두 절반을 합치면
* `(A_i + A_i+1) / 2 × Δd`가 되어 평균단면법과 정확히 일치한다.
*/
function integrate(samples: AreaSample[], conversion: EarthworkConversion): MassHaulResult | null {
if (samples.length < 2) return null;
const points: MassHaulPoint[] = [
{
station_id: samples[0].station_id,
chainage_m: samples[0].chainage_m,
net_volume_m3: 0,
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,
natural_spoil: samples[0].natural_spoil,
net_area_m2: sampleNetArea(samples[0], conversion),
},
];
const cutNatural = emptyVolumes();
let cutCompacted = 0;
let fillCompacted = 0;
let cumulative = 0;
let minCumulative = 0;
let maxCumulative = 0;
for (let index = 1; index < samples.length; index += 1) {
const previous = samples[index - 1];
const current = samples[index];
const spanM = current.chainage_m - previous.chainage_m;
// 같은 위치에 점이 겹쳐 들어오면 구간 부피가 0이라 곡선에 기여하지 않는다.
if (!(spanM > 0)) continue;
const halfSpan = spanM / 2;
// 절토: 양 끝 점이 각자 절반씩 가져가고, 각 점 안에서 토사분·암반분이 따로 환산된다.
let segmentCut = 0;
let segmentCutSoil = 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;
if (soilM3 > 0) {
cutNatural.soil += soilM3;
segmentCutSoil += soilM3;
segmentCut += soilM3 * factorFor(conversion, "soil");
}
if (rockM3 > 0 && end.rock_kind) {
cutNatural[end.rock_kind] += rockM3;
if (end.rock_kind === "blasting_rock") segmentCutBlasting += rockM3;
else segmentCutRipping += rockM3;
segmentCut += rockM3 * factorFor(conversion, end.rock_kind);
}
}
// 성토: 설계 성토량이 곧 다짐상태 물량이라 환산하지 않는다.
const segmentFill = (previous.fill_area_m2 + current.fill_area_m2) * halfSpan;
const net = segmentCut - segmentFill;
cutCompacted += segmentCut;
fillCompacted += segmentFill;
cumulative += net;
minCumulative = Math.min(minCumulative, cumulative);
maxCumulative = Math.max(maxCumulative, cumulative);
points.push({
station_id: current.station_id,
chainage_m: current.chainage_m,
net_volume_m3: net,
cumulative_volume_m3: cumulative,
cut_soil_m3: segmentCutSoil,
cut_rock_m3: segmentCutRipping + segmentCutBlasting,
cut_rr_m3: segmentCutRipping,
cut_br_m3: segmentCutBlasting,
cut_compacted_m3: segmentCut,
fill_m3: segmentFill,
natural_spoil: previous.natural_spoil && current.natural_spoil,
net_area_m2: sampleNetArea(current, conversion),
});
}
if (points.length < 2) return null;
return {
points,
cut_natural_m3: cutNatural,
cut_compacted_m3: cutCompacted,
fill_compacted_m3: fillCompacted,
final_cumulative_m3: cumulative,
surplus_m3: Math.max(cumulative, 0),
shortage_m3: Math.max(-cumulative, 0),
min_cumulative_m3: minCumulative,
max_cumulative_m3: maxCumulative,
conversion,
};
}
/** 설계가 지정된 측점만 누가거리 순으로 모은다. 두 기준이 공통으로 쓰는 기준점 목록. */
function designedSections(crossSections: MassHaulSection[]): MassHaulSection[] {
return crossSections
.filter((section) => section.design && Number.isFinite(section.chainage_m))
.sort((a, b) => a.chainage_m - b.chainage_m);
}
/**
* 자연방토 가능 여부. 성토측 자연 지반이 판정 경사보다 가파르면 부어 놓은 흙이 쌓이지 않고
* 스스로 흘러내린다. 판정 경사(config)를 못 받았으면 **전부 불가**로 본다 — 프론트에 사본을
* 두지 않으며, 모르면 운반비를 세는 쪽이 안전하다.
*/
function naturalSpoilAllowed(design: MassHaulDesign | undefined, minSlope?: number): boolean {
if (!Number.isFinite(minSlope)) return false;
const slope = design?.fill_ground_slope;
return Number.isFinite(slope) && (slope as number) >= (minSlope as number);
}
/** 횡단 기준 — 엔진이 낸 실측 단면적을 그대로 쓴다. */
function crossAreaSamples(sections: MassHaulSection[], minSlope?: number): AreaSample[] {
return sections.map((section) => {
const cut = splitCut(section.design);
return {
station_id: section.station_id,
chainage_m: section.chainage_m,
cut_soil_area_m2: cut.soil,
cut_rock_area_m2: cut.rock,
rock_kind: cut.rock_kind,
fill_area_m2: finiteArea(section.design?.fill_area_m2),
natural_spoil: naturalSpoilAllowed(section.design, minSlope),
};
});
}
/**
* 종단 기준 — 계획고와 지반고의 차이(절토고/성토고)에 노반폭을 곱한 개략 단면적.
*
* `difference_m = 계획고 − 지반고`이므로 음수면 절토, 양수면 성토다
* (`B05_wf2_Route_UI_Profile_Alignment.ts`가 이미 쓰는 부호 규약과 같다).
* 노반폭과 지반유형은 종단 샘플에 없으므로 **가장 가까운 설계 측점**에서 가져온다.
* 설계된 측점이 하나도 없으면 노반폭을 알 수 없어 곡선을 만들지 않는다.
*
* 개략 단면적에는 암반 경계선이 없으므로, **가장 가까운 측점의 토사:암반 면적비**를 그대로
* 물려 두 기준이 같은 환산을 타게 한다(비율을 못 구하면 전량 토사로 둔다).
*/
function longitudinalAreaSamples(
longitudinal: MassHaulLongitudinal,
sections: MassHaulSection[],
): AreaSample[] {
const profile = longitudinal.design_profiles?.[0];
if (!profile || !sections.length) return [];
const samples = profile.samples
.filter((sample) => Number.isFinite(sample.chainage_m) && Number.isFinite(sample.difference_m))
.sort((a, b) => a.chainage_m - b.chainage_m);
if (samples.length < 2) return [];
// 두 배열 모두 누가거리 순이라 포인터 하나로 최근접 측점을 훑는다.
let cursor = 0;
return samples.map((sample, index) => {
while (
cursor + 1 < sections.length &&
Math.abs(sections[cursor + 1].chainage_m - sample.chainage_m) <=
Math.abs(sections[cursor].chainage_m - sample.chainage_m)
) {
cursor += 1;
}
const nearest = sections[cursor];
const width = finiteArea(nearest.design?.roadbed_width_m);
const height = sample.difference_m;
const cutArea = height < 0 ? -height * width : 0;
const cut = splitCut(nearest.design);
const total = cut.soil + cut.rock;
const rockRatio = total > 0 ? cut.rock / total : 0;
return {
station_id: `${nearest.station_id}@${index}`,
chainage_m: sample.chainage_m,
cut_soil_area_m2: cutArea * (1 - rockRatio),
cut_rock_area_m2: cutArea * rockRatio,
rock_kind: cut.rock_kind,
fill_area_m2: height > 0 ? height * width : 0,
natural_spoil: false,
};
});
}
/**
* 측점별 설계 결과에서 유토곡선을 계산한다(횡단 기준·지반유형별 환산).
* 확정 저장과 B08 인계에 쓰는 **정식 곡선**이라 시그니처를 그대로 유지한다.
*/
export function computeMassHaul(
crossSections: MassHaulSection[],
conversion: EarthworkConversion,
naturalSpoilMinSlope?: number,
): MassHaulResult | null {
return integrate(
crossAreaSamples(designedSections(crossSections), naturalSpoilMinSlope),
conversion,
);
}
/**
* 비교용 곡선 묶음. 산출 기준 2종(횡단/종단)을 계산한다.
* 계산이 불가능한 기준(설계 측점 부족, 종단 계획선 없음)은 조용히 빠진다.
*/
export function computeMassHaulSeries(
longitudinal: MassHaulLongitudinal,
crossSections: MassHaulSection[],
conversion: EarthworkConversion,
naturalSpoilMinSlope?: number,
): MassHaulSeries[] {
const sections = designedSections(crossSections);
const sampleSets: Record<MassHaulBasis, AreaSample[]> = {
cross: crossAreaSamples(sections, naturalSpoilMinSlope),
longitudinal: longitudinalAreaSamples(longitudinal, sections),
};
const series: MassHaulSeries[] = [];
for (const basis of SERIES_BASES) {
const result = integrate(sampleSets[basis], conversion);
if (result) series.push({ key: basis, basis, result });
}
return series;
}
/**
* 확정 시 DB(`longitudinal_sections.data.mass_haul`)에 넣을 직렬화 형태로 정리한다.
* 토량 분배(평형선)까지 냈으면 `haul_plan`으로 함께 실어 B08 수량·B09 견적이 되받게 한다.
*/
export function massHaulPayload(
result: MassHaulResult,
haulPlan?: HaulPlan | null,
balloonOffsets?: Record<string, [number, number]>,
): Record<string, unknown> {
const round = (value: number): number => Math.round(value * 100) / 100;
return {
basis: "compacted",
conversion: result.conversion,
...(haulPlan ? { haul_plan: haulPlanPayload(haulPlan) } : {}),
// 사용자가 끌어 옮긴 balloon 위치 — 비어 있어도 보낸다(초기화가 저장에 반영돼야 한다).
...(balloonOffsets ? { balloon_offsets: balloonOffsets } : {}),
cut_natural_m3: {
soil: round(result.cut_natural_m3.soil),
ripping_rock: round(result.cut_natural_m3.ripping_rock),
blasting_rock: round(result.cut_natural_m3.blasting_rock),
},
cut_compacted_m3: round(result.cut_compacted_m3),
fill_compacted_m3: round(result.fill_compacted_m3),
final_cumulative_m3: round(result.final_cumulative_m3),
surplus_m3: round(result.surplus_m3),
shortage_m3: round(result.shortage_m3),
points: result.points.map((point) => ({
station_id: point.station_id,
chainage_m: round(point.chainage_m),
net_volume_m3: round(point.net_volume_m3),
cumulative_volume_m3: round(point.cumulative_volume_m3),
// 구간 절토 내역(자연상태) — 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),
net_area_m2: round(point.net_area_m2),
fill_m3: round(point.fill_m3),
})),
};
}
@@ -0,0 +1,523 @@
/* =============================================================================
* common_util_mass_haul_balance.ts
* 토량 분배(평형선 · 운반 블록 · 장비 띠) 계산 엔진 — 순수 함수, DOM 의존 없음.
*
* 누가토량 곡선(`common_util_mass_haul`)은 "얼마가 남는가"만 말한다. 이 파일은 그 곡선에서
* **어디 흙을 어디로 얼마나 옮기는가**를 뽑아 낸다: 평형선 → 운반 블록 → 장비 띠 →
* 띠별 운반토량 `Q` · 평균운반거리 `L` · 지반유형 안분.
*
* ── 작도 규칙(참고자료) ─────────────────────────────────────────────
* <https://clouds-daily.tistory.com/249>
* - 상승 = 절토, 하강 = 성토. 극대·극소점 = 절·성토 경계.
* - 산 모양 = 좌→우 운반, 골 모양 = 우→좌 운반.
* - 평형선(수평선)이 곡선을 자르는 두 점 사이는 절토량 = 성토량.
* - 운반토량 = 평형선에서 곡선 정점까지의 종거.
* - 평균운반거리 = **종거의 1/2 지점을 지나는 수평선 B′C′의 길이**(반종거 수평선).
* - 배분 3원칙 = ① 운반거리 최소 ② 높은 곳 → 낮은 곳 ③ 토량을 모아 운반.
*
* ── 평형선을 어떻게 자동으로 잡는가 ─────────────────────────────────
* 참고자료는 "가장 유리한 평형점을 도상에서 구한다"까지만 말한다. **0선 하나로 고정하면
* 안 된다** — 임도는 절토가 크게 우세해 사토가 전부 종점 한 덩어리로 몰린다. 그래서 곡선을
* 왼쪽부터 훑으며 평형선 높이를 **계단으로 올린다**:
*
* 커서(곡선 위, 높이 = 현재 평형선)에서 다음 극값을 본다.
* - 올라갔다가(절토) 다시 현재 높이 **아래**까지 내려오면 → 성토가 절토를 다 받는다.
* 평형선은 그대로, 블록은 [커서 … 곡선이 평형선으로 되돌아온 점].
* - 올라갔다가 현재 높이 **위**에서 멈추면 → 남는 절토가 생긴다. 평형선을 그 골 높이로
* 올리고, 못 받은 만큼을 **그 자리에서** 사토로 계상한다.
* - 내려가는 경우는 정확히 대칭(토취).
*
* 이렇게 하면 ① 구간이 겹치거나 비지 않고 ② 사토·토취가 발생 지점에 남으며
* ③ 단차의 합이 최종 누가토량과 정확히 일치한다.
*
* ── 블록 하나를 장비 띠로 가르는 이유 ───────────────────────────────
* 참고 도면의 토량배분도에는 수평선이 블록마다 여러 개 그려져 있고(A–A′ / B–B′ / C–C′),
* 그 사이 띠마다 번호가 붙어 종단면의 해칭 구간과 1:1로 대응한다. 하단 브래킷
* (무대운반거리 / 도자 평균운반거리 / 덤프 평균운반거리)의 폭이 각 수평선의 길이와 같다.
*
* 즉 **수평선의 높이는 그 현(弦)의 길이가 장비 경계거리와 같아지는 높이**다. 누가토량
* 그래프에서 세로축이 곧 토량이므로, 두 수평선 사이 띠의 두께가 그 장비가 옮기는 토량이다.
* 현 길이 20m 되는 높이 위쪽 → 종무대
* 거기서 70m 되는 높이까지 → 도쟈
* 그 아래 ~ 평형선 → 덤프
* 경계값 정의처는 `config_system.EARTHWORK_HAUL_EQUIPMENT_LIMITS_M` 한 곳뿐이다.
*
* ── 단위 ─────────────────────────────────────────────────────────
* 곡선이 다짐상태 기준이므로 `Q`·EA/RR/BR 안분도 **다짐상태**다. 내역서용 자연상태 수량은
* `MassHaulResult.cut_natural_m3` / 측점별 `cut_soil_m3`·`cut_rr_m3`·`cut_br_m3`가 따로 든다.
* ========================================================================== */
import type { MassHaulPoint, MassHaulResult } from "./common_util_mass_haul";
import {
crossFrom,
EPSILON,
evaluate,
extremaIndices,
pruneExtrema,
segmentOf,
} from "./common_util_mass_haul_curve";
import {
apportion,
cutMix,
naturalSpoilRatio,
settleResiduals,
sortedLimits,
} from "./common_util_mass_haul_settle";
export { haulPlanPayload } from "./common_util_mass_haul_settle";
/** 운반거리 상한 하나(m). `max_distance_m: null`이면 상한 없음(나머지를 전부 받는다). */
export interface HaulEquipmentLimit {
key: string;
max_distance_m: number | null;
}
/** 운반 방향. 산 모양이면 좌→우(`forward`), 골 모양이면 우→좌(`backward`). */
export type HaulDirection = "forward" | "backward";
/** 곡선 위 한 점(누가거리 m, 누가토량 ㎥). 렌더러가 그대로 좌표로 옮긴다. */
export interface CurvePoint {
m: number;
v: number;
}
/** 블록 하나를 장비 경계현으로 가른 띠. 띠 두께가 곧 그 장비의 운반토량이다. */
export interface HaulBand {
/** 전체 통번호(1부터). 도면의 ①②③에 해당한다. */
index: number;
/** config 경계로 고른 장비 키. 경계값을 못 받으면 null(프론트에 사본을 두지 않는다). */
equipment: string | null;
/** 띠의 평형선 쪽 / 정점 쪽 경계 높이(누가토량 ㎥). */
level_base_m3: number;
level_apex_m3: number;
/** 이 띠의 운반토량(㎥, 다짐상태) = 두 경계의 차. */
volume_m3: number;
/** 평형선 쪽 경계현(= 이 장비의 경계거리와 길이가 같은 수평선)의 양 끝. */
boundary_from_m: number;
boundary_to_m: number;
/** 평균운반거리 `L`(m) = 띠 중간 높이 현의 길이. */
haul_distance_m: number;
haul_from_m: number;
haul_to_m: number;
/** `Q`를 절토 구간의 지반유형 구성비로 안분한 값(㎥). 합 = `volume_m3`. */
ea_m3: number;
rr_m3: number;
br_m3: number;
/** 띠를 감싸는 다각형(곡선 두 변 + 위아래 현). 클릭 대상이자 해칭 면이다. */
outline: CurvePoint[];
}
export interface HaulBlock {
/** 1부터. */
index: number;
/** 이 블록의 평형선 높이(누가토량 축, ㎥). */
base_m3: number;
/** 평형선이 곡선을 자르는 두 점(누가거리 m). */
from_m: number;
to_m: number;
/** 정점(극대/극소) 위치와 값. */
apex_m: number;
apex_volume_m3: number;
/** 블록 전체 운반토량 `Q`(㎥) = 종거. 띠 물량의 합과 같다. */
volume_m3: number;
direction: HaulDirection;
/** 장비 경계현으로 가른 띠. 정점 쪽(가까운 장비)부터 평형선 쪽 순으로 담긴다. */
bands: HaulBand[];
}
/** 받아 줄 짝이 없어 남은 토량. `spoil` = 사토(잉여), `borrow` = 토취(부족). */
export interface HaulResidual {
index: number;
kind: "spoil" | "borrow";
from_m: number;
to_m: number;
volume_m3: number;
/** 평형선이 이 잔량만큼 계단으로 옮겨 간다 — 렌더러가 단차를 그대로 그린다. */
level_from_m3: number;
level_to_m3: number;
/**
* 사토의 지반유형 안분(㎥). 사토는 절토에서 남은 흙이라 구성비를 물을 수 있다.
* **토취는 0이다** — 밖에서 사 오는 흙이라 이 노선의 지반유형이 없다.
*/
ea_m3: number;
rr_m3: number;
br_m3: number;
/**
* 사토 중 **자연방토**로 처리되는 몫(㎥) — 성토사면으로 흘려보내 운반비를 세지 않는다.
* 나머지(`volume_m3 − natural_m3`)는 사토장으로 실어 내야 한다.
*
* 참고 도면은 사토 balloon에 `L=`(운반거리) 없이 `M.N=측점`만 적는다. 그 노선의 사토가
* 이미 운반비 없이 처리됐다는 뜻이라, 프로그램도 **새 항목을 만들지 않고 사토 안에서**
* 가른다(2026-08-02 사용자 확정). 토취는 밖에서 사 오는 흙이라 해당 없음(0).
*/
natural_m3: number;
}
/**
* 떨어진 잉여 구간 → 부족 구간 **장거리 운반**. 참고 도면의 덤프 운반 중 L이 900~1000m대인
* 것들이 이것으로, 한 산의 현으로는 설명되지 않는다(노선 연장보다 긴 현은 없다).
*
* 배분 3원칙 ③ "토량을 모아 한 가지 방법으로 운반"이 바로 이 항목이다. 이것이 없으면
* 절토가 우세한 노선인데도 국지 부족이 그대로 토취로 계상돼, 사지 않아도 될 흙을 사게 된다.
*/
export interface HaulTransfer {
index: number;
/** 퍼오는 자리 / 채우는 자리(누가거리 m, 각 잔량 구간의 중앙). */
from_m: number;
to_m: number;
volume_m3: number;
/** 두 자리 사이 거리 = 이 운반의 `L`. */
haul_distance_m: number;
/** 연결 수평선을 그릴 높이(누가토량 ㎥). */
level_m3: number;
equipment: string | null;
ea_m3: number;
rr_m3: number;
br_m3: number;
}
/** 계단형 평형선 한 칸. 렌더러가 칸 사이를 수직선으로 이어 계단을 만든다. */
export interface BalanceStep {
from_m: number;
to_m: number;
level_m3: number;
}
export interface HaulPlan {
blocks: HaulBlock[];
residuals: HaulResidual[];
transfers: HaulTransfer[];
steps: BalanceStep[];
spoil_m3: number;
borrow_m3: number;
/** 사토 중 자연방토 몫(㎥) — 운반비를 세지 않는다. */
natural_spoil_m3: number;
/** 블록 안에서 옮기는 양(㎥). */
hauled_m3: number;
/** 떨어진 구간끼리 장거리로 옮기는 양(㎥). */
transferred_m3: number;
/**
* 총 성토량(㎥) — **검산 기준값**. 성토는 전부 운반으로 채워지므로
* `hauled_m3 + transferred_m3`가 이 값과 같아야 한다.
* 참고 도면에서 balloon 43개 합이 총 성토량과 소수점까지 일치하는 것이 근거다.
*/
fill_total_m3: number;
}
/**
* 이보다 작은 진동은 블록으로 세지 않는다. 측점 하나짜리 요철까지 블록을 만들면 balloon이
* 수십 개 깔려 도면을 못 읽는다. 곡선 진폭 대비 비율이라 노선 규모에 자동으로 맞는다.
*/
const MIN_SWING_RATIO = 0.02;
/** 경계현 높이를 훑을 때 쪼개는 칸 수. 잔요철에 속지 않을 만큼 촘촘해야 한다. */
const CHORD_SCAN_STEPS = 64;
/** 훑어서 찾은 칸 안에서 좁히는 이분법 반복 수. 24회면 칸 폭의 1/1600만 남는다. */
const CHORD_SOLVE_STEPS = 24;
/** 띠 테두리를 곡선(포물선)에 붙이려고 구간 하나를 쪼개는 수. */
const OUTLINE_STEPS = 4;
/** 한 블록을 장비 띠로 가르는 데 필요한 기하 계산 묶음. */
interface BlockGeometry {
/** `ratio`(0 = 평형선, 1 = 정점)에 해당하는 누가토량 높이. */
levelAt: (ratio: number) => number;
/** 그 높이 수평선이 블록 안에서 곡선을 자르는 두 점. */
chordAt: (ratio: number) => { from: number; to: number; length: number };
}
function blockGeometry(
points: MassHaulPoint[],
fromM: number,
toM: number,
apexM: number,
base: number,
apex: number,
): BlockGeometry {
const levelAt = (ratio: number): number => base + (apex - base) * ratio;
const chordAt = (ratio: number): { from: number; to: number; length: number } => {
const level = levelAt(ratio);
const from = crossFrom(points, fromM, apexM, level) ?? fromM;
const to = crossFrom(points, apexM, toM, level) ?? toM;
return { from, to, length: Math.max(to - from, 0) };
};
return { levelAt, chordAt };
}
/**
* 현의 길이가 `target` **이하로 확정되는** 가장 낮은 높이를 찾는다(반환값은 `ratio`).
*
* 현 길이는 평형선(가장 김)에서 정점(0)으로 갈수록 줄지만 **단조가 아니다** — 가지치기로
* 블록을 하나로 묶어도 그 안의 원래 폴리라인에는 잔요철이 남아, 높이를 올리는 도중 현이
* 잠깐 다시 길어질 수 있다. 이분법만 쓰면 그 지점에서 엉뚱한 근으로 수렴한다(무작위 검증에서
* 종무대 경계현이 45m로 잡히는 사례를 잡았다).
*
* 그래서 **훑기 → 이분법** 두 단계로 간다: 먼저 전 구간을 성기게 훑어 현이 `target`을 넘는
* **가장 높은** 자리를 찾고, 그 바로 위 칸에서만 이분법으로 좁힌다. 이렇게 잡은 경계 위쪽은
* 전부 `target` 이하가 보장된다 — "이 띠는 이 장비로 옮길 수 있다"가 성립해야 하기 때문이다.
* 평형선에서 이미 `target` 이하면 그 장비가 블록 전체를 맡는다 → 0.
*/
function ratioForChord(geometry: BlockGeometry, target: number): number {
let lastOver = -1;
for (let step = 0; step <= CHORD_SCAN_STEPS; step += 1) {
if (geometry.chordAt(step / CHORD_SCAN_STEPS).length > target) lastOver = step;
}
if (lastOver < 0) return 0;
if (lastOver >= CHORD_SCAN_STEPS) return 1;
let low = lastOver / CHORD_SCAN_STEPS;
let high = (lastOver + 1) / CHORD_SCAN_STEPS;
for (let step = 0; step < CHORD_SOLVE_STEPS; step += 1) {
const middle = (low + high) / 2;
if (geometry.chordAt(middle).length > target) low = middle;
else high = middle;
}
return high;
}
/**
* 띠를 감싸는 다각형.
*
* 예전에는 「아래 현 → 왼쪽 곡선 → 위 현 → 오른쪽 곡선」을 이어 붙였는데, 곡선이 두 현 사이에서
* 요동치면 그 조각이 띠 밖으로 삐져나가 **면이 스스로 겹쳤다**(2026-08-02 사용자 지적).
*
* 정의를 바꿨다. 띠는 「아래 현의 두 끝 사이에서, 아래 높이부터 **곡선과 위 높이 중 안쪽**까지」다.
* 아래 현을 밑변으로 깔고 그 위를 훑으며 곡선값을 두 높이 사이로 **자른 값**만 찍는다.
* 이러면 어떤 지형에서도 단순 다각형이라 스스로 겹칠 수가 없다.
*/
function bandOutline(
points: MassHaulPoint[],
low: { from: number; to: number },
lowLevel: number,
highLevel: number,
): CurvePoint[] {
const floor = Math.min(lowLevel, highLevel);
const ceil = Math.max(lowLevel, highLevel);
const outline: CurvePoint[] = [{ m: low.from, v: lowLevel }];
const push = (m: number, v: number): void => {
const previous = outline[outline.length - 1];
if (previous && Math.abs(previous.m - m) < 1e-9 && Math.abs(previous.v - v) < 1e-9) return;
outline.push({ m, v });
};
for (let index = 1; index < points.length; index += 1) {
const segment = segmentOf(points, index);
if (!segment) continue;
const start = Math.max(segment.x0, low.from);
const end = Math.min(segment.x0 + segment.span, low.to);
if (!(end > start)) continue;
for (let step = 0; step <= OUTLINE_STEPS; step += 1) {
const m = start + ((end - start) * step) / OUTLINE_STEPS;
push(m, Math.min(Math.max(evaluate(segment, m - segment.x0), floor), ceil));
}
}
push(low.to, lowLevel);
return outline;
}
/**
* 누가토량 곡선에서 토량 분배(평형선·운반 블록·장비 띠·사토/토취)를 뽑는다.
* 블록도 잔량도 안 나오면(평탄한 곡선) 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 tiers = sortedLimits(limits);
const blocks: HaulBlock[] = [];
const residuals: HaulResidual[] = [];
const steps: BalanceStep[] = [];
let bandCounter = 0;
const pushBlock = (
fromM: number,
toM: number,
base: number,
apexM: number,
apex: number,
direction: HaulDirection,
): void => {
const volume = Math.abs(apex - base);
if (!(volume > EPSILON) || !(toM > fromM)) return;
const geometry = blockGeometry(points, fromM, toM, apexM, base, apex);
// 정점 쪽(가까운 장비)부터 평형선 쪽으로 내려오며 띠를 자른다.
const bands: HaulBand[] = [];
let upper = 1;
for (const tier of tiers) {
if (upper <= EPSILON) break;
const lower = tier.max_distance_m === null ? 0 : ratioForChord(geometry, tier.max_distance_m);
if (upper - lower <= 1e-6) {
upper = lower;
continue;
}
const lowChord = geometry.chordAt(lower);
const highChord = geometry.chordAt(upper);
const haulChord = geometry.chordAt((lower + upper) / 2);
// 절토 쪽 변에서 잘라 낸 구간이 이 띠가 퍼오는 흙이다(산이면 왼쪽 변, 골이면 오른쪽 변).
const cutFrom = direction === "forward" ? lowChord.from : highChord.to;
const cutTo = direction === "forward" ? highChord.from : lowChord.to;
const bandVolume = (upper - lower) * volume;
bandCounter += 1;
bands.push({
index: bandCounter,
equipment: tier.key || null,
level_base_m3: geometry.levelAt(lower),
level_apex_m3: geometry.levelAt(upper),
volume_m3: bandVolume,
boundary_from_m: lowChord.from,
boundary_to_m: lowChord.to,
haul_distance_m: haulChord.length,
haul_from_m: haulChord.from,
haul_to_m: haulChord.to,
outline: bandOutline(points, lowChord, geometry.levelAt(lower), geometry.levelAt(upper)),
...apportion(cutMix(points, cutFrom, cutTo, result.conversion), bandVolume),
});
upper = lower;
}
blocks.push({
index: blocks.length + 1,
base_m3: base,
from_m: fromM,
to_m: toM,
apex_m: apexM,
apex_volume_m3: apex,
volume_m3: volume,
direction,
bands,
});
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;
const volume = Math.abs(delta);
residuals.push({
index: residuals.length + 1,
kind: delta > 0 ? "spoil" : "borrow",
from_m: fromM,
to_m: toM,
volume_m3: volume,
level_from_m3: from,
level_to_m3: to,
// 자연방토는 사토에만 해당한다 — 토취는 밖에서 사 오는 흙이다.
natural_m3: delta > 0 ? volume * naturalSpoilRatio(points, fromM, toM) : 0,
// 사토만 지반유형을 물을 수 있다(절토에서 남은 흙). 토취는 밖에서 사 온다.
...(delta > 0
? apportion(cutMix(points, fromM, toM, result.conversion), volume)
: { ea_m3: 0, rr_m3: 0, br_m3: 0 }),
});
};
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 = 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");
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");
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");
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;
const transfers = settleResiduals(points, residuals, result.conversion, limits);
const settled = residuals.filter((residual) => residual.volume_m3 > EPSILON);
settled.forEach((residual, index) => {
residual.index = index + 1;
});
let spoil = 0;
let borrow = 0;
let naturalSpoil = 0;
for (const residual of settled) {
if (residual.kind === "spoil") {
spoil += residual.volume_m3;
naturalSpoil += residual.natural_m3;
} else borrow += residual.volume_m3;
}
let fillTotal = 0;
for (const point of points) fillTotal += point.fill_m3;
return {
blocks,
residuals: settled,
transfers,
steps,
spoil_m3: spoil,
borrow_m3: borrow,
natural_spoil_m3: naturalSpoil,
hauled_m3: blocks.reduce((sum, block) => sum + block.volume_m3, 0),
transferred_m3: transfers.reduce((sum, entry) => sum + entry.volume_m3, 0),
fill_total_m3: fillTotal,
};
}
@@ -0,0 +1,520 @@
/* =============================================================================
* common_util_mass_haul_balance_view.ts
* 토량 분배(평형선 · 장비 띠) SVG 렌더러 — 유토곡선 위에 겹치는 레이어.
*
* 그리는 것:
* 1. 계단형 평형선 — 블록마다 수평(실선). 사토·토취가 나는 자리에서 계단으로 옮겨 간다.
* 2. 장비 띠 면 — 경계현 두 개와 곡선 두 변으로 둘러싸인 다각형. 클릭 대상이다.
* 3. 경계현(파선) — 그 현의 길이가 장비 경계거리와 같아지는 높이의 수평선.
* 4. 평균운반거리(점선 + 화살촉) — 띠 중간 높이의 현. 방향은 산=좌→우, 골=우→좌.
* 5. 장거리 운반선(1점쇄선) — 떨어진 잉여 ↔ 부족을 잇는 선.
* 6. balloon — 도형이 곧 운반수단이다: 종무대 육각 / 도쟈 원 / 덤프 사각.
* 사토·토취는 도형 없이 **언더바만**(2026-08-02 사용자 지시).
*
* 좌표 변환(x/y)과 그릴 상자는 유토곡선 렌더러가 넘겨준다 — 축을 두 번 정의하지 않는다.
* 계산은 전부 `common_util_mass_haul_balance`가 끝내 두므로 여기서는 배치만 판단한다.
* balloon의 도형·자리 찾기·드래그·위치 보관은 `common_util_mass_haul_balloon`이 맡는다.
* ========================================================================== */
import type {
HaulBand,
HaulBlock,
HaulPlan,
HaulResidual,
HaulTransfer,
} from "./common_util_mass_haul_balance";
import type { BalanceLayerBox, PlacedBox } from "./common_util_mass_haul_balloon";
import {
attachBalloonDrag,
balloonOffsets,
balloonShape,
compactDistance,
compactVolume,
EQUIPMENT_SHAPE,
equipmentLabel,
findSlot,
FULL_BALLOON_ROOM_PX,
LINE_HEIGHT,
MIN_BAND_THICKNESS_PX,
MIN_BAND_WIDTH_PX,
NOTCH,
PAD_X,
PAD_Y,
textWidth,
TWO_LINE_ROOM_PX,
} from "./common_util_mass_haul_balloon";
import { L, stationLabel, svgElement, svgText } from "./common_util_svg";
export type { BalanceLayerBox } from "./common_util_mass_haul_balloon";
export {
balloonOffsetsPayload,
configureBalloonOffsets,
resetBalloonOffsets,
} from "./common_util_mass_haul_balloon";
/**
* 옮긴 balloon이 그래프 밖으로 나가지 않게 이동량을 자른다. 화면을 줄이면 상자가 좁아져
* 예전에 저장해 둔 이동량이 밖을 가리킬 수 있으므로, 그릴 때마다 다시 자른다
* (2026-08-02 사용자 지적).
*/
function offsetClamper(
home: { x: number; y: number },
width: number,
height: number,
box: BalanceLayerBox,
): (dx: number, dy: number) => [number, number] {
return (dx, dy) => [
Math.min(Math.max(home.x + dx, box.left + width / 2), box.right - width / 2) - home.x,
Math.min(Math.max(home.y + dy, box.top + height / 2), box.bottom - height / 2) - home.y,
];
}
/** 계단형 평형선(기선) — 칸 사이는 수직선으로 이어 계단을 만든다. */
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 };
}
}
/** 띠 하나의 면·경계현·평균운반거리 선. 면은 클릭 대상이라 핸들을 돌려준다. */
function appendBandGeometry(
group: SVGGElement,
block: HaulBlock,
band: HaulBand,
box: BalanceLayerBox,
): SVGGElement {
const bandGroup = svgElement("g", {
class: `b06-balance__band b06-balance__band--${band.equipment ?? "unknown"}`,
});
bandGroup.append(
svgElement("polygon", {
points: band.outline.map((point) => `${box.x(point.m)},${box.y(point.v)}`).join(" "),
class: "b06-balance__band-face",
}),
);
const baseY = box.y(band.level_base_m3);
const apexY = box.y(band.level_apex_m3);
// 평형선과 겹치는 아래 경계현은 다시 긋지 않는다 — 기선은 이미 실선으로 깔려 있다.
if (Math.abs(band.level_base_m3 - block.base_m3) > 1e-6) {
bandGroup.append(
svgElement("line", {
x1: box.x(band.boundary_from_m),
y1: baseY,
x2: box.x(band.boundary_to_m),
y2: baseY,
class: "b06-balance__chord",
}),
);
}
// 평균운반거리 — 띠 중간 높이의 현. 띠가 얇으면 경계현과 붙어 버려 생략한다.
const from = box.x(band.haul_from_m);
const to = box.x(band.haul_to_m);
if (Math.abs(baseY - apexY) >= MIN_BAND_THICKNESS_PX && to - from >= 3) {
const midY = (baseY + apexY) / 2;
const tipX = block.direction === "forward" ? to : from;
const backX = block.direction === "forward" ? to - 6 : from + 6;
bandGroup.append(
svgElement("line", { x1: from, y1: midY, x2: to, y2: midY, class: "b06-balance__haul" }),
svgElement("polygon", {
points: `${tipX},${midY} ${backX},${midY - 3.5} ${backX},${midY + 3.5}`,
class: "b06-balance__haul-arrow",
}),
);
}
group.append(bandGroup);
return bandGroup;
}
/** 띠 balloon. 도형이 운반수단이고, 자리가 좁으면 줄을 줄여서라도 물량은 남긴다. */
function appendBandBalloon(
group: SVGGElement,
svg: SVGSVGElement,
block: HaulBlock,
band: HaulBand,
box: BalanceLayerBox,
placed: PlacedBox[],
): SVGGElement | null {
if (box.x(block.to_m) - box.x(block.from_m) < MIN_BAND_WIDTH_PX) return null;
const equipment = equipmentLabel(band.equipment);
const room = box.bottom - box.top;
// 자리가 나면 도면과 같은 항목별 세로 표기, 좁아지면 줄을 접는다.
const lines =
room >= FULL_BALLOON_ROOM_PX
? [
`${equipment || "-"} ${band.index}`,
`Q=${compactVolume(band.volume_m3)}㎥`,
`L=${compactDistance(band.haul_distance_m)}m`,
`EA=${compactVolume(band.ea_m3)}㎥`,
`RR=${compactVolume(band.rr_m3)}㎥`,
`BR=${compactVolume(band.br_m3)}㎥`,
]
: room >= TWO_LINE_ROOM_PX
? [
`${band.index} · ${compactVolume(band.volume_m3)}㎥`,
`L ${compactDistance(band.haul_distance_m)}m${equipment ? ` · ${equipment}` : ""}`,
]
: [`${band.index} · ${compactVolume(band.volume_m3)}㎥`];
const width = Math.max(...lines.map(textWidth)) + PAD_X * 2 + NOTCH;
const height = lines.length * LINE_HEIGHT + PAD_Y * 2;
const baseY = box.y(band.level_base_m3);
const apexY = box.y(band.level_apex_m3);
// 지시선은 **그 띠의 평균운반거리 수평선 한가운데**를 가리킨다. 정점(극값) 위치를 쓰면
// 측점선과 수평선이 만나는 자리를 찍게 되어 무엇을 가리키는지 읽히지 않는다
// (2026-08-02 사용자 지적). balloon이 설명하는 대상은 정점이 아니라 그 수평선이다.
const anchorX = box.x((band.haul_from_m + band.haul_to_m) / 2);
const anchorY = (baseY + apexY) / 2;
const upward = block.direction === "forward";
const home = findSlot(
placed,
anchorX,
Math.min(Math.max(anchorY, box.top + height / 2), box.bottom - height / 2),
width,
height,
box,
upward,
);
placed.push({
left: home.x - width / 2,
right: home.x + width / 2,
top: home.y - height / 2,
bottom: home.y + height / 2,
});
// 지시선은 balloon **밖**에 둔다 — 안에 두면 balloon을 옮길 때 지시선도 같이 끌려가
// 어느 띠의 것인지 가리키지 못한다.
const leader = svgElement("line", {
x1: anchorX,
y1: anchorY,
x2: home.x,
y2: home.y,
class: "b06-balance__leader",
});
const balloon = svgElement("g", { class: "b06-balance__balloon" });
const title = svgElement("title");
title.textContent =
`${L("B06_MassHaul_Block")} ${block.index} · ${equipment || "-"} · ` +
`${L("B06_MassHaul_HaulVolume")} ${compactVolume(band.volume_m3)}㎥ · ` +
`${L("B06_MassHaul_HaulDistance")} ${compactDistance(band.haul_distance_m)}m · ` +
`EA ${compactVolume(band.ea_m3)} / RR ${compactVolume(band.rr_m3)} / BR ${compactVolume(band.br_m3)}㎥`;
balloon.append(
title,
balloonShape(EQUIPMENT_SHAPE[band.equipment ?? ""] ?? "rect", home.x, home.y, width, height),
...lines.map((line, index) =>
svgText(line, {
x: home.x,
y: home.y - height / 2 + PAD_Y + LINE_HEIGHT * (index + 0.8),
"text-anchor": "middle",
class: "b06-balance__balloon-text",
}),
),
);
group.append(leader, balloon);
const clampOffset = offsetClamper(home, width, height, box);
const saved = balloonOffsets.get(band.index);
if (saved) {
const [dx, dy] = clampOffset(saved[0], saved[1]);
balloon.setAttribute("transform", `translate(${dx} ${dy})`);
leader.setAttribute("x2", String(home.x + dx));
leader.setAttribute("y2", String(home.y + dy));
}
attachBalloonDrag(balloon, leader, svg, band.index, home, clampOffset);
return balloon;
}
/**
* 사토·토취 — **balloon과 같은 규칙**으로 그린다(2026-08-02 사용자 확정: B + D안).
*
* 예전에는 평형선 단차 높이만큼 세로 화살표를 통째로 그려서, 단차가 크면 화살표가 그래프
* 높이를 거의 다 먹고 라벨이 곡선 한가운데 얹혔다. 이제 화살표는 **위치만 가리키는 짧은
* 표시**로 자르고, 수량 라벨은 balloon과 같은 자리 찾기·지시선·드래그를 쓴다.
* 도형은 두르지 않고 **언더바만** 남긴다 — 도형은 운반수단 표시 전용이기 때문이다.
*/
function appendResidual(
group: SVGGElement,
svg: SVGSVGElement,
residual: HaulResidual,
box: BalanceLayerBox,
placed: PlacedBox[],
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);
// 화살표는 **실제 단차 끝까지** 그린다 — 끝나는 높이가 새 평형선이라 그 자체가 정보다
// (2026-08-02 사용자 지시). 짧게 자르면 어디까지 옮겨 갔는지 읽을 수 없다.
// 대신 수량 라벨을 balloon처럼 떼어 놓아 곡선을 가리지 않게 했다.
const down = y2 > y1;
const tipY = Math.min(Math.max(y2, box.top + 2), box.bottom - 2);
// 도면은 사토 balloon에 거리(L)가 아니라 **측점**(M.N)을 적는다 — 사토는 운반이 아니라
// 그 자리 처리라 운반거리를 매기지 않기 때문이다(2026-08-02 도면 분석).
const at = (residual.from_m + residual.to_m) / 2;
const station = box.stationInterval ? stationLabel(at, box.stationInterval) : "-";
const kindLabel = L(residual.kind === "spoil" ? "B06_MassHaul_Surplus" : "B06_MassHaul_Shortage");
// 사토장으로 실어 내야 하는 몫(자연방토로 못 빠진 나머지).
const hauledOut = Math.max(residual.volume_m3 - residual.natural_m3, 0);
const label = `${kindLabel} ${compactVolume(residual.volume_m3)}㎥ · ${station}`;
const marker = svgElement("g", {
class: `b06-balance__residual b06-balance__residual--${residual.kind}`,
});
const title = svgElement("title");
title.textContent = label;
const head = down ? tipY - 6 : tipY + 6;
marker.append(
title,
svgElement("line", {
x1: centerX,
y1,
x2: centerX,
y2: tipY,
class: "b06-balance__step-arrow",
}),
svgElement("polygon", {
points: `${centerX},${tipY} ${centerX - 3.5},${head} ${centerX + 3.5},${head}`,
class: "b06-balance__step-head",
}),
);
group.append(marker);
// 좁은 그래프에서는 화살표만 남기고 수량은 툴팁으로 돌린다.
if (compact) return;
// 자리가 나면 도면 양식으로 편다. 사토는 `L=` 대신 **`M.N=측점`**이고, 토취는 밖에서
// 사 오는 흙이라 지반유형(EA/RR/BR)이 없어 세 줄을 뺀다.
const room = box.bottom - box.top;
const lines =
room >= FULL_BALLOON_ROOM_PX
? [
`${kindLabel} ${residual.index}`,
`Q=${compactVolume(residual.volume_m3)}㎥`,
// 도면과 같은 표기 — 사토에는 운반거리를 매기지 않고 발생 측점만 적는다.
`M.N=${station}`,
// 자연방토로 못 빠진 몫만 따로 밝힌다. 전량 자연방토면 이 줄이 없어 도면과 같다.
...(hauledOut > 0.5
? [`${L("B06_MassHaul_SpoilHauled")}=${compactVolume(hauledOut)}㎥`]
: []),
...(residual.kind === "spoil"
? [
`EA=${compactVolume(residual.ea_m3)}㎥`,
`RR=${compactVolume(residual.rr_m3)}㎥`,
`BR=${compactVolume(residual.br_m3)}㎥`,
]
: []),
]
: [label];
const width = Math.max(...lines.map(textWidth)) + PAD_X * 2;
const height = lines.length * LINE_HEIGHT + PAD_Y * 2;
const home = findSlot(
placed,
centerX,
tipY + (down ? height : -height),
width,
height,
box,
!down,
);
placed.push({
left: home.x - width / 2,
right: home.x + width / 2,
top: home.y - height / 2,
bottom: home.y + height / 2,
});
const leader = svgElement("line", {
x1: centerX,
y1: tipY,
x2: home.x,
y2: home.y,
class: "b06-balance__leader",
});
const text = svgElement("g", {
class: `b06-balance__residual b06-balance__residual--${residual.kind} b06-balance__balloon`,
});
const labelTitle = svgElement("title");
labelTitle.textContent = label;
const bottomY = home.y - height / 2 + PAD_Y + LINE_HEIGHT * (lines.length - 0.2);
text.append(
labelTitle,
...lines.map((line, index) =>
svgText(line, {
x: home.x,
y: home.y - height / 2 + PAD_Y + LINE_HEIGHT * (index + 0.8),
"text-anchor": "middle",
class: "b06-balance__residual-text",
}),
),
// 도형 대신 언더바만 두른다 — 도형은 운반수단 표시 전용이다.
svgElement("line", {
x1: home.x - width / 2 + PAD_X,
y1: bottomY,
x2: home.x + width / 2 - PAD_X,
y2: bottomY,
class: "b06-balance__residual-underline",
}),
);
group.append(leader, text);
// 잔량 라벨도 끌어 옮길 수 있다. 띠 번호와 겹치지 않게 **음수 키**를 쓴다.
const clampOffset = offsetClamper(home, width, height, box);
const saved = balloonOffsets.get(-residual.index);
if (saved) {
const [dx, dy] = clampOffset(saved[0], saved[1]);
text.setAttribute("transform", `translate(${dx} ${dy})`);
leader.setAttribute("x2", String(home.x + dx));
leader.setAttribute("y2", String(home.y + dy));
}
attachBalloonDrag(text, leader, svg, -residual.index, home, clampOffset);
}
/**
* 떨어진 잉여 → 부족 **장거리 운반**. 한 산의 현이 아니라 두 지역을 잇는 선이라
* 1점쇄선 + 양 끝 화살촉으로 구분한다. 길이가 곧 그 운반의 `L`이다.
*/
function appendTransfer(
group: SVGGElement,
transfer: HaulTransfer,
box: BalanceLayerBox,
compact: boolean,
): void {
const fromX = box.x(transfer.from_m);
const toX = box.x(transfer.to_m);
const y = Math.min(Math.max(box.y(transfer.level_m3), box.top + 4), box.bottom - 4);
const marker = svgElement("g", { class: "b06-balance__transfer" });
const title = svgElement("title");
const equipment = equipmentLabel(transfer.equipment);
title.textContent =
`${L("B06_MassHaul_Transfer")} ${transfer.index} · ${equipment || "-"} · ` +
`${L("B06_MassHaul_HaulVolume")} ${compactVolume(transfer.volume_m3)}㎥ · ` +
`${L("B06_MassHaul_HaulDistance")} ${compactDistance(transfer.haul_distance_m)}m · ` +
`EA ${compactVolume(transfer.ea_m3)} / RR ${compactVolume(transfer.rr_m3)} / BR ${compactVolume(transfer.br_m3)}㎥`;
const tip = toX >= fromX ? toX : toX;
const back = toX >= fromX ? toX - 7 : toX + 7;
marker.append(
title,
svgElement("line", { x1: fromX, y1: y, x2: toX, y2: y, class: "b06-balance__transfer-line" }),
svgElement("polygon", {
points: `${tip},${y} ${back},${y - 4} ${back},${y + 4}`,
class: "b06-balance__transfer-head",
}),
);
if (!compact) {
marker.append(
svgText(
`${compactVolume(transfer.volume_m3)}㎥ · ${compactDistance(transfer.haul_distance_m)}m${equipment ? ` · ${equipment}` : ""}`,
{
x: (fromX + toX) / 2,
y: y - 4,
"text-anchor": "middle",
class: "b06-balance__transfer-text",
},
),
);
}
group.append(marker);
}
/**
* 토량 분배 레이어를 유토곡선 SVG에 얹는다. 곡선·측점선 위, 선택 말풍선 아래에 놓아야
* 읽는 순서가 맞으므로 호출 위치를 옮기지 말 것.
*
* 띠 면을 누르면 그 balloon이 강조된다. 다른 띠나 빈 곳을 누르면 풀린다
* (2026-08-02 사용자 지시). 강조는 이 SVG 안에서만 사는 상태라 다시 그리면 초기화된다.
*/
export function appendBalanceLayer(svg: SVGSVGElement, plan: HaulPlan, box: BalanceLayerBox): void {
const group = svgElement("g", { class: "b06-balance" });
appendBalanceLine(group, plan, box);
const compact = box.bottom - box.top < 110;
for (const transfer of plan.transfers) appendTransfer(group, transfer, box, compact);
const faces: SVGGElement[] = [];
const balloons: Array<SVGGElement | null> = [];
for (const block of plan.blocks) {
for (const band of block.bands) faces.push(appendBandGeometry(group, block, band, box));
}
const placed: PlacedBox[] = [];
for (const block of plan.blocks) {
for (const band of block.bands)
balloons.push(appendBandBalloon(group, svg, block, band, box, placed));
}
// 잔량 라벨은 balloon **뒤에** 자리를 잡는다 — 같은 `placed`를 공유해 서로 겹치지 않는다.
for (const residual of plan.residuals) appendResidual(group, svg, residual, box, placed, compact);
const clearAll = (): void => {
for (const face of faces) face.classList.remove("is-active");
for (const balloon of balloons) balloon?.classList.remove("is-active");
};
// 띠 면과 balloon은 **같은 짝**이라 어느 쪽을 눌러도 둘 다 켜진다(2026-08-02 사용자 지시).
const bind = (target: SVGGElement | null, index: number): void => {
target?.addEventListener("click", (event) => {
// 카드·측점 선택까지 올라가면 그래프가 다시 그려져 방금 켠 강조가 사라진다.
event.stopPropagation();
const already = faces[index].classList.contains("is-active");
clearAll();
if (already) return;
faces[index].classList.add("is-active");
balloons[index]?.classList.add("is-active");
});
};
faces.forEach((face, index) => bind(face, index));
balloons.forEach((balloon, index) => bind(balloon, index));
// 띠 밖(그래프 빈 곳)을 누르면 강조를 푼다.
svg.addEventListener("click", clearAll);
svg.append(group);
}
/**
* 곡선 아래 요약줄에 덧붙일 토량 분배 수치.
* 사토·토취는 요약줄이 이미 곡선 기준으로 적고 있고 계단 단차의 합이 그 값과 같으므로
* 여기서 또 적지 않는다(같은 수치가 두 번 뜨면 서로 다른 값으로 오해한다).
*/
export function haulPlanChips(plan: HaulPlan | null): Array<[string, string]> {
if (!plan) return [];
const bands = plan.blocks.reduce((sum, block) => sum + block.bands.length, 0);
const moved = plan.hauled_m3 + plan.transferred_m3;
// 검산 — 성토는 **절토를 옮겨 온 것 + 사 온 것(토취)**으로만 채워진다:
// 운반(띠) + 장거리 운반 + 토취 = 총 성토량
// 참고 도면에서 balloon 43개 합이 총 성토량과 소수점까지 일치하는 것이 근거다
// (그 노선은 절토 우세라 토취가 0이었다).
const gap = moved + plan.borrow_m3 - plan.fill_total_m3;
const ratio = plan.fill_total_m3 > 0 ? Math.abs(gap) / plan.fill_total_m3 : 0;
const chips: Array<[string, string]> = [
[L("B06_MassHaul_HaulTotal"), `${compactVolume(moved)}㎥`],
[L("B06_MassHaul_BlockCount"), `${plan.blocks.length} / ${bands}`],
];
if (plan.transfers.length) {
chips.push([L("B06_MassHaul_Transfer"), `${compactVolume(plan.transferred_m3)}㎥`]);
}
if (plan.natural_spoil_m3 > 0.5) {
chips.push([L("B06_MassHaul_NaturalSpoil"), `${compactVolume(plan.natural_spoil_m3)}㎥`]);
}
chips.push([
L("B06_MassHaul_Check"),
ratio <= 0.005
? L("B06_MassHaul_Check_Ok")
: `${gap > 0 ? "+" : "−"}${compactVolume(Math.abs(gap))}㎥`,
]);
return chips;
}
@@ -0,0 +1,337 @@
/* =============================================================================
* common_util_mass_haul_balloon.ts
* 유토곡선 balloon의 공통 부품 — 좌표 상자 · 도형 · 자리 찾기 · 드래그 · 위치 보관.
*
* 토량 분배 렌더러(`_UI_MassHaul_Balance_View`)가 700줄을 넘겨, 「무엇을 그리나」(그쪽)와
* 「어떻게 놓고 어떻게 잡나」(여기)로 갈랐다. 띠 balloon과 사토·토취 라벨이 이 부품을
* **함께** 쓰므로 배치·드래그 규칙이 한 곳에서만 정의된다.
* ========================================================================== */
import { L } from "./common_util_svg";
import type { LocaleKey } from "@ui/ui_template_locale";
import { svgElement } from "./common_util_svg";
/** 유토곡선 렌더러가 넘겨주는 좌표계와 그릴 수 있는 상자(px). */
export interface BalanceLayerBox {
x: (chainageM: number) => number;
y: (volumeM3: number) => number;
left: number;
right: number;
top: number;
bottom: number;
/**
* 주어진 가로 구간에서 곡선이 실제로 지나는 세로 범위(없으면 null).
* balloon을 곡선과 겹치지 않는 자리에 놓기 위해 유토곡선 렌더러가 넘겨준다.
*/
curveYAt?: (fromPx: number, toPx: number) => { top: number; bottom: number } | null;
/** 사토·토취 발생 위치를 측점으로 적기 위한 측점 간격(m). */
stationInterval?: number;
}
export const EQUIPMENT_LABEL: Record<string, LocaleKey> = {
free_haul: "B06_MassHaul_Equip_FreeHaul",
dozer: "B06_MassHaul_Equip_Dozer",
dump_truck: "B06_MassHaul_Equip_Dump",
};
/** balloon 도형 = 운반수단. 모르는 장비는 사각으로 떨어뜨린다. */
export type BalloonShape = "hexagon" | "ellipse" | "rect";
export const EQUIPMENT_SHAPE: Record<string, BalloonShape> = {
free_haul: "hexagon",
dozer: "ellipse",
dump_truck: "rect",
};
/* balloon 치수 — 도면과 달리 화면 그래프는 200px 안팎이라 최대한 작게 잡는다
(2026-08-02 사용자 지적: 도형이 크고 빈자리를 못 찾는다). 자세한 값은 툴팁이 맡는다. */
export const LINE_HEIGHT = 10;
export const PAD_X = 5;
export const PAD_Y = 3;
/** 육각형 좌우 꼭짓점이 파고드는 깊이(px). */
export const NOTCH = 5;
/** balloon과 곡선 사이 최소 틈(px). */
export const CURVE_CLEARANCE = 5;
/** 이보다 좁은 띠에는 balloon을 달지 않는다 — 서로 겹쳐 도면을 못 읽는다. */
export const MIN_BAND_WIDTH_PX = 34;
/**
* 그래프가 이만큼 높으면 balloon을 **도면 양식(항목별 세로 표기)**으로 편다
* (2026-08-02 사용자 지시). 도면 balloon이 `종무대 1 / Q= / L= / EA= / RR= / BR=` 6줄이다.
* 기본 패널 높이(유토곡선 190px)에서 바로 이 양식이 나온다.
*/
export const FULL_BALLOON_ROOM_PX = 165;
/** 그보다 낮으면 두 줄, 더 낮으면 물량 한 줄만 남긴다. */
export const TWO_LINE_ROOM_PX = 120;
/** 이보다 얇은 띠에는 평균운반거리 선을 긋지 않는다(경계현과 붙어 한 줄로 보인다). */
export const MIN_BAND_THICKNESS_PX = 9;
/**
* 사용자가 끌어 옮긴 balloon 위치(띠 번호 → [dx, dy]).
*
* 저장 경로가 둘이다:
* ① **프론트 캐시**(localStorage) — 끌어 옮기는 즉시. 새로고침·재접속에도 남는다.
* ② **영구저장소** — 횡단 확정 시점에 `mass_haul.balloon_offsets`로 넘어간다.
* 진입할 때는 ②가 있으면 그것으로 캐시를 덮어써 **다른 브라우저에서도 같은 자리**에 뜬다
* (2026-08-02 사용자 지시). 캐시는 경로별로 갈라 다른 노선의 위치가 섞이지 않게 한다.
*/
const BALLOON_OFFSET_PREFIX = "b06:balance-balloon-offset";
let offsetStorageKey = BALLOON_OFFSET_PREFIX;
export let balloonOffsets = new Map<number, [number, number]>();
function parseOffsets(source: unknown): Map<number, [number, number]> {
const result = new Map<number, [number, number]>();
if (!source || typeof source !== "object") return result;
for (const [key, value] of Object.entries(source as Record<string, unknown>)) {
if (!Array.isArray(value) || value.length !== 2) continue;
const [dx, dy] = value as [number, number];
if (Number.isFinite(dx) && Number.isFinite(dy)) result.set(Number(key), [dx, dy]);
}
return result;
}
export function writeOffsets(): void {
try {
localStorage.setItem(offsetStorageKey, JSON.stringify(balloonOffsetsPayload()));
} catch {
/* 저장 실패는 무시 — 위치는 화면이 살아 있는 동안 유지된다. */
}
}
/**
* 노선이 바뀌거나 상세를 새로 받았을 때 캐시를 맞춘다.
* `stored`(영구저장소 값)가 있으면 **그것이 이긴다** — 다른 브라우저에서 옮긴 자리를 그대로
* 받아야 하기 때문이다. 없으면 이 브라우저의 캐시를 그대로 이어 쓴다.
*/
export function configureBalloonOffsets(scope: string, stored?: unknown): void {
offsetStorageKey = `${BALLOON_OFFSET_PREFIX}:${scope}`;
if (stored && Object.keys(stored as object).length) {
balloonOffsets = parseOffsets(stored);
writeOffsets();
return;
}
try {
balloonOffsets = parseOffsets(JSON.parse(localStorage.getItem(offsetStorageKey) ?? "null"));
} catch {
balloonOffsets = new Map();
}
}
/** 확정 시 영구저장소로 넘길 형태. */
export function balloonOffsetsPayload(): Record<string, [number, number]> {
return Object.fromEntries([...balloonOffsets].map(([key, value]) => [String(key), value]));
}
/** 자동 배치로 되돌린다 — 프론트 캐시를 비우고, 다음 확정 때 영구저장소도 빈 값으로 덮인다. */
export function resetBalloonOffsets(): void {
balloonOffsets = new Map();
try {
localStorage.removeItem(offsetStorageKey);
} catch {
/* 무시 */
}
}
/** SVG는 자동 크기가 없어 폭을 어림해야 한다. 한글은 라틴 글자보다 넓다. */
export function textWidth(value: string): number {
let width = 0;
for (const char of value) width += /[가-힣ㄱ-ㅎㅏ-ㅣ]/.test(char) ? 9.5 : 5.2;
return width;
}
/** balloon 안 수치는 자리를 아껴야 한다 — 천 단위 구분만 두고 소수점은 버린다. */
export function compactVolume(value: number): string {
return Math.round(value).toLocaleString();
}
export function compactDistance(value: number): string {
return value >= 100 ? `${Math.round(value)}` : value.toFixed(1);
}
export function equipmentLabel(key: string | null): string {
if (!key) return "";
const locale = EQUIPMENT_LABEL[key];
return locale ? L(locale) : key;
}
/** 육각형 — 좌우 끝이 뾰족한 도면용 모양. */
export 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(" ");
}
export function balloonShape(
shape: BalloonShape,
cx: number,
cy: number,
width: number,
height: number,
): SVGElement {
if (shape === "ellipse") {
// 원형은 같은 글자를 담으려면 사각보다 커야 한다 — 모서리가 비기 때문이다.
return svgElement("ellipse", {
cx,
cy,
rx: width / 2 + 5,
ry: height / 2 + 3,
class: "b06-balance__balloon-shape",
});
}
if (shape === "rect") {
return svgElement("rect", {
x: cx - width / 2,
y: cy - height / 2,
width,
height,
rx: 2,
ry: 2,
class: "b06-balance__balloon-shape",
});
}
return svgElement("polygon", {
points: hexagonPoints(cx, cy, width, height),
class: "b06-balance__balloon-shape",
});
}
export interface PlacedBox {
left: number;
right: number;
top: number;
bottom: number;
}
export function overlaps(a: PlacedBox, b: PlacedBox): boolean {
return !(a.right <= b.left || a.left >= b.right || a.bottom <= b.top || a.top >= b.bottom);
}
/** 이 자리에 놓으면 곡선과 겹치는가. 곡선 정보를 못 받았으면 겹치지 않는 것으로 본다. */
export function hitsCurve(box: BalanceLayerBox, candidate: PlacedBox): boolean {
const band = box.curveYAt?.(candidate.left, candidate.right);
if (!band) return false;
return !(
candidate.bottom + CURVE_CLEARANCE <= band.top || candidate.top - CURVE_CLEARANCE >= band.bottom
);
}
/**
* 빈자리를 찾는다. 선호 지점에서 **가로·세로 양쪽으로** 후보를 넓혀 가며, 이미 놓인 balloon과
* 겹치지 않고 곡선도 피하는 첫 자리를 고른다(2026-08-02 사용자 지적: 남는 공간을 못 찾음).
*
* 곡선까지 피하는 자리가 끝내 없으면 **곡선 조건만 버리고** 다시 훑는다 — 겹치더라도 놓는 게
* 안 그리는 것보다 낫고, 사용자가 끌어 옮길 수 있다.
*/
export function findSlot(
placed: PlacedBox[],
preferredX: number,
preferredY: number,
width: number,
height: number,
box: BalanceLayerBox,
upward: boolean,
): { x: number; y: number } {
const stepY = height + 3;
const stepX = width * 0.55;
const clampX = (value: number): number =>
Math.min(Math.max(value, box.left + width / 2), box.right - width / 2);
const candidates: Array<{ x: number; y: number }> = [];
for (let ring = 0; ring < 7; ring += 1) {
for (const dy of ring === 0 ? [0] : upward ? [-ring, ring] : [ring, -ring]) {
for (const dx of ring === 0 ? [0] : [0, -1, 1, -2, 2]) {
candidates.push({ x: clampX(preferredX + dx * stepX), y: preferredY + dy * stepY });
}
}
}
for (const avoidCurve of [true, false]) {
for (const candidate of candidates) {
if (candidate.y - height / 2 < box.top || candidate.y + height / 2 > box.bottom) continue;
const rect: PlacedBox = {
left: candidate.x - width / 2,
right: candidate.x + width / 2,
top: candidate.y - height / 2,
bottom: candidate.y + height / 2,
};
if (placed.some((entry) => overlaps(entry, rect))) continue;
if (avoidCurve && hitsCurve(box, rect)) continue;
return candidate;
}
}
// 빈자리를 못 찾아도 **상자 안에는** 넣는다 — 안 그러면 라벨이 그래프 밖으로 나간다
// (2026-08-02 사용자 지적).
return {
x: clampX(preferredX),
y: Math.min(Math.max(preferredY, box.top + height / 2), box.bottom - height / 2),
};
}
/**
* balloon을 끌어 옮길 수 있게 한다. 자동 배치가 아무리 좋아도 200px 높이 그래프에서는
* 빈자리가 모자라므로, 마지막 판단은 사용자에게 맡긴다(2026-08-02 사용자 지시).
* 옮긴 만큼 지시선 끝점도 따라가고, 놓은 자리는 세션에 남는다.
*/
export function attachBalloonDrag(
balloon: SVGGElement,
leader: SVGLineElement,
svg: SVGSVGElement,
key: number,
home: { x: number; y: number },
/** 옮긴 자리를 그릴 수 있는 범위 안으로 자르는 함수. 없으면 자르지 않는다. */
clampOffset?: (dx: number, dy: number) => [number, number],
): void {
let start: { x: number; y: number; dx: number; dy: number } | null = null;
const scale = (): number => {
const rendered = svg.getBoundingClientRect().width;
const viewBox = svg.viewBox.baseVal?.width || rendered;
return rendered > 0 && viewBox > 0 ? viewBox / rendered : 1;
};
const clampTo = (dx: number, dy: number): [number, number] =>
clampOffset ? clampOffset(dx, dy) : [dx, dy];
const apply = (dx: number, dy: number): void => {
balloon.setAttribute("transform", `translate(${dx} ${dy})`);
leader.setAttribute("x2", String(home.x + dx));
leader.setAttribute("y2", String(home.y + dy));
};
balloon.addEventListener("pointerdown", (event) => {
if (event.button !== 0) return;
// 카드·측점 선택으로 번지면 그래프가 다시 그려져 끌던 balloon이 사라진다.
event.stopPropagation();
event.preventDefault();
const current = balloonOffsets.get(key) ?? [0, 0];
start = { x: event.clientX, y: event.clientY, dx: current[0], dy: current[1] };
balloon.setPointerCapture(event.pointerId);
balloon.classList.add("is-dragging");
});
balloon.addEventListener("pointermove", (event) => {
if (!start) return;
const factor = scale();
const [dx, dy] = clampTo(
start.dx + (event.clientX - start.x) * factor,
start.dy + (event.clientY - start.y) * factor,
);
balloonOffsets.set(key, [dx, dy]);
apply(dx, dy);
});
const finish = (event: PointerEvent): void => {
if (!start) return;
start = null;
balloon.releasePointerCapture(event.pointerId);
balloon.classList.remove("is-dragging");
writeOffsets();
};
balloon.addEventListener("pointerup", finish);
balloon.addEventListener("pointercancel", finish);
// 두 번 누르면 자동 배치로 되돌린다 — 잘못 끌었을 때 되돌릴 길을 남긴다.
balloon.addEventListener("dblclick", (event) => {
event.stopPropagation();
balloonOffsets.delete(key);
apply(0, 0);
writeOffsets();
});
}
+166
View File
@@ -0,0 +1,166 @@
/* =============================================================================
* common_util_mass_haul_curve.ts
* 누가토량 곡선의 기하 — 포물선 보간 · 수평선 교점 · 극값 추출.
*
* ── 측점 사이를 무엇으로 잇는가: **직선**(2026-08-02 사용자 확정) ──────────
* 이론상으로는 포물선이다 — 평균단면법 `V = (A₁+A₂)/2 × L`이 "구간 내 단면적이 선형으로
* 변한다"를 전제하므로, 그 적분인 누가토량은 2차식이 된다. 참고 도면의 종무대 평균운반거리에
* `20/√2 = 14.14m`가 12번 찍히는 것도 포물선 + "종거 1/2 수평선" 규칙으로만 설명된다
* (직선이면 `20/2 = 10m`).
*
* 그럼에도 **직선을 쓴다.** 측점을 기준으로 절·성토량을 읽고 판단하는 것이 실무이고, 도면도
* 직선으로 그린다는 사용자(임도설계 실무자) 판단이다. 대가는 평균운반거리가 포물선 대비
* 약 40% 짧게 나오는 것이며, 이는 20m·70m 장비 경계 판정에 직접 영향을 준다.
*
* 되돌릴 일이 생기면 `PARABOLIC_MASS_CURVE` 하나만 켜면 된다 — 그리기·재기가 함께 바뀐다.
*
* 토량 분배 계산(`common_util_mass_haul_balance`)이 700줄을 넘겨 곡선 기하만 여기로 떼어냈다.
* ========================================================================== */
import type { MassHaulPoint } from "./common_util_mass_haul";
export const EPSILON = 1e-9;
/**
* 누가토량 곡선을 측점 사이에서 포물선으로 볼지 여부. **false = 직선**(사용자 확정).
* 이 값 하나가 곡선 렌더링(`curvePath`)과 수평선 교점(`crossFrom`)을 함께 바꾼다 —
* 둘이 어긋나면 수평선 끝이 곡선에서 떠 보인다(2026-08-02에 이미 겪은 문제).
*/
export const PARABOLIC_MASS_CURVE = false;
/**
* 구간 하나의 2차식 계수. 측점 사이에서 단면적이 선형으로 변하므로 누가토량은
* `V(t) = V₀ + a₀·t + (a₁ − a₀)·t² / (2Δ)` (t = 구간 시작으로부터의 거리)
* 이다. `V(Δ) = V₀ + (a₀ + a₁)Δ/2`라 평균단면법 결과와 정확히 같다.
*
* 순단면적이 없는 데이터(옛 payload·종단 기준 개략값)는 구간 평균 단면적을 양 끝에 같이
* 넣어 자동으로 직선으로 떨어진다.
*/
export interface Segment {
x0: number;
span: number;
v0: number;
a0: number;
curvature: number;
}
export function segmentOf(points: MassHaulPoint[], index: number): Segment | null {
const a = points[index - 1];
const b = points[index];
const span = b.chainage_m - a.chainage_m;
if (!(span > 0)) return null;
const average = (b.cumulative_volume_m3 - a.cumulative_volume_m3) / span;
const start = Number.isFinite(a.net_area_m2) ? a.net_area_m2 : average;
const end = Number.isFinite(b.net_area_m2) ? b.net_area_m2 : average;
// 포물선이 **구간 끝값을 정확히 통과하도록** 두 단면적을 같은 양만큼 옮긴다.
// `V(Δ) = V₀ + (a₀+a₁)Δ/2`이므로 평균이 어긋나면 끝값이 어긋나고, 그러면 수평선 교점을
// 못 찾아 블록이 엉뚱하게 길어진다(도면 곡선 검증에서 성토 1455㎥가 한 블록에 묻혔다).
// 곡률 `(a₁−a₀)`는 그대로라 포물선 모양은 보존된다.
const shift = average - (start + end) / 2;
return {
x0: a.chainage_m,
span,
v0: a.cumulative_volume_m3,
// 직선 모드에서는 구간 평균 단면적 하나로 기울기를 잡는다 → 곡률 0 = 직선.
a0: PARABOLIC_MASS_CURVE ? start + shift : average,
curvature: PARABOLIC_MASS_CURVE ? (end - start) / (2 * span) : 0,
};
}
export function evaluate(segment: Segment, t: number): number {
return segment.v0 + segment.a0 * t + segment.curvature * t * t;
}
/** 구간 2차식이 `[tLo, tHi]` 안에서 `level`을 처음 지나는 t. 없으면 null. */
function solveSegment(segment: Segment, level: number, tLo: number, tHi: number): number | null {
const c = segment.v0 - level;
const roots: number[] = [];
if (Math.abs(segment.curvature) < 1e-12) {
if (Math.abs(segment.a0) > EPSILON) roots.push(-c / segment.a0);
} else {
const discriminant = segment.a0 * segment.a0 - 4 * segment.curvature * c;
if (discriminant < 0) return null;
const root = Math.sqrt(discriminant);
roots.push((-segment.a0 - root) / (2 * segment.curvature));
roots.push((-segment.a0 + root) / (2 * segment.curvature));
}
const inside = roots
.filter((t) => t >= tLo - 1e-9 && t <= tHi + 1e-9)
.sort((left, right) => left - right);
return inside.length ? Math.min(Math.max(inside[0], tLo), tHi) : null;
}
/**
* `[fromX, toX]`를 왼쪽부터 훑어 곡선이 `level`을 **처음** 지나는 x. 없으면 null.
* 평형선·경계현·반종거 수평선의 교점이 전부 이 함수 하나로 나온다.
*/
export 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 segment = segmentOf(points, index);
if (!segment) continue;
const x0 = Math.max(segment.x0, fromX);
const x1 = Math.min(segment.x0 + segment.span, toX);
if (!(x1 > x0)) continue;
const t = solveSegment(segment, level, x0 - segment.x0, x1 - segment.x0);
if (t !== null) return segment.x0 + t;
// 구간 끝에서 부호가 바뀌었는데 2차 근을 못 찾으면(수치 한계) 직선으로 떨어뜨린다.
// 교점을 놓치면 블록 경계가 통째로 밀리므로 여기서 반드시 하나는 돌려줘야 한다.
const y0 = evaluate(segment, x0 - segment.x0);
const y1 = evaluate(segment, x1 - segment.x0);
if ((y0 - level) * (y1 - level) <= 0) {
if (Math.abs(y1 - y0) < EPSILON) return x0;
return x0 + ((level - y0) / (y1 - y0)) * (x1 - x0);
}
}
return null;
}
/** 곡선의 극값 인덱스 수열(양 끝점 포함). 기울기 부호가 바뀌는 자리가 극값이다. */
export 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` 미만인 극값은 **짝이 되는 이웃과 함께** 지운다 —
* 하나만 지우면 극대·극소 교대가 깨져 뒤 계산이 방향을 잃는다. 양 끝점은 남긴다.
*/
export 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;
}
+223
View File
@@ -0,0 +1,223 @@
/* =============================================================================
* common_util_mass_haul_settle.ts
* 토량 분배의 **정산** 쪽 — 지반유형 안분 · 장비 선정 · 잉여/부족 상쇄 · 직렬화.
*
* `common_util_mass_haul_balance`가 700줄을 넘겨, 「곡선에서 블록·띠를 어떻게 뽑나」(그쪽)와
* 「뽑은 물량을 무엇으로 나누고 어디로 보내나」(여기)로 갈랐다.
*
* 의존 방향은 **한 쪽뿐**이다: Balance → Settle(함수), Settle → Balance(타입만).
* 타입 import는 실행 시 사라지므로 순환이 생기지 않는다.
* ========================================================================== */
import type { EarthworkConversion, GroundType } from "./common_util_mass_haul_types";
import type { MassHaulPoint } from "./common_util_mass_haul";
import type {
HaulBand,
HaulEquipmentLimit,
HaulPlan,
HaulResidual,
HaulTransfer,
} from "./common_util_mass_haul_balance";
import { EPSILON } from "./common_util_mass_haul_curve";
/** 환산계수가 비어 있거나 값이 이상하면 1.0으로 떨어뜨려 계산이 멈추지 않게 한다. */
function factorFor(conversion: EarthworkConversion, ground: GroundType): number {
const factor = conversion?.[ground]?.compacted;
return Number.isFinite(factor) && factor > 0 ? factor : 1;
}
export interface CutMix {
ea: number;
rr: number;
br: number;
}
/**
* `[fromX, toX]` 절토 구간의 지반유형 구성(다짐상태 가중치). 구간이 측점 사이를 반만
* 물면 그 비율만큼만 센다 — 경계현 교점은 측점에 딱 떨어지지 않는다.
*/
export 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;
}
/**
* `[fromX, toX]` 중 자연방토가 가능한 구간의 **길이 비율**(0~1).
* 구간마다 양 끝 측점이 둘 다 가능해야 그 구간을 인정한다(`natural_spoil`).
*/
export function naturalSpoilRatio(points: MassHaulPoint[], fromX: number, toX: number): number {
let total = 0;
let allowed = 0;
for (let index = 1; index < points.length; index += 1) {
const a = points[index - 1].chainage_m;
const b = points[index].chainage_m;
const overlap = Math.min(b, toX) - Math.max(a, fromX);
if (overlap <= 0) continue;
total += overlap;
if (points[index].natural_spoil) allowed += overlap;
}
return total > 0 ? allowed / total : 0;
}
/** 구성비로 물량을 안분한다. 구간에 절토 기록이 전혀 없으면 지반유형 미상 = 토사로 둔다. */
export function apportion(
mix: CutMix,
volume: number,
): Pick<HaulBand, "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,
};
}
/** 거리 짧은 장비부터. `max_distance_m: null`(상한 없음)은 항상 맨 뒤. */
export function sortedLimits(limits: HaulEquipmentLimit[] | undefined): HaulEquipmentLimit[] {
if (!limits?.length) return [{ key: "", max_distance_m: null }];
return [...limits].sort((a, b) => {
if (a.max_distance_m === null) return 1;
if (b.max_distance_m === null) return -1;
return a.max_distance_m - b.max_distance_m;
});
}
/**
* 운반거리에 맞는 장비를 고른다. 띠 분할은 경계현으로 하지만 **장거리 운반**은 거리가
* 먼저 정해지므로 이렇게 거꾸로 고른다. 경계 정의처는 config 한 곳뿐이라, 값을 못 받았으면
* 프론트에 사본을 두지 않고 그냥 비운다.
*/
export function pickEquipment(
limits: HaulEquipmentLimit[] | undefined,
distanceM: number,
): string | null {
if (!limits?.length) return null;
for (const limit of sortedLimits(limits)) {
if (limit.max_distance_m === null || distanceM <= limit.max_distance_m)
return limit.key || null;
}
return null;
}
/**
* 남은 잉여(사토)와 부족(토취)을 **거리가 가까운 짝부터** 맞물려 장거리 운반으로 바꾼다
* (배분 3원칙 ① 운반거리 최소). 맞물린 만큼 두 잔량에서 덜어 내고, 끝내 남는 쪽만
* 진짜 사토/토취로 남는다. 잉여가 우세한 노선이면 토취가 전부 사라진다.
*
* `residuals`를 그 자리에서 깎으므로 호출한 쪽은 0이 된 항목을 걸러야 한다.
*/
export function settleResiduals(
points: MassHaulPoint[],
residuals: HaulResidual[],
conversion: EarthworkConversion,
limits: HaulEquipmentLimit[] | undefined,
): HaulTransfer[] {
const center = (residual: HaulResidual): number => (residual.from_m + residual.to_m) / 2;
const level = (residual: HaulResidual): number =>
(residual.level_from_m3 + residual.level_to_m3) / 2;
const transfers: HaulTransfer[] = [];
const pairs: Array<{ spoil: HaulResidual; borrow: HaulResidual; distance: number }> = [];
for (const spoil of residuals.filter((entry) => entry.kind === "spoil")) {
for (const borrow of residuals.filter((entry) => entry.kind === "borrow")) {
pairs.push({ spoil, borrow, distance: Math.abs(center(spoil) - center(borrow)) });
}
}
pairs.sort((a, b) => a.distance - b.distance);
for (const pair of pairs) {
const volume = Math.min(pair.spoil.volume_m3, pair.borrow.volume_m3);
if (!(volume > EPSILON)) continue;
// 잔량이 깎인 만큼 지반유형 안분도 같은 비율로 줄인다 — 남은 사토의 구성비는 그대로다.
const ratio = pair.spoil.volume_m3 > 0 ? 1 - volume / pair.spoil.volume_m3 : 0;
pair.spoil.ea_m3 *= ratio;
pair.spoil.rr_m3 *= ratio;
pair.spoil.br_m3 *= ratio;
pair.spoil.natural_m3 *= ratio;
pair.spoil.volume_m3 -= volume;
pair.borrow.volume_m3 -= volume;
// 퍼오는 쪽이 절토 구간이므로 지반유형은 사토 구간에서 읽는다.
const mix = cutMix(points, pair.spoil.from_m, pair.spoil.to_m, conversion);
transfers.push({
index: transfers.length + 1,
from_m: center(pair.spoil),
to_m: center(pair.borrow),
volume_m3: volume,
haul_distance_m: pair.distance,
level_m3: (level(pair.spoil) + level(pair.borrow)) / 2,
equipment: pickEquipment(limits, pair.distance),
...apportion(mix, volume),
});
}
return transfers;
}
/** 확정 저장·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),
natural_spoil_m3: round(plan.natural_spoil_m3),
borrow_m3: round(plan.borrow_m3),
hauled_m3: round(plan.hauled_m3),
transferred_m3: round(plan.transferred_m3),
fill_total_m3: round(plan.fill_total_m3),
// 떨어진 구간끼리의 장거리 운반 — B08 내역서가 별도 운반 항목으로 세운다.
transfers: plan.transfers.map((transfer) => ({
index: transfer.index,
from_m: round(transfer.from_m),
to_m: round(transfer.to_m),
volume_m3: round(transfer.volume_m3),
haul_distance_m: round(transfer.haul_distance_m),
equipment: transfer.equipment,
ea_m3: round(transfer.ea_m3),
rr_m3: round(transfer.rr_m3),
br_m3: round(transfer.br_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),
direction: block.direction,
// 장비 띠 — B08 내역서는 이 단위로 운반 항목을 세운다.
bands: block.bands.map((band) => ({
index: band.index,
equipment: band.equipment,
volume_m3: round(band.volume_m3),
haul_distance_m: round(band.haul_distance_m),
ea_m3: round(band.ea_m3),
rr_m3: round(band.rr_m3),
br_m3: round(band.br_m3),
})),
})),
residuals: plan.residuals.map((residual) => ({
kind: residual.kind,
from_m: round(residual.from_m),
to_m: round(residual.to_m),
volume_m3: round(residual.volume_m3),
natural_m3: round(residual.natural_m3),
ea_m3: round(residual.ea_m3),
rr_m3: round(residual.rr_m3),
br_m3: round(residual.br_m3),
})),
};
}
@@ -0,0 +1,84 @@
/* =============================================================================
* common_util_mass_haul_types.ts
* 유토곡선 엔진이 읽는 최소 입력 타입 — B05(계획 기준)와 B06(정식 기준)이 함께 쓴다.
*
* 엔진은 횡단 설계 결과에서 **자기가 실제로 읽는 필드만** 알면 된다. 페이지별 API 응답
* 타입(`B06_..._Api_Fetch.CrossSection` 등)을 그대로 요구하면 B05가 B06 모듈을 끌어와야
* 하므로, 구조적 부분집합만 여기 두고 각 페이지 타입이 그대로 대입되게 한다.
*
* 지반유형·토량환산계수는 정의처가 `config_system.py` 한 곳이므로 여기서도 한 번만 적고,
* 페이지 API 타입 모듈이 이 정의를 재수출한다(사본 금지).
* ========================================================================== */
export type GroundType = "soil" | "ripping_rock" | "blasting_rock";
/**
* 지반유형 하나의 토량환산계수(둘 다 자연상태 기준 배율).
* loose = 흐트러진 상태 / 자연상태(팽창률), compacted = 다져진 상태 / 자연상태(다짐률).
* 정의처는 config_system.EARTHWORK_CONVERSION_FACTORS 한 곳뿐이다.
*/
export interface EarthworkConversionFactor {
loose: number;
compacted: number;
}
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;
}
/** 유토곡선 balloon을 끌어 옮긴 위치(띠 번호 → [dx, dy], px). */
export type BalloonOffsets = Record<string, [number, number]>;
/** 엔진이 읽는 횡단 설계 필드만 추린 구조. 페이지 `CrossDesign`이 그대로 대입된다. */
export interface MassHaulDesign {
ground_type: GroundType;
roadbed_width_m: number;
cut_area_m2: number;
cut_soil_area_m2?: number;
cut_rock_area_m2?: number;
cut_rock_kind?: GroundType | null;
fill_area_m2: number;
/** 성토측 자연 지반 경사(rise/run). 자연방토 판정 입력. 성토측이 없으면 null. */
fill_ground_slope?: number | null;
}
/** 엔진이 읽는 측점 필드만 추린 구조. 페이지 `CrossSection`이 그대로 대입된다. */
export interface MassHaulSection {
station_id: string;
chainage_m: number;
design?: MassHaulDesign;
}
/** 계획선 샘플 중 개략 토량 산출에 쓰는 필드(`difference_m = 계획고 − 지반고`). */
export interface MassHaulProfileSample {
chainage_m: number;
difference_m: number;
}
export interface MassHaulProfile {
samples: MassHaulProfileSample[];
}
/** 엔진이 읽는 종단 필드만 추린 구조. 페이지 `LongitudinalSection`이 그대로 대입된다. */
export interface MassHaulLongitudinal {
length_m: number;
design_profiles?: MassHaulProfile[];
}
/** 유토곡선에 측점선을 세울 때 읽는 필드. `kind`는 선 색 변형 클래스에 그대로 붙는다. */
export interface MassHaulStation {
station_id: string;
chainage_m: number;
kind: string;
}
export interface MassHaulStationSource {
stations: MassHaulStation[];
}
+601
View File
@@ -0,0 +1,601 @@
/* =============================================================================
* common_util_mass_haul_view.ts
* 유토곡선(Mass Haul Diagram) SVG 렌더러.
*
* 종단면도(`_UI_Longitudinal`) 바로 아래에 붙어 **같은 X 매핑**을 쓴다. 폭·좌우 여백
* (LONG_PAD)·누가거리 최댓값(longitudinalMaxChainage)을 종단도와 동일하게 받아,
* 같은 측점이 두 그래프에서 같은 가로 위치에 오도록 맞춘다.
*
* 산출 기준 2종(횡단/종단)의 곡선을 한 축에 겹쳐 그리고, 그래프 아래 범례 버튼으로 켜고
* 끈다. **Y 스케일은 표시 여부와 무관하게 항상 전체 곡선 기준으로 잡는다** — 곡선을 껐다
* 켤 때마다 축이 움직이면 비교 자체가 불가능해진다.
*
* Y축은 데이터 범위 기반 자동 스케일이다. 참고 도면의 수직축척이 공구마다 다르므로
* (1공구 V=1:10,000 / 3공구 V=1:50,000) 고정 축척을 쓰면 안 된다.
* 근거: docs/raw/2026-08-02_유토곡선_3공구_분석.md 3.2절
* ========================================================================== */
import type { MassHaulBasis, MassHaulPoint, MassHaulSeries } from "./common_util_mass_haul";
import type { MassHaulStationSource } from "./common_util_mass_haul_types";
import type { HaulPlan } from "./common_util_mass_haul_balance";
import type { LocaleKey } from "@ui/ui_template_locale";
import { MASS_HAUL_BALANCE_KEY } from "./common_util_mass_haul";
import { appendBalanceLayer, haulPlanChips } from "./common_util_mass_haul_balance_view";
import { segmentOf } from "./common_util_mass_haul_curve";
import { L, stationLabel, svgElement, svgText } from "./common_util_svg";
/**
* 유토곡선 X축을 **호출한 쪽 종단 그래프와 맞추는** 값. 좌우 여백과 누가거리 최댓값이
* 종단도와 같아야 같은 측점이 같은 가로 위치에 온다. 페이지마다 종단 렌더러가 다르므로
* (B06 `_UI_Longitudinal`, B05 종단 그래프) 상수를 이 모듈에 두지 않고 받아 쓴다.
*/
export interface MassHaulAxis {
maxChainageM: number;
padLeft: number;
padRight: number;
}
/** 축 눈금이 읽히는 최소 높이. 이보다 낮아지면 그래프가 뭉개진다. */
export const MASS_HAUL_MIN_HEIGHT = 90;
/** 패널을 건드리지 않았을 때의 유토곡선 높이. */
export const MASS_HAUL_HEIGHT = 190;
/**
* 유토곡선 전용 상하 여백. 좌우는 종단면도와 **반드시 같아야**(LONG_PAD) X축이 맞으므로
* 건드리지 않고, 위아래만 좁힌다. 위쪽을 좁히는 이유는 바로 위 종단면도의 측점 라벨과
* 붙여 두 그래프가 측점 표기를 공유하는 것처럼 보이게 하기 위해서다(2026-08-02 사용자 지시).
* 아래쪽은 X축 제목을 종단면도 상단으로 합치면서 비었다.
*/
const MASS_PAD_TOP = 10;
const MASS_PAD_BOTTOM = 8;
const BASIS_LABEL: Record<MassHaulBasis, LocaleKey> = {
cross: "B06_MassHaul_Basis_Cross",
longitudinal: "B06_MassHaul_Basis_Long",
};
/** 범례·곡선에 함께 쓰는 이름. 색이 곧 산출 기준이다. */
export function massHaulSeriesLabel(series: MassHaulSeries): string {
return L(BASIS_LABEL[series.basis]);
}
function seriesClass(series: MassHaulSeries, base: string): string {
return `${base} ${base}--${series.basis}`;
}
/**
* 곡선 경로. **`common_util_mass_haul_curve`가 쓰는 것과 같은 기하**로 그려야 한다 — 평형선·장비
* 경계현·평균운반거리선의 양 끝이 그 기하와의 교점으로 계산되므로, 그리기와 재기가 어긋나면
* 수평선 끝이 곡선에서 떠 보인다(2026-08-02에 겪은 문제).
*
* 지금은 직선 모드(`PARABOLIC_MASS_CURVE = false`)라 `L` 명령만 나온다. 포물선 모드를 켜면
* 제어점 `(x₀ + Δ/2, V₀ + a₀Δ/2)`의 2차 베지에가 구간 포물선과 **정확히** 같아진다.
*/
function curvePath(
points: MassHaulPoint[],
x: (chainage: number) => number,
y: (volume: number) => number,
): string {
if (!points.length) return "";
const parts = [`M ${x(points[0].chainage_m)},${y(points[0].cumulative_volume_m3)}`];
for (let index = 1; index < points.length; index += 1) {
const point = points[index];
const segment = segmentOf(points, index);
if (!segment) {
parts.push(`L ${x(point.chainage_m)},${y(point.cumulative_volume_m3)}`);
continue;
}
if (Math.abs(segment.curvature) < 1e-12) {
// 직선 모드 — 제어점을 둘 이유가 없다. 명령 자체를 직선으로 낸다.
parts.push(`L ${x(point.chainage_m)},${y(point.cumulative_volume_m3)}`);
continue;
}
const controlX = segment.x0 + segment.span / 2;
const controlY = segment.v0 + (segment.a0 * segment.span) / 2;
parts.push(
`Q ${x(controlX)},${y(controlY)} ${x(point.chainage_m)},${y(point.cumulative_volume_m3)}`,
);
}
return parts.join(" ");
}
/** 누가토량 값 표기 — 천 단위 구분 + 소수점 1자리. */
export function formatVolume(value: number): string {
return value.toLocaleString(undefined, {
minimumFractionDigits: 1,
maximumFractionDigits: 1,
});
}
/**
* Y축 상·하한을 잡는다. 0선은 항상 보이도록 범위에 포함하고, 위아래로 5% 여유를 준다.
* 값이 전부 0이면 폭이 0이 되어 나눗셈이 깨지므로 최소 폭을 씌운다.
*
* 범위는 **표시 중인 곡선이 아니라 전체 곡선**에서 잡는다(축 고정).
*/
function volumeRange(series: MassHaulSeries[]): { min: number; max: number } {
let rawMin = 0;
let rawMax = 0;
for (const entry of series) {
rawMin = Math.min(rawMin, entry.result.min_cumulative_m3);
rawMax = Math.max(rawMax, entry.result.max_cumulative_m3);
}
const padding = Math.max((rawMax - rawMin) * 0.05, 1);
return { min: rawMin - padding, max: rawMax + padding };
}
/**
* 선택된 측점의 곡선 점을 찾는다.
*
* 측점 id로 직접 맞추지 않고 **누가거리로 맞춘다** — 종단 기준 곡선의 점 id는 종단 샘플
* 인덱스가 붙은 파생값(`${station_id}@${index}`)이라 측점 id와 형태가 다르기 때문이다.
*/
export function massHaulPointAt(
series: MassHaulSeries,
longitudinal: MassHaulStationSource,
stationId: string | null,
): MassHaulPoint | null {
if (!stationId) return null;
const station = longitudinal.stations.find((entry) => entry.station_id === stationId);
if (!station) return null;
let best: MassHaulPoint | null = null;
let bestGap = Number.POSITIVE_INFINITY;
for (const point of series.result.points) {
const gap = Math.abs(point.chainage_m - station.chainage_m);
if (gap < bestGap) {
bestGap = gap;
best = point;
}
}
return bestGap <= 0.5 ? best : null;
}
interface CalloutPlacement {
anchorX: number;
anchorY: number;
left: number;
right: number;
top: number;
bottom: number;
/** 주어진 가로 구간에서 표시 중인 곡선이 지나는 세로 범위(없으면 null). */
curveYAt: (fromPx: number, toPx: number) => { top: number; bottom: number } | null;
}
const CALLOUT_LINE_H = 14;
const CALLOUT_PAD_X = 9;
const CALLOUT_PAD_Y = 7;
/** 말풍선과 곡선 사이 최소 틈(px). */
const CALLOUT_CLEARANCE = 6;
/** SVG는 자동 크기가 없어 폭을 어림해야 한다. 한글은 라틴 글자보다 넓다. */
function textWidth(value: string): number {
let width = 0;
for (const char of value) width += /[가-힣ㄱ-ㅎㅏ-ㅣ]/.test(char) ? 11 : 6.1;
return width;
}
/**
* 선택 측점의 구간 물량 말풍선(둥근 사각형)을 **곡선과 겹치지 않는 자리에** 그린다.
*
* 말풍선이 놓일 가로 구간에서 곡선이 실제로 지나는 세로 범위를 먼저 재고, 그 위/아래
* 빈 곳 중 들어갈 수 있는 쪽에 놓는다(2026-08-02 사용자 지시). 오른쪽·왼쪽 자리를 모두
* 재서 여유가 큰 쪽을 고르며, 어느 쪽에도 안 들어가면 여유가 더 큰 쪽 끝에 붙인다.
*/
function appendVolumeCallout(
svg: SVGSVGElement,
point: MassHaulPoint,
stationInterval: number,
box: CalloutPlacement,
): void {
const lines = [
`${stationLabel(point.chainage_m, stationInterval)}` +
` · ${L("B06_MassHaul_Station_Net")} ${formatVolume(point.net_volume_m3)}` +
` · ${L("B06_MassHaul_Station_Cumulative")} ${formatVolume(point.cumulative_volume_m3)}`,
`${L("B06_Design_Cut_Soil_Area")} ${formatVolume(point.cut_soil_m3)}` +
` · ${L("B06_Design_Cut_Rock_Area")} ${formatVolume(point.cut_rock_m3)}` +
` · ${L("B06_MassHaul_CutCompacted")} ${formatVolume(point.cut_compacted_m3)}` +
` · ${L("B06_MassHaul_Fill")} ${formatVolume(point.fill_m3)} (㎥)`,
];
const width = Math.min(
Math.max(...lines.map(textWidth)) + CALLOUT_PAD_X * 2,
Math.max(box.right - box.left, 80),
);
const height = lines.length * CALLOUT_LINE_H + CALLOUT_PAD_Y * 2;
const clampX = (value: number): number =>
Math.min(Math.max(value, box.left), Math.max(box.left, box.right - width));
// 오른쪽 먼저, 안 되면 왼쪽 — 각 자리마다 위/아래 여유를 재서 가장 넉넉한 조합을 고른다.
let best: { x: number; y: number; room: number; fits: boolean } | null = null;
for (const candidateX of [clampX(box.anchorX + 14), clampX(box.anchorX - 14 - width)]) {
const band = box.curveYAt(candidateX, candidateX + width);
const roomAbove = (band ? band.top : box.bottom) - box.top;
const roomBelow = box.bottom - (band ? band.bottom : box.top);
for (const [room, top] of [
[roomAbove, (band ? band.top : box.bottom) - height - CALLOUT_CLEARANCE],
[roomBelow, (band ? band.bottom : box.top) + CALLOUT_CLEARANCE],
] as Array<[number, number]>) {
const fits = room >= height + CALLOUT_CLEARANCE;
const better = !best || (fits && !best.fits) || (fits === best.fits && room > best.room);
if (better) best = { x: candidateX, y: top, room, fits };
}
}
if (!best) return;
const boxY = Math.min(
Math.max(best.y, box.top + 2),
Math.max(box.top + 2, box.bottom - height - 2),
);
const group = svgElement("g", { class: "b06-masshaul__callout" });
// 꼬리는 앵커가 말풍선 가로 범위 안에 있을 때만 — 밖이면 지시선으로 잇는다.
const tailX = Math.min(Math.max(box.anchorX, best.x + 12), best.x + width - 12);
const below = boxY > box.anchorY;
if (box.anchorX >= best.x && box.anchorX <= best.x + width) {
const edge = below ? boxY : boxY + height;
const tip = below ? edge - 7 : edge + 7;
group.append(
svgElement("polygon", {
points: `${tailX - 6},${edge} ${tailX + 6},${edge} ${tailX},${tip}`,
class: "b06-masshaul__callout-tail",
}),
);
} else {
group.append(
svgElement("line", {
x1: box.anchorX,
y1: box.anchorY,
x2: box.anchorX < best.x ? best.x : best.x + width,
y2: boxY + height / 2,
class: "b06-masshaul__callout-leader",
}),
);
}
group.append(
svgElement("rect", { x: best.x, y: boxY, width, height, rx: 8, ry: 8 }),
...lines.map((line, index) =>
svgText(line, {
x: best.x + CALLOUT_PAD_X,
y: boxY + CALLOUT_PAD_Y + CALLOUT_LINE_H * (index + 0.8),
class: "b06-masshaul__callout-text",
}),
),
);
svg.append(group);
}
export function createMassHaulChart(
series: MassHaulSeries[],
visibleKeys: ReadonlySet<string>,
longitudinal: MassHaulStationSource,
axis: MassHaulAxis,
selectedStationId: string | null,
stationInterval: number,
widthPx: number,
heightPx: number,
minimumWidthPx: number,
onSelectStation: (stationId: string) => void,
haulPlan: HaulPlan | null,
): SVGSVGElement {
const svg = svgElement("svg", {
class: "b06-section__chart b06-masshaul",
width: widthPx,
height: heightPx,
viewBox: `0 0 ${widthPx} ${heightPx}`,
role: "img",
"aria-label": L("B06_MassHaul_Title"),
});
svg.style.width = "100%";
svg.style.minWidth = `${minimumWidthPx}px`;
svg.append(svgElement("rect", { width: widthPx, height: heightPx, class: "b06-chart__bg" }));
// 종단면도와 완전히 같은 X 매핑 — 여백·최댓값 모두 호출한 쪽 종단 렌더러에서 받는다.
const maxChainage = Math.max(axis.maxChainageM, 1);
const plotWidth = widthPx - axis.padLeft - axis.padRight;
const plotHeight = heightPx - MASS_PAD_TOP - MASS_PAD_BOTTOM;
const x = (chainage: number) => axis.padLeft + (chainage / maxChainage) * plotWidth;
const { min, max } = volumeRange(series);
const span = Math.max(max - min, 1e-6);
const y = (volume: number) => MASS_PAD_TOP + ((max - volume) / span) * plotHeight;
// 패널을 줄이면 그래프 몫이 60px대까지 내려간다 — 눈금 5개를 그대로 두면 라벨이 서로 겹친다.
const tickRatios = plotHeight < 110 ? [0, 0.5, 1] : [0, 0.25, 0.5, 0.75, 1];
for (const ratio of tickRatios) {
const gridY = MASS_PAD_TOP + ratio * plotHeight;
const value = max - ratio * span;
svg.append(
svgElement("line", {
x1: axis.padLeft,
y1: gridY,
x2: widthPx - axis.padRight,
y2: gridY,
class: "b06-chart__grid",
}),
svgText(formatVolume(value), {
x: axis.padLeft - 9,
y: gridY + 4,
"text-anchor": "end",
class: "b06-chart__tick",
}),
);
}
const zeroY = y(0);
const visible = series.filter((entry) => visibleKeys.has(entry.key));
/** 주어진 가로 구간에서 표시 중인 곡선이 지나는 세로 범위. 말풍선·balloon 배치가 함께 쓴다. */
const curveYAt = (fromPx: number, toPx: number): { top: number; bottom: number } | null => {
let lowest = Number.POSITIVE_INFINITY;
let highest = Number.NEGATIVE_INFINITY;
for (const entry of visible) {
for (const point of entry.result.points) {
const px = x(point.chainage_m);
if (px < fromPx || px > toPx) continue;
const py = y(point.cumulative_volume_m3);
lowest = Math.min(lowest, py);
highest = Math.max(highest, py);
}
}
return Number.isFinite(lowest) ? { top: lowest, bottom: highest } : null;
};
// 면(band)은 첫 번째 표시 곡선에만 깐다 — 여러 곡선에 겹쳐 칠하면 서로 가려 못 읽는다.
const banded = visible[0];
if (banded) {
const last = banded.result.points[banded.result.points.length - 1];
svg.append(
svgElement("path", {
// 곡선과 **같은 경로**로 위쪽 테두리를 그린 뒤 0선을 따라 닫는다.
d:
`${curvePath(banded.result.points, x, y)} ` +
`L ${x(last.chainage_m)},${zeroY} L ${x(banded.result.points[0].chainage_m)},${zeroY} Z`,
class: seriesClass(banded, "b06-masshaul__band"),
}),
);
}
// 측점선 — 종단도와 같은 자리에 서고, 눌러서 측점을 고를 수 있다(3자 선택 동기화).
// 라벨은 바로 위 종단면도가 이미 달고 있어 여기서는 생략한다(중복 표기 방지).
for (const station of longitudinal.stations) {
const stationX = x(station.chainage_m);
const selected = station.station_id === selectedStationId;
const marker = svgElement("g", {
class: `b06-chart__station${selected ? " b06-chart__station--selected" : ""}`,
tabindex: "0",
role: "button",
"aria-label": `${stationLabel(station.chainage_m, stationInterval)} ${L("B06_MassHaul_Title")}`,
});
marker.addEventListener("click", () => onSelectStation(station.station_id));
marker.addEventListener("keydown", (event) => {
if (event.key === "Enter" || event.key === " ") onSelectStation(station.station_id);
});
marker.append(
svgElement("line", {
x1: stationX,
y1: MASS_PAD_TOP,
x2: stationX,
y2: heightPx - MASS_PAD_BOTTOM,
class: "b06-chart__station-hit",
}),
svgElement("line", {
x1: stationX,
y1: MASS_PAD_TOP,
x2: stationX,
y2: heightPx - MASS_PAD_BOTTOM,
class: `b06-chart__station-line b06-chart__station-line--${selected ? "selected" : station.kind}`,
}),
);
svg.append(marker);
}
// 0선 — 절토 우세와 성토 우세를 가르는 기준선이라 격자보다 진하게 그리고 눈금값도 따로 적는다.
// Y 범위 계산(`volumeRange`)이 0을 항상 품으므로 이 선은 어떤 노선에서도 화면에 남는다.
svg.append(
svgElement("line", {
x1: axis.padLeft,
y1: zeroY,
x2: widthPx - axis.padRight,
y2: zeroY,
class: "b06-masshaul__zero",
}),
svgText("0", {
x: axis.padLeft - 9,
y: zeroY + 4,
"text-anchor": "end",
class: "b06-chart__tick b06-masshaul__zero-tick",
}),
);
// 뒤에 그린 곡선이 위로 오므로 범례 순서의 역순으로 그려 첫 곡선(정식)을 가장 위에 둔다.
for (const entry of [...visible].reverse()) {
svg.append(
svgElement("path", {
d: curvePath(entry.result.points, x, y),
class: seriesClass(entry, "b06-masshaul__curve"),
}),
);
}
// 토량 분배 레이어 — 곡선 **위**, 선택 말풍선 **아래**. 순서를 바꾸면 평형선이 곡선을
// 가리거나(위로 올리면) 선택 말풍선이 balloon에 묻힌다(아래로 내리면).
if (haulPlan) {
appendBalanceLayer(svg, haulPlan, {
x,
y,
left: axis.padLeft,
right: widthPx - axis.padRight,
top: MASS_PAD_TOP,
bottom: heightPx - MASS_PAD_BOTTOM,
curveYAt,
stationInterval,
});
}
// 선택 측점 강조 — 곡선 위 점 + 구간 물량 말풍선.
const focus = banded ? massHaulPointAt(banded, longitudinal, selectedStationId) : null;
if (focus) {
const focusX = x(focus.chainage_m);
svg.append(
svgElement("circle", {
cx: focusX,
cy: y(focus.cumulative_volume_m3),
r: 4,
class: "b06-masshaul__focus",
}),
);
appendVolumeCallout(svg, focus, stationInterval, {
anchorX: focusX,
anchorY: y(focus.cumulative_volume_m3),
left: axis.padLeft,
right: widthPx - axis.padRight,
top: MASS_PAD_TOP,
bottom: heightPx - MASS_PAD_BOTTOM,
curveYAt,
});
}
svg.append(
svgElement("line", {
x1: axis.padLeft,
y1: MASS_PAD_TOP,
x2: axis.padLeft,
y2: heightPx - MASS_PAD_BOTTOM,
class: "b06-chart__axis",
}),
// X축 제목은 종단면도 이름표 하나로 합쳤다 — 여기서 또 적으면 두 그래프가 별개 축으로 읽힌다.
svgText(L("B06_MassHaul_YAxis"), {
x: 15,
y: heightPx / 2,
"text-anchor": "middle",
transform: `rotate(-90 15 ${heightPx / 2})`,
class: "b06-chart__axis-label",
}),
);
// 그래프 이름표 — 종단면도와 같은 양식이되 **하단**에 놓고, 곡선이 지나가는 자리라
// 불투명 배경을 깐다(2026-08-02 사용자 지시).
const nameText = L("B06_MassHaul_Title");
const nameWidth = textWidth(nameText) + 14;
const nameY = heightPx - MASS_PAD_BOTTOM - 6;
svg.append(
svgElement("rect", {
x: widthPx / 2 - nameWidth / 2,
y: nameY - 12,
width: nameWidth,
height: 17,
rx: 4,
ry: 4,
class: "b06-masshaul__name-plate",
}),
svgText(nameText, {
x: widthPx / 2,
y: nameY,
"text-anchor": "middle",
class: "b06-chart__axis-label",
}),
);
return svg;
}
/**
* 곡선 표시/숨김 버튼 줄.
*
* 그래프 **아래**에 둔다(2026-08-02 사용자 지시). 예전에는 그래프 위에 겹치는 오버레이였는데,
* 가로 스크롤 컨테이너 안에 있으면 컨테이너 폭 계산에 끼어들어 종단면도와 X축이 어긋난다.
* 지금은 스크롤 컨테이너 **밖** 패널 본문에 붙어 곡선 폭에 전혀 관여하지 않는다.
*/
export function createMassHaulLegend(
series: MassHaulSeries[],
visibleKeys: ReadonlySet<string>,
onToggle: (key: string) => void,
onResetBalloons?: () => void,
): HTMLElement {
const legend = document.createElement("div");
legend.className = "b06-masshaul__legend";
legend.setAttribute("role", "group");
legend.setAttribute("aria-label", L("B06_MassHaul_Legend_Title"));
const addItem = (key: string, label: string, variantClass: string): void => {
const shown = visibleKeys.has(key);
const button = document.createElement("button");
button.type = "button";
button.className = variantClass;
button.classList.toggle("is-off", !shown);
button.setAttribute("aria-pressed", String(shown));
button.title = `${label} — ${L(shown ? "B06_MassHaul_Legend_Hide" : "B06_MassHaul_Legend_Show")}`;
const swatch = document.createElement("span");
swatch.className = "b06-masshaul__legend-swatch";
swatch.setAttribute("aria-hidden", "true");
const text = document.createElement("span");
text.textContent = label;
button.append(swatch, text);
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",
);
// 끌어 옮긴 balloon을 한 번에 자동 배치로 되돌린다(프론트 캐시 + 다음 확정 시 영구저장소).
if (onResetBalloons && visibleKeys.has(MASS_HAUL_BALANCE_KEY)) {
const reset = document.createElement("button");
reset.type = "button";
reset.className = "b06-masshaul__legend-item b06-masshaul__legend-item--reset";
reset.textContent = L("B06_MassHaul_Balloon_Reset");
reset.title = L("B06_MassHaul_Balloon_Reset_Tip");
reset.addEventListener("click", onResetBalloons);
legend.append(reset);
}
return legend;
}
/**
* 곡선 아래 총괄 요약 줄. 절토는 자연상태(내역 기준)와 환산 후(곡선 기준)를 함께 적는다.
* 곡선이 여러 개라 수치가 어느 곡선의 것인지 끝에 반드시 밝힌다.
*/
export function createMassHaulSummary(
series: MassHaulSeries,
haulPlan: HaulPlan | null,
): HTMLElement {
const result = series.result;
const row = document.createElement("div");
row.className = "b06-masshaul__summary";
const cut = result.cut_natural_m3;
const chips: Array<[string, string]> = [
[
L("B06_MassHaul_CutNatural"),
`${formatVolume(cut.soil + cut.ripping_rock + cut.blasting_rock)}㎥`,
],
[L("B06_Design_Ground_Soil"), `${formatVolume(cut.soil)}㎥`],
[L("B06_Design_Ground_Ripping"), `${formatVolume(cut.ripping_rock)}㎥`],
[L("B06_Design_Ground_Blasting"), `${formatVolume(cut.blasting_rock)}㎥`],
[L("B06_MassHaul_CutCompacted"), `${formatVolume(result.cut_compacted_m3)}㎥`],
[L("B06_MassHaul_Fill"), `${formatVolume(result.fill_compacted_m3)}㎥`],
];
// 잉여와 부족은 동시에 성립하지 않으므로 해당하는 쪽만 보여 준다.
chips.push(
result.shortage_m3 > 0
? [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";
const name = document.createElement("em");
name.textContent = label;
const amount = document.createElement("strong");
amount.textContent = value;
chip.append(name, amount);
row.append(chip);
}
const basis = document.createElement("span");
basis.className = seriesClass(series, "b06-masshaul__basis");
basis.textContent = massHaulSeriesLabel(series);
row.append(basis);
return row;
}
+63
View File
@@ -0,0 +1,63 @@
/* =============================================================================
* common_util_svg.ts
* SVG 도면 렌더러가 공유하는 최소 유틸리티 — 요소 생성·텍스트·측점 표기.
*
* B06 종·횡단 렌더러가 쓰던 것을 유토곡선 모듈 공용화(2026-08-03)에 맞춰 옮겼다.
* B05 계획 유토곡선과 B06 정식 유토곡선이 **같은 표기 규칙**을 쓰게 하는 것이 목적이며,
* B06 쪽은 `_UI_Section_Common`이 그대로 재수출해 기존 임포트 경로를 유지한다.
* ========================================================================== */
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
export const SVG_NS = "http://www.w3.org/2000/svg";
export function L(key: keyof typeof ui_locales): string {
return ui_locales[key][currentLanguageIndex];
}
export function svgElement<K extends keyof SVGElementTagNameMap>(
tag: K,
attributes: Record<string, string | number> = {},
): SVGElementTagNameMap[K] {
const element = document.createElementNS(SVG_NS, tag);
Object.entries(attributes).forEach(([key, value]) => element.setAttribute(key, String(value)));
return element;
}
export function svgText(
value: string,
attributes: Record<string, string | number>,
): SVGTextElement {
const text = svgElement("text", attributes);
text.textContent = value;
return text;
}
/** 측점 간격을 실제 측점 배열에서 되짚는다(최빈 간격). 라벨 표기의 분모다. */
export function inferStationInterval(stations: Array<{ chainage_m: number }>): number {
const counts = new Map<number, number>();
for (let index = 1; index < stations.length; index += 1) {
const difference = stations[index].chainage_m - stations[index - 1].chainage_m;
if (difference <= 0) continue;
const rounded = Math.round(difference * 10) / 10;
counts.set(rounded, (counts.get(rounded) ?? 0) + 1);
}
return (
[...counts.entries()].sort(
([intervalA, countA], [intervalB, countB]) => countB - countA || intervalB - intervalA,
)[0]?.[0] ?? 1
);
}
/** 측점 라벨 — "STA" 접두사 없이 `측점번호+잔여거리`(예: `4+0.0`). */
export function stationLabel(chainage: number, interval: number): string {
const safeInterval = interval > 0 ? interval : 1;
let stationNumber = Math.floor((chainage + 1e-6) / safeInterval);
let remainder = chainage - stationNumber * safeInterval;
if (Math.abs(remainder) < 0.05) remainder = 0;
if (remainder >= safeInterval - 0.05) {
stationNumber += 1;
remainder = 0;
}
return `${stationNumber}+${remainder.toFixed(1)}`;
}