Animation
GlyphField
Word built as a bevelled 3D solid of glyphs that tilts toward the cursor.
npx bezel-add add glyph-fieldpackages/ui/src/animation/GlyphField.tsx · 810 lines
"use client";
import { useEffect, useRef, useSyncExternalStore, type CSSProperties } from "react";
/*
* GlyphField: a word built as a solid object out of glyphs, that turns toward
* the cursor and parts around it.
*
* The text is set on an offscreen canvas and its mask becomes a height map: an
* exact distance transform gives every point inside a letter its distance from
* the edge, and that distance is bent into a rounded bevel. The letters are then
* a slab with a front face, bevelled edges and side walls running back into the
* page.
*
* Each frame the slab is turned in 3D and a ray is cast from a perspective
* camera through the centre of every cell of a fixed grid, the way a terminal
* draws a solid. A ray that meets the front face is shaded from the bevel's
* normal under a fixed light from the upper left, with a small highlight; a ray
* that misses the face is marched back through the depth of the slab, and one
* that meets a side wall is shaded from the wall's normal and darkened toward
* the back. The shade picks one glyph from a pre-drawn ramp that grows in size
* and ink, so lit bevels thin out and walls in shadow print heavy.
*
* Under the pointer a raised-cosine torch lifts cells a few steps and into the
* accent, and pushes them aside; its dithered edge comes from a fixed random
* threshold per cell. The word tilts to face the pointer. With nobody pointing
* it sways slowly and the torch wanders; that loop stops off screen and in
* hidden tabs. The shaded object is cached, so a frame without a change of
* angle is one blit plus the cells within the torch's reach. Reduced motion
* holds one fixed three-quarter angle with no tilt or sway, and the torch still
* recolours under a real pointer but does not push.
*/
const CSS = `
.bz-gf{--bz-gf-ground:var(--bz-paper,#ffffff);--bz-gf-t1:#cfcfd3;--bz-gf-t2:var(--bz-ink-subtle,#6b6b70);--bz-gf-t3:var(--bz-ink,#0a0a0a);--bz-gf-a1:#e3a79d;--bz-gf-a2:#c0513f;--bz-gf-a3:var(--bz-accent,#912c22);position:relative;display:block;width:100%;min-height:200px;overflow:hidden;background:var(--bz-gf-ground);font-family:var(--bz-font-sans,ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,sans-serif);touch-action:pan-y pinch-zoom;-webkit-user-select:none;user-select:none}
.bz-gf-canvas{position:absolute;inset:0;display:block;width:100%;height:100%}
`;
const RM_QUERY = "(prefers-reduced-motion: reduce)";
const subscribeReducedMotion = (onChange: () => void) => {
const query = window.matchMedia(RM_QUERY);
query.addEventListener("change", onChange);
return () => query.removeEventListener("change", onChange);
};
const readReducedMotion = () => window.matchMedia(RM_QUERY).matches;
const serverReducedMotion = () => false;
const IDLE_MS = 2500;
/** Glyph steps in each ramp, from barely there to full ink. */
const LEVELS = 14;
/** Mask samples per cell along each axis. */
const SUPERSAMPLE = 2;
const DEG = Math.PI / 180;
/** The angle the word rests at, and holds under reduced motion: turned left and up, so its right and lower walls show. */
const REST_YAW = -18 * DEG;
const REST_PITCH = 14 * DEG;
/**
* Light from the upper right and in front, and the half vector for the highlight. Against the resting angle
* this gives three planes three tones, the way a lit cube reads: a mid face, a lit right wall, a dark lower one.
*/
const LIGHT = normalize3(0.55, -0.75, 0.5);
const HALF = normalize3(LIGHT[0], LIGHT[1], LIGHT[2] + 1);
function normalize3(x: number, y: number, z: number): [number, number, number] {
const l = Math.hypot(x, y, z) || 1;
return [x / l, y / l, z / l];
}
type Options = {
text: string;
glyph: string;
cell: number;
radius: number;
wander: boolean;
reduced: boolean;
depth: number;
tilt: number;
};
type Field = { refresh: () => void; wake: () => void; destroy: () => void };
/** Squared distance transform of one row or column (Felzenszwalb and Huttenlocher). */
function transform1d(f: Float64Array, n: number, out: Float64Array, v: Int32Array, z: Float64Array) {
let k = 0;
v[0] = 0;
z[0] = -1e20;
z[1] = 1e20;
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] = 1e20;
}
k = 0;
for (let q = 0; q < n; q++) {
while (z[k + 1] < q) k++;
out[q] = (q - v[k]) * (q - v[k]) + f[v[k]];
}
}
/** Euclidean distance from every sample to the nearest sample where `seed` is true. */
function distanceTo(seed: Uint8Array, w: number, h: number) {
const n = Math.max(w, h);
const f = new Float64Array(n);
const d = new Float64Array(n);
const v = new Int32Array(n);
const z = new Float64Array(n + 1);
const grid = new Float64Array(w * h);
for (let i = 0; i < grid.length; i++) grid[i] = seed[i] ? 0 : 1e10;
for (let x = 0; x < w; x++) {
for (let y = 0; y < h; y++) f[y] = grid[y * w + x];
transform1d(f, h, d, v, z);
for (let y = 0; y < h; y++) grid[y * w + x] = d[y];
}
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) f[x] = grid[y * w + x];
transform1d(f, w, d, v, z);
for (let x = 0; x < w; x++) grid[y * w + x] = d[x];
}
const out = new Float32Array(w * h);
for (let i = 0; i < out.length; i++) out[i] = Math.sqrt(grid[i]);
return out;
}
function createField(root: HTMLElement, canvas: HTMLCanvasElement, options: () => Options): Field | null {
const ctx = canvas.getContext("2d");
const source = document.createElement("canvas");
const sctx = source.getContext("2d");
const sampler = document.createElement("canvas");
const pctx = sampler.getContext("2d", { willReadFrequently: true });
const sheet = document.createElement("canvas");
const hctx = sheet.getContext("2d");
const cache = document.createElement("canvas");
const cctx = cache.getContext("2d");
if (!ctx || !sctx || !pctx || !hctx || !cctx) return null;
let dead = false;
let raf = 0;
let last = 0;
let inView = true;
let W = 0;
let H = 0;
let dpr = 1;
let cols = 0;
let rows = 0;
let cell = 8;
let sprite = 0;
let jitter = new Float32Array(0);
/** Glyph step per cell for the current angle, or -1 for bare paper. */
let levels = new Int8Array(0);
/** Cells whose step moved since the cache was last painted, and how many. */
let changed = new Int32Array(0);
let changes = 0;
// The height map, one entry per mask sample.
let mw = 0;
let mh = 0;
let solid = new Uint8Array(0);
let faceX = new Float32Array(0);
let faceY = new Float32Array(0);
let faceZ = new Float32Array(0);
let wallX = new Float32Array(0);
let wallY = new Float32Array(0);
let box = { x0: 0, y0: 0, x1: 0, y1: 0 };
let slab = 20;
let shapeDirty = true;
let cacheDirty = true;
let dirty = true;
const pointer = { x: -1e4, y: -1e4, tx: -1e4, ty: -1e4, real: false, lastReal: -1e9 };
const turn = { yaw: REST_YAW, pitch: REST_PITCH };
const running = () => inView && document.visibilityState === "visible";
function buildSheet() {
const style = getComputedStyle(root);
const read = (name: string, fallback: string) => style.getPropertyValue(name).trim() || fallback;
const rgb = (color: string) => {
hctx!.fillStyle = "#000000";
hctx!.fillStyle = color;
const s = String(hctx!.fillStyle);
if (s.startsWith("#")) return [1, 3, 5].map((i) => parseInt(s.slice(i, i + 2), 16));
const m = s.match(/[\d.]+/g);
return m ? m.slice(0, 3).map(Number) : [0, 0, 0];
};
const ground = rgb(read("--bz-gf-ground", "#ffffff"));
const t1 = rgb(read("--bz-gf-t1", "#cfcfd3"));
const t2 = rgb(read("--bz-gf-t2", "#6b6b70"));
const t3 = rgb(read("--bz-gf-t3", "#0a0a0a"));
const a1 = rgb(read("--bz-gf-a1", "#e3a79d"));
const a2 = rgb(read("--bz-gf-a2", "#c0513f"));
const a3 = rgb(read("--bz-gf-a3", "#912c22"));
const mix = (a: number[], b: number[], k: number) => a.map((v, i) => v + (b[i] - v) * k);
const ramps = [
[mix(ground, t1, 0.55), t1, t2, t3],
[mix(ground, a1, 0.6), a1, a2, a3, mix(a3, t3, 0.45)],
];
const at = (stops: number[][], q: number) => {
const p = q * (stops.length - 1);
const i = Math.min(stops.length - 2, Math.floor(p));
const c = mix(stops[i], stops[i + 1], p - i);
return `rgb(${c.map((v) => Math.round(v)).join(",")})`;
};
sheet.width = sprite * LEVELS * ramps.length;
sheet.height = sprite;
hctx!.clearRect(0, 0, sheet.width, sheet.height);
hctx!.textAlign = "center";
hctx!.textBaseline = "middle";
hctx!.lineJoin = "round";
const glyph = options().glyph;
ramps.forEach((stops, r) => {
for (let l = 0; l < LEVELS; l++) {
const q = l / (LEVELS - 1);
const ink = at(stops, q);
const px = Math.max(2, Math.round(sprite * (0.62 + 0.8 * q)));
const left = (r * LEVELS + l) * sprite;
// Each glyph stays inside its own square, so a cell can be redrawn on its own.
hctx!.save();
hctx!.beginPath();
hctx!.rect(left, 0, sprite, sprite);
hctx!.clip();
hctx!.font = `700 ${px}px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace`;
hctx!.fillStyle = ink;
const x = left + sprite / 2;
const y = sprite / 2 + sprite * 0.04;
hctx!.fillText(glyph, x, y);
if (q > 0.55) {
hctx!.strokeStyle = ink;
hctx!.lineWidth = (q - 0.55) * sprite * 0.22;
hctx!.strokeText(glyph, x, y);
}
hctx!.restore();
}
});
}
function sample() {
const { text, depth } = options();
mw = Math.max(1, cols * SUPERSAMPLE);
mh = Math.max(1, rows * SUPERSAMPLE);
source.width = mw * 2;
source.height = mh * 2;
sctx!.fillStyle = "#ffffff";
sctx!.fillRect(0, 0, source.width, source.height);
const family = getComputedStyle(root).fontFamily || "system-ui, sans-serif";
sctx!.font = `800 100px ${family}`;
const width = Math.max(1, sctx!.measureText(text).width);
// Room to turn: the word is set smaller than the flat version would be.
const fontPx = Math.min(((source.width * 0.74) / width) * 100, source.height * 0.56);
sctx!.font = `800 ${fontPx}px ${family}`;
sctx!.textAlign = "center";
sctx!.textBaseline = "middle";
sctx!.fillStyle = "#000000";
sctx!.fillText(text, source.width / 2, source.height / 2 + fontPx * 0.04);
sampler.width = mw;
sampler.height = mh;
pctx!.imageSmoothingEnabled = true;
pctx!.imageSmoothingQuality = "high";
pctx!.drawImage(source, 0, 0, mw, mh);
const data = pctx!.getImageData(0, 0, mw, mh).data;
const count = mw * mh;
solid = new Uint8Array(count);
const empty = new Uint8Array(count);
let x0 = mw;
let y0 = mh;
let x1 = -1;
let y1 = -1;
for (let i = 0, p = 0; i < count; i++, p += 4) {
const luma = (0.2126 * data[p] + 0.7152 * data[p + 1] + 0.0722 * data[p + 2]) / 255;
if (luma < 0.5) {
solid[i] = 1;
const x = i % mw;
const y = (i - x) / mw;
if (x < x0) x0 = x;
if (x > x1) x1 = x;
if (y < y0) y0 = y;
if (y > y1) y1 = y;
} else {
empty[i] = 1;
}
}
const inside = distanceTo(empty, mw, mh);
const outside = distanceTo(solid, mw, mh);
let deepest = 1;
for (let i = 0; i < count; i++) if (inside[i] > deepest) deepest = inside[i];
// A narrow bevel leaves a flat top to every stroke, so light and shadow sit on the edges.
const cssFont = fontPx * (W / source.width);
const spacing = W / mw;
const bevel = Math.max(1.5, Math.min(deepest * 0.6, (cssFont * 0.055) / spacing));
faceX = new Float32Array(count);
faceY = new Float32Array(count);
faceZ = new Float32Array(count);
wallX = new Float32Array(count);
wallY = new Float32Array(count);
const sdf = (x: number, y: number) => {
const cx = Math.max(0, Math.min(mw - 1, x));
const cy = Math.max(0, Math.min(mh - 1, y));
const i = cy * mw + cx;
return inside[i] - outside[i];
};
for (let y = 0; y < mh; y++) {
for (let x = 0; x < mw; x++) {
const i = y * mw + x;
faceZ[i] = 1;
if (!solid[i]) continue;
// The signed distance rises inward; its gradient points into the letter.
let gx = sdf(x + 1, y) - sdf(x - 1, y);
let gy = sdf(x, y + 1) - sdf(x, y - 1);
const g = Math.hypot(gx, gy);
if (g < 1e-3) continue;
gx /= g;
gy /= g;
wallX[i] = -gx;
wallY[i] = -gy;
// A quarter-round bevel: steep at the edge, flat once `bevel` in.
const t = Math.min(1, Math.max(0, (inside[i] - 0.5) / bevel));
const slope = t >= 1 ? 0 : Math.min(2.4, (1 - t) / Math.sqrt(Math.max(1e-3, 1 - (1 - t) * (1 - t))));
const n = normalize3(-gx * slope, -gy * slope, 1);
faceX[i] = n[0];
faceY[i] = n[1];
faceZ[i] = n[2];
}
}
const sx = W / mw;
const sy = H / mh;
box = x1 < 0 ? { x0: 0, y0: 0, x1: -1, y1: -1 } : { x0: x0 * sx - W / 2, y0: y0 * sy - H / 2, x1: (x1 + 1) * sx - W / 2, y1: (y1 + 1) * sy - H / 2 };
slab = Math.max(cell, cssFont * Math.max(0, depth));
shapeDirty = true;
cacheDirty = true;
dirty = true;
}
function layout() {
const rect = root.getBoundingClientRect();
W = Math.max(1, Math.round(rect.width));
H = Math.max(1, Math.round(rect.height));
dpr = Math.min(window.devicePixelRatio || 1, 2);
// Small fields use finer cells, so a letter is still wide enough in cells to show its bevel.
cell = Math.max(4, Math.min(Math.round(options().cell), Math.round(Math.min(W, H * 2.2) / 70)));
const cw = Math.round(W * dpr);
const ch = Math.round(H * dpr);
if (canvas.width !== cw || canvas.height !== ch) {
canvas.width = cw;
canvas.height = ch;
}
if (cache.width !== cw || cache.height !== ch) {
cache.width = cw;
cache.height = ch;
}
const nextCols = Math.ceil(W / cell);
const nextRows = Math.ceil(H / cell);
if (nextCols !== cols || nextRows !== rows) {
cols = nextCols;
rows = nextRows;
levels = new Int8Array(cols * rows).fill(-1);
changed = new Int32Array(cols * rows);
jitter = new Float32Array(cols * rows);
for (let i = 0; i < jitter.length; i++) jitter[i] = Math.random();
}
sprite = Math.max(2, Math.round(cell * dpr));
buildSheet();
sample();
}
/** Cast one ray per cell through the slab at the current angle, and keep the glyph step each one lands on. */
function shade() {
const ca = Math.cos(turn.yaw);
const sa = Math.sin(turn.yaw);
const cb = Math.cos(turn.pitch);
const sb = Math.sin(turn.pitch);
// R = yaw about the vertical axis, after pitch about the horizontal one.
const r00 = ca;
const r01 = sa * sb;
const r02 = sa * cb;
const r11 = cb;
const r12 = -sb;
const r20 = -sa;
const r21 = ca * sb;
const r22 = ca * cb;
const focal = Math.max(W, H) * 1.35;
// The camera, in the slab's own frame.
const ox = r20 * focal;
const oy = r21 * focal;
const oz = r22 * focal;
const half = slab / 2;
const steps = Math.max(3, Math.min(10, Math.ceil(slab / (cell * 0.75))));
const sx = mw / W;
const sy = mh / H;
const hw = W / 2;
const hh = H / 2;
const top = LEVELS - 1;
const [lx, ly, lz] = LIGHT;
const [hx, hy, hz] = HALF;
const bx0 = box.x0 - cell;
const bx1 = box.x1 + cell;
const by0 = box.y0 - cell;
const by1 = box.y1 + cell;
for (let r = 0; r < rows; r++) {
const Y = (r + 0.5) * cell - hh;
for (let c = 0; c < cols; c++) {
const i = r * cols + c;
let next = -1;
const X = (c + 0.5) * cell - hw;
const dx = r00 * X - r20 * focal;
const dy = r01 * X + r11 * Y - r21 * focal;
const dz = r02 * X + r12 * Y - r22 * focal;
const tf = (half - oz) / dz;
const tb = (-half - oz) / dz;
const fu = ox + tf * dx;
const fv = oy + tf * dy;
const bu = ox + tb * dx;
const bv = oy + tb * dy;
if (dz < -1e-6 && Math.max(fu, bu) >= bx0 && Math.min(fu, bu) <= bx1 && Math.max(fv, bv) >= by0 && Math.min(fv, bv) <= by1) {
let mx = Math.floor((fu + hw) * sx);
let my = Math.floor((fv + hh) * sy);
if (mx >= 0 && my >= 0 && mx < mw && my < mh && solid[my * mw + mx]) {
const j = my * mw + mx;
const nx = faceX[j];
const ny = faceY[j];
const nz = faceZ[j];
const wx = r00 * nx + r01 * ny + r02 * nz;
const wy = r11 * ny + r12 * nz;
const wz = r20 * nx + r21 * ny + r22 * nz;
const diffuse = Math.max(0, wx * lx + wy * ly + wz * lz);
const spec = Math.max(0, wx * hx + wy * hy + wz * hz);
const s2 = spec * spec;
const s8 = s2 * s2 * s2 * s2;
const light = 0.08 + 0.9 * diffuse + 0.6 * s8 * s8;
// The face keeps to a band lighter than any wall, so letters stay apart from their own sides at every angle.
const ink = 0.34 + 0.4 * Math.max(0, 1 - light);
next = Math.round(ink * top);
} else {
for (let k = 1; k <= steps; k++) {
const f = k / steps;
mx = Math.floor((fu + (bu - fu) * f + hw) * sx);
my = Math.floor((fv + (bv - fv) * f + hh) * sy);
if (mx < 0 || my < 0 || mx >= mw || my >= mh || !solid[my * mw + mx]) continue;
const j = my * mw + mx;
const nx = wallX[j];
const ny = wallY[j];
const wx = r00 * nx + r01 * ny;
const wy = r11 * ny;
const wz = r20 * nx + r21 * ny;
const diffuse = Math.max(0, wx * lx + wy * ly + wz * lz);
// Walls darken toward the back, so depth reads even where the light is flat.
const light = (0.06 + 0.9 * diffuse) * (1 - 0.55 * f);
const ink = 0.78 + 0.22 * Math.max(0, 1 - light);
next = Math.round(ink * top);
break;
}
}
}
if (next > top) next = top;
if (levels[i] !== next) {
levels[i] = next;
changed[changes++] = i;
}
}
}
shapeDirty = false;
}
/** Paint one cell of the cache on whole device pixels, so a cell can be repainted without seams. */
function paintCell(i: number) {
const c = i % cols;
const r = (i - c) / cols;
const x0 = Math.round(c * cell * dpr);
const y0 = Math.round(r * cell * dpr);
const w = Math.round((c + 1) * cell * dpr) - x0;
const h = Math.round((r + 1) * cell * dpr) - y0;
cctx!.clearRect(x0, y0, w, h);
const l = levels[i];
if (l >= 0) cctx!.drawImage(sheet, l * sprite, 0, sprite, sprite, x0, y0, w, h);
}
function drawCache() {
cctx!.setTransform(1, 0, 0, 1, 0, 0);
if (cacheDirty) {
cctx!.clearRect(0, 0, cache.width, cache.height);
for (let i = 0; i < levels.length; i++) if (levels[i] >= 0) paintCell(i);
} else {
for (let k = 0; k < changes; k++) paintCell(changed[k]);
}
changes = 0;
cacheDirty = false;
}
function draw() {
if (shapeDirty) shade();
if (cacheDirty || changes) drawCache();
ctx!.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx!.clearRect(0, 0, W, H);
ctx!.drawImage(cache, 0, 0, W, H);
const px = pointer.x;
const py = pointer.y;
if (px < -1e3) return;
const o = options();
const R = W <= 420 ? o.radius * 0.7 : o.radius;
const R2 = R * R;
const push = o.reduced ? 0 : cell * 1.6;
const reach = R + push + cell;
const c0 = Math.max(0, Math.floor((px - reach) / cell));
const c1 = Math.min(cols - 1, Math.ceil((px + reach) / cell));
const r0 = Math.max(0, Math.floor((py - reach) / cell));
const r1 = Math.min(rows - 1, Math.ceil((py + reach) / cell));
if (c1 < c0 || r1 < r0) return;
ctx!.clearRect(c0 * cell, r0 * cell, (c1 - c0 + 1) * cell, (r1 - r0 + 1) * cell);
const half = cell / 2;
const top = LEVELS - 1;
for (let r = r0; r <= r1; r++) {
const y = r * cell + half;
const dy = py - y;
for (let c = c0; c <= c1; c++) {
const i = r * cols + c;
let l = levels[i];
if (l < 0) continue;
const x = c * cell + half;
const dx = px - x;
const d2 = dx * dx + dy * dy;
let ox = 0;
let oy = 0;
let ramp = 0;
if (d2 < R2) {
const d = Math.sqrt(d2);
const w = 0.5 + 0.5 * Math.cos((Math.PI * d) / R);
if (w > jitter[i]) {
l = Math.min(top, l + 3);
ramp = LEVELS;
}
const k = d > 0.001 ? (w * push) / d : 0;
ox = -dx * k;
oy = -dy * k;
}
ctx!.drawImage(
sheet,
(ramp + l) * sprite,
0,
sprite,
sprite,
Math.round((x - half + ox) * dpr) / dpr,
Math.round((y - half + oy) * dpr) / dpr,
cell,
cell,
);
}
}
}
function schedule() {
if (!raf && !dead && running()) raf = requestAnimationFrame(frame);
}
function frame(now: number) {
raf = 0;
if (dead || !running()) return;
const dt = last ? Math.min(64, now - last) : 16;
last = now;
const o = options();
const idle = !pointer.real && now - pointer.lastReal > IDLE_MS;
const wandering = idle && o.wander && !o.reduced;
if (wandering) {
pointer.tx = W * (0.5 + 0.4 * Math.sin(now * 0.00037));
pointer.ty = H * (0.5 + 0.3 * Math.sin(now * 0.00053 + 1.3));
} else if (idle && pointer.tx > -1e3) {
pointer.tx = -1e4;
pointer.ty = -1e4;
}
if (pointer.x < -1e3 || pointer.tx < -1e3) {
if (pointer.x !== pointer.tx || pointer.y !== pointer.ty) dirty = true;
pointer.x = pointer.tx;
pointer.y = pointer.ty;
} else {
const k = 1 - Math.exp(-dt / 70);
pointer.x += (pointer.tx - pointer.x) * k;
pointer.y += (pointer.ty - pointer.y) * k;
}
const settled = Math.abs(pointer.tx - pointer.x) < 0.1 && Math.abs(pointer.ty - pointer.y) < 0.1;
if (settled) {
pointer.x = pointer.tx;
pointer.y = pointer.ty;
}
// Where the word wants to face.
const tilt = Math.max(0, o.tilt) * DEG;
let yaw = REST_YAW;
let pitch = REST_PITCH;
if (!o.reduced) {
if (pointer.real || (!idle && pointer.tx > -1e3)) {
const nx = Math.max(-1, Math.min(1, (pointer.tx - W / 2) / (W / 2)));
const ny = Math.max(-1, Math.min(1, (pointer.ty - H / 2) / (H / 2)));
yaw = REST_YAW * 0.6 + nx * tilt;
pitch = REST_PITCH * 0.6 - ny * tilt * 0.6;
} else if (wandering) {
yaw = REST_YAW * 0.75 + tilt * 0.6 * Math.sin(now * 0.00041);
pitch = REST_PITCH * 0.75 + tilt * 0.3 * Math.sin(now * 0.00029 + 0.8);
}
}
let turning = false;
if (o.reduced) {
if (turn.yaw !== REST_YAW || turn.pitch !== REST_PITCH) shapeDirty = true;
turn.yaw = REST_YAW;
turn.pitch = REST_PITCH;
} else {
const k = 1 - Math.exp(-dt / 240);
const ny = turn.yaw + (yaw - turn.yaw) * k;
const np = turn.pitch + (pitch - turn.pitch) * k;
turning = Math.abs(yaw - ny) > 0.0004 || Math.abs(pitch - np) > 0.0004;
if (Math.abs(ny - turn.yaw) > 0.00005 || Math.abs(np - turn.pitch) > 0.00005) shapeDirty = true;
turn.yaw = turning ? ny : yaw;
turn.pitch = turning ? np : pitch;
}
if (dirty || cacheDirty || shapeDirty || !settled) {
draw();
dirty = false;
}
// Keep going while moving, turning, wandering, or waiting for the idle wander to begin.
if (!settled || turning || wandering || (!pointer.real && !idle && o.wander && !o.reduced)) schedule();
}
function wake() {
dirty = true;
last = 0;
schedule();
}
const toLocal = (event: PointerEvent) => {
const rect = root.getBoundingClientRect();
pointer.tx = event.clientX - rect.left;
pointer.ty = event.clientY - rect.top;
pointer.real = true;
pointer.lastReal = performance.now();
wake();
};
const release = () => {
pointer.real = false;
pointer.lastReal = performance.now();
wake();
};
const noHover = window.matchMedia("(hover: none)");
const onUp = () => {
if (noHover.matches) release();
};
root.addEventListener("pointermove", toLocal, { passive: true });
root.addEventListener("pointerdown", toLocal, { passive: true });
root.addEventListener("pointerleave", release);
root.addEventListener("pointercancel", release);
root.addEventListener("pointerup", onUp);
const onVisibility = () => wake();
document.addEventListener("visibilitychange", onVisibility);
const ro = new ResizeObserver(() => {
layout();
draw();
schedule();
});
ro.observe(root);
let io: IntersectionObserver | null = null;
if ("IntersectionObserver" in window) {
io = new IntersectionObserver(
([entry]) => {
inView = entry.isIntersecting;
if (inView) wake();
},
{ rootMargin: "10%" },
);
io.observe(root);
}
let dprQuery = window.matchMedia(`(resolution: ${window.devicePixelRatio || 1}dppx)`);
const onDpr = () => {
dprQuery.removeEventListener("change", onDpr);
dprQuery = window.matchMedia(`(resolution: ${window.devicePixelRatio || 1}dppx)`);
dprQuery.addEventListener("change", onDpr);
layout();
wake();
};
dprQuery.addEventListener("change", onDpr);
layout();
draw();
schedule();
document.fonts?.ready
.then(() => {
if (dead) return;
sample();
wake();
})
.catch(() => {});
return {
refresh() {
layout();
wake();
},
wake() {
shapeDirty = true;
wake();
},
destroy() {
dead = true;
if (raf) cancelAnimationFrame(raf);
raf = 0;
ro.disconnect();
io?.disconnect();
dprQuery.removeEventListener("change", onDpr);
document.removeEventListener("visibilitychange", onVisibility);
root.removeEventListener("pointermove", toLocal);
root.removeEventListener("pointerdown", toLocal);
root.removeEventListener("pointerleave", release);
root.removeEventListener("pointercancel", release);
root.removeEventListener("pointerup", onUp);
},
};
}
export type GlyphFieldProps = {
/** The word or short phrase the field draws. */
text: string;
/** Accessible name. Defaults to `text`. */
label?: string;
/** Hide it from assistive tech entirely, when the text is repeated elsewhere. */
decorative?: boolean;
/** The character every cell is drawn with. */
glyph?: string;
/** Largest cell size in CSS pixels. Small fields use finer cells so the bevels still resolve. */
cellSize?: number;
/** Radius of the pointer's reach in CSS pixels. */
radius?: number;
/** How far the letters run back into the page, as a fraction of the type size. */
depth?: number;
/** How far the word turns toward the pointer, in degrees. */
tilt?: number;
/** Sway, and let the torch drift, while nobody is pointing. */
wander?: boolean;
className?: string;
style?: CSSProperties;
};
export function GlyphField({
text,
label,
decorative = false,
glyph = "+",
cellSize = 8,
radius = 110,
depth = 0.22,
tilt = 18,
wander = true,
className = "",
style,
}: GlyphFieldProps) {
const reduced = useSyncExternalStore(subscribeReducedMotion, readReducedMotion, serverReducedMotion);
const rootRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const fieldRef = useRef<Field | null>(null);
const optionsRef = useRef<Options>({ text, glyph, cell: cellSize, radius, wander, reduced, depth, tilt });
optionsRef.current = { text, glyph, cell: cellSize, radius, wander, reduced, depth, tilt };
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
if (!root || !canvas) return;
const field = createField(root, canvas, () => optionsRef.current);
fieldRef.current = field;
return () => {
field?.destroy();
fieldRef.current = null;
};
}, []);
useEffect(() => {
fieldRef.current?.refresh();
}, [text, glyph, cellSize, depth]);
useEffect(() => {
fieldRef.current?.wake();
}, [reduced, wander, radius, tilt]);
return (
<>
<style dangerouslySetInnerHTML={{ __html: CSS }} />
<div
ref={rootRef}
className={`bz-gf ${className}`.trim()}
style={style}
role={decorative ? undefined : "img"}
aria-label={decorative ? undefined : label ?? text}
aria-hidden={decorative ? true : undefined}
>
<canvas ref={canvasRef} className="bz-gf-canvas" aria-hidden="true" />
</div>
</>
);
}