Files
ncmr-songs-videos/src/song-274/nodeAnimation.ts
T
mikkeli bc39455cfe feat(274): transect composition synced to the lyric
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
2026-09-17 09:40:53 +09:00

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;