Dither Canvas

beta

The engine behind the dither charts: a DPR-capped, self-pausing canvas loop, token colours, a deterministic cell hash, springs, geometry and a keyboard scrubber.

Installation

Terminal
pnpm dlx @dowel-ui/cli add dither-canvas

No npm packages are needed beyond what Dowel already requires.

Accessibility

The canvas is aria-hidden by default: a chart built on it names itself with role="img" and an aria-label summary, and renders its values in DitherTable, which stays in the accessibility tree when visually hidden. useDitherScrubber gives a chart cursor APG slider semantics (arrows, Page keys, Home/End). The loop pauses off-screen and in hidden tabs, and under reduced motion (or --motion-scale near zero) draws a single static frame with springs settled.

Props

DitherCanvas

PropTypeDefault
draw (required)DitherDraw
animate

Run the shimmer clock. Reduced motion stops it regardless. Default true.

boolean
cell

Cell pitch in CSS pixels passed to the draw function. Default 4.6.

number

Plus every attribute of <canvas> except children.

DitherTable

PropTypeDefault
caption (required)string
columns (required)

Column headers; the first names the row header column.

readonly string[]
rows (required)

One row per datum; the first cell becomes the row header.

readonly (readonly string[])[]
visible

Show the table. Hidden tables are still read by assistive technology.

booleanfalse

Plus every attribute of <table>.

Quality

7/7 checks, measured from the source and its tests

  • Testedpasses
  • axe assertionpasses
  • Keyboard testeddoes not apply
  • Storybook examplespasses
  • Accessibility documentedpasses
  • Semantic tokens onlypasses
  • Motion from tokensdoes not apply
  • className mergedpasses
  • Visible focusdoes not apply
  • No fixed widthspasses

Source

This is exactly what dowel add dither-canvas writes into your project, with imports rewritten to your own path alias.

ui/dither-canvas.tsx
"use client";

// Ported from amicro "Dither Charts" (MIT, © 2026 Syed Subhan Uddin). See THIRD_PARTY_NOTICES.md.
import {
  useCallback,
  useEffect,
  useRef,
  useState,
  type ComponentPropsWithRef,
  type RefObject,
} from "react";

import { cn } from "@/lib/utils";

import { clamp, DITHER_CELL, resolveColor } from "./dither-engine";

/*
 * The React half of the dither engine: one canvas, sized to its box, drawn by
 * a function the chart supplies. The engine owns everything a canvas chart
 * gets wrong when each one reimplements it (ADR 0014, Canvas):
 *
 * - Size: a ResizeObserver keeps the backing store at the box size × DPR, with
 *   DPR capped at 2. The draw function works in CSS pixels.
 * - Loop: requestAnimationFrame, paused when the canvas is off-screen
 *   (IntersectionObserver) or the tab is hidden, and cancelled on unmount.
 *   With `animate` off, or under reduced motion, it draws on demand only —
 *   when the draw function changes, the box resizes or the theme changes — and
 *   keeps drawing only while `draw` returns true (a spring still settling).
 * - Reduced motion: the clock stops, so the shimmer is one static frame, and
 *   `frame.reducedMotion` tells springs to jump to their targets.
 * - Colour: `frame.color("primary")` resolves a token at runtime, cached until
 *   the <html> class, data-theme or style changes, or the colour scheme does.
 */

export interface DitherFrame {
  /** Box size in CSS pixels. The context is already scaled to them. */
  width: number;
  height: number;
  /** Device pixel ratio in use, capped at 2. */
  dpr: number;
  /** Seconds of animation clock. Frozen while `animated` is false. */
  time: number;
  /** Seconds since the previous frame, clamped to 1/15; 0 after a pause. */
  delta: number;
  /** Cell pitch in CSS pixels. */
  cell: number;
  /** True when the clock runs: `animate` is on and motion is not reduced. */
  animated: boolean;
  reducedMotion: boolean;
  /** Resolves a colour token (`"primary"`, `"--color-primary"`, `color-mix(…)`). */
  color: (token: string) => string;
}

/**
 * Draws one frame. The canvas is cleared and scaled before it is called.
 * Return true to ask for another frame even when not animating — while a
 * spring is still moving, for example.
 */
export type DitherDraw = (ctx: CanvasRenderingContext2D, frame: DitherFrame) => boolean | void;

export interface DitherCanvasOptions {
  /** Run the shimmer clock. Reduced motion stops it regardless. Default true. */
  animate?: boolean;
  /** Cell pitch in CSS pixels passed to the draw function. Default 4.6. */
  cell?: number;
}

export interface DitherCanvasHandle {
  canvasRef: RefObject<HTMLCanvasElement | null>;
  /** Requests one frame. Coalesced: calling it twice draws once. */
  redraw: () => void;
  reducedMotion: boolean;
}

const MAX_DPR = 2;
const REDUCED_QUERY = "(prefers-reduced-motion: reduce)";
const SCHEME_QUERY = "(prefers-color-scheme: dark)";

function media(query: string): MediaQueryList | null {
  return typeof window !== "undefined" && typeof window.matchMedia === "function"
    ? window.matchMedia(query)
    : null;
}

/**
 * Whether motion is reduced: the OS preference, or the theme's own
 * `--motion-scale` turned down to (near) zero.
 */
export function prefersReducedMotion(): boolean {
  if (typeof window === "undefined") return false;
  if (media(REDUCED_QUERY)?.matches) return true;
  const scale = Number.parseFloat(
    window.getComputedStyle(document.documentElement).getPropertyValue("--motion-scale"),
  );
  return Number.isFinite(scale) && scale < 0.01;
}

/** Tracks `prefersReducedMotion()`, updating when the OS preference changes. */
export function useReducedMotionPreference(): boolean {
  const [reduced, setReduced] = useState(false);
  useEffect(() => {
    const update = () => {
      setReduced(prefersReducedMotion());
    };
    update();
    const query = media(REDUCED_QUERY);
    query?.addEventListener("change", update);
    return () => query?.removeEventListener("change", update);
  }, []);
  return reduced;
}

/** Runs the engine for a canvas you render yourself. */
export function useDitherCanvas(
  draw: DitherDraw,
  options: DitherCanvasOptions = {},
): DitherCanvasHandle {
  const { animate = true, cell = DITHER_CELL } = options;
  const canvasRef = useRef<HTMLCanvasElement | null>(null);
  const reducedMotion = useReducedMotionPreference();
  const latest = useRef({ draw, animate, cell, reducedMotion });
  const requestRef = useRef<() => void>(() => {});

  // Every render may carry new data or state, so every render asks for a
  // frame. Requests coalesce into one rAF, so this costs one draw at most.
  useEffect(() => {
    latest.current = { draw, animate, cell, reducedMotion };
    requestRef.current();
  });

  useEffect(() => {
    const canvas = canvasRef.current;
    if (!canvas) return;

    const colors = new Map<string, string>();
    let width = 0;
    let height = 0;
    let dpr = 1;
    let raf = 0;
    let last: number | null = null;
    let time = 0;
    let onScreen = true;

    const visible = () => onScreen && !document.hidden;

    const request = () => {
      if (raf || !visible()) return;
      raf = requestAnimationFrame(tick);
    };

    function tick(now: number) {
      raf = 0;
      if (!visible() || !canvas) {
        last = null;
        return;
      }
      const current = latest.current;
      const animated = current.animate && !current.reducedMotion;
      const delta = last === null ? 0 : clamp((now - last) / 1000, 0, 1 / 15);
      last = now;
      if (animated) time += delta;

      const ctx = width > 0 && height > 0 ? canvas.getContext("2d") : null;
      if (!ctx) return;
      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
      ctx.clearRect(0, 0, width, height);
      ctx.globalAlpha = 1;
      const more =
        current.draw(ctx, {
          width,
          height,
          dpr,
          time,
          delta,
          cell: current.cell,
          animated,
          reducedMotion: current.reducedMotion,
          color: (token) => {
            let value = colors.get(token);
            if (value === undefined) {
              value = resolveColor(canvas, token);
              colors.set(token, value);
            }
            return value;
          },
        }) === true;
      if (animated || more) request();
      else last = null;
    }

    const resize = (nextWidth: number, nextHeight: number) => {
      width = nextWidth;
      height = nextHeight;
      dpr = Math.min(window.devicePixelRatio || 1, MAX_DPR);
      canvas.width = Math.max(1, Math.round(width * dpr));
      canvas.height = Math.max(1, Math.round(height * dpr));
      request();
    };

    const box = canvas.getBoundingClientRect();
    resize(box.width, box.height);

    const resizeObserver = new ResizeObserver((entries) => {
      for (const entry of entries) resize(entry.contentRect.width, entry.contentRect.height);
    });
    resizeObserver.observe(canvas);

    let intersectionObserver: IntersectionObserver | undefined;
    if (typeof IntersectionObserver === "function") {
      intersectionObserver = new IntersectionObserver(
        (entries) => {
          for (const entry of entries) onScreen = entry.isIntersecting;
          request();
        },
        { rootMargin: "100px" },
      );
      intersectionObserver.observe(canvas);
    }

    const onVisibility = () => {
      last = null;
      request();
    };
    document.addEventListener("visibilitychange", onVisibility);

    const onTheme = () => {
      colors.clear();
      request();
    };
    const themeObserver = new MutationObserver(onTheme);
    themeObserver.observe(document.documentElement, {
      attributes: true,
      attributeFilter: ["class", "data-theme", "style"],
    });
    const scheme = media(SCHEME_QUERY);
    scheme?.addEventListener("change", onTheme);

    requestRef.current = request;
    request();

    return () => {
      requestRef.current = () => {};
      if (raf) cancelAnimationFrame(raf);
      resizeObserver.disconnect();
      intersectionObserver?.disconnect();
      themeObserver.disconnect();
      scheme?.removeEventListener("change", onTheme);
      document.removeEventListener("visibilitychange", onVisibility);
    };
  }, []);

  const redraw = useCallback(() => {
    requestRef.current();
  }, []);

  return { canvasRef, redraw, reducedMotion };
}

export interface DitherCanvasProps
  extends Omit<ComponentPropsWithRef<"canvas">, "children">, DitherCanvasOptions {
  draw: DitherDraw;
}

/**
 * A canvas the dither engine draws. Decorative by default (aria-hidden): the
 * chart around it carries the accessible name, readouts and data table. Give
 * it a `role` or `aria-label` to expose it instead.
 */
export function DitherCanvas({
  draw,
  animate,
  cell,
  className,
  ref,
  ...props
}: DitherCanvasProps) {
  const { canvasRef } = useDitherCanvas(draw, { animate, cell });
  const exposed = props.role !== undefined || props["aria-label"] !== undefined;
  const setRef = useCallback(
    (node: HTMLCanvasElement | null) => {
      canvasRef.current = node;
      if (typeof ref === "function") return ref(node);
      if (ref) ref.current = node;
      return undefined;
    },
    [canvasRef, ref],
  );

  return (
    <canvas
      aria-hidden={exposed ? undefined : true}
      data-slot="dither-canvas"
      {...props}
      ref={setRef}
      className={cn("block size-full", className)}
    />
  );
}

/** Formats a value with the reader's locale grouping. The charts' default. */
export function formatDitherValue(value: number): string {
  return new Intl.NumberFormat().format(Math.round(value * 100) / 100);
}

export interface DitherTableProps extends ComponentPropsWithRef<"table"> {
  caption: string;
  /** Column headers; the first names the row header column. */
  columns: readonly string[];
  /** One row per datum; the first cell becomes the row header. */
  rows: readonly (readonly string[])[];
  /** Show the table. Hidden tables are still read by assistive technology. */
  visible?: boolean;
}

/**
 * The chart's data as a table: always in the accessibility tree, visible on
 * request. A canvas has no content, and an aria-label summary is too coarse to
 * read exact values from, so every dither chart renders one.
 */
export function DitherTable({
  caption,
  columns,
  rows,
  visible = false,
  className,
  ...props
}: DitherTableProps) {
  return (
    <table
      data-slot="dither-table"
      data-visible={visible || undefined}
      {...props}
      className={cn(
        visible ? "w-full border-collapse text-start text-xs tabular-nums" : "sr-only",
        className,
      )}
    >
      <caption
        className={
          visible ? "pb-2 text-start text-xs font-medium text-muted-foreground" : undefined
        }
      >
        {caption}
      </caption>
      <thead>
        <tr>
          {columns.map((column) => (
            <th
              key={column}
              scope="col"
              className={
                visible ? "border-b border-border py-1 pe-3 text-start font-medium" : undefined
              }
            >
              {column}
            </th>
          ))}
        </tr>
      </thead>
      <tbody>
        {rows.map(([head, ...cells], rowIndex) => (
          <tr key={`${head ?? ""}-${String(rowIndex)}`}>
            <th
              scope="row"
              className={visible ? "py-1 pe-3 text-start font-normal" : undefined}
            >
              {head}
            </th>
            {cells.map((cellText, cellIndex) => (
              <td
                key={cellIndex}
                className={visible ? "py-1 pe-3 text-muted-foreground" : undefined}
              >
                {cellText}
              </td>
            ))}
          </tr>
        ))}
      </tbody>
    </table>
  );
}
ui/dither-engine.ts
// Ported from amicro "Dither Charts" (MIT, © 2026 Syed Subhan Uddin). See THIRD_PARTY_NOTICES.md.

/*
 * The pure half of the dither engine: no React, no DOM beyond the canvas
 * context it is handed. Everything a dither chart needs to decide *how big a
 * cell is* lives here, so it can be unit-tested without a canvas.
 *
 * The technique, as amicro draws it: the plot is a grid of fixed cells
 * (~4.6 CSS px). Each cell is drawn as one small square, centred in the cell,
 * whose side is `cell × density`. Density is built from three ingredients —
 * a shape term (distance, fill level) eased with smoothstep, a time-based sum of
 * sines for shimmer, and a per-cell hash for jitter — and the region is clipped
 * to the shape's path. amicro's hash was `fract(sin(…) × 43758)` and two charts
 * used Math.random; this one is an integer hash, so a frame is the same on every
 * machine and every render (and a test can assert it).
 */

/** Default cell pitch in CSS pixels, as amicro's donut draws it. */
export const DITHER_CELL = 4.6;

/** Axis-aligned rectangle in CSS pixels. */
export interface DitherBounds {
  x: number;
  y: number;
  width: number;
  height: number;
}

export function clamp(value: number, min: number, max: number): number {
  return Math.min(max, Math.max(min, value));
}

export function lerp(from: number, to: number, t: number): number {
  return from + (to - from) * t;
}

/** Hermite ease between two edges; 0 below `edge0`, 1 above `edge1`. */
export function smoothstep(edge0: number, edge1: number, value: number): number {
  if (edge0 === edge1) return value < edge0 ? 0 : 1;
  const x = clamp((value - edge0) / (edge1 - edge0), 0, 1);
  return x * x * (3 - 2 * x);
}

/**
 * Deterministic per-cell hash in [0, 1).
 *
 * Coordinates are quantised to 1/100 px so the float noise in a cell's centre
 * never changes its jitter. `seed` gives a second, independent field (the
 * particle layer uses one) from the same cell.
 */
export function hash2(x: number, y: number, seed = 0): number {
  let h =
    Math.imul(Math.round(x * 100) | 0, 0x27d4eb2d) ^
    Math.imul(Math.round(y * 100) | 0, 0x165667b1);
  h = Math.imul(h ^ Math.imul(seed | 0, 0x9e3779b1), 0x85ebca6b);
  h ^= h >>> 13;
  h = Math.imul(h, 0xc2b2ae35);
  h ^= h >>> 16;
  return (h >>> 0) / 4294967296;
}

/**
 * amicro's shimmer: a sum of sines eased into 0..1. Pass the raw terms (each
 * in −1..1) so each chart keeps its own wave; with no terms — the reduced-motion
 * case — it is the neutral 0.5.
 */
export function shimmer(...waves: number[]): number {
  let sum = 0;
  for (const wave of waves) sum += wave;
  return smoothstep(-1.5, 1.5, sum);
}

/** The two-sine drift amicro's device donut and revenue fill share. */
export function drift(x: number, y: number, time: number): number {
  return shimmer(Math.sin(x * 0.05 + time), Math.sin(y * 0.05 + time * 0.7));
}

/**
 * The drawn square for one cell: centred, side `cell × density`, never larger
 * than the cell. Returns null when the density leaves nothing to draw.
 */
export function cellSquare(
  x: number,
  y: number,
  cell: number,
  density: number,
): [x: number, y: number, size: number] | null {
  const size = cell * clamp(density, 0, 1);
  if (size <= 0.05) return null;
  return [x + (cell - size) / 2, y + (cell - size) / 2, size];
}

/**
 * Visits every cell overlapping `bounds`, on a grid anchored at the canvas
 * origin. Anchoring matters: a grid anchored to each shape would swim as the
 * shape animates, and two shapes would not share cells.
 */
export function forEachCell(
  bounds: DitherBounds,
  cell: number,
  visit: (x: number, y: number) => void,
): void {
  if (cell <= 0 || bounds.width <= 0 || bounds.height <= 0) return;
  const x0 = Math.floor(bounds.x / cell) * cell;
  const y0 = Math.floor(bounds.y / cell) * cell;
  const x1 = bounds.x + bounds.width;
  const y1 = bounds.y + bounds.height;
  for (let x = x0; x < x1; x += cell) {
    for (let y = y0; y < y1; y += cell) visit(x, y);
  }
}

/**
 * Density function for one cell. `cx`/`cy` are the cell's centre and `x`/`y`
 * its top-left corner, both in CSS pixels. Return 0..1 — the fraction of the
 * cell the square covers; 0 or less skips the cell.
 */
export type DitherDensity = (cx: number, cy: number, x: number, y: number) => number;

export interface DitherFillOptions {
  /** Region to scan. Keep it tight: every cell in it runs `density`. */
  bounds: DitherBounds;
  density: DitherDensity;
  /** Cell pitch in CSS pixels. Defaults to DITHER_CELL. */
  cell?: number;
  /** Clip region — usually a Path2D from `makePath`. Omit to fill the bounds. */
  clip?: Path2D | null;
  /** Resolved colour (`frame.color("primary")`). Omit to keep the current fillStyle. */
  color?: string;
  /** Multiplies the current globalAlpha for this fill. */
  alpha?: number;
}

/**
 * Fills a region with dithered cells. Saves and restores the context, so the
 * clip, colour and alpha never leak into the next shape. Returns the number of
 * squares drawn.
 */
export function ditherFill(ctx: CanvasRenderingContext2D, options: DitherFillOptions): number {
  const cell = options.cell ?? DITHER_CELL;
  let drawn = 0;
  ctx.save();
  if (options.clip) ctx.clip(options.clip);
  if (options.color !== undefined) ctx.fillStyle = options.color;
  if (options.alpha !== undefined) ctx.globalAlpha *= clamp(options.alpha, 0, 1);
  forEachCell(options.bounds, cell, (x, y) => {
    const square = cellSquare(x, y, cell, options.density(x + cell / 2, y + cell / 2, x, y));
    if (!square) return;
    ctx.fillRect(square[0], square[1], square[2], square[2]);
    drawn += 1;
  });
  ctx.restore();
  return drawn;
}

/* Colour ------------------------------------------------------------------ */

/**
 * Turns a colour reference into a CSS value:
 * `"primary"` and `"--color-primary"` become `var(--color-primary)`; anything
 * else (`var(…)`, `color-mix(…)`, `currentColor`) is passed through.
 */
export function tokenToCss(token: string): string {
  if (token.startsWith("--")) return `var(${token})`;
  if (/^[a-z][a-z0-9-]*$/.test(token) && token !== "currentcolor" && token !== "transparent") {
    return `var(--color-${token})`;
  }
  return token;
}

/**
 * Resolves a colour token to a concrete value a canvas accepts, as seen from
 * `element` — so a theme scoped to a subtree applies. A hidden probe element
 * lets the browser do the resolving, which also settles `color-mix()` and
 * nested `var()`s that a canvas fillStyle would reject.
 */
export function resolveColor(element: Element, token: string): string {
  const css = tokenToCss(token);
  const doc = element.ownerDocument;
  const view = doc.defaultView;
  if (!view) return css;
  const host = element.parentElement ?? doc.body;
  const probe = doc.createElement("span");
  probe.style.display = "none";
  probe.style.color = css;
  host.appendChild(probe);
  const resolved = view.getComputedStyle(probe).color;
  probe.remove();
  if (resolved && !resolved.includes("var(")) return resolved;
  // No resolution (a detached element, or a DOM without CSS): fall back to the
  // custom property's own value, then to the expression itself.
  const name = /^var\((--[\w-]+)\)$/.exec(css)?.[1];
  const raw = name ? view.getComputedStyle(element).getPropertyValue(name).trim() : "";
  return raw || css;
}

/**
 * Default series colours: one hue — the theme's primary — stepped toward the
 * card surface, as amicro's monochrome ramp is. Status tokens (success,
 * warning, destructive) are deliberately absent: they mean state, not series.
 * Identity never rests on colour alone; every chart also names each series in
 * a legend, its readout and its data table.
 */
export const DITHER_PALETTE: readonly string[] = [
  "primary",
  "color-mix(in oklab, var(--color-primary) 72%, var(--color-card))",
  "color-mix(in oklab, var(--color-primary) 50%, var(--color-card))",
  "color-mix(in oklab, var(--color-primary) 34%, var(--color-card))",
  "color-mix(in oklab, var(--color-primary) 22%, var(--color-card))",
  "muted-foreground",
];

/** The colour for series `index`: its own if given, else the palette in order. */
export function seriesColor(index: number, color?: string): string {
  return color ?? DITHER_PALETTE[index % DITHER_PALETTE.length] ?? "primary";
}

/* Value animation ---------------------------------------------------------- */

export interface SpringConfig {
  stiffness?: number;
  damping?: number;
  mass?: number;
  /** Distance and speed below which the spring snaps to rest. */
  precision?: number;
}

export interface Spring {
  readonly value: number;
  readonly target: number;
  readonly velocity: number;
  /** Aims the spring at a new target; it moves on the next `step`. */
  set(target: number): void;
  /** Places the spring at rest on `value`. */
  jump(value: number): void;
  /** Advances by `dt` seconds. Returns true while still moving. */
  step(dt: number, reducedMotion?: boolean): boolean;
}

/** amicro's number spring (motion's useSpring at 190 / 27 / 0.7). */
export const DEFAULT_SPRING: Required<SpringConfig> = {
  stiffness: 190,
  damping: 27,
  mass: 0.7,
  precision: 0.001,
};

/**
 * A damped spring for canvas values. Integrated in fixed sub-steps, so a long
 * frame (a tab coming back into view) cannot make it explode. Under reduced
 * motion `step` jumps straight to the target: the settled value, drawn once.
 */
export function createSpring(initial: number, config: SpringConfig = {}): Spring {
  const { stiffness, damping, mass, precision } = { ...DEFAULT_SPRING, ...config };
  let value = initial;
  let target = initial;
  let velocity = 0;
  return {
    get value() {
      return value;
    },
    get target() {
      return target;
    },
    get velocity() {
      return velocity;
    },
    set(next) {
      target = next;
    },
    jump(next) {
      value = next;
      target = next;
      velocity = 0;
    },
    step(dt, reducedMotion = false) {
      if (reducedMotion) {
        value = target;
        velocity = 0;
        return false;
      }
      let remaining = clamp(dt, 0, 0.1);
      while (remaining > 0) {
        const h = Math.min(remaining, 1 / 240);
        const force = -stiffness * (value - target) - damping * velocity;
        velocity += (force / mass) * h;
        value += velocity * h;
        remaining -= h;
      }
      const scale = Math.max(1, Math.abs(target));
      if (
        Math.abs(value - target) < precision * scale &&
        Math.abs(velocity) < precision * scale
      ) {
        value = target;
        velocity = 0;
        return false;
      }
      return true;
    },
  };
}

export interface SpringList {
  readonly values: number[];
  /** Aims at new targets. A different length resamples the current values. */
  set(targets: readonly number[]): void;
  jump(targets: readonly number[]): void;
  step(dt: number, reducedMotion?: boolean): boolean;
}

/**
 * Resamples `values` to `length` points by nearest index, so a 7-point series
 * can morph into a 30-point one without a jump to zero (amicro's range switch).
 */
export function resample(values: readonly number[], length: number): number[] {
  if (length <= 0) return [];
  if (values.length === 0) return Array.from({ length }, () => 0);
  if (values.length === length) return [...values];
  return Array.from({ length }, (_, index) => {
    const t = length === 1 ? 0 : index / (length - 1);
    return values[Math.round(t * (values.length - 1))] ?? 0;
  });
}

/** A list of springs that animates an array of values together. */
export function createSprings(
  initial: readonly number[],
  config: SpringConfig = {},
): SpringList {
  let springs = initial.map((value) => createSpring(value, config));
  const list: SpringList = {
    get values() {
      return springs.map((spring) => spring.value);
    },
    set(targets) {
      if (targets.length !== springs.length) {
        springs = resample(list.values, targets.length).map((value) =>
          createSpring(value, config),
        );
      }
      targets.forEach((target, index) => springs[index]?.set(target));
    },
    jump(targets) {
      springs = targets.map((value) => createSpring(value, config));
    },
    step(dt, reducedMotion = false) {
      let moving = false;
      for (const spring of springs) moving = spring.step(dt, reducedMotion) || moving;
      return moving;
    },
  };
  return list;
}
ui/dither-geometry.ts
// Ported from amicro "Dither Charts" (MIT, © 2026 Syed Subhan Uddin). See THIRD_PARTY_NOTICES.md.

/*
 * Shape geometry for dither charts. Every tracer writes to a `PathSink` — the
 * subset of the path API that both CanvasRenderingContext2D and Path2D share —
 * so the same function builds a clip region, strokes an outline, or records
 * its commands in a test.
 */

export interface PathSink {
  moveTo(x: number, y: number): void;
  lineTo(x: number, y: number): void;
  arc(x: number, y: number, radius: number, start: number, end: number, ccw?: boolean): void;
  arcTo(x1: number, y1: number, x2: number, y2: number, radius: number): void;
  bezierCurveTo(c1x: number, c1y: number, c2x: number, c2y: number, x: number, y: number): void;
  closePath(): void;
}

export interface Point {
  x: number;
  y: number;
}

/**
 * Builds a Path2D from a tracer. Returns null where Path2D does not exist
 * (server rendering, some test DOMs), so callers simply skip the clip.
 */
export function makePath(trace: (sink: PathSink) => void): Path2D | null {
  if (typeof Path2D === "undefined") return null;
  const path = new Path2D();
  trace(path);
  return path;
}

const TAU = Math.PI * 2;

/** Normalises an angle into [0, 2π). */
export function normalizeAngle(angle: number): number {
  return ((angle % TAU) + TAU) % TAU;
}

export interface Wedge {
  index: number;
  /** Fraction of the whole, 0..1. */
  share: number;
  start: number;
  end: number;
  mid: number;
}

/**
 * Splits a circle into wedges, clockwise from 12 o'clock by default, leaving
 * `gap` radians between neighbours (amicro's graph donut uses 0.07).
 * Negative and non-finite values count as zero.
 */
export function pieWedges(
  values: readonly number[],
  options: { start?: number; gap?: number } = {},
): Wedge[] {
  const start = options.start ?? -Math.PI / 2;
  const gap = options.gap ?? 0;
  const clean = values.map((value) => (Number.isFinite(value) && value > 0 ? value : 0));
  const total = clean.reduce((sum, value) => sum + value, 0);
  let cursor = start;
  return clean.map((value, index) => {
    const share = total > 0 ? value / total : 0;
    const sweep = share * TAU;
    const half = sweep > gap ? gap / 2 : sweep / 2;
    const wedge = {
      index,
      share,
      start: cursor + half,
      end: cursor + sweep - half,
      mid: cursor + sweep / 2,
    };
    cursor += sweep;
    return wedge;
  });
}

/** True when `angle` lies within the wedge's sweep. */
export function angleInWedge(angle: number, start: number, end: number): boolean {
  if (end <= start) return false;
  return normalizeAngle(angle - start) <= end - start;
}

/**
 * The wedge under a point, or null — hit-testing for a donut from pointer
 * coordinates, so hover never needs to read pixels back.
 */
export function wedgeAt(
  wedges: readonly Wedge[],
  center: Point,
  inner: number,
  outer: number,
  point: Point,
): number | null {
  const dx = point.x - center.x;
  const dy = point.y - center.y;
  const distance = Math.hypot(dx, dy);
  if (distance < inner || distance > outer) return null;
  const angle = Math.atan2(dy, dx);
  const hit = wedges.find(
    (wedge) => wedge.share > 0 && angleInWedge(angle, wedge.start, wedge.end),
  );
  return hit ? hit.index : null;
}

/** A plain annular sector (amicro's device donut). */
export function traceWedge(
  sink: PathSink,
  center: Point,
  inner: number,
  outer: number,
  start: number,
  end: number,
): void {
  if (end - start <= 0.0001) return;
  sink.moveTo(center.x + outer * Math.cos(start), center.y + outer * Math.sin(start));
  sink.arc(center.x, center.y, outer, start, end);
  sink.lineTo(center.x + inner * Math.cos(end), center.y + inner * Math.sin(end));
  sink.arc(center.x, center.y, inner, end, start, true);
  sink.closePath();
}

/** An annular sector with rounded corners (amicro's graph donut). */
export function traceRoundedWedge(
  sink: PathSink,
  center: Point,
  inner: number,
  outer: number,
  start: number,
  end: number,
  radius: number,
): void {
  const sweep = end - start;
  if (sweep <= 0.001) return;
  const r = Math.max(0, Math.min(radius, (outer - inner) / 2, (sweep * inner) / 2));
  if (r === 0) {
    traceWedge(sink, center, inner, outer, start, end);
    return;
  }
  const { x: cx, y: cy } = center;
  const at = (distance: number, angle: number): [number, number] => [
    cx + distance * Math.cos(angle),
    cy + distance * Math.sin(angle),
  ];
  const [isx, isy] = at(inner, start + r / inner);
  sink.moveTo(isx, isy);
  sink.arc(cx, cy, inner, start + r / inner, end - r / inner);
  sink.arcTo(...at(inner, end), ...at(outer, end), r);
  sink.arcTo(...at(outer, end), ...at(outer, end - r / outer), r);
  sink.arc(cx, cy, outer, end - r / outer, start + r / outer, true);
  sink.arcTo(...at(outer, start), ...at(inner, start), r);
  sink.arcTo(...at(inner, start), isx, isy, r);
  sink.closePath();
}

/** A rectangle with separate top and bottom corner radii (a stacked segment). */
export function traceRoundedRect(
  sink: PathSink,
  x: number,
  y: number,
  width: number,
  height: number,
  radiusTop: number,
  radiusBottom = radiusTop,
): void {
  if (width <= 0 || height <= 0) return;
  const top = Math.max(0, Math.min(radiusTop, width / 2, height / 2));
  const bottom = Math.max(0, Math.min(radiusBottom, width / 2, height / 2));
  sink.moveTo(x + top, y);
  sink.lineTo(x + width - top, y);
  sink.arcTo(x + width, y, x + width, y + top, top);
  sink.lineTo(x + width, y + height - bottom);
  sink.arcTo(x + width, y + height, x + width - bottom, y + height, bottom);
  sink.lineTo(x + bottom, y + height);
  sink.arcTo(x, y + height, x, y + height - bottom, bottom);
  sink.lineTo(x, y + top);
  sink.arcTo(x, y, x + top, y, top);
  sink.closePath();
}

/**
 * Tangents for a monotone cubic spline (Fritsch–Carlson). Monotone means the
 * curve never overshoots its data: a smooth line that invents a dip below zero
 * between two positive days is lying.
 */
export function monotoneTangents(points: readonly Point[]): number[] {
  const n = points.length;
  if (n < 2) return points.map(() => 0);
  const slopes: number[] = [];
  for (let i = 0; i < n - 1; i++) {
    const a = points[i] as Point;
    const b = points[i + 1] as Point;
    const dx = b.x - a.x;
    slopes.push(dx === 0 ? 0 : (b.y - a.y) / dx);
  }
  const tangents = points.map((_, i) => {
    if (i === 0) return slopes[0] ?? 0;
    if (i === n - 1) return slopes[n - 2] ?? 0;
    const left = slopes[i - 1] ?? 0;
    const right = slopes[i] ?? 0;
    return left * right <= 0 ? 0 : (left + right) / 2;
  });
  for (let i = 0; i < n - 1; i++) {
    const slope = slopes[i] ?? 0;
    if (slope === 0) {
      tangents[i] = 0;
      tangents[i + 1] = 0;
      continue;
    }
    const a = (tangents[i] ?? 0) / slope;
    const b = (tangents[i + 1] ?? 0) / slope;
    const s = a * a + b * b;
    if (s > 9) {
      const t = 3 / Math.sqrt(s);
      tangents[i] = t * a * slope;
      tangents[i + 1] = t * b * slope;
    }
  }
  return tangents;
}

/**
 * Traces a monotone spline through `points`. With `baseline`, it closes the
 * shape down to that y — the region under the line, ready to clip a fill.
 */
export function traceSpline(sink: PathSink, points: readonly Point[], baseline?: number): void {
  const first = points[0];
  const last = points[points.length - 1];
  if (!first || !last) return;
  const tangents = monotoneTangents(points);
  sink.moveTo(first.x, first.y);
  for (let i = 0; i < points.length - 1; i++) {
    const a = points[i] as Point;
    const b = points[i + 1] as Point;
    const third = (b.x - a.x) / 3;
    sink.bezierCurveTo(
      a.x + third,
      a.y + third * (tangents[i] ?? 0),
      b.x - third,
      b.y - third * (tangents[i + 1] ?? 0),
      b.x,
      b.y,
    );
  }
  if (baseline !== undefined) {
    sink.lineTo(last.x, baseline);
    sink.lineTo(first.x, baseline);
    sink.closePath();
  }
}

/** Traces straight segments through `points`, optionally closed to a baseline. */
export function tracePolyline(
  sink: PathSink,
  points: readonly Point[],
  baseline?: number,
): void {
  const first = points[0];
  const last = points[points.length - 1];
  if (!first || !last) return;
  sink.moveTo(first.x, first.y);
  for (const point of points.slice(1)) sink.lineTo(point.x, point.y);
  if (baseline !== undefined) {
    sink.lineTo(last.x, baseline);
    sink.lineTo(first.x, baseline);
    sink.closePath();
  }
}

/** Linear interpolation of a series at fractional index `at` (0..length−1). */
export function valueAt(values: readonly number[], at: number): number {
  if (values.length === 0) return 0;
  const clamped = Math.min(values.length - 1, Math.max(0, at));
  const i0 = Math.floor(clamped);
  const i1 = Math.min(i0 + 1, values.length - 1);
  const a = values[i0] ?? 0;
  const b = values[i1] ?? 0;
  return a + (b - a) * (clamped - i0);
}

/**
 * A round axis maximum just above `value`: 1, 2 or 5 × 10ⁿ, with 5% headroom
 * (amicro's getAxisMax). Zero and negatives give 1.
 */
export function niceMax(value: number): number {
  if (!(value > 0)) return 1;
  const target = value * 1.05;
  const power = 10 ** Math.floor(Math.log10(target));
  const normalized = target / power;
  const step = normalized <= 1 ? 1 : normalized <= 2 ? 2 : normalized <= 5 ? 5 : 10;
  return step * power;
}
ui/dither-scrubber.tsx
"use client";

// Original design (pattern inspired by amicro "Dither Area Growth").
import {
  useCallback,
  useState,
  type ComponentPropsWithRef,
  type FocusEvent,
  type KeyboardEvent,
  type PointerEvent,
  type ReactNode,
} from "react";

import { focusRing } from "@/lib/styles";
import { cn } from "@/lib/utils";

/*
 * A scrubber over a series: the cursor amicro's growth chart drags with the
 * pointer, made operable without one. The plot overlay is an APG slider whose
 * value is a point index — arrow keys step, Page keys jump a tenth, Home/End go
 * to the ends — so the readout a pointer reveals is also reachable by keyboard
 * and announced through aria-valuetext.
 */

/** The index under a pointer: `clientX` across `rect`, rounded to a point. */
export function indexFromPointer(
  clientX: number,
  rect: { left: number; width: number },
  count: number,
): number {
  if (count <= 1 || rect.width <= 0) return 0;
  const t = Math.min(1, Math.max(0, (clientX - rect.left) / rect.width));
  return Math.round(t * (count - 1));
}

/** The index a key moves to, or null when the key is not a slider key. */
export function indexFromKey(key: string, current: number, count: number): number | null {
  const lastIndex = Math.max(0, count - 1);
  const page = Math.max(1, Math.round(count / 10));
  const clampTo = (value: number) => Math.min(lastIndex, Math.max(0, value));
  switch (key) {
    case "ArrowRight":
    case "ArrowUp":
      return clampTo(current + 1);
    case "ArrowLeft":
    case "ArrowDown":
      return clampTo(current - 1);
    case "PageUp":
      return clampTo(current + page);
    case "PageDown":
      return clampTo(current - page);
    case "Home":
      return 0;
    case "End":
      return lastIndex;
    default:
      return null;
  }
}

export interface DitherScrubberOptions {
  /** Number of points. */
  count: number;
  /** Controlled cursor index; null hides the cursor. */
  index?: number | null;
  defaultIndex?: number | null;
  onIndexChange?: (index: number | null) => void;
}

export interface DitherScrubberProps {
  role: "slider";
  tabIndex: number;
  "aria-valuemin": number;
  "aria-valuemax": number;
  "aria-valuenow": number;
  "aria-orientation": "horizontal";
  onKeyDown: (event: KeyboardEvent<HTMLElement>) => void;
  onPointerMove: (event: PointerEvent<HTMLElement>) => void;
  onPointerLeave: (event: PointerEvent<HTMLElement>) => void;
  onFocus: (event: FocusEvent<HTMLElement>) => void;
  onBlur: (event: FocusEvent<HTMLElement>) => void;
}

export interface DitherScrubber {
  /** The cursor's point, or null when neither pointer nor focus is on the plot. */
  index: number | null;
  /** The point the slider reports: the cursor, or the latest point at rest. */
  valueIndex: number;
  setIndex: (index: number | null) => void;
  /** Spread onto the plot overlay. Add aria-label and aria-valuetext yourself. */
  sliderProps: DitherScrubberProps;
}

/** State and slider props for a keyboard-operable chart cursor. */
export function useDitherScrubber({
  count,
  index: controlled,
  defaultIndex = null,
  onIndexChange,
}: DitherScrubberOptions): DitherScrubber {
  const [uncontrolled, setUncontrolled] = useState<number | null>(defaultIndex);
  const isControlled = controlled !== undefined;
  const raw = isControlled ? controlled : uncontrolled;
  const lastIndex = Math.max(0, count - 1);
  const index = raw === null ? null : Math.min(lastIndex, Math.max(0, raw));

  const setIndex = useCallback(
    (next: number | null) => {
      if (!isControlled) setUncontrolled(next);
      onIndexChange?.(next);
    },
    [isControlled, onIndexChange],
  );

  const valueIndex = index ?? lastIndex;

  return {
    index,
    valueIndex,
    setIndex,
    sliderProps: {
      role: "slider",
      tabIndex: 0,
      "aria-valuemin": 0,
      "aria-valuemax": lastIndex,
      "aria-valuenow": valueIndex,
      "aria-orientation": "horizontal",
      onKeyDown: (event) => {
        if (event.key === "Escape" && index !== null) {
          event.preventDefault();
          setIndex(null);
          return;
        }
        const next = indexFromKey(event.key, valueIndex, count);
        if (next === null) return;
        event.preventDefault();
        if (next !== index) setIndex(next);
      },
      onPointerMove: (event) => {
        const next = indexFromPointer(
          event.clientX,
          event.currentTarget.getBoundingClientRect(),
          count,
        );
        if (next !== index) setIndex(next);
      },
      onPointerLeave: (event) => {
        if (event.currentTarget.ownerDocument.activeElement !== event.currentTarget)
          setIndex(null);
      },
      onFocus: () => {
        if (index === null) setIndex(lastIndex);
      },
      onBlur: () => {
        setIndex(null);
      },
    },
  };
}

export interface DitherCursorProps extends Omit<ComponentPropsWithRef<"div">, "children"> {
  scrubber: DitherScrubber;
  /** Names the slider, e.g. "Members by day". */
  label: string;
  /** What the slider announces for the current point, e.g. "Jul 3: 24". */
  valueText: string;
  /** Cursor position across the plot, 0..1. */
  x: number;
  /** Point position down the plot, 0..1 from the top; null draws no dot. */
  y?: number | null;
  /** Readout content, shown beside the cursor. */
  readout?: ReactNode;
}

/**
 * The scrubber's overlay: an invisible slider covering its positioned parent,
 * plus the cursor line, point and readout while a point is active. All real
 * DOM — the readout is text, not pixels. Place it after the chart's role="img"
 * element, not inside it: a slider inside an image would be hidden.
 */
export function DitherCursor({
  scrubber,
  label,
  valueText,
  x,
  y = null,
  readout,
  className,
  ...props
}: DitherCursorProps) {
  const active = scrubber.index !== null;
  const left = `${String(Math.min(1, Math.max(0, x)) * 100)}%`;
  // Keep the readout inside the plot near either edge.
  const shift = x < 0.15 ? "0%" : x > 0.85 ? "-100%" : "-50%";
  const move =
    "transition-[left,top,transform] duration-[var(--duration-fast)] ease-[var(--ease-out-quint)]";
  return (
    <>
      <div
        data-slot="dither-cursor"
        aria-label={label}
        aria-valuetext={valueText}
        {...scrubber.sliderProps}
        {...props}
        className={cn(
          "absolute inset-0 cursor-crosshair touch-pan-y rounded-md",
          focusRing,
          className,
        )}
      />
      {active && (
        <div aria-hidden className="pointer-events-none absolute inset-0">
          <div
            data-slot="dither-cursor-line"
            className={cn("absolute inset-y-0 w-px bg-primary/70", move)}
            style={{ left }}
          />
          {y !== null && (
            <div
              data-slot="dither-cursor-point"
              className={cn(
                "absolute size-3 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-background bg-primary shadow-sm",
                move,
              )}
              style={{ left, top: `${String(y * 100)}%` }}
            />
          )}
          {readout !== undefined && (
            <div
              data-slot="dither-cursor-readout"
              className={cn(
                "absolute top-0 z-10 rounded-md border border-border bg-popover px-2.5 py-1.5 text-xs whitespace-nowrap text-popover-foreground shadow-md",
                move,
              )}
              style={{ left, transform: `translate(${shift}, calc(-100% - 0.375rem))` }}
            >
              {readout}
            </div>
          )}
        </div>
      )}
    </>
  );
}