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
This commit is contained in:
simon
2026-08-09 22:58:08 +02:00
co-authored by Claude Opus 5
parent 2066966694
commit 6d306d1b98
5 changed files with 268 additions and 10 deletions
+224
View File
@@ -14,6 +14,11 @@ import {
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';
@@ -299,6 +304,224 @@ export function startCorners(context) {
};
}
// --- 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
@@ -687,6 +910,7 @@ export const ANIMATIONS = {
bounce: startBounce,
corners: startCorners,
drag: startDragResize,
trail: startTrail,
};
const NAMES = Object.keys(ANIMATIONS);
+6
View File
@@ -70,6 +70,12 @@ body {
opacity: 1;
}
/* The trail ramps opacity frame-by-frame as a still ages, so the CSS transition
above would only fight it. */
#stills img.trail {
transition: none;
}
/* --- the moving frame -------------------------------------------------- */
#frameMover {