Files
ncmr-songs-videos/src/song-274/data.ts
T

214 lines
6.1 KiB
TypeScript
Raw Normal View History

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,
};
};