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