Climate

Grid Load Gauge

A real-time grid-load dial with colored threshold zones; the needle retargets to live values and the active zone follows the reading.

Install

npx shadcn@latest add @paragon/grid-load-gauge

Also installs: number-ticker

grid-load-gauge.tsx

"use client";

import * as React from "react";
import { useInView, useReducedMotion } from "motion/react";
import { NumberTicker } from "@/registry/paragon/ui/number-ticker";
import { cn } from "@/lib/utils";

export interface GridLoadZone {
  /** Upper bound of the zone as a fraction of max, 0–1. */
  to: number;
  color: string;
  label: string;
}

const DEFAULT_ZONES: GridLoadZone[] = [
  { to: 0.6, color: "var(--success)", label: "Nominal" },
  { to: 0.85, color: "var(--warning)", label: "Elevated" },
  { to: 1, color: "var(--destructive)", label: "Peak" },
];

export interface GridLoadGaugeProps extends React.ComponentProps<"div"> {
  /** Current load. */
  value?: number;
  max?: number;
  /** Threshold zones ascending by `to` (fraction of max). */
  zones?: GridLoadZone[];
  size?: number;
  unit?: string;
  /** Renders the final needle position immediately. */
  static?: boolean;
}

const START = 135; // degrees, sweep start (bottom-left)
const SWEEP = 270; // total dial arc in degrees

function polar(cx: number, cy: number, r: number, deg: number) {
  const rad = ((deg - 90) * Math.PI) / 180;
  return { x: cx + r * Math.cos(rad), y: cy + r * Math.sin(rad) };
}

function arcPath(cx: number, cy: number, r: number, a0: number, a1: number) {
  const p0 = polar(cx, cy, r, a0);
  const p1 = polar(cx, cy, r, a1);
  const large = a1 - a0 > 180 ? 1 : 0;
  return `M ${p0.x} ${p0.y} A ${r} ${r} 0 ${large} 1 ${p1.x} ${p1.y}`;
}

/**
 * A real-time grid-load dial. Colored threshold zones tint the track; a needle
 * rotates to the current load via a CSS transform transition, so live values
 * retarget mid-sweep instead of restarting, and the readout springs on the
 * number-ticker. The active zone label and needle color follow the reading. On
 * first view the needle sweeps up from zero; reduced motion (or `static`)
 * shows the final position.
 */
export function GridLoadGauge({
  value = 62,
  max = 100,
  zones = DEFAULT_ZONES,
  size = 220,
  unit = "GW",
  static: isStatic = false,
  className,
  ...props
}: GridLoadGaugeProps) {
  const ref = React.useRef<HTMLDivElement>(null);
  const reducedMotion = useReducedMotion();
  const inView = useInView(ref, { once: true, margin: "0px 0px -24px 0px" });
  const animate = !isStatic && !reducedMotion;
  const armed = !animate || inView;

  const fraction = Math.min(Math.max(value / max, 0), 1);
  const shown = armed ? fraction : 0;

  const active =
    zones.find((z) => fraction <= z.to) ?? zones[zones.length - 1];

  const thickness = 14;
  const cx = size / 2;
  const cy = size / 2;
  const r = size / 2 - thickness / 2 - 2;
  const height = size * 0.82;

  const needleDeg = START + shown * SWEEP;

  let zoneStart = 0;
  return (
    <div
      ref={ref}
      data-slot="grid-load-gauge"
      className={cn("relative inline-block", className)}
      style={{ width: size, height }}
      {...props}
    >
      <svg
        role="img"
        aria-label={`Grid load ${Math.round(value)} of ${max} ${unit}, ${active.label}`}
        width={size}
        height={height}
        viewBox={`0 0 ${size} ${height}`}
        className="block overflow-visible"
      >
        {/* Track */}
        <path
          d={arcPath(cx, cy, r, START, START + SWEEP)}
          fill="none"
          stroke="currentColor"
          strokeOpacity={0.08}
          strokeWidth={thickness}
          strokeLinecap="round"
        />
        {/* Threshold zones */}
        {zones.map((z, i) => {
          const a0 = START + zoneStart * SWEEP;
          const a1 = START + Math.min(z.to, 1) * SWEEP;
          zoneStart = z.to;
          if (a1 <= a0) return null;
          return (
            <path
              key={i}
              d={arcPath(cx, cy, r, a0, a1)}
              fill="none"
              stroke={z.color}
              strokeOpacity={0.28}
              strokeWidth={thickness}
            />
          );
        })}

        {/* Needle — rotates via transform transition */}
        <g
          style={{
            transform: `rotate(${needleDeg}deg)`,
            transformOrigin: `${cx}px ${cy}px`,
            transition: animate
              ? "transform 600ms var(--ease-out)"
              : undefined,
          }}
        >
          <line
            x1={cx}
            y1={cy}
            x2={cx}
            y2={cy - r + 4}
            stroke={active.color}
            strokeWidth={2.5}
            strokeLinecap="round"
          />
        </g>
        <circle cx={cx} cy={cy} r={5} fill="var(--card)" stroke={active.color} strokeWidth={2} />
      </svg>

      <div className="pointer-events-none absolute inset-x-0 bottom-0 flex flex-col items-center">
        <div className="flex items-baseline gap-1">
          <NumberTicker
            value={value}
            static={isStatic}
            className="text-3xl font-semibold tracking-tight"
          />
          <span className="text-sm font-medium text-muted-foreground">
            {unit}
          </span>
        </div>
        <span
          className="mt-0.5 rounded-full px-2 py-0.5 text-xs font-medium transition-colors duration-300"
          style={{ color: active.color, background: `color-mix(in oklch, ${active.color} 12%, transparent)` }}
        >
          {active.label}
        </span>
      </div>
    </div>
  );
}