benday
uibendaysource: ok · refreshed
Benday
Turn any logo into a customizable animated Ben-Day dot field.
npx shadcn add "https://benday.kacemmathlouthi.dev/r/benday.json"free
No selection tags yet — empty means no evidence, not lack of support.
Files7 · source cached 10/8/2026, 2:23:26 AM
registry/ui/benday.tsx
"use client";
import type { CSSProperties, CanvasHTMLAttributes } from "react";
import { useEffect, useRef } from "react";
import type { BakeSource } from "./benday/bake";
import { createRenderer } from "./benday/renderer";
import type {
BakeOptions,
BendayState,
DotMap,
Renderer,
RendererOptions,
} from "./benday/types";
import { useDotMap } from "./benday/use-dot-map";
export interface BendayProps
extends
Omit<
CanvasHTMLAttributes<HTMLCanvasElement>,
"color" | "height" | "style" | "width"
>,
Omit<RendererOptions, "dotMap"> {
/** Image to bake at runtime — URL, data URI, File/Blob, or a loaded image. */
src?: BakeSource;
/** A pre-baked dot map. Takes precedence over `src`; this is the build-time path. */
dotMap?: DotMap;
/** Options for the runtime bake. Ignored when `dotMap` is supplied. */
bake?: BakeOptions;
style?: CSSProperties;
}
const STATE_LABEL: Record<BendayState, string> = {
done: "Done",
idle: "Idle",
thinking: "Thinking…",
};
/**
* A canvas indicator built from your logo. This owns only the element, the bake
* and the props; `renderer.update()` takes changes without restarting anything.
*/
export function Benday({
src,
dotMap: dotMapProp,
bake: bakeOptions,
preset = "contour",
state = "thinking",
size = 64,
fit = "square",
speed = 1,
color = "currentColor",
dotScale = 0.62,
shape = "circle",
glow = 0,
padding = 0.06,
weight = 0.5,
paused = false,
reducedMotion = "auto",
style,
"aria-label": ariaLabel,
...rest
}: BendayProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const rendererRef = useRef<Renderer | null>(null);
// Only bake internally when no map was handed in.
const baked = useDotMap(dotMapProp ? null : src, bakeOptions);
const dotMap = dotMapProp ?? baked.dotMap;
const options: RendererOptions = {
color,
dotMap,
dotScale,
fit,
glow,
padding,
paused,
preset,
reducedMotion,
shape,
size,
speed,
state,
weight,
};
// Declared before the mount effect so it has already run when the renderer is
// created — writing a ref during render is what React Compiler rules out.
const optionsRef = useRef(options);
useEffect(() => {
optionsRef.current = options;
});
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) {
return;
}
const renderer = createRenderer(canvas, optionsRef.current);
rendererRef.current = renderer;
return () => {
renderer.destroy();
rendererRef.current = null;
};
}, []);
useEffect(() => {
rendererRef.current?.update({
color,
dotMap,
dotScale,
fit,
glow,
padding,
paused,
preset,
reducedMotion,
shape,
size,
speed,
state,
weight,
});
}, [
color,
dotMap,
dotScale,
fit,
glow,
padding,
paused,
preset,
reducedMotion,
shape,
size,
speed,
state,
weight,
]);
// Reserve the box before the renderer sizes the canvas, so nothing shifts.
const height = fit === "natural" && dotMap ? size / safeAspect(dotMap) : size;
return (
<canvas
aria-label={ariaLabel ?? STATE_LABEL[state]}
ref={canvasRef}
role="img"
style={{ display: "block", height, width: size, ...style }}
{...rest}
/>
);
}
export {
DEFAULT_BAKE,
bake,
bakeCached,
bakeKey,
clearBakeCache,
resolveBakeOptions,
} from "./benday/bake";
export type { BakeSource } from "./benday/bake";
export { prefersReducedMotion } from "./benday/dom";
export {
PRESET_FAMILIES,
PRESET_NAMES,
PRESETS,
dotRandom,
makeFrame,
} from "./benday/presets";
export type { PresetDefinition, PresetFamily } from "./benday/presets";
export { DEFAULT_RENDERER_OPTIONS, createRenderer } from "./benday/renderer";
export type {
BakeOptions,
BendayState,
Dot,
DotContext,
DotFrame,
DotMap,
DotShape,
Fit,
MaskMode,
Preset,
PresetName,
Renderer,
RendererOptions,
ResolvedBakeOptions,
ResolvedRendererOptions,
} from "./benday/types";
export { useDotMap } from "./benday/use-dot-map";
export type { UseDotMapResult } from "./benday/use-dot-map";
function safeAspect(dotMap: DotMap): number {
return Number.isFinite(dotMap.aspect) && dotMap.aspect > 0
? dotMap.aspect
: dotMap.cols / Math.max(1, dotMap.rows);
}
registry/ui/benday/bake.ts
import type {
BakeOptions,
Dot,
DotMap,
MaskMode,
ResolvedBakeOptions,
} from "./types";
export const DEFAULT_BAKE: Omit<ResolvedBakeOptions, "workingSize"> = {
dilate: 0,
gamma: 1,
grid: 24,
invert: false,
maskMode: "auto",
threshold: 0.18,
trim: true,
};
const clamp = (v: number, lo: number, hi: number) =>
Math.min(hi, Math.max(lo, v));
const clamp01 = (v: number) => clamp(v, 0, 1);
export function resolveBakeOptions(
options: BakeOptions = {}
): ResolvedBakeOptions {
const merged = { ...DEFAULT_BAKE, ...stripUndefined(options) };
const workingSize =
options.workingSize ?? clamp(Math.round(merged.grid * 16), 192, 768);
return { ...merged, workingSize };
}
function stripUndefined<T extends object>(o: T): Partial<T> {
const out: Partial<T> = {};
for (const k of Object.keys(o) as (keyof T)[]) {
if (o[k] !== undefined) {
out[k] = o[k];
}
}
return out;
}
export type BakeSource = string | Blob | HTMLImageElement | ImageBitmap;
function loadImage(src: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const img = new Image();
img.crossOrigin = "anonymous";
img.addEventListener("load", () => resolve(img), { once: true });
img.addEventListener(
"error",
() => reject(new Error(`benday: failed to load image "${src}"`)),
{ once: true }
);
img.src = src;
});
}
/**
* Rasterize at `workingSize` (longest side). SVGs go straight to the target
* size — vectors rasterize cleanly at any scale, so no intermediate pass.
*/
async function rasterize(
source: BakeSource,
workingSize: number
): Promise<{ data: ImageData; width: number; height: number }> {
let el: CanvasImageSource;
let iw = 0;
let ih = 0;
let revoke: string | undefined;
try {
if (typeof source === "string") {
const img = await loadImage(source);
el = img;
iw = img.naturalWidth || img.width;
ih = img.naturalHeight || img.height;
} else if (
typeof ImageBitmap !== "undefined" &&
source instanceof ImageBitmap
) {
el = source;
iw = source.width;
ih = source.height;
} else if (source instanceof Blob) {
revoke = URL.createObjectURL(source);
const img = await loadImage(revoke);
el = img;
iw = img.naturalWidth || img.width;
ih = img.naturalHeight || img.height;
} else {
const img = source as HTMLImageElement;
el = img;
iw = img.naturalWidth || img.width;
ih = img.naturalHeight || img.height;
}
// SVGs with only a viewBox report 0×0 in some browsers.
if (!iw || !ih) {
iw = 1024;
ih = 1024;
}
const scale = workingSize / Math.max(iw, ih);
const width = Math.max(1, Math.round(iw * scale));
const height = Math.max(1, Math.round(ih * scale));
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext("2d", { willReadFrequently: true });
if (!ctx) {
throw new Error("benday: could not acquire a 2D context");
}
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = "high";
ctx.drawImage(el, 0, 0, width, height);
let data: ImageData;
try {
data = ctx.getImageData(0, 0, width, height);
} catch {
throw new Error(
"benday: the image tainted the canvas (cross-origin without CORS headers)"
);
}
return { data, height, width };
} finally {
if (revoke) {
URL.revokeObjectURL(revoke);
}
}
}
const luma = (r: number, g: number, b: number) =>
(0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
function hasAlpha(px: Uint8ClampedArray): boolean {
// A handful of soft edge pixels is enough; a fully opaque photo has none.
let soft = 0;
for (let i = 3; i < px.length; i += 4) {
if (px[i] < 250) {
soft++;
if (soft > 8) {
return true;
}
}
}
return false;
}
/**
* Ink coverage per pixel, 0 = background, 1 = solid ink. Luma mode reads the
* corners for a background level, then measures each pixel's departure.
*/
function buildCoverage(
img: ImageData,
width: number,
height: number,
mode: "alpha" | "luma",
invert: boolean
): Float32Array {
const px = img.data;
const cov = new Float32Array(width * height);
if (mode === "alpha") {
for (let i = 0, p = 0; p < cov.length; i += 4, p++) {
cov[p] = px[i + 3] / 255;
}
return cov;
}
const corners = [
0,
(width - 1) * 4,
(height - 1) * width * 4,
((height - 1) * width + width - 1) * 4,
];
const cornerLuma = corners
.map((i) => luma(px[i], px[i + 1], px[i + 2]))
.toSorted((a, b) => a - b);
let bg = (cornerLuma[1] + cornerLuma[2]) / 2; // median of four
let bgIsLight = bg > 0.5;
if (invert) {
bgIsLight = !bgIsLight;
}
// Guard against a background that sits mid-grey, which would leave no headroom.
const span = Math.max(0.12, bgIsLight ? bg : 1 - bg);
if (!bgIsLight) {
bg = Math.min(bg, 1 - span);
}
for (let i = 0, p = 0; p < cov.length; i += 4, p++) {
const l = luma(px[i], px[i + 1], px[i + 2]);
const ink = bgIsLight ? bg - l : l - bg;
// Transparent regions in a luma-mode image still read as background.
cov[p] = clamp01(ink / span) * (px[i + 3] / 255);
}
return cov;
}
/**
* Preserve visible layers independently from silhouette coverage. Alpha tells
* us where artwork exists, but not whether one opaque region is lighter than
* another.
*
* Tone is each pixel's contrast against the surface the artwork reads against,
* scaled by the strongest such contrast present. Stretching the source's own
* range instead answered "where in this artwork's range does the pixel sit",
* which is a different question: a mid grey in a three-tone mark came out at
* a tenth of the area its darkness earns. Uniform artwork stays at full tone.
*/
function buildTone(img: ImageData, cov: Float32Array): Float32Array {
const px = img.data;
const histogram = new Float64Array(256);
let total = 0;
let luminanceSum = 0;
for (let i = 0, p = 0; p < cov.length; i += 4, p++) {
const coverage = cov[p];
if (coverage <= 0.02) {
continue;
}
const level = Math.round(luma(px[i], px[i + 1], px[i + 2]) * 255);
histogram[level] += coverage;
luminanceSum += level * coverage;
total += coverage;
}
const tone = new Float32Array(cov.length);
if (total === 0) {
return tone;
}
// White artwork on transparency is as common as black artwork. Let the
// dominant half of the source decide which surface it contrasts against.
const reference = luminanceSum / total > 128 ? 0 : 255;
const contrast = new Float64Array(256);
for (let level = 0; level < 256; level++) {
contrast[Math.abs(level - reference)] += histogram[level] as number;
}
// A percentile rather than the maximum, so one stray pixel cannot set the scale.
const strongest = histogramPercentile(contrast, total, 0.98);
if (strongest <= 0) {
return tone;
}
for (let i = 0, p = 0; p < cov.length; i += 4, p++) {
if (cov[p] <= 0.02) {
continue;
}
const level = luma(px[i], px[i + 1], px[i + 2]) * 255;
tone[p] = clamp01(Math.abs(level - reference) / strongest);
}
return tone;
}
function histogramPercentile(
histogram: Float64Array,
total: number,
percentile: number
): number {
const target = total * percentile;
let seen = 0;
for (let i = 0; i < histogram.length; i++) {
seen += histogram[i];
if (seen >= target) {
return i;
}
}
return histogram.length - 1;
}
/** Separable box dilation — grows the mask to rescue hairline strokes. */
function dilateCoverage(
cov: Float32Array,
width: number,
height: number,
r: number
): Float32Array {
if (r <= 0) {
return cov;
}
const radius = Math.round(r);
const tmp = new Float32Array(cov.length);
const out = new Float32Array(cov.length);
for (let y = 0; y < height; y++) {
const row = y * width;
for (let x = 0; x < width; x++) {
let m = 0;
const lo = Math.max(0, x - radius);
const hi = Math.min(width - 1, x + radius);
for (let k = lo; k <= hi; k++) {
if (cov[row + k] > m) {
m = cov[row + k];
}
}
tmp[row + x] = m;
}
}
for (let x = 0; x < width; x++) {
for (let y = 0; y < height; y++) {
let m = 0;
const lo = Math.max(0, y - radius);
const hi = Math.min(height - 1, y + radius);
for (let k = lo; k <= hi; k++) {
const v = tmp[k * width + x];
if (v > m) {
m = v;
}
}
out[y * width + x] = m;
}
}
return out;
}
const INF = 1e20;
/** 1D squared distance transform of a sampled function, in place-ish. */
function edt1d(
f: Float64Array,
d: Float64Array,
v: Int32Array,
z: Float64Array,
n: number
): void {
let k = 0;
v[0] = 0;
z[0] = -INF;
z[1] = INF;
for (let q = 1; q < n; q++) {
let s = (f[q] + q * q - (f[v[k]] + v[k] * v[k])) / (2 * q - 2 * v[k]);
while (s <= z[k]) {
k--;
s = (f[q] + q * q - (f[v[k]] + v[k] * v[k])) / (2 * q - 2 * v[k]);
}
k++;
v[k] = q;
z[k] = s;
z[k + 1] = INF;
}
k = 0;
for (let q = 0; q < n; q++) {
while (z[k + 1] < q) {
k++;
}
const dq = q - v[k];
d[q] = dq * dq + f[v[k]];
}
}
/**
* Exact distance from each inside pixel to the nearest outside one, after
* Felzenszwalb & Huttenlocher: O(n), and true distance a ripple can order by.
*/
function distanceTransform(
mask: Uint8Array,
width: number,
height: number
): { dist: Float32Array; max: number } {
const grid = new Float64Array(width * height);
for (let i = 0; i < grid.length; i++) {
grid[i] = mask[i] ? INF : 0;
}
const n = Math.max(width, height);
const f = new Float64Array(n);
const d = new Float64Array(n);
const v = new Int32Array(n);
const z = new Float64Array(n + 1);
for (let y = 0; y < height; y++) {
const row = y * width;
for (let x = 0; x < width; x++) {
f[x] = grid[row + x];
}
edt1d(f, d, v, z, width);
for (let x = 0; x < width; x++) {
grid[row + x] = d[x];
}
}
for (let x = 0; x < width; x++) {
for (let y = 0; y < height; y++) {
f[y] = grid[y * width + x];
}
edt1d(f, d, v, z, height);
for (let y = 0; y < height; y++) {
grid[y * width + x] = d[y];
}
}
const dist = new Float32Array(width * height);
let max = 0;
for (let i = 0; i < dist.length; i++) {
const val = Math.sqrt(grid[i]);
dist[i] = val;
if (val > max) {
max = val;
}
}
return { dist, max };
}
interface ContentBounds {
minX: number;
minY: number;
boxW: number;
boxH: number;
}
/** Which masking strategy to run, resolving `auto` against the pixels. */
function resolveMaskMode(
mode: MaskMode,
pixels: Uint8ClampedArray
): "alpha" | "luma" {
if (mode !== "auto") {
return mode;
}
return hasAlpha(pixels) ? "alpha" : "luma";
}
/** Bounding box of the ink; the full frame if trimming is off or it is empty. */
function findContentBounds(
cov: Float32Array,
width: number,
height: number,
trim: boolean
): ContentBounds {
let minX = width;
let minY = height;
let maxX = -1;
let maxY = -1;
if (trim) {
for (let y = 0; y < height; y++) {
const row = y * width;
for (let x = 0; x < width; x++) {
if (cov[row + x] > 0.06) {
minX = Math.min(minX, x);
maxX = Math.max(maxX, x);
minY = Math.min(minY, y);
maxY = Math.max(maxY, y);
}
}
}
}
if (maxX < minX || maxY < minY) {
return { boxH: height, boxW: width, minX: 0, minY: 0 };
}
return {
boxH: maxY - minY + 1,
boxW: maxX - minX + 1,
minX,
minY,
};
}
/** Mean coverage and coverage-weighted depth per cell. */
function sampleDots(
cov: Float32Array,
tone: Float32Array,
dist: Float32Array,
width: number,
height: number,
bounds: ContentBounds,
cols: number,
rows: number,
threshold: number
): Dot[] {
const cellW = bounds.boxW / cols;
const cellH = bounds.boxH / rows;
const dots: Dot[] = [];
for (let row = 0; row < rows; row++) {
const y0 = Math.floor(bounds.minY + row * cellH);
const y1 = Math.max(y0 + 1, Math.floor(bounds.minY + (row + 1) * cellH));
for (let col = 0; col < cols; col++) {
const x0 = Math.floor(bounds.minX + col * cellW);
const x1 = Math.max(x0 + 1, Math.floor(bounds.minX + (col + 1) * cellW));
let sum = 0;
let count = 0;
let weightedDepth = 0;
let weightedTone = 0;
for (let y = y0; y < y1 && y < height; y++) {
const r = y * width;
for (let x = x0; x < x1 && x < width; x++) {
sum += cov[r + x];
weightedDepth += dist[r + x] * cov[r + x];
weightedTone += tone[r + x] * cov[r + x];
count++;
}
}
if (count === 0) {
continue;
}
const v = sum / count;
if (v < threshold) {
continue;
}
dots.push({
col,
d: sum > 0 ? weightedDepth / sum : 0,
row,
t: sum > 0 ? weightedTone / sum : 1,
v: clamp01(v),
x: (col + 0.5) / cols,
y: (row + 0.5) / rows,
});
}
}
return dots;
}
/**
* Normalize against the deepest sampled dot, not the deepest pixel: a hairline
* mark would otherwise flatten `d` to zero and leave depth presets inert.
*/
function normalizeDepth(dots: Dot[]): void {
let maxDepth = 0;
for (const dot of dots) {
maxDepth = Math.max(maxDepth, dot.d);
}
for (const dot of dots) {
dot.d = maxDepth > 0 ? clamp01(dot.d / maxDepth) : 0;
}
}
/**
* Turn an image into a {@link DotMap}. Browser-only: it goes via a canvas. Run
* it at build time and ship the JSON, or at runtime for any-logo drop-in.
*/
export async function bake(
source: BakeSource,
options: BakeOptions = {}
): Promise<DotMap> {
const opts = resolveBakeOptions(options);
const { data, width, height } = await rasterize(source, opts.workingSize);
const mode = resolveMaskMode(opts.maskMode, data.data);
let cov = buildCoverage(data, width, height, mode, opts.invert);
const tone = buildTone(data, cov);
if (opts.gamma !== 1) {
const g = Math.max(0.05, opts.gamma);
for (let i = 0; i < cov.length; i++) {
cov[i] **= g;
}
}
cov = dilateCoverage(cov, width, height, opts.dilate);
// Binary mask for the distance transform. A low fixed cut keeps antialiased
// edges inside the shape so the EDT measures the stroke, not the core.
const mask = new Uint8Array(cov.length);
for (let i = 0; i < cov.length; i++) {
mask[i] = cov[i] > 0.5 ? 1 : 0;
}
const bounds = findContentBounds(cov, width, height, opts.trim);
const { dist } = distanceTransform(mask, width, height);
// Grid the content box so the longest side gets `grid` cells.
const longest = Math.max(bounds.boxW, bounds.boxH);
const cols = Math.max(1, Math.round((bounds.boxW / longest) * opts.grid));
const rows = Math.max(1, Math.round((bounds.boxH / longest) * opts.grid));
const dots = sampleDots(
cov,
tone,
dist,
width,
height,
bounds,
cols,
rows,
opts.threshold
);
normalizeDepth(dots);
return {
aspect: bounds.boxW / bounds.boxH,
cells: cols * rows,
cols,
dots,
maskMode: mode,
rows,
};
}
const cache = new Map<string, Promise<DotMap>>();
/** Cache key for a (source, options) pair. Only string sources are cacheable. */
export function bakeKey(src: string, options: BakeOptions = {}): string {
const o = resolveBakeOptions(options);
return [
src,
o.grid,
o.threshold,
o.gamma,
o.maskMode,
o.invert ? 1 : 0,
o.dilate,
o.trim ? 1 : 0,
o.workingSize,
].join("|");
}
/** A failed bake must not stay cached, or the error is permanent. */
async function bakeAndForgetOnFailure(
key: string,
src: string,
options: BakeOptions
): Promise<DotMap> {
try {
return await bake(src, options);
} catch (error) {
cache.delete(key);
throw error;
}
}
/** {@link bake}, memoized per (url, options). */
export function bakeCached(
src: string,
options: BakeOptions = {}
): Promise<DotMap> {
const key = bakeKey(src, options);
let hit = cache.get(key);
if (!hit) {
hit = bakeAndForgetOnFailure(key, src, options);
cache.set(key, hit);
}
return hit;
}
export function clearBakeCache(): void {
cache.clear();
}
registry/ui/benday/dom.ts
/** Framework-agnostic DOM plumbing; what keeps the React wrapper a thin shell. */
const noop = () => {
// no environment to observe
};
const canObserve = typeof window !== "undefined";
/** A canvas cannot inherit `currentColor`; resolve it off the computed style. */
export function resolveInk(canvas: HTMLCanvasElement, color: string): string {
if (color !== "currentColor") {
return color;
}
return getComputedStyle(canvas).color || "#000";
}
/**
* Fires when the ambient theme could have changed: OS preference, or a
* `class`/`data-theme` flip up the tree. Re-resolve `currentColor` on it.
*/
export function watchTheme(onChange: () => void): () => void {
if (!canObserve) {
return noop;
}
const query = window.matchMedia("(prefers-color-scheme: dark)");
query.addEventListener("change", onChange);
const observer = new MutationObserver(onChange);
observer.observe(document.documentElement, {
attributeFilter: ["class", "data-theme", "style"],
attributes: true,
subtree: true,
});
return () => {
query.removeEventListener("change", onChange);
observer.disconnect();
};
}
export function prefersReducedMotion(): boolean {
if (!canObserve) {
return false;
}
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
export function watchReducedMotion(
onChange: (reduced: boolean) => void
): () => void {
if (!canObserve) {
return noop;
}
const query = window.matchMedia("(prefers-reduced-motion: reduce)");
const handler = (event: MediaQueryListEvent) => onChange(event.matches);
query.addEventListener("change", handler);
return () => query.removeEventListener("change", handler);
}
/**
* Whether the element is worth painting: on screen and in a visible tab. Off
* either one, the indicator should cost nothing.
*/
export function watchPaintability(
element: Element,
onChange: (paintable: boolean) => void
): () => void {
if (!canObserve) {
return noop;
}
let onScreen = true;
const emit = () =>
onChange(onScreen && document.visibilityState !== "hidden");
const observer =
typeof IntersectionObserver === "undefined"
? null
: new IntersectionObserver((entries) => {
const [entry] = entries;
if (entry) {
onScreen = entry.isIntersecting;
emit();
}
});
observer?.observe(element);
document.addEventListener("visibilitychange", emit);
// Without an IntersectionObserver, assume on-screen and let visibility drive.
if (!observer) {
emit();
}
return () => {
observer?.disconnect();
document.removeEventListener("visibilitychange", emit);
};
}
/**
* Backing-store pixel ratio, capped. The floor is 2 even on a 1× display: small
* marks are all edge, and supersampling a 20px canvas is 1600 cheap pixels.
*/
export function devicePixelRatioCapped(max = 3, min = 2): number {
const ratio = typeof devicePixelRatio === "undefined" ? 1 : devicePixelRatio;
return Math.min(max, Math.max(min, ratio || 1));
}
registry/ui/benday/presets.ts
import type { DotFrame, Preset, PresetName } from "./types";
const TAU = Math.PI * 2;
const fract = (v: number) => v - Math.floor(v);
const clamp01 = (v: number) => Math.min(1, Math.max(0, v));
/** Signed wrapped difference in a 0..1 cyclic space, -0.5..0.5. */
const cyclicDelta = (v: number) => {
const d = fract(v);
return d > 0.5 ? d - 1 : d;
};
const gauss = (d: number, sigma: number) =>
Math.exp(-(d * d) / (2 * sigma * sigma));
function smoothstep(edge0: number, edge1: number, value: number): number {
const x = clamp01((value - edge0) / (edge1 - edge0));
return x * x * (3 - 2 * x);
}
function hash2(x: number, y: number): number {
const s = Math.sin(x * 127.1 + y * 311.7) * 43_758.5453;
return s - Math.floor(s);
}
/** Smoothed 2D value noise, 0..1. */
function noise2(x: number, y: number): number {
const xi = Math.floor(x);
const yi = Math.floor(y);
let fx = x - xi;
let fy = y - yi;
fx = fx * fx * (3 - 2 * fx);
fy = fy * fy * (3 - 2 * fy);
const a = hash2(xi, yi);
const b = hash2(xi + 1, yi);
const c = hash2(xi, yi + 1);
const d = hash2(xi + 1, yi + 1);
return a + (b - a) * fx + (c - a) * fy + (a - b - c + d) * fx * fy;
}
/* Each preset writes into a reused `out` — it runs once per dot per frame, so
allocating there would churn the heap at 60fps × 500 dots. */
/** A lit band sweeps the mark on the diagonal — the shimmer-text idiom, in dots. */
const shimmer: Preset = (c, t, out) => {
const u = c.x * 0.72 + c.y * 0.28;
const phase = cyclicDelta(u - t * 0.42);
const g = gauss(phase, 0.11);
out.a = 0.2 + 0.8 * g;
out.s = 0.8 + 0.4 * g;
out.dx = 0;
out.dy = 0;
};
/** Concentric rings pulse outward from the mark's center. */
const ripple: Preset = (c, t, out) => {
const w = (Math.sin(t * 2.4 - c.r * 7) + 1) / 2;
const g = w * w;
out.a = 0.18 + 0.82 * g;
out.s = 0.76 + 0.42 * g;
out.dx = 0;
out.dy = 0;
};
/** A wave travels along the shape's own thickness: outline first, core last. */
const contour: Preset = (c, t, out) => {
const w = (Math.sin(t * 2.2 - c.d * 6.5) + 1) / 2;
const g = w ** 1.6;
out.a = 0.16 + 0.84 * g;
out.s = 0.7 + 0.55 * g;
out.dx = 0;
out.dy = 0;
};
/** Dots drift off the lattice on a noise field, then reconverge into the mark. */
const scatter: Preset = (c, t, out) => {
const spread = 0.5 - 0.5 * Math.cos(t * 0.85);
const amp = spread * 2.4;
const nx = noise2(c.x * 3.1 + t * 0.22, c.y * 3.1) * 2 - 1;
const ny = noise2(c.x * 3.1 + 17.3, c.y * 3.1 - t * 0.22) * 2 - 1;
const jitter = (c.rand - 0.5) * 0.6 * spread;
out.dx = nx * amp + jitter;
out.dy = ny * amp - jitter;
out.a = 1 - 0.5 * spread;
out.s = 1 - 0.28 * spread;
};
/** A random subset of dots blinks at any moment. */
const flicker: Preset = (c, t, out) => {
const phase = fract(t * 0.7 + c.rand);
const g = phase < 0.3 ? Math.sin((phase / 0.3) * Math.PI) : 0;
out.a = 0.14 + 0.86 * g;
out.s = 0.82 + 0.34 * g;
out.dx = 0;
out.dy = 0;
};
/** The whole mark swells and settles, with a slight delay toward the edges. */
const breathe: Preset = (c, t, out) => {
const g = (Math.sin(t * 1.5 - c.r * 1.1) + 1) / 2;
out.a = 0.42 + 0.58 * g;
out.s = 0.86 + 0.24 * g;
out.dx = 0;
out.dy = 0;
};
/** The mark twists around its center, outer dots lagging the inner ones. */
const swirl: Preset = (c, t, out) => {
const rotation = 0.45 * Math.sin(t * 1.3 - c.r * 2.4);
const cos = Math.cos(rotation);
const sin = Math.sin(rotation);
const rx = c.nx * cos - c.ny * sin;
const ry = c.nx * sin + c.ny * cos;
out.dx = (rx - c.nx) * (c.cols / 2);
out.dy = (ry - c.ny) * (c.rows / 2);
const swing = Math.abs(rotation) / 0.45;
out.a = 0.55 + 0.45 * (1 - swing);
out.s = 0.9 + 0.15 * (1 - swing);
};
/** A narrow vertical beam traverses the complete silhouette. */
const scan: Preset = (c, t, out) => {
const phase = cyclicDelta(c.x - t * 0.34);
const g = gauss(phase, 0.075);
out.a = 0.2 + 0.8 * g;
out.s = 0.76 + 0.42 * g;
out.dx = 0;
out.dy = 0;
};
/** One long signal follows the baked lattice in row-wise serpentine order. */
const cascade: Preset = (c, t, out) => {
const row = Math.min(c.rows - 1, Math.floor(c.y * c.rows));
const across = row % 2 === 0 ? c.x : 1 - c.x;
const path = (row + across) / c.rows;
const phase = cyclicDelta(path - t * 0.24);
const g = gauss(phase, 0.055);
out.a = 0.16 + 0.84 * g;
out.s = 0.72 + 0.5 * g;
out.dx = 0;
out.dy = 0;
};
/** A soft energy point circles the center without rotating the logo itself. */
const orbit: Preset = (c, t, out) => {
const angle = t * 1.25;
const ox = Math.cos(angle) * 0.62;
const oy = Math.sin(angle) * 0.62;
const distance = Math.hypot(c.nx - ox, c.ny - oy);
const g = gauss(distance, 0.3);
out.a = 0.2 + 0.8 * g;
out.s = 0.76 + 0.44 * g;
out.dx = 0;
out.dy = 0;
};
/** A bright head and tapered tail chase clockwise around the mark. */
const comet: Preset = (c, t, out) => {
const angle = fract(c.angle / TAU);
const behind = fract(t * 0.2 - angle);
const trail = Math.exp(-behind * 7);
const ring = 0.35 + 0.65 * smoothstep(0.12, 0.72, c.r);
const g = trail * ring;
out.a = 0.18 + 0.82 * g;
out.s = 0.75 + 0.48 * g;
out.dx = -c.ny * g * 0.26;
out.dy = c.nx * g * 0.26;
};
/** A rotating search beam crosses the full logo with a soft angular wake. */
const radar: Preset = (c, t, out) => {
const angle = fract(c.angle / TAU);
const head = fract(t * 0.16);
const delta = cyclicDelta(angle - head);
const beam = gauss(delta, 0.045);
const wake = delta < 0 ? Math.exp(delta * 8) * 0.42 : 0;
const g = Math.max(beam, wake) * (0.72 + c.r * 0.28);
out.a = 0.18 + 0.82 * g;
out.s = 0.76 + 0.42 * g;
out.dx = 0;
out.dy = 0;
};
/** Three curved blades rotate through the mark around a steady core. */
const pinwheel: Preset = (c, t, out) => {
const wave = (Math.sin(c.angle * 3 - t * 2.1 - c.r * 3.4) + 1) / 2;
const g = wave ** 2.2;
const core = gauss(c.r, 0.18);
out.a = 0.18 + 0.82 * Math.max(g, core);
out.s = 0.76 + 0.4 * Math.max(g, core);
out.dx = 0;
out.dy = 0;
};
/** A soft traveling current bends the lattice without breaking the silhouette. */
const wave: Preset = (c, t, out) => {
const phase = t * 1.7 - c.y * 6.2;
const crest = (Math.sin(phase) + 1) / 2;
const sway = Math.sin(phase) * (0.28 + c.r * 0.42);
out.a = 0.46 + 0.54 * crest;
out.s = 0.86 + 0.24 * crest;
out.dx = sway;
out.dy = Math.cos(phase - c.x * 1.4) * 0.08;
};
/** Independent column levels rise and fall like a restrained spectrum display. */
const equalizer: Preset = (c, t, out) => {
const col = Math.min(c.cols - 1, Math.floor(c.x * c.cols));
const phase = t * 1.85 + col * 0.73 + hash2(col, 4.2) * 1.4;
const level = 0.2 + 0.75 * ((Math.sin(phase) + 1) / 2);
const g = 1 - smoothstep(level - 0.08, level + 0.08, Math.abs(c.ny));
out.a = 0.18 + 0.82 * g;
out.s = 0.76 + 0.4 * g;
out.dx = 0;
out.dy = 0;
};
/** Counter-moving diagonal bands cross and briefly brighten where they meet. */
const weave: Preset = (c, t, out) => {
const a = gauss(cyclicDelta(c.x * 0.62 + c.y * 0.38 - t * 0.3), 0.08);
const b = gauss(cyclicDelta(c.x * 0.62 - c.y * 0.38 + t * 0.24), 0.08);
const g = clamp01(Math.max(a, b, Math.min(a, b) * 1.25));
out.a = 0.18 + 0.82 * g;
out.s = 0.76 + 0.44 * g;
out.dx = 0;
out.dy = 0;
};
/** Emphasis hands off between north, east, south and west. */
const beacon: Preset = (c, t, out) => {
let sector = 0;
if (Math.abs(c.nx) > Math.abs(c.ny)) {
sector = c.nx > 0 ? 0.25 : 0.75;
} else if (c.ny > 0) {
sector = 0.5;
}
const phase = cyclicDelta(sector - t * 0.28);
const g = gauss(phase, 0.1) * (0.55 + c.r * 0.45);
out.a = 0.2 + 0.8 * g;
out.s = 0.78 + 0.4 * g;
out.dx = 0;
out.dy = 0;
};
/** Brief horizontal faults disturb a few rows, then snap cleanly home. */
const glitch: Preset = (c, t, out) => {
const row = Math.min(c.rows - 1, Math.floor(c.y * c.rows));
const cycle = Math.floor(t * 0.48 + 0.5);
const burst = gauss(cyclicDelta(t * 0.48), 0.055);
const slice = hash2(row, cycle);
const active = slice > 0.58 ? burst : 0;
out.a = 1 - active * 0.34;
out.s = 1 - active * 0.12;
out.dx = (slice - 0.5) * 3.2 * active;
out.dy = 0;
};
/** Offset droplets descend each column with soft tails. */
const rain: Preset = (c, t, out) => {
const col = Math.min(c.cols - 1, Math.floor(c.x * c.cols));
const head = fract(t * 0.34 + hash2(col, 8.1));
const delta = cyclicDelta(c.y - head);
const trail = delta < 0 ? Math.exp(delta * 6) : Math.exp(-delta * 18);
out.a = 0.16 + 0.84 * trail;
out.s = 0.74 + 0.44 * trail;
out.dx = 0;
out.dy = trail * 0.18;
};
/** The field is pulled toward its center and released without losing its shape. */
const magnetic: Preset = (c, t, out) => {
const pull = 0.5 - 0.5 * Math.cos(t * 1.15);
const strength = pull * (0.35 + c.r * 0.65);
out.dx = -c.nx * c.cols * 0.12 * strength;
out.dy = -c.ny * c.rows * 0.12 * strength;
out.a = 1 - pull * 0.28;
out.s = 1 - pull * 0.2;
};
/** Dots assemble in a stable random order, hold, then dissolve to begin again. */
const resolve: Preset = (c, t, out) => {
const progress = 0.5 - 0.5 * Math.cos(t * 1.08);
const g = smoothstep(c.rand - 0.12, c.rand + 0.12, progress);
out.a = 0.14 + 0.86 * g;
out.s = 0.66 + 0.34 * g;
out.dx = c.nx * (1 - g) * 0.38;
out.dy = c.ny * (1 - g) * 0.38;
};
export type PresetFamily =
| "Signature"
| "Sweep"
| "Orbit"
| "Field"
| "Transform";
export interface PresetDefinition {
name: PresetName;
label: string;
description: string;
family: PresetFamily;
fn: Preset;
}
export const PRESET_FAMILIES: PresetFamily[] = [
"Signature",
"Sweep",
"Orbit",
"Field",
"Transform",
];
export const PRESETS: Record<PresetName, PresetDefinition> = {
beacon: {
description: "Emphasis hands off between the four cardinal directions.",
family: "Orbit",
fn: beacon,
label: "Beacon",
name: "beacon",
},
breathe: {
description: "The whole mark swells and settles with a soft edge delay.",
family: "Signature",
fn: breathe,
label: "Breathe",
name: "breathe",
},
cascade: {
description: "One signal follows the lattice in serpentine order.",
family: "Sweep",
fn: cascade,
label: "Cascade",
name: "cascade",
},
comet: {
description: "A bright head and tapered tail chase around the mark.",
family: "Orbit",
fn: comet,
label: "Comet",
name: "comet",
},
contour: {
description: "A wave follows the mark’s thickness from outline to core.",
family: "Signature",
fn: contour,
label: "Contour",
name: "contour",
},
equalizer: {
description: "Independent column levels rise and fall like a spectrum.",
family: "Field",
fn: equalizer,
label: "Equalizer",
name: "equalizer",
},
flicker: {
description: "A stable random subset of dots blinks at any moment.",
family: "Field",
fn: flicker,
label: "Flicker",
name: "flicker",
},
glitch: {
description: "Brief horizontal faults disturb a few rows, then clear.",
family: "Transform",
fn: glitch,
label: "Glitch",
name: "glitch",
},
magnetic: {
description: "The field pulls toward its center and releases.",
family: "Transform",
fn: magnetic,
label: "Magnetic",
name: "magnetic",
},
orbit: {
description: "A soft energy point circles the center of the mark.",
family: "Orbit",
fn: orbit,
label: "Orbit",
name: "orbit",
},
pinwheel: {
description: "Three curved blades rotate around a steady core.",
family: "Orbit",
fn: pinwheel,
label: "Pinwheel",
name: "pinwheel",
},
radar: {
description: "A rotating search beam crosses the logo with a soft wake.",
family: "Orbit",
fn: radar,
label: "Radar",
name: "radar",
},
rain: {
description: "Offset droplets descend each column with soft tails.",
family: "Sweep",
fn: rain,
label: "Rain",
name: "rain",
},
resolve: {
description: "Dots assemble in stable random order, then dissolve.",
family: "Field",
fn: resolve,
label: "Resolve",
name: "resolve",
},
ripple: {
description: "Concentric rings pulse outward from the center.",
family: "Signature",
fn: ripple,
label: "Ripple",
name: "ripple",
},
scan: {
description: "A narrow vertical beam traverses the silhouette.",
family: "Sweep",
fn: scan,
label: "Scan",
name: "scan",
},
scatter: {
description: "Dots drift off the lattice, then reconverge into the mark.",
family: "Transform",
fn: scatter,
label: "Scatter",
name: "scatter",
},
shimmer: {
description: "A lit band sweeps across the mark on the diagonal.",
family: "Signature",
fn: shimmer,
label: "Shimmer",
name: "shimmer",
},
swirl: {
description: "The mark twists around its center, with outer dots lagging.",
family: "Orbit",
fn: swirl,
label: "Swirl",
name: "swirl",
},
wave: {
description: "A soft traveling current bends the dot lattice.",
family: "Field",
fn: wave,
label: "Wave",
name: "wave",
},
weave: {
description: "Counter-moving diagonal bands cross through the mark.",
family: "Sweep",
fn: weave,
label: "Weave",
name: "weave",
},
};
/** Curated gallery order: approachable signatures before more expressive motion. */
export const PRESET_NAMES: PresetName[] = [
"contour",
"shimmer",
"ripple",
"breathe",
"scan",
"cascade",
"weave",
"rain",
"swirl",
"orbit",
"comet",
"radar",
"pinwheel",
"beacon",
"flicker",
"wave",
"equalizer",
"resolve",
"scatter",
"magnetic",
"glitch",
];
/** Per-dot randomness that stays stable across frames. */
export function dotRandom(i: number): number {
return hash2(i * 0.371, i * 0.917 + 3.14);
}
export function makeFrame(): DotFrame {
return { a: 1, dx: 0, dy: 0, s: 1 };
}
registry/ui/benday/renderer.ts
import {
devicePixelRatioCapped,
prefersReducedMotion,
resolveInk,
watchPaintability,
watchReducedMotion,
watchTheme,
} from "./dom";
import { PRESETS, dotRandom } from "./presets";
import type {
DotContext,
DotFrame,
DotMap,
DotShape,
Preset,
Renderer,
RendererOptions,
ResolvedRendererOptions,
} from "./types";
export const DEFAULT_RENDERER_OPTIONS: ResolvedRendererOptions = {
color: "currentColor",
dotMap: null,
dotScale: 0.62,
fit: "square",
glow: 0,
padding: 0.06,
paused: false,
preset: "contour",
reducedMotion: "auto",
shape: "circle",
size: 64,
speed: 1,
state: "thinking",
weight: 0.5,
};
/** Light spring for the thinking ↔ crisp transition. Slightly under-damped. */
const STIFFNESS = 140;
const DAMPING = 20;
const MAX_TIMESTEP = 0.05;
/** Below this the lattice is subpixel haze. In device pixels: that is where dots resolve. */
const MIN_AUTO_CELL_DEVICE_PX = 2;
/** Dot diameter, in cells, whose area fills the cell — what full ink must reach to paint solid. */
const FULL_COVERAGE_DIAMETER = 2 / Math.sqrt(Math.PI);
/** Options whose change invalidates the cached per-dot contexts. */
const DERIVED_KEYS = ["dotMap", "fit", "padding", "size", "weight"] as const;
/** Options whose change invalidates the cached canvas geometry. */
const LAYOUT_KEYS = ["dotMap", "size", "fit", "padding", "dotScale"] as const;
/**
* Paint a {@link DotMap} onto a canvas and animate it. One per canvas, updated
* through {@link Renderer.update}: the clock lives here, so props never restart it.
*/
export function createRenderer(
canvas: HTMLCanvasElement,
initial: RendererOptions = {}
): Renderer {
const context = canvas.getContext("2d");
if (!context) {
throw new Error("benday: could not acquire a 2D context");
}
const ctx = context;
let opts: ResolvedRendererOptions = {
...DEFAULT_RENDERER_OPTIONS,
...strip(initial),
};
// Caches, rebuilt only when their inputs change.
let dots: DotContext[] = [];
let toneScales: number[] = [];
let displayCols = 1;
let displayRows = 1;
let ink = "#000";
let cssWidth = 0;
let cssHeight = 0;
let cell = 0;
let originX = 0;
let originY = 0;
let baseRadius = 0;
// Animation state, deliberately outliving update().
let clock = 0;
let settle = 1;
let velocity = 0;
let frameId = 0;
let running = false;
let lastFrame = 0;
let paintable = true;
let systemReducedMotion = prefersReducedMotion();
const frame: DotFrame = { a: 1, dx: 0, dy: 0, s: 1 };
function isReduced(): boolean {
return opts.reducedMotion === "auto"
? systemReducedMotion
: opts.reducedMotion;
}
function presetFn(): Preset {
return typeof opts.preset === "function"
? opts.preset
: PRESETS[opts.preset].fn;
}
function buildDerived(): void {
const map = opts.dotMap;
if (!map) {
dots = [];
toneScales = [];
displayCols = 1;
displayRows = 1;
return;
}
const display = buildDisplayMap(map, opts);
displayCols = display.cols;
displayRows = display.rows;
dots = display.dots.map((dot, i) => {
const nx = dot.x * 2 - 1;
const ny = dot.y * 2 - 1;
return {
angle: Math.atan2(ny, nx),
cols: displayCols,
d: dot.d,
i,
n: display.dots.length,
nx,
ny,
r: Math.min(1, Math.hypot(nx, ny) / Math.SQRT2),
rand: dotRandom(i),
rows: displayRows,
t: dot.t,
v: dot.v,
x: dot.x,
y: dot.y,
};
});
toneScales = display.dots.map((dot) =>
toneScale(dot.v, dot.t, opts.weight)
);
}
function buildLayout(): void {
const map = opts.dotMap;
const cols = map ? displayCols : 1;
const rows = map ? displayRows : 1;
cssWidth = opts.size;
cssHeight =
opts.fit === "natural" ? opts.size / safeAspect(map) : opts.size;
const dpr = devicePixelRatioCapped();
canvas.width = Math.round(cssWidth * dpr);
canvas.height = Math.round(cssHeight * dpr);
canvas.style.width = `${cssWidth}px`;
canvas.style.height = `${cssHeight}px`;
const padding = opticalPadding(opts.padding, Math.min(cssWidth, cssHeight));
const availableWidth = cssWidth * (1 - padding * 2);
const availableHeight = cssHeight * (1 - padding * 2);
cell = Math.min(availableWidth / cols, availableHeight / rows);
// A fine lattice lands every dot on a different fraction of a device pixel,
// so each antialiases differently and the grid dissolves. Only small marks
// need it, and only if the snapped lattice still fits the canvas.
if (opticalFactor(Math.min(cssWidth, cssHeight)) > 0) {
const devicePixel = 1 / dpr;
const snapped = Math.max(
devicePixel,
Math.round(cell / devicePixel) * devicePixel
);
if (snapped * cols <= cssWidth && snapped * rows <= cssHeight) {
cell = snapped;
}
}
originX = snapTo((cssWidth - cell * cols) / 2, dpr);
originY = snapTo((cssHeight - cell * rows) / 2, dpr);
baseRadius = (cell * FULL_COVERAGE_DIAMETER * opts.dotScale) / 2;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function buildInk(): void {
ink = resolveInk(canvas, opts.color);
}
function paint(): void {
ctx.clearRect(0, 0, cssWidth, cssHeight);
if (dots.length === 0) {
return;
}
ctx.fillStyle = ink;
const optical = opticalFactor(Math.min(cssWidth, cssHeight));
// Only `glow` blurs. Small marks used to get a halo here for optical
// weight, but at 16px it came out wider than the cell and the lattice bled
// into haze; `optical` buys that weight through radius and alpha instead.
if (opts.glow > 0) {
ctx.shadowColor = ink;
ctx.shadowBlur = baseRadius * 4 * opts.glow;
} else {
ctx.shadowBlur = 0;
}
const run = presetFn();
const cols = displayCols;
const rows = displayRows;
const motion = (1 - settle) * (1 - optical * 0.65);
for (let i = 0; i < dots.length; i++) {
const dot = dots[i] as DotContext;
frame.s = 1;
frame.a = 1;
frame.dx = 0;
frame.dy = 0;
run(dot, clock, frame);
// settle = 1 is the crisp logo; blend every channel toward it.
const settledScale = frame.s + (1 - frame.s) * settle;
const settledAlpha = frame.a + (1 - frame.a) * settle;
const scale = settledScale + (1 - settledScale) * optical * 0.55;
const alpha = settledAlpha + (1 - settledAlpha) * optical * 0.8;
const radius =
baseRadius * scale * (toneScales[i] as number) * shapeScale(opts.shape);
if (radius <= 0.05) {
continue;
}
const opacity = Math.min(1, Math.max(0, alpha));
if (opacity <= 0.004) {
continue;
}
ctx.globalAlpha = opacity;
drawDot(
ctx,
opts.shape,
originX + (dot.x * cols + frame.dx * motion) * cell,
originY + (dot.y * rows + frame.dy * motion) * cell,
radius
);
}
ctx.globalAlpha = 1;
ctx.shadowBlur = 0;
}
function settleTarget(): number {
return opts.state === "thinking" ? 0 : 1;
}
function isSettled(): boolean {
return settle === settleTarget() && velocity === 0;
}
function shouldAnimate(): boolean {
if (isReduced() || opts.paused || !paintable) {
return false;
}
return opts.state === "thinking" || !isSettled();
}
function step(now: number): void {
const dt = lastFrame
? Math.min(MAX_TIMESTEP, (now - lastFrame) / 1000)
: 1 / 60;
lastFrame = now;
clock += dt * opts.speed;
const target = settleTarget();
velocity += ((target - settle) * STIFFNESS - velocity * DAMPING) * dt;
settle += velocity * dt;
if (Math.abs(target - settle) < 0.001 && Math.abs(velocity) < 0.01) {
settle = target;
velocity = 0;
}
paint();
if (running && shouldAnimate()) {
frameId = requestAnimationFrame(step);
} else {
running = false;
}
}
function start(): void {
if (running) {
return;
}
running = true;
lastFrame = 0;
frameId = requestAnimationFrame(step);
}
function stop(): void {
running = false;
cancelAnimationFrame(frameId);
}
function sync(): void {
if (shouldAnimate()) {
start();
} else {
stop();
}
}
// Reduced motion means "show me the mark, not the animation".
function applyReducedMotion(): void {
if (isReduced()) {
settle = 1;
velocity = 0;
}
}
const stopTheme = watchTheme(() => {
buildInk();
if (!running) {
paint();
}
});
const stopReducedMotion = watchReducedMotion((reduced) => {
systemReducedMotion = reduced;
applyReducedMotion();
paint();
sync();
});
const stopPaintability = watchPaintability(canvas, (next) => {
paintable = next;
sync();
});
buildDerived();
buildLayout();
buildInk();
applyReducedMotion();
paint();
sync();
return {
destroy(): void {
stop();
stopTheme();
stopReducedMotion();
stopPaintability();
},
get height(): number {
return cssHeight;
},
get options(): Readonly<ResolvedRendererOptions> {
return opts;
},
update(next: RendererOptions): void {
const previous = opts;
opts = { ...opts, ...strip(next) };
if (DERIVED_KEYS.some((key) => previous[key] !== opts[key])) {
buildDerived();
}
if (LAYOUT_KEYS.some((key) => previous[key] !== opts[key])) {
buildLayout();
}
if (previous.color !== opts.color) {
buildInk();
}
applyReducedMotion();
paint();
sync();
},
};
}
function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value));
}
/** Round a CSS-pixel position onto the backing store's pixel grid. */
function snapTo(value: number, dpr: number): number {
return Math.round(value * dpr) / dpr;
}
function clamp01(value: number): number {
return clamp(value, 0, 1);
}
interface DisplayDot {
d: number;
t: number;
v: number;
x: number;
y: number;
}
interface DisplayMap {
cols: number;
dots: DisplayDot[];
rows: number;
}
/**
* Downsample an over-dense map into a coarser regular lattice. Missing source
* dots count as empty cells, so the aggregate retains the source's ink mass
* instead of merely making every surviving dot larger.
*/
function buildDisplayMap(
map: DotMap,
opts: ResolvedRendererOptions
): DisplayMap {
const sourceCols = Math.max(1, Math.round(map.cols));
const sourceRows = Math.max(1, Math.round(map.rows));
const source = { cols: sourceCols, dots: map.dots, rows: sourceRows };
if (map.dots.length === 0) {
return source;
}
const width = Math.max(1, opts.size);
const height = opts.fit === "natural" ? width / safeAspect(map) : width;
const padding = opticalPadding(opts.padding, Math.min(width, height));
const cell = Math.min(
(width * (1 - padding * 2)) / sourceCols,
(height * (1 - padding * 2)) / sourceRows
);
const minCell = MIN_AUTO_CELL_DEVICE_PX / devicePixelRatioCapped();
if (!Number.isFinite(cell) || cell >= minCell) {
return source;
}
const scale = cell / minCell;
const cols = Math.max(1, Math.floor(sourceCols * scale));
const rows = Math.max(1, Math.floor(sourceRows * scale));
if (cols === sourceCols && rows === sourceRows) {
return source;
}
const bins = Array.from({ length: cols * rows }, () => ({
depth: 0,
ink: 0,
tone: 0,
}));
// Split each source cell across the target cells it overlaps, by area. Giving
// it whole to one bin doubles the ink in every nth bin, which is worst at the
// near-1 ratios small marks land on — the lattice grew a beat of its own.
for (const dot of map.dots) {
const coverage = clamp01(dot.v);
if (coverage <= 0) {
continue;
}
const left = (clamp(dot.col, 0, sourceCols) * cols) / sourceCols;
const right = (clamp(dot.col + 1, 0, sourceCols) * cols) / sourceCols;
const top = (clamp(dot.row, 0, sourceRows) * rows) / sourceRows;
const bottom = (clamp(dot.row + 1, 0, sourceRows) * rows) / sourceRows;
for (
let row = Math.floor(top);
row < Math.min(rows, Math.ceil(bottom));
row++
) {
const overlapY = Math.min(bottom, row + 1) - Math.max(top, row);
if (overlapY <= 0) {
continue;
}
for (
let col = Math.floor(left);
col < Math.min(cols, Math.ceil(right));
col++
) {
const overlapX = Math.min(right, col + 1) - Math.max(left, col);
if (overlapX <= 0) {
continue;
}
const bin = bins[row * cols + col];
if (!bin) {
continue;
}
// Weights over a full target cell sum to 1, so ink is already its mean.
const ink = coverage * overlapX * overlapY;
bin.ink += ink;
bin.depth += clamp01(dot.d) * ink;
bin.tone += clamp01(dot.t) * ink;
}
}
}
const dots: DisplayDot[] = [];
for (let row = 0; row < rows; row++) {
for (let col = 0; col < cols; col++) {
const bin = bins[row * cols + col];
if (!bin || bin.ink <= 0.004) {
continue;
}
dots.push({
d: bin.depth / bin.ink,
t: bin.tone / bin.ink,
v: clamp01(bin.ink),
x: (col + 0.5) / cols,
y: (row + 0.5) / rows,
});
}
}
return { cols, dots, rows };
}
/**
* A halftone represents coverage through area: radius therefore follows the
* square root of tone. Opacity remains available to the animation instead of
* applying coverage twice (radius, then alpha), which used to erase fine ink.
*
* Tone is what the cell covers times how dark it is. The floors this used to
* multiply in compounded: empty cells painted 0.169 and full ones 0.319, so
* every logo arrived the same flat grey. Depth stays out — it is the animation
* channel, and folding it in only pulls area off the artwork's own tones.
*/
function toneScale(
coverage: number,
sourceTone: number,
weight: number
): number {
const w = clamp01(weight);
// Below 1, weight interpolates toward a uniform field rather than shifting a floor.
const weighted = 1 - w + w * clamp01(coverage);
return Math.sqrt(clamp01(weighted * clamp01(sourceTone)));
}
/** Small marks need optical weight and restrained motion to remain identifiable. */
function opticalFactor(size: number): number {
return clamp01((32 - size) / 16);
}
function opticalPadding(padding: number, size: number): number {
return clamp(padding, 0, 0.49) * (1 - opticalFactor(size) * 0.65);
}
/** Keep circle, square and diamond at equal painted area for the same tone. */
function shapeScale(shape: DotShape): number {
if (shape === "square") {
return Math.sqrt(Math.PI / 4);
}
if (shape === "diamond") {
return Math.sqrt(Math.PI / 2);
}
return 1;
}
function safeAspect(map: DotMap | null): number {
if (map && Number.isFinite(map.aspect) && map.aspect > 0) {
return map.aspect;
}
return map ? map.cols / Math.max(1, map.rows) : 1;
}
function drawDot(
ctx: CanvasRenderingContext2D,
shape: DotShape,
x: number,
y: number,
r: number
): void {
if (shape === "circle") {
ctx.beginPath();
ctx.arc(x, y, r, 0, Math.PI * 2);
ctx.fill();
return;
}
if (shape === "square") {
ctx.fillRect(x - r, y - r, r * 2, r * 2);
return;
}
ctx.beginPath();
ctx.moveTo(x, y - r);
ctx.lineTo(x + r, y);
ctx.lineTo(x, y + r);
ctx.lineTo(x - r, y);
ctx.closePath();
ctx.fill();
}
/** Drop explicit `undefined` so spreading options never clobbers a default. */
function strip(options: RendererOptions): RendererOptions {
const out: Record<string, unknown> = {};
for (const [key, value] of Object.entries(options)) {
if (value !== undefined) {
out[key] = value;
}
}
return out as RendererOptions;
}
registry/ui/benday/types.ts
/** A single dot sampled out of the source image. */
export interface Dot {
/** Grid column. */
col: number;
/** Grid row. */
row: number;
/** Cell-center x, normalized 0..1 across the trimmed content box. */
x: number;
/** Cell-center y, normalized 0..1 across the trimmed content box. */
y: number;
/** Ink coverage of the cell, 0..1 (post-gamma). Drives the dot's painted area. */
v: number;
/** Source tone, 0 for the weakest layer and 1 for the strongest layer. */
t: number;
/** Depth inside the shape, 0 at the outline → 1 at the thickest point (from the EDT). */
d: number;
}
/**
* A baked logo: dots plus the geometry to lay them out. All the renderer needs,
* small and JSON-serializable, so it can be produced at build time.
*/
export interface DotMap {
/** Grid columns across the trimmed content box. */
cols: number;
/** Grid rows across the trimmed content box. */
rows: number;
/** width / height of the trimmed content box. */
aspect: number;
/** The dots that cleared the coverage threshold. */
dots: Dot[];
/** How many grid cells were tested (cols * rows) — useful for tuning feedback. */
cells: number;
/** Which masking strategy actually ran. */
maskMode: "alpha" | "luma";
}
export type MaskMode = "auto" | "alpha" | "luma";
export interface BakeOptions {
/** Dots across the longest side of the trimmed logo. @default 24 */
grid?: number;
/** Cell coverage required to emit a dot, 0..1. @default 0.18 */
threshold?: number;
/** Gamma applied to coverage before thresholding. <1 boosts faint ink. @default 1 */
gamma?: number;
/** How to separate ink from background; `auto` picks alpha over luma. @default 'auto' */
maskMode?: MaskMode;
/** Treat the luminance mask as inverted (light ink on dark). Only used in luma mode. */
invert?: boolean;
/** Grow the mask by N working pixels — the hairline rescue knob. @default 0 */
dilate?: number;
/** Trim to the mask's bounding box before gridding. @default true */
trim?: boolean;
/** Resolution the mask is computed at, longest side. @default auto from grid */
workingSize?: number;
}
export type ResolvedBakeOptions = Required<Omit<BakeOptions, "workingSize">> & {
workingSize: number;
};
/** Per-dot input handed to an animation preset each frame. */
export interface DotContext {
/** Index in the dot list. */
i: number;
/** Total dot count. */
n: number;
/** Normalized position, 0..1. */
x: number;
y: number;
/** Normalized position relative to the mark's center, roughly -1..1. */
nx: number;
ny: number;
/** Radial distance from center, normalized so the corner is ~1. */
r: number;
/** Angle from center, -PI..PI. */
angle: number;
/** Ink coverage, 0..1. */
v: number;
/** Source tone, 0..1. */
t: number;
/** Depth inside the shape, 0 at the outline → 1 at the core. */
d: number;
/** Stable pseudo-random value for this dot, 0..1. */
rand: number;
/** Grid dimensions, so offsets can be reasoned about in cell units. */
cols: number;
rows: number;
}
/** What a preset produces for one dot on one frame. */
export interface DotFrame {
/** Radius multiplier. */
s: number;
/** Alpha, 0..1. */
a: number;
/** Offset in cell units (1 = one grid cell). */
dx: number;
dy: number;
}
export type Preset = (c: DotContext, t: number, out: DotFrame) => void;
export type PresetName =
| "shimmer"
| "ripple"
| "contour"
| "scatter"
| "flicker"
| "breathe"
| "swirl"
| "scan"
| "cascade"
| "orbit"
| "comet"
| "radar"
| "pinwheel"
| "wave"
| "equalizer"
| "weave"
| "beacon"
| "glitch"
| "rain"
| "magnetic"
| "resolve";
export type BendayState = "idle" | "thinking" | "done";
export type DotShape = "circle" | "square" | "diamond";
export type Fit = "square" | "natural";
/** Everything the renderer needs to paint a frame. */
export interface RendererOptions {
/** The baked map to draw. `null` paints nothing. */
dotMap?: DotMap | null;
/** Preset name, or your own per-dot function. @default 'contour' */
preset?: PresetName | Preset;
/** @default 'thinking' */
state?: BendayState;
/** Rendered size in CSS pixels. @default 64 */
size?: number;
/** `natural` sizes the canvas to the mark's aspect, which wordmarks need. @default 'square' */
fit?: Fit;
/** Animation speed multiplier. @default 1 */
speed?: number;
/** Dot color. `currentColor` resolves against the canvas. @default 'currentColor' */
color?: string;
/** Dot size against a full-coverage halftone dot; 1 lets solid ink paint solid. @default 0.62 */
dotScale?: number;
/** @default 'circle' */
shape?: DotShape;
/** Glow radius as a fraction of the dot size, 0 disables. @default 0 */
glow?: number;
/** Inset around the mark as a fraction of the box. @default 0.06 */
padding?: number;
/** How strongly ink coverage drives each dot's painted area, 0..1. @default 0.5 */
weight?: number;
/** Freeze on the current frame. @default false */
paused?: boolean;
/** `auto` follows prefers-reduced-motion live; a boolean pins it. @default 'auto' */
reducedMotion?: boolean | "auto";
}
export type ResolvedRendererOptions = Required<
Omit<RendererOptions, "dotMap">
> & {
dotMap: DotMap | null;
};
/**
* A live canvas painter. Update it rather than recreating it, so the clock and
* the settle spring survive prop changes.
*/
export interface Renderer {
/** Merge new options in and repaint. Unspecified keys keep their value. */
update: (next: RendererOptions) => void;
/** The currently applied options. */
readonly options: Readonly<ResolvedRendererOptions>;
/** Canvas CSS height for the current options. */
readonly height: number;
/** Tear down observers and cancel any pending frame. */
destroy: () => void;
}
registry/ui/benday/use-dot-map.ts
"use client";
import { useEffect, useRef, useState } from "react";
import type { BakeSource } from "./bake";
import { bake, bakeCached } from "./bake";
import type { BakeOptions, DotMap } from "./types";
export interface UseDotMapResult {
dotMap: DotMap | null;
loading: boolean;
error: Error | null;
/** Milliseconds the last bake took — handy for tuning. */
elapsed: number | null;
}
const IDLE: UseDotMapResult = {
dotMap: null,
elapsed: null,
error: null,
loading: false,
};
const LOADING: UseDotMapResult = { ...IDLE, loading: true };
/**
* Bake a source into a {@link DotMap}, re-running on change. Strings hit the
* module cache; Blobs and Files bake fresh, having no stable identity.
*/
export function useDotMap(
source: BakeSource | null | undefined,
options: BakeOptions = {}
): UseDotMapResult {
const optionsKey = JSON.stringify(options);
// A string plus its options determines the result, so the pair keys the
// effect. Anything else falls back to reference identity.
const signature =
typeof source === "string" ? `${source}|${optionsKey}` : source;
const optionsRef = useRef<BakeOptions>(options);
useEffect(() => {
optionsRef.current = options;
});
const [state, setState] = useState<UseDotMapResult>(source ? LOADING : IDLE);
// Reset to a loading frame *during* render when the input changes, rather
// than in an effect — an effect would paint one frame of stale results first.
const [lastSignature, setLastSignature] = useState(signature);
if (signature !== lastSignature) {
setLastSignature(signature);
setState(source ? LOADING : IDLE);
}
useEffect(() => {
if (!source) {
return;
}
let live = true;
const started = performance.now();
const run = async () => {
try {
const dotMap =
typeof source === "string"
? await bakeCached(source, optionsRef.current)
: await bake(source, optionsRef.current);
if (live) {
setState({
dotMap,
elapsed: performance.now() - started,
error: null,
loading: false,
});
}
} catch (error) {
if (live) {
setState({
dotMap: null,
elapsed: null,
error: error as Error,
loading: false,
});
}
}
};
run();
return () => {
live = false;
};
}, [signature, source]);
return state;
}