/* ============================================================================= * 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, /** 실제로 붙일 자리 — 그래프 칸 밖을 자르는 겹이 있으면 그쪽(2026-09-04). 재는 일은 * `svg`(뷰박스가 필요)가 그대로 맡는다. */ host: SVGSVGElement | SVGGElement = svg, ): 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 = []; 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); host.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; }