Catalog / Recipe
Point lift
A picture arrives as a field of its own coloured dots that settle onto a grid and grow into square cells tiling it exactly; then the lid comes off onto the real img.
- Family
- point system
- Status
- passed
- Used for
- HeroFigurePage load
- Size
- 5,199 of 5,500 bytes, gzipped
mount(element)The family’s picture recipe, on the lid harness: the picture is
copied to a texture at the lid’s own size, the real img is never
touched, and taking the lid off is the rest state. One program draws
the page’s ground and then one point per grid cell over it. Each point
is a pure function of its index, the progress and the seed; at the end
every point is a square of exactly one cell sampling the picture at its
own pixels, so the last frame is the picture and the lid comes off
without a seam.
Use
import { mount } from './point-lift';
mount(document.querySelector('.hero'));
The element holds one or more imgs, each in a positioned parent. A
picture in view at mount arrives at once; one below the fold arrives
when it enters.
The lid
As every lid recipe: a bitmap at the lid’s size made off the main
thread, a pooled context, one cover a frame, a row whole or plain, a
rescue if script stalls, once per page load, and a cross-origin
picture left plain. See shader-lift.
How it moves
- The lid goes on as the ground with the dots already on it, each a little off its cell, in its cell’s own colour.
- Each dot sets off at its own moment within
spreadand eases to its cell’s centre. - Over the last tenth of its own run it grows from
dotof a cell to the whole cell, its disc squares off, and its colour turns from its cell’s centre to the picture under it. - At the end the lid is the picture; it fades off in 120 ms and the
imgis what is left.
Options
| Option | Default | What it does |
|---|---|---|
cell |
6 | One grid cell, in whole device pixels, 4 to 12 |
scatter |
0.15 | How far a dot starts from its cell, as a share of the lid’s shorter side |
dot |
0.45 | A dot in flight, as a share of the cell |
spread |
0.4 | The share of the run over which dots set off |
colour |
0 | 0 a dot carries its cell’s pixel; 1 it shows the picture under it now |
duration |
1200 | ms |
stagger |
70 | ms between the images of one group |
maxLive, maxPixelRatio, rescueMs, once |
6, 2, 3000, true | As every lid recipe |
Versions
| Version | What it is |
|---|---|
| Lift | The defaults |
| Loose | A wide scatter that trickles in |
| Fine | A fine screen |
| Coarse | A mosaic that barely moves |
| Window | The picture seen through a scattered screen |
Where it must not go
Not on a thumbnail, a screenshot, a diagram or a chart: while the dots are in flight anything smaller than a few cells is gone, and a reader who came for the picture waits for it. Not on a gallery or a listing, where that is multiplied by the row. The bench shows it on the gallery because the lid suite and the stills drive that fixture; that is not a recommendation.
What it does when things are missing
| Situation | What the reader sees |
|---|---|
| No WebGL2, a software renderer, no createImageBitmap | The picture, plain |
| A cross-origin picture | The picture, plain; never tried again |
| A row the pool cannot cover whole | The row, plain |
| Reduced motion, forced colours | The picture, plain; no bitmap is made |
| A stalled loop | The compositor takes the lid off inside the rescue delay |
| Context lost, print, hiding, resize, destroy | The picture, plain |
Learned while building it
- At 0.3 of the run 90% of the lid differs from the picture and 84% is the ground token: the dots are on a ground, as designed, and the check can tell a picture from a screen of its own colours.
- An even cell puts a point’s centre on a pixel edge and an odd one on
a pixel centre; both tile exactly, because the square of
cellpx centred there covers exactly the cell’s pixels either way. - The lid harness left a picture in view at mount plain, by the gallery
rule that an arrival is for what comes in, and this recipe’s own
use_forsays hero and page-load. The review caught the doc claiming otherwise. A lid recipe now declaresCOVERS_IN_VIEW; this one says true, the two gallery recipes keep false, and the browser check mounts with the gallery on screen and expects lids on it here and none undershader-lift. - The handover check first resized the whole picture for its reference
while the lid crops to what
object-fit: covershows, a 1.29% baseline that left little room under the 2% threshold. It now crops with the recipe’s owncoverCrop, measures the editor tile (small type), the last cell’s column and row at the edges separately, and the threshold is 0.5%.
/**
* point-lift
*
* A picture arrives as a field of its own coloured dots, scattered a
* little off a grid, which settle onto the grid and grow into square
* cells that tile the picture exactly; then the lid comes off onto the
* real `img`, which was there all along and is never touched.
*
* Copy this file. It imports nothing.
*
* import { mount } from './point-lift';
* mount(document.querySelector('.hero'));
*
* For a hero, a decorative figure, an arrival. Not for a thumbnail or
* any picture that is evidence: while the dots are in flight, anything
* smaller than a few cells is gone. Same-origin images only. Without
* WebGL2 nothing is added.
*/
export interface Options {
/** How long the arrival takes, in ms. */
duration: number;
/** Delay between the images of one group, in ms. */
stagger: number;
/** How many lids may be live at once, 1 to 6. A group larger than this arrives plain. */
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;
/** Arrive once per page load, the first time each image is seen. */
once: boolean;
/** One cell of the grid, in whole device pixels, 4 to 12. The count is the lid's area over its square. */
cell: number;
/** How far a dot starts from its cell, as a share of the lid's shorter side, 0 to 0.4. Above about 0.3 the picture is gone mid-run. */
scatter: number;
/** A dot in flight, as a share of the cell, 0.3 to 0.9. Small reads as a screen, large as a mosaic. */
dot: number;
/** Share of the run over which dots set off, 0 to 0.8. Zero is a rigid move. */
spread: number;
/** What a dot shows: 0 its own pixel, carried from its cell; 1 the picture under where it is now. */
colour: number;
}
export const DEFAULTS: Options = {
duration: 1200,
stagger: 70,
maxLive: 6,
maxPixelRatio: 2,
rescueMs: 3000,
once: true,
cell: 6,
scatter: 0.15,
dot: 0.45,
spread: 0.4,
colour: 0,
};
export interface Handle {
update(options: Partial<Options>): void;
destroy(): void;
}
// ---- the effect -------------------------------------------------------------
// One program, two passes. Pass 0 is the full-screen triangle, drawn as
// the page's ground. Pass 1 is one point per grid cell: its target is
// the cell's centre, its start the target plus a hashed offset, its
// moment and its ease its own. Over the last tenth of its own run `vS`
// goes 0 to 1: the point grows from a dot to the full cell, and the
// fragment turns from a disc to a square and from the cell's colour to
// the picture under it. At the end every cell is a square sampling the
// picture at its own pixels, which is the picture. The end target is
// held inside the buffer so a partial edge cell is not a point a driver
// may drop; its square overlaps the cell before it, which the window
// colour makes harmless.
const VERTEX = `#version 300 es
precision highp float;precision highp int;
uniform sampler2D uImage;
uniform vec2 uResolution;
uniform float uProgress,uSeed,uPass;
uniform vec4 uDial,uGrid;
out vec3 vHome;out float vS;
uint hh(uint x){x^=x>>16u;x*=0x7feb352du;x^=x>>15u;x*=0x846ca68bu;x^=x>>16u;return x;}
float h(uint i,uint k){return float(hh(hh(i+uint(uSeed*65535.))+k*0x9e3779b9u))/4294967295.;}
void main(){
if(uPass<.5){gl_Position=vec4(vec2(gl_VertexID<<1&2,gl_VertexID&2)*2.-1.,0,1);gl_PointSize=1.;vS=0.;vHome=vec3(0);return;}
int i=gl_VertexID;uint u=uint(i);
float cell=uDial.x;int cols=int(uGrid.x);
vec2 t=(vec2(float(i%cols),float(i/cols))+.5)*cell;
t=clamp(t,vec2(cell*.5),uResolution-cell*.5);
float a=h(u,0u)*6.2832,r=h(u,1u)*uDial.y*min(uResolution.x,uResolution.y);
vec2 s=t+vec2(cos(a),sin(a))*r;
float sp=uDial.w,e=clamp((uProgress-h(u,2u)*sp)/max(1.-sp,.001),0.,1.);
e=1.-pow(1.-e,3.);
vS=smoothstep(.9,1.,e);
gl_Position=vec4(mix(s,t,e)/uResolution*2.-1.,0,1);
gl_PointSize=mix(uDial.z*cell,cell,vS);
vec2 uv=t/uResolution;uv.y=1.-uv.y;
vHome=texture(uImage,uv).rgb;
}`;
const FRAGMENT = `#version 300 es
precision highp float;
uniform sampler2D uImage;
uniform vec2 uResolution,uDirection;
uniform float uProgress,uSeed,uPass;
uniform vec3 uGround,uShade,uTint;
uniform vec4 uDial,uGrid;
in vec3 vHome;in float vS;
out vec4 o;
void main(){
if(uPass<.5){o=vec4(uGround,1);return;}
float m=mix(1.-smoothstep(.45,.5,length(gl_PointCoord-.5)),1.,vS);
if(m<.01)discard;
vec2 uv=gl_FragCoord.xy/uResolution;uv.y=1.-uv.y;
vec3 win=texture(uImage,uv).rgb;
vec3 col=uGrid.z>.5?win:mix(vHome,win,vS);
o=vec4(col*m,m);
}`;
/** CSS tokens read into uniforms, with what stands in for a missing one. Only the ground is used here. */
const TOKENS: readonly (readonly [uniform: string, property: string, fallback: string])[] = [
['uGround', '--ground', '#fff'],
['uShade', '--ink', '#000'],
['uTint', '--ground', '#fff'],
];
/** The lid starts as the ground with the dots already on it: the picture arrives. */
const FIRST = 'ground' as const;
void FIRST;
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);
/** The most points a lid draws; above it the cell grows. */
const MAX_POINTS = 100000;
export interface Dials {
cell: number;
scatter: number;
dot: number;
spread: number;
colour: number;
}
/** Every dial inside the range the shader survives. */
export function dials(o: Options): Dials {
return {
cell: whole(o.cell, 4, 12),
scatter: clamp(o.scatter, 0, 0.4),
dot: clamp(o.dot, 0.3, 0.9),
spread: clamp(o.spread, 0, 0.8),
colour: whole(o.colour, 0, 1),
};
}
/** The grid over a lid of `width` by `height` buffer pixels: one point a cell, partial cells counted, never over the point ceiling. */
export function gridOf(width: number, height: number, cell: number): { cols: number; rows: number; count: number; cell: number } {
const w = Math.max(1, width), h = Math.max(1, height);
let size = Math.max(1, Math.round(cell));
for (;;) {
const cols = Math.ceil(w / size), rows = Math.ceil(h / size);
if (cols * rows <= MAX_POINTS) return { cols, rows, count: cols * rows, cell: size };
size += 1;
}
}
/** The effect's own uniforms, made safe: the panel can send any number. */
const uploadDials = (gl: WebGL2RenderingContext, at: (name: string) => WebGLUniformLocation | null, o: Options): void => {
const d = dials(o);
gl.uniform4f(at('uDial'), d.cell, d.scatter, d.dot, d.spread);
};
/** The two tones are not used; the ground is. */
const tonesOf = (_o: Options): [shade: string, tint: string] => ['--ink', '--ground'];
/** The entering edge is not used: the scatter has no direction. */
const directionFor = (_o: Options, entered: [number, number]): [number, number] => entered;
/** The ground as a triangle, then one point per cell over it, blended. The grid follows the buffer, so a remade lid gets its own. */
const draws: Draws = (gl, at, o) => {
const d = dials(o);
const grid = gridOf(gl.drawingBufferWidth, gl.drawingBufferHeight, d.cell);
if (grid.cell !== d.cell) gl.uniform4f(at('uDial'), grid.cell, d.scatter, d.dot, d.spread);
gl.uniform4f(at('uGrid'), grid.cols, grid.rows, d.colour, 0);
gl.uniform1f(at('uPass'), 0);
gl.disable(gl.BLEND);
gl.drawArrays(gl.TRIANGLES, 0, 3);
gl.uniform1f(at('uPass'), 1);
gl.enable(gl.BLEND);
gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
gl.drawArrays(gl.POINTS, 0, grid.count);
};
/** A picture in view at mount arrives too: this is a hero's recipe, and a hero is on screen at load. */
const COVERS_IN_VIEW = true;
/**
* Named versions of the effect: sets of options that belong together.
* The first is the defaults. `mount(element, PRESETS.Loose)`.
*/
export const PRESETS: Record<string, Partial<Options>> = {
Lift: {},
Loose: { scatter: 0.3, dot: 0.35, spread: 0.6 },
Fine: { cell: 4, dot: 0.6 },
Coarse: { cell: 10, dot: 0.7, scatter: 0.08 },
Window: { colour: 1, scatter: 0.2 },
};
// ---- HARNESS lid: begin -----------------------------------------------------
//
// Identical in every 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
// duration, stagger, maxLive, maxPixelRatio, rescueMs and once),
// DEFAULTS, Handle, VERTEX, FRAGMENT, TOKENS, FIRST, uploadDials, tonesOf,
// directionFor, draws (the draw call, given the context, the uniform
// locations and the options; most recipes draw one triangle) and
// COVERS_IN_VIEW (whether a picture in view at mount arrives, or is
// left plain as a gallery's are).
//
// The real `img` is never hidden and never moved. An effect is one
// opaque canvas laid over it for as long as the effect runs, drawn from
// a copy of the image made off the main thread at the canvas's own
// pixel size. Taking the canvas off is the rest state and every failure
// path. Contexts are pooled, because they are the scarce thing.
/** The draw call, given the context, the uniform locations and the options. */
type Draws = (gl: WebGL2RenderingContext, at: (name: string) => WebGLUniformLocation | null, options: Options) => void;
const NOOP: Handle = { update() {}, destroy() {} };
const mounted = new WeakMap<HTMLElement, Handle>();
const CEILING = 6;
const IDLE_MS = 4000;
/** 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 to its lift. */
export interface Lid {
img: HTMLImageElement;
parent: HTMLElement;
bitmap: ImageBitmap | null;
preparing: string | null;
near: boolean;
done: boolean;
entry: Entry | null;
clock: Animation | null;
rescue: Animation | null;
frame: number;
direction: [number, number];
/** Covers tried while a fresh entry's program was still linking. */
tries: number;
/** Has entered the viewport and is waiting for a lid. */
entered: boolean;
/** Its bitmap came after it entered, so the lid goes on faded, not cut. */
late: boolean;
}
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. Idle
* entries left by an earlier mount with a larger cap do not raise it.
*/
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);
}
/** The edge an image entered by: from the top when it rose from below, from the bottom when it came down. In UV with y down. */
export function enterDirection(box: { top: number; bottom: number }, root: { top: number; bottom: number }): [number, number] {
if (box.top < root.top && box.bottom <= root.bottom) return [0, -1];
return [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])];
}
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, clock: null, rescue: null, frame: 0, direction: [0, 1], tries: 0, entered: false, late: false });
}
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
}
// An image that entered before its bitmap was ready has been
// waiting: it is covered now, late, rather than left the one
// plain tile among treated neighbours. On a slower machine or a
// 2x screen some of a row's bitmaps come after a fast scroll in.
if (lid.entered && !lid.entry && !lid.done) {
lid.late = true;
regroup(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 [shade, tint] = tonesOf(options);
const probe = canvas.style;
for (const [uniform, property, fallback] of TOKENS) {
const read = uniform === 'uShade' ? shade : uniform === 'uTint' ? tint : property;
// 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(${read}, ${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 the end of an effect 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, so the next mount finds it warm and no context is left
* behind; a lost context is dropped from the pool.
*/
const off = (lid: Lid, how: 'fade' | 'now' | 'drop' = 'fade'): void => {
cancelAnimationFrame(lid.frame);
lid.frame = 0;
lid.clock?.cancel();
lid.clock = null;
lid.rescue?.cancel();
lid.rescue = null;
const entry = lid.entry;
lid.entry = null;
if (!entry) return;
if (how === 'drop') return drop(entry);
if (how === 'now') return release(entry);
entry.canvas.dataset.state = 'off';
const fade = entry.canvas.animate([{ opacity: 1 }, { opacity: 0 }], { duration: 120, fill: 'forwards' });
fade.onfinish = () => release(entry);
};
const draw = (lid: Lid): void => {
lid.frame = 0;
const entry = lid.entry;
if (!entry || !lid.clock || done) 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;
};
// During a stagger's delay the progress is null: not yet begun, not done.
const progress = lid.clock.effect?.getComputedTiming().progress ?? 0;
gl.uniform1f(at('uProgress'), progress);
uploadDials(gl, at, options);
draws(gl, at, options);
if (lid.rescue) lid.rescue.currentTime = 0;
if (progress >= 1) {
lid.done = options.once;
off(lid);
} else {
lid.frame = requestAnimationFrame(() => draw(lid));
}
};
/** Puts the lid on: upload, uniforms, first frame, insert, clock, rescue. Anything thrown leaves the image plain. */
const cover = (lid: Lid, delay: number): void => {
if (done || lid.done || lid.entry || !lid.bitmap) return;
const entry = warm();
if (!entry) return;
const gl = entry.gl;
// Asked only now, at least two frames after linking, and never
// blocking in practice: 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, delay));
return;
}
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;
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);
gl.uniform1f(at('uSeed'), Math.random());
const direction = directionFor(options, lid.direction);
gl.uniform2f(at('uDirection'), direction[0], direction[1]);
// In the document, hidden, so the tokens have a computed value.
tokens(entry.canvas, gl, at);
gl.uniform1f(at('uProgress'), 0);
uploadDials(gl, at, options);
draws(gl, at, options);
entry.canvas.style.visibility = '';
// A lid that comes late goes over a picture the reader has seen: faded on, not cut.
if (lid.late) entry.canvas.animate([{ opacity: 0 }, { opacity: 1 }], { duration: 120 });
const easing = getComputedStyle(lid.img).getPropertyValue('--load-ease').trim() || 'cubic-bezier(0.4, 0, 0.2, 1)';
const timing = { duration: Math.max(1, options.duration), delay, fill: 'forwards' as const };
try {
lid.clock = entry.canvas.animate(null, { ...timing, easing });
} catch {
lid.clock = entry.canvas.animate(null, timing);
}
lid.rescue = entry.canvas.animate([{ opacity: 1 }, { opacity: 0 }], { delay: Math.max(500, options.rescueMs), duration: 200, fill: 'forwards' });
lid.rescue.onfinish = () => off(lid);
lid.frame = requestAnimationFrame(() => draw(lid));
} catch {
// A cross-origin image throws on upload: this image is plain for good.
lid.done = true;
off(lid, 'now');
}
};
// Images that enter in the same frame are grouped by row, and a row
// is all or none: the first row takes the lids there are, a later row
// that cannot be covered whole arrives plain. A row half treated was
// seen in the research and is worse than either.
// An image whose bitmap is not ready when it enters stays entered, and
// is grouped again the moment the bitmap comes.
// Entering does not wait on "near": the observers report in no
// promised order, and an iframe drops the margin, so a row jumped to
// was entered first, turned away, and never asked again.
let group: Lid[] = [];
let grouping = 0;
const arrive = (lid: Lid, box: DOMRectReadOnly, root: DOMRectReadOnly | null): void => {
if (lid.done || lid.entry) return;
lid.direction = enterDirection(box, root ?? { top: 0, bottom: innerHeight });
lid.entered = true;
lid.near = true;
prepare(lid);
regroup(lid);
};
// Once each: handed in again when its bitmap came, a row of three
// counted as six, over the cap, and was left plain for good.
const regroup = (lid: Lid): void => {
if (!group.includes(lid)) group.push(lid);
if (grouping) return;
grouping = requestAnimationFrame(() => {
grouping = 0;
const ready = group.filter((item) => item.bitmap && !item.done && !item.entry);
group = [];
const rows = new Map<number, Lid[]>();
for (const item of ready) {
const top = Math.round(item.img.getBoundingClientRect().top / 4);
rows.set(top, [...(rows.get(top) ?? []), item]);
}
let idle = pool.filter((entry) => !entry.busy).length;
let live = pool.filter((entry) => entry.busy).length;
let index = 0;
for (const top of [...rows.keys()].sort((a, b) => a - b)) {
const row = rows.get(top) ?? [];
if (!groupCovered(row.length, idle, live, options.maxLive)) {
for (const item of row) item.done = options.once;
continue;
}
for (const item of row) {
const at = index;
index += 1;
enqueue(() => cover(item, at * Math.max(0, options.stagger)));
}
const fromIdle = Math.min(idle, row.length);
idle -= fromIdle;
live += row.length;
}
});
};
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 forget(lid);
}
},
{ rootMargin: '60%' },
);
const entering = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
const lid = byImage.get(entry.target as HTMLImageElement);
if (!lid) continue;
if (entry.intersectionRatio >= 0.12) arrive(lid, entry.boundingClientRect, entry.rootBounds);
}
},
{ threshold: [0.12] },
);
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');
};
const onVisibility = (): void => {
if (document.visibilityState !== 'visible') 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);
};
// In view at mount is left plain unless the recipe says otherwise: an
// arrival is for what comes in, but a hero is on screen at load.
for (const lid of lids) {
const box = lid.img.getBoundingClientRect();
if (!COVERS_IN_VIEW && box.bottom > 0 && box.top < innerHeight && box.height > 0) lid.done = options.once;
nearness.observe(lid.img);
entering.observe(lid.img);
sizes.observe(lid.img);
lid.img.addEventListener('load', onLoad);
}
addEventListener('beforeprint', stopAll);
document.addEventListener('visibilitychange', onVisibility);
document.addEventListener('webglcontextlost', onLost, true);
const handle: Handle = {
update(next) {
options = mergeOptions(options, next);
},
destroy() {
done = true;
nearness.disconnect();
entering.disconnect();
sizes.disconnect();
cancelAnimationFrame(grouping);
grouping = 0;
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');
forget(lid);
}
if (mounted.get(element) === handle) mounted.delete(element);
},
};
mounted.set(element, handle);
return handle;
}
// ---- HARNESS lid: end -------------------------------------------------------