274: transect composition, spectrum outro, and lyric fixes it exposed #1
@@ -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");
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import "./index.css";
|
||||
import { MyComposition } from "./Composition";
|
||||
import { Song274Transect } from "./song-274/Composition";
|
||||
|
||||
export const RemotionRoot: React.FC = () => {
|
||||
return (
|
||||
<>
|
||||
<MyComposition />
|
||||
<Song274Transect />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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("");
|
||||
@@ -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;
|
||||
@@ -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",
|
||||
};
|
||||
@@ -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]];
|
||||
};
|
||||
Reference in New Issue
Block a user