From c490e2ff4550430266f3deadc65f3f4aa84c8bcf Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 2 Aug 2026 17:00:05 +0900 Subject: [PATCH] =?UTF-8?q?feat(B06):=20=EC=9C=A0=ED=86=A0=EA=B3=A1?= =?UTF-8?q?=EC=84=A0=20balloon=20=EC=B6=95=EC=86=8C=C2=B7=EA=B3=A1?= =?UTF-8?q?=EC=84=A0=20=ED=9A=8C=ED=94=BC=20=EB=B0=B0=EC=B9=98=C2=B7?= =?UTF-8?q?=EB=93=9C=EB=9E=98=EA=B7=B8=20=EC=9D=B4=EB=8F=99=20+=20?= =?UTF-8?q?=EC=96=91=EB=B0=A9=ED=96=A5=20=ED=95=98=EC=9D=B4=EB=9D=BC?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 도형이 크고 남는 공간을 못 찾는다는 지적 반영. - balloon을 두 줄 상한(번호·물량 / L·장비)으로 줄이고 여백·육각 노치 축소. EA/RR/BR은 툴팁으로 옮김 - 배치를 세로 밀기에서 가로·세로 링 탐색으로 바꾸고 curveYAt으로 곡선까지 회피. 못 피하면 곡선 조건만 버리고 재탐색(안 그리는 것보다 낫다) - balloon을 끌어 옮길 수 있고 지시선 끝점이 따라간다. 옮긴 자리는 세션에 남고 더블클릭이면 자동 배치로 복귀. 지시선은 balloon 밖에 둬야 같이 끌려가지 않는다 - 띠 면과 balloon을 같은 짝으로 묶어 어느 쪽을 눌러도 둘 다 강조된다 검증: 4개 크기에서 NaN 0건, balloon 11개 중 곡선 겹침 0~1개(이전엔 회피 없음), 도형·클릭 짝·드래그·세션 저장·더블클릭 복귀 확인. Co-Authored-By: Claude Opus 5 --- ...3_ProfileCross_UI_MassHaul_Balance_View.ts | 255 ++++++++++++++---- .../B06_wf3_ProfileCross_UI_MassHaul_View.ts | 33 +-- ...B06_wf3_ProfileCross_UI_Style_MassHaul.css | 17 +- 3 files changed, 228 insertions(+), 77 deletions(-) diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul_Balance_View.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul_Balance_View.ts index 8297f784..e72a8690 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul_Balance_View.ts +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul_Balance_View.ts @@ -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 = { @@ -48,16 +53,54 @@ const EQUIPMENT_SHAPE: Record = { 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 { + 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).map(([key, value]) => [ + Number(key), + value, + ]), + ); + } catch { + return new Map(); + } +} + +function writeOffsets(offsets: Map): 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, + 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, ): 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); diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul_View.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul_View.ts index f8223177..815a0276 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul_View.ts +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul_View.ts @@ -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, }); } diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style_MassHaul.css b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style_MassHaul.css index 5aca301c..30a3fbb6 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style_MassHaul.css +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style_MassHaul.css @@ -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);