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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user