Charts

Affordability Gauge

A semicircular gauge with threshold zones and a needle that sweeps to the current ratio and retargets on input.

Install

npx shadcn@latest add @paragon/affordability-gauge

affordability-gauge.tsx

"use client";

import * as React from "react";
import { useInView, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";

export interface AffordabilityZone {
  /** Upper bound of this zone as a fraction of the arc (0–1), ascending. */
  upTo: number;
  color: string;
  label: string;
}

export interface AffordabilityGaugeProps extends React.ComponentProps<"div"> {
  /** Current ratio to plot (e.g. debt-to-income), 0–1. Clamped. */
  value: number;
  size?: number;
  /** Ascending threshold zones painted along the arc. */
  zones?: AffordabilityZone[];
  /** Caption under the readout. */
  caption?: string;
  /** Formats the center readout. Defaults to a percentage. */
  formatValue?: (value: number) => string;
  /** Renders the needle at target immediately, no sweep. */
  static?: boolean;
}

const DEFAULT_ZONES: AffordabilityZone[] = [
  { upTo: 0.36, color: "var(--color-success)", label: "Comfortable" },
  { upTo: 0.43, color: "var(--color-warning)", label: "Stretched" },
  { upTo: 1, color: "var(--color-destructive)", label: "Overextended" },
];

/**
 * A semicircular affordability gauge. Zone segments are painted as
 * parametric arc paths (polar → cartesian, exact start/end fractions) and a
 * needle sweeps to the current ratio. The needle uses an interruptible CSS
 * rotate transition, so retargeting mid-sweep redirects rather than restarts;
 * the sweep runs once when the gauge enters view.
 *
 * Interactive: hover or keyboard-focus a zone band to reveal its range and
 * label. Reduced motion places the needle without sweeping.
 */
export function AffordabilityGauge({
  value,
  size = 240,
  zones = DEFAULT_ZONES,
  caption,
  formatValue = (v) => `${Math.round(v * 100)}%`,
  static: isStatic = false,
  className,
  ...props
}: AffordabilityGaugeProps) {
  const ref = React.useRef<HTMLDivElement>(null);
  const reducedMotion = useReducedMotion();
  const inView = useInView(ref, { once: true, margin: "0px 0px -24px 0px" });
  const [active, setActive] = React.useState<number | null>(null);

  const animate = !isStatic && !reducedMotion;
  const armed = !animate || inView;

  const clamped = Math.min(Math.max(value, 0), 1);
  const activeZoneIndex = zones.findIndex((z) => clamped <= z.upTo);
  const resolvedActiveIndex =
    activeZoneIndex === -1 ? zones.length - 1 : activeZoneIndex;
  const activeZone = zones[resolvedActiveIndex];

  const w = size;
  const h = size / 2 + 24;
  const cx = w / 2;
  const cy = size / 2 + 4;
  const r = size / 2 - 16;
  const stroke = 14;

  // Polar helper: t in [0,1] maps left (180°) → right (0°).
  const point = (t: number) => {
    const angle = Math.PI - t * Math.PI;
    return [cx + r * Math.cos(angle), cy - r * Math.sin(angle)] as const;
  };

  const arcPath = (from: number, to: number) => {
    const [x1, y1] = point(from);
    const [x2, y2] = point(to);
    const large = to - from > 0.5 ? 1 : 0;
    return `M ${x1.toFixed(3)} ${y1.toFixed(3)} A ${r} ${r} 0 ${large} 1 ${x2.toFixed(3)} ${y2.toFixed(3)}`;
  };

  // Needle rests at the bottom-left (pointing to t=0) until armed, then
  // rotates to the target — an interruptible transition retargets mid-sweep.
  const shownFraction = armed ? clamped : 0;
  const needleAngleDeg = -90 + shownFraction * 180;

  const percentFormat = React.useMemo(
    () =>
      new Intl.NumberFormat("en-US", {
        style: "percent",
        maximumFractionDigits: 0,
      }),
    [],
  );

  let cursor = 0;
  const segments = zones.map((z) => {
    const from = cursor;
    cursor = z.upTo;
    return { ...z, from, to: z.upTo };
  });

  return (
    <div
      ref={ref}
      data-slot="affordability-gauge"
      className={cn("inline-flex flex-col items-center", className)}
      {...props}
    >
      <div
        className="relative"
        style={{ width: w, height: h }}
        onPointerLeave={() => setActive(null)}
      >
        <svg
          role="img"
          aria-label={`Affordability gauge: ${formatValue(clamped)}, ${activeZone.label}`}
          width={w}
          height={h}
          viewBox={`0 0 ${w} ${h}`}
          className="block overflow-visible"
        >
          {/* Track */}
          <path
            d={arcPath(0, 1)}
            fill="none"
            stroke="var(--color-secondary)"
            strokeWidth={stroke}
            strokeLinecap="round"
          />
          {/* Zone segments — parametric arcs with small visual gaps */}
          {segments.map((seg, i) => {
            const from = seg.from + (i === 0 ? 0 : 0.004);
            const to =
              Math.min(seg.to, 1) - (i === segments.length - 1 ? 0 : 0.004);
            const isActive = active === i;
            const dimmed = active !== null && !isActive;
            return (
              <path
                key={i}
                d={arcPath(from, to)}
                fill="none"
                stroke={seg.color}
                strokeWidth={stroke}
                strokeLinecap="round"
                vectorEffect="non-scaling-stroke"
                tabIndex={0}
                role="button"
                aria-label={`${seg.label}: ${percentFormat.format(
                  seg.from,
                )}–${percentFormat.format(seg.to)}`}
                onPointerEnter={() => setActive(i)}
                onFocus={() => setActive(i)}
                onBlur={() => setActive(null)}
                className="cursor-pointer outline-none focus-visible:stroke-foreground"
                style={{
                  opacity: dimmed ? 0.4 : 0.9,
                  transition: "opacity 150ms var(--ease-out)",
                }}
              />
            );
          })}

          {/* Needle — rotates from center pivot */}
          <g
            style={{
              transformOrigin: `${cx}px ${cy}px`,
              transform: `rotate(${needleAngleDeg}deg)`,
              transition: animate
                ? "transform 550ms var(--ease-out)"
                : undefined,
            }}
          >
            <line
              x1={cx}
              y1={cy}
              x2={cx}
              y2={cy - r + stroke / 2 + 2}
              stroke="var(--color-foreground)"
              strokeWidth={2.5}
              strokeLinecap="round"
              vectorEffect="non-scaling-stroke"
            />
          </g>
          <circle cx={cx} cy={cy} r={5} fill="var(--color-foreground)" />
          <circle cx={cx} cy={cy} r={2} fill="var(--color-background)" />
        </svg>

        <div className="pointer-events-none absolute inset-x-0 bottom-0 flex flex-col items-center">
          {active !== null ? (
            <>
              <span className="text-2xl font-semibold tabular-nums">
                {percentFormat.format(segments[active].from)}
                {percentFormat.format(segments[active].to)}
              </span>
              <span
                className="mt-0.5 text-sm font-medium"
                style={{ color: segments[active].color }}
              >
                {segments[active].label}
              </span>
            </>
          ) : (
            <>
              <span className="text-3xl font-semibold tabular-nums">
                {formatValue(clamped)}
              </span>
              <span
                className="mt-0.5 text-sm font-medium"
                style={{ color: activeZone.color }}
              >
                {activeZone.label}
              </span>
            </>
          )}
        </div>
      </div>

      {caption && (
        <p className="mt-2 max-w-xs text-center text-xs text-muted-foreground">
          {caption}
        </p>
      )}
    </div>
  );
}