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>
521 lines
21 KiB
TypeScript
521 lines
21 KiB
TypeScript
/* =============================================================================
|
||
* 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;
|
||
}
|