mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-14 23:32:20 +00:00
Live trains from 30 keyless operator feeds: GPS positions from 29 and Irish Rail's station reports, shown as the operators publish them. 28 work out of the box; Renfe long and medium distance and KTMB appear once their pack is built, because their vehicles name no route. Packs are opt-in (scripts/build-rail-paths.mjs): the operator's own route lines, stop names, next stops and trip-update delays for live trains. Nothing downloads when the app starts, no train is placed from a timetable, and no request carries a deployer's key.
125 lines
4.5 KiB
JavaScript
125 lines
4.5 KiB
JavaScript
/**
|
|
* @module railScheduleIndex
|
|
* @description Server-side lookup over a schedule pack (the
|
|
* `<feedId>.schedule.json` scripts/build-rail-paths.mjs writes) for a
|
|
* realtime source that names a train by its number (Irish Rail's train
|
|
* codes): `createRunIndex`, the dated runs of a pack's trips, so a reported
|
|
* train takes the trip it is on (its line and stops). The train's position
|
|
* is never taken from the timetable.
|
|
*
|
|
* Pure: no I/O. Times are epoch milliseconds; a run starts at the service
|
|
* day's GTFS origin (noon minus 12 h in the pack's time zone) plus the
|
|
* trip's start, and lasts its profile's last offset, as
|
|
* src/data/railSchedule.js reads them.
|
|
*/
|
|
|
|
import { serviceDayOriginMs } from './railTimeZones.js';
|
|
|
|
function packTrips(raw) {
|
|
return raw?.trips && typeof raw.trips === 'object' ? raw.trips : {};
|
|
}
|
|
|
|
function packServices(raw) {
|
|
return raw?.services && typeof raw.services === 'object' ? raw.services : {};
|
|
}
|
|
|
|
function serviceDates(services, service) {
|
|
const dates = services[service];
|
|
return Array.isArray(dates) ? dates : [];
|
|
}
|
|
|
|
/**
|
|
* The dated runs of a pack's trips. Built lazily per trip, so a source that
|
|
* looks up a few hundred train numbers never expands the whole pack. It
|
|
* keeps the parts of the pack it reads (trips, profiles, services), not the
|
|
* pack itself.
|
|
* @param {object} raw Decoded schedule pack.
|
|
* @returns {{runsOf: (tripId: string) => Array<{tripId: string, date: string,
|
|
* startMs: number, endMs: number}>,
|
|
* tripsNamed: (name: string) => string[],
|
|
* activeTrip: (name: string, nowMs: number,
|
|
* options?: {slackMs?: number, delayMs?: number}) =>
|
|
* ({tripId: string, date: string, startMs: number, endMs: number}|null)}}
|
|
*/
|
|
export function createRunIndex(raw) {
|
|
const trips = packTrips(raw);
|
|
const services = packServices(raw);
|
|
const zone = typeof raw?.timezone === 'string' ? raw.timezone : 'UTC';
|
|
const profiles = Array.isArray(raw?.profiles) ? raw.profiles : [];
|
|
const origins = new Map();
|
|
const originOf = (date) => {
|
|
let origin = origins.get(date);
|
|
if (origin === undefined) {
|
|
origin = serviceDayOriginMs(date, zone);
|
|
origins.set(date, origin);
|
|
}
|
|
return origin;
|
|
};
|
|
let names = null;
|
|
const runs = new Map();
|
|
function runsOf(tripId) {
|
|
let list = runs.get(tripId);
|
|
if (list) return list;
|
|
list = [];
|
|
const trip = Object.hasOwn(trips, tripId) ? trips[tripId] : null;
|
|
const profile = trip ? profiles[trip.profile] : null;
|
|
if (trip && Array.isArray(profile) && Number.isFinite(trip.startSec)) {
|
|
const durationMs = (Number(profile[profile.length - 1]) || 0) * 1000;
|
|
for (const date of serviceDates(services, trip.service)) {
|
|
const origin = originOf(date);
|
|
if (!Number.isFinite(origin)) continue;
|
|
const startMs = origin + trip.startSec * 1000;
|
|
list.push({ tripId, date, startMs, endMs: startMs + durationMs });
|
|
}
|
|
}
|
|
runs.set(tripId, list);
|
|
return list;
|
|
}
|
|
return {
|
|
runsOf,
|
|
tripsNamed(name) {
|
|
if (!names) {
|
|
names = new Map();
|
|
for (const [tripId, trip] of Object.entries(trips)) {
|
|
if (typeof trip?.name !== 'string' || !trip.name) continue;
|
|
const list = names.get(trip.name);
|
|
if (list) list.push(tripId);
|
|
else names.set(trip.name, [tripId]);
|
|
}
|
|
}
|
|
return names.get(String(name ?? '')) ?? [];
|
|
},
|
|
/**
|
|
* The run of a train number that is under way at `nowMs` (or within
|
|
* `slackMs` of its start or end); the one whose span is nearest when
|
|
* several qualify, null when none or when two tie. A train running
|
|
* `delayMs` late (negative: early) is where its timetable was that long
|
|
* ago, so its runs are compared at `nowMs - delayMs`.
|
|
*/
|
|
activeTrip(name, nowMs, { slackMs = 10 * 60_000, delayMs = 0 } = {}) {
|
|
const at = nowMs - (Number.isFinite(delayMs) ? delayMs : 0);
|
|
let best = null;
|
|
let bestScore = Infinity;
|
|
let tie = false;
|
|
for (const tripId of this.tripsNamed(name)) {
|
|
for (const run of runsOf(tripId)) {
|
|
if (at < run.startMs - slackMs || at > run.endMs + slackMs) continue;
|
|
const score =
|
|
at < run.startMs
|
|
? run.startMs - at
|
|
: at > run.endMs
|
|
? at - run.endMs
|
|
: 0;
|
|
if (score < bestScore) {
|
|
best = run;
|
|
bestScore = score;
|
|
tie = false;
|
|
} else if (score === bestScore && best && run.tripId !== best.tripId)
|
|
tie = true;
|
|
}
|
|
}
|
|
return tie ? null : best;
|
|
},
|
|
};
|
|
}
|