mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-14 07:12:21 +00:00
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:
1 parent
14db73f201
commit
830c080803
114 files changed
+26639
-26
No files matched your search
@@ -0,0 +1,409 @@
|
||||
// A Rail layer on a stub viewer, a scripted proxy and fake camera services
|
||||
// that record what the layer asks of the camera. Shared by the Rail layer's
|
||||
// tests that run a whole layer (camera follow, live-only feeds, ground
|
||||
// height, layer stats, selected route lines).
|
||||
import * as Cesium from 'cesium';
|
||||
import { createRailLayer } from '../layers/rail/index.js';
|
||||
import { CAMERA_DEBOUNCE_MS } from '../layers/rail/policy.js';
|
||||
import { WORLD_FOCUS_FRAMING } from '../worldFocus.js';
|
||||
import { publicRailCatalog } from '../data/railFeeds.js';
|
||||
|
||||
/** The tests' wall clock at the start: 2026-09-10T00:26:40Z. */
|
||||
export const RAIL_HARNESS_T0 = 1_789_000_000_000;
|
||||
export const BOSTON = Object.freeze({ lat: 42.3601, lon: -71.0589 });
|
||||
|
||||
/** A train as the rail proxy sends it. */
|
||||
export function railTrain(id, lat, lon, extra = {}) {
|
||||
return {
|
||||
id,
|
||||
lat,
|
||||
lon,
|
||||
bearing: null,
|
||||
speedMps: null,
|
||||
timestamp: Math.floor(Date.now() / 1000),
|
||||
timestampSource: 'vehicle',
|
||||
routeId: 'R1',
|
||||
tripId: `trip-${id}`,
|
||||
label: id,
|
||||
category: 'regional',
|
||||
mode: 'rail',
|
||||
positionSource: 'gps',
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
/** A canvas good enough for the sprite drawing: every 2D call is a no-op. */
|
||||
function fakeCanvas() {
|
||||
const context = new Proxy(
|
||||
{},
|
||||
{
|
||||
get: (target, key) => (key in target ? target[key] : () => {}),
|
||||
set: (target, key, value) => {
|
||||
target[key] = value;
|
||||
return true;
|
||||
},
|
||||
},
|
||||
);
|
||||
return { width: 0, height: 0, getContext: () => context };
|
||||
}
|
||||
|
||||
const headersOf = (values = {}) => ({
|
||||
get: (name) => values[String(name).toLowerCase()] ?? null,
|
||||
});
|
||||
|
||||
/**
|
||||
* The harness. `feeds` scripts each feed's live answer, one call per poll:
|
||||
* an array of trains (200), `{positions: 'schedule'}` (a 200 answer with
|
||||
* no live positions, which the layer must not draw), `{status, backoff?, retryInSec?}` (a failure as the proxy
|
||||
* answers it), or nothing (502). `schedules` holds the schedule packs the
|
||||
* proxy serves for stop labels (404 otherwise).
|
||||
* `requested`, `requestedSchedules` and `requestedPaths` list the feed ids
|
||||
* asked for, in order.
|
||||
* @param {import('node:test').TestContext} t
|
||||
* @param {{at?: {lat: number, lon: number}, altitude?: number,
|
||||
* catalog?: object[]|Promise<object[]>|Function|false,
|
||||
* ground?: object,
|
||||
* stops?: object|null, scheduleStops?: boolean,
|
||||
* paths?: Record<string, object>|null}} [options]
|
||||
* `ground`: a floor service in place of the always-cold stub; `catalog`:
|
||||
* the server's feed catalog (it may list fewer feeds than are bundled; a
|
||||
* promise answers when it settles, a function is called per request and
|
||||
* answers so); without it, a server with every pack built (all enabled
|
||||
* feeds); `false` for a source with no catalog at all;
|
||||
* `stops`: a stops provider (none by default); `scheduleStops`: the
|
||||
* layer's own provider, its schedule tier, in its place;
|
||||
* `paths`: feed id → the path pack the proxy serves, or a
|
||||
* failure status it answers with (404 for any other feed; without it the
|
||||
* source has no path route at all).
|
||||
*/
|
||||
export function railLayerHarness(
|
||||
t,
|
||||
{
|
||||
at = BOSTON,
|
||||
altitude = 20_000,
|
||||
catalog = undefined,
|
||||
ground = null,
|
||||
stops = null,
|
||||
scheduleStops = false,
|
||||
paths = null,
|
||||
} = {},
|
||||
) {
|
||||
const T0 = RAIL_HARNESS_T0;
|
||||
t.mock.timers.enable({
|
||||
apis: ['Date', 'setTimeout', 'setInterval'],
|
||||
now: T0,
|
||||
});
|
||||
t.mock.method(performance, 'now', () => Date.now() - T0);
|
||||
for (const name of [
|
||||
'HTMLCanvasElement',
|
||||
'HTMLImageElement',
|
||||
'ImageBitmap',
|
||||
'OffscreenCanvas',
|
||||
]) {
|
||||
const prior = globalThis[name];
|
||||
globalThis[name] = class {};
|
||||
t.after(() => {
|
||||
if (prior === undefined) delete globalThis[name];
|
||||
else globalThis[name] = prior;
|
||||
});
|
||||
}
|
||||
const originalDocument = globalThis.document;
|
||||
const originalWindow = globalThis.window;
|
||||
globalThis.document = Object.assign(new EventTarget(), {
|
||||
visibilityState: 'visible',
|
||||
documentElement: { dataset: {} },
|
||||
createElement: () => fakeCanvas(),
|
||||
});
|
||||
globalThis.window = new EventTarget();
|
||||
// The layer logs its feeds and catalog.
|
||||
t.mock.method(console, 'info', () => {});
|
||||
t.mock.method(console, 'log', () => {});
|
||||
|
||||
const view = { lat: at.lat, lon: at.lon, altitude };
|
||||
const cameraChanged = new Cesium.Event();
|
||||
const trackedEntityChanged = new Cesium.Event();
|
||||
const preRender = new Cesium.Event();
|
||||
const primitives = [];
|
||||
const primitiveCollection = {
|
||||
add(primitive) {
|
||||
primitives.push(primitive);
|
||||
return primitive;
|
||||
},
|
||||
remove(primitive) {
|
||||
const index = primitives.indexOf(primitive);
|
||||
if (index >= 0) primitives.splice(index, 1);
|
||||
return index >= 0;
|
||||
},
|
||||
contains: (primitive) => primitives.includes(primitive),
|
||||
raiseToTop() {},
|
||||
get length() {
|
||||
return primitives.length;
|
||||
},
|
||||
get: (index) => primitives[index],
|
||||
};
|
||||
let trackedEntity;
|
||||
const viewer = {
|
||||
entities: new Cesium.EntityCollection(),
|
||||
get trackedEntity() {
|
||||
return trackedEntity;
|
||||
},
|
||||
// Cesium's own setter raises the event on every change, as here.
|
||||
set trackedEntity(value) {
|
||||
if (trackedEntity === value) return;
|
||||
trackedEntity = value;
|
||||
trackedEntityChanged.raiseEvent(value);
|
||||
},
|
||||
trackedEntityChanged,
|
||||
isDestroyed: () => false,
|
||||
camera: {
|
||||
changed: cameraChanged,
|
||||
percentageChanged: 0.5,
|
||||
heading: 0,
|
||||
flightsCancelled: 0,
|
||||
positionWC: Cesium.Cartesian3.fromDegrees(at.lon, at.lat, altitude),
|
||||
get positionCartographic() {
|
||||
return {
|
||||
height: view.altitude,
|
||||
latitude: Cesium.Math.toRadians(view.lat),
|
||||
longitude: Cesium.Math.toRadians(view.lon),
|
||||
};
|
||||
},
|
||||
computeViewRectangle() {
|
||||
return Cesium.Rectangle.fromDegrees(
|
||||
view.lon - 0.1,
|
||||
view.lat - 0.1,
|
||||
view.lon + 0.1,
|
||||
view.lat + 0.1,
|
||||
);
|
||||
},
|
||||
cancelFlight() {
|
||||
this.flightsCancelled += 1;
|
||||
},
|
||||
},
|
||||
scene: {
|
||||
canvas: { addEventListener() {}, removeEventListener() {} },
|
||||
globe: { ellipsoid: Cesium.Ellipsoid.WGS84 },
|
||||
pick: () => undefined,
|
||||
requestRender() {},
|
||||
preRender,
|
||||
primitives: primitiveCollection,
|
||||
groundPrimitives: primitiveCollection,
|
||||
},
|
||||
};
|
||||
|
||||
const feeds = new Map();
|
||||
const schedules = new Map();
|
||||
const requested = [];
|
||||
const requestedSchedules = [];
|
||||
const requestedPaths = [];
|
||||
/** When each catalog request was made. */
|
||||
const catalogRequests = [];
|
||||
/**
|
||||
* A pack as the proxy serves it: 200 with its body, a number as that
|
||||
* failure status, else 404.
|
||||
*/
|
||||
const packAnswer = (raw) =>
|
||||
raw && typeof raw === 'object'
|
||||
? { ok: true, status: 200, headers: headersOf(), json: async () => raw }
|
||||
: {
|
||||
ok: false,
|
||||
status: typeof raw === 'number' ? raw : 404,
|
||||
headers: headersOf(),
|
||||
json: async () => ({}),
|
||||
};
|
||||
const source = {
|
||||
async requestSnapshot(feedId) {
|
||||
requested.push(feedId);
|
||||
const answer = feeds.get(feedId)?.() ?? null;
|
||||
if (Array.isArray(answer))
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: headersOf(),
|
||||
json: async () => ({ fetchedAt: Date.now(), trains: answer }),
|
||||
};
|
||||
if (answer?.positions === 'schedule')
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: headersOf({ 'x-gev-cache': 'SCHEDULE' }),
|
||||
json: async () => ({
|
||||
fetchedAt: Date.now(),
|
||||
positions: 'schedule',
|
||||
trains: [],
|
||||
}),
|
||||
};
|
||||
const status = answer?.status ?? 502;
|
||||
return {
|
||||
ok: false,
|
||||
status,
|
||||
headers: headersOf(
|
||||
answer?.backoff ? { 'x-rail-backoff': answer.backoff } : {},
|
||||
),
|
||||
json: async () => ({
|
||||
error: 'Rail feed unavailable',
|
||||
...(Number.isFinite(answer?.retryInSec)
|
||||
? { retryInSec: answer.retryInSec }
|
||||
: {}),
|
||||
}),
|
||||
};
|
||||
},
|
||||
async requestSchedule(feedId) {
|
||||
requestedSchedules.push(feedId);
|
||||
return packAnswer(schedules.get(feedId));
|
||||
},
|
||||
...(paths
|
||||
? {
|
||||
async requestPaths(feedId) {
|
||||
requestedPaths.push(feedId);
|
||||
return packAnswer(paths[feedId]);
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(catalog === false
|
||||
? {}
|
||||
: {
|
||||
async getFeeds() {
|
||||
catalogRequests.push(Date.now());
|
||||
if (catalog === undefined) return publicRailCatalog();
|
||||
return typeof catalog === 'function' ? catalog() : catalog;
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
const focusRequests = [];
|
||||
const frames = [];
|
||||
/** Overlay source id → the entries last published to it. */
|
||||
const overlayEntries = new Map();
|
||||
let cockpit = false;
|
||||
const layer = createRailLayer({
|
||||
source,
|
||||
// Left out, the layer reads stops from its own schedule tier.
|
||||
...(scheduleStops ? {} : { stops }),
|
||||
services: {
|
||||
render: {
|
||||
governorRequestRender() {},
|
||||
holdContinuousRender() {},
|
||||
releaseContinuousRender() {},
|
||||
},
|
||||
sprites: {
|
||||
registerSpriteCollection() {},
|
||||
unregisterSpriteCollection() {},
|
||||
restoreSpriteOrder() {},
|
||||
},
|
||||
picking: { registerPickOwner() {}, unregisterPickOwner() {} },
|
||||
overlays: {
|
||||
setOverlayEntries(sourceId, entries) {
|
||||
overlayEntries.set(sourceId, entries);
|
||||
},
|
||||
setOverlaySourceVisible() {},
|
||||
clearOverlaySource() {},
|
||||
},
|
||||
credits: {
|
||||
registerDynamicCredit() {},
|
||||
railFeedCredit: (feed) => ({ key: `rail-${feed.id}`, html: feed.id }),
|
||||
},
|
||||
ground: ground ?? {
|
||||
GROUND_FLOOR_LIFT_M: 1.5,
|
||||
cachedGroundFloor: () => null,
|
||||
warmGroundFloor() {},
|
||||
},
|
||||
camera: {
|
||||
framing: WORLD_FOCUS_FRAMING.train,
|
||||
requestWorldFocus(detail) {
|
||||
focusRequests.push(detail);
|
||||
return true;
|
||||
},
|
||||
applyTrackedCameraFrame(_viewer, entity, viewFrom) {
|
||||
const frame = { entity, viewFrom, stopped: false };
|
||||
frames.push(frame);
|
||||
return () => {
|
||||
frame.stopped = true;
|
||||
};
|
||||
},
|
||||
isCockpitActive: () => cockpit,
|
||||
},
|
||||
},
|
||||
});
|
||||
layer.init(viewer);
|
||||
|
||||
t.after(() => {
|
||||
try {
|
||||
layer.destroy(viewer);
|
||||
} catch {
|
||||
/* a test may already have destroyed it */
|
||||
}
|
||||
if (originalDocument === undefined) delete globalThis.document;
|
||||
else globalThis.document = originalDocument;
|
||||
if (originalWindow === undefined) delete globalThis.window;
|
||||
else globalThis.window = originalWindow;
|
||||
});
|
||||
|
||||
const state = () => layer._railStateForTest();
|
||||
const parts = () => layer._railPartsForTest();
|
||||
/** Let pending promises (a poll's fetch, JSON and packs) settle. */
|
||||
const settle = async () => {
|
||||
for (let i = 0; i < 24; i += 1) await Promise.resolve();
|
||||
};
|
||||
|
||||
return {
|
||||
layer,
|
||||
viewer,
|
||||
state,
|
||||
parts,
|
||||
feeds,
|
||||
schedules,
|
||||
requested,
|
||||
requestedSchedules,
|
||||
requestedPaths,
|
||||
catalogRequests,
|
||||
primitives,
|
||||
overlayEntries,
|
||||
focusRequests,
|
||||
frames,
|
||||
settle,
|
||||
setCockpit(value) {
|
||||
cockpit = value;
|
||||
},
|
||||
entry: (key) => state()._trains.get(key),
|
||||
async enable() {
|
||||
layer.enable(viewer);
|
||||
await settle();
|
||||
t.mock.timers.tick(0);
|
||||
await settle();
|
||||
},
|
||||
async poll() {
|
||||
await layer.update();
|
||||
await settle();
|
||||
// The duplicate filter runs off the poll's stack.
|
||||
t.mock.timers.tick(0);
|
||||
await settle();
|
||||
},
|
||||
/** Wall time with frames along the way, as in the app. */
|
||||
run(ms, step = 250) {
|
||||
const until = Date.now() + ms;
|
||||
while (Date.now() < until) {
|
||||
t.mock.timers.tick(Math.min(step, until - Date.now()));
|
||||
preRender.raiseEvent();
|
||||
}
|
||||
},
|
||||
/** Move the camera and let the debounced feed check run. */
|
||||
async moveCamera(next) {
|
||||
Object.assign(view, next);
|
||||
cameraChanged.raiseEvent();
|
||||
t.mock.timers.tick(CAMERA_DEBOUNCE_MS);
|
||||
await settle();
|
||||
},
|
||||
/** Let a deferred feed check (after a selection change) run. */
|
||||
async recheck() {
|
||||
t.mock.timers.tick(0);
|
||||
await settle();
|
||||
},
|
||||
trackedPosition() {
|
||||
return viewer.trackedEntity?.position?.getValue(
|
||||
Cesium.JulianDate.now(),
|
||||
new Cesium.Cartesian3(),
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user