129 lines
5.3 KiB
TypeScript
129 lines
5.3 KiB
TypeScript
/* =============================================================================
|
|
* 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);
|
|
}
|