Files
Aislo/common_util/common_util_mass_haul_balance_view.ts
T
eomsangdonandClaude Fable 5 8812657022 feat(B05/B02): 표시 정보 정비 + 설계속도 축 도입
- 종단 상단 표시줄을 법정 판정 3항목으로 축소: 최대 기울기/상한(별표2 Ⅰ.2.라),
  절·성토 불균형(Ⅰ.1.나.(4)(다)), 종단곡선 필요 n곳(Ⅰ.2.마 — 대수차 5% 초과인데
  곡선이 빠진 변화점). 변화점·종단곡선 개수·기본 곡선길이 L·중복 경고칩은 제거.
- [초기선 복원] → [편집 되돌리기] — 실제 동작은 화면 편집 델타 삭제이지 저장 지점
  복귀가 아니다. 툴팁에 동작과 [초기화]와의 차이를 적었다.
- 3D 뷰포트 안내 라벨 삭제(로딩 완료 후 남던 조작법 문구).
- 배수유역 버튼 정비: 유역 다시 나누기 / 선택한 관 삭제 / 배관 배치 초기화 +
  각 툴팁에 실제 동작 명시(선택 삭제는 툴팁 자체가 없었다).
- 유토곡선 요약 12칩 → 5칩(절토·성토·잉여|부족·운반·검산). 토질 3종 내역·다짐
  환산·블록 수·장거리 운반·사토는 해당 칩 툴팁으로. B05·B06 공용 함수라 동시 반영.
- 설계속도 축 도입(임도는 속도를 낼 수 없는 노선 — 기본 20km/h):
  · 임도 종류는 프로젝트 등록값(projects.road_type)을 읽어 B05가 읽기 전용 표시
    (main→간선임도, fire→산불진화임도, 그 외→작업임도). 화면에서 고치지 않는다.
  · 설계속도 선택(간선·산불진화 20/30/40, 작업 20 고정) 신설 — 종단기울기 상한과
    종단곡선 반경이 등급이 아니라 이 값으로 정해진다(지식DB 설계제원_총괄 §6·§7).
  · B02 임도 종류 선택지를 현행 3종으로 정리(지선 폐지·계류보전은 사방 구분).
  · 백엔드: ROUTE_GRADE_CLASSES 3종+branch 호환, selectable_design_speeds 신설,
    resolve_design_speed 신설, legal_grade_limit_pct·resolve_grade_options에
    설계속도 인자, sections/context가 road_type 제공(B06도 저장값 승계).
  · 계획선 정책은 그릴 때마다 현재 기준으로 동기화 — 저장분의 옛 상한이 남지 않는다.
- 검증: pytest 125 통과(설계속도 12건 신규), tsc, 헤드 브라우저 — 진입 시 상한 9%,
  40km/h 전환 시 7% 즉시 반영, 원복 9%, 안내문 '간선임도 · 설계속도 20km/h ·
  일반지형', 유토곡선 5칩, 유역 버튼 새 이름·툴팁, 3D 라벨 빈 문자열 확인.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 19:46:03 +09:00

523 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =============================================================================
* 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, 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;
/* 요약 줄 압축(2026-08-19) — 블록·띠 수와 장거리 운반·사토는 칩으로 두지 않고
* 운반 총량 칩의 툴팁으로 내린다. 판단에 쓰는 값은 "얼마나 옮기는가"와 검산이다. */
const haulTip = [`${L("B06_MassHaul_BlockCount")} ${plan.blocks.length} / ${bands}`];
if (plan.transfers.length) {
haulTip.push(`${L("B06_MassHaul_Transfer")} ${compactVolume(plan.transferred_m3)}㎥`);
}
if (plan.natural_spoil_m3 > 0.5) {
haulTip.push(`${L("B06_MassHaul_NaturalSpoil")} ${compactVolume(plan.natural_spoil_m3)}㎥`);
}
const chips: Array<[string, string, string?]> = [
[L("B06_MassHaul_HaulTotal"), `${compactVolume(moved)}㎥`, haulTip.join(" · ")],
];
chips.push([
L("B06_MassHaul_Check"),
ratio <= 0.005
? L("B06_MassHaul_Check_Ok")
: `${gap > 0 ? "+" : ""}${compactVolume(Math.abs(gap))}㎥`,
]);
return chips;
}