214 lines
6.1 KiB
TypeScript
214 lines
6.1 KiB
TypeScript
|
|
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,
|
||
|
|
};
|
||
|
|
};
|