/* ============================================================================= * common_util_mass_haul_badge.ts * 「최종 누가토량」 배지 — 종단 그래프 **좌측 상단에 겹쳐** 띄우는 한 줄. * * 왜 배지인가(2026-09-06 사용자 지시) — 유토곡선 그래프는 자리를 많이 먹는데, 계획선을 * 만지며 늘 봐야 하는 값은 **마지막 지점의 누가토량 하나**다. 그래서 그 값만 그래프 위에 * 겹쳐 두고, 곡선 자체는 B05 의 유토곡선 패널에서만 펼쳐 본다(B06 에서는 곡선을 뺐다). * * 기준은 **횡단**이다 — 종단 기준 곡선은 계획선만 보는 개략값이라 뒤에 없앨 예정이다 * (2026-09-06 사용자). 값을 넣는 쪽에서 횡단 기준 계열을 골라 넘긴다. * ========================================================================== */ /** 배지에 실리는 값 한 벌 — 유토곡선 계산 결과에서 뽑는다. */ export interface MassHaulBadgeValues { /** 마지막 측점의 누계 토량(㎥). 음수면 부족(토취). */ finalM3: number; cutNaturalM3: number; cutCompactedM3: number; fillCompactedM3: number; surplusM3: number; shortageM3: number; } /** 누가토량 표기 — 천 단위 구분 + 소수점 1자리(유토곡선 요약과 같은 규칙). */ function volume(value: number): string { return `${value.toLocaleString("ko-KR", { maximumFractionDigits: 1 })}㎥`; } export interface MassHaulBadge { /** 그래프 컨테이너(`position: relative`)에 붙일 요소. */ root: HTMLElement; /** 값을 갈아 끼운다. null 이면 배지를 숨긴다(아직 계산 못 한 상태). */ set: (values: MassHaulBadgeValues | null) => void; } /** 유토곡선 계산 결과 한 벌에서 배지 값을 뽑는다 — B05·B06 이 같은 규칙을 쓴다. */ export function badgeValuesFrom(result: { points: ReadonlyArray<{ cumulative_volume_m3: number }>; cut_natural_m3: { soil: number; ripping_rock: number; blasting_rock: number }; cut_compacted_m3: number; fill_compacted_m3: number; surplus_m3: number; shortage_m3: number; }): MassHaulBadgeValues { const cut = result.cut_natural_m3; const points = result.points; return { finalM3: points.length ? points[points.length - 1].cumulative_volume_m3 : 0, cutNaturalM3: cut.soil + cut.ripping_rock + cut.blasting_rock, cutCompactedM3: result.cut_compacted_m3, fillCompactedM3: result.fill_compacted_m3, surplusM3: result.surplus_m3, shortageM3: result.shortage_m3, }; } /** 배지를 만든다. 붙이는 것은 부르는 쪽 몫이다(그래프 컨테이너 첫 자식). */ export function createMassHaulBadge(): MassHaulBadge { const root = document.createElement("span"); root.className = "mass-haul-badge"; root.hidden = true; const caption = document.createElement("em"); caption.textContent = "누가토량"; const value = document.createElement("strong"); root.append(caption, value); return { root, set(values) { if (!values) { root.hidden = true; return; } root.hidden = false; root.classList.toggle("is-over", values.finalM3 < 0); value.textContent = volume(values.finalM3); root.title = [ `절토(자연) ${volume(values.cutNaturalM3)} · 절토(다짐) ${volume(values.cutCompactedM3)}`, `성토(다짐) ${volume(values.fillCompactedM3)}`, values.shortageM3 > 0 ? `부족 ${volume(values.shortageM3)}` : `잉여 ${volume(values.surplusM3)}`, "기준: 횡단", ].join("\n"); }, }; } /** 알약 자리 저장 창구(화면 설정 한 칸) — 값 = `{"x":0~1,"y":0~1}` 글. */ export interface MassHaulBadgePlace { read: () => string | null; write: (value: string) => void; } /** 끈 것으로 보는 움직임(px) — 이 안이면 그냥 누름(지금 동작 그대로). */ const DRAG_SLOP_PX = 4; /** * 알약 끌어 옮기기(309-2) — 마우스 · 손가락 같은 pointer 길 · `area`(그래프 스크롤러)의 보이는 상자 안에서만. * 영역 = 알약 처음 자리(Y축 띠 오른쪽 · 측점 ▲ 줄 아래)가 왼쪽 위 끝 · 스크롤러 보이는 오른쪽 · 아래 끝까지. * 자리 = 영역 안 비율 — 폭 · 높이가 바뀌거나 다시 그려도 같은 비율. 알약은 그래프 형제라 끌기가 그래프 고름에 안 닿음. */ export function attachMassHaulBadgeDrag( root: HTMLElement, area: HTMLElement, place: MassHaulBadgePlace, ): void { let origin: { x: number; y: number } | null = null; const bounds = () => { if (!origin) { const [left, top] = [root.style.left, root.style.top]; root.style.left = root.style.top = ""; origin = { x: root.offsetLeft, y: root.offsetTop }; [root.style.left, root.style.top] = [left, top]; } const w = area.offsetLeft + area.clientWidth - root.offsetWidth - origin.x; const h = area.offsetTop + area.clientHeight - root.offsetHeight - origin.y; return { ...origin, w: Math.max(0, w), h: Math.max(0, h) }; }; const clamp = (v: number): number => Math.min(1, Math.max(0, v)); const put = (x: number, y: number): void => { const b = bounds(); root.style.left = `${Math.round(b.x + clamp(x) * b.w)}px`; root.style.top = `${Math.round(b.y + clamp(y) * b.h)}px`; }; const restore = (): void => { if (root.hidden || !root.isConnected) return; try { const v = JSON.parse(place.read() ?? "null") as { x?: unknown; y?: unknown } | null; if (typeof v?.x === "number" && typeof v.y === "number") put(v.x, v.y); } catch { /* 못 읽는 값 = 처음 자리 */ } }; new ResizeObserver(restore).observe(area); new MutationObserver(restore).observe(root, { attributeFilter: ["hidden"] }); let drag: { id: number; px: number; py: number; left: number; top: number } | null = null; let moved: { x: number; y: number } | null = null; root.addEventListener("pointerdown", (e) => { if (e.button !== 0) return; drag = { id: e.pointerId, px: e.clientX, py: e.clientY, left: root.offsetLeft, top: root.offsetTop, }; moved = null; root.setPointerCapture(e.pointerId); }); root.addEventListener("pointermove", (e) => { if (!drag || e.pointerId !== drag.id) return; const dx = e.clientX - drag.px; const dy = e.clientY - drag.py; if (!moved && Math.hypot(dx, dy) < DRAG_SLOP_PX) return; const b = bounds(); moved = { x: b.w ? clamp((drag.left + dx - b.x) / b.w) : 0, y: b.h ? clamp((drag.top + dy - b.y) / b.h) : 0, }; root.classList.add("is-dragging"); put(moved.x, moved.y); }); const end = (e: PointerEvent): void => { if (!drag || e.pointerId !== drag.id) return; drag = null; root.classList.remove("is-dragging"); if (moved) place.write(JSON.stringify({ x: +moved.x.toFixed(4), y: +moved.y.toFixed(4) })); }; root.addEventListener("pointerup", end); root.addEventListener("pointercancel", end); // 끈 뒤 따라오는 click 은 삼킴 — 안 끈 누름은 그대로 지나감 root.addEventListener( "click", (e) => { if (!moved) return; moved = null; e.stopPropagation(); e.preventDefault(); }, true, ); }