"use client"; import { motion, type MotionValue, useReducedMotion, useSpring, useTransform } from "motion/react"; import { type KeyboardEvent, useEffect, useMemo, useRef, useState } from "react"; import { cn } from "@/lib/utils"; export interface DonutSlice { id: string; label: string; value: number; color?: string; hint?: string; } export interface DonutLegendProps { title?: string; caption?: string; slices?: DonutSlice[]; totalLabel?: string; format?: Intl.NumberFormatOptions; locale?: string; className?: string; } const PALETTE = [ "var(--color-violet-500)", "var(--color-rose-600)", "var(--color-sky-600)", "var(--color-amber-600)", "var(--color-emerald-600)", ]; const DIGITS = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]; const SIZE = 176; const C = SIZE / 2; const R_OUT = 84; const R_IN = 58; const defaultSlices: DonutSlice[] = [ { id: "compute", label: "Compute", value: 21480, hint: "38 instances" }, { id: "database", label: "Database", value: 9875, hint: "4 clusters" }, { id: "storage", label: "Object storage", value: 7120, hint: "212 TB" }, { id: "network", label: "Network egress", value: 5310, hint: "64 TB out" }, { id: "other", label: "Everything else", value: 4425, hint: "Queues, DNS, logs" }, ]; function RollingNumber({ text, instant, className }: { text: string; instant?: boolean; className?: string }) { const reduce = useReducedMotion(); let d = 0; let o = 0; const glyphs = [...text] .reverse() .map((ch) => { const isDigit = ch >= "0" && ch <= "9"; return { ch, key: isDigit ? `d${d++}` : `s${o++}${ch}`, digit: isDigit ? Number(ch) : null }; }) .reverse(); return ( {text} {glyphs.map((g) => g.digit === null ? ( {g.ch} ) : ( 0 {DIGITS.map((n) => ( {n} ))} ), )} ); } function arc(a0: number, a1: number, rOut: number, rIn: number) { const sweep = Math.min(Math.PI * 2 - 0.0001, Math.max(0, a1 - a0)); if (sweep <= 0.0005) return ""; const end = a0 + sweep; const large = sweep > Math.PI ? 1 : 0; const p = (r: number, a: number) => `${(C + r * Math.sin(a)).toFixed(3)} ${(C - r * Math.cos(a)).toFixed(3)}`; return `M${p(rOut, a0)} A${rOut} ${rOut} 0 ${large} 1 ${p(rOut, end)} L${p(rIn, end)} A${rIn} ${rIn} 0 ${large} 0 ${p(rIn, a0)} Z`; } function Slice({ start, end, color, active, dimmed, onEnter, }: { start: number; end: number; color: string; active: boolean; dimmed: boolean; onEnter: () => void; }) { const reduce = useReducedMotion(); const s = useSpring(0, { stiffness: 140, damping: 22, mass: 0.9 }); const e = useSpring(0, { stiffness: 140, damping: 22, mass: 0.9 }); const grow = useSpring(0, { stiffness: 420, damping: 30 }); useEffect(() => { if (reduce) { s.jump(start); e.jump(end); } else { s.set(start); e.set(end); } }, [start, end, s, e, reduce]); useEffect(() => { if (reduce) grow.jump(active ? 1 : 0); else grow.set(active ? 1 : 0); }, [active, grow, reduce]); const d = useTransform([s, e, grow] as MotionValue[], ([a0, a1, g]: number[]) => { return arc(a0, a1, R_OUT + g * 6, R_IN - g * 2); }); return ( Math.PI * 2 - 0.01 ? 0 : 2} onPointerEnter={onEnter} style={{ fill: color }} transition={{ duration: reduce ? 0 : 0.15 }} /> ); } export function DonutLegend({ title = "Cloud spend", caption = "October, month to date", slices = defaultSlices, totalLabel = "Total", format = { style: "currency", currency: "USD", maximumFractionDigits: 0 }, locale = "en-US", className, }: DonutLegendProps) { const reduce = useReducedMotion(); const [active, setActive] = useState(null); const [hidden, setHidden] = useState>(() => new Set()); const rowRefs = useRef<(HTMLButtonElement | null)[]>([]); const fmt = useMemo(() => new Intl.NumberFormat(locale, format), [locale, format]); const shown = slices.filter((s) => !hidden.has(s.id)); const total = shown.reduce((sum, s) => sum + s.value, 0); const angles = useMemo(() => { let acc = 0; return slices.map((s) => { const value = hidden.has(s.id) ? 0 : s.value; const a0 = (acc / (total || 1)) * Math.PI * 2; acc += value; return { a0, a1: (acc / (total || 1)) * Math.PI * 2 }; }); }, [slices, hidden, total]); const current = slices.find((s) => s.id === active && !hidden.has(s.id)) ?? null; const shownKey = current?.id ?? "total"; const lastKey = useRef(shownKey); const swapped = lastKey.current !== shownKey; useEffect(() => { lastKey.current = shownKey; }, [shownKey]); const share = (v: number) => (total ? (v / total) * 100 : 0); function toggle(id: string) { setHidden((prev) => { const next = new Set(prev); if (next.has(id)) next.delete(id); else if (slices.length - next.size > 1) next.add(id); return next; }); } function onKeyDown(e: KeyboardEvent, i: number) { const next = e.key === "ArrowDown" ? i + 1 : e.key === "ArrowUp" ? i - 1 : e.key === "Home" ? 0 : e.key === "End" ? slices.length - 1 : null; if (next === null) return; e.preventDefault(); rowRefs.current[(next + slices.length) % slices.length]?.focus(); } return (

{title}

{caption}
setActive(null)} style={{ width: SIZE, height: SIZE }}> {slices.map((s, i) => ( setActive(s.id)} start={angles[i].a0} /> ))}
{current?.label ?? totalLabel} {current ? `${share(current.value).toFixed(1)}% of total` : `${shown.length} of ${slices.length} shown`}
    {slices.map((s, i) => { const off = hidden.has(s.id); const on = active === s.id; return (
  • ); })}
); }