Dither Canvas
betaThe 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
pnpm dlx @dowel-ui/cli add dither-canvasNo 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
| Prop | Type | Default |
|---|---|---|
draw (required) | DitherDraw | — |
animateRun the shimmer clock. Reduced motion stops it regardless. Default true. | boolean | — |
cellCell pitch in CSS pixels passed to the draw function. Default 4.6. | number | — |
Plus every attribute of <canvas> except children.
DitherTable
| Prop | Type | Default |
|---|---|---|
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[])[] | — |
visibleShow the table. Hidden tables are still read by assistive technology. | boolean | false |
Plus every attribute of <table>.
Quality
7/7 checks, measured from the source and its tests
- Tested — passes
- axe assertion — passes
- Keyboard tested — does not apply
- Storybook examples — passes
- Accessibility documented — passes
- Semantic tokens only — passes
- Motion from tokens — does not apply
- className merged — passes
- Visible focus — does not apply
- No fixed widths — passes
Source
This is exactly what dowel add dither-canvas writes into your project, with imports rewritten to your own path alias.
"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>
);
}
// 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;
}
// 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;
}
"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>
)}
</>
);
}