Sparkline

A trend line that draws on with an emphasised endpoint, and accepts external progress so a parent can drive several off one clock.

Install

$ npx shadcn@latest add wisp.pouriah.com/r/sparkline.json
data/sparkline.tsx
"use client";

import * as React from "react";
import {
  DEFAULT_WISP,
  hsl,
  useCanvasScene,
  useRevealClock,
  useTokenColor,
  useWispGain,
} from "@pouriahlabs/wisp-ui";

/**
 * Sparkline — a trend line that draws on, with an emphasised endpoint.
 *
 * Every stat tile wants one. The endpoint dot is not decoration: on a line this
 * small the reader's question is "where did it end up", and marking the last
 * point answers it before they trace the shape.
 *
 * Exposed as a standalone so `StatTile` can compose it, but it takes a
 * `progress` prop so a parent can drive several of these off one clock — three
 * animations racing to finish at different times is what makes a stat card feel
 * cheap.
 */

export interface SparklineProps extends React.HTMLAttributes<HTMLDivElement> {
  data: readonly number[];
  /** Point labels, in order. Enables an accessible summary — omit to leave
   *  the chart decorative (the common case when a sibling already names it). */
  labels?: readonly string[];
  token?: string;
  /** Draw-on 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) draws once and holds — a
   * product sparkline shouldn't reset every few seconds. Set it for a demo.
   */
  loop?: number;
  /** Fill the area under the line. */
  area?: boolean;
  strokeWidth?: number;
}

export function Sparkline({
  data,
  labels,
  token = "--wisp",
  duration = 1300,
  progress,
  loop = 0,
  area = true,
  strokeWidth = 1.4,
  className,
  ...props
}: SparklineProps) {
  const ref = React.useRef<HTMLCanvasElement>(null);
  const color = useTokenColor(token, DEFAULT_WISP);
  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 });
  const seriesKey = data.join(",");

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

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

      const t = tick(dt, still);
      // A driven reveal repaints on the parent's next render; a self-animating
      // one holds once it finishes and isn't looping. Either way the loop can
      // stop rather than repaint an identical frame at 60fps.
      const done = progress !== undefined || (loop === 0 && t >= 1);

      const lo = Math.min(...data);
      const hi = Math.max(...data);
      const span = hi - lo || 1;
      const pad = 3;

      const x = (i: number) => (i / (data.length - 1)) * (width - 2) + 1;
      const y = (value: number) => height - pad - ((value - lo) / span) * (height - pad * 2);

      // Interpolate the head so the line grows smoothly between samples rather
      // than snapping from point to point.
      const upto = t * (data.length - 1);
      const lastIndex = Math.min(data.length - 1, Math.floor(upto));
      const frac = upto - lastIndex;

      const points: Array<[number, number]> = [];
      for (let i = 0; i <= lastIndex; i++) points.push([x(i), y(data[i]!)]);
      if (frac > 0 && lastIndex < data.length - 1) {
        const a = data[lastIndex]!;
        const b = data[lastIndex + 1]!;
        points.push([
          x(lastIndex) + (x(lastIndex + 1) - x(lastIndex)) * frac,
          y(a) + (y(b) - y(a)) * frac,
        ]);
      }
      if (points.length < 2) return;

      if (area) {
        const fill = ctx.createLinearGradient(0, 0, 0, height);
        fill.addColorStop(0, hsl(color, color[2], 0.28 * gain));
        fill.addColorStop(1, hsl(color, color[2], 0));
        ctx.fillStyle = fill;
        ctx.beginPath();
        ctx.moveTo(points[0]![0], height);
        for (const [px, py] of points) ctx.lineTo(px, py);
        ctx.lineTo(points[points.length - 1]![0], height);
        ctx.closePath();
        ctx.fill();
      }

      ctx.strokeStyle = hsl(color, color[2], 0.95);
      ctx.lineWidth = strokeWidth;
      ctx.lineJoin = "round";
      ctx.lineCap = "round";
      ctx.beginPath();
      ctx.moveTo(points[0]![0], points[0]![1]);
      for (let i = 1; i < points.length; i++) ctx.lineTo(points[i]![0], points[i]![1]);
      ctx.stroke();

      const end = points[points.length - 1]!;
      ctx.fillStyle = hsl(color, color[2] + 18, 1);
      ctx.beginPath();
      ctx.arc(end[0], end[1], strokeWidth + 0.6, 0, Math.PI * 2);
      ctx.fill();

      return done;
    },
  });

  const summary = labels
    ? labels.map((label, i) => `${label} ${data[i] ?? 0}`).join(", ")
    : undefined;

  return (
    <div
      className={className}
      data-series={seriesKey}
      role={summary ? "img" : undefined}
      aria-label={summary}
      aria-hidden={summary ? undefined : true}
      {...props}
    >
      <canvas ref={ref} className="block h-full w-full" />
    </div>
  );
}

Certified against the contract