Affordability Gauge
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 (180°) affordability gauge. The value arc sweeps left→right
 * across the top; zone segments are parametric arc paths (polar → cartesian)
 * with butt caps, so every boundary reads as a crisp break in the band. Since
 * no segment ever exceeds 180°, the large-arc flag is always 0 — the band
 * stays a clean semicircle and never wraps back through the readout. Boundary
 * percentages are labeled on the rim (measure-and-skip so adjacent thresholds
 * never collide) and a tapered needle sweeps to the current ratio on an
 * interruptible CSS rotation. The needle lives entirely in the upper half; the
 * readout sits below the pivot so the two never touch. Everything is contained
 * within the padded viewBox.
 *
 * Interactive: hover, tap, or keyboard-focus a zone band to crossfade the
 * readout to 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];

  // --- Geometry (all computed; nothing escapes the padded viewBox). ---
  const stroke = 16;
  const pad = 18;
  const w = size;
  const cx = w / 2;
  const r = size / 2 - pad - stroke / 2;
  // Room above the arc for boundary chips, below the pivot for the readout.
  const labelBand = 18;
  const cy = pad + r + stroke / 2 + labelBand;
  const readoutBand = 66;
  const h = cy + readoutBand;

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

  // Every arc here spans at most 180°, so the large-arc flag is always 0.
  const arcPath = (from: number, to: number) => {
    const [x1, y1] = point(from);
    const [x2, y2] = point(to);
    return `M ${x1.toFixed(3)} ${y1.toFixed(3)} A ${r} ${r} 0 0 1 ${x2.toFixed(3)} ${y2.toFixed(3)}`;
  };

  // Needle rests pointing left (t=0) until armed, then rotates to the target.
  // At rotation 0 the needle points straight up; -90° → left, +90° → right.
  const shownFraction = armed ? clamped : 0;
  const needleAngleDeg = -90 + shownFraction * 180;
  const needleTipY = cy - (r - stroke / 2 - 4);
  // Base sits on the pivot (no tail) so nothing dips into the readout below.
  const needleBaseY = cy;
  const needleHalf = 3;

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

  // Rim labels at internal zone boundaries. Measure-and-skip: estimate each
  // label's box from its anchor and drop any that would intersect an
  // already-kept one, so custom zone sets never collide.
  const boundaryLabels = React.useMemo(() => {
    const internal = segments
      .slice(0, -1)
      .map((seg) => seg.to)
      .filter((t) => t > 0 && t < 1);
    const labelRadius = r + stroke / 2 + 10;
    const kept: {
      t: number;
      x: number;
      y: number;
      anchor: "start" | "middle" | "end";
      box: { x0: number; x1: number; y0: number; y1: number };
    }[] = [];
    for (const t of internal) {
      const [x, y] = point(t, labelRadius);
      const anchor: "start" | "middle" | "end" =
        Math.abs(x - cx) < 8 ? "middle" : x > cx ? "start" : "end";
      const textW = percentFormat.format(t).length * 5.7; // ~9.5px tabular
      const x0 =
        anchor === "middle" ? x - textW / 2 : anchor === "start" ? x : x - textW;
      const box = { x0, x1: x0 + textW, y0: y - 5.5, y1: y + 5.5 };
      const collides = kept.some(
        (k) =>
          box.x0 < k.box.x1 + 4 &&
          box.x1 > k.box.x0 - 4 &&
          box.y0 < k.box.y1 + 2 &&
          box.y1 > k.box.y0 - 2,
      );
      if (!collides) kept.push({ t, x, y, anchor, box });
    }
    return kept;
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [JSON.stringify(zones.map((z) => z.upTo)), r, cx, cy, stroke]);

  const readoutLayer =
    "absolute inset-x-0 bottom-0 flex flex-col items-center transition-opacity duration-150";

  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"
        >
          {/* Track */}
          <path
            d={arcPath(0, 1)}
            fill="none"
            stroke="var(--color-secondary)"
            strokeWidth={stroke}
          />
          {/* Zone segments — butt caps so each boundary gap is exact. */}
          {segments.map((seg, i) => {
            const from = seg.from + (i === 0 ? 0 : 0.006);
            const to =
              Math.min(seg.to, 1) - (i === segments.length - 1 ? 0 : 0.006);
            const isActive = active === i;
            const dimmed = active !== null && !isActive;
            if (to <= from) return null;
            return (
              <path
                key={i}
                d={arcPath(from, to)}
                fill="none"
                stroke={seg.color}
                strokeWidth={stroke}
                tabIndex={0}
                role="img"
                aria-label={`${seg.label}: ${percentFormat.format(
                  seg.from,
                )}–${percentFormat.format(seg.to)}`}
                onPointerEnter={() => setActive(i)}
                onFocus={() => setActive(i)}
                onBlur={() => setActive(null)}
                onKeyDown={(e) => {
                  if (e.key !== "ArrowRight" && e.key !== "ArrowLeft") return;
                  e.preventDefault();
                  const dir = e.key === "ArrowRight" ? 1 : -1;
                  const bands = Array.from(
                    e.currentTarget.parentElement?.querySelectorAll(
                      "path[role='img'][tabindex='0']",
                    ) ?? [],
                  ) as SVGPathElement[];
                  const at = bands.indexOf(e.currentTarget);
                  bands[(at + dir + bands.length) % bands.length]?.focus();
                }}
                className="cursor-pointer outline-none focus-visible:stroke-foreground"
                style={{
                  opacity: dimmed ? 0.4 : 0.9,
                  transition:
                    "opacity 150ms var(--ease-out), stroke 150ms var(--ease-out)",
                }}
              />
            );
          })}

          {/* Boundary threshold labels on the rim + 0% / 100% under the ends */}
          {boundaryLabels.map(({ t, x, y, anchor }) => (
            <text
              key={t}
              x={x.toFixed(2)}
              y={y.toFixed(2)}
              textAnchor={anchor}
              dominantBaseline="middle"
              fontSize={9.5}
              className="fill-muted-foreground tabular-nums select-none"
              aria-hidden
            >
              {percentFormat.format(t)}
            </text>
          ))}
          <text
            x={cx - r}
            y={cy + 15}
            textAnchor="middle"
            fontSize={9.5}
            className="fill-muted-foreground tabular-nums select-none"
            aria-hidden
          >
            0%
          </text>
          <text
            x={cx + r}
            y={cy + 15}
            textAnchor="middle"
            fontSize={9.5}
            className="fill-muted-foreground tabular-nums select-none"
            aria-hidden
          >
            100%
          </text>

          {/* Needle — a tapered pointer rotating from the center pivot. Lives
              entirely in the upper half, clear of the readout below. */}
          <g
            style={{
              transformOrigin: `${cx}px ${cy}px`,
              transform: `rotate(${needleAngleDeg}deg)`,
              transition: animate
                ? "transform 550ms var(--ease-out)"
                : undefined,
            }}
          >
            <path
              d={`M ${cx - needleHalf} ${needleBaseY} L ${cx - 0.75} ${needleTipY} L ${
                cx + 0.75
              } ${needleTipY} L ${cx + needleHalf} ${needleBaseY} Z`}
              fill="var(--color-foreground)"
              stroke="var(--color-foreground)"
              strokeWidth={1}
              strokeLinejoin="round"
            />
          </g>
          <circle cx={cx} cy={cy} r={6} fill="var(--color-foreground)" />
          <circle cx={cx} cy={cy} r={2.5} fill="var(--color-background)" />
        </svg>

        {/* Readout — two layers crossfade between the value and the hovered
            zone. Anchored below the pivot, in space the needle never enters. */}
        <div
          className="pointer-events-none absolute inset-x-0 bottom-0"
          style={{ height: readoutBand - 6 }}
          aria-hidden
        >
          <div className={cn(readoutLayer, active !== null && "opacity-0")}>
            <span className="text-3xl font-semibold tabular-nums">
              {formatValue(clamped)}
            </span>
            <span
              className="mt-0.5 text-sm font-medium transition-colors duration-300"
              style={{ color: activeZone.color }}
            >
              {activeZone.label}
            </span>
          </div>
          <div className={cn(readoutLayer, active === null && "opacity-0")}>
            {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>
              </>
            )}
          </div>
        </div>
      </div>

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