"use client"; import { motion, useReducedMotion, useSpring, useTransform } from "motion/react"; import { type ReactNode, useEffect, useState } from "react"; import { cn } from "@/lib/utils"; export type GaugeTone = "success" | "warning" | "destructive" | "info"; export interface GaugeBand { to: number; label: string; tone: GaugeTone; } export interface RadialGaugeProps { value: number; min?: number; max?: number; target?: number; bands?: GaugeBand[]; label?: string; unit?: string; caption?: string; action?: ReactNode; children?: ReactNode; className?: string; } const W = 260; const H = 178; const CX = W / 2; const CY = 124; const R = 92; const SWEEP = 240; const DIGITS = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]; const TRACK = 10; const CAP = ((TRACK / 2 / R) * 180) / Math.PI; const TONE: Record = { success: { stroke: "stroke-success", text: "text-success-foreground", bg: "bg-success/10 dark:bg-success/16" }, warning: { stroke: "stroke-warning", text: "text-warning-foreground", bg: "bg-warning/10 dark:bg-warning/16" }, destructive: { stroke: "stroke-destructive", text: "text-destructive-foreground", bg: "bg-destructive/10 dark:bg-destructive/16" }, info: { stroke: "stroke-info", text: "text-info-foreground", bg: "bg-info/10 dark:bg-info/16" }, }; const defaultBands: GaugeBand[] = [ { to: 70, label: "Healthy", tone: "success" }, { to: 85, label: "Busy", tone: "warning" }, { to: 100, label: "Saturated", tone: "destructive" }, ]; function RollingNumber({ text, className }: { text: string; 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} ))} ), )} ); } const rad = (deg: number) => (deg * Math.PI) / 180; const round = (n: number) => Math.round(n * 100) / 100; const pt = (r: number, deg: number) => [round(CX + r * Math.sin(rad(deg))), round(CY - r * Math.cos(rad(deg)))] as const; function arcPath(r: number, d0: number, d1: number) { if (d1 - d0 < 0.01) return ""; const [x0, y0] = pt(r, d0); const [x1, y1] = pt(r, d1); return `M${x0.toFixed(2)} ${y0.toFixed(2)} A${r} ${r} 0 ${d1 - d0 > 180 ? 1 : 0} 1 ${x1.toFixed(2)} ${y1.toFixed(2)}`; } export function RadialGauge({ value, min = 0, max = 100, target, bands = defaultBands, label = "CPU", unit = "%", caption, action, children, className, }: RadialGaugeProps) { const reduce = useReducedMotion(); const [hoverBand, setHoverBand] = useState(null); const clamp = (v: number) => Math.max(min, Math.min(max, v)); const deg = (v: number) => -SWEEP / 2 + ((clamp(v) - min) / (max - min || 1)) * SWEEP; const spring = useSpring(min, { stiffness: 120, damping: 19, mass: 1 }); useEffect(() => { if (reduce) spring.jump(value); else spring.set(value); }, [value, reduce, spring]); const needle = useTransform(spring, (v) => { const a = deg(v); const [tx, ty] = pt(R - 18, a); const [lx, ly] = pt(3.2, a - 90); const [rx, ry] = pt(3.2, a + 90); const [bx, by] = pt(12, a + 180); return `M${bx.toFixed(2)} ${by.toFixed(2)} L${lx.toFixed(2)} ${ly.toFixed(2)} L${tx.toFixed(2)} ${ty.toFixed(2)} L${rx.toFixed(2)} ${ry.toFixed(2)} Z`; }); const fill = useTransform(spring, (v) => arcPath(R, -SWEEP / 2 + CAP, Math.max(-SWEEP / 2 + CAP + 0.02, deg(v) - CAP))); const bandIndex = Math.max(0, bands.findIndex((b) => clamp(value) <= b.to)); const band = bands[bandIndex] ?? bands[bands.length - 1]; const tone = TONE[band.tone]; const ticks = Array.from({ length: 21 }, (_, i) => min + ((max - min) * i) / 20); const shown = hoverBand === null ? null : bands[hoverBand]; const gap = target === undefined ? null : value - target; return (

{label}

{caption ? {caption} : null}
{action}
{bands.map((b, i) => { const from = i === 0 ? min : bands[i - 1].to; const d0 = deg(from) + (i === 0 ? 0 : 1.2); const d1 = deg(b.to) - (i === bands.length - 1 ? 0 : 1.2); return ( setHoverBand(i)} onPointerLeave={() => setHoverBand(null)}> ); })} {ticks.map((t, i) => { const major = i % 5 === 0; const [x0, y0] = pt(R - 12, deg(t)); const [x1, y1] = pt(R - (major ? 19 : 15), deg(t)); return ( ); })} {ticks .filter((_, i) => i % 5 === 0) .map((t) => { const [x, y] = pt(R - 30, deg(t)); return ( {Math.round(t)} ); })} {target !== undefined ? ( {(() => { const a = deg(target); const [x0, y0] = pt(R - 8, a); const [x1, y1] = pt(R + 17, a); return ( <> ); })()} ) : null}
{unit}
{shown ? `${shown.label} · ${hoverBand === 0 ? min : bands[(hoverBand ?? 1) - 1].to}–${shown.to}${unit}` : band.label} {gap !== null && !shown ? ( {Math.abs(gap) < 0.5 ? "On target" : `${Math.abs(Math.round(gap))}${unit === "%" ? " pts" : unit} ${gap > 0 ? "over" : "under"} target`} ) : null}
{children ?
{children}
: null}
); }