Catalog / Recipe
Plane bend
While the page moves fast the middle of each picture trails its edges inside its own box, and when the page stops it is the plain picture. Scroll is read, never written.
- Family
- plane
- Status
- lab
- Used for
- GalleryListing
- Size
- 4,910 of 5,000 bytes, gzipped
mount(element)The one effect that answers “scroll motion” literally, built the one way that keeps native scrolling: each picture’s lid sits in the page and scrolls with it, so nothing tracks anything, and the effect stays inside the picture’s own box. The page’s speed is read from the scroll position and never written. There is no smooth-scroll library, no wheel handler, no transformed wrapper, and a test holds the file to that.
Use
import { mount } from './plane-bend';
mount(document.querySelector('.gallery'));
A lab experiment, off by default on a real page. It is the costliest lid effect, a lid per visible picture, six uploads at the start of a flick and six lids drawn a frame, and the least useful to a reader who is scanning. The research recommends against it by default; the lab shows it so the owner can say go or no-go.
How it moves
The page’s signed speed, less a dead zone and as a share of the speed
at which the trail is at full, is the trail, clamped to one either way,
taking hold with a fast attack and letting go with a slow release. The
defaults are tuned for a mouse wheel: a notch in Chrome moves the page
at 500 to 900 px/s for a fraction of a second, which is most of the way
to full; a trackpad flick at 2,000 and more saturates it. The Flick
version wants the flick and ignores the notch. The displacement is zero at the top
and bottom edges so nothing is read from outside the box, and the
centre bows more than the sides by curl. When the first flick above
the dead zone comes, every picture on screen whose bitmap is ready is
covered, all or none; each lid comes off when the page is under the
dead zone and its trail is under half a device pixel.
Options
| Option | Default | Meaning |
|---|---|---|
amount |
0.05 | How far the middle trails at full speed, as a share of the height |
curl |
0.6 | How much the sides hold while the centre bows |
split |
0 | Colour split along the trail; a taste |
deadzone |
120 | Speed below which nothing responds, in CSS px a second |
fullAt |
900 | The speed at which the trail is at full. Lower is more sensitive |
attack |
60 | How fast it takes hold, in ms |
release |
350 | How fast it lets go, in ms |
maxLive, maxPixelRatio, rescueMs |
6, 2, 3000 | As every lid recipe; six, so a three-column grid of two rows responds whole |
Versions
| Version | What it is |
|---|---|
| Bend | The defaults |
| Sag | A deeper trail, the sides holding more |
| Shift | A flat shift, no bow |
| Fringed | A colour split along the trail |
| Lazy | Takes hold and lets go slower |
| Flick | Ignores a wheel notch and answers a trackpad flick |
None of them has been judged in motion on a real page.
Where it must not go
- Beside running prose. Nothing moves beside the text unless the reader starts it, and a flick is not that.
- A grid with more pictures on screen than
maxLive. Then none responds, by the lid’s all-or-nothing rule, and the effect is simply absent. - A phone, until it has been measured on one. A flick on a phone does drive it; the research would not ship a lid there unmeasured.
- Anything a reader reads by colour with a split on.
What it does when things are missing
The plain img, with no canvas in the document: under reduced motion
or forced colours, without WebGL2 or createImageBitmap, on a
software renderer, on an unpositioned parent, when the pool is spent,
on a cross-origin image, in print, when the tab is hidden, and after
any exception. A slow reading scroll under the dead zone starts
nothing.
Learned while building it
- Speed comes from the scroll events, not from the frame. A flick after a long rest, differenced against the last frame of that rest, reads as a crawl; differenced between its own first two events it is the flick it was.
- The rest test is in pixels. A trail under a threshold of speed kept six lids on for two seconds showing nothing.
- Tune for the wheel, not the flick. The research’s dials put full at 3,000 px/s with a trail of 0.03 of the height: on a 200 px picture a wheel notch gave under a pixel of bow, and the owner could not tell whether anything moved. And the dial was called gain, so raising it made the effect weaker; it is now the speed at which the trail is full.
/**
* plane-bend
*
* While the page moves fast, the middle of each picture trails its
* edges, inside its own box; when the page stops it is the plain
* picture again. Scroll position, speed and direction are read and
* never written: native scrolling is untouched. On a lid over the real
* `img`, which is never touched either; every picture on screen gets
* its lid together, or none does.
*
* Copy this file. It imports nothing.
*
* import { mount } from './plane-bend';
* mount(document.querySelector('.gallery'));
*
* A lab experiment, off by default on a real page: it is the costliest
* lid effect (a lid per visible picture) and the least useful to a
* reader who is scanning. Not measured on a phone.
*/
export interface Options {
/** How many lids may be live at once, 1 to 6. More pictures on screen than this, and none responds. */
maxLive: number;
/** Ceiling on lid pixels per CSS pixel, snapped to a whole fraction of the device's. */
maxPixelRatio: number;
/** If script stalls with a lid on, the compositor takes it off after this many ms. */
rescueMs: number;
/** How far the middle trails at full speed, as a share of the picture's height. Under 0.02 reads as soft focus; over 0.1 text smears. */
amount: number;
/** How much the sides hold while the centre bows, 0 to 1. 0 is a flat shift. */
curl: number;
/** Colour split along the trail, 0 to 1. Fringed type mid-flick; a taste. */
split: number;
/** Scroll speed below which nothing responds, in CSS px a second. A reading scroll stays under it. */
deadzone: number;
/** The speed at which the trail is at full, in CSS px a second. Lower is more sensitive. A wheel notch in Chrome moves the page at 500 to 900; a trackpad flick at 2,000 and more. */
fullAt: number;
/** How fast the trail takes hold when the page speeds up, in ms. */
attack: number;
/** How fast it lets go when the page slows, in ms. */
release: number;
}
export const DEFAULTS: Options = {
maxLive: 6,
maxPixelRatio: 2,
rescueMs: 3000,
amount: 0.05,
curl: 0.6,
split: 0,
deadzone: 120,
fullAt: 900,
attack: 60,
release: 350,
};
export interface Handle {
update(options: Partial<Options>): void;
destroy(): void;
}
// ---- the effect -------------------------------------------------------------
// The displacement is vertical, weighted by 4y(1 - y) so it is zero at
// the top and bottom edges and nothing is read from outside the box,
// and by 1 - curl x² so the centre bows more than the sides. `v` is
// the page's smoothed speed, -1 to 1, positive when the page scrolls
// down and the content moves up, so the middle lags behind. With a
// split the three channels are read at three offsets along the trail.
const FRAGMENT = `#version 300 es
precision highp float;
uniform sampler2D uImage;
uniform vec2 uResolution;
uniform float uV;
uniform vec3 uGround;
uniform vec4 uDial;
out vec4 o;
vec3 at(vec2 uv){return texture(uImage,clamp(uv,0.,1.)).rgb;}
void main(){
vec2 uv=gl_FragCoord.xy/uResolution;uv.y=1.-uv.y;
float wy=4.*uv.y*(1.-uv.y),cx=uv.x*2.-1.;
float s=uV*uDial.x*wy*(1.-uDial.y*cx*cx),sp=s*uDial.z;
vec2 q=uv-vec2(0.,s);
o=vec4(at(q-vec2(0.,sp)).r,at(q).g,at(q+vec2(0.,sp)).b,1);}`;
/** CSS tokens read into uniforms, with what stands in for a missing one. The bend paints no ground; the lid reads it by convention. */
const TOKENS: readonly (readonly [uniform: string, property: string, fallback: string])[] = [['uGround', '--ground', '#fff']];
/** What drives it: the page's speed, for every picture on screen at once. */
const TRIGGER: 'pointer' | 'scroll' = 'scroll';
const clamp = (value: number, low: number, high: number): number => Math.min(high, Math.max(low, value));
/** The smoothed value one lid carries: the trail, -1 to 1. */
export interface State {
v: number;
}
const makeState = (): State => ({ v: 0 });
/**
* One step of an exponential approach: the value moves toward the
* target by the share `1 - e^(-dt / tau)`, so two steps of 16 ms are
* one of 32, and it never passes the target. A time constant of 0 is a
* jump; a step of 0 or none is no move.
*/
export function approach(value: number, target: number, dt: number, tau: number): number {
if (!(dt > 0)) return value;
if (!(tau > 0)) return target;
return value + (target - value) * (1 - Math.exp(-dt / tau));
}
/** The trail a speed asks for: the speed less the dead zone, as a share of the speed at which it is full, clamped to one either way, keeping its sign. */
export function speedTarget(speed: number, deadzone: number, fullAt: number): number {
if (!Number.isFinite(speed)) return 0;
const over = Math.max(0, Math.abs(speed) - Math.max(0, deadzone));
const target = clamp((Math.sign(speed) * over) / Math.max(1, fullAt - Math.max(0, deadzone)), -1, 1);
return target === 0 ? 0 : target;
}
/** At rest: the page is under the dead zone and the largest trail is under half a device pixel of the lid's height. */
export function restOf(state: State, speed: number, o: Options, height: number): boolean {
return Math.abs(speed) <= o.deadzone && Math.abs(state.v) * clamp(o.amount, 0, 0.2) * height < 0.5;
}
/** The page moving faster than the dead zone starts it. */
const wants = (input: Input, o: Options): boolean => Math.abs(input.speed) > o.deadzone;
/** The trail takes hold with the attack and lets go with the release. Returns true at rest. */
export function step(state: State, input: Input, dt: number, o: Options, _width: number, height: number): boolean {
const target = speedTarget(input.speed, o.deadzone, o.fullAt);
state.v = approach(state.v, target, dt, Math.abs(target) > Math.abs(state.v) ? o.attack : o.release);
return restOf(state, input.speed, o, height);
}
const uploadFrame = (gl: WebGL2RenderingContext, at: (name: string) => WebGLUniformLocation | null, state: State, o: Options): void => {
gl.uniform1f(at('uV'), state.v);
gl.uniform4f(at('uDial'), clamp(o.amount, 0, 0.2), clamp(o.curl, 0, 1), clamp(o.split, 0, 1), 0);
};
/**
* Named versions of the effect: sets of options that belong together.
* The first is the defaults. `mount(element, PRESETS.Sag)`.
*/
export const PRESETS: Record<string, Partial<Options>> = {
Bend: {},
Sag: { amount: 0.09, curl: 0.8 },
Shift: { curl: 0, amount: 0.035 },
Fringed: { split: 0.5 },
Lazy: { release: 700, attack: 150 },
Flick: { deadzone: 400, fullAt: 2500, amount: 0.07 },
};
// ---- HARNESS lid-driven: begin ----------------------------------------------
//
// Identical in every driven lid 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
// maxLive, maxPixelRatio and rescueMs), DEFAULTS, Handle, FRAGMENT,
// TOKENS, TRIGGER ('pointer' or 'scroll'), State and makeState, wants,
// step and uploadFrame.
//
// The lid, driven by input instead of a clock. The real `img` is never
// hidden and never moved. An effect is one opaque canvas laid over it,
// drawn from a copy of the image made off the main thread at the
// canvas's own pixel size, showing the plain picture when it goes on.
// It runs while the recipe's step says it is not at rest, and comes
// off then, and on every failure path. Contexts are pooled, because
// they are the scarce thing. The scroll position is read and never
// written.
const VERTEX = `#version 300 es
void main(){gl_Position=vec4(vec2(gl_VertexID<<1&2,gl_VertexID&2)*2.-1.,0,1);}`;
const NOOP: Handle = { update() {}, destroy() {} };
const mounted = new WeakMap<HTMLElement, Handle>();
const CEILING = 6;
const IDLE_MS = 4000;
/** The longest step the smoothing is asked to take, in ms: a tab that was hidden does not jump. */
const MAX_DT = 50;
/** What the harness collects for a recipe: the pointer in the picture's UV with y down, whether it is present, and the page's signed speed in CSS px a second. */
export interface Input {
x: number;
y: number;
present: boolean;
speed: number;
}
/** A pooled canvas with its context and the recipe's program. */
export interface Entry {
canvas: HTMLCanvasElement;
gl: WebGL2RenderingContext;
program: WebGLProgram;
locations: Map<string, WebGLUniformLocation | null>;
texture: WebGLTexture;
linkedAt: number;
busy: boolean;
idleTimer: number;
}
/** One image, from mount on. */
export interface Lid {
img: HTMLImageElement;
parent: HTMLElement;
bitmap: ImageBitmap | null;
preparing: string | null;
near: boolean;
/** Plain for good: its upload threw. */
done: boolean;
entry: Entry | null;
/** An entry fading off; taken back if the lid is wanted again before the fade ends. */
fading: Entry | null;
fade: Animation | null;
rescue: Animation | null;
state: State;
input: Input;
/** Covers tried while a fresh entry's program was still linking. */
tries: number;
/** When the pointer last left it: the lid left longest ago gives up its canvas first. */
leftAt: number;
}
const pool: Entry[] = [];
let webglOff = false;
const queue: (() => void)[] = [];
let draining = 0;
/** The lid's backing size: the box in device pixels, capped by a ratio snapped to a whole fraction of the device's. */
export function lidSize(cssWidth: number, cssHeight: number, pixelRatio: number, maxPixelRatio: number): { width: number; height: number; ratio: number } {
const dpr = Math.max(0.5, pixelRatio || 1);
let n = 1;
while (dpr / n > Math.max(0.25, maxPixelRatio) + 1e-9) n += 1;
const ratio = dpr / n;
return { width: Math.max(1, Math.round(cssWidth * ratio)), height: Math.max(1, Math.round(cssHeight * ratio)), ratio };
}
/** The part of the source that object-fit: cover shows in a box of this shape, 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 };
}
/**
* Whether a group of this size can all have lids: no more than the cap
* may be live, and what is not idle has to be made under the cap.
*/
export function groupCovered(size: number, idle: number, live: number, maxLive: number): boolean {
const cap = Math.min(CEILING, Math.max(1, Math.round(maxLive)));
const slots = Math.max(0, cap - live);
const creatable = Math.max(0, cap - idle - live);
return size <= Math.min(slots, idle + creatable);
}
/** A client point as the picture's UV, y down. */
export function pointerUv(point: { clientX: number; clientY: number }, box: { left: number; top: number; width: number; height: number }): [number, number] {
return [(point.clientX - box.left) / Math.max(1, box.width), (point.clientY - box.top) / Math.max(1, box.height)];
}
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])];
}
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;
}
const out = merged as unknown as Options;
out.maxLive = Math.min(CEILING, Math.max(1, Math.round(out.maxLive)));
return out;
}
/** One lid covered per frame, page-wide: covering costs 4 to 35 ms of main thread. */
const enqueue = (job: () => void): void => {
queue.push(job);
if (draining === 0) {
const drain = (): void => {
const next = queue.shift();
if (next) next();
draining = queue.length > 0 ? requestAnimationFrame(drain) : 0;
};
draining = requestAnimationFrame(drain);
}
};
/** A new pool entry: its program is linked now and asked about later. */
const make = (): Entry | null => {
if (webglOff) return null;
const canvas = document.createElement('canvas');
const gl = canvas.getContext('webgl2', { alpha: false, antialias: false, depth: false, stencil: false, powerPreference: 'low-power', failIfMajorPerformanceCaveat: true });
if (!gl) {
webglOff = true;
return null;
}
const 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 null;
gl.shaderSource(shader, source);
gl.compileShader(shader);
gl.attachShader(program, shader);
gl.deleteShader(shader);
}
gl.linkProgram(program);
const texture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, texture);
for (const [key, value] of [[gl.TEXTURE_MIN_FILTER, gl.LINEAR], [gl.TEXTURE_MAG_FILTER, gl.LINEAR], [gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE], [gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE]] as const) {
gl.texParameteri(gl.TEXTURE_2D, key, value);
}
canvas.setAttribute('aria-hidden', 'true');
const entry: Entry = { canvas, gl, program, locations: new Map(), texture, linkedAt: performance.now(), busy: false, idleTimer: 0 };
pool.push(entry);
return entry;
};
/** An idle entry, or a new one. */
const warm = (): Entry | null => pool.find((entry) => !entry.busy) ?? make();
const release = (entry: Entry): void => {
entry.busy = false;
// A lid comes off through a fade that fills forwards. Left on the
// canvas, it held the canvas at opacity 0 for the next picture the
// pool lent it to: a lid on, drawing, that nobody could see. Seen on
// the owner's bench with the recipe's own state on screen.
for (const animation of entry.canvas.getAnimations()) animation.cancel();
entry.canvas.remove();
clearTimeout(entry.idleTimer);
entry.idleTimer = window.setTimeout(() => {
if (entry.busy) return;
const at = pool.indexOf(entry);
if (at !== -1) pool.splice(at, 1);
entry.gl.deleteProgram(entry.program);
entry.gl.deleteTexture(entry.texture);
entry.gl.getExtension('WEBGL_lose_context')?.loseContext();
}, IDLE_MS);
};
const drop = (entry: Entry): void => {
entry.busy = false;
entry.canvas.remove();
clearTimeout(entry.idleTimer);
const at = pool.indexOf(entry);
if (at !== -1) pool.splice(at, 1);
};
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 createImageBitmap === 'undefined' ||
typeof IntersectionObserver === 'undefined' ||
typeof ResizeObserver === 'undefined'
) {
return NOOP;
}
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();
let options = mergeOptions(DEFAULTS, explicit);
const images = element instanceof HTMLImageElement ? [element] : Array.from(element.querySelectorAll('img'));
const lids: Lid[] = [];
for (const img of images) {
const parent = img.parentElement;
if (!parent || ['static', ''].includes(getComputedStyle(parent).position)) continue;
lids.push({ img, parent, bitmap: null, preparing: null, near: false, done: false, entry: null, fading: null, fade: null, rescue: null, state: makeState(), input: { x: 0.5, y: 0.5, present: false, speed: 0 }, tries: 0, leftAt: 0 });
}
const byImage = new Map(lids.map((lid) => [lid.img, lid]));
let done = false;
const size = (lid: Lid): { width: number; height: number; ratio: number } =>
lidSize(lid.img.clientWidth, lid.img.clientHeight, devicePixelRatio || 1, options.maxPixelRatio);
/** The bitmap for a lid: decoded, cropped to what object-fit shows, resized to the lid, off the main thread. */
const prepare = (lid: Lid): void => {
const img = lid.img;
if (!img.complete || img.naturalWidth === 0) return;
const { width, height } = size(lid);
const key = `${img.currentSrc}|${width}x${height}`;
if (lid.preparing === key) return;
if (lid.preparing === null && lid.bitmap && lid.bitmap.width === width && lid.bitmap.height === height) return;
lid.preparing = key;
const fit = getComputedStyle(img).objectFit;
const crop = fit === 'cover' ? coverCrop(img.naturalWidth, img.naturalHeight, img.clientWidth, img.clientHeight) : { sx: 0, sy: 0, sw: img.naturalWidth, sh: img.naturalHeight };
createImageBitmap(img, crop.sx, crop.sy, crop.sw, crop.sh, { resizeWidth: width, resizeHeight: height, resizeQuality: 'high' }).then(
(bitmap) => {
if (lid.preparing !== key || done) return bitmap.close();
lid.bitmap?.close();
lid.bitmap = bitmap;
lid.preparing = null;
// Enough entries for the images that are near, up to the cap,
// so that none has to be made at the moment of covering.
const wanted = Math.min(options.maxLive, lids.filter((item) => item.near && !item.done).length);
while (pool.length < wanted && make()) {
// one entry a call, until the pool holds enough
}
// A pointer that arrived before the bitmap was ready is still there.
if (TRIGGER === 'pointer' && lid.input.present && wants(lid.input, options)) ask(lid);
},
() => {
lid.preparing = null;
},
);
};
const forget = (lid: Lid): void => {
lid.bitmap?.close();
lid.bitmap = null;
lid.preparing = null;
};
const tokens = (canvas: HTMLCanvasElement, gl: WebGL2RenderingContext, at: (name: string) => WebGLUniformLocation | null): void => {
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 = '';
};
/**
* Ends one lid. At rest it fades off on the compositor and its entry
* goes back to the pool; at a stop or a destroy it goes back at once;
* a lost context is dropped from the pool.
*/
const off = (lid: Lid, how: 'fade' | 'now' | 'drop' = 'fade'): void => {
lid.rescue?.cancel();
lid.rescue = null;
const entry = lid.entry;
lid.entry = null;
lid.state = makeState();
if (!entry) return;
if (how === 'drop') return drop(entry);
if (how === 'now') return release(entry);
entry.canvas.dataset.state = 'off';
lid.fading = entry;
lid.fade = entry.canvas.animate([{ opacity: 1 }, { opacity: 0 }], { duration: 120, fill: 'forwards' });
lid.fade.onfinish = () => {
if (lid.fading === entry) {
lid.fading = null;
lid.fade = null;
}
release(entry);
};
};
/** A fading entry wanted again is taken back whole, so no image carries two canvases. */
const reclaim = (lid: Lid): void => {
const entry = lid.fading;
if (!entry) return;
lid.fade?.cancel();
lid.fade = null;
lid.fading = null;
release(entry);
};
// --- the pointer: its last known place, and whether it is known at all
// Presence over a picture is derived from this every frame, never from
// an enter and leave pair: a leave that never came (a drag, a lost
// window, a frame covered by the bench) kept a lid present for good.
const pointer = { x: 0, y: 0, known: false };
const inside = (lid: Lid): boolean => {
if (!pointer.known) return false;
const box = lid.parent.getBoundingClientRect();
return pointer.x >= box.left && pointer.x < box.right && pointer.y >= box.top && pointer.y < box.bottom && box.width > 0;
};
/** Reads the pointer against one picture: where it is in the picture, and whether it is there at all. */
const sense = (lid: Lid, now: number): void => {
const was = lid.input.present;
const here = inside(lid);
lid.input.present = here;
if (here) {
const [x, y] = pointerUv({ clientX: pointer.x, clientY: pointer.y }, lid.img.getBoundingClientRect());
lid.input.x = x;
lid.input.y = y;
if (!lid.near) {
lid.near = true;
prepare(lid);
}
if (TRIGGER === 'pointer' && !lid.entry && wants(lid.input, options)) ask(lid);
} else if (was) {
lid.leftAt = now;
}
};
// --- the loop: one for the mount, while anything is live or the page moves
let ticking = 0;
let lastTick = 0;
let wake = 0;
let scrollAt = 0;
let scrollY0 = 0;
let speed = 0;
const drawOne = (lid: Lid, dt: number): void => {
const entry = lid.entry;
if (!entry) return;
const gl = entry.gl;
const at = (name: string): WebGLUniformLocation | null => {
if (!entry.locations.has(name)) entry.locations.set(name, gl.getUniformLocation(entry.program, name));
return entry.locations.get(name) ?? null;
};
lid.input.speed = speed;
const rest = step(lid.state, lid.input, dt, options, entry.canvas.width, entry.canvas.height);
uploadFrame(gl, at, lid.state, options);
gl.drawArrays(gl.TRIANGLES, 0, 3);
if (lid.rescue) lid.rescue.currentTime = 0;
if (rest) off(lid);
};
const tick = (now: number): void => {
ticking = 0;
if (done) return;
const dt = Math.min(MAX_DT, lastTick ? now - lastTick : 0);
lastTick = now;
// Speed from the scroll events themselves, so a flick after a long
// rest is measured from its first two events, not from the rest.
if (now - scrollAt > 120) speed = 0;
if (TRIGGER === 'pointer') for (const lid of lids) sense(lid, now);
let live = 0;
for (const lid of lids) {
if (!lid.entry) continue;
live += 1;
try {
drawOne(lid, dt);
} catch {
off(lid, 'now');
}
}
if (TRIGGER === 'scroll' && live === 0 && wants({ x: 0.5, y: 0.5, present: false, speed }, options)) askVisible();
const resting = TRIGGER === 'pointer' && lids.some((lid) => lid.input.present && !lid.entry && !lid.done);
if (live > 0 || resting || now - wake < 200 || queue.length > 0) ticking = requestAnimationFrame(tick);
else lastTick = 0;
};
const run = (): void => {
wake = performance.now();
if (!ticking) ticking = requestAnimationFrame(tick);
};
/** Puts the lid on: upload, uniforms, first frame at identity, insert, rescue. Anything thrown leaves the image plain. */
const cover = (lid: Lid): void => {
if (done || lid.done || lid.entry || !lid.bitmap) return;
reclaim(lid);
const entry = warm();
if (!entry) return;
const gl = entry.gl;
// Asked only now, at least two frames after linking: the entry was
// warmed when the image was near. One made just now is given a few
// frames before it is asked.
if (performance.now() - entry.linkedAt < 34) {
lid.tries += 1;
if (lid.tries < 12) enqueue(() => cover(lid));
return;
}
lid.tries = 0;
if (!gl.getProgramParameter(entry.program, gl.LINK_STATUS)) return;
try {
entry.busy = true;
clearTimeout(entry.idleTimer);
for (const animation of entry.canvas.getAnimations()) animation.cancel();
lid.entry = entry;
lid.state = makeState();
const { width, height } = size(lid);
entry.canvas.width = width;
entry.canvas.height = height;
entry.canvas.style.cssText = `position:absolute;left:${lid.img.offsetLeft}px;top:${lid.img.offsetTop}px;width:${lid.img.offsetWidth}px;height:${lid.img.offsetHeight}px;pointer-events:none;border-radius:${getComputedStyle(lid.img).borderRadius};visibility:hidden;image-rendering:pixelated`;
entry.canvas.dataset.state = 'running';
lid.img.after(entry.canvas);
gl.viewport(0, 0, width, height);
gl.useProgram(entry.program);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, entry.texture);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, lid.bitmap);
const at = (name: string): WebGLUniformLocation | null => {
if (!entry.locations.has(name)) entry.locations.set(name, gl.getUniformLocation(entry.program, name));
return entry.locations.get(name) ?? null;
};
gl.uniform1i(at('uImage'), 0);
gl.uniform2f(at('uResolution'), width, height);
// In the document, hidden, so the tokens have a computed value.
tokens(entry.canvas, gl, at);
// The first frame is the plain picture: the state is at rest.
uploadFrame(gl, at, lid.state, options);
gl.drawArrays(gl.TRIANGLES, 0, 3);
entry.canvas.style.visibility = '';
lid.rescue = entry.canvas.animate([{ opacity: 1 }, { opacity: 0 }], { delay: Math.max(500, options.rescueMs), duration: 200, fill: 'forwards' });
lid.rescue.onfinish = () => off(lid);
run();
} catch {
// A cross-origin image throws on upload: this image is plain for good.
lid.done = true;
off(lid, 'now');
}
};
/**
* One image asked for, by the pointer: covered if the pool has room.
* When it has none, a lid whose pointer has left is only letting go,
* and the one left longest ago gives its canvas to the picture the
* pointer is on now: a hover outranks a release tail. Seen on the
* bench with a cap of two: the third card in a sweep got nothing.
*/
const ask = (lid: Lid): void => {
if (done || lid.done || lid.entry || !lid.bitmap) return;
const idle = pool.filter((entry) => !entry.busy).length;
const live = pool.filter((entry) => entry.busy).length;
if (!groupCovered(1, idle, live, options.maxLive)) {
const victim = lids.filter((item) => item.entry && !item.input.present).sort((a, b) => a.leftAt - b.leftAt)[0];
if (!victim) return;
off(victim, 'now');
}
enqueue(() => cover(lid));
};
/** Every image on screen asked for together, by the page's speed: all of them, or none. */
const askVisible = (): void => {
const visible = lids.filter((lid) => {
if (lid.done || lid.entry || !lid.bitmap) return false;
const box = lid.img.getBoundingClientRect();
return box.bottom > 0 && box.top < innerHeight && box.height > 0;
});
if (visible.length === 0) return;
const idle = pool.filter((entry) => !entry.busy).length;
const live = pool.filter((entry) => entry.busy).length;
if (!groupCovered(visible.length, idle, live, options.maxLive)) return;
for (const lid of visible) enqueue(() => cover(lid));
};
// --- the input: the pointer anywhere in the document, mouse and pen only; the page's speed, read only
const onPointer = (event: PointerEvent): void => {
if (event.pointerType === 'touch') return;
if (event.type === 'pointercancel' || (event.type === 'pointerout' && event.relatedTarget === null)) {
pointer.known = false;
} else {
pointer.x = event.clientX;
pointer.y = event.clientY;
pointer.known = true;
}
if (TRIGGER === 'pointer') run();
};
const onGone = (): void => {
pointer.known = false;
if (TRIGGER === 'pointer') run();
};
const onScroll = (): void => {
const now = performance.now();
const y = scrollY;
// The speed between this event and the last, smoothed over about
// 60 ms of events: a single event whose timing disagrees with the
// frame it came from reads as a spike otherwise, and a reading
// scroll crawled past the dead zone once in a check.
if (scrollAt && now - scrollAt < 200 && now > scrollAt) {
const instant = ((y - scrollY0) / (now - scrollAt)) * 1000;
speed += (instant - speed) * (1 - Math.exp(-(now - scrollAt) / 60));
}
scrollAt = now;
scrollY0 = y;
if (TRIGGER === 'scroll') run();
};
const nearness = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
const lid = byImage.get(entry.target as HTMLImageElement);
if (!lid) continue;
lid.near = entry.isIntersecting;
if (lid.near) prepare(lid);
else if (!lid.entry) forget(lid);
}
},
{ rootMargin: '60%' },
);
const sizes = new ResizeObserver((entries) => {
for (const entry of entries) {
const lid = byImage.get(entry.target as HTMLImageElement);
if (!lid) continue;
// A resized image is a different lid: stop any running one, remake the bitmap.
if (lid.entry) off(lid);
if (lid.near) prepare(lid);
}
});
const onLoad = (event: Event): void => {
const lid = byImage.get(event.target as HTMLImageElement);
if (lid?.near) prepare(lid);
};
const stopAll = (): void => {
for (const lid of lids) {
if (lid.entry) off(lid, 'now');
reclaim(lid);
}
};
const onVisibility = (): void => {
if (document.visibilityState !== 'visible') {
pointer.known = false;
stopAll();
}
};
const onLost = (event: Event): void => {
const canvas = event.target as HTMLCanvasElement;
const lid = lids.find((item) => item.entry?.canvas === canvas);
if (lid) off(lid, 'drop');
const entry = pool.find((item) => item.canvas === canvas);
if (entry) drop(entry);
};
for (const lid of lids) {
nearness.observe(lid.img);
sizes.observe(lid.img);
lid.img.addEventListener('load', onLoad);
}
for (const type of ['pointermove', 'pointerdown', 'pointerup', 'pointerout', 'pointercancel'] as const) document.addEventListener(type, onPointer, { capture: true, passive: true });
addEventListener('blur', onGone);
addEventListener('scroll', onScroll, { passive: true });
addEventListener('beforeprint', stopAll);
document.addEventListener('visibilitychange', onVisibility);
document.addEventListener('webglcontextlost', onLost, true);
const handle: Handle = {
update(next) {
options = mergeOptions(options, next);
},
destroy() {
done = true;
cancelAnimationFrame(ticking);
ticking = 0;
nearness.disconnect();
sizes.disconnect();
for (const type of ['pointermove', 'pointerdown', 'pointerup', 'pointerout', 'pointercancel'] as const) document.removeEventListener(type, onPointer, { capture: true });
removeEventListener('blur', onGone);
removeEventListener('scroll', onScroll);
removeEventListener('beforeprint', stopAll);
document.removeEventListener('visibilitychange', onVisibility);
document.removeEventListener('webglcontextlost', onLost, true);
for (const lid of lids) {
lid.img.removeEventListener('load', onLoad);
if (lid.entry) off(lid, 'now');
reclaim(lid);
forget(lid);
}
if (mounted.get(element) === handle) mounted.delete(element);
},
};
mounted.set(element, handle);
return handle;
}
// ---- HARNESS lid-driven: end ------------------------------------------------