Files
party-player/public/player/motion.js
T
simonandClaude Opus 5 6d306d1b98 Add a comet trail animation
The frame glides from waypoint to waypoint and drops a screenshot of
itself every frame-width or so. The stills stay where they were taken
and fade over a few seconds, so the live video reads as the head of a
comet whose tail is its own recent past.

The steering caps how fast the heading may change: an instant turn would
lay the stills out in straight dotted lines, and it is the curve that
makes a tail read as one. Drops are spaced by distance travelled rather
than by a timer, so they stay even however the frame is turning.

Since the capture is a crop of the tab, a still lying under the frame
would be photographed into the next one. A drop waits until the frame is
clear of the tail, keeping the invariant corners already relies on.

Also picks up the FRAME_SCALE, BOUNCE_SPEED and CORNER_INTERVAL_SECONDS
tuning sitting in the tree.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tg15tLAuT6NrCE1ZxKVgDB
2026-08-09 22:58:08 +02:00

961 lines
29 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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,
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);
};
}
// --- 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,
};
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;
},
};
}