ArcReveal

A donut whose slices sweep in while the centre total counts up on the same clock — the number is written from inside the draw loop, so the two cannot desync.

Install

$ npx shadcn@latest add wisp.pouriah.com/r/arc-reveal.json
Chart · canvas + DOMcanvas
100kinstalls
  • Atmosphere38%
  • Demo surfaces26%
  • Primitives21%
  • Data & state15%
breakdown
68%capacity
  • Used68%
  • Free32%
capacity

ArcRevealesc or double-click to exit full screendouble-tap to exit

data/arc-reveal.tsx
"use client";

import * as React from "react";
import {
  cx,
  DEFAULT_LANTERN,
  DEFAULT_WISP,
  hsl,
  mixHsl,
  useCanvasScene,
  useRevealClock,
  useTokenColors,
  useWispGain,
  type Hsl,
} from "@pouriahlabs/wisp-ui";

/**
 * ArcReveal — a donut whose segments sweep in while the centre total counts up
 * on the same clock.
 *
 * The composition is the component. A ring that draws on and a number that
 * counts are each a five-line effect; the reason breakdown charts usually feel
 * assembled rather than designed is that the two run on separate timers and
 * finish a quarter of a second apart. Here there is one clock: the total is
 * written from inside the draw loop, so the last degree of arc and the last
 * digit land on the same frame. They cannot desync, because there is nothing to
 * keep in sync.
 *
 * The ring is made of the seam. Segments without an explicit token are
 * interpolated between `--wisp` and `--lantern`, so a six-slice breakdown
 * introduces no new colours and still reads as six distinct slices. Name a
 * token on a segment when a slice is semantic rather than merely another slice.
 *
 * The canvas is decoration. The legend is the chart: it carries every label and
 * value as real text, so the breakdown survives a screen reader, a text-only
 * render and a failed paint. `legend={false}` therefore hides it from *sight*
 * only — it stays in the document, clipped, the same way `.wisp-chip-read`
 * carries the direction word a ▲ merely looks like it says. A ring that is a
 * bare canvas to a screen reader is not a chart; it is a decoration that used
 * to be one, and a layout preference should not be able to make that trade.
 */

export interface ArcSegment {
  label: string;
  value: number;
  /** Token for this slice. Omit to take a colour from the seam. */
  token?: string;
}

export interface ArcRevealProps extends React.HTMLAttributes<HTMLDivElement> {
  data: readonly ArcSegment[];
  /** Ring diameter, CSS pixels. */
  size?: number;
  /** Ring thickness, CSS pixels. */
  thickness?: number;
  /** Sweep duration, ms. Ignored when `progress` is supplied. */
  duration?: number;
  /** Externally driven reveal, `0`–`1`. Omit to self-animate. */
  progress?: number;
  /**
   * Pause before replaying, ms. `0` (the default) sweeps once and holds — a
   * dashboard donut shouldn't reset every few seconds. Set it for a demo.
   */
  loop?: number;
  /** Space between slices, degrees. */
  gap?: number;
  /** Where the sweep starts, degrees clockwise from twelve o'clock. */
  startAngle?: number;
  /** Format the centre total. Overrides `locale`. */
  format?: (total: number) => string;
  /** Locale for the default total's grouping separators. Pass `false` for none. */
  locale?: string | false;
  /** Small line under the centre total. */
  caption?: string;
  /**
   * Show the labelled legend. `false` clips it rather than dropping it — it is
   * the chart's accessible content and does not come off.
   */
  legend?: boolean;
}

const SEAM: [Hsl, Hsl] = [DEFAULT_WISP, DEFAULT_LANTERN];

export function ArcReveal({
  data,
  size = 132,
  thickness = 12,
  duration = 1300,
  progress,
  loop = 0,
  gap = 3,
  startAngle = -90,
  locale = "en-US",
  format = (total) =>
    locale === false ? String(Math.round(total)) : Math.round(total).toLocaleString(locale),
  caption,
  legend = true,
  className,
  style,
  ...props
}: ArcRevealProps) {
  const ref = React.useRef<HTMLCanvasElement>(null);
  const totalRef = React.useRef<HTMLSpanElement>(null);

  const [wisp, lantern] = useTokenColors(["--wisp", "--lantern"], SEAM);
  const gain = useWispGain();
  // The public props are ms; the canvas ticks the reveal clock in seconds.
  const { tick, reset } = useRevealClock({ duration: duration / 1000, loop: loop / 1000, progress });

  // One hook for every named slice, whatever the data length — the array of
  // names is what varies, not the number of hooks.
  const named = React.useMemo(
    () => Array.from(new Set(data.map((d) => d.token).filter(Boolean) as string[])),
    [data],
  );
  const namedFallbacks = React.useMemo(() => named.map(() => SEAM[0]), [named]);
  const namedColors = useTokenColors(named, namedFallbacks);

  const total = data.reduce((sum, d) => sum + d.value, 0);

  const colorAt = React.useCallback(
    (i: number): Hsl => {
      const token = data[i]?.token;
      if (token) {
        const found = named.indexOf(token);
        if (found >= 0 && namedColors[found]) return namedColors[found]!;
      }
      const steps = Math.max(1, data.length - 1);
      return mixHsl(wisp ?? SEAM[0], lantern ?? SEAM[1], i / steps);
    },
    [data, named, namedColors, wisp, lantern],
  );

  useCanvasScene(ref, {
    setup: () => reset(),

    draw: ({ ctx, width, height, dt, still }) => {
      ctx.clearRect(0, 0, width, height);
      if (width < 8 || height < 8) return;

      const t = tick(dt, still);
      // Driven reveals repaint on the parent's render; a self-animating one
      // holds once finished and not looping — either way the loop can stop.
      const done = progress !== undefined || (loop === 0 && t >= 1);

      // The one clock. Written straight to the DOM rather than through state:
      // a re-render per frame to move one text node is the wrong trade, and
      // going through React is also how the number ends up a frame behind the
      // arc it is supposed to be describing.
      if (totalRef.current) totalRef.current.textContent = format(total * t);

      const cx0 = width / 2;
      const cy0 = height / 2;
      const radius = Math.min(width, height) / 2 - thickness / 2 - 1;
      if (radius <= 0 || total <= 0) return;

      const start = (startAngle * Math.PI) / 180;
      const gapRad = (gap * Math.PI) / 180;
      const sweep = Math.PI * 2 * t;

      // The track: without it a part-drawn ring reads as a ring that is missing
      // a piece rather than one that is still arriving.
      ctx.strokeStyle = hsl(wisp ?? SEAM[0], (wisp ?? SEAM[0])[2], 0.11 * gain);
      ctx.lineWidth = thickness;
      ctx.beginPath();
      ctx.arc(cx0, cy0, radius, 0, Math.PI * 2);
      ctx.stroke();

      let angle = start;
      for (let i = 0; i < data.length; i++) {
        const share = (data[i]!.value / total) * Math.PI * 2;
        const from = angle;
        const to = angle + share;
        angle = to;

        const cutoff = Math.min(to, start + sweep);
        if (cutoff <= from) break;

        const closed = cutoff >= to - 1e-6;
        const a0 = from + gapRad / 2;
        const a1 = closed ? cutoff - gapRad / 2 : cutoff;
        if (a1 <= a0) continue;

        const color = colorAt(i);
        ctx.strokeStyle = hsl(color, color[2], 0.95);
        ctx.lineWidth = thickness;
        ctx.lineCap = "butt";
        ctx.beginPath();
        ctx.arc(cx0, cy0, radius, a0, a1);
        ctx.stroke();
      }

      return done;
    },
  });

  return (
    <div className={cx("wisp-arc", className)} style={style} {...props}>
      <div className="wisp-arc-ring" style={{ width: size, height: size }}>
        <canvas ref={ref} aria-hidden className="block h-full w-full" />
        <span className="wisp-arc-center">
          {/* Rendered settled, then overwritten every frame. Without JavaScript,
              or before the first frame, this is already the true total. */}
          <span ref={totalRef} className="wisp-arc-total tabular-nums">
            {format(total)}
          </span>
          {caption ? <span className="wisp-arc-caption">{caption}</span> : null}
        </span>
      </div>

      <ul className={cx("wisp-arc-legend", !legend && "is-read")}>
        {data.map((slice, i) => {
          const color = colorAt(i);
          return (
            <li key={slice.label} className="wisp-arc-item">
              <i aria-hidden className="wisp-arc-swatch" style={{ background: hsl(color) }} />
              <span className="wisp-arc-label">{slice.label}</span>
              <span className="wisp-arc-value tabular-nums">
                {total > 0 ? `${Math.round((slice.value / total) * 100)}%` : "—"}
              </span>
            </li>
          );
        })}
      </ul>
    </div>
  );
}

Certified against the contract