The frame parks in the middle and the colour changes instead: a gradient creeps round the colour wheel behind the poster, and the frame's glow travels with it. Every other animation moves the frame, so the still one reads as a rest between them rather than as a dead spot. The wash layer blends with mix-blend-mode: color, which keeps the poster's luminance and replaces only its hue. soft-light was the first try and barely shifted the colour at all against a poster already knocked back to 45% brightness. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Tg15tLAuT6NrCE1ZxKVgDB
1037 lines
31 KiB
JavaScript
1037 lines
31 KiB
JavaScript
// Frame motion. Everything here writes to #frameMover's transform and nothing
|
||
// else, so additional animation layers can be nested inside without conflict.
|
||
//
|
||
// An animation is `start(context) => stop()`. `context` carries the element to
|
||
// move and a capture handle (possibly null). `stop()` must undo everything the
|
||
// animation set up — timers, listeners, DOM — because the next animation takes
|
||
// over the same element immediately.
|
||
|
||
import {
|
||
FRAME_SCALE,
|
||
BOUNCE_SPEED,
|
||
CORNER_INTERVAL_SECONDS,
|
||
CORNER_MARGIN,
|
||
DRAG_FRAME_SCALE,
|
||
DRAG_MIN_SCALE,
|
||
DRAG_MAX_SCALE,
|
||
TRAIL_SPEED,
|
||
TRAIL_TURN_RATE,
|
||
TRAIL_SPACING,
|
||
TRAIL_LENGTH,
|
||
TRAIL_FADE_SECONDS,
|
||
WASH_CYCLE_SECONDS,
|
||
WASH_SPREAD,
|
||
WASH_STRENGTH,
|
||
AUTO_ANIMATION,
|
||
} from '/config.js';
|
||
|
||
const ASPECT = 9 / 16;
|
||
|
||
/**
|
||
* Frame size for a viewport: `scale` of the width, but never taller than
|
||
* `scale` of the height, so a wide screen does not get a letterbox strip.
|
||
*/
|
||
export function frameSize(viewportWidth, viewportHeight, scale = FRAME_SCALE) {
|
||
let width = viewportWidth * scale;
|
||
let height = width * ASPECT;
|
||
|
||
const maxHeight = viewportHeight * scale;
|
||
if (height > maxHeight) {
|
||
height = maxHeight;
|
||
width = height / ASPECT;
|
||
}
|
||
|
||
return { width, height };
|
||
}
|
||
|
||
/** Size the frame to the current viewport and report the size back. */
|
||
function applyFrameSize(element) {
|
||
const size = frameSize(window.innerWidth, window.innerHeight);
|
||
element.style.width = `${size.width}px`;
|
||
element.style.height = `${size.height}px`;
|
||
return size;
|
||
}
|
||
|
||
function place(element, x, y) {
|
||
element.style.transform = `translate3d(${x.toFixed(1)}px, ${y.toFixed(1)}px, 0)`;
|
||
}
|
||
|
||
// --- picker -------------------------------------------------------------
|
||
|
||
/**
|
||
* Shuffle bag over 0..count-1: every animation comes up exactly once per pass,
|
||
* so selection stays even, and a swap at the bag boundary keeps the same one
|
||
* from landing twice in a row. Pure apart from the injected RNG.
|
||
*/
|
||
export function createPicker(count, random = Math.random) {
|
||
let bag = [];
|
||
let last = -1;
|
||
|
||
function refill() {
|
||
bag = Array.from({ length: count }, (_, i) => i);
|
||
|
||
// Fisher–Yates.
|
||
for (let i = bag.length - 1; i > 0; i--) {
|
||
const j = Math.floor(random() * (i + 1));
|
||
[bag[i], bag[j]] = [bag[j], bag[i]];
|
||
}
|
||
|
||
// next() pops off the end, so bag[length-1] is served first. A repeat can
|
||
// only happen right here, at the seam between two bags.
|
||
if (bag.length > 1 && bag[bag.length - 1] === last) {
|
||
const j = Math.floor(random() * (bag.length - 1));
|
||
[bag[bag.length - 1], bag[j]] = [bag[j], bag[bag.length - 1]];
|
||
}
|
||
}
|
||
|
||
return {
|
||
next() {
|
||
if (count <= 1) return 0;
|
||
if (bag.length === 0) refill();
|
||
last = bag.pop();
|
||
return last;
|
||
},
|
||
};
|
||
}
|
||
|
||
// --- bounce -------------------------------------------------------------
|
||
|
||
/**
|
||
* Diagonal drift with reflection off the viewport edges. Pure state + step, so
|
||
* the physics can be exercised without a browser and without waiting on rAF.
|
||
*/
|
||
export function createBouncer({ x = 0, y = 0 } = {}) {
|
||
const bouncer = {
|
||
x,
|
||
y,
|
||
// A shallow diagonal reads better than 45°, and never retraces its path.
|
||
vx: BOUNCE_SPEED,
|
||
vy: BOUNCE_SPEED * 0.62,
|
||
|
||
step(dt, bounds) {
|
||
const maxX = Math.max(0, bounds.width);
|
||
const maxY = Math.max(0, bounds.height);
|
||
|
||
bouncer.x += bouncer.vx * dt;
|
||
bouncer.y += bouncer.vy * dt;
|
||
|
||
// Reflect, mirroring the overshoot back inside the viewport.
|
||
if (bouncer.x <= 0) {
|
||
bouncer.x = Math.min(-bouncer.x, maxX);
|
||
bouncer.vx = Math.abs(bouncer.vx);
|
||
} else if (bouncer.x >= maxX) {
|
||
bouncer.x = Math.max(maxX - (bouncer.x - maxX), 0);
|
||
bouncer.vx = -Math.abs(bouncer.vx);
|
||
}
|
||
|
||
if (bouncer.y <= 0) {
|
||
bouncer.y = Math.min(-bouncer.y, maxY);
|
||
bouncer.vy = Math.abs(bouncer.vy);
|
||
} else if (bouncer.y >= maxY) {
|
||
bouncer.y = Math.max(maxY - (bouncer.y - maxY), 0);
|
||
bouncer.vy = -Math.abs(bouncer.vy);
|
||
}
|
||
|
||
return bouncer;
|
||
},
|
||
};
|
||
|
||
return bouncer;
|
||
}
|
||
|
||
export function startBounce({ element }) {
|
||
let size = applyFrameSize(element);
|
||
const bouncer = createBouncer({
|
||
x: (window.innerWidth - size.width) / 2,
|
||
y: (window.innerHeight - size.height) / 2,
|
||
});
|
||
|
||
function bounds() {
|
||
return {
|
||
width: window.innerWidth - size.width,
|
||
height: window.innerHeight - size.height,
|
||
};
|
||
}
|
||
|
||
function measure() {
|
||
size = applyFrameSize(element);
|
||
|
||
const limit = bounds();
|
||
bouncer.x = Math.min(bouncer.x, Math.max(0, limit.width));
|
||
bouncer.y = Math.min(bouncer.y, Math.max(0, limit.height));
|
||
}
|
||
|
||
window.addEventListener('resize', measure);
|
||
|
||
let last = performance.now();
|
||
let handle = 0;
|
||
let stopped = false;
|
||
|
||
function frame(now) {
|
||
if (stopped) return;
|
||
|
||
// Clamped so a stalled tab does not teleport the frame on resume.
|
||
const dt = Math.min((now - last) / 1000, 0.05);
|
||
last = now;
|
||
|
||
bouncer.step(dt, bounds());
|
||
place(element, bouncer.x, bouncer.y);
|
||
handle = requestAnimationFrame(frame);
|
||
}
|
||
|
||
handle = requestAnimationFrame(frame);
|
||
|
||
return function stop() {
|
||
// The flag matters as much as the cancel: a frame already queued would
|
||
// otherwise fire once more and fight whatever animation comes next.
|
||
stopped = true;
|
||
cancelAnimationFrame(handle);
|
||
window.removeEventListener('resize', measure);
|
||
};
|
||
}
|
||
|
||
// --- corners ------------------------------------------------------------
|
||
|
||
// Clockwise from the top left.
|
||
const CORNERS = 4;
|
||
|
||
/** Top-left offset for a corner, inset by `margin` from the viewport edge. */
|
||
export function cornerPosition(index, size, viewport, margin) {
|
||
const right = index === 1 || index === 2;
|
||
const bottom = index === 2 || index === 3;
|
||
|
||
return {
|
||
x: right ? Math.max(margin, viewport.width - size.width - margin) : margin,
|
||
y: bottom ? Math.max(margin, viewport.height - size.height - margin) : margin,
|
||
};
|
||
}
|
||
|
||
// How many stills each corner holds, and how far apart they sit as a fraction
|
||
// of the frame size. Deep enough that a corner reads as a pile, shallow enough
|
||
// that the stack stays in its own quarter of the screen.
|
||
const CORNER_STACK = 3;
|
||
const CORNER_STACK_STEP = 0.09;
|
||
|
||
/**
|
||
* Top-left offset for the `depth`-th still of a corner: the same spot as the
|
||
* corner itself, nudged inward (toward the middle of the screen) once per
|
||
* depth. Successive stills therefore never land on top of each other — each
|
||
* one leaves the edges of the ones below it showing.
|
||
*/
|
||
export function cornerStackPosition(index, depth, size, viewport, margin) {
|
||
const at = cornerPosition(index, size, viewport, margin);
|
||
const right = index === 1 || index === 2;
|
||
const bottom = index === 2 || index === 3;
|
||
const step = depth * CORNER_STACK_STEP;
|
||
|
||
return {
|
||
x: at.x + (right ? -1 : 1) * step * size.width,
|
||
y: at.y + (bottom ? -1 : 1) * step * size.height,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* The video parks in one random corner and stays there; every few seconds a
|
||
* screenshot of it lands in the next of the three *other* corners, cycling
|
||
* clockwise. Because the video's own corner is never a slot, a still can never
|
||
* cover the video — and since the crop only ever covers the video's rect, a
|
||
* still never contains another still either.
|
||
*
|
||
* Each corner stacks CORNER_STACK stills at staggered offsets, and the newest
|
||
* shot always moves to the top of the layer, so a full corner still reads as
|
||
* something new arriving rather than as an unchanged picture.
|
||
*/
|
||
export function startCorners(context) {
|
||
const { element } = context;
|
||
const layer = document.getElementById('stills');
|
||
const home = Math.floor(Math.random() * CORNERS);
|
||
|
||
// The three non-home corners, in clockwise order starting after the video.
|
||
const slots = [1, 2, 3].map((step) => (home + step) % CORNERS);
|
||
|
||
// Slot-major: consecutive shots move to the next corner, and the depth only
|
||
// advances once every corner at the current depth has been filled.
|
||
const cells = [];
|
||
for (let depth = 0; depth < CORNER_STACK; depth += 1) {
|
||
for (const corner of slots) {
|
||
const img = document.createElement('img');
|
||
layer.appendChild(img);
|
||
cells.push({ img, corner, depth });
|
||
}
|
||
}
|
||
|
||
let next = 0;
|
||
let size;
|
||
|
||
function layout() {
|
||
size = applyFrameSize(element);
|
||
const viewport = { width: window.innerWidth, height: window.innerHeight };
|
||
const margin = Math.min(viewport.width, viewport.height) * CORNER_MARGIN;
|
||
|
||
const spot = cornerPosition(home, size, viewport, margin);
|
||
place(element, spot.x, spot.y);
|
||
|
||
for (const cell of cells) {
|
||
const at = cornerStackPosition(cell.corner, cell.depth, size, viewport, margin);
|
||
cell.img.style.width = `${size.width}px`;
|
||
cell.img.style.height = `${size.height}px`;
|
||
place(cell.img, at.x, at.y);
|
||
}
|
||
}
|
||
|
||
function shoot() {
|
||
// Read late: the capture handle may still be waiting on the operator to
|
||
// answer the screen-share prompt when this animation starts.
|
||
if (!context.capture) return;
|
||
|
||
const rect = element.getBoundingClientRect();
|
||
const shot = context.capture.grab(rect);
|
||
if (!shot) return;
|
||
|
||
const cell = cells[next];
|
||
cell.img.src = shot;
|
||
cell.img.classList.add('visible');
|
||
// Re-append so the freshest still paints over the rest of its pile.
|
||
layer.appendChild(cell.img);
|
||
next = (next + 1) % cells.length;
|
||
}
|
||
|
||
layout();
|
||
window.addEventListener('resize', layout);
|
||
const timer = setInterval(shoot, CORNER_INTERVAL_SECONDS * 1000);
|
||
|
||
return function stop() {
|
||
clearInterval(timer);
|
||
window.removeEventListener('resize', layout);
|
||
for (const cell of cells) cell.img.remove();
|
||
};
|
||
}
|
||
|
||
// --- trail --------------------------------------------------------------
|
||
|
||
// The frame glides along a curving path and drops a photograph of itself every
|
||
// so often. Each still stays where it was taken and fades, so the live video
|
||
// reads as the head of a comet whose tail is its own recent past.
|
||
|
||
/**
|
||
* Steering toward a waypoint at constant speed, with a cap on how fast the
|
||
* heading may change. The cap is the whole point: an instant turn would lay the
|
||
* stills out in straight dotted lines, and it is the curve that makes a tail
|
||
* read as one. Pure apart from the injected RNG, so the path can be exercised
|
||
* without a browser.
|
||
*/
|
||
export function createGlider({ x = 0, y = 0 } = {}, random = Math.random) {
|
||
// How close counts as arrived. Anything tighter than the turn radius
|
||
// (speed / turn rate) risks the glider circling a waypoint forever, unable to
|
||
// steer hard enough to land on it.
|
||
const reach = TRAIL_SPEED / TRAIL_TURN_RATE;
|
||
|
||
function normalise(angle) {
|
||
return Math.atan2(Math.sin(angle), Math.cos(angle));
|
||
}
|
||
|
||
const glider = {
|
||
x,
|
||
y,
|
||
heading: random() * Math.PI * 2,
|
||
target: null,
|
||
|
||
retarget(bounds) {
|
||
glider.target = {
|
||
x: random() * Math.max(0, bounds.width),
|
||
y: random() * Math.max(0, bounds.height),
|
||
};
|
||
return glider.target;
|
||
},
|
||
|
||
step(dt, bounds) {
|
||
const maxX = Math.max(0, bounds.width);
|
||
const maxY = Math.max(0, bounds.height);
|
||
|
||
if (!glider.target) glider.retarget(bounds);
|
||
if (Math.hypot(glider.target.x - glider.x, glider.target.y - glider.y) < reach) {
|
||
glider.retarget(bounds);
|
||
}
|
||
|
||
// Shortest way round to the bearing of the waypoint, then take at most
|
||
// this step's share of the turn rate.
|
||
const bearing = Math.atan2(glider.target.y - glider.y, glider.target.x - glider.x);
|
||
const turn = normalise(bearing - glider.heading);
|
||
glider.heading = normalise(
|
||
glider.heading + clamp(turn, -TRAIL_TURN_RATE * dt, TRAIL_TURN_RATE * dt),
|
||
);
|
||
|
||
glider.x += Math.cos(glider.heading) * TRAIL_SPEED * dt;
|
||
glider.y += Math.sin(glider.heading) * TRAIL_SPEED * dt;
|
||
|
||
// Reflect off the edges rather than clamping and sliding along them. The
|
||
// waypoint is always inside the bounds, so steering picks up from there
|
||
// and pulls the glider back inward instead of straight into the wall.
|
||
if (glider.x < 0 || glider.x > maxX) {
|
||
glider.x = clamp(glider.x, 0, maxX);
|
||
glider.heading = normalise(Math.PI - glider.heading);
|
||
}
|
||
if (glider.y < 0 || glider.y > maxY) {
|
||
glider.y = clamp(glider.y, 0, maxY);
|
||
glider.heading = normalise(-glider.heading);
|
||
}
|
||
|
||
return glider;
|
||
},
|
||
};
|
||
|
||
return glider;
|
||
}
|
||
|
||
export function startTrail(context) {
|
||
const { element } = context;
|
||
const layer = document.getElementById('stills');
|
||
|
||
let size = applyFrameSize(element);
|
||
const glider = createGlider({
|
||
x: (window.innerWidth - size.width) / 2,
|
||
y: (window.innerHeight - size.height) / 2,
|
||
});
|
||
|
||
// A ring buffer: the tail is a fixed number of images, and the oldest one is
|
||
// what the next drop reuses. `rect` doubles as the "currently showing" flag.
|
||
const cells = [];
|
||
for (let i = 0; i < TRAIL_LENGTH; i += 1) {
|
||
const img = document.createElement('img');
|
||
img.classList.add('trail');
|
||
layer.appendChild(img);
|
||
cells.push({ img, born: 0, rect: null });
|
||
}
|
||
|
||
let next = 0;
|
||
let travelled = 0;
|
||
|
||
function bounds() {
|
||
return {
|
||
width: window.innerWidth - size.width,
|
||
height: window.innerHeight - size.height,
|
||
};
|
||
}
|
||
|
||
function measure() {
|
||
size = applyFrameSize(element);
|
||
|
||
const limit = bounds();
|
||
glider.x = Math.min(glider.x, Math.max(0, limit.width));
|
||
glider.y = Math.min(glider.y, Math.max(0, limit.height));
|
||
}
|
||
|
||
/** True when the frame overlaps nothing that is still showing in the tail. */
|
||
function clearOfTail(rect) {
|
||
for (const cell of cells) {
|
||
const other = cell.rect;
|
||
if (!other) continue;
|
||
if (
|
||
rect.left < other.right &&
|
||
rect.right > other.left &&
|
||
rect.top < other.bottom &&
|
||
rect.bottom > other.top
|
||
) {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
return true;
|
||
}
|
||
|
||
/** Photograph the frame where it stands. Reports whether a still landed. */
|
||
function drop(now) {
|
||
// Read late: the capture handle may still be waiting on the operator to
|
||
// answer the screen-share prompt when this animation starts.
|
||
if (!context.capture) return false;
|
||
|
||
const rect = element.getBoundingClientRect();
|
||
|
||
// The capture is a crop of the tab, so an older still lying under the frame
|
||
// would be photographed into the new one. Skip this drop instead; the
|
||
// travelled distance is not spent, so it happens as soon as the frame is
|
||
// back in the clear.
|
||
if (!clearOfTail(rect)) return false;
|
||
|
||
const shot = context.capture.grab(rect);
|
||
if (!shot) return false;
|
||
|
||
const cell = cells[next];
|
||
cell.img.src = shot;
|
||
cell.img.style.width = `${rect.width}px`;
|
||
cell.img.style.height = `${rect.height}px`;
|
||
place(cell.img, rect.left, rect.top);
|
||
cell.born = now;
|
||
cell.rect = { left: rect.left, top: rect.top, right: rect.right, bottom: rect.bottom };
|
||
// Re-append so the freshest still paints over the rest of the tail.
|
||
layer.appendChild(cell.img);
|
||
|
||
next = (next + 1) % cells.length;
|
||
return true;
|
||
}
|
||
|
||
function fade(now) {
|
||
for (const cell of cells) {
|
||
if (!cell.rect) continue;
|
||
|
||
const life = 1 - (now - cell.born) / 1000 / TRAIL_FADE_SECONDS;
|
||
if (life <= 0) {
|
||
cell.img.style.opacity = '0';
|
||
// Gone, so it stops holding up drops that pass over its old spot.
|
||
cell.rect = null;
|
||
continue;
|
||
}
|
||
|
||
// Squared: a still holds its brightness for a moment and then goes
|
||
// quickly, which thins the tail out instead of trailing a long grey smear.
|
||
cell.img.style.opacity = (life * life).toFixed(3);
|
||
}
|
||
}
|
||
|
||
place(element, glider.x, glider.y);
|
||
window.addEventListener('resize', measure);
|
||
|
||
let last = performance.now();
|
||
let handle = 0;
|
||
let stopped = false;
|
||
|
||
function frame(now) {
|
||
if (stopped) return;
|
||
|
||
const dt = Math.min((now - last) / 1000, 0.05);
|
||
last = now;
|
||
|
||
const wasX = glider.x;
|
||
const wasY = glider.y;
|
||
glider.step(dt, bounds());
|
||
place(element, glider.x, glider.y);
|
||
|
||
// Spacing by distance rather than by a timer keeps the stills evenly spread
|
||
// however the frame is turning.
|
||
travelled += Math.hypot(glider.x - wasX, glider.y - wasY);
|
||
if (travelled >= size.width * TRAIL_SPACING && drop(now)) travelled = 0;
|
||
|
||
fade(now);
|
||
handle = requestAnimationFrame(frame);
|
||
}
|
||
|
||
handle = requestAnimationFrame(frame);
|
||
|
||
return function stop() {
|
||
stopped = true;
|
||
cancelAnimationFrame(handle);
|
||
window.removeEventListener('resize', measure);
|
||
for (const cell of cells) cell.img.remove();
|
||
};
|
||
}
|
||
|
||
// --- drag and resize ----------------------------------------------------
|
||
|
||
// Someone shoving a video window around a desktop: a pointer travels to the
|
||
// frame, grabs it, drags it somewhere else, lets go for a moment, then takes
|
||
// hold of one corner and pulls the frame to a new size. The pointer is drawn,
|
||
// so every move on screen has a visible cause.
|
||
|
||
const ARROW = `<svg viewBox="0 0 24 24" width="27" height="27" aria-hidden="true">
|
||
<path d="M5 2.5 L5 19.6 L9.5 15.4 L12.3 21.5 L15.2 20.1 L12.4 14.2 L18.6 14 Z"
|
||
fill="#fff" stroke="#14060f" stroke-width="1.3" stroke-linejoin="round" />
|
||
</svg>`;
|
||
|
||
// Where on the frame the pointer takes hold, as a fraction of its height —
|
||
// the top strip, where a window's title bar would be.
|
||
const GRAB_BAND = [0.06, 0.3];
|
||
|
||
// Seconds the pointer rests between grabs, and how far it drifts while resting.
|
||
const PAUSE = [0.5, 1.7];
|
||
const IDLE_DRIFT = 2.5;
|
||
|
||
function clamp(value, min, max) {
|
||
return Math.min(Math.max(value, min), max);
|
||
}
|
||
|
||
function between(min, max) {
|
||
return min + Math.random() * (max - min);
|
||
}
|
||
|
||
/** Smoothstep: no jerk at either end, which is what an arm does. */
|
||
function smooth(t) {
|
||
return t * t * (3 - 2 * t);
|
||
}
|
||
|
||
/**
|
||
* Progress along a hand-driven move: a gentle launch, then a slight overshoot
|
||
* that pulls back onto the target. `overshoot` 0 is a plain smooth ramp; the
|
||
* bigger it gets, the further past the target the move swings first.
|
||
*/
|
||
export function settle(t, overshoot = 0.6) {
|
||
const back = smooth(clamp(t, 0, 1)) - 1;
|
||
return 1 + (overshoot + 1) * back ** 3 + overshoot * back ** 2;
|
||
}
|
||
|
||
/**
|
||
* Pointer position at `t` (0..1) on its way from `from` to `to`. `curve` bows
|
||
* the path sideways — a hand does not move in a straight line — and its sign
|
||
* decides which way the arc leans.
|
||
*/
|
||
export function pointerAt(from, to, t, { curve = 0, overshoot = 0.6 } = {}) {
|
||
const dx = to.x - from.x;
|
||
const dy = to.y - from.y;
|
||
const p = settle(t, overshoot);
|
||
const bow = Math.sin(Math.PI * clamp(t, 0, 1)) * curve;
|
||
|
||
return {
|
||
x: from.x + dx * p - dy * bow,
|
||
y: from.y + dy * p + dx * bow,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* The rect you get by dragging `corner` (0 = top left, clockwise) to `pointer`
|
||
* while the opposite corner stays pinned, the way a window resize works.
|
||
*
|
||
* The aspect ratio is locked — a squashed video reads as broken rather than as
|
||
* someone resizing — so the axis the pointer pulled furthest wins, and the
|
||
* width is clamped into `min`..`max`.
|
||
*/
|
||
export function resizeRect(rect, corner, pointer, { min, max }) {
|
||
const anchor = anchorOf(rect, corner);
|
||
const pulled = Math.max(
|
||
Math.abs(pointer.x - anchor.x),
|
||
Math.abs(pointer.y - anchor.y) / ASPECT,
|
||
);
|
||
|
||
return rectFromWidth(rect, corner, clamp(pulled, min, max));
|
||
}
|
||
|
||
/** The corner that stays put while `corner` is dragged. */
|
||
function anchorOf(rect, corner) {
|
||
const right = corner === 1 || corner === 2;
|
||
const bottom = corner === 2 || corner === 3;
|
||
|
||
return {
|
||
x: right ? rect.x : rect.x + rect.width,
|
||
y: bottom ? rect.y : rect.y + rect.height,
|
||
};
|
||
}
|
||
|
||
/** Grow `rect` to `width` about its anchor, keeping the aspect ratio. */
|
||
function rectFromWidth(rect, corner, width) {
|
||
const anchor = anchorOf(rect, corner);
|
||
const right = corner === 1 || corner === 2;
|
||
const bottom = corner === 2 || corner === 3;
|
||
const height = width * ASPECT;
|
||
|
||
return {
|
||
x: right ? anchor.x : anchor.x - width,
|
||
y: bottom ? anchor.y : anchor.y - height,
|
||
width,
|
||
height,
|
||
};
|
||
}
|
||
|
||
/** Screen position of one corner of a rect, same indexing as resizeRect. */
|
||
function cornerOf(rect, corner) {
|
||
const right = corner === 1 || corner === 2;
|
||
const bottom = corner === 2 || corner === 3;
|
||
|
||
return {
|
||
x: right ? rect.x + rect.width : rect.x,
|
||
y: bottom ? rect.y + rect.height : rect.y,
|
||
};
|
||
}
|
||
|
||
function distance(a, b) {
|
||
return Math.hypot(b.x - a.x, b.y - a.y);
|
||
}
|
||
|
||
/** Longer trips take longer, but not proportionally — Fitts, roughly. */
|
||
function reachTime(px) {
|
||
return clamp(0.28 + px / 1500, 0.28, 1.2);
|
||
}
|
||
|
||
/** Dragging is slower than moving an empty hand, and much slower than a flick. */
|
||
function dragTime(px) {
|
||
return clamp(0.55 + px / 700, 0.55, 2.4);
|
||
}
|
||
|
||
export function startDragResize({ element }) {
|
||
const pointer = document.createElement('div');
|
||
pointer.id = 'pointer';
|
||
pointer.innerHTML = ARROW;
|
||
document.body.appendChild(pointer);
|
||
|
||
let base = frameSize(window.innerWidth, window.innerHeight, DRAG_FRAME_SCALE);
|
||
let rect = {
|
||
x: (window.innerWidth - base.width) / 2,
|
||
y: (window.innerHeight - base.height) / 2,
|
||
width: base.width,
|
||
height: base.height,
|
||
};
|
||
|
||
// The pointer starts off screen and slides in for its first grab.
|
||
let at = { x: -40, y: window.innerHeight * 0.75 };
|
||
|
||
function margin() {
|
||
return Math.min(window.innerWidth, window.innerHeight) * CORNER_MARGIN;
|
||
}
|
||
|
||
/** Widths the frame may take: never wider than the screen allows. */
|
||
function widthLimits() {
|
||
const fit = Math.min(
|
||
window.innerWidth - 2 * margin(),
|
||
(window.innerHeight - 2 * margin()) / ASPECT,
|
||
);
|
||
const min = Math.min(base.width * DRAG_MIN_SCALE, fit);
|
||
return { min, max: clamp(base.width * DRAG_MAX_SCALE, min, fit) };
|
||
}
|
||
|
||
function draw() {
|
||
element.style.width = `${rect.width.toFixed(1)}px`;
|
||
element.style.height = `${rect.height.toFixed(1)}px`;
|
||
place(element, rect.x, rect.y);
|
||
place(pointer, at.x, at.y);
|
||
}
|
||
|
||
// --- the script ---
|
||
//
|
||
// One cycle is a fixed sequence of beats; each beat gets its endpoints when
|
||
// it starts, so a viewport resize part-way through never strands the pointer
|
||
// at coordinates that no longer exist.
|
||
|
||
let steps = [];
|
||
let index = 0;
|
||
let elapsed = 0;
|
||
|
||
// Filled in by the press beats and read by the drag beats that follow.
|
||
let held = { x: 0, y: 0 };
|
||
let corner = 0;
|
||
let anchored = rect;
|
||
|
||
function travel(to, options) {
|
||
const from = at;
|
||
const target = to();
|
||
return {
|
||
duration: options.time(distance(from, target)),
|
||
update(p) {
|
||
at = pointerAt(from, target, p, options);
|
||
options.moved?.();
|
||
},
|
||
};
|
||
}
|
||
|
||
/** Grab: the pointer lands on the frame's title-bar strip. */
|
||
function reachFrame() {
|
||
return travel(
|
||
() => ({
|
||
x: rect.x + between(0.2, 0.8) * rect.width,
|
||
y: rect.y + between(GRAB_BAND[0], GRAB_BAND[1]) * rect.height,
|
||
}),
|
||
// An empty hand shoots past the target and pulls back onto it.
|
||
{ time: reachTime, curve: between(-0.07, 0.07), overshoot: 1.6 },
|
||
);
|
||
}
|
||
|
||
function reachCorner() {
|
||
corner = Math.floor(Math.random() * CORNERS);
|
||
return travel(() => cornerOf(rect, corner), {
|
||
time: reachTime,
|
||
curve: between(-0.05, 0.05),
|
||
overshoot: 1.4,
|
||
});
|
||
}
|
||
|
||
function press() {
|
||
return {
|
||
duration: between(0.1, 0.22),
|
||
begin() {
|
||
pointer.classList.add('down');
|
||
element.classList.add('dragging');
|
||
held = { x: at.x - rect.x, y: at.y - rect.y };
|
||
anchored = { ...rect };
|
||
},
|
||
update() {},
|
||
};
|
||
}
|
||
|
||
function release() {
|
||
const from = at;
|
||
return {
|
||
duration: between(PAUSE[0], PAUSE[1]),
|
||
begin() {
|
||
pointer.classList.remove('down');
|
||
element.classList.remove('dragging');
|
||
},
|
||
// A resting hand is never quite still.
|
||
update(p) {
|
||
at = {
|
||
x: from.x + Math.sin(p * 7) * IDLE_DRIFT,
|
||
y: from.y + Math.sin(p * 5 + 1) * IDLE_DRIFT * 0.7,
|
||
};
|
||
},
|
||
};
|
||
}
|
||
|
||
/** Drag: the frame hangs off the pointer at the offset it was grabbed by. */
|
||
function dragFrame() {
|
||
const gap = margin();
|
||
return travel(
|
||
() => {
|
||
const limitX = Math.max(gap, window.innerWidth - rect.width - gap);
|
||
const limitY = Math.max(gap, window.innerHeight - rect.height - gap);
|
||
return {
|
||
x: between(gap, limitX) + held.x,
|
||
y: between(gap, limitY) + held.y,
|
||
};
|
||
},
|
||
{
|
||
time: dragTime,
|
||
curve: between(-0.04, 0.04),
|
||
// A loaded hand overshoots less than an empty one.
|
||
overshoot: 0.8,
|
||
moved() {
|
||
rect.x = at.x - held.x;
|
||
rect.y = at.y - held.y;
|
||
},
|
||
},
|
||
);
|
||
}
|
||
|
||
/** Resize: the held corner follows the pointer, the opposite one stays put. */
|
||
function dragCorner() {
|
||
const limits = widthLimits();
|
||
const gap = margin();
|
||
|
||
return travel(
|
||
() => {
|
||
// How far the corner can travel before it leaves the screen — the same
|
||
// wall a real hand would run the window into. The anchor never moves,
|
||
// so the room is measured from there.
|
||
const anchor = anchorOf(anchored, corner);
|
||
const right = corner === 1 || corner === 2;
|
||
const bottom = corner === 2 || corner === 3;
|
||
const room = Math.min(
|
||
right ? window.innerWidth - gap - anchor.x : anchor.x - gap,
|
||
(bottom ? window.innerHeight - gap - anchor.y : anchor.y - gap) / ASPECT,
|
||
);
|
||
|
||
const width = clamp(
|
||
between(limits.min, limits.max),
|
||
limits.min,
|
||
Math.max(limits.min, room),
|
||
);
|
||
|
||
return cornerOf(rectFromWidth(anchored, corner, width), corner);
|
||
},
|
||
{
|
||
time: dragTime,
|
||
curve: between(-0.03, 0.03),
|
||
// Least of all when pulling against a corner: the size lands and stays.
|
||
overshoot: 0.5,
|
||
moved() {
|
||
rect = resizeRect(anchored, corner, at, limits);
|
||
},
|
||
},
|
||
);
|
||
}
|
||
|
||
// Beats are built lazily: each one reads the frame where the previous one
|
||
// left it.
|
||
const CYCLE = [reachFrame, press, dragFrame, release, reachCorner, press, dragCorner, release];
|
||
|
||
function step() {
|
||
const build = CYCLE[index % CYCLE.length];
|
||
index += 1;
|
||
const current = build();
|
||
current.begin?.();
|
||
return current;
|
||
}
|
||
|
||
let current = step();
|
||
|
||
function measure() {
|
||
base = frameSize(window.innerWidth, window.innerHeight, DRAG_FRAME_SCALE);
|
||
|
||
const gap = margin();
|
||
const limits = widthLimits();
|
||
rect.width = clamp(rect.width, limits.min, limits.max);
|
||
rect.height = rect.width * ASPECT;
|
||
rect.x = clamp(rect.x, gap, Math.max(gap, window.innerWidth - rect.width - gap));
|
||
rect.y = clamp(rect.y, gap, Math.max(gap, window.innerHeight - rect.height - gap));
|
||
|
||
// Whatever the pointer was aiming at is stale now; start the next beat
|
||
// from where things actually are.
|
||
pointer.classList.remove('down');
|
||
element.classList.remove('dragging');
|
||
elapsed = 0;
|
||
current = step();
|
||
}
|
||
|
||
window.addEventListener('resize', measure);
|
||
|
||
let last = performance.now();
|
||
let handle = 0;
|
||
let stopped = false;
|
||
|
||
function frame(now) {
|
||
if (stopped) return;
|
||
|
||
const dt = Math.min((now - last) / 1000, 0.05);
|
||
last = now;
|
||
elapsed += dt;
|
||
|
||
const p = current.duration > 0 ? Math.min(elapsed / current.duration, 1) : 1;
|
||
current.update(p);
|
||
draw();
|
||
|
||
if (p >= 1) {
|
||
elapsed = 0;
|
||
current = step();
|
||
}
|
||
|
||
handle = requestAnimationFrame(frame);
|
||
}
|
||
|
||
draw();
|
||
handle = requestAnimationFrame(frame);
|
||
|
||
return function stop() {
|
||
stopped = true;
|
||
cancelAnimationFrame(handle);
|
||
window.removeEventListener('resize', measure);
|
||
element.classList.remove('dragging');
|
||
pointer.remove();
|
||
// The next animation sizes the frame itself, but leaving a stretched frame
|
||
// up for even one paint would flash.
|
||
applyFrameSize(element);
|
||
};
|
||
}
|
||
|
||
// --- colour wash --------------------------------------------------------
|
||
|
||
// The frame parks in the middle and the room changes colour around it: a wash
|
||
// creeps round the colour wheel behind the poster, and the frame's glow travels
|
||
// with it, so the whole screen shifts as one. It is the quiet one — everything
|
||
// else in the registry moves the frame, and after a few of those a still frame
|
||
// with a slow colour behind it is a relief rather than a dead spot.
|
||
|
||
/**
|
||
* The two ends of the wash gradient at `elapsed` seconds, as hues in degrees.
|
||
* One trip round the wheel takes WASH_CYCLE_SECONDS, and the second hue trails
|
||
* the first by WASH_SPREAD so the gradient always has somewhere to go. Pure, so
|
||
* the cycle can be exercised without a browser.
|
||
*/
|
||
export function washHues(elapsed) {
|
||
const turn = ((elapsed / WASH_CYCLE_SECONDS) * 360) % 360;
|
||
return [turn, (turn + WASH_SPREAD) % 360];
|
||
}
|
||
|
||
/** A wash stop as a colour: saturated and mid-bright, before the blend mode. */
|
||
function washStop(hue, alpha = 1) {
|
||
return `hsl(${hue.toFixed(1)} 85% 55% / ${alpha})`;
|
||
}
|
||
|
||
// The frame's glow is the wash colour held back to the same weight the fixed
|
||
// pink had, so a hue with more punch to it does not brighten the whole frame.
|
||
const GLOW_ALPHA = 0.55;
|
||
|
||
export function startWash({ element }) {
|
||
const wash = document.createElement('div');
|
||
wash.id = 'wash';
|
||
wash.style.opacity = String(WASH_STRENGTH);
|
||
// Behind the stills and the frame, in front of the poster it tints.
|
||
document.body.appendChild(wash);
|
||
element.classList.add('washing');
|
||
|
||
function centre() {
|
||
const size = applyFrameSize(element);
|
||
place(element, (window.innerWidth - size.width) / 2, (window.innerHeight - size.height) / 2);
|
||
}
|
||
|
||
centre();
|
||
window.addEventListener('resize', centre);
|
||
|
||
const started = performance.now();
|
||
let handle = 0;
|
||
let stopped = false;
|
||
|
||
function frame(now) {
|
||
if (stopped) return;
|
||
|
||
const [from, to] = washHues((now - started) / 1000);
|
||
wash.style.background = `linear-gradient(135deg, ${washStop(from)}, ${washStop(to)})`;
|
||
// The frame's glow rides the leading hue, so it belongs to the wash instead
|
||
// of sitting on top of it in a fixed pink.
|
||
element.style.setProperty('--wash', washStop(from, GLOW_ALPHA));
|
||
|
||
handle = requestAnimationFrame(frame);
|
||
}
|
||
|
||
handle = requestAnimationFrame(frame);
|
||
|
||
return function stop() {
|
||
stopped = true;
|
||
cancelAnimationFrame(handle);
|
||
window.removeEventListener('resize', centre);
|
||
wash.remove();
|
||
element.classList.remove('washing');
|
||
element.style.removeProperty('--wash');
|
||
};
|
||
}
|
||
|
||
// --- registry -----------------------------------------------------------
|
||
|
||
// Keyed by the names in config.js, which is what travels over the socket.
|
||
export const ANIMATIONS = {
|
||
bounce: startBounce,
|
||
corners: startCorners,
|
||
drag: startDragResize,
|
||
trail: startTrail,
|
||
wash: startWash,
|
||
};
|
||
|
||
const NAMES = Object.keys(ANIMATIONS);
|
||
|
||
/**
|
||
* Run one animation at a time. In auto mode next() swaps to a freshly picked
|
||
* one on every track change; pinned to a name (from the queue page, for
|
||
* testing) it stays put until the mode changes again.
|
||
*/
|
||
export function startAnimationCycle(context, mode = AUTO_ANIMATION) {
|
||
const picker = createPicker(NAMES.length);
|
||
let stop = null;
|
||
let current = null;
|
||
|
||
function run(name) {
|
||
stop?.();
|
||
current = name;
|
||
stop = ANIMATIONS[name](context);
|
||
return current;
|
||
}
|
||
|
||
function next() {
|
||
// Pinned: the track changed, but the operator asked for this one.
|
||
if (mode !== AUTO_ANIMATION) return current;
|
||
return run(NAMES[picker.next()]);
|
||
}
|
||
|
||
function setMode(value) {
|
||
mode = ANIMATIONS[value] ? value : AUTO_ANIMATION;
|
||
// Always restarts, so switching back to auto takes effect immediately
|
||
// rather than waiting out the current track.
|
||
return mode === AUTO_ANIMATION ? run(NAMES[picker.next()]) : run(mode);
|
||
}
|
||
|
||
setMode(mode);
|
||
|
||
return {
|
||
next,
|
||
setMode,
|
||
get mode() {
|
||
return mode;
|
||
},
|
||
get name() {
|
||
return current;
|
||
},
|
||
};
|
||
}
|