/* ============================================================================= * common_util_mass_haul_callout.ts * 유토곡선 값 표기 · 선택 측점 구간 물량 말풍선(`common_util_mass_haul_view` 에서 나눔). * ========================================================================== */ import type { MassHaulPoint } from "./common_util_mass_haul"; import { L, stationLabel, svgElement, svgText } from "./common_util_svg"; /** 누가토량 값 표기 — 천 단위 구분 + 소수점 1자리. */ export function formatVolume(value: number): string { return value.toLocaleString(undefined, { minimumFractionDigits: 1, maximumFractionDigits: 1, }); } export 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는 자동 크기가 없어 폭을 어림해야 한다. 한글은 라틴 글자보다 넓다. */ export function textWidth(value: string): number { let width = 0; for (const char of value) width += /[가-힣ㄱ-ㅎㅏ-ㅣ]/.test(char) ? 11 : 6.1; return width; } /** * 선택 측점의 구간 물량 말풍선(둥근 사각형)을 **곡선과 겹치지 않는 자리에** 그린다. * * 말풍선이 놓일 가로 구간에서 곡선이 실제로 지나는 세로 범위를 먼저 재고, 그 위/아래 * 빈 곳 중 들어갈 수 있는 쪽에 놓는다(2026-08-02 사용자 지시). 오른쪽·왼쪽 자리를 모두 * 재서 여유가 큰 쪽을 고르며, 어느 쪽에도 안 들어가면 여유가 더 큰 쪽 끝에 붙인다. */ export function appendVolumeCallout( /** 붙일 자리 — 그래프 칸 밖을 자르는 겹이다(2026-09-04). */ svg: SVGSVGElement | SVGGElement, 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); }