Catalog / Recipe
Word contour
Evenly spaced contour rings around the page's own heading, drawn from an exact distance field of the word and revealed outward from it; the heading is the summit of a map in the page's ink, and no line touches a glyph.
- Family
- topology
- Status
- passed
- Used for
- HeroPage loadSection intro
- Size
- 6,522 of 6,600 bytes, gzipped
mount(element)The topology look of contour-field applied to the page’s own type.
contour-field draws rings from a noise height that has nothing to do
with the page; this draws them from a distance field of the heading, so
the map is of the word. The heading is rasterised where the page draws
it, every pixel’s distance from the nearest inked edge is computed
exactly on the main thread, and one fragment pass turns that distance
into evenly spaced lines that never touch a glyph.
Use
import { mount } from './word-contour';
mount(document.querySelector('.hero'));
The mark is the first [data-mask] inside the element, else its first
h1 or h2, else its first img. A text mark is one line of display
type, 32 px or more; it stays as it is, real text under a transparent
canvas. A picture mark’s alpha is the shape: a transparent logo gives
rings around the logo, an opaque photo rings around its box.
The field
The word is drawn as the page renders it (whitespace collapsed, the
transform applied in the page’s language, one line measured through a
Range, the half-leading baseline, letter spacing and stretch) after the
fonts settle or 1.5 s, on a canvas the shape of the element’s padding
box (where the canvas sits) at detail times CSS resolution, no wider
or taller than 1,200 px. The alpha is kept as coverage. The transform
is Felzenszwalb and Huttenlocher’s lower envelope of parabolas, outside
the ink only, in three tasks: the read-back, then the columns, then the
rows, with a yield between each. Every pixel
with any ink is a feature, the transform records which feature is
nearest, and each distance is corrected by that pixel’s coverage, so an
antialiased edge is where it is and not where its pixel centre is. The
field is then blurred by smooth pixels, since an exact field is a
union of cones from every edge pixel and its rings are polylines with a
crease wherever the nearest pixel changes; the blur turns them into
curves. It goes up as one R16F texture, linear, clamped.
The shade
One pass over the full-screen triangle. The distance in CSS pixels,
less the gap and divided by the spacing, is the level; the line is
contour-field’s, a one-pixel smoothstep around the nearest level,
never thinner than a device pixel, using the field’s constant slope of
1 for its width. Every fifth ring is heavier, counted from the first
ring after the gap. Nothing is drawn on or within half the gap of a
glyph; the rings fade by the reach and before the box’s edge. Terrain
adds two octaves of seeded value noise that are zero at the gap and
full by 60% of the reach, so the near rings hug the letters and the far
ones wander like coastline.
Options
| Option | Default | What it does |
|---|---|---|
spacing |
14 | CSS px between rings for a 128 px heading, 6 to 40; scaled with the heading down to 0.6 |
width |
1.25 | Line width, CSS px, 0.75 to 2 |
gap |
0.1 | Clear of the glyphs, in em, 0.02 to 0.4, never under 4 px: the canvas word sits within about a pixel of the live one |
reach |
220 | How far the rings go, CSS px for a 128 px heading, 40 to 600; scaled with the heading |
index |
2 | A heavier ring: 0 off, 1 every fourth, 2 every fifth, counted from the first ring after the gap |
terrain |
0 | Noise height far from the word, CSS px, up to 80 |
feature |
180 | Noise feature size, CSS px, 90 to 400 |
motion |
0 | 0 Tide, 1 Ripple, 2 Outline |
strength |
1 | Ink strength, 0.15 to 1 |
tint |
0 | 0 ink, 1 accent |
detail |
1 | The field’s resolution as a share of CSS pixels, 0.25 to 1; below 1 the rings wobble on a narrow element; read once at the start |
edge |
24 | Fade before the box’s edge, CSS px, up to 64 |
smooth |
3 | A blur of the field, CSS px, up to 8; rings are curves, not polylines. 0 is the exact field; read once at the start |
trigger |
0 | 0 on load, 1 when the element enters view |
rest |
1 | 1 keeps Tide’s still; 0 ends empty |
duration, maxPixelRatio, maxPixels, arrivalOnly |
1400, 2, 2,500,000, true | As every one-shot recipe |
Versions
| Version | What it is |
|---|---|
| Map | The defaults: Tide, the still kept |
| Terrain | Rings become coastline far from the word |
| Ripple | Rings emitted by the word, leaving; ends empty |
| Outline | One ring leaves, in the accent; ends empty |
| Intro | Nothing until the element enters view |
Where it must not go
Never beside a real map or chart, where drawn contours read as data. Never on body text, a reading column, a listing or a card grid. Never on a heading under 32 px, which the code refuses. A hero, a page’s arrival, a section’s heading.
What it does when things are missing
| Situation | What the reader sees |
|---|---|
No WebGL2, a software renderer; no IntersectionObserver with trigger 1 (it starts on load instead) |
The heading, plain; or the run on load |
No mark, an empty or two-line mark, type under 32 px, a font the canvas cannot parse, a font-stretch no canvas keyword names (anything but the nine from 50% to 200%) or one the canvas cannot set, an unloaded picture, a mask with no ink, a page host (body or html) |
The heading, plain; nothing attached |
| An opaque picture that fills the host | A field of zeros: nothing is drawn for the run, and Tide keeps an empty still |
A picture shown at an aspect other than its own (object-fit not cover), or with padding or a border |
A mask that does not match what is shown; not refused |
| Fonts not settled in 1.5 s | Rings around the fallback face’s shapes; the swap moves the word under them |
| Reduced motion, forced colours | The heading, plain |
The R16F upload fails |
Nothing attached |
| The run ends | Tide’s still, or nothing |
update({ detail }) after the start |
Nothing: the field is made once |
| The still as a raster, or nothing |
Learned while building it
- The field’s resolution is judged against the mask’s size. The research saw a half-resolution field indistinguishable from the second ring out, on a 1,200 px mask; on the catalog’s 550 px band the same 0.5 wobbled along straight edges on every ring, and 0.25 badly. The default is 1. At full resolution on that band the first build ran the read-back and the column pass in one 62 ms task; a yield after the read-back makes it three tasks, each under 60 ms.
- Ripple must keep emitting. The research sketch slid the levels outward and cut everything inside the slide, so after the first ring left the word sat bare with two faint lines far out. With the levels sliding and the whole set fading, rings leave the gap one after another.
- The one-shot harness starts its clock when
prepareresolves, which here is after the transform’s two tasks. A check that read the clock as soon as the canvas appeared found none; every check and the stills script now wait for it. - Registration: at the end of Tide, not one lit pixel sat on a glyph or within half the gap (6.5 px) of one, out of 23,841 lit; the rings up the word’s middle column repeat at 14 px within a pixel and a half; a picture’s disc got 20,396 lit pixels in its ring band and none inside.
- 6,488 bytes gzipped with the blur, 6,272 after the review pass, 6,063 before it: the research’s sketch at 5,510 plus the picture source, the refusals, the em gap, the heading scale and the edge fade, then the padding box, the stretch keywords, the page-host refusal and the gap floor, then the blur. The budget was set at 6,600.
- A
detailchange after the start does nothing: the field is made once inprepare, which the one-shot form runs once. The bench remounts on every change, so it is not seen there. - The review found two versions drawing the wrong thing, which no check could see. The heavy index ring sat at the gap and outlined every letter, because the levels count from zero at the gap; it is now counted from the first ring after it. Outline drew two half-lines a spacing apart, because the line function’s zero is at integers and the level had been offset by a half; it is now one ring. Both have checks: the first run up the word’s column is thin and the fifth is heavy, and Outline has exactly one run.
- The computed
font-stretchis a percentage and the canvas takes a keyword, so the assignment the first build made was a silent no-op on Chrome and Firefox and a stretched heading would have registered at normal width. The nine percentages with keywords are mapped; any other width is refused.point-gatherandink-resolvecarry the same assignment and are not changed in this lab. - The canvas sits in the host’s padding box and the mask was drawn in its border box: the band fixture’s 1 px border is the “1 px offset whose cause is unknown” of the research. The mask is now drawn in the padding box, a host with a 14 px border keeps its rings off the glyphs, and a page host is refused.
- The gap is never under 4 px: at 32 px type 0.1 em is 3.2, and a ring that close sits on the glyph’s antialiased fringe once the canvas word is a pixel off the live one.
- The owner saw the rings as jagged on the recipe page, and 4x crops at pixel ratios 1, 1.5 and 2 showed why: an exact distance field is a union of cones from every edge pixel, so its rings are polylines with a crease wherever the nearest pixel changes, and the coverage correction leaves a half-pixel notch at each change. No dial touched it. A Gaussian blur of the field, 3 px by default and a dial up to 8, turns the polylines into curves; the crops after it show no notch and rounded creases, and the registration and ring checks still hold. The line width uses the derivative on a blurred field, since its slope is under 1 at a rounded crease.
/**
* word-contour
*
* Evenly spaced contour rings around the page's own heading, drawn from
* an exact distance field of the word, revealed outward from it (Tide),
* emitted by it (Ripple) or as one ring that leaves (Outline), and
* roughened into terrain far from the word if asked. The heading is
* never hidden, moved or restyled, and no line touches a glyph.
*
* Copy this file. It imports nothing.
*
* import { mount } from './word-contour';
* mount(document.querySelector('.hero'));
*
* The mark is the first [data-mask] inside the element, else its first
* heading, else its first img. A text mark is one line of display type,
* 32 px or more. It runs once, for its duration, on arrival or when the
* element enters view.
*/
export interface Options {
/** Length of the run in ms. */
duration: number;
/** Ceiling on canvas pixels per CSS pixel. */
maxPixelRatio: number;
/** Ceiling on canvas pixels in total. */
maxPixels: number;
/** Run only on an arrival from outside the site, not on a reload or a history move. */
arrivalOnly: boolean;
/** CSS px between rings for a 128 px heading, 6 to 40; scaled with the heading down to 0.6. */
spacing: number;
/** Line width in CSS px, 0.75 to 2. */
width: number;
/** Clear of the glyphs, in em of the heading, 0.02 to 0.4. */
gap: number;
/** How far the rings go, CSS px for a 128 px heading, 40 to 600; scaled with the heading. */
reach: number;
/** A heavier ring, counted from the first ring after the gap: 0 off, 1 every fourth, 2 every fifth. */
index: number;
/** Noise height far from the word, CSS px, 0 to 80. 0 is pure offset rings. */
terrain: number;
/** Noise feature size, CSS px, 90 to 400. */
feature: number;
/** 0 Tide (rings appear outward), 1 Ripple (rings leave the word), 2 Outline (one ring leaves). */
motion: number;
/** Ink strength, 0.15 to 1. */
strength: number;
/** 0 ink, 1 accent. */
tint: number;
/** The field's resolution as a share of CSS pixels, 0.25 to 1. Below 1 the rings wobble along straight edges on a narrow element (seen at 0.5 on a 550 px band); 0.5 is a quarter of the transform's cost. Read once, at the start. */
detail: number;
/** Fade before the element's box edge, CSS px, 0 to 64. */
edge: number;
/** A blur of the field, CSS px, 0 to 8: rounds the creases where the nearest edge changes and the half-pixel notches of an exact field, so rings are curves and not polylines. 0 is the exact field. Read once, at the start. */
smooth: number;
/** 0 on load, 1 when the element enters view. */
trigger: number;
/** 1 keeps Tide's still when the run ends; 0 ends empty. Ripple and Outline always end empty. */
rest: number;
}
export const DEFAULTS: Options = {
duration: 1400,
maxPixelRatio: 2,
maxPixels: 2_500_000,
arrivalOnly: true,
spacing: 14,
width: 1.25,
gap: 0.1,
reach: 220,
index: 2,
terrain: 0,
feature: 180,
motion: 0,
strength: 1,
tint: 0,
detail: 1,
edge: 24,
smooth: 3,
trigger: 0,
rest: 1,
};
export interface Handle {
update(options: Partial<Options>): void;
destroy(): void;
}
// ---- the effect -------------------------------------------------------------
const VERTEX = `#version 300 es
void main(){vec2 p=vec2((gl_VertexID<<1)&2,gl_VertexID&2);gl_Position=vec4(p*2.-1.,0,1);}`;
// The field is distance from the glyphs in mask px; divided by the
// mask's scale it is CSS px. Spacing and reach are tuned for a 128 px
// heading and scale with the font down to 0.6; the gap is in em. Lines
// are contour-field's: distance to the nearest level in device pixels
// through a one-pixel smoothstep, never thinner than a device pixel.
// The slope of a distance field is 1, so the plain rings use that
// constant for the width (no derivative, so no blobs on a flat run of
// texels); terrain adds noise and brings the derivative back. Nothing
// is drawn on or within half the gap of a glyph, the rings fade by the
// reach and before the box's edge, and the motion's envelope and the
// strength finish the alpha. The noise hashes integers, so it is the
// same on every GPU. The gap is never under 4 px, since the canvas word
// sits within about a pixel of the live one and a closer ring would
// touch the glyph's fringe; the heavy index ring is counted from the
// first ring after the gap, or it would outline every letter; Outline
// is the one level at q = 0, where ln's distance is zero, kept alone
// by the step.
const FRAGMENT = `#version 300 es
precision highp float;
uniform sampler2D uField;
uniform vec2 uResolution;
uniform float uPixelRatio,uProgress,uTime,uSeed,uScale,uFont;
uniform vec4 uRing,uLook,uMove;
uniform vec3 uInk,uAccent;
out vec4 o;
uint hh(uint x){x^=x>>16u;x*=0x7feb352du;x^=x>>15u;x*=0x846ca68bu;x^=x>>16u;return x;}
float h2(ivec2 p){return float(hh(uint(p.x)*1973u+hh(uint(p.y)+uint(uSeed*65535.))))/4294967295.;}
float vn(vec2 p){ivec2 i=ivec2(floor(p));vec2 f=fract(p);f=f*f*(3.-2.*f);
return mix(mix(h2(i),h2(i+ivec2(1,0)),f.x),mix(h2(i+ivec2(0,1)),h2(i+ivec2(1,1)),f.x),f.y);}
float ln(float v,float g,float w){float d=abs(fract(v-.5)-.5)/max(g,1e-6),k=max(w,1.);
return (1.-smoothstep(k*.5-.5,k*.5+.5,d))*min(w,1.);}
void main(){
vec2 uv=gl_FragCoord.xy/uResolution;uv.y=1.-uv.y;
vec2 c=gl_FragCoord.xy/uPixelRatio,box=uResolution/uPixelRatio;
float d=texture(uField,uv).r/uScale;
float k=clamp(uFont/128.,.6,1.);
float sp=uRing.x*k,gap=max(uRing.z*uFont,4.),reach=uRing.w*k,p=uProgress;
float n=uLook.x>0.?(vn(c/uLook.y)+.5*vn(c/uLook.y*2.03)-.75)*1.33*uLook.x*smoothstep(gap,reach*.6,d):0.;
float shift=uMove.x>.5&&uMove.x<1.5?p*reach:0.;
float v=(d+n-gap-shift)/sp;
float g=uLook.x>0.||uMove.y>0.?length(vec2(dFdx(v),dFdy(v))):1./(sp*uPixelRatio);
float w=uRing.y*uPixelRatio;
float a=max(ln(v,g,w)*.7,uLook.z>0.?ln((v+1.)/uLook.z,g/uLook.z,w*1.6):0.);
if(uMove.x>1.5){float q=(d+n-gap-p*reach)/sp;a=ln(q,g,w)*step(abs(q),.5);}
a*=smoothstep(gap*.5,gap,d)*(1.-smoothstep(reach*.6,reach,d));
float e=uMove.w;if(e>0.)a*=smoothstep(0.,e,c.x)*smoothstep(0.,e,box.x-c.x)*smoothstep(0.,e,c.y)*smoothstep(0.,e,box.y-c.y);
if(uMove.x<.5)a*=1.-smoothstep(p*reach*1.15-12.,p*reach*1.15,d);
else if(uMove.x<1.5)a*=1.-p;
else a*=1.-p;
a*=uLook.w;
o=vec4((uMove.z>.5?uAccent:uInk)*a,a);
}`;
/** CSS tokens read into uniforms by these names, with what stands in for a missing one. */
const TOKENS: readonly (readonly [uniform: string, property: string, fallback: string])[] = [
['uInk', '--ink', '#000'],
['uAccent', '--accent', '#00f'],
];
const clamp = (value: number, low: number, high: number): number => Math.min(high, Math.max(low, value));
const whole = (value: number, low: number, high: number): number => clamp(Math.round(Number.isFinite(value) ? value : low), low, high);
export interface Dials {
spacing: number;
width: number;
gap: number;
reach: number;
index: number;
terrain: number;
feature: number;
motion: number;
strength: number;
tint: number;
detail: number;
edge: number;
smooth: number;
trigger: number;
rest: number;
}
/** Every dial inside the range the shader survives; index becomes the count of rings between heavy ones, 0, 4 or 5. */
export function dials(o: Options): Dials {
const index = whole(o.index, 0, 2);
return {
spacing: clamp(o.spacing, 6, 40),
width: clamp(o.width, 0.75, 2),
gap: clamp(o.gap, 0.02, 0.4),
reach: clamp(o.reach, 40, 600),
index: index === 0 ? 0 : index === 1 ? 4 : 5,
terrain: clamp(o.terrain, 0, 80),
feature: clamp(o.feature, 90, 400),
motion: whole(o.motion, 0, 2),
strength: clamp(o.strength, 0.15, 1),
tint: whole(o.tint, 0, 1),
detail: clamp(o.detail, 0.25, 1),
edge: clamp(o.edge, 0, 64),
smooth: clamp(o.smooth, 0, 8),
trigger: whole(o.trigger, 0, 1),
rest: whole(o.rest, 0, 1),
};
}
/** Spacing and reach are tuned for a 128 px heading and scale with it, down to 0.6 at 77 px and below. The shader computes the same from uFont. */
export const headingScale = (fontSize: number): number => clamp(fontSize / 128, 0.6, 1);
const uploadDials = (gl: WebGL2RenderingContext, at: (name: string) => WebGLUniformLocation | null, o: Options): void => {
const d = dials(o);
gl.uniform4f(at('uRing'), d.spacing, d.width, d.gap, d.reach);
gl.uniform4f(at('uLook'), d.terrain, d.feature, d.index, d.strength);
gl.uniform4f(at('uMove'), d.motion, d.smooth, d.tint, d.edge);
};
/** Tide may keep its rings as a still; Ripple and Outline end empty. */
const keepsStill = (o: Options): boolean => dials(o).rest === 1 && dials(o).motion === 0;
const fadeInMs = (_options: Options): number => 0;
const layerUnder = (_options: Options): boolean => false;
const canvasStyle = (_options: Options): string => '';
export const geometry = (gl: WebGL2RenderingContext, _o: Options): [mode: number, count: number] => [gl.TRIANGLES, 3];
const DEPTH = false;
const startsOnView = (o: Options): boolean => dials(o).trigger === 1;
/** The first [data-mask] in the element, else its first heading, else its first picture. */
export function markOf(element: HTMLElement): HTMLElement | null {
return element.querySelector<HTMLElement>('[data-mask]') ?? element.querySelector<HTMLElement>('h1, h2') ?? element.querySelector<HTMLElement>('img');
}
/**
* The extent of one line of text from its fragments (a styled child
* makes more than one), or null when the fragments sit on more than
* one line or there is no text.
*/
export function lineOf(rects: readonly DOMRect[]): DOMRect | null {
const parts = rects.filter((r) => r.width > 0);
const first = parts[0];
if (!first) return null;
let left = first.left, right = first.right, top = first.top, bottom = first.bottom;
for (const r of parts) {
if (Math.abs(r.top - first.top) > 1) return null;
left = Math.min(left, r.left);
right = Math.max(right, r.right);
top = Math.min(top, r.top);
bottom = Math.max(bottom, r.bottom);
}
return new DOMRect(left, top, right - left, bottom - top);
}
/**
* The element's box and the one line of text in the mark, measured
* through a Range so that a block mark (a heading) gives its text's
* own extent, not its box: a centred heading is measured where its
* letters are, and a heading on two lines gives null.
*/
export function measureMark(element: HTMLElement, mark: HTMLElement): { box: DOMRect; rect: DOMRect } | null {
const box = element.getBoundingClientRect();
if (!(box.width > 0 && box.height > 0)) return null;
const range = document.createRange();
range.selectNodeContents(mark);
const rect = lineOf(Array.from(range.getClientRects()));
return rect ? { box, rect } : null;
}
/** The text as the page renders it: whitespace collapsed, the text-transform applied in the page's language. */
export function maskText(text: string, transform: string, lang: string): string {
const flat = text.replace(/\s+/g, ' ').trim();
if (transform === 'uppercase') return flat.toLocaleUpperCase(lang);
if (transform === 'lowercase') return flat.toLocaleLowerCase(lang);
if (transform === 'capitalize') return flat.replace(/(^|\s)(\S)/g, (_m, s: string, c: string) => s + c.toLocaleUpperCase(lang));
return flat;
}
/** Where the baseline of one line sits: the top, half the slack, then the ascent. */
export const baseline = (top: number, height: number, ascent: number, descent: number): number =>
top + (height - (ascent + descent)) / 2 + ascent;
/** Settled fonts, or the wait, whichever is first. A page without a font set settles at once. */
export function fontsReady(ms: number): Promise<void> {
const ready = document.fonts?.ready;
if (!ready) return Promise.resolve();
return Promise.race([ready.then(() => undefined), new Promise<void>((resolve) => setTimeout(resolve, ms))]);
}
/** The element's padding box in viewport coordinates: where an absolutely placed canvas at inset 0 sits. */
export function paddingBox(element: HTMLElement): { left: number; top: number; width: number; height: number } {
const rect = element.getBoundingClientRect();
return { left: rect.left + element.clientLeft, top: rect.top + element.clientTop, width: element.clientWidth, height: element.clientHeight };
}
/** The canvas keyword for a computed font-stretch percentage, 'normal' for 100%, or null for a width no keyword names. */
export function stretchKeyword(value: string): string | null {
const pct = value.endsWith('%') ? Number(value.slice(0, -1)) : value === 'normal' ? 100 : NaN;
const table: Record<number, string> = { 50: 'ultra-condensed', 62.5: 'extra-condensed', 75: 'condensed', 87.5: 'semi-condensed', 100: 'normal', 112.5: 'semi-expanded', 125: 'expanded', 150: 'extra-expanded', 200: 'ultra-expanded' };
return table[pct] ?? null;
}
/** A picture that has loaded with a size; an unloaded one would draw nothing. */
export const pictureReady = (img: HTMLImageElement): boolean => img.complete && img.naturalWidth > 0;
/** The crop of a picture that object-fit cover shows in a box, centred. */
export function coverCrop(naturalWidth: number, naturalHeight: number, boxWidth: number, boxHeight: number): { sx: number; sy: number; sw: number; sh: number } {
const scale = Math.max(boxWidth / naturalWidth, boxHeight / naturalHeight);
const sw = Math.round(boxWidth / scale);
const sh = Math.round(boxHeight / scale);
return { sx: Math.round((naturalWidth - sw) / 2), sy: Math.round((naturalHeight - sh) / 2), sw, sh };
}
/** One row or column of the Felzenszwalb-Huttenlocher transform: the lower envelope of parabolas, recording the nearest feature. */
function pass(f: Float32Array, off: number, stride: number, n: number, d: Float32Array, v: Uint16Array, z: Float32Array, arg: Uint16Array): void {
let k = 0;
v[0] = 0;
z[0] = -1e20;
z[1] = 1e20;
for (let q = 1; q < n; q += 1) {
const fq = (f[off + q * stride] as number) + q * q;
let s = 0;
for (;;) {
const r = v[k] as number;
s = (fq - (f[off + r * stride] as number) - r * r) / (2 * (q - r));
if (s <= (z[k] as number) && k > 0) k -= 1;
else break;
}
k += 1;
v[k] = q;
z[k] = s;
z[k + 1] = 1e20;
}
k = 0;
for (let q = 0; q < n; q += 1) {
while ((z[k + 1] as number) < q) k += 1;
const r = v[k] as number;
d[q] = (q - r) * (q - r) + (f[off + r * stride] as number);
arg[q] = r;
}
for (let q = 0; q < n; q += 1) f[off + q * stride] = d[q] as number;
}
const yieldTask = (): Promise<void> => new Promise((resolve) => setTimeout(resolve, 0));
/** Pixels of transform work between yields: about 25 ms on an integrated laptop. */
const TASK_PIXELS = 120_000;
/**
* The field blurred by a Gaussian of `sigma` px, separably, clamped at
* the edges; the field itself at sigma 0. An exact distance field is
* a union of cones, one per edge pixel: its rings are polylines with a
* crease wherever the nearest edge pixel changes, and the coverage
* correction leaves a half-pixel notch at each change. A blur of a few
* pixels turns the polylines into curves and keeps the field within a
* fraction of a pixel of exact away from the creases.
*/
export function smooth(field: Float32Array, w: number, h: number, sigma: number): Float32Array {
if (!(sigma > 0)) return field;
const r = Math.ceil(sigma * 3);
const k = new Float32Array(r * 2 + 1);
let sum = 0;
for (let i = -r; i <= r; i += 1) { k[i + r] = Math.exp(-(i * i) / (2 * sigma * sigma)); sum += k[i + r] as number; }
for (let i = 0; i < k.length; i += 1) k[i] = (k[i] as number) / sum;
const tmp = new Float32Array(w * h);
const out = new Float32Array(w * h);
for (let y = 0; y < h; y += 1) for (let x = 0; x < w; x += 1) {
let t = 0;
for (let i = -r; i <= r; i += 1) t += (field[y * w + Math.min(w - 1, Math.max(0, x + i))] as number) * (k[i + r] as number);
tmp[y * w + x] = t;
}
for (let y = 0; y < h; y += 1) for (let x = 0; x < w; x += 1) {
let t = 0;
for (let i = -r; i <= r; i += 1) t += (tmp[Math.min(h - 1, Math.max(0, y + i)) * w + x] as number) * (k[i + r] as number);
out[y * w + x] = t;
}
return out;
}
/**
* Distance outside the ink in mask px, from the alpha of RGBA pixels:
* every pixel with any ink is a feature, the transform is exact, and
* each distance is corrected by the coverage of the nearest feature,
* `sqrt(f) + 0.5 - a`, so an antialiased edge is where it is and not
* where its pixel centre is. Columns, a yield, then rows: two tasks of
* half the work each instead of one long one. Throws when nothing is
* inked, since a field of infinities would draw nothing and say so to
* nobody.
*/
export async function outside(alpha: Uint8ClampedArray, w: number, h: number): Promise<Float32Array> {
const n = Math.max(w, h);
const f = new Float32Array(w * h);
const iy = new Uint16Array(w * h);
let inked = 0;
for (let i = 0; i < w * h; i += 1) {
const a = alpha[i * 4 + 3] as number;
if (a > 0) inked += 1;
f[i] = a > 0 ? 0 : 1e10;
}
if (inked === 0) throw new Error('nothing inked');
const d = new Float32Array(n), v = new Uint16Array(n), z = new Float32Array(n + 1), arg = new Uint16Array(n);
// A yield every so many pixels of work: a hero-sized mask is then many
// tasks of a few tens of milliseconds, not two of over a hundred.
const every = Math.max(1, Math.floor(TASK_PIXELS / Math.max(w, h)));
for (let x = 0; x < w; x += 1) {
if (x > 0 && x % every === 0) await yieldTask();
pass(f, x, w, h, d, v, z, arg);
for (let y = 0; y < h; y += 1) iy[y * w + x] = arg[y] as number;
}
await yieldTask();
for (let y = 0; y < h; y += 1) {
if (y > 0 && y % every === 0) await yieldTask();
pass(f, y * w, 1, w, d, v, z, arg);
for (let x = 0; x < w; x += 1) {
const i = y * w + x;
const qx = arg[x] as number;
const cover = (alpha[((iy[y * w + qx] as number) * w + qx) * 4 + 3] as number) / 255;
f[i] = (alpha[i * 4 + 3] as number) === 255 ? 0 : Math.max(0, Math.sqrt(f[i] as number) + 0.5 - cover);
}
}
return f;
}
/** The mask is drawn no wider than this, in CSS px, before the field's own scale: the read-back and the transform are the main-thread cost. */
const MASK_WIDTH = 1200;
/** How long to wait for the fonts before drawing on whatever face is in use. */
const FONT_WAIT = 1500;
/** Rings crowd type smaller than this, in CSS px. */
const MIN_FONT = 32;
/**
* Before the first frame: wait for the fonts, draw the mark where the
* page draws it (the word in its font, or the picture cover-cropped at
* its rect), transform the coverage into a distance field in two tasks,
* upload it as R16F, and set the field's scale and the heading's size as
* uniforms. Throws for anything it cannot draw rightly, and the harness
* then attaches nothing: no mark, an empty or two-line text, type under
* 32 px, a font the canvas cannot parse, a stretched heading where the
* canvas has no fontStretch, a picture not yet loaded, a mask with no
* ink, an upload the context refuses.
*/
const prepare: Prepare = async (gl, at, element, o) => {
await fontsReady(FONT_WAIT);
const mark = markOf(element);
if (!mark) throw new Error('no mark');
const pic = mark instanceof HTMLImageElement;
// A page host would mean a document-tall mask squeezed onto the viewport: refused.
if (element === document.body || element === document.documentElement) throw new Error('page host');
// The canvas covers the padding box (inset 0 of a positioned host), so the mask is drawn in it too, not the border box.
const box = paddingBox(element);
if (!(box.width > 0 && box.height > 0)) throw new Error('no box');
const d = dials(o);
const scale = Math.min(1, MASK_WIDTH / box.width, MASK_WIDTH / box.height) * d.detail;
const mask = document.createElement('canvas');
mask.width = Math.max(1, Math.round(box.width * scale));
mask.height = Math.max(1, Math.round(box.height * scale));
const ctx = mask.getContext('2d', { willReadFrequently: true });
if (!ctx) throw new Error('no 2d');
ctx.scale(scale, scale);
let font = 128;
if (pic) {
if (!pictureReady(mark)) throw new Error('picture not loaded');
const rect = mark.getBoundingClientRect();
if (!(rect.width > 0 && rect.height > 0)) throw new Error('no picture box');
const crop = coverCrop(mark.naturalWidth, mark.naturalHeight, rect.width, rect.height);
ctx.drawImage(mark, crop.sx, crop.sy, crop.sw, crop.sh, rect.left - box.left, rect.top - box.top, rect.width, rect.height);
} else {
const style = getComputedStyle(mark);
const lang = mark.closest<HTMLElement>('[lang]')?.lang || document.documentElement.lang || 'en';
const text = maskText(mark.textContent ?? '', style.textTransform, lang);
const measured = text ? measureMark(element, mark) : null;
if (!measured) throw new Error('no one-line mark');
font = parseFloat(style.fontSize);
if (!(font >= MIN_FONT)) throw new Error('type too small');
const { rect } = measured;
// The mark's rect is placed against the padding box, where the canvas is.
// A font the canvas cannot parse leaves the default in place, and a
// mask in the default face lands nowhere near the word: stop instead.
const unset = ctx.font;
ctx.font = `${style.fontStyle} ${style.fontWeight} ${style.fontSize} ${style.fontFamily}`;
if (ctx.font === unset) throw new Error('font');
const text2d = ctx as CanvasRenderingContext2D & { letterSpacing?: string; fontStretch?: CanvasFontStretch };
if ('letterSpacing' in text2d) text2d.letterSpacing = style.letterSpacing;
// A stretched heading the canvas cannot stretch registers wrongly:
// nothing is better than rings around the wrong shape. The computed
// value is a percentage and the canvas takes a keyword; a percentage
// with no keyword, or a canvas with no fontStretch, is refused.
const stretch = stretchKeyword(style.fontStretch);
if (stretch === null) throw new Error('stretch');
if (stretch !== 'normal') {
if (!('fontStretch' in text2d)) throw new Error('stretch');
text2d.fontStretch = stretch as CanvasFontStretch;
}
ctx.textAlign = 'left';
ctx.textBaseline = 'alphabetic';
ctx.fillStyle = '#000';
const metrics = ctx.measureText(text);
ctx.fillText(text, rect.left - box.left, baseline(rect.top - box.top, rect.height, metrics.fontBoundingBoxAscent, metrics.fontBoundingBoxDescent));
}
const { width, height } = mask;
const alpha = ctx.getImageData(0, 0, width, height).data;
// The read-back is its own task; the transform's two halves follow, each after a yield.
await yieldTask();
const exact = await outside(alpha, width, height);
// The blur in mask px, its own task after the transform's two.
if (d.smooth > 0) await yieldTask();
const field = smooth(exact, width, height, d.smooth * scale);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, gl.createTexture());
// Linear filtering on a half-float texture is core in WebGL2; on a
// 32-bit float it is not, and an incomplete texture reads as zero.
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
// A one-channel row of odd width is not 4-byte aligned.
gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.R16F, width, height, 0, gl.RED, gl.FLOAT, field);
if (gl.getError() !== gl.NO_ERROR) throw new Error('upload');
gl.uniform1i(at('uField'), 0);
// Set here, on the program, never in module state: two mounts on one
// page would share a variable, and the second would draw the first's field at the wrong scale.
gl.uniform1f(at('uScale'), scale);
gl.uniform1f(at('uFont'), font);
gl.enable(gl.BLEND);
gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
};
/**
* Named versions of the effect: sets of options that belong together.
* The first is the defaults. `mount(element, PRESETS.Terrain)`.
*/
export const PRESETS: Record<string, Partial<Options>> = {
Map: {},
Terrain: { terrain: 40, reach: 320 },
Ripple: { motion: 1 },
Outline: { motion: 2, tint: 1 },
Intro: { trigger: 1 },
};
// ---- HARNESS one-shot: begin ------------------------------------------------
//
// Identical in every one-shot shader recipe. `npm run check` fails if a
// copy differs, so change it in one recipe and copy it to the others.
//
// Above this line a recipe declares, by these names: Options (with
// duration, maxPixelRatio, maxPixels and arrivalOnly), DEFAULTS, Handle,
// VERTEX, FRAGMENT, TOKENS, uploadDials, keepsStill, fadeInMs,
// layerUnder, canvasStyle, geometry (the draw mode and vertex count),
// DEPTH (whether the context has a depth buffer and tests with it),
// prepare (the recipe's own setup after the program is in use, which
// may return a promise the start waits on) and startsOnView (whether
// nothing is made until the element enters the viewport).
//
// What it promises: it never throws, never hides or moves content, and
// changes nothing on the page until its first frame is ready. It never
// asks the GPU a blocking question where a non-blocking one exists.
// When the effect ends, or on destroy, nothing of it remains.
/** The recipe's setup, given the context, the uniform locations, the host and the options; a promise delays the start. */
type Prepare = (gl: WebGL2RenderingContext, at: (name: string) => WebGLUniformLocation | null, element: HTMLElement, options: Options) => void | Promise<void>;
const NOOP: Handle = { update() {}, destroy() {} };
/** One mount to an element. A second replaces the first. */
const mounted = new WeakMap<HTMLElement, Handle>();
/** The buffer size for a box measured in device pixels, under both ceilings. */
export function bufferSize(
deviceWidth: number,
deviceHeight: number,
pixelRatio: number,
maxPixelRatio: number,
maxPixels: number,
): [width: number, height: number] {
const area = deviceWidth * deviceHeight;
if (!(area > 0)) return [1, 1];
const scale = Math.min(1, maxPixelRatio / Math.max(pixelRatio, 0.01), Math.sqrt(maxPixels / area));
return [Math.max(1, Math.round(deviceWidth * scale)), Math.max(1, Math.round(deviceHeight * scale))];
}
/** True when this page load came from outside the site, and is not a reload or a history move. */
export function isArrival(type: string | undefined, referrer: string, origin: string): boolean {
if (type && type !== 'navigate') return false;
if (!referrer) return true;
try {
return new URL(referrer).origin !== origin;
} catch {
return true;
}
}
/** `color(srgb r g b)` as the browser serialises it, to three numbers in 0..1. */
export function parseSrgb(value: string): [number, number, number] | null {
const match = /color\(srgb\s+([-\d.e]+)\s+([-\d.e]+)\s+([-\d.e]+)/.exec(value);
if (!match) return null;
const channel = (text: string | undefined): number => Math.min(1, Math.max(0, Number(text) || 0));
return [channel(match[1]), channel(match[2]), channel(match[3])];
}
/** A CSS time, in ms. `null` for anything that is not one. */
export function parseTime(value: string): number | null {
const match = /^\s*(\d*\.?\d+)(ms|s)\s*$/.exec(value);
if (!match) return null;
return match[2] === 's' ? Number(match[1]) * 1000 : Number(match[1]);
}
/**
* Options over a base, keeping only what is the right type. A control
* panel, a message from another frame and a careless caller can all
* send anything, and none of them may reach the GPU unchecked.
*/
export function mergeOptions(base: Options, next: Partial<Options>): Options {
const merged: Record<string, unknown> = { ...base };
for (const [key, standard] of Object.entries(DEFAULTS)) {
const value: unknown = (next as Record<string, unknown>)[key];
if (typeof value === typeof standard && (typeof value !== 'number' || Number.isFinite(value))) merged[key] = value;
}
return merged as unknown as Options;
}
export function mount(element: HTMLElement | null, options: Partial<Options> = {}): Handle {
try {
return start(element, options);
} catch {
return NOOP;
}
}
function start(element: HTMLElement | null, explicit: Partial<Options>): Handle {
if (!element || typeof WebGL2RenderingContext === 'undefined' || typeof ResizeObserver === 'undefined') return NOOP;
// The one place motion is decided, so a host can override matchMedia.
const asks = (query: string): boolean => typeof matchMedia === 'function' && matchMedia(query).matches;
if (asks('(prefers-reduced-motion: reduce)') || asks('(forced-colors: active)')) return NOOP;
mounted.get(element)?.destroy();
const fromToken = parseTime(getComputedStyle(element).getPropertyValue('--load-duration'));
let options = mergeOptions(fromToken === null ? DEFAULTS : { ...DEFAULTS, duration: fromToken }, explicit);
if (options.arrivalOnly) {
const entry = performance.getEntriesByType?.('navigation')[0] as PerformanceNavigationTiming | undefined;
if (!isArrival(entry?.type, document.referrer, location.origin)) return NOOP;
}
const page = element === document.body || element === document.documentElement;
const under = layerUnder(options);
const canvas = document.createElement('canvas');
canvas.setAttribute('aria-hidden', 'true');
canvas.style.cssText = `position:${page ? 'fixed' : 'absolute'};inset:0;width:100%;height:100%;pointer-events:none;z-index:${under ? -1 : 2147483647};${canvasStyle(options)}`;
let gl: WebGL2RenderingContext | null = null;
let program: WebGLProgram | null = null;
let observer: ResizeObserver | null = null;
let clock: Animation | null = null;
let still: HTMLCanvasElement | null = null;
let seen: IntersectionObserver | null = null;
let hostStyle: string | null | undefined;
let frame = 0;
let timer = 0;
let slow = 0;
let last = 0;
let seconds = 0;
let done = false;
const locations = new Map<string, WebGLUniformLocation | null>();
const at = (name: string): WebGLUniformLocation | null => {
if (!locations.has(name)) locations.set(name, gl && program ? gl.getUniformLocation(program, name) : null);
return locations.get(name) ?? null;
};
/** Ends the effect. With `keep`, the last frame stays as a plain 2D canvas. */
const finish = (keep: boolean): void => {
if (done) return;
done = true;
cancelAnimationFrame(frame);
clearTimeout(timer);
observer?.disconnect();
seen?.disconnect();
clock?.cancel();
document.removeEventListener('visibilitychange', wake);
removeEventListener('pagehide', stop);
removeEventListener('pageswap', stop);
canvas.removeEventListener('webglcontextlost', stop);
if (keep && gl && canvas.isConnected) {
// Copied in the same task as the last draw: after the next
// composite the drawing buffer is gone.
still = document.createElement('canvas');
still.width = canvas.width;
still.height = canvas.height;
still.setAttribute('aria-hidden', 'true');
still.style.cssText = canvas.style.cssText;
still.dataset.state = 'rest';
still.getContext('2d')?.drawImage(canvas, 0, 0);
canvas.replaceWith(still);
} else {
// Removed first, then released: a lost canvas left in the page
// shows a broken-image mark.
canvas.remove();
}
if (gl) {
gl.deleteProgram(program);
gl.getExtension('WEBGL_lose_context')?.loseContext();
}
canvas.width = canvas.height = 0;
gl = program = null;
if (!still) restoreHost();
};
const stop = (): void => finish(false);
const restoreHost = (): void => {
if (hostStyle === undefined) return;
// Read it before removing it. In Chrome, a style set through
// `.style` and removed before anyone has read the attribute leaves
// `style=""` behind, and "as it was" would be nearly true.
element.getAttribute('style');
if (hostStyle === null) element.removeAttribute('style');
else element.setAttribute('style', hostStyle);
hostStyle = undefined;
};
const tokens = (): void => {
if (!gl) return;
const probe = canvas.style;
for (const [uniform, property, fallback] of TOKENS) {
// Any colour syntax in, one serialisation out: mixing a colour
// with itself in sRGB makes the browser do the conversion.
probe.color = `color-mix(in srgb, var(${property}, ${fallback}) 100%, #000 0%)`;
const rgb = parseSrgb(getComputedStyle(canvas).color) ?? [0, 0, 0];
gl.uniform3f(at(uniform), rgb[0], rgb[1], rgb[2]);
}
probe.color = '';
};
const draw = (now: number): void => {
frame = 0;
if (done || !gl || !clock) return;
const progress = clock.effect?.getComputedTiming().progress ?? 1;
const gap = last ? now - last : 0;
last = now;
seconds += Math.min(gap, 100) / 1000;
// Four frames in a row slower than 15 a second: this is not a GPU worth keeping.
slow = gap > 66 ? slow + 1 : 0;
gl.uniform1f(at('uProgress'), progress);
gl.uniform1f(at('uTime'), seconds);
uploadDials(gl, at, options);
// Cleared first: a fragment that discards, above a horizon, must not
// show the last frame through the hole.
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
const [mode, count] = geometry(gl, options);
gl.drawArrays(mode, 0, count);
if (progress >= 1) finish(keepsStill(options));
else if (slow > 3) finish(false);
else frame = requestAnimationFrame(draw);
};
const resize = (entries: ResizeObserverEntry[]): void => {
const entry = entries[0];
if (done || !gl || !entry) return;
const ratio = devicePixelRatio || 1;
const exact = entry.devicePixelContentBoxSize?.[0];
const box = entry.contentBoxSize?.[0];
const cssWidth = box ? box.inlineSize : entry.contentRect.width;
const cssHeight = box ? box.blockSize : entry.contentRect.height;
// The device-pixel box is for snapping, not for scale: it is trusted
// only where it agrees with devicePixelRatio to within a pixel.
// Under DevTools device emulation it reports CSS pixels.
const snapped = exact && Math.abs(exact.inlineSize - cssWidth * ratio) < 2 && Math.abs(exact.blockSize - cssHeight * ratio) < 2;
const [width, height] = bufferSize(
snapped ? exact.inlineSize : cssWidth * ratio,
snapped ? exact.blockSize : cssHeight * ratio,
ratio,
options.maxPixelRatio,
options.maxPixels,
);
if (canvas.width !== width || canvas.height !== height) {
canvas.width = width;
canvas.height = height;
gl.viewport(0, 0, width, height);
}
gl.uniform2f(at('uResolution'), width, height);
gl.uniform1f(at('uPixelRatio'), width / Math.max(1, cssWidth));
if (!clock) {
// The clock is a Web Animation with nothing in it: the browser
// keeps the time and applies the easing token.
const easing = getComputedStyle(element).getPropertyValue('--load-ease').trim() || 'linear';
const timing = { duration: Math.max(options.duration, 1), fill: 'forwards' as const };
try {
clock = canvas.animate(null, { ...timing, easing });
} catch {
clock = canvas.animate(null, timing);
}
const fade = fadeInMs(options);
if (fade > 0) canvas.animate({ opacity: [0, 1] }, { duration: fade });
}
// Sizing clears the buffer, so draw before this frame is shown.
cancelAnimationFrame(frame);
draw(performance.now());
};
const attach = (): void => {
if (done || !gl) return;
if (under || !page) {
// The host becomes the canvas's box, and behind the content a
// stacking context too, so the canvas sits above its background
// and below everything in it.
hostStyle = element.getAttribute('style');
if (getComputedStyle(element).position === 'static') element.style.position = 'relative';
}
if (under) {
element.style.isolation = 'isolate';
element.prepend(canvas);
} else {
element.append(canvas);
}
canvas.dataset.state = 'running';
// In the document first: an element outside it has no computed style.
tokens();
observer = new ResizeObserver(resize);
try {
observer.observe(canvas, { box: 'device-pixel-content-box' });
} catch {
observer.observe(canvas);
}
};
const ready = (): void => {
if (done || !gl || !program) return;
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) return stop();
gl.useProgram(program);
if (DEPTH) gl.enable(gl.DEPTH_TEST);
gl.uniform1f(at('uSeed'), Math.random());
// The recipe's own setup, which may wait: for a font, for a picture.
// A throw, sync or async, ends the effect with nothing shown.
const context = gl;
Promise.resolve()
.then(() => prepare(context, at, element, options))
.then(attach, stop);
};
const build = (): void => {
if (done || gl) return;
if (document.visibilityState !== 'visible') return;
document.removeEventListener('visibilitychange', wake);
gl = canvas.getContext('webgl2', {
alpha: true,
antialias: false,
depth: DEPTH,
stencil: false,
powerPreference: 'low-power',
// A software renderer would burn a weak machine's cores for decoration.
failIfMajorPerformanceCaveat: true,
});
if (!gl) return stop();
canvas.addEventListener('webglcontextlost', stop);
program = gl.createProgram();
for (const [type, source] of [[gl.VERTEX_SHADER, VERTEX], [gl.FRAGMENT_SHADER, FRAGMENT]] as const) {
const shader = gl.createShader(type);
if (!shader) return stop();
gl.shaderSource(shader, source);
gl.compileShader(shader);
gl.attachShader(program, shader);
gl.deleteShader(shader);
}
gl.linkProgram(program);
const parallel = gl.getExtension('KHR_parallel_shader_compile');
let waited = 0;
const poll = (): void => {
frame = 0;
if (done || !gl || !program) return;
waited += 1;
// With the extension, ask without blocking. Without it, give the
// driver two frames, then ask and take the wait. Asking early
// moved first paint by more than a second in testing.
if (parallel ? gl.getProgramParameter(program, parallel.COMPLETION_STATUS_KHR) : waited > 2) ready();
else frame = requestAnimationFrame(poll);
};
frame = requestAnimationFrame(poll);
};
function wake(): void {
build();
}
const handle: Handle = {
update(next) {
options = mergeOptions(options, next);
},
destroy() {
stop();
still?.remove();
still = null;
restoreHost();
if (mounted.get(element) === handle) mounted.delete(element);
},
};
mounted.set(element, handle);
addEventListener('pagehide', stop);
addEventListener('pageswap', stop);
document.addEventListener('visibilitychange', wake);
// After a paint, never before one: the page is on screen first, and
// creating a context is a blocking call of tens of milliseconds.
const begin = (): void => {
frame = requestAnimationFrame(() => {
frame = 0;
timer = window.setTimeout(build, 0);
});
};
if (startsOnView(options) && typeof IntersectionObserver === 'function') {
// Nothing is made until the element is in view: no context held
// below the fold, and the recipe prepares against the layout and
// fonts of the moment it starts.
seen = new IntersectionObserver((entries) => {
if (done || !entries.some((entry) => entry.isIntersecting)) return;
seen?.disconnect();
seen = null;
begin();
});
seen.observe(element);
} else {
begin();
}
return handle;
}
// ---- HARNESS one-shot: end --------------------------------------------------