Sparkline Automaton

Data viz

Inline KPI sparkline grown over an elementary cellular-automaton texture — the Wolfram rule is picked by the series' volatility, one generation column per data point.

Install
npx shadcn add https://design.helpmarq.com/r/sparkline-automaton.json
Source
registry/core/sparkline-automaton/component.tsx
"use client";

import { useEffect, useRef } from "react";

// ---------------------------------------------------------------------------
// RuleSparkline — inline KPI sparkline over an elementary cellular-automaton
// texture. The Wolfram rule is selected by the series' volatility (coefficient
// of variation): calm data grows a sparse automaton, volatile data a chaotic
// one, one generation column per data point — the texture reads the data
// instead of decorating it. CA is computed once per data update and cached to
// an offscreen canvas; rAF exists only during the entrance sweep and active
// scrub, cancelled otherwise.
// ---------------------------------------------------------------------------

type Vec3 = [number, number, number];

// cubic-bezier(0.22, 1, 0.36, 1) solved via Newton–Raphson
function makeBezier(p1x: number, p1y: number, p2x: number, p2y: number) {
  const cx = 3 * p1x;
  const bx = 3 * (p2x - p1x) - cx;
  const ax = 1 - cx - bx;
  const cy = 3 * p1y;
  const by = 3 * (p2y - p1y) - cy;
  const ay = 1 - cy - by;
  const sampleX = (t: number) => ((ax * t + bx) * t + cx) * t;
  const sampleY = (t: number) => ((ay * t + by) * t + cy) * t;
  const slopeX = (t: number) => (3 * ax * t + 2 * bx) * t + cx;
  return (x: number) => {
    if (x <= 0) return 0;
    if (x >= 1) return 1;
    let t = x;
    for (let i = 0; i < 6; i++) {
      const s = slopeX(t);
      if (Math.abs(s) < 1e-6) break;
      t -= (sampleX(t) - x) / s;
    }
    return sampleY(Math.min(1, Math.max(0, t)));
  };
}
const glideEase = makeBezier(0.22, 1, 0.36, 1);

function parseColor(raw: string): Vec3 | null {
  const s = raw.trim();
  if (s.startsWith("#")) {
    const hex = s.slice(1);
    if (hex.length === 3) {
      const r = parseInt(hex.slice(0, 1) + hex.slice(0, 1), 16);
      const g = parseInt(hex.slice(1, 2) + hex.slice(1, 2), 16);
      const b = parseInt(hex.slice(2, 3) + hex.slice(2, 3), 16);
      return Number.isNaN(r + g + b) ? null : [r, g, b];
    }
    if (hex.length >= 6) {
      const r = parseInt(hex.slice(0, 2), 16);
      const g = parseInt(hex.slice(2, 4), 16);
      const b = parseInt(hex.slice(4, 6), 16);
      return Number.isNaN(r + g + b) ? null : [r, g, b];
    }
    return null;
  }
  const m = s.match(/rgba?\(\s*([\d.]+)[,\s]+([\d.]+)[,\s]+([\d.]+)/);
  return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
}

// deterministic per-cell hash → stable alpha texture across repaints
function hash01(x: number, y: number) {
  const n = Math.sin(x * 127.1 + y * 311.7) * 43758.5453123;
  return n - Math.floor(n);
}

// volatility bucket → elementary rule. cv = stddev/mean, zero-mean guarded.
function volatilityRule(data: number[]): { rule: number; cv: number } {
  const n = data.length;
  if (n < 2) return { rule: 4, cv: 0 };
  let sum = 0;
  for (let i = 0; i < n; i++) sum += data[i] ?? 0;
  const mean = sum / n;
  let sq = 0;
  for (let i = 0; i < n; i++) {
    const d = (data[i] ?? 0) - mean;
    sq += d * d;
  }
  const sd = Math.sqrt(sq / n);
  const denom = Math.abs(mean);
  const cv = denom < 1e-9 ? (sd > 1e-9 ? 1 : 0) : sd / denom;
  if (cv < 0.08) return { rule: 4, cv };
  if (cv < 0.2) return { rule: 108, cv };
  if (cv < 0.4) return { rule: 110, cv };
  return { rule: 30, cv };
}

// seed row derived from the first value's IEEE-754 bits (mulberry32 walk) —
// identical data reproduces identical texture
function seedRow(rows: number, firstValue: number): Uint8Array {
  const f = new Float32Array(1);
  f[0] = firstValue;
  let s = (new Uint32Array(f.buffer)[0] ?? 0) || 0x9e3779b9;
  const out = new Uint8Array(rows);
  for (let i = 0; i < rows; i++) {
    s = (s + 0x6d2b79f5) | 0;
    let t = Math.imul(s ^ (s >>> 15), 1 | s);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    out[i] = (((t ^ (t >>> 14)) >>> 0) >>> 8) & 1;
  }
  return out;
}

function nextGen(prev: Uint8Array, rule: number): Uint8Array {
  const n = prev.length;
  const out = new Uint8Array(n);
  for (let i = 0; i < n; i++) {
    const l = prev[(i - 1 + n) % n] ?? 0;
    const c = prev[i] ?? 0;
    const r = prev[(i + 1) % n] ?? 0;
    out[i] = (rule >> ((l << 2) | (c << 1) | r)) & 1;
  }
  return out;
}

const DEFAULT_DATA = [
  42, 44, 43, 47, 45, 49, 46, 51, 48, 53, 50, 47, 52, 55, 51, 56, 53, 58, 55,
  60, 57, 61, 59, 63,
];

function defaultFormat(v: number) {
  return v.toLocaleString("en-US", { maximumFractionDigits: 1 });
}

export function RuleSparkline({
  data = DEFAULT_DATA,
  cellSize = 3,
  entranceMs = 900,
  strokeWidth = 1.5,
  formatValue = defaultFormat,
  className = "h-14",
  "aria-label": ariaLabel = "KPI sparkline",
}: {
  /** series values, oldest → newest; one CA generation per point */
  data?: number[];
  /** CA cell height in px */
  cellSize?: number;
  /** entrance sweep duration in ms */
  entranceMs?: number;
  /** polyline stroke width in px */
  strokeWidth?: number;
  /** scrub readout formatter */
  formatValue?: (value: number, index: number) => string;
  className?: string;
  "aria-label"?: string;
}) {
  const rootRef = useRef<HTMLDivElement>(null);
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const readoutRef = useRef<HTMLDivElement>(null);
  const dataRef = useRef(data);
  const formatRef = useRef(formatValue);
  formatRef.current = formatValue;
  const ariaLabelRef = useRef(ariaLabel);
  ariaLabelRef.current = ariaLabel;
  const engineRef = useRef<{ setData: (d: number[]) => void } | null>(null);

  useEffect(() => {
    const root = rootRef.current;
    const canvas = canvasRef.current;
    const readout = readoutRef.current;
    if (!root || !canvas || !readout) return;
    const ctx = canvas.getContext("2d");
    if (!ctx) return;
    const off = document.createElement("canvas");
    const octx = off.getContext("2d");
    if (!octx) return;

    const reduced = window.matchMedia(
      "(prefers-reduced-motion: reduce)"
    ).matches;

    // -- token-derived ink: read at mount, re-derived on theme class change --
    let fg: Vec3 = [237, 237, 237];
    let ac: Vec3 = [0, 107, 255];
    let mu: Vec3 = [143, 143, 143];
    const derive = () => {
      const cs = getComputedStyle(document.documentElement);
      fg = parseColor(cs.getPropertyValue("--foreground")) ?? fg;
      ac = parseColor(cs.getPropertyValue("--accent")) ?? ac;
      mu = parseColor(cs.getPropertyValue("--muted")) ?? mu;
    };
    derive();

    // -- hot-path state: locals only, never React state ---------------------
    let w = 0;
    let h = 0;
    let dpr = 1;
    let raf = 0;
    let last = 0;
    let cur: number[] = dataRef.current;
    let gens: Uint8Array[] = [];
    let rule = 4;
    let rows = 0;
    let xs = new Float32Array(0);
    let ys = new Float32Array(0);
    let pathLen = 0;
    // sweep: -1 = settled (fully revealed)
    let sweepStart = -1;
    let sweepDur = entranceMs;
    let sweepFromFrac = 0;
    // scrub spring: k = 250 s^-2, zeta = 1.0 (critically damped)
    const K = 250;
    const C = 2 * Math.sqrt(K);
    let hx = 0;
    let hv = 0;
    let tx = 0;
    let alpha = 0;
    let targetAlpha = 0;
    let ai = -1;
    let readoutAi = -1;
    let readoutShown = false;

    const computeCA = () => {
      gens = [];
      const picked = volatilityRule(cur);
      rule = picked.rule;
      rows = Math.max(0, Math.floor(h / cellSize));
      if (cur.length === 0 || rows === 0) return;
      let row = seedRow(rows, cur[0] ?? 0);
      gens.push(row);
      for (let i = 1; i < cur.length; i++) {
        row = nextGen(row, rule);
        gens.push(row);
      }
    };

    const computeGeometry = () => {
      const n = cur.length;
      xs = new Float32Array(n);
      ys = new Float32Array(n);
      pathLen = 0;
      if (n === 0 || w <= 0 || h <= 0) return;
      let mn = Infinity;
      let mx = -Infinity;
      for (let i = 0; i < n; i++) {
        const v = cur[i] ?? 0;
        if (v < mn) mn = v;
        if (v > mx) mx = v;
      }
      const span = mx - mn;
      const padY = Math.max(3, strokeWidth + 2);
      for (let i = 0; i < n; i++) {
        xs[i] = n > 1 ? (i / (n - 1)) * w : w / 2;
        ys[i] =
          span > 1e-12
            ? padY + (1 - ((cur[i] ?? 0) - mn) / span) * (h - padY * 2)
            : h / 2;
      }
      for (let i = 1; i < n; i++) {
        const dx = (xs[i] ?? 0) - (xs[i - 1] ?? 0);
        const dy = (ys[i] ?? 0) - (ys[i - 1] ?? 0);
        pathLen += Math.sqrt(dx * dx + dy * dy);
      }
    };

    // CA texture painted ONCE per data/theme/size change — never per frame
    const paintOffscreen = () => {
      off.width = Math.max(1, Math.round(w * dpr));
      off.height = Math.max(1, Math.round(h * dpr));
      const n = gens.length;
      if (w <= 0 || h <= 0 || n === 0) return;
      octx.setTransform(dpr, 0, 0, dpr, 0, 0);
      octx.clearRect(0, 0, w, h);
      const colW = w / n;
      const gap = colW > 2.5 ? 1 : 0;
      const cellGap = cellSize > 2 ? 1 : 0;
      for (let i = 0; i < n; i++) {
        const g = gens[i];
        if (!g) continue;
        const x = i * colW;
        for (let r = 0; r < rows; r++) {
          if (!g[r]) continue;
          // foreground ink at 8–14% alpha, deterministic per cell
          const a = 0.08 + 0.06 * hash01(i * 7.13 + 1.7, r * 3.71 + 9.2);
          octx.fillStyle = `rgba(${fg[0]},${fg[1]},${fg[2]},${a.toFixed(3)})`;
          octx.fillRect(
            x,
            r * cellSize,
            Math.max(0.5, colW - gap),
            cellSize - cellGap
          );
        }
      }
    };

    const sweepX = (now: number) => {
      if (sweepStart < 0) return w;
      const p = (now - sweepStart) / sweepDur;
      if (p >= 1) {
        sweepStart = -1;
        return w;
      }
      const from = sweepFromFrac * w;
      return from + (w - from) * glideEase(p);
    };

    const draw = (now: number) => {
      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
      ctx.clearRect(0, 0, w, h);
      if (w <= 0 || h <= 0) return;
      const sx = Math.max(0, Math.min(w, sweepX(now)));
      // CA backdrop revealed by clip-rect sweep (source-rect blit)
      if (sx > 0.5 && off.width > 1) {
        ctx.drawImage(off, 0, 0, sx * dpr, off.height, 0, 0, sx, h);
      }
      // polyline drawn via dash-offset technique
      const n = cur.length;
      if (n >= 2 && pathLen > 0) {
        const frac = sx / w;
        if (frac > 0.001) {
          ctx.beginPath();
          ctx.moveTo(xs[0] ?? 0, ys[0] ?? 0);
          for (let i = 1; i < n; i++) ctx.lineTo(xs[i] ?? 0, ys[i] ?? 0);
          ctx.strokeStyle = `rgb(${ac[0]},${ac[1]},${ac[2]})`;
          ctx.lineWidth = strokeWidth;
          ctx.lineJoin = "round";
          ctx.lineCap = "round";
          if (frac < 1) ctx.setLineDash([pathLen * frac, pathLen]);
          ctx.stroke();
          ctx.setLineDash([]);
        }
      }
      // scrub hairline + snapped point marker
      if (alpha > 0.01 && n > 0 && ai >= 0) {
        const cx = Math.round(hx) + 0.5;
        ctx.globalAlpha = alpha;
        ctx.strokeStyle = `rgba(${mu[0]},${mu[1]},${mu[2]},0.7)`;
        ctx.lineWidth = 1;
        ctx.beginPath();
        ctx.moveTo(cx, 0);
        ctx.lineTo(cx, h);
        ctx.stroke();
        ctx.fillStyle = `rgb(${ac[0]},${ac[1]},${ac[2]})`;
        ctx.beginPath();
        ctx.arc(xs[ai] ?? 0, ys[ai] ?? 0, strokeWidth + 1.5, 0, Math.PI * 2);
        ctx.fill();
        ctx.globalAlpha = 1;
      }
    };

    const springActive = () =>
      targetAlpha > 0 && (Math.abs(tx - hx) > 0.3 || Math.abs(hv) > 3);
    const active = () =>
      sweepStart >= 0 || springActive() || Math.abs(targetAlpha - alpha) > 0.01;

    const loop = (now: number) => {
      const dt = Math.min(0.05, last === 0 ? 1 / 60 : (now - last) / 1000);
      last = now;
      // critically damped spring toward the snapped target
      const a = K * (tx - hx) - C * hv;
      hv += a * dt;
      hx += hv * dt;
      if (!springActive() && targetAlpha > 0) {
        hx = tx;
        hv = 0;
      }
      alpha += (targetAlpha - alpha) * Math.min(1, dt * 14);
      if (Math.abs(targetAlpha - alpha) <= 0.01) alpha = targetAlpha;
      draw(now);
      if (active()) {
        raf = requestAnimationFrame(loop);
      } else {
        // genuinely idle: no rAF at rest — draw once settled, then stop
        raf = 0;
        last = 0;
        draw(now);
      }
    };
    const wake = () => {
      if (raf === 0 && !reduced) {
        last = 0;
        raf = requestAnimationFrame(loop);
      }
    };

    const updateReadout = () => {
      const show = targetAlpha > 0 && ai >= 0 && ai < cur.length;
      if (show && (readoutAi !== ai || !readoutShown)) {
        readoutAi = ai;
        const val = formatRef.current(cur[ai] ?? 0, ai);
        readout.textContent = val;
        root.setAttribute("aria-label", `${ariaLabelRef.current}, ${val}`);
        const rw = readout.offsetWidth;
        const x = Math.max(0, Math.min(w - rw, (xs[ai] ?? 0) - rw / 2));
        readout.style.transform = `translateX(${x.toFixed(1)}px)`;
      }
      if (show !== readoutShown) {
        readoutShown = show;
        readout.style.opacity = show ? "1" : "0";
        if (!show) root.setAttribute("aria-label", ariaLabelRef.current);
      }
    };

    const setActive = (i: number, show: boolean) => {
      const n = cur.length;
      if (n === 0 || w <= 0) return;
      ai = Math.max(0, Math.min(n - 1, i));
      tx = xs[ai] ?? 0;
      targetAlpha = show ? 1 : 0;
      updateReadout();
      if (reduced) {
        hx = tx;
        hv = 0;
        alpha = targetAlpha;
        draw(performance.now());
      } else {
        wake();
      }
    };

    const rebuild = () => {
      computeCA();
      computeGeometry();
      paintOffscreen();
    };

    const resize = () => {
      const rect = root.getBoundingClientRect();
      w = rect.width;
      h = rect.height;
      dpr = Math.min(2, window.devicePixelRatio || 1);
      canvas.width = Math.max(1, Math.round(w * dpr));
      canvas.height = Math.max(1, Math.round(h * dpr));
      rebuild();
      if (ai >= 0) {
        tx = xs[ai] ?? 0;
        readoutAi = -1; // reposition the readout against the new geometry
        updateReadout();
      }
      if (raf === 0) draw(performance.now());
    };
    // arm the entrance BEFORE the first paint so frame 0 renders at sweep≈0
    // (no one-frame flash of the finished texture)
    if (!reduced && entranceMs > 0) {
      sweepFromFrac = 0;
      sweepDur = entranceMs;
      sweepStart = performance.now();
    }
    resize();

    const samePrefix = (a: number[], b: number[]) => {
      for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
      return true;
    };

    const setData = (d: number[]) => {
      if (d === cur) return;
      const prev = cur;
      const prevRule = rule;
      cur = d;
      const picked = volatilityRule(cur);
      // tail push: same prefix, same rule bucket → extrude only new columns
      const isTail =
        prev.length > 0 &&
        cur.length > prev.length &&
        picked.rule === prevRule &&
        gens.length === prev.length &&
        rows > 0 &&
        samePrefix(prev, cur);
      if (isTail) {
        rule = picked.rule;
        let row = gens[gens.length - 1] ?? seedRow(rows, cur[0] ?? 0);
        for (let i = prev.length; i < cur.length; i++) {
          row = nextGen(row, rule);
          gens.push(row);
        }
        computeGeometry();
        paintOffscreen();
      } else {
        rebuild();
      }
      if (ai >= cur.length) ai = cur.length - 1;
      if (ai >= 0) {
        tx = xs[ai] ?? 0;
        readoutAi = -1; // force readout refresh
        updateReadout();
      }
      if (reduced || w <= 0) {
        draw(performance.now());
        return;
      }
      // 200ms sweep over the new tail only (full width on replacement)
      sweepFromFrac =
        isTail && cur.length > 1 ? (prev.length - 1) / (cur.length - 1) : 0;
      sweepDur = 200;
      sweepStart = performance.now();
      wake();
    };
    engineRef.current = { setData };

    // -- entrance: 900ms left→right reveal, skipped under reduced motion ----
    if (!reduced && entranceMs > 0) wake();

    // -- events -------------------------------------------------------------
    const onMove = (e: PointerEvent) => {
      const rect = canvas.getBoundingClientRect();
      const n = cur.length;
      if (n === 0 || rect.width <= 0) return;
      const x = e.clientX - rect.left;
      setActive(Math.round((x / rect.width) * (n - 1)), true);
    };
    const onLeave = () => {
      targetAlpha = 0;
      updateReadout();
      if (reduced) {
        alpha = 0;
        draw(performance.now());
      } else {
        wake();
      }
    };
    const onKey = (e: KeyboardEvent) => {
      if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return;
      e.preventDefault();
      const step = e.key === "ArrowLeft" ? -1 : 1;
      setActive((ai < 0 ? cur.length - 1 : ai) + step, true);
    };
    const onBlur = () => onLeave();
    root.addEventListener("pointermove", onMove);
    root.addEventListener("pointerdown", onMove);
    root.addEventListener("pointerleave", onLeave);
    root.addEventListener("keydown", onKey);
    root.addEventListener("blur", onBlur);

    const ro = new ResizeObserver(resize);
    ro.observe(root);
    // live theme re-derive: repaint the cached CA in the new ink
    const mo = new MutationObserver(() => {
      derive();
      paintOffscreen();
      if (raf === 0) draw(performance.now());
    });
    mo.observe(document.documentElement, {
      attributes: true,
      attributeFilter: ["class"],
    });

    return () => {
      cancelAnimationFrame(raf);
      ro.disconnect();
      mo.disconnect();
      root.removeEventListener("pointermove", onMove);
      root.removeEventListener("pointerdown", onMove);
      root.removeEventListener("pointerleave", onLeave);
      root.removeEventListener("keydown", onKey);
      root.removeEventListener("blur", onBlur);
      engineRef.current = null;
    };
  }, [cellSize, entranceMs, strokeWidth]);

  useEffect(() => {
    dataRef.current = data;
    engineRef.current?.setData(data);
  }, [data]);

  return (
    <div
      ref={rootRef}
      role="group"
      aria-label={ariaLabel}
      tabIndex={0}
      style={{ touchAction: "pan-y" }}
      className={`relative w-full cursor-crosshair overflow-hidden rounded-sm outline-none transition-colors duration-200 hover:bg-foreground/[0.03] focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent ${className}`}
    >
      <canvas
        ref={canvasRef}
        aria-hidden
        className="absolute inset-0 h-full w-full"
      />
      <div
        ref={readoutRef}
        role="status"
        aria-live="polite"
        aria-atomic="true"
        className="pointer-events-none absolute left-0 top-0.5 rounded-sm border border-border bg-surface px-1.5 py-0.5 font-mono text-[10px] tabular-nums text-foreground opacity-0 transition-opacity duration-150"
      />
    </div>
  );
}
Build spec

An inline KPI sparkline on a single DPR-aware (clamp 2) Canvas 2D where the backdrop is an elementary 1D cellular automaton that reads the data instead of decorating it. CA: cells 3px tall, state vector vertical, one generation COLUMN per data point advancing left to right — each data point extrudes the next generation. The Wolfram rule is selected by the series' coefficient of variation (stddev/mean, zero-mean and zero-size guarded): cv < 0.08 → rule 4 (sparse), 0.08–0.2 → rule 108, 0.2–0.4 → rule 110, > 0.4 → rule 30 (chaotic). The seed row derives from the first value's IEEE-754 bits through a mulberry32 walk so identical data reproduces identical texture. The CA is computed once per data update and painted to a cached offscreen canvas in --foreground ink at 8–14% per-cell hashed alpha — never per frame. Motion is entrance-only: CA and the --accent polyline reveal left→right over 900ms with cubic-bezier(0.22,1,0.36,1) — the CA via a clip-rect (source-rect blit) sweep, the line via canvas setLineDash([pathLen*frac, pathLen]) dash-offset; a data push extrudes only the new tail generations and re-sweeps just that region over 200ms. Cursor scrub: a vertical hairline eases to the snapped nearest-point x with a critically damped spring (k=250 s^-2, zeta=1.0) plus an accent dot on the line and a font-mono DOM readout positioned by direct transform writes; the wrapper is focusable, shows a visible focus ring, and left/right arrows step the readout, which is an aria-live region and also mirrors its value into the wrapper's aria-label while scrubbing. rAF exists only during entrance/tail sweeps and unsettled scrub — cancelled otherwise, the texture is the ambient interest. All drawn colors (--foreground, --accent, --muted) are read via getComputedStyle at mount and re-derived (offscreen CA repainted) by a MutationObserver on documentElement class changes so both themes survive; ResizeObserver recomputes the cell grid and geometry; every observer/listener/rAF torn down on unmount. prefers-reduced-motion: finished CA texture and full line painted instantly, scrub readout still works with the hairline snapping instead of easing. Demo: three stacked metric tiles (calm Revenue / moderate Latency / volatile Errors) on a surface card with mono labels, large tabular numbers, success/error delta pills, and per-tile RULE/CV captions so the rule-vs-volatility mapping is visible by comparison.

Tags
canvasdata-vizsparklinecellular-automatongenerativescrubkpi