Fazekit

Code

"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>
  );
}

More in Data Display

View all →