"use client";
import * as React from "react";
import { motion, useReducedMotion, useSpring, useTransform } from "motion/react";
import { CircleAlert, CircleCheck, TriangleAlert } from "lucide-react";
import { cn } from "@/lib/utils";
export type GaugeThreshold = {
/** Upper bound of this zone. */
value: number;
label: string;
/** "good" | "warning" | "critical" pick a status colour, or pass any CSS colour. */
tone?: "good" | "warning" | "critical";
color?: string;
};
export type GaugeMeterProps = {
value: number;
min?: number;
max?: number;
label?: string;
unit?: string;
thresholds?: GaugeThreshold[];
valueFormatter?: (v: number) => string;
/** Maximum diameter in px (the gauge shrinks to fit its container). */
size?: number;
/** Number of labelled major ticks. */
majorTicks?: number;
className?: string;
};
/* Status colours are fixed (not themed) and always paired with an icon + label. */
const TONES = {
good: { color: "#0ca30c", Icon: CircleCheck },
warning: { color: "#e59f00", Icon: TriangleAlert },
critical: { color: "#d03b3b", Icon: CircleAlert },
} as const;
const DEFAULT_THRESHOLDS: GaugeThreshold[] = [
{ value: 60, label: "Healthy", tone: "good" },
{ value: 85, label: "Elevated", tone: "warning" },
{ value: 100, label: "Critical", tone: "critical" },
];
function useElementWidth<T extends HTMLElement>() {
const ref = React.useRef<T>(null);
const [width, setWidth] = React.useState(0);
React.useEffect(() => {
const el = ref.current;
if (!el) return;
const ro = new ResizeObserver((entries) => setWidth(Math.floor(entries[0].contentRect.width)));
ro.observe(el);
return () => ro.disconnect();
}, []);
return [ref, width] as const;
}
const START = (150 * Math.PI) / 180;
const SWEEP = (240 * Math.PI) / 180;
function arc(cx: number, cy: number, r: number, a0: number, a1: number) {
const s = Math.max(0.0001, a1 - a0);
const [x0, y0] = [cx + r * Math.cos(a0), cy + r * Math.sin(a0)];
const [x1, y1] = [cx + r * Math.cos(a0 + s), cy + r * Math.sin(a0 + s)];
return `M${x0},${y0}A${r},${r},0,${s > Math.PI ? 1 : 0},1,${x1},${y1}`;
}
export function GaugeMeter({
value,
min = 0,
max = 100,
label = "CPU load",
unit = "%",
thresholds = DEFAULT_THRESHOLDS,
valueFormatter = (v) => Math.round(v).toString(),
size = 260,
majorTicks = 5,
className,
}: GaugeMeterProps) {
const reduce = useReducedMotion();
const [ref, width] = useElementWidth<HTMLDivElement>();
const clamped = Math.min(max, Math.max(min, value));
const t = (v: number) => (Math.min(max, Math.max(min, v)) - min) / (max - min || 1);
const spring = useSpring(min, { stiffness: 70, damping: 14, mass: 0.9 });
React.useEffect(() => {
if (reduce) spring.jump(clamped);
else spring.set(clamped);
}, [clamped, reduce, spring]);
const d = Math.max(140, Math.min(size, width || size));
const cx = d / 2;
const cy = d / 2;
const r = d / 2 - 14;
const trackW = Math.max(8, d * 0.05);
const small = d < 200;
const h = cy + Math.sin(START) * r + trackW + 6; // arc bottom ≈ 0.5 + 0.5*sin(150°)
const zones = thresholds.map((z, i) => {
const from = i === 0 ? min : thresholds[i - 1].value;
const tone = z.tone ? TONES[z.tone] : null;
return { ...z, from, color: z.color ?? tone?.color ?? "var(--primary)", Icon: tone?.Icon ?? CircleCheck };
});
const zone = zones.find((z) => clamped <= z.value) ?? zones[zones.length - 1];
const needleD = useTransform(spring, (v) => {
const a = START + t(v) * SWEEP;
const len = r * 0.72;
const c = Math.cos(a);
const s = Math.sin(a);
const pt = (along: number, across: number) => `${cx + along * c - across * s},${cy + along * s + across * c}`;
return `M${pt(-4, -3.2)}L${pt(len, -1.2)}L${pt(len, 1.2)}L${pt(-4, 3.2)}Z`;
});
const progressD = useTransform(spring, (v) => arc(cx, cy, r, START, START + Math.max(0.002, t(v)) * SWEEP));
const arcColor = useTransform(spring, (v) => (zones.find((z) => v <= z.value) ?? zones[zones.length - 1]).color);
const readout = useTransform(spring, (v) => valueFormatter(v));
const ticks: { a: number; major: boolean; v: number }[] = [];
const minor = majorTicks * 5;
for (let i = 0; i <= minor; i++) {
const v = min + ((max - min) * i) / minor;
ticks.push({ a: START + (i / minor) * SWEEP, major: i % 5 === 0, v });
}
return (
<section aria-label={label} className={cn("w-full rounded-xl border bg-card p-4 text-card-foreground shadow-xs sm:p-5", className)}>
<div className="flex flex-wrap items-center justify-between gap-2">
<h3 className="text-sm font-medium text-muted-foreground">{label}</h3>
<span className="inline-flex shrink-0 items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] font-medium">
<zone.Icon className="size-3" style={{ color: zone.color }} aria-hidden />
{zone.label}
</span>
</div>
<div ref={ref} className="mx-auto mt-2 w-full" style={{ maxWidth: size }}>
<div
role="meter"
aria-valuemin={min}
aria-valuemax={max}
aria-valuenow={Math.round(clamped * 100) / 100}
aria-valuetext={`${valueFormatter(clamped)}${unit}, ${zone.label}`}
aria-label={label}
className="relative mx-auto"
style={{ width: d, height: h }}
>
<svg width={d} height={h} aria-hidden className="block overflow-visible">
{/* zone bands */}
{zones.map((z) => (
<path
key={z.label}
d={arc(cx, cy, r, START + t(z.from) * SWEEP + 0.012, START + t(z.value) * SWEEP - 0.012)}
fill="none"
stroke={z.color}
strokeOpacity={0.18}
strokeWidth={trackW}
strokeLinecap="butt"
/>
))}
{/* value arc */}
<motion.path d={progressD} fill="none" style={{ stroke: arcColor }} strokeWidth={trackW} strokeLinecap="round" />
{/* ticks */}
{ticks.map((tk, i) => {
const r0 = r - trackW / 2 - (tk.major ? 10 : 6);
const r1 = r - trackW / 2 - 3;
return (
<line
key={i}
x1={cx + r0 * Math.cos(tk.a)}
y1={cy + r0 * Math.sin(tk.a)}
x2={cx + r1 * Math.cos(tk.a)}
y2={cy + r1 * Math.sin(tk.a)}
className={tk.major ? "stroke-muted-foreground/70" : "stroke-muted-foreground/30"}
strokeWidth={tk.major ? 1.5 : 1}
/>
);
})}
{!small &&
ticks
.filter((tk) => tk.major)
.map((tk) => {
const rl = r - trackW / 2 - 22;
return (
<text
key={tk.v}
x={cx + rl * Math.cos(tk.a)}
y={cy + rl * Math.sin(tk.a)}
dy="0.34em"
textAnchor="middle"
className="fill-muted-foreground text-[10px] tabular-nums"
>
{Math.round(tk.v)}
</text>
);
})}
{/* threshold markers */}
{zones.slice(0, -1).map((z) => {
const a = START + t(z.value) * SWEEP;
return (
<line
key={`m-${z.label}`}
x1={cx + (r + trackW / 2 + 1) * Math.cos(a)}
y1={cy + (r + trackW / 2 + 1) * Math.sin(a)}
x2={cx + (r - trackW / 2 - 1) * Math.cos(a)}
y2={cy + (r - trackW / 2 - 1) * Math.sin(a)}
className="stroke-card"
strokeWidth={2.5}
/>
);
})}
{/* needle */}
<motion.path d={needleD} className="fill-foreground" />
<circle cx={cx} cy={cy} r={small ? 6 : 8} className="fill-card stroke-foreground" strokeWidth={2.5} />
</svg>
<div className="pointer-events-none absolute inset-x-0 text-center" style={{ top: cy + (small ? 12 : 18) }}>
<p className={cn("font-semibold tracking-tight tabular-nums", small ? "text-xl" : "text-3xl")}>
<motion.span>{readout}</motion.span>
<span className="ml-0.5 text-[0.55em] font-medium text-muted-foreground">{unit}</span>
</p>
</div>
</div>
</div>
<ul className="mt-2 flex flex-wrap justify-center gap-x-3 gap-y-1 text-[11px] text-muted-foreground" aria-label="Thresholds">
{zones.map((z) => (
<li key={z.label} className={cn("flex items-center gap-1", z === zone && "font-medium text-foreground")}>
<span aria-hidden className="size-2 rounded-full" style={{ background: z.color }} />
{z.label} ≤ {z.value}
{unit}
</li>
))}
</ul>
</section>
);
}