ChartReveal

A line that draws on, then a crosshair whose tooltip measures itself against the container so it can never run off an edge.

Install

$ npx shadcn@latest add wisp.pouriah.com/r/chart-reveal.json
Chart · canvashover
  • Jan $12.4k
  • Feb $19.1k
  • Mar $15.3k
  • Apr $25.8k
  • May $22.0k
  • Jun $31.4k
  • Jul $28.2k
  • Aug $35.9k
  • Sep $33.1k
  • Oct $41.6k
  • Nov $44.0k
  • Dec $48.2k
move across to scrub

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

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

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

/**
 * ChartReveal — a line chart that draws on, with a crosshair that stays in
 * bounds.
 *
 * The reveal is the easy half. The crosshair is where the library's thesis
 * shows up in a chart: the tooltip measures its own text against the container
 * and clamps, so at the first and last datapoint it slides inward instead of
 * running off the edge. Charting libraries usually solve this with a portal and
 * a popper; on a canvas it is four lines of arithmetic and it can never escape.
 *
 * When the pointer is away, the crosshair sweeps on its own — so the component
 * still demonstrates itself in a screenshot or a scroll-past.
 *
 * The canvas is decoration. Every point's label and value also render as a
 * real, always-in-DOM list — visually clipped, not `display: none`, the same
 * treatment `ArcReveal`'s legend gets — so the chart survives a screen reader
 * and a failed paint instead of being a bare canvas with nothing to read.
 */

export interface ChartRevealPoint {
  label: string;
  value: number;
}

export interface ChartRevealProps extends React.HTMLAttributes<HTMLDivElement> {
  data: readonly ChartRevealPoint[];
  lineToken?: string;
  crosshairToken?: string;
  /** Draw-on duration, ms. Ignored when `progress` is supplied. */
  duration?: number;
  /** Externally driven reveal, `0`–`1`. Omit to self-animate. */
  progress?: number;
  /** Format the tooltip value. */
  format?: (value: number) => string;
  /** Show every nth label on the axis. */
  labelEvery?: number;
  /** Sweep the crosshair when the pointer is elsewhere. */
  autoSweep?: boolean;
}

const PAD = { left: 14, right: 14, top: 18, bottom: 26 };

export function ChartReveal({
  data,
  lineToken = "--wisp",
  crosshairToken = "--lantern",
  duration = 1500,
  progress,
  format = (v) => v.toFixed(1),
  labelEvery = 3,
  autoSweep = true,
  className,
  ...props
}: ChartRevealProps) {
  const ref = React.useRef<HTMLCanvasElement>(null);

  const line = useTokenColor(lineToken, DEFAULT_WISP);
  const cross = useTokenColor(crosshairToken, DEFAULT_LANTERN);
  const grid = useTokenColor("--wisp-line", [198, 13, 24]);
  const ink = useTokenColor("--wisp-ink-faint", [198, 9, 44]);
  const surface = useTokenColor("--wisp-surface", [200, 20, 8]);
  const gain = useWispGain();
  const light = gain < 0.9;

  // The public prop is ms; the canvas ticks the reveal clock in seconds.
  const { tick, reset } = useRevealClock({ duration: duration / 1000, progress });
  const sweep = React.useRef(0);
  const pointer = React.useRef<number | null>(null);

  // Once stilled, useCanvasScene's rAF loop is stopped, so nothing re-reads
  // `pointer` on its own. A render nudges its still-frame refresh effect,
  // which repaints with whatever `pointer` holds now.
  const [, repaintStill] = React.useState(0);

  useCanvasScene(ref, {
    setup: () => {
      reset();
      sweep.current = 0;
    },

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

      sweep.current += dt * 0.9;
      const t = tick(dt, still);

      const values = data.map((d) => d.value);
      const lo = Math.min(...values);
      const hi = Math.max(...values);
      // A little headroom so the peak never touches the top edge.
      const span = (hi - lo) * 1.15 || 1;
      const floor = lo - (hi - lo) * 0.075;

      const x = (i: number) => PAD.left + (i / (data.length - 1)) * (width - PAD.left - PAD.right);
      const y = (v: number) => PAD.top + (1 - (v - floor) / span) * (height - PAD.top - PAD.bottom);
      const base = height - PAD.bottom;

      // Baseline grid.
      ctx.strokeStyle = hsl(grid, grid[2], 0.55);
      ctx.lineWidth = 1;
      for (let i = 0; i < 3; i++) {
        const gy = Math.round(PAD.top + (i / 2) * (height - PAD.top - PAD.bottom)) + 0.5;
        ctx.beginPath();
        ctx.moveTo(PAD.left, gy);
        ctx.lineTo(width - PAD.right, gy);
        ctx.stroke();
      }

      // Axis labels.
      ctx.fillStyle = hsl(ink, ink[2], 0.9);
      ctx.font = "9px ui-monospace, SFMono-Regular, Menlo, monospace";
      ctx.textAlign = "center";
      for (let i = 0; i < data.length; i += labelEvery) {
        ctx.fillText(data[i]!.label, x(i), height - 9);
      }
      ctx.textAlign = "start";

      // The revealed span.
      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(values[i]!)]);
      if (frac > 0 && lastIndex < data.length - 1) {
        const a = values[lastIndex]!;
        const b = values[lastIndex + 1]!;
        points.push([
          x(lastIndex) + (x(lastIndex + 1) - x(lastIndex)) * frac,
          y(a) + (y(b) - y(a)) * frac,
        ]);
      }
      if (points.length < 2) return;

      const fill = ctx.createLinearGradient(0, PAD.top, 0, base);
      fill.addColorStop(0, hsl(line, line[2], 0.3 * gain));
      fill.addColorStop(1, hsl(line, line[2], 0));
      ctx.fillStyle = fill;
      ctx.beginPath();
      ctx.moveTo(points[0]![0], base);
      for (const [px, py] of points) ctx.lineTo(px, py);
      ctx.lineTo(points[points.length - 1]![0], base);
      ctx.closePath();
      ctx.fill();

      ctx.strokeStyle = hsl(line, line[2] + 6, 1);
      ctx.lineWidth = 1.8;
      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(line, line[2] + 22, 1);
      ctx.beginPath();
      ctx.arc(end[0], end[1], 3, 0, Math.PI * 2);
      ctx.fill();

      // Still drawing on: a driven reveal parks and repaints on the parent's
      // render, a self-animating one keeps looping until the line completes.
      if (t < 1) return progress !== undefined;

      // Crosshair: the pointer if it's here, otherwise a self-demo sweep.
      let index: number;
      if (pointer.current !== null) {
        index = Math.round(
          ((pointer.current - PAD.left) / (width - PAD.left - PAD.right)) * (data.length - 1),
        );
      } else if (autoSweep) {
        const pingPong = Math.abs(((sweep.current * 0.5) % 2) - 1);
        index = Math.round(pingPong * (data.length - 1));
      } else {
        // Reveal done, no pointer, no self-sweep: nothing moves — settle.
        return true;
      }
      index = Math.max(0, Math.min(data.length - 1, index));

      const hx = x(index);
      const hy = y(values[index]!);

      ctx.strokeStyle = hsl(cross, cross[2], 0.5);
      ctx.lineWidth = 1;
      ctx.setLineDash([3, 3]);
      ctx.beginPath();
      ctx.moveTo(hx, PAD.top - 4);
      ctx.lineTo(hx, base);
      ctx.stroke();
      ctx.setLineDash([]);

      ctx.fillStyle = hsl(cross, cross[2] + 20, 1);
      ctx.beginPath();
      ctx.arc(hx, hy, 3.4, 0, Math.PI * 2);
      ctx.fill();

      // The tooltip, measured and clamped so it cannot leave the container.
      const text = `${data[index]!.label}  ${format(values[index]!)}`;
      ctx.font = "600 10px ui-monospace, SFMono-Regular, Menlo, monospace";
      const boxWidth = ctx.measureText(text).width + 16;
      const boxHeight = 20;
      const bx = Math.max(2, Math.min(width - boxWidth - 2, hx - boxWidth / 2));
      const by = Math.max(2, hy - boxHeight - 10);

      ctx.fillStyle = hsl(surface, light ? 100 : surface[2], 0.95);
      ctx.strokeStyle = hsl(cross, cross[2], 0.55);
      ctx.beginPath();
      if (ctx.roundRect) ctx.roundRect(bx, by, boxWidth, boxHeight, 3);
      else ctx.rect(bx, by, boxWidth, boxHeight);
      ctx.fill();
      ctx.stroke();

      ctx.fillStyle = hsl(cross, light ? Math.max(cross[2] - 6, 22) : cross[2] + 26, 1);
      ctx.textBaseline = "middle";
      ctx.fillText(text, bx + 8, by + boxHeight / 2 + 0.5);
      ctx.textBaseline = "alphabetic";

      // Reached here only with a live pointer or the self-sweep running, both of
      // which need the next frame — never settled.
      return false;
    },
  });

  return (
    <div className={className} {...props}>
      <canvas
        ref={ref}
        aria-hidden
        className="block h-full w-full"
        onPointerMove={(event) => {
          const rect = event.currentTarget.getBoundingClientRect();
          const wasAway = pointer.current === null;
          pointer.current = event.clientX - rect.left;
          // One render on entry wakes a settled loop (see useCanvasScene); once
          // it is running it tracks the pointer itself without further renders.
          // Under reduced motion there is no loop, so every move must repaint.
          if (prefersStill() || wasAway) repaintStill((n) => n + 1);
        }}
        onPointerLeave={() => {
          pointer.current = null;
          repaintStill((n) => n + 1);
        }}
      />
      <ul className="wisp-chart-summary">
        {data.map((point) => (
          <li key={point.label}>
            {point.label} {format(point.value)}
          </li>
        ))}
      </ul>
    </div>
  );
}

Certified against the contract