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:
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)}`;
|
||||
}
|
||||
Reference in New Issue
Block a user