StreamLine

A streaming sparkline whose window scrolls smoothly left as the parent appends live data, easing each new point in from the right under an emphasised head.

Install

$ npx shadcn@latest add wisp.pouriah.com/r/stream-line.json
Live · canvaslive state
requests / sec · streaming
the window scrolls as points arrive

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

data/stream-line.tsx
"use client";

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

/**
 * StreamLine — a sparkline that stays live.
 *
 * `Sparkline` draws a fixed series once; this is its streaming sibling. The
 * parent keeps appending to `data` — a metric ticking in over a socket, a poll,
 * a sensor — and the window scrolls left to keep the newest point pinned at the
 * right edge under an emphasised head.
 *
 * The point of the component is the *ease*: a naive live chart snaps the whole
 * line one notch left the instant a sample lands, which reads as a stutter at
 * the exact moment you want the reader's eye drawn to the fresh value. Instead
 * we track the arrival in a ref and slide the horizontal offset back to rest
 * over a fraction of a second, so a new point glides in from beyond the right
 * edge rather than teleporting into place.
 *
 * Live doesn't mean always-running: once the slide has eased to rest the frame
 * is static, so `draw` reports itself settled and the loop parks instead of
 * repainting an identical picture at 60fps between samples. New data can only
 * arrive through the `data` prop — a render — and a render is exactly what
 * wakes a settled scene, so the next sample un-parks it and glides in as usual.
 * Under reduced motion `useCanvasScene` stills it to one composed frame — and
 * because the ease is zeroed on the still frame, that frame is simply the
 * current window at rest, never a half-slid smear.
 */

export interface StreamLineProps extends React.HTMLAttributes<HTMLDivElement> {
  /** The full series so far. The parent appends to it; StreamLine shows the tail. */
  data: readonly number[];
  /** Labels aligned to `data`. Supplying them names the latest value for a
   *  screen reader; omit to leave the chart decorative (a sibling already
   *  names it — the common case on a stat card). */
  labels?: readonly string[];
  token?: string;
  /** How many of the most recent points stay visible. */
  window?: number;
  /** Fill the area under the line. */
  area?: boolean;
  strokeWidth?: number;
}

/**
 * Seconds the incoming point takes to ease from off the right edge to its
 * resting slot. Short on purpose: long enough to kill the snap, short enough
 * that at any realistic sample cadence the head is at rest — and so fully
 * visible under its dot — by the time the next point lands.
 */
const SLIDE_TAU = 0.13;

export function StreamLine({
  data,
  labels,
  token = "--wisp",
  window: windowSize = 40,
  area = true,
  strokeWidth = 1.4,
  className,
  ...props
}: StreamLineProps) {
  const ref = React.useRef<HTMLCanvasElement>(null);
  const color = useTokenColor(token, DEFAULT_WISP);
  const gain = useWispGain();

  // The scroll animation lives entirely in refs so an append never has to
  // restart the loop or re-run an effect — the running `draw` notices the new
  // length on its next frame and eases from there.
  //   scroll:  points still to travel, in point-widths, decaying toward 0.
  //   prevLen: the length `draw` last saw, so it can tell how many arrived.
  const scrollRef = React.useRef(0);
  const prevLenRef = React.useRef<number | null>(null);

  useCanvasScene(ref, {
    draw: ({ ctx, width, height, dt, still }) => {
      ctx.clearRect(0, 0, width, height);

      const slots = Math.max(2, windowSize);
      const len = data.length;
      const visible = Math.min(slots, len);
      const startIndex = len - visible;

      // A first observation, a still frame, or a shrunk series has nothing to
      // ease — adopt the length silently. Only genuine growth feeds the slide,
      // and only while animating (a still frame owes a resting picture).
      if (prevLenRef.current === null || still) {
        prevLenRef.current = len;
      } else if (len > prevLenRef.current) {
        scrollRef.current += len - prevLenRef.current;
        prevLenRef.current = len;
      } else if (len < prevLenRef.current) {
        prevLenRef.current = len;
        scrollRef.current = 0;
      }

      if (still) {
        scrollRef.current = 0;
      } else {
        // Exponential ease-out toward rest: framerate-independent because the
        // step is derived from `dt`, and it never overshoots, so back-to-back
        // arrivals just keep chasing 0 as one continuous glide.
        scrollRef.current += (0 - scrollRef.current) * (1 - Math.exp(-dt / SLIDE_TAU));
        if (scrollRef.current < 0.002) scrollRef.current = 0;
      }

      if (width < 2 || height < 2 || visible < 2) return true;

      // The head dot (and its halo) needs room on every side it can touch:
      // without a right-hand inset it sits exactly on `x = width` and renders
      // half-clipped forever, and the vertical pad has to cover the halo
      // radius for a point at the extremes of the range.
      const inset = strokeWidth + 4;

      // Spacing is fixed to the full window, not to how many points exist yet,
      // so a half-filled stream fills from the right at the same density it
      // will keep once full — no lurch in scale as the window saturates.
      const step = (width - inset) / (slots - 1);
      const scroll = scrollRef.current;

      // Scale to the visible window: a live chart tracks the range it is
      // currently showing, so points scrolling off the left stop constraining
      // the axis the moment they leave.
      let lo = Infinity;
      let hi = -Infinity;
      for (let j = 0; j < visible; j++) {
        const v = data[startIndex + j]!;
        if (v < lo) lo = v;
        if (v > hi) hi = v;
      }
      const span = hi - lo || 1;
      const pad = inset;

      // Right-anchored: the newest point rests just inside the right inset,
      // older ones step back to the left. `scroll` shifts the whole line right
      // so the just-arrived point starts beyond the edge and slides in.
      const x = (j: number) => width - inset - (visible - 1 - j) * step + scroll * step;
      const y = (value: number) => height - pad - ((value - lo) / span) * (height - pad * 2);

      const points: Array<[number, number]> = [];
      for (let j = 0; j < visible; j++) points.push([x(j), y(data[startIndex + j]!)]);

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

      // The head marks the live value. A faint halo under the dot lifts it off
      // the line so the eye lands on "now" before it traces the trend — the
      // whole reason to reach for a live chart over a static one.
      const head = points[points.length - 1]!;
      ctx.fillStyle = hsl(color, color[2] + 18, 0.22 * gain);
      ctx.beginPath();
      ctx.arc(head[0], head[1], strokeWidth + 3, 0, Math.PI * 2);
      ctx.fill();
      ctx.fillStyle = hsl(color, color[2] + 18, 1);
      ctx.beginPath();
      ctx.arc(head[0], head[1], strokeWidth + 0.6, 0, Math.PI * 2);
      ctx.fill();

      // At rest the picture is static — park the loop until the next append's
      // render wakes it. While the slide is still easing, keep running.
      return scrollRef.current === 0;
    },
  });

  // Announce only the latest value — on a stream, "where it is now" is the one
  // fact worth reading aloud; the history is decorative.
  const latest = data.length ? data[data.length - 1] : undefined;
  const summary = labels
    ? `${labels[labels.length - 1] ?? "Latest"} ${latest ?? 0}`
    : undefined;

  return (
    <div
      className={className}
      data-points={data.length}
      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