diff --git a/remotion.config.ts b/remotion.config.ts
index 694eafb..cab3912 100644
--- a/remotion.config.ts
+++ b/remotion.config.ts
@@ -12,3 +12,6 @@ Config.setRspack(true);
Config.setVideoImageFormat("jpeg");
Config.setOverwriteOutput(true);
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");
diff --git a/src/Root.tsx b/src/Root.tsx
index 2be4c12..b0ab2a5 100644
--- a/src/Root.tsx
+++ b/src/Root.tsx
@@ -1,10 +1,12 @@
import "./index.css";
import { MyComposition } from "./Composition";
+import { Song274Transect } from "./song-274/Composition";
export const RemotionRoot: React.FC = () => {
return (
<>
+
>
);
};
diff --git a/src/song-274/Composition.tsx b/src/song-274/Composition.tsx
new file mode 100644
index 0000000..373149c
--- /dev/null
+++ b/src/song-274/Composition.tsx
@@ -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 = async ({
+ props,
+}) => ({
+ props: { ...props, data: await loadTransectData() },
+});
+
+export const Song274Transect: React.FC = () => (
+
+);
diff --git a/src/song-274/Transect.tsx b/src/song-274/Transect.tsx
new file mode 100644
index 0000000..c4e70f2
--- /dev/null
+++ b/src/song-274/Transect.tsx
@@ -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 => {
+ const map = new Map();
+ 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 = ({ 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 ;
+ }
+
+ 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]) => (
+
+ );
+
+ return (
+
+
+
+
+ );
+};
diff --git a/src/song-274/camera.ts b/src/song-274/camera.ts
new file mode 100644
index 0000000..f56b9e3
--- /dev/null
+++ b/src/song-274/camera.ts
@@ -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,
+ };
+};
diff --git a/src/song-274/data.ts b/src/song-274/data.ts
new file mode 100644
index 0000000..98f2f52
--- /dev/null
+++ b/src/song-274/data.ts
@@ -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[] => {
+ 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 = {};
+ header.forEach((key, j) => {
+ record[key] = r[j] === undefined ? "" : r[j];
+ });
+ return record;
+ });
+};
+
+const fetchText = async (path: string): Promise => {
+ 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 => {
+ 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 => {
+ 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();
+ 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,
+ };
+};
diff --git a/src/song-274/layout.ts b/src/song-274/layout.ts
new file mode 100644
index 0000000..dee8ee8
--- /dev/null
+++ b/src/song-274/layout.ts
@@ -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("");
diff --git a/src/song-274/nodeAnimation.ts b/src/song-274/nodeAnimation.ts
new file mode 100644
index 0000000..17913d3
--- /dev/null
+++ b/src/song-274/nodeAnimation.ts
@@ -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;
diff --git a/src/song-274/palette.ts b/src/song-274/palette.ts
new file mode 100644
index 0000000..405b358
--- /dev/null
+++ b/src/song-274/palette.ts
@@ -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",
+};
diff --git a/src/song-274/progress.ts b/src/song-274/progress.ts
new file mode 100644
index 0000000..8de0e90
--- /dev/null
+++ b/src/song-274/progress.ts
@@ -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]];
+};