feat(rail): add a Rail layer with keyless live trains

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.
This commit is contained in:
Denis Groshev committed 2026-11-11 16:06:22 +01:00
1 parent 14db73f201
commit 830c080803
114 files changed
+26639 -26

No files matched your search

+288
View File
@@ -0,0 +1,288 @@
/**
* @module railPaths
* @description Track geometry for the Rail layer: a railway line as an indexed
* polyline measured by arc length, with projection of a position onto it and
* the point at a given arc length.
*
* Paths come from per-feed path packs (scripts/build-rail-paths.mjs), served by
* `/api/rail/paths/<feedId>`. A pack holds each unique shape as a flat
* `[lat, lon, lat, lon, ...]` array with its cumulative distances in metres.
*
* Distances are measured in one local equirectangular frame per path, centred
* on the path's middle latitude. Over the extent of a commuter network (a few
* hundred kilometres) the east-west scale error stays within a few per cent,
* which moves a projection by metres, well under what a marker shows.
*
* Pure: no Cesium, no DOM, no Node built-ins.
*/
/** Metres per degree of latitude (and of longitude at the equator). */
const M_PER_DEG = 111_320;
/** Segments per bounding box in the whole-path search index. */
const CHUNK = 32;
/** Longitude difference wrapped into (-180, 180]. */
function wrapLon(delta) {
return ((((delta + 180) % 360) + 360) % 360) - 180;
}
/**
* Build an indexed path from a flat lat/lon array.
* @param {ArrayLike<number>} coords `[lat, lon, lat, lon, ...]`, at least two points.
* @param {ArrayLike<number>} [dist] Cumulative metres per point. When absent,
* wrong-sized or decreasing, the distances are measured in the local frame.
* @returns {object|null} The path, or null when the input is not a polyline.
*/
export function createRailPath(coords, dist = null) {
const n = Math.floor((coords?.length ?? 0) / 2);
if (n < 2) return null;
const lat = new Float64Array(n);
const lon = new Float64Array(n);
let minLat = Infinity;
let maxLat = -Infinity;
for (let i = 0; i < n; i++) {
const a = Number(coords[2 * i]);
const o = Number(coords[2 * i + 1]);
if (!Number.isFinite(a) || !Number.isFinite(o) || Math.abs(a) > 90)
return null;
lat[i] = a;
lon[i] = o;
if (a < minLat) minLat = a;
if (a > maxLat) maxLat = a;
}
const lat0 = (minLat + maxLat) / 2;
const lon0 = lon[0];
const kx = M_PER_DEG * Math.cos((lat0 * Math.PI) / 180);
const ky = M_PER_DEG;
const x = new Float64Array(n);
const y = new Float64Array(n);
for (let i = 0; i < n; i++) {
x[i] = wrapLon(lon[i] - lon0) * kx;
y[i] = (lat[i] - lat0) * ky;
}
const s = new Float64Array(n);
let given = dist?.length === n && Number(dist[0]) >= 0;
for (let i = 0; given && i < n; i++) {
const d = Number(dist[i]);
if (!Number.isFinite(d) || (i > 0 && d < s[i - 1])) given = false;
else s[i] = d;
}
if (!given) {
s[0] = 0;
for (let i = 1; i < n; i++)
s[i] = s[i - 1] + Math.hypot(x[i] - x[i - 1], y[i] - y[i - 1]);
}
const chunks = Math.ceil((n - 1) / CHUNK);
const box = new Float64Array(chunks * 4);
for (let c = 0; c < chunks; c++) {
let x0 = Infinity;
let x1 = -Infinity;
let y0 = Infinity;
let y1 = -Infinity;
const end = Math.min(n - 1, (c + 1) * CHUNK);
for (let i = c * CHUNK; i <= end; i++) {
if (x[i] < x0) x0 = x[i];
if (x[i] > x1) x1 = x[i];
if (y[i] < y0) y0 = y[i];
if (y[i] > y1) y1 = y[i];
}
box[4 * c] = x0;
box[4 * c + 1] = x1;
box[4 * c + 2] = y0;
box[4 * c + 3] = y1;
}
return Object.freeze({
n,
lat,
lon,
x,
y,
s,
box,
lat0,
lon0,
kx,
ky,
length: s[n - 1],
});
}
/** Index of the segment [i, i+1] whose arc range holds `s`, clamped. */
function segmentAt(path, s) {
const arc = path.s;
let lo = 0;
let hi = path.n - 2;
if (!(s > arc[0])) return 0;
if (s >= arc[hi + 1]) return hi;
while (lo < hi) {
const mid = (lo + hi + 1) >> 1;
if (arc[mid] <= s) lo = mid;
else hi = mid - 1;
}
return lo;
}
/**
* Squared distance from (px, py) to segment i, writing the parameter to `hit`.
* @returns {number}
*/
function segmentDistance2(path, i, px, py, hit) {
const { x, y } = path;
const dx = x[i + 1] - x[i];
const dy = y[i + 1] - y[i];
const len2 = dx * dx + dy * dy;
let t = len2 > 0 ? ((px - x[i]) * dx + (py - y[i]) * dy) / len2 : 0;
if (t < 0) t = 0;
else if (t > 1) t = 1;
const ex = x[i] + dx * t - px;
const ey = y[i] + dy * t - py;
hit.t = t;
return ex * ex + ey * ey;
}
/**
* Project a position onto a path.
*
* With a hint, only the segments whose arc range overlaps
* `[hintS - windowM, hintS + windowM]` are searched: a train known to be near
* arc `hintS` cannot jump to the far side of a loop or to a parallel branch
* that the same shape passes later. Without one, the whole path is searched.
*
* @param {object} path From createRailPath.
* @param {number} lat
* @param {number} lon
* @param {number} [hintS] Expected arc length (m).
* @param {number} [windowM] Half-width of the search window around the hint (m).
* @param {{s?: number, offsetM?: number, index?: number}} [out]
* @returns {{s: number, offsetM: number, index: number}|null}
*/
export function projectOnPath(path, lat, lon, hintS, windowM, out = {}) {
if (!path || !Number.isFinite(lat) || !Number.isFinite(lon)) return null;
const px = wrapLon(lon - path.lon0) * path.kx;
const py = (lat - path.lat0) * path.ky;
const hit = { t: 0 };
let best = Infinity;
let bestIndex = -1;
let bestT = 0;
if (Number.isFinite(hintS) && Number.isFinite(windowM) && windowM >= 0) {
const first = segmentAt(path, hintS - windowM);
const last = segmentAt(path, hintS + windowM);
for (let i = first; i <= last; i++) {
const d2 = segmentDistance2(path, i, px, py, hit);
if (d2 < best) {
best = d2;
bestIndex = i;
bestT = hit.t;
}
}
} else {
const { box } = path;
const chunks = box.length / 4;
for (let c = 0; c < chunks; c++) {
const ox = Math.max(box[4 * c] - px, 0, px - box[4 * c + 1]);
const oy = Math.max(box[4 * c + 2] - py, 0, py - box[4 * c + 3]);
if (ox * ox + oy * oy >= best) continue;
const end = Math.min(path.n - 2, (c + 1) * CHUNK - 1);
for (let i = c * CHUNK; i <= end; i++) {
const d2 = segmentDistance2(path, i, px, py, hit);
if (d2 < best) {
best = d2;
bestIndex = i;
bestT = hit.t;
}
}
}
}
if (bestIndex < 0) return null;
const s = path.s;
out.s = s[bestIndex] + (s[bestIndex + 1] - s[bestIndex]) * bestT;
out.offsetM = Math.sqrt(best);
out.index = bestIndex;
return out;
}
/**
* The position and direction of travel at an arc length, clamped to the path.
* @param {object} path From createRailPath.
* @param {number} s Arc length (m).
* @param {{lat?: number, lon?: number, bearingDeg?: number}} [out]
* @returns {{lat: number, lon: number, bearingDeg: number}|null}
*/
export function pointAtS(path, s, out = {}) {
if (!path || !Number.isFinite(s)) return null;
const clamped = Math.max(0, Math.min(path.length, s));
let i = segmentAt(path, clamped);
// A zero-length segment has no direction; borrow its neighbour's.
while (i < path.n - 2 && path.s[i + 1] === path.s[i]) i++;
const span = path.s[i + 1] - path.s[i];
const t =
span > 0 ? Math.max(0, Math.min(1, (clamped - path.s[i]) / span)) : 0;
out.lat = path.lat[i] + (path.lat[i + 1] - path.lat[i]) * t;
let lon = path.lon[i] + wrapLon(path.lon[i + 1] - path.lon[i]) * t;
if (lon > 180) lon -= 360;
else if (lon < -180) lon += 360;
out.lon = lon;
const dx = path.x[i + 1] - path.x[i];
const dy = path.y[i + 1] - path.y[i];
out.bearingDeg =
dx === 0 && dy === 0
? NaN
: ((Math.atan2(dx, dy) * 180) / Math.PI + 360) % 360;
return out;
}
/** Path-pack format this module reads. */
export const RAIL_PATH_PACK_VERSION = 1;
/**
* Wrap a decoded path pack for lookups. Shapes are indexed lazily, the first
* time a train asks for one.
* @param {object} raw The JSON body of `/api/rail/paths/<feedId>`.
* @returns {{feedId: string, shapeCount: number,
* pathAt: (index: number) => object|null,
* shapeForTrip: (tripId: string) => number|null,
* shapesForRoute: (routeId: string) => number[]}}
*/
export function createRailPathPack(raw) {
if (
!raw ||
raw.version !== RAIL_PATH_PACK_VERSION ||
typeof raw.feedId !== 'string' ||
!Array.isArray(raw.shapes) ||
!Array.isArray(raw.dist) ||
raw.dist.length !== raw.shapes.length
)
throw new TypeError('Malformed rail path pack');
const shapes = raw.shapes;
const dist = raw.dist;
const trips = raw.trips && typeof raw.trips === 'object' ? raw.trips : {};
const routes = raw.routes && typeof raw.routes === 'object' ? raw.routes : {};
const built = new Map();
const validIndex = (value) =>
Number.isInteger(value) && value >= 0 && value < shapes.length
? value
: null;
return Object.freeze({
feedId: raw.feedId,
/** When the pack was built (ISO), matched by a schedule's pathsBuiltAt. */
builtAt: typeof raw.builtAt === 'string' ? raw.builtAt : null,
shapeCount: shapes.length,
pathAt(index) {
if (validIndex(index) === null) return null;
if (!built.has(index))
built.set(index, createRailPath(shapes[index], dist[index]));
return built.get(index);
},
shapeForTrip(tripId) {
const id = String(tripId ?? '');
return id && Object.hasOwn(trips, id) ? validIndex(trips[id]) : null;
},
shapesForRoute(routeId) {
const id = String(routeId ?? '');
const list = id && Object.hasOwn(routes, id) ? routes[id] : null;
return Array.isArray(list)
? list.filter((value) => validIndex(value) !== null)
: [];
},
});
}