"use client"; import { useEffect, useRef, useState, type RefObject } from "react"; import "./balance-stats.css"; /** * Balance Stats * Money in against money out, on a real balance. Each coin is a fixed sum; * they drop into the two pans one at a time as the scale scrolls into view, * and every landing knocks the beam, which swings and settles at an angle * set by the difference. The needle at the pivot reads the surplus. Pick * another month and the coins that no longer belong lift away while new * ones fall, and the beam finds its new rest. */ type Month = { label: string; left: number; right: number }; type Props = { months: Month[]; /** Names of the two pans. */ names?: [string, string]; /** How much one coin is worth. */ coin?: number; currency?: string; title?: string; theme?: "paper" | "night"; motion?: "full" | "reduced"; className?: string; }; const W = 520, H = 292; const PX = W / 2, PY = 92; // pivot const ARM = 168; // half-beam const CHAIN = 96; // pivot-to-pan drop const MAX_TILT = 14; // degrees const fmt = (n: number) => Math.round(n).toLocaleString("en-US"); /** Where coin i sits in a pan: a little pyramid of stacked columns. */ function coinSpot(i: number) { const perCol = 6; const cols = [0, -1, 1, -2, 2, -3, 3]; const c = Math.floor(i / perCol), r = i % perCol; return { x: cols[c % cols.length] * 19 + (c >= cols.length ? 9 : 0), y: -r * 6.2 }; } export function BalanceStats({ months, names = ["Earned", "Spent"], coin = 500, currency = "OMR", title = "Earned and spent", theme = "paper", motion = "full", className = "" }: Props) { const root = useRef(null); const beam = useRef(null); const panL = useRef(null); const panR = useRef(null); const needle = useRef(null); const [mi, setMi] = useState(months.length - 1); const [on, setOn] = useState(false); const [count, setCount] = useState<[number, number]>([0, 0]); const phys = useRef({ a: 0, v: 0, raf: 0, last: 0 }); const m = months[mi]; const target: [number, number] = [Math.round(m.left / coin), Math.round(m.right / coin)]; const still = () => motion === "reduced" || (typeof window !== "undefined" && matchMedia("(prefers-reduced-motion: reduce)").matches); useEffect(() => { const el = root.current; if (!el) return; const io = new IntersectionObserver(([e]) => { if (e.isIntersecting) { setOn(true); io.disconnect(); } }, { threshold: 0.35 }); io.observe(el); return () => io.disconnect(); }, []); // Coins move one at a time toward the month's counts, alternating pans so the beam rocks as it fills. useEffect(() => { if (!on) return; if (still()) { setCount(target); return; } const id = window.setInterval(() => { setCount((c) => { const d0 = target[0] - c[0], d1 = target[1] - c[1]; if (!d0 && !d1) { window.clearInterval(id); return c; } // Whichever pan is further from done moves next. if (Math.abs(d0) >= Math.abs(d1)) return [c[0] + Math.sign(d0), c[1]]; return [c[0], c[1] + Math.sign(d1)]; }); }, 85); return () => window.clearInterval(id); // eslint-disable-next-line react-hooks/exhaustive-deps }, [on, mi]); // The beam: a lightly damped spring toward the angle the weights ask for; each change knocks it. const weightL = count[0] * coin, weightR = count[1] * coin; const want = weightL + weightR ? Math.max(-MAX_TILT, Math.min(MAX_TILT, ((weightR - weightL) / (weightL + weightR)) * 90)) : 0; useEffect(() => { const st = phys.current; const draw = () => { const a = st.a, r = (a * Math.PI) / 180; beam.current?.setAttribute("transform", `rotate(${a.toFixed(3)} ${PX} ${PY})`); const lx = PX - Math.cos(r) * ARM, ly = PY - Math.sin(r) * ARM; const rx = PX + Math.cos(r) * ARM, ry = PY + Math.sin(r) * ARM; panL.current?.setAttribute("transform", `translate(${lx.toFixed(2)} ${ly.toFixed(2)})`); panR.current?.setAttribute("transform", `translate(${rx.toFixed(2)} ${ry.toFixed(2)})`); needle.current?.setAttribute("transform", `rotate(${(-a * 2.2).toFixed(3)} ${PX} ${PY})`); }; if (still()) { st.a = want; st.v = 0; draw(); return; } st.v += (want - st.a) * 0.6; // the knock of a coin landing const step = (now: number) => { const dt = Math.min(0.032, st.last ? (now - st.last) / 1000 : 0.016); st.last = now; const k = 38, c = 2 * 0.32 * Math.sqrt(k); st.v += ((want - st.a) * k - st.v * c) * dt; st.a += st.v * dt; draw(); if (Math.abs(want - st.a) > 0.01 || Math.abs(st.v) > 0.02) st.raf = requestAnimationFrame(step); else { st.a = want; st.v = 0; st.raf = 0; st.last = 0; draw(); } }; cancelAnimationFrame(st.raf); st.raf = requestAnimationFrame(step); return () => { cancelAnimationFrame(st.raf); st.raf = 0; st.last = 0; }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [want, motion]); const diff = m.left - m.right; const most: [number, number] = [Math.max(...months.map((x) => Math.round(x.left / coin))), Math.max(...months.map((x) => Math.round(x.right / coin)))]; const coins = (n: number, side: 0 | 1) => // Every coin either pan could ever hold is drawn, so coins that leave can lift away rather than vanish. Array.from({ length: most[side] }, (_, i) => { const p = coinSpot(i); const live = i < n; return ( ); }); const pan = (side: 0 | 1, ref: RefObject) => ( {/* Chains: three strings from the hook to the dish rim. */} {coins(count[side], side)} ); return (

{diff >= 0 ? "+" : "−"} {currency} {fmt(Math.abs(diff))}

{diff >= 0 ? "surplus" : "shortfall"} in {m.label}

{months.map((x, i) => ( ))}
= 0 ? "surplus" : "shortfall"} of ${currency} ${fmt(Math.abs(diff))}.`}> {/* Stand */} {/* Dial behind the pivot, read by the needle */} {[-30, -15, 0, 15, 30].map((d) => { const r = ((d - 90) * Math.PI) / 180; return ; })} {pan(0, panL)} {pan(1, panR)}

1 coin = {currency} {fmt(coin)}

{/* Tables ignore overflow, so the hidden table sits inside a hidden box. */}
{months.map((x) => ( ))}
{title}
Month{names[0]}{names[1]}Difference
{x.label}{fmt(x.left)}{fmt(x.right)}{fmt(x.left - x.right)}
); }