Catalog / Recipe
Plane press
Under the pointer a linked thumbnail swells slightly and is dragged along the pointer's motion, settles when it stops, and lets go when it leaves.
- Family
- plane
- Status
- lab
- Used for
- ListingCard grid
- Size
- 5,025 of 5,200 bytes, gzipped
mount(element)A hover that answers with material. The press is a soft Gaussian
around the pointer; inside it the picture is dragged along the
pointer’s motion and swells toward the pointer. It is the lid of
plane-arrive with a driver in place of a clock: the lid goes on
showing the plain picture, so putting it on is invisible, runs while
the pointer is there and the press is still letting go, and comes off
when nothing is displaced by half a device pixel.
Use
import { mount } from './plane-press';
mount(document.querySelector('.cards'));
Every img inside, in a positioned parent, is a target. The pointer
is read on that parent.
How it moves
The press follows the pointer with a time constant (follow); the
velocity is the smoothed motion; presence eases to 1 on arrival over
attack and to 0 after leaving over release. The research saw this
only in stills, where a bulge of 0.03 is almost invisible: motion
carries the effect, and the owner’s hand decides.
Options
| Option | Default | Meaning |
|---|---|---|
radius |
0.28 | Reach of the press, as a share of the picture’s short side |
drag |
0.06 | How far the picture is dragged along the motion |
bulge |
0.03 | How much it swells toward the pointer |
split |
0 | Colour split along the motion; a taste for photographs |
attack |
160 | How fast the press takes hold, in ms |
release |
450 | How fast it lets go, in ms |
follow |
90 | How closely it follows the pointer, in ms |
maxLive, maxPixelRatio, rescueMs |
3, 2, 3000 | As every lid recipe; three live lids: the one under the pointer, one letting go, one spare |
Versions
| Version | What it is |
|---|---|
| Press | The defaults |
| Drag | Mostly drag, almost no swell |
| Lens | Mostly swell, a wider reach |
| Fringed | A colour split along the motion |
| Snappy | Takes hold and lets go faster, closer to the cursor |
None of them has been judged in motion on a real page.
Where it must not go
- Any picture a reader inspects: a figure, a gallery image, a diagram, a screenshot. The pointer rests on exactly the thing being read, and this would distort it there. That is the evidence rule applied to hover.
- Touch. There is no hover; the recipe ignores touch pointers.
- A thumbnail that is not a link. A hover that answers “about to open” on something that does not open is a lie.
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 pointer that arrives before the bitmap is ready waits
for it; one that leaves before it is ready starts nothing.
Learned while building it
- The rest test is in pixels. Presence under a threshold left lids on for seconds showing nothing. The lid comes off when the largest displacement is under half a device pixel on the longer side.
- A lid wanted again while it is fading off is taken back whole. Otherwise a quick second hover gave one picture two canvases for a frame.
- A hover outranks a release tail. With a cap of two and a release that takes over a second to reach rest, a sweep across three cards left the third plain, and a card come back to while two others were letting go stayed plain. When the pool is full, the lid whose pointer left longest ago gives its canvas to the picture under the pointer.
- Prepare on approach. The bitmap is made when the picture is near (a 60 percent margin), so the first hover does not wait on it.
- A canvas lent again carries no old fade. The fade that takes a lid off fills forwards, and left on the canvas it held the next picture’s lid at opacity 0: the recipe’s own state said the lid was on and the pointer present, and the picture looked plain. This was the fault behind “it works until I leave the picture, then sometimes not”, and a check that counted canvases could not see it.
- Presence is read from the pointer’s position every frame, never from an enter and leave pair. A leave that never comes (a drag, a lost window, a frame covered by something) kept a lid present for good and its canvas out of the pool. The recipe keeps the pointer’s last known place, counts a cancel, a blur or an exit of the window as gone, and asks for a picture the pointer rests on every frame until it has its lid.
/**
* plane-press
*
* Under a mouse or a pen the picture swells slightly and is dragged
* along the pointer's motion, with a soft falloff; it settles when the
* pointer stops and lets go when it leaves. On a lid over the real
* `img`, which is never touched: the lid goes on showing the plain
* picture, so putting it on is invisible, and comes off when the
* pointer has gone and nothing is displaced by half a pixel.
*
* Copy this file. It imports nothing.
*
* import { mount } from './plane-press';
* mount(document.querySelector('.cards'));
*
* For listing thumbnails that are links, where a hover means "about to
* open" and the picture answers it. Never on a picture the reader
* inspects: the pointer rests on exactly the thing being read. Touch
* pointers start nothing.
*/
export interface Options {
/** How many lids may be live at once, 1 to 6. Three: the picture under the pointer, one letting go, one spare. A wanted picture takes the canvas of the lid left longest ago when none is free. */
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;
/** Reach of the press around the pointer, as a share of the picture's short side. Under 0.14 reads as a fault; at 0.5 the whole card slides. */
radius: number;
/** How far the picture is dragged along the pointer's motion. Over 0.14 is a smear. */
drag: number;
/** How much the picture swells under the pointer. 0.03 is felt in motion, not seen in a still; 0.1 is a fisheye. */
bulge: number;
/** Colour split along the motion, 0 to 0.5. Loud on type; a taste for photographs. */
split: number;
/** How fast the press takes hold when the pointer arrives, in ms. */
attack: number;
/** How fast it lets go when the pointer leaves, in ms. */
release: number;
/** How closely the press follows the pointer, in ms. Low is stuck to the cursor; high is syrupy. */
follow: number;
}
export const DEFAULTS: Options = {
maxLive: 3,
maxPixelRatio: 2,
rescueMs: 3000,
radius: 0.28,
drag: 0.06,
bulge: 0.03,
split: 0,
attack: 160,
release: 450,
follow: 90,
};
export interface Handle {
update(options: Partial<Options>): void;
destroy(): void;
}
// ---- the effect -------------------------------------------------------------
// `f` is a Gaussian around the pointer, in units of the picture's short
// side, scaled by presence. The picture is read dragged along the
// smoothed velocity and pulled toward the pointer, both by `f`; with a
// split the three channels are read at three offsets along the motion.
// At presence 0 every term is zero and the lid is the picture.
const FRAGMENT = `#version 300 es
precision highp float;
uniform sampler2D uImage;
uniform vec2 uResolution,uPointer,uVelocity;
uniform float uPresence;
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;
vec2 asp=vec2(uResolution.x/uResolution.y,1.);
vec2 d=(uv-uPointer)*asp;float r=uDial.x;
float f=exp(-dot(d,d)/(r*r))*uPresence;
vec2 q=uv-uVelocity*uDial.y*f-(uv-uPointer)*uDial.z*f,sp=uVelocity*uDial.w*f*.1;
o=vec4(at(q-sp).r,at(q).g,at(q+sp).b,1);}`;
/** CSS tokens read into uniforms, with what stands in for a missing one. The press 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 pointer over one picture at a time. */
const TRIGGER: 'pointer' | 'scroll' = 'pointer';
const clamp = (value: number, low: number, high: number): number => Math.min(high, Math.max(low, value));
/** The smoothed values one lid carries: where the press is, how fast it moves, how present it is, and its target. */
export interface State {
px: number;
py: number;
vx: number;
vy: number;
presence: number;
tx: number;
ty: number;
seen: boolean;
}
const makeState = (): State => ({ px: 0.5, py: 0.5, vx: 0, vy: 0, presence: 0, tx: 0.5, ty: 0.5, seen: false });
/**
* 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));
}
/** At rest: the pointer is gone, and the largest displacement is under half a device pixel on the lid's longer side. */
export function restOf(state: State, present: boolean, o: Options, side: number): boolean {
return !present && state.presence * (Math.hypot(state.vx, state.vy) * clamp(o.drag, 0, 0.5) + clamp(o.bulge, 0, 0.3)) * side < 0.5;
}
/** A pointer over the picture starts it. The harness has already dropped touch pointers. */
const wants = (input: Input, _o: Options): boolean => input.present;
/**
* The press follows its target with the `follow` time constant; the
* velocity is the smoothed motion in UV a second; presence eases to 1
* on arrival and to 0 after leaving. Returns true at rest.
*/
export function step(state: State, input: Input, dt: number, o: Options, width: number, height: number): boolean {
if (input.present) {
state.tx = input.x;
state.ty = input.y;
if (!state.seen) {
state.px = input.x;
state.py = input.y;
state.seen = true;
}
}
const nx = approach(state.px, state.tx, dt, o.follow);
const ny = approach(state.py, state.ty, dt, o.follow);
const seconds = dt > 0 ? dt / 1000 : 0;
state.vx = approach(state.vx, seconds > 0 ? (nx - state.px) / seconds : 0, dt, o.follow);
state.vy = approach(state.vy, seconds > 0 ? (ny - state.py) / seconds : 0, dt, o.follow);
state.px = nx;
state.py = ny;
state.presence = approach(state.presence, input.present ? 1 : 0, dt, input.present ? o.attack : o.release);
return restOf(state, input.present, o, Math.max(width, height));
}
const uploadFrame = (gl: WebGL2RenderingContext, at: (name: string) => WebGLUniformLocation | null, state: State, o: Options): void => {
gl.uniform2f(at('uPointer'), state.px, state.py);
gl.uniform2f(at('uVelocity'), state.vx, state.vy);
gl.uniform1f(at('uPresence'), state.presence);
gl.uniform4f(at('uDial'), clamp(o.radius, 0.05, 1), clamp(o.drag, 0, 0.5), clamp(o.bulge, 0, 0.3), clamp(o.split, 0, 1));
};
/**
* Named versions of the effect: sets of options that belong together.
* The first is the defaults. `mount(element, PRESETS.Lens)`.
*/
export const PRESETS: Record<string, Partial<Options>> = {
Press: {},
Drag: { drag: 0.12, bulge: 0.01 },
Lens: { bulge: 0.08, drag: 0.02, radius: 0.4 },
Fringed: { split: 0.25 },
Snappy: { attack: 100, release: 250, follow: 50 },
};
// ---- 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 ------------------------------------------------