feat(B06): 유토곡선 balloon 축소·곡선 회피 배치·드래그 이동 + 양방향 하이라이트

도형이 크고 남는 공간을 못 찾는다는 지적 반영.

- balloon을 두 줄 상한(번호·물량 / L·장비)으로 줄이고 여백·육각 노치 축소.
  EA/RR/BR은 툴팁으로 옮김
- 배치를 세로 밀기에서 가로·세로 링 탐색으로 바꾸고 curveYAt으로 곡선까지 회피.
  못 피하면 곡선 조건만 버리고 재탐색(안 그리는 것보다 낫다)
- balloon을 끌어 옮길 수 있고 지시선 끝점이 따라간다. 옮긴 자리는 세션에 남고
  더블클릭이면 자동 배치로 복귀. 지시선은 balloon 밖에 둬야 같이 끌려가지 않는다
- 띠 면과 balloon을 같은 짝으로 묶어 어느 쪽을 눌러도 둘 다 강조된다

검증: 4개 크기에서 NaN 0건, balloon 11개 중 곡선 겹침 0~1개(이전엔 회피 없음),
도형·클릭 짝·드래그·세션 저장·더블클릭 복귀 확인.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-02 17:00:05 +09:00
co-authored by Claude Opus 5
parent bc877b832a
commit c490e2ff45
3 changed files with 228 additions and 77 deletions
@@ -32,6 +32,11 @@ export interface BalanceLayerBox {
right: number;
top: number;
bottom: number;
/**
* 주어진 가로 구간에서 곡선이 실제로 지나는 세로 범위(없으면 null).
* balloon을 곡선과 겹치지 않는 자리에 놓기 위해 유토곡선 렌더러가 넘겨준다.
*/
curveYAt?: (fromPx: number, toPx: number) => { top: number; bottom: number } | null;
}
const EQUIPMENT_LABEL: Record<string, LocaleKey> = {
@@ -48,16 +53,54 @@ const EQUIPMENT_SHAPE: Record<string, BalloonShape> = {
dump_truck: "rect",
};
const LINE_HEIGHT = 11;
const PAD_X = 8;
const PAD_Y = 5;
/* balloon 치수 — 도면과 달리 화면 그래프는 200px 안팎이라 최대한 작게 잡는다
(2026-08-02 사용자 지적: 도형이 크고 빈자리를 못 찾는다). 자세한 값은 툴팁이 맡는다. */
const LINE_HEIGHT = 10;
const PAD_X = 5;
const PAD_Y = 3;
/** 육각형 좌우 꼭짓점이 파고드는 깊이(px). */
const NOTCH = 8;
const NOTCH = 5;
/** balloon과 곡선 사이 최소 틈(px). */
const CURVE_CLEARANCE = 5;
/** 이보다 좁은 띠에는 balloon을 달지 않는다 — 서로 겹쳐 도면을 못 읽는다. */
const MIN_BAND_WIDTH_PX = 34;
/** 이보다 얇은 띠에는 평균운반거리 선을 긋지 않는다(경계현과 붙어 한 줄로 보인다). */
const MIN_BAND_THICKNESS_PX = 9;
/**
* 사용자가 끌어 옮긴 balloon 위치(띠 번호 → [dx, dy]). 다시 그릴 때마다 자동 배치로
* 되돌아가면 옮긴 보람이 없으므로 세션에 남긴다(패널 높이·접힘과 같은 규칙).
*/
const BALLOON_OFFSET_KEY = "b06:balance-balloon-offset";
function readOffsets(): Map<number, [number, number]> {
try {
const raw = sessionStorage.getItem(BALLOON_OFFSET_KEY);
if (!raw) return new Map();
const parsed: unknown = JSON.parse(raw);
if (!parsed || typeof parsed !== "object") return new Map();
return new Map(
Object.entries(parsed as Record<string, [number, number]>).map(([key, value]) => [
Number(key),
value,
]),
);
} catch {
return new Map();
}
}
function writeOffsets(offsets: Map<number, [number, number]>): void {
try {
sessionStorage.setItem(
BALLOON_OFFSET_KEY,
JSON.stringify(Object.fromEntries([...offsets].map(([key, value]) => [String(key), value]))),
);
} catch {
/* 세션 저장 실패는 무시 — 위치는 화면이 살아 있는 동안 유지된다. */
}
}
/** SVG는 자동 크기가 없어 폭을 어림해야 한다. 한글은 라틴 글자보다 넓다. */
function textWidth(value: string): number {
let width = 0;
@@ -140,35 +183,118 @@ function overlaps(a: PlacedBox, b: PlacedBox): boolean {
return !(a.right <= b.left || a.left >= b.right || a.bottom <= b.top || a.top >= b.bottom);
}
/** 이 자리에 놓으면 곡선과 겹치는가. 곡선 정보를 못 받았으면 겹치지 않는 것으로 본다. */
function hitsCurve(box: BalanceLayerBox, candidate: PlacedBox): boolean {
const band = box.curveYAt?.(candidate.left, candidate.right);
if (!band) return false;
return !(
candidate.bottom + CURVE_CLEARANCE <= band.top || candidate.top - CURVE_CLEARANCE >= band.bottom
);
}
/**
* 겹치지 않는 세로 자리를 찾는다. 선호 방향으로 한 칸씩 밀어 보고, 상자를 벗어나면 반대쪽을
* 시도한다. 끝내 못 찾으면 처음 자리에 그대로 둔다 — 안 그리는 것보다 낫다.
* 자리를 찾는다. 선호 지점에서 **가로·세로 양쪽으로** 후보를 넓혀 가며, 이미 놓인 balloon과
* 겹치지 않고 곡선도 피하는 첫 자리를 고른다(2026-08-02 사용자 지적: 남는 공간을 못 찾음).
*
* 곡선까지 피하는 자리가 끝내 없으면 **곡선 조건만 버리고** 다시 훑는다 — 겹치더라도 놓는 게
* 안 그리는 것보다 낫고, 사용자가 끌어 옮길 수 있다.
*/
function findSlot(
placed: PlacedBox[],
centerX: number,
preferredX: number,
preferredY: number,
width: number,
height: number,
box: BalanceLayerBox,
upward: boolean,
): number {
const step = height + 4;
const half = height / 2;
for (const direction of upward ? [-1, 1] : [1, -1]) {
for (let attempt = 0; attempt < 6; attempt += 1) {
const centerY = preferredY + direction * step * attempt;
if (centerY - half < box.top || centerY + half > box.bottom) break;
const candidate: PlacedBox = {
left: centerX - width / 2,
right: centerX + width / 2,
top: centerY - half,
bottom: centerY + half,
};
if (!placed.some((entry) => overlaps(entry, candidate))) return centerY;
): { x: number; y: number } {
const stepY = height + 3;
const stepX = width * 0.55;
const clampX = (value: number): number =>
Math.min(Math.max(value, box.left + width / 2), box.right - width / 2);
const candidates: Array<{ x: number; y: number }> = [];
for (let ring = 0; ring < 7; ring += 1) {
for (const dy of ring === 0 ? [0] : upward ? [-ring, ring] : [ring, -ring]) {
for (const dx of ring === 0 ? [0] : [0, -1, 1, -2, 2]) {
candidates.push({ x: clampX(preferredX + dx * stepX), y: preferredY + dy * stepY });
}
}
}
return preferredY;
for (const avoidCurve of [true, false]) {
for (const candidate of candidates) {
if (candidate.y - height / 2 < box.top || candidate.y + height / 2 > box.bottom) continue;
const rect: PlacedBox = {
left: candidate.x - width / 2,
right: candidate.x + width / 2,
top: candidate.y - height / 2,
bottom: candidate.y + height / 2,
};
if (placed.some((entry) => overlaps(entry, rect))) continue;
if (avoidCurve && hitsCurve(box, rect)) continue;
return candidate;
}
}
return { x: clampX(preferredX), y: preferredY };
}
/**
* balloon을 끌어 옮길 수 있게 한다. 자동 배치가 아무리 좋아도 200px 높이 그래프에서는
* 빈자리가 모자라므로, 마지막 판단은 사용자에게 맡긴다(2026-08-02 사용자 지시).
* 옮긴 만큼 지시선 끝점도 따라가고, 놓은 자리는 세션에 남는다.
*/
function attachBalloonDrag(
balloon: SVGGElement,
leader: SVGLineElement,
svg: SVGSVGElement,
key: number,
offsets: Map<number, [number, number]>,
home: { x: number; y: number },
): void {
let start: { x: number; y: number; dx: number; dy: number } | null = null;
const scale = (): number => {
const rendered = svg.getBoundingClientRect().width;
const viewBox = svg.viewBox.baseVal?.width || rendered;
return rendered > 0 && viewBox > 0 ? viewBox / rendered : 1;
};
const apply = (dx: number, dy: number): void => {
balloon.setAttribute("transform", `translate(${dx} ${dy})`);
leader.setAttribute("x2", String(home.x + dx));
leader.setAttribute("y2", String(home.y + dy));
};
balloon.addEventListener("pointerdown", (event) => {
if (event.button !== 0) return;
// 카드·측점 선택으로 번지면 그래프가 다시 그려져 끌던 balloon이 사라진다.
event.stopPropagation();
event.preventDefault();
const current = offsets.get(key) ?? [0, 0];
start = { x: event.clientX, y: event.clientY, dx: current[0], dy: current[1] };
balloon.setPointerCapture(event.pointerId);
balloon.classList.add("is-dragging");
});
balloon.addEventListener("pointermove", (event) => {
if (!start) return;
const factor = scale();
const dx = start.dx + (event.clientX - start.x) * factor;
const dy = start.dy + (event.clientY - start.y) * factor;
offsets.set(key, [dx, dy]);
apply(dx, dy);
});
const finish = (event: PointerEvent): void => {
if (!start) return;
start = null;
balloon.releasePointerCapture(event.pointerId);
balloon.classList.remove("is-dragging");
writeOffsets(offsets);
};
balloon.addEventListener("pointerup", finish);
balloon.addEventListener("pointercancel", finish);
// 두 번 누르면 자동 배치로 되돌린다 — 잘못 끌었을 때 되돌릴 길을 남긴다.
balloon.addEventListener("dblclick", (event) => {
event.stopPropagation();
offsets.delete(key);
apply(0, 0);
writeOffsets(offsets);
});
}
/** 계단형 평형선(기선) — 칸 사이는 수직선으로 이어 계단을 만든다. */
@@ -249,46 +375,55 @@ function appendBandGeometry(
/** 띠 balloon. 도형이 운반수단이고, 자리가 좁으면 줄을 줄여서라도 물량은 남긴다. */
function appendBandBalloon(
group: SVGGElement,
svg: SVGSVGElement,
block: HaulBlock,
band: HaulBand,
box: BalanceLayerBox,
placed: PlacedBox[],
offsets: Map<number, [number, number]>,
): 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;
// 화면 balloon은 도면과 달리 두 줄이 상한이다. EA/RR/BR·블록 번호는 툴팁이 맡는다.
const lines = [`${band.index} · ${compactVolume(band.volume_m3)}`];
if (room >= 110) {
if (room >= 120) {
lines.push(`L ${compactDistance(band.haul_distance_m)}m${equipment ? ` · ${equipment}` : ""}`);
}
if (room >= 150) {
lines.push(
`EA ${compactVolume(band.ea_m3)} · RR ${compactVolume(band.rr_m3)} · BR ${compactVolume(band.br_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);
const anchorX = box.x(block.apex_m);
const anchorY = (baseY + apexY) / 2;
const upward = block.direction === "forward";
const preferred = Math.min(
Math.max((baseY + apexY) / 2, box.top + height / 2),
box.bottom - height / 2,
const home = findSlot(
placed,
anchorX,
Math.min(Math.max(anchorY, box.top + height / 2), box.bottom - height / 2),
width,
height,
box,
upward,
);
const centerX = Math.min(
Math.max(box.x(block.apex_m), box.left + width / 2),
box.right - width / 2,
);
const centerY = findSlot(placed, centerX, preferred, width, height, box, upward);
placed.push({
left: centerX - width / 2,
right: centerX + width / 2,
top: centerY - height / 2,
bottom: centerY + height / 2,
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 =
@@ -296,27 +431,27 @@ function appendBandBalloon(
`${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이 밀려났을 때 어느 띠의 것인지 잇는다.
balloon.append(
title,
svgElement("line", {
x1: box.x(block.apex_m),
y1: (baseY + apexY) / 2,
x2: centerX,
y2: centerY,
class: "b06-balance__leader",
}),
balloonShape(EQUIPMENT_SHAPE[band.equipment ?? ""] ?? "rect", centerX, centerY, width, height),
balloonShape(EQUIPMENT_SHAPE[band.equipment ?? ""] ?? "rect", home.x, home.y, width, height),
...lines.map((line, index) =>
svgText(line, {
x: centerX,
y: centerY - height / 2 + PAD_Y + LINE_HEIGHT * (index + 0.8),
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(balloon);
group.append(leader, balloon);
const saved = offsets.get(band.index);
if (saved) {
balloon.setAttribute("transform", `translate(${saved[0]} ${saved[1]})`);
leader.setAttribute("x2", String(home.x + saved[0]));
leader.setAttribute("y2", String(home.y + saved[1]));
}
attachBalloonDrag(balloon, leader, svg, band.index, offsets, home);
return balloon;
}
@@ -388,26 +523,30 @@ export function appendBalanceLayer(svg: SVGSVGElement, plan: HaulPlan, box: Bala
for (const band of block.bands) faces.push(appendBandGeometry(group, block, band, box));
}
const placed: PlacedBox[] = [];
const offsets = readOffsets();
for (const block of plan.blocks) {
for (const band of block.bands)
balloons.push(appendBandBalloon(group, block, band, box, placed));
balloons.push(appendBandBalloon(group, svg, block, band, box, placed, offsets));
}
const clearAll = (): void => {
for (const face of faces) face.classList.remove("is-active");
for (const balloon of balloons) balloon?.classList.remove("is-active");
};
faces.forEach((face, index) => {
face.addEventListener("click", (event) => {
// 띠 면과 balloon은 **같은 짝**이라 어느 쪽을 눌러도 둘 다 켜진다(2026-08-02 사용자 지시).
const bind = (target: SVGGElement | null, index: number): void => {
target?.addEventListener("click", (event) => {
// 카드·측점 선택까지 올라가면 그래프가 다시 그려져 방금 켠 강조가 사라진다.
event.stopPropagation();
const already = face.classList.contains("is-active");
const already = faces[index].classList.contains("is-active");
clearAll();
if (already) return;
face.classList.add("is-active");
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);
@@ -284,6 +284,22 @@ export function createMassHaulChart(
const zeroY = y(0);
const visible = series.filter((entry) => visibleKeys.has(entry.key));
/** 주어진 가로 구간에서 표시 중인 곡선이 지나는 세로 범위. 말풍선·balloon 배치가 함께 쓴다. */
const curveYAt = (fromPx: number, toPx: number): { top: number; bottom: number } | null => {
let lowest = Number.POSITIVE_INFINITY;
let highest = Number.NEGATIVE_INFINITY;
for (const entry of visible) {
for (const point of entry.result.points) {
const px = x(point.chainage_m);
if (px < fromPx || px > toPx) continue;
const py = y(point.cumulative_volume_m3);
lowest = Math.min(lowest, py);
highest = Math.max(highest, py);
}
}
return Number.isFinite(lowest) ? { top: lowest, bottom: highest } : null;
};
// 면(band)은 첫 번째 표시 곡선에만 깐다 — 여러 곡선에 겹쳐 칠하면 서로 가려 못 읽는다.
const banded = visible[0];
if (banded) {
@@ -375,6 +391,7 @@ export function createMassHaulChart(
right: widthPx - LONG_PAD.right,
top: MASS_PAD_TOP,
bottom: heightPx - MASS_PAD_BOTTOM,
curveYAt,
});
}
@@ -397,21 +414,7 @@ export function createMassHaulChart(
right: widthPx - LONG_PAD.right,
top: MASS_PAD_TOP,
bottom: heightPx - MASS_PAD_BOTTOM,
curveYAt: (fromPx, toPx) => {
// 말풍선이 놓일 가로 구간에서 곡선들이 실제로 지나는 세로 범위.
let lowest = Number.POSITIVE_INFINITY;
let highest = Number.NEGATIVE_INFINITY;
for (const entry of visible) {
for (const point of entry.result.points) {
const px = x(point.chainage_m);
if (px < fromPx || px > toPx) continue;
const py = y(point.cumulative_volume_m3);
lowest = Math.min(lowest, py);
highest = Math.max(highest, py);
}
}
return Number.isFinite(lowest) ? { top: lowest, bottom: highest } : null;
},
curveYAt,
});
}
@@ -203,16 +203,25 @@
fill: color-mix(in srgb, var(--color-warning) 22%, transparent);
}
/* balloon은 끌어 옮길 수 있다(더블클릭이면 자동 배치로 복귀) — 커서로 그 사실을 알린다. */
.b06-balance__balloon {
cursor: move;
}
.b06-balance__balloon.is-dragging {
cursor: grabbing;
}
.b06-balance__balloon.is-dragging > .b06-balance__balloon-shape {
stroke-width: 2;
}
/* 눌린 띠의 balloon은 테두리를 굵혀 어느 띠의 값인지 바로 짚이게 한다. */
.b06-balance__balloon.is-active > .b06-balance__balloon-shape {
fill: color-mix(in srgb, var(--color-warning) 20%, var(--color-surface-raised));
stroke-width: 2;
}
.b06-balance__balloon.is-active > .b06-balance__leader {
opacity: 1;
}
/* 계단의 수직 연결선 — 사토·토취가 나는 자리라 파선으로 "옮겨 갔음"을 드러낸다. */
.b06-balance__riser {
stroke: var(--color-warning);