bc39455cfe
Song274Transect draws Route 274 in its true shape (the "Ground" view of song-274/route/274-transect.html) and plays it along with the song. - data.ts: reads song-274/route/ at load time (lyrics index, roster, route and the optional east/west stubs); nothing is copied into the code. Only sung nodes are shown; districts and 東に続く light nothing. - progress.ts: a "covered so far" paint over the dimmed road. Its head is pinned to each sung object's km as that line starts and glides between pins on a monotone cubic, so it never runs backwards. After 東に続く it runs out along the east stub; the west stub is always painted. - camera.ts: fixed close zoom during each narration part (lines under 20 s apart), following a Gaussian-smoothed head; pull back to frame the covered road plus some road ahead during breaks, and to the full map after the lyric. - nodeAnimation.ts: lightUp / switchOff as reusable functions over a NodeLook, with long lines switching off after MAX_LIT_S. - palette.ts: every colour as a named token (dark default, light alongside), including shrine and railway families. remotion.config.ts sets song-274/ as the public dir so staticFile() reaches the song's audio/ and route/ in place. Verified: eslint + tsc clean; stills checked across parts, breaks and the ending. Render with --muted and add audio with ffmpeg: Remotion's own AAC lands 42.75 ms late. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PLt25nCVMsoroKALYUBu6u
81 lines
2.8 KiB
TypeScript
81 lines
2.8 KiB
TypeScript
import type { Cue } from "./data";
|
|
|
|
/**
|
|
* How one node looks at one instant. The animation functions below produce
|
|
* these, and Transect.tsx only draws them, so adding a property here (a colour,
|
|
* a stroke, a glow) is the only change the drawing needs to follow.
|
|
*/
|
|
export type NodeLook = {
|
|
radius: number;
|
|
opacity: number;
|
|
};
|
|
|
|
/**
|
|
* A node that is not being sung. Radius is in map units (the page's pixels),
|
|
* so nodes grow with the camera's zoom. (The page itself uses 3 at 0.85.)
|
|
*/
|
|
export const RESTING: NodeLook = { radius: 2.5, opacity: 0.9 };
|
|
/** A node while its line is sung: the page's selected mark. */
|
|
export const LIT: NodeLook = { radius: 7, opacity: 1 };
|
|
|
|
const clamp01 = (v: number) => Math.min(1, Math.max(0, v));
|
|
|
|
/** Slightly slower lighting up and switching off. Relatively slower decay. */
|
|
const LIGHT_UP_S = 0.12;
|
|
const SWITCH_OFF_S = 0.5;
|
|
|
|
|
|
/** Blend two looks; p = 0 is `from`, p = 1 is `to`. */
|
|
export const mixLooks = (from: NodeLook, to: NodeLook, p: number): NodeLook => ({
|
|
radius: from.radius + (to.radius - from.radius) * p,
|
|
opacity: from.opacity + (to.opacity - from.opacity) * p,
|
|
});
|
|
|
|
/**
|
|
* A node being switched on.
|
|
* @param elapsedS seconds since its line started (0 at the lyric timestamp)
|
|
* @param from the look it starts from
|
|
*/
|
|
export const lightUp = (elapsedS: number, from: NodeLook = RESTING): NodeLook =>
|
|
mixLooks(from, LIT, clamp01(elapsedS / LIGHT_UP_S));
|
|
|
|
/**
|
|
* A node being switched off.
|
|
* @param elapsedS seconds since its line ended (0 at the next lyric timestamp)
|
|
* @param from the look it had when it was switched off
|
|
*/
|
|
export const switchOff = (elapsedS: number, from: NodeLook = LIT): NodeLook =>
|
|
mixLooks(from, RESTING, clamp01(elapsedS / SWITCH_OFF_S));
|
|
|
|
/** Longest a node holds fully lit, after lighting up, before it switches off. */
|
|
export const MAX_LIT_S = 0.9;
|
|
|
|
/**
|
|
* When a cue's nodes start switching off: when the line ends, or once they have
|
|
* lit up and held for MAX_LIT_S, whichever comes first, so a long line does not
|
|
* keep a node lit for too long.
|
|
*/
|
|
export const switchOffAt = (cue: Cue): number =>
|
|
Math.min(cue.stopS, cue.startS + LIGHT_UP_S + MAX_LIT_S);
|
|
|
|
/**
|
|
* The look of a node at time `t`, given the cue that lights it (or none).
|
|
* Before the line: resting. Then lighting up until switchOffAt(). After that:
|
|
* switching off, starting from however far the light-up got, so a short line
|
|
* never jumps.
|
|
*/
|
|
export const nodeLookAt = (cue: Cue | undefined, t: number): NodeLook => {
|
|
if (!cue || t < cue.startS) {
|
|
return RESTING;
|
|
}
|
|
const offS = switchOffAt(cue);
|
|
if (t < offS) {
|
|
return lightUp(t - cue.startS);
|
|
}
|
|
return switchOff(t - offS, lightUp(offS - cue.startS));
|
|
};
|
|
|
|
/** Whether a look is anything other than resting (drawn on top if so). */
|
|
export const isAwake = (look: NodeLook): boolean =>
|
|
look.radius !== RESTING.radius || look.opacity !== RESTING.opacity;
|