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.
164 lines
5.5 KiB
JavaScript
164 lines
5.5 KiB
JavaScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import {
|
|
RAIL_PATH_PACK_VERSION,
|
|
createRailPath,
|
|
createRailPathPack,
|
|
pointAtS,
|
|
projectOnPath,
|
|
} from './railPaths.js';
|
|
|
|
const M_PER_DEG = 111_320;
|
|
|
|
/** An L: 1 km east along the equator, then 1 km north. */
|
|
function lPath() {
|
|
const dLon = 1000 / M_PER_DEG;
|
|
return createRailPath([0, 0, 0, dLon, dLon, dLon]);
|
|
}
|
|
|
|
/**
|
|
* A hairpin: 2 km east along lat 0, a 100 m step north, then 2 km back west.
|
|
* The two legs are parallel and 100 m apart, like the two sides of a loop.
|
|
*/
|
|
function hairpin() {
|
|
const d = 1 / M_PER_DEG;
|
|
return createRailPath([0, 0, 0, 2000 * d, 100 * d, 2000 * d, 100 * d, 0]);
|
|
}
|
|
|
|
test('a path measures its arc length in metres', () => {
|
|
const path = lPath();
|
|
assert.equal(path.n, 3);
|
|
assert.ok(Math.abs(path.length - 2000) < 0.5, String(path.length));
|
|
assert.equal(createRailPath([0, 0]), null, 'one point is not a path');
|
|
assert.equal(createRailPath([0, 0, Number.NaN, 1]), null);
|
|
assert.equal(createRailPath(null), null);
|
|
});
|
|
|
|
test('given distances are used, and bad ones are measured instead', () => {
|
|
const coords = [0, 0, 0, 0.01, 0, 0.02];
|
|
assert.deepEqual([...createRailPath(coords, [0, 5, 10]).s], [0, 5, 10]);
|
|
const measured = createRailPath(coords, [0, 10, 5]);
|
|
assert.ok(Math.abs(measured.s[2] - 2 * 0.01 * M_PER_DEG) < 0.5);
|
|
const wrongSize = createRailPath(coords, [0, 1]);
|
|
assert.ok(wrongSize.s[1] > 1000);
|
|
});
|
|
|
|
test('projection finds the arc and the offset from the line', () => {
|
|
const path = lPath();
|
|
const d = 1 / M_PER_DEG;
|
|
const hit = projectOnPath(path, 20 * d, 500 * d);
|
|
assert.ok(Math.abs(hit.s - 500) < 0.5, String(hit.s));
|
|
assert.ok(Math.abs(hit.offsetM - 20) < 0.5, String(hit.offsetM));
|
|
assert.equal(hit.index, 0);
|
|
const second = projectOnPath(path, 300 * d, 1030 * d);
|
|
assert.ok(Math.abs(second.s - 1300) < 0.5, String(second.s));
|
|
assert.ok(Math.abs(second.offsetM - 30) < 0.5);
|
|
assert.equal(second.index, 1);
|
|
const beyond = projectOnPath(path, 2000 * d, 1000 * d);
|
|
assert.ok(Math.abs(beyond.s - 2000) < 0.5, 'clamped to the end');
|
|
assert.ok(Math.abs(beyond.offsetM - 1000) < 1);
|
|
assert.equal(projectOnPath(path, Number.NaN, 0), null);
|
|
assert.equal(projectOnPath(null, 0, 0), null);
|
|
});
|
|
|
|
test('a hint keeps the projection on its own side of a loop', () => {
|
|
const path = hairpin();
|
|
const d = 1 / M_PER_DEG;
|
|
// 45 m north of the outbound leg, 55 m south of the return leg: the
|
|
// whole-path answer is the outbound leg...
|
|
const point = [45 * d, 1000 * d];
|
|
const free = projectOnPath(path, ...point);
|
|
assert.ok(Math.abs(free.s - 1000) < 1, String(free.s));
|
|
// ...but a train known to be on the return leg stays on it.
|
|
const hinted = projectOnPath(path, ...point, 3100, 500);
|
|
assert.ok(Math.abs(hinted.s - 3100) < 1, String(hinted.s));
|
|
assert.ok(Math.abs(hinted.offsetM - 55) < 0.5);
|
|
});
|
|
|
|
test('the whole-path search agrees with a brute-force scan on a long path', () => {
|
|
const coords = [];
|
|
for (let i = 0; i < 500; i++)
|
|
coords.push(40 + Math.sin(i / 20) * 0.01, -74 + i * 0.001);
|
|
const path = createRailPath(coords);
|
|
for (const [lat, lon] of [
|
|
[40.004, -73.9],
|
|
[40.02, -73.75],
|
|
[39.99, -73.6],
|
|
]) {
|
|
const fast = projectOnPath(path, lat, lon);
|
|
let best = Infinity;
|
|
for (let i = 0; i < path.n - 1; i++) {
|
|
const hit = projectOnPath(
|
|
path,
|
|
lat,
|
|
lon,
|
|
(path.s[i] + path.s[i + 1]) / 2,
|
|
0,
|
|
);
|
|
best = Math.min(best, hit.offsetM);
|
|
}
|
|
assert.ok(
|
|
Math.abs(fast.offsetM - best) < 1e-6,
|
|
`${fast.offsetM} vs ${best}`,
|
|
);
|
|
}
|
|
});
|
|
|
|
test('the point at an arc length carries the direction of travel', () => {
|
|
const path = lPath();
|
|
const d = 1 / M_PER_DEG;
|
|
const east = pointAtS(path, 250);
|
|
assert.ok(Math.abs(east.lon - 250 * d) < 1e-9);
|
|
assert.equal(east.lat, 0);
|
|
assert.ok(Math.abs(east.bearingDeg - 90) < 1e-6, String(east.bearingDeg));
|
|
const north = pointAtS(path, 1500);
|
|
assert.ok(Math.abs(north.lat - 500 * d) < 1e-6);
|
|
assert.ok(
|
|
Math.abs(north.bearingDeg - 0) < 1e-6 ||
|
|
Math.abs(north.bearingDeg - 360) < 1e-6,
|
|
);
|
|
const out = {};
|
|
assert.equal(pointAtS(path, -50, out), out, 'writes into the caller scratch');
|
|
assert.equal(out.lat, 0);
|
|
assert.equal(out.lon, 0);
|
|
assert.ok(Math.abs(pointAtS(path, 9e9).lat - 1000 * d) < 1e-9);
|
|
assert.equal(pointAtS(path, Number.NaN), null);
|
|
});
|
|
|
|
test('a pack resolves trips and routes to shapes and builds paths lazily', () => {
|
|
const raw = {
|
|
version: RAIL_PATH_PACK_VERSION,
|
|
feedId: 'grt-ion',
|
|
shapes: [
|
|
[43.4, -80.5, 43.41, -80.5],
|
|
[43.4, -80.5, 43.4, -80.49],
|
|
],
|
|
dist: [
|
|
[0, 1113.2],
|
|
[0, 808.9],
|
|
],
|
|
trips: { t1: 0, t2: 1, bad: 7 },
|
|
routes: { 301: [0, 1, 9] },
|
|
};
|
|
const pack = createRailPathPack(raw);
|
|
assert.equal(pack.feedId, 'grt-ion');
|
|
assert.equal(pack.shapeCount, 2);
|
|
assert.equal(pack.shapeForTrip('t1'), 0);
|
|
assert.equal(pack.shapeForTrip('bad'), null, 'out-of-range index');
|
|
assert.equal(pack.shapeForTrip('constructor'), null, 'no prototype keys');
|
|
assert.equal(pack.shapeForTrip(''), null);
|
|
assert.deepEqual(pack.shapesForRoute('301'), [0, 1]);
|
|
assert.deepEqual(pack.shapesForRoute('302'), []);
|
|
const path = pack.pathAt(1);
|
|
assert.equal(path.length, 808.9);
|
|
assert.equal(pack.pathAt(1), path, 'built once');
|
|
assert.equal(pack.pathAt(5), null);
|
|
for (const broken of [
|
|
null,
|
|
{ ...raw, version: 2 },
|
|
{ ...raw, feedId: 3 },
|
|
{ ...raw, dist: [] },
|
|
])
|
|
assert.throws(() => createRailPathPack(broken), TypeError);
|
|
});
|