From bc39455cfe853b47bed21a96591b2166414f8bcc Mon Sep 17 00:00:00 2001 From: Mikkeli Date: Thu, 17 Sep 2026 09:40:53 +0900 Subject: [PATCH] feat(274): transect composition synced to the lyric MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01PLt25nCVMsoroKALYUBu6u --- remotion.config.ts | 3 + src/Root.tsx | 2 + src/song-274/Composition.tsx | 32 +++++ src/song-274/Transect.tsx | 184 +++++++++++++++++++++++++++++ src/song-274/camera.ts | 167 ++++++++++++++++++++++++++ src/song-274/data.ts | 213 ++++++++++++++++++++++++++++++++++ src/song-274/layout.ts | 68 +++++++++++ src/song-274/nodeAnimation.ts | 80 +++++++++++++ src/song-274/palette.ts | 66 +++++++++++ src/song-274/progress.ts | 154 ++++++++++++++++++++++++ 10 files changed, 969 insertions(+) create mode 100644 src/song-274/Composition.tsx create mode 100644 src/song-274/Transect.tsx create mode 100644 src/song-274/camera.ts create mode 100644 src/song-274/data.ts create mode 100644 src/song-274/layout.ts create mode 100644 src/song-274/nodeAnimation.ts create mode 100644 src/song-274/palette.ts create mode 100644 src/song-274/progress.ts 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 ( + + + + + + {/* The road arriving from the west: already travelled, so always painted. */} + {westRoad ? ( + + ) : null} + + {/* 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)} + + + + ); +}; 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]]; +};