Files
Aislo/common_util/common_util_mass_haul_balance_view.ts
T
eomsangdonandClaude Opus 5 fceec5fb24 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>
2026-08-03 18:35:02 +09:00

521 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]> {
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;
}