"use client";
import { useEffect, useId, useRef, useState, type CSSProperties, type ReactNode } from "react";
import "./orbit-ring-stats.css";
/**
* Orbit Ring Stats
* Ring gauges whose arc is a slowly turning gradient. On scroll-in each arc
* sweeps to its value and the number rolls in. The metric's name and goal
* orbit the ring as a line of small caps, slowing right down when you look at
* it; hover floods the centre with the ring's colour and the number inverts
* exactly where the flood has reached.
*/
export type Orbit = {
label: string;
value: number;
max?: number;
unit?: string;
/** Short line under the ring, e.g. "+6 pts on Q2". */
note?: string;
/** Words that orbit the ring. Defaults to the label. */
orbit?: string;
};
type Props = {
rings: Orbit[];
label?: string;
theme?: "paper" | "night";
motion?: "full" | "reduced";
className?: string;
};
const R = 93; // orbit radius in the 200-unit viewBox
const LAP = 2 * Math.PI * R;
const FAST = 7; // deg/s at rest
const SLOW = 0.8; // deg/s while hovered
function Reels({ text, on }: { text: string; on: boolean }) {
const chars = text.split("");
return (
<>
{chars.map((c, i) => {
const key = chars.length - i;
return /\d/.test(c) ? (
{Array.from({ length: 10 }, (_, k) => {k})}
) : (
{c}
);
})}
>
);
}
export function OrbitRingStats({ rings, label = "Key figures", theme = "night", motion = "full", className = "" }: Props) {
const uid = useId().replace(/:/g, "");
const root = useRef(null);
const spins = useRef<(SVGGElement | null)[]>([]);
const hot = useRef(-1);
const raf = useRef(0);
const [on, setOn] = useState(false);
// Start once, when a third of the row is visible.
useEffect(() => {
const el = root.current!;
const io = new IntersectionObserver(([e]) => { if (e.isIntersecting) { setOn(true); io.disconnect(); } }, { threshold: 0.35 });
io.observe(el);
return () => io.disconnect();
}, []);
// The orbiting words: one loop for every ring, each easing toward its own speed. Paused offscreen.
useEffect(() => {
const reduce = motion === "reduced" || matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduce) return;
const ang = rings.map((_, i) => i * 47);
const vel = rings.map(() => FAST);
let last = 0;
const tick = (t: number) => {
const dt = last ? Math.min(0.05, (t - last) / 1000) : 0;
last = t;
for (let i = 0; i < rings.length; i++) {
const want = hot.current === i ? SLOW : FAST;
vel[i] += (want - vel[i]) * Math.min(1, dt * 4);
ang[i] = (ang[i] + vel[i] * dt) % 360;
spins.current[i]?.setAttribute("transform", `rotate(${ang[i].toFixed(2)} 100 100)`);
}
raf.current = requestAnimationFrame(tick);
};
const io = new IntersectionObserver(([e]) => {
if (e.isIntersecting && !raf.current) { last = 0; raf.current = requestAnimationFrame(tick); }
if (!e.isIntersecting && raf.current) { cancelAnimationFrame(raf.current); raf.current = 0; }
});
io.observe(root.current!);
return () => { io.disconnect(); cancelAnimationFrame(raf.current); raf.current = 0; };
}, [rings, motion]);
return (
{rings.map((g, i) => {
const max = g.max ?? 100;
const p = Math.max(0, Math.min(1, g.value / max));
const words = (g.orbit ?? g.label).toUpperCase();
// Repeat the words until they go round once, then spread them to exactly one lap.
const reps = Math.max(1, Math.round(LAP / ((words.length + 3) * 5.4)));
const lap = Array.from({ length: reps }, () => `${words} ยท `).join("");
const centre: ReactNode = (
{g.unit && {g.unit}}
);
return (
- (hot.current = i)} onPointerLeave={() => (hot.current = -1)}>
{centre}
{centre}
{g.label}
{g.note && {g.note}
}
);
})}
);
}