274: transect composition, spectrum outro, and lyric fixes it exposed #1

Merged
mikkeli merged 7 commits from song274/transect into song274/master 2026-09-17 01:36:54 +00:00
10 changed files with 969 additions and 0 deletions
Showing only changes of commit bc39455cfe - Show all commits
+3
View File
@@ -12,3 +12,6 @@ Config.setRspack(true);
Config.setVideoImageFormat("jpeg"); Config.setVideoImageFormat("jpeg");
Config.setOverwriteOutput(true); Config.setOverwriteOutput(true);
Config.overrideBundlerConfig(enableTailwind); Config.overrideBundlerConfig(enableTailwind);
// Song branch: staticFile() paths resolve inside the song folder, so compositions
// read audio/, fonts/ and route/ where they already live instead of a copy.
Config.setPublicDir("song-274");
+2
View File
@@ -1,10 +1,12 @@
import "./index.css"; import "./index.css";
import { MyComposition } from "./Composition"; import { MyComposition } from "./Composition";
import { Song274Transect } from "./song-274/Composition";
export const RemotionRoot: React.FC = () => { export const RemotionRoot: React.FC = () => {
return ( return (
<> <>
<MyComposition /> <MyComposition />
<Song274Transect />
</> </>
); );
}; };
+32
View File
@@ -0,0 +1,32 @@
import React from "react";
import { CalculateMetadataFunction, Composition } from "remotion";
import { loadTransectData } from "./data";
import { darkPalette } from "./palette";
import { Transect, type TransectProps } from "./Transect";
const FPS = 30;
/** Length of song-274/audio/274_release2.wav (see BOM.md). */
const AUDIO_DURATION_S = 619.270083;
const calculateMetadata: CalculateMetadataFunction<TransectProps> = async ({
props,
}) => ({
props: { ...props, data: await loadTransectData() },
});
export const Song274Transect: React.FC = () => (
<Composition
id="Song274Transect"
component={Transect}
durationInFrames={Math.ceil(AUDIO_DURATION_S * FPS)}
fps={FPS}
width={1920}
height={1080}
defaultProps={{
palette: darkPalette,
audio: "audio/274_release2.wav",
data: null,
}}
calculateMetadata={calculateMetadata}
/>
);
+184
View File
@@ -0,0 +1,184 @@
import React, { useMemo } from "react";
import {
AbsoluteFill,
Html5Audio,
staticFile,
useCurrentFrame,
useVideoConfig,
} from "remotion";
import {
blendViews,
followView,
narrationParts,
overviewLeadKm,
overviewView,
overviewWeight,
smoothedFocus,
toCamera,
type Frame,
} from "./camera";
import type { Cue, RosterItem, TransectData } from "./data";
import { extendBounds, GROUND, prefixBounds, projectGround, routePath } from "./layout";
import { isAwake, nodeLookAt } from "./nodeAnimation";
import type { TransectPalette } from "./palette";
import {
coveredRoute,
monotoneCurve,
paintableRoute,
progressKeyframes,
routeAtKm,
} from "./progress";
export type TransectProps = {
palette: TransectPalette;
/** Path under the public dir (song-274/). */
audio: string;
/** Filled in by calculateMetadata from song-274/route/. */
data: TransectData | null;
};
/** Road stroke widths in map units; they grow with zoom like everything else. */
const ROAD_AHEAD_WIDTH = 1.4;
const ROAD_COVERED_WIDTH = 2.4;
/** Each object's cues in time order; most nodes are sung exactly once. */
const cuesByObject = (cues: Cue[]): Map<string, Cue[]> => {
const map = new Map<string, Cue[]>();
for (const cue of cues) {
for (const object of cue.objects) {
const list = map.get(object) ?? [];
list.push(cue);
map.set(object, list);
}
}
return map;
};
/** The cue that governs an object at `t`: the latest one to have started. */
const currentCue = (list: Cue[] | undefined, t: number): Cue | undefined => {
let current: Cue | undefined;
for (const cue of list ?? []) {
if (cue.startS <= t) {
current = cue;
}
}
return current;
};
export const Transect: React.FC<TransectProps> = ({ palette, audio, data }) => {
const frame = useCurrentFrame();
const { fps, width, height } = useVideoConfig();
const projection = useMemo(() => (data ? projectGround(data.route) : null), [data]);
const fullRoad = useMemo(
() => (data && projection ? routePath(data.route, projection) : ""),
[data, projection],
);
const bounds = useMemo(
() => (data && projection ? prefixBounds(data.route, projection) : []),
[data, projection],
);
const kmAt = useMemo(() => (data ? monotoneCurve(progressKeyframes(data)) : null), [data]);
// The paint may run past the main road into the east continuation; the camera
// and overview stay on the main road, so that run leaves the screen.
const paintRoute = useMemo(() => (data ? paintableRoute(data) : []), [data]);
const westRoad = useMemo(
() =>
data && projection && data.routeWest.length > 1
? routePath(data.routeWest, projection)
: "",
[data, projection],
);
const parts = useMemo(() => narrationParts(data ? data.lines : []), [data]);
const cueMap = useMemo(() => cuesByObject(data ? data.cues : []), [data]);
if (!data || !projection || !kmAt) {
return <AbsoluteFill style={{ backgroundColor: palette.background }} />;
}
const t = frame / fps;
const headAt = (time: number) => {
const km = kmAt(time);
const pos = routeAtKm(data.route, km);
return { km, index: pos.index, x: projection.x(pos.lon), y: projection.y(pos.lat) };
};
const head = headAt(t);
const routeStartKm = data.route[0][0];
const routeEndKm = data.route[data.route.length - 1][0];
const lead = routeAtKm(
data.route,
Math.min(routeEndKm, head.km + overviewLeadKm(head.km - routeStartKm)),
);
const view: Frame = {
worldWidth: GROUND.width,
worldHeight: projection.height,
frameWidth: width,
frameHeight: height,
};
const camera = toCamera(
blendViews(
followView(smoothedFocus(headAt, t)),
overviewView(
extendBounds(bounds[lead.index], projection.x(lead.lon), projection.y(lead.lat)),
view,
),
overviewWeight(parts, t),
),
view,
);
const nodes = data.items.map((item: RosterItem) => ({
item,
look: nodeLookAt(currentCue(cueMap.get(item.object), t), t),
}));
const draw = ({ item, look }: (typeof nodes)[number]) => (
<circle
key={item.object}
cx={projection.x(item.lon)}
cy={projection.y(item.lat)}
r={look.radius}
fill={palette[item.family]}
opacity={look.opacity}
/>
);
return (
<AbsoluteFill style={{ backgroundColor: palette.background }}>
<Html5Audio src={staticFile(audio)} />
<svg width={width} height={height} viewBox={`0 0 ${width} ${height}`}>
<g transform={`translate(${camera.originX} ${camera.originY}) scale(${camera.scale})`}>
<path
d={fullRoad}
fill="none"
stroke={palette.roadAhead}
strokeWidth={ROAD_AHEAD_WIDTH}
strokeLinejoin="round"
/>
{/* The road arriving from the west: already travelled, so always painted. */}
{westRoad ? (
<path
d={westRoad}
fill="none"
stroke={palette.roadCovered}
strokeWidth={ROAD_COVERED_WIDTH}
strokeLinejoin="round"
strokeLinecap="round"
/>
) : null}
<path
d={routePath(coveredRoute(paintRoute, head.km), projection)}
fill="none"
stroke={palette.roadCovered}
strokeWidth={ROAD_COVERED_WIDTH}
strokeLinejoin="round"
strokeLinecap="round"
/>
{/* Animating nodes last so they sit on top of their neighbours. */}
{nodes.filter((n) => !isAwake(n.look)).map(draw)}
{nodes.filter((n) => isAwake(n.look)).map(draw)}
</g>
</svg>
</AbsoluteFill>
);
};
+167
View File
@@ -0,0 +1,167 @@
import type { Bounds } from "./layout";
/**
* Where the map sits on screen.
*
* The song narrates the road in parts separated by long instrumental breaks.
* During a part the camera holds a fixed close zoom and follows the head of the
* covered road. When a part ends it pulls back to frame everything covered so
* far, and just before the next part it closes in on the head again, arriving
* as the first line starts. After the last part it pulls back for good, which
* by then frames the whole route.
*
* Views are described in map units ({zoom, centre}); `toCamera` turns one into
* the scale and origin the drawing uses: screen = map unit × scale + origin.
*/
/** Zoom while following, relative to the whole route fitting the frame width. */
export const FOLLOW_ZOOM = 4;
/** Lines closer together than this belong to the same narration part. */
export const PART_GAP_S = 20;
/** Pull-back after a part ends, and close-in before the next one starts. */
export const ZOOM_OUT_S = 3;
export const ZOOM_IN_S = 3;
/**
* The camera follows a Gaussian-weighted average of the head's position, so a
* road zigzagging under a nearly straight run of objects does not shake it.
* This is the standard deviation, in seconds; the window spans ±3 of these.
*/
export const FOLLOW_SMOOTH_S = 0.6;
/**
* An overview frames the covered road plus some road ahead of the head, so the
* head does not sit against the frame edge: this fraction of the distance
* covered so far, but never less than the minimum.
*/
export const OVERVIEW_LEAD = 0.25;
export const OVERVIEW_LEAD_MIN_KM = 3;
/** Frame pixels kept clear around an overview. */
export const MARGIN_X = 80;
export const MARGIN_Y = 80;
export type Span = { startS: number; endS: number };
export type Point = { x: number; y: number };
export type View = { zoom: number; centreX: number; centreY: number };
export type Camera = { scale: number; originX: number; originY: number };
export type Frame = {
worldWidth: number;
worldHeight: number;
frameWidth: number;
frameHeight: number;
};
const clamp01 = (v: number) => Math.min(1, Math.max(0, v));
const easeInOutCubic = (p: number) =>
p < 0.5 ? 4 * p * p * p : 1 - Math.pow(-2 * p + 2, 3) / 2;
/** Merge lyric lines into narration parts wherever the gap is short. */
export const narrationParts = (lines: { startS: number; stopS: number }[]): Span[] => {
const sorted = lines.slice().sort((a, b) => a.startS - b.startS);
const parts: Span[] = [];
for (const line of sorted) {
const last = parts[parts.length - 1];
if (last && line.startS - last.endS <= PART_GAP_S) {
last.endS = Math.max(last.endS, line.stopS);
} else {
parts.push({ startS: line.startS, endS: line.stopS });
}
}
return parts;
};
/**
* 0 while following the head, 1 while showing everything covered so far.
* The intro before the first part counts as following.
*/
export const overviewWeight = (parts: Span[], t: number): number => {
for (let i = 0; i < parts.length; i++) {
const part = parts[i];
if (t < part.startS) {
if (i === 0) {
return 0;
}
const out = clamp01((t - parts[i - 1].endS) / ZOOM_OUT_S);
const into = clamp01((part.startS - t) / ZOOM_IN_S);
return easeInOutCubic(Math.min(out, into));
}
if (t <= part.endS) {
return 0;
}
}
const lastPart = parts[parts.length - 1];
return lastPart ? easeInOutCubic(clamp01((t - lastPart.endS) / ZOOM_OUT_S)) : 0;
};
/** The head's position averaged over a Gaussian window around `t`. */
export const smoothedFocus = (focusAt: (t: number) => Point, t: number): Point => {
const step = 1 / 30;
const reach = 3 * FOLLOW_SMOOTH_S;
let sumX = 0;
let sumY = 0;
let sumW = 0;
for (let dt = -reach; dt <= reach + 1e-9; dt += step) {
const w = Math.exp(-(dt * dt) / (2 * FOLLOW_SMOOTH_S * FOLLOW_SMOOTH_S));
const p = focusAt(t + dt);
sumX += p.x * w;
sumY += p.y * w;
sumW += w;
}
return { x: sumX / sumW, y: sumY / sumW };
};
const fitScale = (frame: Frame) => (frame.frameWidth - 2 * MARGIN_X) / frame.worldWidth;
/** How much road beyond the head (km) an overview includes. */
export const overviewLeadKm = (coveredKm: number): number =>
Math.max(OVERVIEW_LEAD_MIN_KM, coveredKm * OVERVIEW_LEAD);
export const followView = (focus: Point): View => ({
zoom: FOLLOW_ZOOM,
centreX: focus.x,
centreY: focus.y,
});
/** Frame `bounds` with margins, never closer than following nor wider than the full map. */
export const overviewView = (bounds: Bounds, frame: Frame): View => {
const base = fitScale(frame);
const width = Math.max(bounds.maxX - bounds.minX, 1e-6);
const height = Math.max(bounds.maxY - bounds.minY, 1e-6);
const zoom = Math.min(
(frame.frameWidth - 2 * MARGIN_X) / (width * base),
(frame.frameHeight - 2 * MARGIN_Y) / (height * base),
);
return {
zoom: Math.min(FOLLOW_ZOOM, Math.max(1, zoom)),
centreX: (bounds.minX + bounds.maxX) / 2,
centreY: (bounds.minY + bounds.maxY) / 2,
};
};
/** Blend two views; zoom on a log scale so zooming in and out feel even. */
export const blendViews = (a: View, b: View, w: number): View => ({
zoom: Math.exp(Math.log(a.zoom) + (Math.log(b.zoom) - Math.log(a.zoom)) * w),
centreX: a.centreX + (b.centreX - a.centreX) * w,
centreY: a.centreY + (b.centreY - a.centreY) * w,
});
/**
* Centre on `focus`, but never scroll past the map's ends: once the map is
* smaller than the view, centre the map itself.
*/
const clampAxis = (focus: number, worldSize: number, halfView: number, scale: number) => {
const low = halfView / scale;
const high = worldSize - halfView / scale;
return low >= high ? worldSize / 2 : Math.min(high, Math.max(low, focus));
};
export const toCamera = (view: View, frame: Frame): Camera => {
const scale = fitScale(frame) * view.zoom;
const centreX = clampAxis(view.centreX, frame.worldWidth, frame.frameWidth / 2 - MARGIN_X, scale);
const centreY = clampAxis(view.centreY, frame.worldHeight, frame.frameHeight / 2, scale);
return {
scale,
originX: frame.frameWidth / 2 - centreX * scale,
originY: frame.frameHeight / 2 - centreY * scale,
};
};
+213
View File
@@ -0,0 +1,213 @@
import { staticFile } from "remotion";
import type { FeatureFamily } from "./palette";
/**
* Loads the route data straight from `song-274/route/` (the public dir), so the
* video never holds its own copy that could drift from the wiki. See that
* folder's README for what each file is.
*/
export type RosterItem = {
object: string;
kind: string;
family: FeatureFamily;
lat: number;
lon: number;
};
/** `[km, lat, lon]` along the road, in order of km. */
export type RoutePoint = [number, number, number];
/** One sung line: the roster entries it lights, where they are, and when. */
export type Cue = {
startS: number;
stopS: number;
/** Distance along the road; the nearest road point for off-road objects. */
km: number;
objects: string[];
};
export type TransectData = {
route: RoutePoint[];
/**
* The road carrying on past the eastern cutoff (274-route-east.json), painted
* only after 東に続く. Empty if the file is absent.
*/
routeEast: RoutePoint[];
/**
* The road arriving before the western cutoff (274-route-west.json), always
* drawn as covered. Empty if the file is absent.
*/
routeWest: RoutePoint[];
/** When 東に続く (the closing line) starts, or null if the index has none. */
closingStartS: number | null;
/** Only the entries the song names; nothing else is drawn. */
items: RosterItem[];
cues: Cue[];
/** Every lyric line, districts and 東に続く included: when words are sung. */
lines: { startS: number; stopS: number }[];
/** When the last lyric line (東に続く included) ends. */
lyricsEndS: number;
};
/**
* The page's famOf() grouping, plus shrines and railway. Railway goes by name as
* well as kind: some 信号場 are in the roster as `place (locality)`.
*/
const familyOf = (kind: string, object: string): FeatureFamily => {
if (
kind === "railway station" ||
kind === "railway signal post" ||
/信号場$/.test(object)
) {
return "railway";
}
if (
kind === "bridge" ||
kind === "tunnel" ||
kind === "shed" ||
kind === "watercourse"
) {
return kind;
} else if (
kind === "Shinto shrine"
) {
return "shrine";
}
return kind.indexOf("place") === 0 ? "place" : "other";
};
/** RFC 4180 CSV into header-keyed rows. Quoted fields may hold commas. */
const parseCsv = (text: string): Record<string, string>[] => {
const rows: string[][] = [];
let row: string[] = [];
let field = "";
let quoted = false;
const src = text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
for (let i = 0; i < src.length; i++) {
const c = src[i];
if (quoted) {
if (c !== '"') {
field += c;
} else if (src[i + 1] === '"') {
field += '"';
i++;
} else {
quoted = false;
}
} else if (c === '"') {
quoted = true;
} else if (c === ",") {
row.push(field);
field = "";
} else if (c === "\n" || c === "\r") {
if (c === "\r" && src[i + 1] === "\n") {
i++;
}
row.push(field);
rows.push(row);
row = [];
field = "";
} else {
field += c;
}
}
if (field !== "" || row.length > 0) {
row.push(field);
rows.push(row);
}
const [header, ...body] = rows.filter((r) => r.length > 1 || r[0] !== "");
return body.map((r) => {
const record: Record<string, string> = {};
header.forEach((key, j) => {
record[key] = r[j] === undefined ? "" : r[j];
});
return record;
});
};
const fetchText = async (path: string): Promise<string> => {
const res = await fetch(staticFile(path));
if (!res.ok) {
throw new Error(`Could not load ${path}: HTTP ${res.status}`);
}
return res.text();
};
/** Like fetchText, but a missing file (404) is not an error: returns null. */
const fetchOptionalText = async (path: string): Promise<string | null> => {
const res = await fetch(staticFile(path));
if (res.status === 404) {
return null;
}
if (!res.ok) {
throw new Error(`Could not load ${path}: HTTP ${res.status}`);
}
return res.text();
};
export const loadTransectData = async (): Promise<TransectData> => {
const [routeJson, routeEastJson, routeWestJson, lookupCsv, indexCsv] = await Promise.all([
fetchText("route/274-route.json"),
fetchOptionalText("route/274-route-east.json"),
fetchOptionalText("route/274-route-west.json"),
fetchText("route/274-lookup.csv"),
fetchText("route/274-lyrics-index.csv"),
]);
const roster = new Map<string, RosterItem>();
for (const r of parseCsv(lookupCsv)) {
roster.set(r.object, {
object: r.object,
kind: r.kind,
family: familyOf(r.kind, r.object),
lat: Number(r.lat),
lon: Number(r.lon),
});
}
const items: RosterItem[] = [];
const cues: Cue[] = [];
const lines: TransectData["lines"] = [];
let lyricsEndS = 0;
let closingStartS: number | null = null;
for (const r of parseCsv(indexCsv)) {
lyricsEndS = Math.max(lyricsEndS, Number(r.stop_s));
lines.push({ startS: Number(r.start_s), stopS: Number(r.stop_s) });
if (r.kind === "closing") {
closingStartS = Number(r.start_s);
}
// Address headers and the closing line have no single node to light.
if (r.kind === "district" || r.kind === "closing") {
continue;
}
const item = roster.get(r.object);
if (!item) {
throw new Error(`Lyrics index names ${r.object}, which is not in the roster`);
}
items.push(item);
const startS = Number(r.start_s);
const stopS = Number(r.stop_s);
const last = cues[cues.length - 1];
// モトツ橋にモトツシェルター emits two rows sharing one timing: light both.
if (last && last.startS === startS && last.stopS === stopS) {
last.objects.push(r.object);
continue;
}
cues.push({ startS, stopS, km: Number(r.km), objects: [r.object] });
}
return {
route: JSON.parse(routeJson) as RoutePoint[],
routeEast: routeEastJson ? (JSON.parse(routeEastJson) as RoutePoint[]) : [],
routeWest: routeWestJson ? (JSON.parse(routeWestJson) as RoutePoint[]) : [],
closingStartS,
items,
cues,
lines,
lyricsEndS,
};
};
+68
View File
@@ -0,0 +1,68 @@
import type { RoutePoint } from "./data";
/**
* The "Ground — true shape" projection of `274-transect.html`, in the page's
* own units: an equirectangular plot with longitude shrunk by cos(lat), so
* distances read true east–west and north–south. camera.ts decides how these
* units land on screen.
*/
export const GROUND = {
width: 1200,
pad: 34,
} as const;
export type Projection = {
height: number;
x: (lon: number) => number;
y: (lat: number) => number;
};
export const projectGround = (route: RoutePoint[]): Projection => {
const lats = route.map((p) => p[1]);
const lons = route.map((p) => p[2]);
const la0 = Math.min(...lats);
const la1 = Math.max(...lats);
const lo0 = Math.min(...lons);
const lo1 = Math.max(...lons);
const kx = Math.cos((((la0 + la1) / 2) * Math.PI) / 180);
const sx = (lo1 - lo0) * kx;
const sy = la1 - la0;
const iw = GROUND.width - 2 * GROUND.pad;
const ih = (iw * sy) / sx;
return {
height: ih + 2 * GROUND.pad,
x: (lon) => GROUND.pad + (((lon - lo0) * kx) / sx) * iw,
y: (lat) => GROUND.pad + ih - ((lat - la0) / sy) * ih,
};
};
export type Bounds = { minX: number; minY: number; maxX: number; maxY: number };
/** Bounds of the projected route from its start up to each point, inclusive. */
export const prefixBounds = (route: RoutePoint[], proj: Projection): Bounds[] => {
const out: Bounds[] = [];
for (const p of route) {
const x = proj.x(p[2]);
const y = proj.y(p[1]);
const prev = out[out.length - 1];
out.push(prev ? extendBounds(prev, x, y) : { minX: x, minY: y, maxX: x, maxY: y });
}
return out;
};
export const extendBounds = (b: Bounds, x: number, y: number): Bounds => ({
minX: Math.min(b.minX, x),
minY: Math.min(b.minY, y),
maxX: Math.max(b.maxX, x),
maxY: Math.max(b.maxY, y),
});
export const routePath = (points: RoutePoint[], proj: Projection): string =>
points
.map(
(p, i) =>
`${i ? "L" : "M"}${proj.x(p[2]).toFixed(2)} ${proj.y(p[1]).toFixed(2)}`,
)
.join("");
+80
View File
@@ -0,0 +1,80 @@
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;
+66
View File
@@ -0,0 +1,66 @@
/**
* Every colour the 274 transect video draws, named by what it is for rather than
* what it looks like. Change values here, or override `palette` in the Studio's
* input props, without touching the drawing code.
*
* The dark palette is the video's own, grown from the dark palette of
* `song-274/route/274-transect.html`; comments name the page's CSS variable each
* token corresponds to. The light palette is the page's, extended with the
* tokens the video added (shrine, railway).
*/
export type TransectPalette = {
/** Frame background. */
background: string;
/** The whole road, before the song has covered it. */
roadAhead: string;
/** The road the song has covered so far. */
roadCovered: string;
/** Node fill per feature family, as on the page's legend. */
bridge: string;
tunnel: string;
shed: string;
watercourse: string;
place: string;
/** Stations and every 信号場, whatever their roster kind. */
railway: string;
shrine: string;
other: string;
};
export type FeatureFamily =
| "bridge"
| "tunnel"
| "shed"
| "watercourse"
| "place"
| "railway"
| "shrine"
| "other";
export const darkPalette: TransectPalette = {
background: "#28323d", // --paper
roadAhead: "#49565f", // --rule
roadCovered: "#9dcde4", // --road
bridge: "#79d1c5", // --bridge
tunnel: "#b6c5c1", // --tunnel
shed: "#eba9eb", // --shed
watercourse: "#5FB6D6", // --water
place: "#129252", // --place
railway: "#129252", // --railway
shrine: "#b32c2c", // --shrine
other: "#ff9900", // --util
};
export const lightPalette: TransectPalette = {
background: "#E7ECEE",
roadAhead: "#C0CCD2",
roadCovered: "#2E4A5A",
bridge: "#116B62",
tunnel: "#3A4650",
shed: "#7A5AA6",
watercourse: "#2F7E9E",
place: "#8A6D1F",
railway: "#1F6E43",
shrine: "#9B2C2C",
other: "#5B6B75",
};
+154
View File
@@ -0,0 +1,154 @@
import type { RoutePoint, TransectData } from "./data";
/**
* How far along the road (km) the song has got at any moment: the head of the
* "covered so far" paint, and what the camera follows.
*
* The head is pinned to each sung object's km at the instant its line starts,
* and glides between those pins on a monotone cubic curve: it passes through
* every pin exactly, never runs backwards, and changes speed smoothly rather
* than lurching at each object.
*/
export type Keyframe = { t: number; km: number };
/** How long the paint takes to run the east continuation once 東に続く starts. */
export const EAST_RUN_S = 4;
/**
* Pins, in time order: the start of the route at 0 s and each sung line's
* object. Then the end of the main road as 東に続く starts, and the end of the
* east continuation EAST_RUN_S later. Without a closing line or an east
* continuation, the main road simply ends as the last lyric line ends.
*/
export const progressKeyframes = (data: TransectData): Keyframe[] => {
const { route, routeEast, closingStartS, cues, lyricsEndS } = data;
const keys: Keyframe[] = [{ t: 0, km: route[0][0] }];
// Keep time strictly increasing and km never decreasing.
const pin = (t: number, km: number) => {
const last = keys[keys.length - 1];
if (t <= last.t) {
last.km = Math.max(last.km, km);
} else {
keys.push({ t, km: Math.max(last.km, km) });
}
};
for (const cue of cues) {
pin(cue.startS, cue.km);
}
const mainEndKm = route[route.length - 1][0];
if (closingStartS !== null && routeEast.length > 0) {
pin(closingStartS, mainEndKm);
pin(closingStartS + EAST_RUN_S, routeEast[routeEast.length - 1][0]);
} else {
pin(lyricsEndS, mainEndKm);
}
return keys;
};
/**
* The road the paint can travel: the main route, then the east continuation
* from where it passes the main route's end (the two overlap slightly).
*/
export const paintableRoute = (data: TransectData): RoutePoint[] => {
const mainEndKm = data.route[data.route.length - 1][0];
return data.route.concat(data.routeEast.filter((p) => p[0] > mainEndKm));
};
/**
* Fritsch–Carlson monotone cubic Hermite interpolation through `keys`.
* Returns km as a function of time, held flat before the first and after the
* last keyframe.
*/
export const monotoneCurve = (keys: Keyframe[]): ((t: number) => number) => {
const n = keys.length;
if (n === 1) {
return () => keys[0].km;
}
const secant: number[] = [];
for (let i = 0; i < n - 1; i++) {
secant.push((keys[i + 1].km - keys[i].km) / (keys[i + 1].t - keys[i].t));
}
const tangent: number[] = [secant[0]];
for (let i = 1; i < n - 1; i++) {
tangent.push(secant[i - 1] * secant[i] <= 0 ? 0 : (secant[i - 1] + secant[i]) / 2);
}
tangent.push(secant[n - 2]);
for (let i = 0; i < n - 1; i++) {
if (secant[i] === 0) {
tangent[i] = 0;
tangent[i + 1] = 0;
continue;
}
const a = tangent[i] / secant[i];
const b = tangent[i + 1] / secant[i];
const h = a * a + b * b;
if (h > 9) {
const tau = 3 / Math.sqrt(h);
tangent[i] = tau * a * secant[i];
tangent[i + 1] = tau * b * secant[i];
}
}
return (t) => {
if (t <= keys[0].t) {
return keys[0].km;
}
if (t >= keys[n - 1].t) {
return keys[n - 1].km;
}
let i = 0;
while (keys[i + 1].t < t) {
i++;
}
const h = keys[i + 1].t - keys[i].t;
const s = (t - keys[i].t) / h;
const s2 = s * s;
const s3 = s2 * s;
return (
(2 * s3 - 3 * s2 + 1) * keys[i].km +
(s3 - 2 * s2 + s) * h * tangent[i] +
(-2 * s3 + 3 * s2) * keys[i + 1].km +
(s3 - s2) * h * tangent[i + 1]
);
};
};
export type RoutePosition = {
/** Last route point at or before this km. */
index: number;
lat: number;
lon: number;
};
/** The point on the road at `km`, interpolated between route points. */
export const routeAtKm = (route: RoutePoint[], km: number): RoutePosition => {
const lastIndex = route.length - 1;
if (km <= route[0][0]) {
return { index: 0, lat: route[0][1], lon: route[0][2] };
}
if (km >= route[lastIndex][0]) {
return { index: lastIndex, lat: route[lastIndex][1], lon: route[lastIndex][2] };
}
let low = 0;
let high = lastIndex;
while (high - low > 1) {
const mid = (low + high) >> 1;
if (route[mid][0] <= km) {
low = mid;
} else {
high = mid;
}
}
const [k0, lat0, lon0] = route[low];
const [k1, lat1, lon1] = route[high];
const p = k1 === k0 ? 0 : (km - k0) / (k1 - k0);
return { index: low, lat: lat0 + (lat1 - lat0) * p, lon: lon0 + (lon1 - lon0) * p };
};
/** The covered stretch of road: every route point up to the head, then the head. */
export const coveredRoute = (route: RoutePoint[], km: number): RoutePoint[] => {
const head = routeAtKm(route, km);
return [...route.slice(0, head.index + 1), [km, head.lat, head.lon]];
};