/* ============================================================================= * common_util_mass_haul_balloon.ts * 유토곡선 balloon의 공통 부품 — 좌표 상자 · 도형 · 자리 찾기 · 드래그 · 위치 보관. * * 토량 분배 렌더러(`_UI_MassHaul_Balance_View`)가 700줄을 넘겨, 「무엇을 그리나」(그쪽)와 * 「어떻게 놓고 어떻게 잡나」(여기)로 갈랐다. 띠 balloon과 사토·토취 라벨이 이 부품을 * **함께** 쓰므로 배치·드래그 규칙이 한 곳에서만 정의된다. * ========================================================================== */ import { L } from "./common_util_svg"; import type { LocaleKey } from "@ui/ui_template_locale"; import { svgElement } from "./common_util_svg"; /** 유토곡선 렌더러가 넘겨주는 좌표계와 그릴 수 있는 상자(px). */ export interface BalanceLayerBox { x: (chainageM: number) => number; y: (volumeM3: number) => number; left: number; right: number; top: number; bottom: number; /** * 주어진 가로 구간에서 곡선이 실제로 지나는 세로 범위(없으면 null). * balloon을 곡선과 겹치지 않는 자리에 놓기 위해 유토곡선 렌더러가 넘겨준다. */ curveYAt?: (fromPx: number, toPx: number) => { top: number; bottom: number } | null; /** 사토·토취 발생 위치를 측점으로 적기 위한 측점 간격(m). */ stationInterval?: number; } export const EQUIPMENT_LABEL: Record = { free_haul: "B06_MassHaul_Equip_FreeHaul", dozer: "B06_MassHaul_Equip_Dozer", dump_truck: "B06_MassHaul_Equip_Dump", }; /** balloon 도형 = 운반수단. 모르는 장비는 사각으로 떨어뜨린다. */ export type BalloonShape = "hexagon" | "ellipse" | "rect"; export const EQUIPMENT_SHAPE: Record = { free_haul: "hexagon", dozer: "ellipse", dump_truck: "rect", }; /* balloon 치수 — 도면과 달리 화면 그래프는 200px 안팎이라 최대한 작게 잡는다 (2026-08-02 사용자 지적: 도형이 크고 빈자리를 못 찾는다). 자세한 값은 툴팁이 맡는다. */ export const LINE_HEIGHT = 10; export const PAD_X = 5; export const PAD_Y = 3; /** 육각형 좌우 꼭짓점이 파고드는 깊이(px). */ export const NOTCH = 5; /** balloon과 곡선 사이 최소 틈(px). */ export const CURVE_CLEARANCE = 5; /** 이보다 좁은 띠에는 balloon을 달지 않는다 — 서로 겹쳐 도면을 못 읽는다. */ export const MIN_BAND_WIDTH_PX = 34; /** * 그래프가 이만큼 높으면 balloon을 **도면 양식(항목별 세로 표기)**으로 편다 * (2026-08-02 사용자 지시). 도면 balloon이 `종무대 1 / Q= / L= / EA= / RR= / BR=` 6줄이다. * 기본 패널 높이(유토곡선 190px)에서 바로 이 양식이 나온다. */ export const FULL_BALLOON_ROOM_PX = 165; /** 그보다 낮으면 두 줄, 더 낮으면 물량 한 줄만 남긴다. */ export const TWO_LINE_ROOM_PX = 120; /** 이보다 얇은 띠에는 평균운반거리 선을 긋지 않는다(경계현과 붙어 한 줄로 보인다). */ export const MIN_BAND_THICKNESS_PX = 9; /** * 사용자가 끌어 옮긴 balloon 위치(띠 번호 → [dx, dy]). * * 저장 경로가 둘이다: * ① **프론트 캐시**(localStorage) — 끌어 옮기는 즉시. 새로고침·재접속에도 남는다. * ② **영구저장소** — 횡단 확정 시점에 `mass_haul.balloon_offsets`로 넘어간다. * 진입할 때는 ②가 있으면 그것으로 캐시를 덮어써 **다른 브라우저에서도 같은 자리**에 뜬다 * (2026-08-02 사용자 지시). 캐시는 경로별로 갈라 다른 노선의 위치가 섞이지 않게 한다. */ const BALLOON_OFFSET_PREFIX = "b06:balance-balloon-offset"; let offsetStorageKey = BALLOON_OFFSET_PREFIX; export let balloonOffsets = new Map(); function parseOffsets(source: unknown): Map { const result = new Map(); if (!source || typeof source !== "object") return result; for (const [key, value] of Object.entries(source as Record)) { if (!Array.isArray(value) || value.length !== 2) continue; const [dx, dy] = value as [number, number]; if (Number.isFinite(dx) && Number.isFinite(dy)) result.set(Number(key), [dx, dy]); } return result; } export function writeOffsets(): void { try { localStorage.setItem(offsetStorageKey, JSON.stringify(balloonOffsetsPayload())); } catch { /* 저장 실패는 무시 — 위치는 화면이 살아 있는 동안 유지된다. */ } } /** * 노선이 바뀌거나 상세를 새로 받았을 때 캐시를 맞춘다. * `stored`(영구저장소 값)가 있으면 **그것이 이긴다** — 다른 브라우저에서 옮긴 자리를 그대로 * 받아야 하기 때문이다. 없으면 이 브라우저의 캐시를 그대로 이어 쓴다. */ export function configureBalloonOffsets(scope: string, stored?: unknown): void { offsetStorageKey = `${BALLOON_OFFSET_PREFIX}:${scope}`; if (stored && Object.keys(stored as object).length) { balloonOffsets = parseOffsets(stored); writeOffsets(); return; } try { balloonOffsets = parseOffsets(JSON.parse(localStorage.getItem(offsetStorageKey) ?? "null")); } catch { balloonOffsets = new Map(); } } /** 확정 시 영구저장소로 넘길 형태. */ export function balloonOffsetsPayload(): Record { return Object.fromEntries([...balloonOffsets].map(([key, value]) => [String(key), value])); } /** 자동 배치로 되돌린다 — 프론트 캐시를 비우고, 다음 확정 때 영구저장소도 빈 값으로 덮인다. */ export function resetBalloonOffsets(): void { balloonOffsets = new Map(); try { localStorage.removeItem(offsetStorageKey); } catch { /* 무시 */ } } /** SVG는 자동 크기가 없어 폭을 어림해야 한다. 한글은 라틴 글자보다 넓다. */ export function textWidth(value: string): number { let width = 0; for (const char of value) width += /[가-힣ㄱ-ㅎㅏ-ㅣ]/.test(char) ? 9.5 : 5.2; return width; } /** balloon 안 수치는 자리를 아껴야 한다 — 천 단위 구분만 두고 소수점은 버린다. */ export function compactVolume(value: number): string { return Math.round(value).toLocaleString(); } export function compactDistance(value: number): string { return value >= 100 ? `${Math.round(value)}` : value.toFixed(1); } export function equipmentLabel(key: string | null): string { if (!key) return ""; const locale = EQUIPMENT_LABEL[key]; return locale ? L(locale) : key; } /** 육각형 — 좌우 끝이 뾰족한 도면용 모양. */ export function hexagonPoints(cx: number, cy: number, width: number, height: number): string { const halfW = width / 2; const halfH = height / 2; const notch = Math.min(NOTCH, width * 0.2); return [ `${cx - halfW},${cy}`, `${cx - halfW + notch},${cy - halfH}`, `${cx + halfW - notch},${cy - halfH}`, `${cx + halfW},${cy}`, `${cx + halfW - notch},${cy + halfH}`, `${cx - halfW + notch},${cy + halfH}`, ].join(" "); } export function balloonShape( shape: BalloonShape, cx: number, cy: number, width: number, height: number, ): SVGElement { if (shape === "ellipse") { // 원형은 같은 글자를 담으려면 사각보다 커야 한다 — 모서리가 비기 때문이다. return svgElement("ellipse", { cx, cy, rx: width / 2 + 5, ry: height / 2 + 3, class: "b06-balance__balloon-shape", }); } if (shape === "rect") { return svgElement("rect", { x: cx - width / 2, y: cy - height / 2, width, height, rx: 2, ry: 2, class: "b06-balance__balloon-shape", }); } return svgElement("polygon", { points: hexagonPoints(cx, cy, width, height), class: "b06-balance__balloon-shape", }); } export interface PlacedBox { left: number; right: number; top: number; bottom: number; } export function overlaps(a: PlacedBox, b: PlacedBox): boolean { return !(a.right <= b.left || a.left >= b.right || a.bottom <= b.top || a.top >= b.bottom); } /** 이 자리에 놓으면 곡선과 겹치는가. 곡선 정보를 못 받았으면 겹치지 않는 것으로 본다. */ export 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 사용자 지적: 남는 공간을 못 찾음). * * 곡선까지 피하는 자리가 끝내 없으면 **곡선 조건만 버리고** 다시 훑는다 — 겹치더라도 놓는 게 * 안 그리는 것보다 낫고, 사용자가 끌어 옮길 수 있다. */ export function findSlot( placed: PlacedBox[], preferredX: number, preferredY: number, width: number, height: number, box: BalanceLayerBox, upward: boolean, ): { 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 }); } } } 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; } } // 빈자리를 못 찾아도 **상자 안에는** 넣는다 — 안 그러면 라벨이 그래프 밖으로 나간다 // (2026-08-02 사용자 지적). return { x: clampX(preferredX), y: Math.min(Math.max(preferredY, box.top + height / 2), box.bottom - height / 2), }; } /** * balloon을 끌어 옮길 수 있게 한다. 자동 배치가 아무리 좋아도 200px 높이 그래프에서는 * 빈자리가 모자라므로, 마지막 판단은 사용자에게 맡긴다(2026-08-02 사용자 지시). * 옮긴 만큼 지시선 끝점도 따라가고, 놓은 자리는 세션에 남는다. */ export function attachBalloonDrag( balloon: SVGGElement, leader: SVGLineElement, svg: SVGSVGElement, key: number, home: { x: number; y: number }, /** 옮긴 자리를 그릴 수 있는 범위 안으로 자르는 함수. 없으면 자르지 않는다. */ clampOffset?: (dx: number, dy: number) => [number, 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 clampTo = (dx: number, dy: number): [number, number] => clampOffset ? clampOffset(dx, dy) : [dx, dy]; 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 = balloonOffsets.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, dy] = clampTo( start.dx + (event.clientX - start.x) * factor, start.dy + (event.clientY - start.y) * factor, ); balloonOffsets.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(); }; balloon.addEventListener("pointerup", finish); balloon.addEventListener("pointercancel", finish); // 두 번 누르면 자동 배치로 되돌린다 — 잘못 끌었을 때 되돌릴 길을 남긴다. balloon.addEventListener("dblclick", (event) => { event.stopPropagation(); balloonOffsets.delete(key); apply(0, 0); writeOffsets(); }); }