mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
feat(transit): live public-transit vehicles from open GTFS-Realtime feeds (#587)
Supersedes #218 by @spcpza, ported onto the current component layout with fixes from review. Verified before merge: format, package boundaries, unit suite, build, and the browser tracking suite; a browser harness for the change where one applies; adversarial review applied. Contributor: @spcpza. His original commit is the base of this branch, and his own re-port onto main (2026-09-15, `5b69011`) was consulted while finishing the height work. That port reads every vehicle's surface from the app's shared ground-floor system (`cachedGroundFloor` / `warmGroundFloor` / `GROUND_FLOOR_LIFT_M`) rather than sampling the scene per layer, keeps a vehicle hidden until its cell answers, warms a bounded batch per poll on a timer instead of per frame, and renders at the ellipsoid above the altitude gate. That is the sounder primitive — one floor system for the app, with the rooftop-versus-street question already settled against a datum-correct DEM prior — and this branch's height work was rebuilt on it, replacing a bespoke cell cache, ground prior and rooftop rule written here. Credit for that direction is his. Future work (not in this PR), both written up in docs/CURRENT-STATE.md: - Readable route names. The realtime feeds carry only `route_id`; `route_short_name` / `route_long_name` live in each operator's static GTFS `routes.txt`, and headsigns in `trips.txt`. Doing it properly wants a server-side metadata loader beside the transit proxy with fixed registered static sources, bounded indexes built once and persisted, a slow conditional refresh, and a versioned route dictionary the browser caches. Shipped here: ids are never rewritten, and a cut one carries an ellipsis instead of reading as a typo. - Below-ground rendering. Subway vehicles are drawn on the surface directly above themselves, because the feeds carry no depth and a train nobody can see is not worth drawing; the card says so plainly. Real treatment wants OSM tunnel geometry per city and a ghosted sprite tethered to the surface. The owner recorded it and left it. Round four and after (2026-09-15). The owner tested round three under FLIR and NVG, and then asked for the design to be taken further: elegant in performance, beautiful on camera, trails with a cache for public transport, Cesium-native, and a core other moving layers can adopt. Codex Astra reviewed the plan (it confirmed the speed fault: a glide started from the drawn position at receipt time but spanned the gap between report times, so with MBTA reporting every ~10 s against a 15 s poll every second glide ran at 1.5x; it rejected a queued-segment patch), then wrote the design, then implemented it in commit-sized passes with adversarial review between them and real-browser harness runs by the integrator. What this PR now contains beyond the contributor's original port: - Delayed playback in a contact-agnostic core (`src/data/contactPlayback.js`): each vehicle keeps a typed, bounded fix history and a display clock that runs at one second per second through it, a feed-derived lag behind real time, holding at its newest fix rather than extrapolating and never sprinting to catch up. Marker, course, motion word, card and trail all read the same displayed sample. The clock has an explicit time-offset and seek API so a rewind is a later feature, not a rewrite; flights and vessels are untouched. - A selected-vehicle trail (`src/data/contactTrailRenderer.js`, `src/layers/transit/trails.js`): the retained history drawn with batched Cesium primitives, clipped at display time so the head is exactly the sprite, faded by age, in the mode colour (white under sensor presets), on the same surface path as the marker. - Bounded proxy-side history (`server/providers/transitHistory.js`): fifteen minutes and at most 128 fixes per vehicle with hard byte ceilings, MBTA only for now (opt-in per feed), served by a small endpoint that never fetches upstream, so a selected trail survives a browser reload while the server keeps running. - Scene work bounded to what is visible and moving, an occluder, cached DETECT candidates, holds on the render loop only while something visible moves. - Sensor presets: white-hot haloed sprites under NVG/FLIR/noir (black-hot inverts), saturated mode colours under CRT, per-mode bracket tiers in every theme, the selected card keeping its mode colour. - A discriminating browser harness (`scripts/qa-transit.mjs`) that samples postRender frames, reads the real card anchor, and measures fleet budgets at 800 and 3,000 vehicles. Not built, recorded as follow-ups: curated route display names (lowest priority per the owner), fleet-wide tails, route snapping, a rewind UI, adoption of the playback core by flights and vessels. Verified at merge (2026-09-15 morning): unit suite 3,896 passing, build clean, format and boundary gates clean, tracking regression 109/109, browser harness keyless 200 pass / 0 fail (13 sensor checks unexercised because the live view offered fewer than six isolated sprites; every judged sprite passed), keyed on Google 3D tiles 198 pass with the per-frame allocation gate as the only failure. Follow-ups recorded: the keyed build's playback sampler still allocates per frame (CPU and frame time stay within budget; the 3,000-vehicle metro view sits at a 27.6 ms frame interval against a 25 ms budget), the shared detection overlay's per-paint allocation, friendlier route names, fleet-wide tails, a rewind UI, and adopting the playback core for vessels and flights. Co-authored-by: spcpza <[email protected]>
This commit is contained in:
1 parent
1ad565c8af
commit
76c20be277
78 files changed
+19842
-186
No files matched your search
@@ -6,6 +6,7 @@ const CONTROL_LAYER_IDS = Object.freeze({
|
||||
cctvLayer: 'cctv',
|
||||
radioLayer: 'radio',
|
||||
bikeshareLayer: 'bikeshare',
|
||||
transitLayer: 'transit',
|
||||
aisLiveVesselsLayer: 'ais-live-vessels',
|
||||
militaryAwarenessLayer: 'military-awareness',
|
||||
militaryInstallationsLayer: 'military-installations',
|
||||
|
||||
@@ -88,6 +88,7 @@ test('controls bind catalog instances rather than similarly named defaults', ()
|
||||
'cctv',
|
||||
'radio',
|
||||
'bikeshare',
|
||||
'transit',
|
||||
'ais-live-vessels',
|
||||
'military-awareness',
|
||||
'military-installations',
|
||||
@@ -97,7 +98,8 @@ test('controls bind catalog instances rather than similarly named defaults', ()
|
||||
const catalog = createLayerCatalog(layers, metadata(layers));
|
||||
const services = catalogControlServices(catalog);
|
||||
assert.equal(services.flightsLayer, layers[1]);
|
||||
assert.equal(services.aisLiveVesselsLayer, layers[7]);
|
||||
assert.equal(services.transitLayer, layers[7]);
|
||||
assert.equal(services.aisLiveVesselsLayer, layers[8]);
|
||||
assert.equal(new Set(Object.values(services)).size, layers.length);
|
||||
assert.throws(
|
||||
() => catalogControlServices(createLayerCatalog([], [])),
|
||||
|
||||
@@ -9,6 +9,7 @@ import { createApplicationRadio } from './layers/radio.js';
|
||||
import { createApplicationTraffic } from './layers/traffic.js';
|
||||
import { createApplicationBikeshare } from './layers/bikeshare.js';
|
||||
import { createApplicationDirections } from './layers/directions.js';
|
||||
import { createApplicationTransit } from './layers/transit.js';
|
||||
import { createApplicationInstallations } from './layers/militaryInstallations.js';
|
||||
import { createApplicationSatellites } from './layers/satellites.js';
|
||||
import { createApplicationLaunches } from './layers/rocketLaunches.js';
|
||||
@@ -111,6 +112,7 @@ export function createApplicationCatalog({
|
||||
createApplicationRadio({ surface, source: sources.radio }),
|
||||
createApplicationBikeshare({ source: sources.bikeshare }),
|
||||
createApplicationDirections(),
|
||||
createApplicationTransit({ surface }),
|
||||
vessels,
|
||||
installations,
|
||||
createApplicationAwareness({
|
||||
|
||||
@@ -38,7 +38,7 @@ test('catalogs construct distinct layers and classification from their supplied
|
||||
signal: b.signal,
|
||||
surface: fixtureSurface(b.signal),
|
||||
});
|
||||
assert.equal(first.layers.length, 18);
|
||||
assert.equal(first.layers.length, 19);
|
||||
assert.deepEqual(
|
||||
first.layers.map(({ id }) => id),
|
||||
second.layers.map(({ id }) => id),
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { createTransitLayer } from '../../layers/transit/index.js';
|
||||
import * as render from '../../renderGovernor.js';
|
||||
import * as sprites from '../../data/spriteOrder.js';
|
||||
import * as picking from '../../data/pickRegistry.js';
|
||||
import * as overlays from '../../overlays/worldOverlay.js';
|
||||
import {
|
||||
registerDynamicCredit,
|
||||
transitFeedCredit,
|
||||
} from '../../data/dataCredits.js';
|
||||
|
||||
/**
|
||||
* Construct one layer using the application scene owners and the
|
||||
* application's surface services.
|
||||
*
|
||||
* The floor comes from `surface.groundFloor` — the ONE instance aircraft,
|
||||
* trails, fires and camera geometry stand on — and the rendered-mesh sampler
|
||||
* from `surface.meshFloor`, so a mesh floor the application resolves for a
|
||||
* cell is the floor transit draws on, and a transit-only session warms its
|
||||
* own mesh floors. An earlier cut imported the legacy module-level singleton
|
||||
* instead and could not see either.
|
||||
*
|
||||
* @param {{surface: object}} options
|
||||
* @returns {object}
|
||||
*/
|
||||
export function createApplicationTransit({ surface }) {
|
||||
if (!surface?.groundFloor || !surface?.meshFloor)
|
||||
throw new TypeError('Transit needs the application surface services');
|
||||
const { groundFloor, meshFloor } = surface;
|
||||
return createTransitLayer({
|
||||
services: {
|
||||
render,
|
||||
sprites,
|
||||
picking,
|
||||
overlays,
|
||||
credits: { registerDynamicCredit, transitFeedCredit },
|
||||
ground: {
|
||||
GROUND_FLOOR_LIFT_M: groundFloor.GROUND_FLOOR_LIFT_M,
|
||||
cachedGroundFloor: groundFloor.cachedGroundFloor,
|
||||
coarseFloorCoord: groundFloor.coarseFloorCoord,
|
||||
neighborFloorM: groundFloor.neighborFloorM,
|
||||
warmGroundFloor: groundFloor.warmGroundFloor,
|
||||
corridorFloorCells: groundFloor.corridorFloorCells,
|
||||
allocateCorridorCells: groundFloor.allocateCorridorCells,
|
||||
},
|
||||
mesh: { sampleMeshFloorCells: meshFloor.sampleMeshFloorCells },
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createSurfaceServices } from '../surfaceServices.js';
|
||||
import { createApplicationTransit } from './transit.js';
|
||||
import { createApplicationCatalog } from '../constructCatalog.js';
|
||||
import { createStandaloneLayerSources } from '../../standalone/layerSources.js';
|
||||
|
||||
/** A terrain source that answers every point with one ellipsoidal height. */
|
||||
function fixtureSurface(signal, demM) {
|
||||
return createSurfaceServices({
|
||||
terrainSource: {
|
||||
getHeights: async (chunk) => chunk.map(() => ({ ellipsoid: demM })),
|
||||
},
|
||||
signal,
|
||||
eventTarget: null,
|
||||
});
|
||||
}
|
||||
|
||||
test('transit reads the mesh floor the application resolved, through the application surface', async (t) => {
|
||||
// The floor transit draws on is the ONE the application constructs — the
|
||||
// instance aircraft and camera geometry stand on — not a module-level copy.
|
||||
// An earlier cut imported the legacy singleton and could not see a mesh
|
||||
// floor the app had already resolved for a cell; it drew on bare earth.
|
||||
const lifetime = new AbortController();
|
||||
t.after(() => lifetime.abort());
|
||||
const surface = fixtureSurface(lifetime.signal, 20);
|
||||
const layer = createApplicationTransit({ surface });
|
||||
const height = layer._transitPartsForTest().height;
|
||||
const lat = 42.3601;
|
||||
const lon = -71.0589;
|
||||
|
||||
// Nothing warm yet: the layer has no floor and no prior to borrow.
|
||||
assert.equal(height.requestHeight(lat, lon).height, null);
|
||||
|
||||
// The application resolves the DEM for that cell...
|
||||
await surface.groundFloor.resolveGroundFloorCells([{ lat, lon }]);
|
||||
const lift = surface.groundFloor.GROUND_FLOOR_LIFT_M;
|
||||
assert.equal(
|
||||
height.requestHeight(lat, lon).height,
|
||||
20 + lift,
|
||||
'the DEM floor, lifted',
|
||||
);
|
||||
|
||||
// ...and then a rendered-mesh sample lands on the same cell, seventeen
|
||||
// metres above bare earth, the way a photogrammetric street sits.
|
||||
assert.equal(
|
||||
surface.groundFloor.reportValidatedMeshFloorCell(lat, lon, 37),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
surface.groundFloor.cachedGroundFloor(lat, lon),
|
||||
37,
|
||||
'the app now floors on the mesh',
|
||||
);
|
||||
assert.equal(
|
||||
height.requestHeight(lat, lon).height,
|
||||
37 + lift,
|
||||
'and so does transit — the same number',
|
||||
);
|
||||
|
||||
// Leaving the photoreal regime drops back to the DEM for everyone at once.
|
||||
surface.groundFloor.setMeshFloorPreferred(false);
|
||||
assert.equal(height.requestHeight(lat, lon).height, 20 + lift);
|
||||
});
|
||||
|
||||
test('the catalog hands transit the same surface it hands everything else', async (t) => {
|
||||
const lifetime = new AbortController();
|
||||
t.after(() => lifetime.abort());
|
||||
const surface = fixtureSurface(lifetime.signal, 5);
|
||||
const catalog = createApplicationCatalog({
|
||||
sources: createStandaloneLayerSources(),
|
||||
signal: lifetime.signal,
|
||||
surface,
|
||||
});
|
||||
const transit = catalog.get('transit');
|
||||
assert.ok(transit, 'transit is in the catalog');
|
||||
const lat = 30.2672;
|
||||
const lon = -97.7431;
|
||||
await surface.groundFloor.resolveGroundFloorCells([{ lat, lon }]);
|
||||
surface.groundFloor.reportValidatedMeshFloorCell(lat, lon, 22);
|
||||
assert.equal(
|
||||
transit._transitPartsForTest().height.requestHeight(lat, lon).height,
|
||||
22 + surface.groundFloor.GROUND_FLOOR_LIFT_M,
|
||||
);
|
||||
});
|
||||
|
||||
test('transit refuses to build without the application surface', () => {
|
||||
assert.throws(() => createApplicationTransit({}), /surface/);
|
||||
assert.throws(
|
||||
() => createApplicationTransit({ surface: { groundFloor: {} } }),
|
||||
/surface/,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
// src/data/cameraSensitivity.js
|
||||
/**
|
||||
* Shared claims on `camera.percentageChanged`.
|
||||
*
|
||||
* `camera.changed` fires once the view has moved by this fraction, and it is a
|
||||
* single number on the camera that every listener in the app shares. Several
|
||||
* layers want it finer than the default — traffic, bikeshare and transit all
|
||||
* ask for 0.05 so they notice they have entered a coverage area — and each of
|
||||
* them used to write the number directly and hand back whatever it remembered.
|
||||
*
|
||||
* That cannot work, because a number is not an owner. Two layers asking for the
|
||||
* same 0.05 write the same value, so when the first one leaves it sees its own
|
||||
* number still in place, concludes nothing else has claimed it, and restores
|
||||
* the coarse default out from under a layer that is still running. The symptom
|
||||
* is a camera-driven layer that quietly stops noticing the camera: Transit is
|
||||
* turned off and Bikeshare stops loading new cities until something else nudges
|
||||
* it.
|
||||
*
|
||||
* So ownership is tracked explicitly. Claims are named, the camera gets the
|
||||
* finest value anyone has asked for, and the original value returns only when
|
||||
* the last claim is gone. Claiming twice under one name replaces that claim.
|
||||
*
|
||||
* This is bookkeeping, not policy: it never decides what a layer should ask
|
||||
* for, only that nobody's answer is taken away while they still need it.
|
||||
*/
|
||||
|
||||
/** @type {WeakMap<object, {base: number, claims: Map<string, number>}>} */
|
||||
const _cameras = new WeakMap();
|
||||
|
||||
/** The value Cesium starts a camera at, used when the camera reports nothing. */
|
||||
const DEFAULT_PERCENTAGE_CHANGED = 0.5;
|
||||
|
||||
function ledgerFor(camera) {
|
||||
let ledger = _cameras.get(camera);
|
||||
if (!ledger) {
|
||||
const base = Number.isFinite(camera.percentageChanged)
|
||||
? camera.percentageChanged
|
||||
: DEFAULT_PERCENTAGE_CHANGED;
|
||||
ledger = { base, claims: new Map() };
|
||||
_cameras.set(camera, ledger);
|
||||
}
|
||||
return ledger;
|
||||
}
|
||||
|
||||
function applyLedger(camera, ledger) {
|
||||
let value = ledger.base;
|
||||
for (const claimed of ledger.claims.values()) {
|
||||
if (claimed < value) value = claimed;
|
||||
}
|
||||
camera.percentageChanged = value;
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask for a camera-change sensitivity at least as fine as `value`.
|
||||
* @param {object|null|undefined} camera A Cesium camera (or anything with `percentageChanged`).
|
||||
* @param {string} owner Stable claim name, usually the layer id.
|
||||
* @param {number} value Requested `percentageChanged`.
|
||||
* @returns {number|null} The value now in force, or null when there is no camera.
|
||||
*/
|
||||
export function claimCameraSensitivity(camera, owner, value) {
|
||||
if (!camera || !owner || !Number.isFinite(value)) return null;
|
||||
const ledger = ledgerFor(camera);
|
||||
ledger.claims.set(owner, value);
|
||||
return applyLedger(camera, ledger);
|
||||
}
|
||||
|
||||
/**
|
||||
* Give up one claim. The camera keeps the finest value still claimed by
|
||||
* somebody else, and returns to its original only when none are left.
|
||||
* @param {object|null|undefined} camera
|
||||
* @param {string} owner
|
||||
* @returns {number|null} The value now in force, or null when there is no camera.
|
||||
*/
|
||||
export function releaseCameraSensitivity(camera, owner) {
|
||||
if (!camera || !owner) return null;
|
||||
const ledger = _cameras.get(camera);
|
||||
if (!ledger) return null;
|
||||
if (!ledger.claims.delete(owner)) return camera.percentageChanged;
|
||||
const applied = applyLedger(camera, ledger);
|
||||
if (ledger.claims.size === 0) _cameras.delete(camera);
|
||||
return applied;
|
||||
}
|
||||
|
||||
/**
|
||||
* Who currently holds a claim. For tests and diagnostics only.
|
||||
* @param {object|null|undefined} camera
|
||||
* @returns {Array<string>}
|
||||
*/
|
||||
export function cameraSensitivityClaims(camera) {
|
||||
const ledger = camera ? _cameras.get(camera) : null;
|
||||
return ledger ? [...ledger.claims.keys()] : [];
|
||||
}
|
||||
@@ -0,0 +1,691 @@
|
||||
/** Portable retained report-time playback. No scene or scheduling ownership. */
|
||||
export const FIX_BYTES = 48;
|
||||
export const FIX_FLAGS = Object.freeze({
|
||||
VEHICLE_TIME: 1,
|
||||
FEED_TIME: 2,
|
||||
RECEIPT_TIME: 4,
|
||||
FLOOR: 8,
|
||||
BREAK: 16,
|
||||
LIVE: 32,
|
||||
});
|
||||
const clampDelay = (n) => Math.max(25_000, Math.min(120_000, n));
|
||||
|
||||
export function createLagReservoir(capacity = 256) {
|
||||
return { values: new Float64Array(capacity), count: 0, cursor: 0 };
|
||||
}
|
||||
function addLag(reservoir, value) {
|
||||
if (!reservoir || !Number.isFinite(value) || value < 0) return;
|
||||
reservoir.values[reservoir.cursor] = value;
|
||||
reservoir.cursor = (reservoir.cursor + 1) % reservoir.values.length;
|
||||
reservoir.count = Math.min(reservoir.count + 1, reservoir.values.length);
|
||||
}
|
||||
function percentile(reservoir, rank) {
|
||||
if (!reservoir?.count) return 0;
|
||||
const sorted = reservoir.values.slice(0, reservoir.count).sort();
|
||||
return sorted[Math.ceil(sorted.length * rank) - 1];
|
||||
}
|
||||
|
||||
/** Optional shared payload budget. Call trimTrack on lower-priority tracks first. */
|
||||
export function createHistoryBudget(maxBytes = 32 * 1024 * 1024) {
|
||||
return { maxBytes, allocatedBytes: 0 };
|
||||
}
|
||||
/** Numeric fields are reserved up front, including unknown mesh heights. */
|
||||
export function createFixSample() {
|
||||
return {
|
||||
t: NaN,
|
||||
lat: NaN,
|
||||
lon: NaN,
|
||||
heightM: NaN,
|
||||
h: NaN,
|
||||
receivedAt: NaN,
|
||||
bearingDeg: NaN,
|
||||
flags: 0,
|
||||
epoch: 0,
|
||||
seq: -1,
|
||||
};
|
||||
}
|
||||
export function createTrack({
|
||||
capacity = 128,
|
||||
retentionMs = 900_000,
|
||||
policy = {},
|
||||
budget = null,
|
||||
} = {}) {
|
||||
capacity = Math.max(2, Math.min(128, Math.floor(capacity)));
|
||||
return {
|
||||
capacity,
|
||||
retentionMs,
|
||||
policy,
|
||||
budget,
|
||||
metricsScratchA: createFixSample(),
|
||||
metricsScratchB: createFixSample(),
|
||||
storage: new DataView(new ArrayBuffer(0)),
|
||||
slots: 0,
|
||||
head: 0,
|
||||
count: 0,
|
||||
baseSeq: 0,
|
||||
revision: 0,
|
||||
truncated: false,
|
||||
epochs: new Map(),
|
||||
lag: createLagReservoir(32),
|
||||
targetDelayMs: 25_000,
|
||||
displayT: NaN,
|
||||
wallAnchor: NaN,
|
||||
monoAnchor: NaN,
|
||||
lastMono: NaN,
|
||||
offset: 0,
|
||||
rate: 1,
|
||||
phase: 'buffering',
|
||||
resets: 0,
|
||||
resetReason: null,
|
||||
acceptedAt: NaN,
|
||||
acceptedMono: NaN,
|
||||
resumePending: false,
|
||||
bracket: 0,
|
||||
metricsRevision: -1,
|
||||
metricsIndex: -1,
|
||||
from: createFixSample(),
|
||||
to: createFixSample(),
|
||||
metrics: { speed: NaN, course: NaN, nextMotionT: Infinity },
|
||||
latest: createFixSample(),
|
||||
pending: null,
|
||||
pendingCount: 0,
|
||||
};
|
||||
}
|
||||
function address(track, index) {
|
||||
return ((track.head + index) % track.slots) * FIX_BYTES;
|
||||
}
|
||||
export function readFix(track, index, out = {}) {
|
||||
if (index < 0 || index >= track.count) return null;
|
||||
const v = track.storage,
|
||||
o = address(track, index);
|
||||
out.t = v.getFloat64(o);
|
||||
out.lat = v.getFloat64(o + 8);
|
||||
out.lon = v.getFloat64(o + 16);
|
||||
out.heightM = v.getFloat32(o + 24);
|
||||
out.h = out.heightM;
|
||||
out.receivedAt = v.getFloat64(o + 28);
|
||||
out.bearingDeg = v.getFloat32(o + 36);
|
||||
out.flags = v.getUint32(o + 40);
|
||||
out.epoch = v.getUint32(o + 44);
|
||||
out.seq = track.baseSeq + index;
|
||||
return out;
|
||||
}
|
||||
function writeFix(track, index, fix) {
|
||||
const v = track.storage,
|
||||
o = address(track, index);
|
||||
v.setFloat64(o, fix.t);
|
||||
v.setFloat64(o + 8, fix.lat);
|
||||
v.setFloat64(o + 16, fix.lon);
|
||||
v.setFloat32(o + 24, fix.heightM ?? fix.h ?? NaN);
|
||||
v.setFloat64(o + 28, fix.receivedAt ?? NaN);
|
||||
v.setFloat32(o + 36, fix.bearingDeg ?? NaN);
|
||||
v.setUint32(o + 40, fix.flags || 0);
|
||||
v.setUint32(o + 44, fix.epoch || 0);
|
||||
}
|
||||
function resize(track, slots) {
|
||||
const bytes = slots * FIX_BYTES,
|
||||
delta = bytes - track.storage.byteLength;
|
||||
if (
|
||||
delta > 0 &&
|
||||
track.budget &&
|
||||
track.budget.allocatedBytes + delta > track.budget.maxBytes
|
||||
)
|
||||
return false;
|
||||
const old = track.storage,
|
||||
head = track.head,
|
||||
oldSlots = track.slots;
|
||||
const next = new Uint8Array(bytes),
|
||||
previous = new Uint8Array(old.buffer);
|
||||
for (let i = 0; i < track.count; i++) {
|
||||
const o = ((head + i) % oldSlots) * FIX_BYTES;
|
||||
next.set(previous.subarray(o, o + FIX_BYTES), i * FIX_BYTES);
|
||||
}
|
||||
track.storage = new DataView(next.buffer);
|
||||
track.slots = slots;
|
||||
track.head = 0;
|
||||
if (track.budget) track.budget.allocatedBytes += delta;
|
||||
return true;
|
||||
}
|
||||
export function destroyTrack(track) {
|
||||
if (track.budget) track.budget.allocatedBytes -= track.storage.byteLength;
|
||||
track.storage = new DataView(new ArrayBuffer(0));
|
||||
track.count = 0;
|
||||
track.slots = 0;
|
||||
track.epochs.clear();
|
||||
}
|
||||
function timeAt(track, i) {
|
||||
return track.storage.getFloat64(address(track, i));
|
||||
}
|
||||
function dropOldest(track) {
|
||||
track.head = (track.head + 1) % track.slots;
|
||||
track.count--;
|
||||
track.baseSeq++;
|
||||
track.revision++;
|
||||
track.truncated = true;
|
||||
}
|
||||
/** Retention never evicts the bracket currently on screen. */
|
||||
export function trimTrack(track, nowMs, keep = track.capacity) {
|
||||
while (
|
||||
track.count > 2 &&
|
||||
(track.count > keep || timeAt(track, 1) < nowMs - track.retentionMs)
|
||||
) {
|
||||
if (Number.isFinite(track.displayT) && timeAt(track, 1) > track.displayT)
|
||||
break;
|
||||
dropOldest(track);
|
||||
}
|
||||
if (keep < track.slots && track.count <= keep)
|
||||
resize(track, Math.max(2, track.count));
|
||||
const used = new Set();
|
||||
for (let i = 0; i < track.count; i++)
|
||||
used.add(track.storage.getUint32(address(track, i) + 44));
|
||||
for (const id of track.epochs.keys())
|
||||
if (!used.has(id)) track.epochs.delete(id);
|
||||
}
|
||||
export function validFix(fix) {
|
||||
return (
|
||||
Number.isFinite(fix?.t) &&
|
||||
fix.t >= 0 &&
|
||||
Number.isFinite(fix.lat) &&
|
||||
Math.abs(fix.lat) <= 90 &&
|
||||
Number.isFinite(fix.lon) &&
|
||||
Math.abs(fix.lon) <= 180
|
||||
);
|
||||
}
|
||||
function distance(a, b) {
|
||||
const lon = ((b.lon - a.lon + 540) % 360) - 180;
|
||||
return Math.hypot(
|
||||
(b.lat - a.lat) * 111320,
|
||||
lon * 111320 * Math.cos((a.lat * Math.PI) / 180),
|
||||
);
|
||||
}
|
||||
function append(track, fix) {
|
||||
if (track.count === track.slots && track.slots < track.capacity)
|
||||
resize(track, Math.min(track.capacity, Math.max(8, track.slots * 2)));
|
||||
if (track.count === track.slots) {
|
||||
if (track.count < 2) return false;
|
||||
if (Number.isFinite(track.displayT) && timeAt(track, 1) > track.displayT) {
|
||||
// Pin both active ends; drop the next future observation and mark the gap.
|
||||
if (track.count < 4) return false;
|
||||
const scratch = {};
|
||||
for (let i = 2; i < track.count - 1; i++) {
|
||||
readFix(track, i + 1, scratch);
|
||||
if (i === 2) scratch.flags |= FIX_FLAGS.BREAK;
|
||||
writeFix(track, i, scratch);
|
||||
}
|
||||
track.count--;
|
||||
track.truncated = true;
|
||||
} else dropOldest(track);
|
||||
}
|
||||
writeFix(track, track.count++, fix);
|
||||
track.revision++;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function insertFix(track, fix, observation = {}) {
|
||||
if (
|
||||
!validFix(fix) ||
|
||||
(Number.isFinite(observation.wallNowMs) &&
|
||||
fix.t > observation.wallNowMs + 10_000)
|
||||
)
|
||||
return { accepted: false, reason: 'malformed' };
|
||||
const last = readFix(track, track.count - 1, track.latest);
|
||||
if (last && fix.t === last.t)
|
||||
return {
|
||||
accepted: false,
|
||||
reason:
|
||||
fix.lat === last.lat && fix.lon === last.lon ? 'repeat' : 'conflict',
|
||||
};
|
||||
let reason = last ? 'moved' : 'first';
|
||||
if (
|
||||
last &&
|
||||
(fix.t < last.t || (track.policy.accept && !track.policy.accept(last, fix)))
|
||||
) {
|
||||
reason = fix.t < last.t ? 'out-of-order' : 'implausible';
|
||||
const prior = track.pending;
|
||||
if (
|
||||
!prior ||
|
||||
prior.reason !== reason ||
|
||||
fix.t <= prior.t ||
|
||||
(track.policy.accept && !track.policy.accept(prior, fix))
|
||||
) {
|
||||
if (!prior || fix.t !== prior.t) {
|
||||
track.pending = { ...fix, reason };
|
||||
track.pendingCount = 1;
|
||||
}
|
||||
return { accepted: false, reason };
|
||||
}
|
||||
track.pendingCount++;
|
||||
const required = reason === 'out-of-order' ? 3 : 2;
|
||||
if (track.pendingCount < required) {
|
||||
track.pending = { ...fix, reason };
|
||||
return { accepted: false, reason };
|
||||
}
|
||||
// Clock correction starts a new time domain. A spatial replacement retains a visible gap.
|
||||
if (reason === 'out-of-order') {
|
||||
track.count = 0;
|
||||
track.head = 0;
|
||||
track.baseSeq += 128;
|
||||
}
|
||||
const epoch = (last.epoch + 1) >>> 0;
|
||||
append(track, {
|
||||
...prior,
|
||||
epoch,
|
||||
flags: (prior.flags || 0) | FIX_FLAGS.BREAK,
|
||||
});
|
||||
fix = { ...fix, epoch };
|
||||
if (reason === 'out-of-order') track.displayT = NaN;
|
||||
track.resetReason = reason;
|
||||
track.resets++;
|
||||
}
|
||||
const receivedAt =
|
||||
observation.receivedAt ?? fix.receivedAt ?? observation.wallNowMs ?? fix.t;
|
||||
trimTrack(track, receivedAt);
|
||||
const accepted = append(track, {
|
||||
...fix,
|
||||
receivedAt,
|
||||
flags: (fix.flags || 0) | FIX_FLAGS.LIVE,
|
||||
});
|
||||
if (accepted) {
|
||||
if (!observation.replayed) {
|
||||
if (last && fix.t > last.t && reason === 'moved') {
|
||||
addLag(track.lag, receivedAt - last.t);
|
||||
addLag(track.policy.feedLag, receivedAt - last.t);
|
||||
track.targetDelayMs = clampDelay(
|
||||
Math.max(
|
||||
percentile(track.lag, 0.95),
|
||||
percentile(track.policy.feedLag, 0.9),
|
||||
) + 5000,
|
||||
);
|
||||
} else if (!last)
|
||||
track.targetDelayMs = clampDelay(
|
||||
Math.max(0, receivedAt - fix.t) + 20_000,
|
||||
);
|
||||
}
|
||||
track.acceptedAt = receivedAt;
|
||||
track.acceptedMono = observation.monoNowMs ?? NaN;
|
||||
track.resumePending = true;
|
||||
track.pending = null;
|
||||
track.pendingCount = 0;
|
||||
}
|
||||
return { accepted, reason: accepted ? reason : 'capacity' };
|
||||
}
|
||||
|
||||
/** Bounded backfill; live positions win conflicts and the clock/health are untouched. */
|
||||
export function mergeHistory(track, fixes) {
|
||||
const merged = new Map();
|
||||
for (const fix of fixes.slice(-128))
|
||||
if (
|
||||
validFix(fix) &&
|
||||
(!Number.isFinite(track.acceptedAt) ||
|
||||
fix.t >= track.acceptedAt - track.retentionMs)
|
||||
)
|
||||
merged.set(fix.t, { ...fix, flags: (fix.flags || 0) & ~FIX_FLAGS.LIVE });
|
||||
for (let i = 0; i < track.count; i++) {
|
||||
const fix = readFix(track, i);
|
||||
merged.set(fix.t, fix);
|
||||
}
|
||||
const retained = Array.from({ length: track.count }, (_, i) =>
|
||||
readFix(track, i),
|
||||
);
|
||||
const active = Number.isFinite(track.displayT)
|
||||
? retained.findLastIndex((f) => f.t <= track.displayT)
|
||||
: -1;
|
||||
const pinnedFrom = retained[active],
|
||||
pinnedTo = retained[active + 1];
|
||||
const trusted = new Set(retained.map((f) => f.t));
|
||||
const candidates = [...merged.values()]
|
||||
.sort((a, b) => a.t - b.t)
|
||||
.filter(
|
||||
(f) =>
|
||||
trusted.has(f.t) ||
|
||||
!pinnedFrom ||
|
||||
!pinnedTo ||
|
||||
f.t < pinnedFrom.t ||
|
||||
f.t > pinnedTo.t,
|
||||
);
|
||||
const ordered = [];
|
||||
// Start from an admitted live position, including when walking backwards
|
||||
// into retained history. A lone outlier is quarantined in either direction.
|
||||
// Never borrow the live pending observation as a second vote for itself.
|
||||
function admit(sequence, anchor, backwards = false) {
|
||||
let last = anchor,
|
||||
pending = null;
|
||||
const plausible = (a, b) =>
|
||||
!track.policy.accept ||
|
||||
(backwards ? track.policy.accept(b, a) : track.policy.accept(a, b));
|
||||
for (const fix of sequence) {
|
||||
if (trusted.has(fix.t) || !last || plausible(last, fix)) {
|
||||
ordered.push(fix);
|
||||
last = fix;
|
||||
pending = null;
|
||||
} else if (pending && plausible(pending, fix)) {
|
||||
// Two distinct coherent observations establish a replacement segment.
|
||||
// Keep a visible gap between it and the authoritative live path.
|
||||
if (backwards) last.flags |= FIX_FLAGS.BREAK;
|
||||
else pending.flags |= FIX_FLAGS.BREAK;
|
||||
ordered.push(pending, fix);
|
||||
last = fix;
|
||||
pending = null;
|
||||
} else pending = fix;
|
||||
}
|
||||
}
|
||||
const first = retained[0];
|
||||
if (first) {
|
||||
admit(
|
||||
candidates.filter((f) => f.t < first.t).reverse(),
|
||||
merged.get(first.t),
|
||||
true,
|
||||
);
|
||||
admit(
|
||||
candidates.filter((f) => f.t >= first.t),
|
||||
null,
|
||||
);
|
||||
} else admit(candidates, null);
|
||||
ordered.sort((a, b) => a.t - b.t);
|
||||
while (ordered.length > track.capacity) {
|
||||
const i = ordered.findIndex(
|
||||
(f) => f.t !== pinnedFrom?.t && f.t !== pinnedTo?.t,
|
||||
);
|
||||
if (i > 0 && i + 1 < ordered.length)
|
||||
ordered[i + 1].flags |= FIX_FLAGS.BREAK;
|
||||
ordered.splice(Math.max(0, i), 1);
|
||||
track.truncated = true;
|
||||
}
|
||||
for (let i = 1; i < ordered.length; i++) {
|
||||
if (track.policy.accept && !track.policy.accept(ordered[i - 1], ordered[i]))
|
||||
ordered[i].flags |= FIX_FLAGS.BREAK;
|
||||
}
|
||||
track.count = 0;
|
||||
track.head = 0;
|
||||
track.baseSeq += 128;
|
||||
for (const fix of ordered) append(track, fix);
|
||||
track.revision++;
|
||||
track.bracket = 0;
|
||||
}
|
||||
export function correctHeight(track, seq, heightM) {
|
||||
const i = seq - track.baseSeq;
|
||||
if (i < 0 || i >= track.count || !Number.isFinite(heightM)) return false;
|
||||
const o = address(track, i),
|
||||
value = Math.fround(heightM);
|
||||
if (track.storage.getFloat32(o + 24) === value) return false;
|
||||
track.storage.setFloat32(o + 24, value);
|
||||
track.storage.setUint32(
|
||||
o + 40,
|
||||
track.storage.getUint32(o + 40) | FIX_FLAGS.FLOOR,
|
||||
);
|
||||
track.revision++;
|
||||
return true;
|
||||
}
|
||||
function anchoredWall(track, clocks) {
|
||||
if (!Number.isFinite(track.wallAnchor)) {
|
||||
track.wallAnchor = clocks.wallNowMs;
|
||||
track.monoAnchor = clocks.monoNowMs;
|
||||
}
|
||||
return track.wallAnchor + Math.max(0, clocks.monoNowMs - track.monoAnchor);
|
||||
}
|
||||
/** Stable numeric fields avoid dictionary properties and boxed doubles in hot samples. */
|
||||
export function createSample() {
|
||||
return {
|
||||
displayT: NaN,
|
||||
revision: 0,
|
||||
nextWakeMonoMs: Infinity,
|
||||
phase: 'buffering',
|
||||
motion: 'unknown',
|
||||
fromSeq: -1,
|
||||
toSeq: -1,
|
||||
lat: NaN,
|
||||
lon: NaN,
|
||||
heightM: NaN,
|
||||
segmentCourseDeg: NaN,
|
||||
segmentSpeedMps: NaN,
|
||||
fraction: NaN,
|
||||
fromT: NaN,
|
||||
toT: NaN,
|
||||
nextMotionT: Infinity,
|
||||
latestReportAgeMs: NaN,
|
||||
actualDelayMs: NaN,
|
||||
};
|
||||
}
|
||||
/** Sample an explicit report time into caller-owned storage. */
|
||||
export function sampleAt(track, displayTimeMs, out) {
|
||||
out.displayT = displayTimeMs;
|
||||
return sampleCurrent(track, out);
|
||||
}
|
||||
/** The frame path keeps its changing double in storage across this call. */
|
||||
function sampleCurrent(track, out) {
|
||||
const sampleTime = out.displayT;
|
||||
out.revision = track.revision;
|
||||
out.nextWakeMonoMs = Infinity;
|
||||
out.phase = track.phase;
|
||||
out.motion = 'unknown';
|
||||
out.fromSeq = -1;
|
||||
out.toSeq = -1;
|
||||
out.lat = NaN;
|
||||
out.lon = NaN;
|
||||
out.heightM = NaN;
|
||||
out.segmentCourseDeg = NaN;
|
||||
out.segmentSpeedMps = 0;
|
||||
if (!track.count) return out;
|
||||
let i = Math.min(track.bracket, track.count - 1);
|
||||
if (timeAt(track, i) > sampleTime) i = 0;
|
||||
while (i + 1 < track.count && timeAt(track, i + 1) <= sampleTime) i++;
|
||||
track.bracket = i;
|
||||
const a = readFix(track, i, track.from);
|
||||
const j = sampleTime < a.t ? i : Math.min(i + 1, track.count - 1);
|
||||
const b = readFix(track, j, track.to);
|
||||
const continuous =
|
||||
i !== j && a.epoch === b.epoch && !(b.flags & FIX_FLAGS.BREAK);
|
||||
out.fromSeq = a.seq;
|
||||
out.toSeq = continuous ? b.seq : a.seq;
|
||||
out.fraction = continuous
|
||||
? Math.max(0, Math.min(1, (sampleTime - a.t) / (b.t - a.t)))
|
||||
: 1;
|
||||
out.fromT = a.t;
|
||||
out.toT = continuous ? b.t : a.t;
|
||||
if (
|
||||
track.metricsRevision !== track.revision ||
|
||||
track.metricsIndex !== i ||
|
||||
track.metricsTo !== j
|
||||
) {
|
||||
const metres = continuous ? distance(a, b) : 0;
|
||||
const metrics = track.metrics;
|
||||
metrics.speed = continuous ? (metres * 1000) / (b.t - a.t) : 0;
|
||||
metrics.course = NaN;
|
||||
if (metres >= (track.policy.courseMinTravelM ?? 12)) {
|
||||
metrics.course =
|
||||
((Math.atan2(
|
||||
(((b.lon - a.lon + 540) % 360) - 180) *
|
||||
Math.cos((a.lat * Math.PI) / 180),
|
||||
b.lat - a.lat,
|
||||
) *
|
||||
180) /
|
||||
Math.PI +
|
||||
360) %
|
||||
360;
|
||||
} else {
|
||||
// Tiny displacement holds the last reliable course in this epoch.
|
||||
// With no reliable displacement yet, use the earliest known bearing.
|
||||
const p = track.metricsScratchA,
|
||||
q = track.metricsScratchB;
|
||||
for (let k = i; k >= 0; k--) {
|
||||
readFix(track, k, q);
|
||||
if (q.epoch !== a.epoch) break;
|
||||
if (Number.isFinite(q.bearingDeg)) metrics.course = q.bearingDeg;
|
||||
if (k === 0 || q.flags & FIX_FLAGS.BREAK) continue;
|
||||
readFix(track, k - 1, p);
|
||||
if (
|
||||
p.epoch === q.epoch &&
|
||||
distance(p, q) >= (track.policy.courseMinTravelM ?? 12)
|
||||
) {
|
||||
metrics.course =
|
||||
((Math.atan2(
|
||||
(((q.lon - p.lon + 540) % 360) - 180) *
|
||||
Math.cos((p.lat * Math.PI) / 180),
|
||||
q.lat - p.lat,
|
||||
) *
|
||||
180) /
|
||||
Math.PI +
|
||||
360) %
|
||||
360;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
metrics.nextMotionT = Infinity;
|
||||
const p = track.metricsScratchA,
|
||||
q = track.metricsScratchB;
|
||||
for (let k = i; k + 1 < track.count; k++) {
|
||||
readFix(track, k, p);
|
||||
readFix(track, k + 1, q);
|
||||
if (
|
||||
p.lat !== q.lat ||
|
||||
p.lon !== q.lon ||
|
||||
p.epoch !== q.epoch ||
|
||||
q.flags & FIX_FLAGS.BREAK
|
||||
) {
|
||||
metrics.nextMotionT =
|
||||
p.epoch !== q.epoch || q.flags & FIX_FLAGS.BREAK ? q.t : p.t;
|
||||
break;
|
||||
}
|
||||
}
|
||||
track.metricsRevision = track.revision;
|
||||
track.metricsIndex = i;
|
||||
track.metricsTo = j;
|
||||
}
|
||||
const f = out.fraction;
|
||||
out.lat = continuous ? a.lat + (b.lat - a.lat) * f : a.lat;
|
||||
out.lon = continuous
|
||||
? a.lon + (((b.lon - a.lon + 540) % 360) - 180) * f
|
||||
: a.lon;
|
||||
if (out.lon > 180) out.lon -= 360;
|
||||
if (out.lon < -180) out.lon += 360;
|
||||
out.heightM = continuous
|
||||
? a.heightM + (b.heightM - a.heightM) * f
|
||||
: a.heightM;
|
||||
out.segmentCourseDeg = track.metrics.course;
|
||||
out.segmentSpeedMps = track.metrics.speed;
|
||||
out.motion = continuous
|
||||
? track.metrics.speed > 0
|
||||
? 'moving'
|
||||
: 'stopped'
|
||||
: 'unknown';
|
||||
out.nextMotionT = track.metrics.nextMotionT;
|
||||
out.latestReportAgeMs = Number.isFinite(track.wallAnchor)
|
||||
? track.wallAnchor +
|
||||
track.lastMono -
|
||||
track.monoAnchor -
|
||||
timeAt(track, track.count - 1)
|
||||
: NaN;
|
||||
out.actualDelayMs = Number.isFinite(track.wallAnchor)
|
||||
? track.wallAnchor + track.lastMono - track.monoAnchor - sampleTime
|
||||
: NaN;
|
||||
return out;
|
||||
}
|
||||
export function advance(track, clocks, out) {
|
||||
const wall = anchoredWall(track, clocks),
|
||||
previousMono = track.lastMono;
|
||||
const dt = Number.isFinite(previousMono)
|
||||
? Math.max(0, clocks.monoNowMs - previousMono)
|
||||
: 0;
|
||||
track.lastMono = clocks.monoNowMs;
|
||||
if (!track.count) return sampleAt(track, NaN, out);
|
||||
const newest = timeAt(track, track.count - 1),
|
||||
oldest = timeAt(track, 0);
|
||||
const ceiling = Math.min(newest, wall - track.targetDelayMs + track.offset);
|
||||
const delayBlocked =
|
||||
track.offset === 0 &&
|
||||
track.phase !== 'playing' &&
|
||||
ceiling < track.displayT &&
|
||||
track.displayT < newest;
|
||||
if (!Number.isFinite(track.displayT))
|
||||
track.displayT = Math.min(
|
||||
newest,
|
||||
wall - track.targetDelayMs + track.offset,
|
||||
);
|
||||
else if (
|
||||
track.offset === 0 &&
|
||||
track.rate !== 0 &&
|
||||
(ceiling - track.displayT > 90_000 ||
|
||||
(track.truncated && track.displayT < oldest))
|
||||
) {
|
||||
track.resetReason =
|
||||
ceiling - track.displayT > 90_000 ? 'backlog' : 'unavailable-bracket';
|
||||
track.displayT = ceiling;
|
||||
track.resets++;
|
||||
} else if (track.rate !== 0) {
|
||||
// While playing, finish the active segment at 1x even if lag grows.
|
||||
const activeCeiling =
|
||||
track.offset < 0 || track.phase === 'playing' ? newest : ceiling;
|
||||
let next = track.displayT + dt * track.rate;
|
||||
if (track.phase === 'held' && track.resumePending) {
|
||||
const sinceReceipt = Number.isFinite(track.acceptedMono)
|
||||
? Math.max(0, clocks.monoNowMs - track.acceptedMono)
|
||||
: 0;
|
||||
next = track.displayT + Math.min(dt, sinceReceipt) * track.rate;
|
||||
}
|
||||
track.displayT = Math.max(track.displayT, Math.min(next, activeCeiling));
|
||||
}
|
||||
track.phase =
|
||||
track.rate === 0
|
||||
? 'paused'
|
||||
: track.displayT < oldest || delayBlocked
|
||||
? 'buffering'
|
||||
: track.displayT >= newest
|
||||
? 'held'
|
||||
: 'playing';
|
||||
track.resumePending = false;
|
||||
out.displayT = track.displayT;
|
||||
sampleCurrent(track, out);
|
||||
if (out.motion !== 'moving' || track.phase !== 'playing') {
|
||||
const boundary = Math.max(oldest, out.nextMotionT);
|
||||
if (
|
||||
Number.isFinite(boundary) &&
|
||||
boundary > track.displayT &&
|
||||
track.rate > 0
|
||||
)
|
||||
out.nextWakeMonoMs =
|
||||
clocks.monoNowMs + (boundary - track.displayT) / track.rate;
|
||||
}
|
||||
if (delayBlocked && track.rate > 0)
|
||||
out.nextWakeMonoMs =
|
||||
clocks.monoNowMs + Math.max(1, track.displayT - ceiling);
|
||||
if (out.latestReportAgeMs > (track.policy.staleMs ?? 90_000))
|
||||
out.motion = 'stale';
|
||||
return out;
|
||||
}
|
||||
export function seek(track, displayTimeMs, clocks) {
|
||||
if (!Number.isFinite(displayTimeMs))
|
||||
throw new TypeError('A seek needs a finite time');
|
||||
anchoredWall(track, clocks);
|
||||
track.displayT = displayTimeMs;
|
||||
track.offset = -1;
|
||||
track.rate = 0;
|
||||
track.phase = 'paused';
|
||||
track.lastMono = clocks.monoNowMs;
|
||||
track.bracket = 0;
|
||||
track.resets++;
|
||||
track.resetReason = 'seek';
|
||||
}
|
||||
export function setTimeOffsetMs(track, offset, clocks) {
|
||||
if (!Number.isFinite(offset) || offset > 0)
|
||||
throw new RangeError('Offset must be zero or negative');
|
||||
if (Number.isFinite(track.displayT)) {
|
||||
track.resets++;
|
||||
track.resetReason = 'time-offset';
|
||||
}
|
||||
track.offset = offset;
|
||||
track.rate = 1;
|
||||
track.displayT = anchoredWall(track, clocks) - track.targetDelayMs + offset;
|
||||
track.lastMono = clocks.monoNowMs;
|
||||
track.phase = 'buffering';
|
||||
track.bracket = 0;
|
||||
}
|
||||
export function setRate(track, rate, clocks) {
|
||||
if (!Number.isFinite(rate) || rate < 0 || rate > 16)
|
||||
throw new RangeError('Rate must be in [0, 16]');
|
||||
if (track.offset === 0 && rate !== 0 && rate !== 1)
|
||||
throw new RangeError('Live playback uses 1x or hold');
|
||||
track.rate = rate;
|
||||
track.lastMono = clocks.monoNowMs;
|
||||
track.phase = rate === 0 ? 'paused' : 'buffering';
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
createTrack,
|
||||
createLagReservoir,
|
||||
createHistoryBudget,
|
||||
insertFix,
|
||||
readFix,
|
||||
advance,
|
||||
sampleAt,
|
||||
seek,
|
||||
setRate,
|
||||
setTimeOffsetMs,
|
||||
mergeHistory,
|
||||
correctHeight,
|
||||
trimTrack,
|
||||
destroyTrack,
|
||||
FIX_BYTES,
|
||||
FIX_FLAGS,
|
||||
} from './contactPlayback.js';
|
||||
const fix = (t, metres = t / 100) => ({
|
||||
t,
|
||||
lat: 42 + metres / 111320,
|
||||
lon: -71,
|
||||
bearingDeg: 0,
|
||||
});
|
||||
const clocks = (t) => ({ wallNowMs: t, monoNowMs: t });
|
||||
const put = (track, t, metres, receipt = t) =>
|
||||
insertFix(track, fix(t, metres), { receivedAt: receipt, wallNowMs: receipt });
|
||||
|
||||
test('10 s reports / 15 s polls and a 6 m crawl move at report speed on ordinary frames', () => {
|
||||
for (const speed of [10, 0.6]) {
|
||||
const track = createTrack(),
|
||||
out = {};
|
||||
let prior = null,
|
||||
moving = 0,
|
||||
pollJumps = 0;
|
||||
for (let now = 5000; now <= 180000; now += 16) {
|
||||
if (
|
||||
!prior ||
|
||||
Math.floor((now - 5000) / 15000) !== Math.floor((now - 5016) / 15000)
|
||||
) {
|
||||
const t = Math.floor((now - 5000) / 10000) * 10000;
|
||||
put(track, t, (t / 1000) * speed, now);
|
||||
}
|
||||
advance(track, clocks(now), out);
|
||||
if (prior && out.displayT >= 0 && prior.displayT >= 0) {
|
||||
const d = (out.lat - prior.lat) * 111320;
|
||||
const dt = out.displayT - prior.displayT;
|
||||
assert.ok(dt >= 0 && dt <= 16.0001);
|
||||
if (dt > 0 && d > 0) {
|
||||
assert.ok(Math.abs(d / (dt / 1000) - speed) < speed * 0.02);
|
||||
moving++;
|
||||
}
|
||||
if (d > speed * 0.0161) pollJumps++;
|
||||
}
|
||||
prior = { ...out };
|
||||
}
|
||||
assert.ok(
|
||||
moving > 5000,
|
||||
`non-vacuous ${speed} m/s trace: ${moving} moving frames`,
|
||||
);
|
||||
assert.equal(pollJumps, 0);
|
||||
}
|
||||
});
|
||||
test('stationary observations survive, schedule departure, and distinguish underflow', () => {
|
||||
const track = createTrack(),
|
||||
out = {};
|
||||
put(track, 0, 0);
|
||||
put(track, 15000, 0);
|
||||
put(track, 30000, 6);
|
||||
setTimeOffsetMs(track, 0, clocks(35000));
|
||||
advance(track, clocks(35000), out);
|
||||
assert.equal(out.motion, 'stopped');
|
||||
assert.equal(out.nextWakeMonoMs, 40000);
|
||||
advance(track, clocks(40000), out);
|
||||
assert.equal(out.motion, 'moving');
|
||||
advance(track, clocks(55000), out);
|
||||
assert.equal(out.phase, 'held');
|
||||
assert.equal(out.segmentSpeedMps, 0);
|
||||
});
|
||||
test('same-time conflicts and invalid coordinates never make zero-duration segments', () => {
|
||||
const track = createTrack();
|
||||
put(track, 0, 0);
|
||||
assert.equal(put(track, 0, 0).reason, 'repeat');
|
||||
assert.equal(put(track, 0, 1).reason, 'conflict');
|
||||
for (const value of [
|
||||
{ t: 1, lat: 91, lon: 0 },
|
||||
{ t: 1, lat: 0, lon: NaN },
|
||||
{ t: -1, lat: 0, lon: 0 },
|
||||
])
|
||||
assert.equal(insertFix(track, value).accepted, false);
|
||||
assert.equal(track.count, 1);
|
||||
assert.equal(put(track, 15000, 0).accepted, true);
|
||||
});
|
||||
test('ring wrap retains 15 minutes and pins an active bracket under capacity pressure', () => {
|
||||
const track = createTrack({ capacity: 8 });
|
||||
for (let i = 0; i < 8; i++) put(track, i * 10000, i);
|
||||
seek(track, 5000, clocks(80000));
|
||||
for (let i = 8; i < 30; i++) put(track, i * 10000, i);
|
||||
const sample = sampleAt(track, 5000, {});
|
||||
assert.equal(sample.fromT, 0);
|
||||
assert.equal(sample.toT, 10000);
|
||||
assert.equal(track.count, 8);
|
||||
assert.equal(track.storage.byteLength, 8 * FIX_BYTES);
|
||||
assert.equal(track.truncated, true);
|
||||
assert.ok(readFix(track, 2).flags & FIX_FLAGS.BREAK);
|
||||
seek(track, 250000, clocks(300000));
|
||||
trimTrack(track, 300000, 2);
|
||||
assert.ok(track.count <= 5);
|
||||
});
|
||||
test('backward seek and history merge leave clock and live health alone', () => {
|
||||
const track = createTrack(),
|
||||
out = {};
|
||||
put(track, 30000, 30);
|
||||
put(track, 45000, 45);
|
||||
seek(track, 37500, clocks(80000));
|
||||
const health = track.acceptedAt;
|
||||
mergeHistory(track, [fix(0, 0), fix(15000, 15), fix(30000, 999)]);
|
||||
assert.equal(track.displayT, 37500);
|
||||
assert.equal(track.acceptedAt, health);
|
||||
assert.equal(readFix(track, 2).lat, fix(30000, 30).lat);
|
||||
seek(track, 7500, clocks(80000));
|
||||
advance(track, clocks(90000), out);
|
||||
assert.equal(out.displayT, 7500);
|
||||
assert.equal(out.phase, 'paused');
|
||||
setRate(track, 1, clocks(90000));
|
||||
advance(track, clocks(91000), out);
|
||||
assert.equal(out.displayT, 8500);
|
||||
});
|
||||
test('system-clock changes do not alter animation elapsed time', () => {
|
||||
const track = createTrack(),
|
||||
out = {};
|
||||
put(track, 0, 0);
|
||||
put(track, 60000, 600);
|
||||
advance(track, clocks(60000), out);
|
||||
const before = out.displayT;
|
||||
advance(track, { wallNowMs: 999999999, monoNowMs: 60016 }, out);
|
||||
assert.equal(out.displayT - before, 16);
|
||||
setTimeOffsetMs(track, -10000, clocks(60016));
|
||||
assert.equal(track.resetReason, 'time-offset');
|
||||
assert.equal(track.resets, 1);
|
||||
});
|
||||
test('a 90 s outage holds, then resumes without a sprint; backlog reset adds no second delay', () => {
|
||||
const track = createTrack(),
|
||||
out = {};
|
||||
put(track, 0, 0);
|
||||
put(track, 15000, 15);
|
||||
for (let t = 25000; t <= 120000; t += 100) advance(track, clocks(t), out);
|
||||
assert.equal(out.displayT, 15000);
|
||||
put(track, 105000, 105, 120000);
|
||||
advance(track, clocks(120100), out);
|
||||
assert.ok(out.displayT - 15000 <= 100);
|
||||
track.targetDelayMs = 25000;
|
||||
put(track, 300000, 300, 300000);
|
||||
track.targetDelayMs = 25000;
|
||||
advance(track, clocks(300000), out);
|
||||
assert.equal(track.resets, 1);
|
||||
assert.equal(track.resetReason, 'backlog');
|
||||
assert.equal(out.displayT, 275000);
|
||||
});
|
||||
test('lag measures receipt of current minus report of previous and ignores cached deliveries', () => {
|
||||
const feedLag = createLagReservoir(),
|
||||
track = createTrack({ policy: { feedLag } });
|
||||
put(track, 0, 0, 5000);
|
||||
put(track, 10000, 100, 20000);
|
||||
assert.equal(track.lag.values[0], 20000);
|
||||
put(track, 10000, 100, 80000);
|
||||
assert.equal(track.lag.count, 1);
|
||||
put(track, 20000, 200, 50000);
|
||||
assert.equal(track.targetDelayMs, 45000);
|
||||
});
|
||||
test('displayed bearing never reads a future fix; height corrections revise typed storage', () => {
|
||||
const track = createTrack();
|
||||
insertFix(track, { ...fix(0, 0), bearingDeg: 90 });
|
||||
insertFix(track, { ...fix(15000, 6), bearingDeg: 270 });
|
||||
const out = sampleAt(track, 7500, {});
|
||||
assert.equal(out.segmentCourseDeg, 90);
|
||||
assert.ok(Number.isNaN(out.heightM));
|
||||
correctHeight(track, out.fromSeq, 11.5);
|
||||
correctHeight(track, out.toSeq, 21.5);
|
||||
assert.equal(sampleAt(track, 7500, out).heightM, 16.5);
|
||||
});
|
||||
test('allocation budget is hard and destruction returns payload bytes', () => {
|
||||
const budget = createHistoryBudget(48 * 16),
|
||||
tracks = Array.from({ length: 3 }, () => createTrack({ budget }));
|
||||
for (const track of tracks)
|
||||
for (let t = 0; t < 100; t++) put(track, t * 1000, t);
|
||||
assert.ok(budget.allocatedBytes <= budget.maxBytes);
|
||||
for (const track of tracks) destroyTrack(track);
|
||||
assert.equal(budget.allocatedBytes, 0);
|
||||
});
|
||||
|
||||
test('tiny displacement holds a preceding reliable course instead of a changed bearing', () => {
|
||||
const track = createTrack();
|
||||
insertFix(track, { ...fix(0, 0), bearingDeg: 90 });
|
||||
insertFix(track, { ...fix(15000, 100), bearingDeg: 270 });
|
||||
insertFix(track, { ...fix(30000, 106), bearingDeg: 180 });
|
||||
assert.equal(sampleAt(track, 20000, {}).segmentCourseDeg, 0);
|
||||
});
|
||||
test('an epoch gap wakes at its far boundary without drawing a connecting segment', () => {
|
||||
const track = createTrack(),
|
||||
out = {};
|
||||
insertFix(track, { ...fix(0, 0), epoch: 1 });
|
||||
insertFix(track, { ...fix(30000, 100), epoch: 2 });
|
||||
advance(track, clocks(40000), out);
|
||||
assert.equal(out.segmentSpeedMps, 0);
|
||||
assert.equal(out.nextWakeMonoMs, 65000);
|
||||
});
|
||||
|
||||
test('increased lag is adopted while rebuffering, with one monotonic wake and no slow playback', () => {
|
||||
const track = createTrack(),
|
||||
out = {};
|
||||
put(track, 0, 0);
|
||||
put(track, 15000, 15);
|
||||
for (let now = 25000; now <= 120000; now += 100)
|
||||
advance(track, clocks(now), out);
|
||||
insertFix(track, fix(105000, 105), {
|
||||
receivedAt: 120000,
|
||||
wallNowMs: 120000,
|
||||
monoNowMs: 120000,
|
||||
});
|
||||
advance(track, clocks(120100), out);
|
||||
assert.equal(out.displayT, 15000);
|
||||
assert.equal(out.phase, 'buffering');
|
||||
assert.equal(out.nextWakeMonoMs, 125000);
|
||||
advance(track, { wallNowMs: -9999999, monoNowMs: 125000 }, out);
|
||||
assert.equal(out.displayT, 15000);
|
||||
advance(track, { wallNowMs: 9999999, monoNowMs: 125016 }, out);
|
||||
assert.equal(out.displayT, 15016);
|
||||
});
|
||||
|
||||
test('capacity rejection leaves both lag reservoirs and clock policy unchanged', () => {
|
||||
const feedLag = createLagReservoir();
|
||||
const track = createTrack({ capacity: 2, policy: { feedLag } });
|
||||
put(track, 0, 0);
|
||||
put(track, 10000, 10);
|
||||
seek(track, 5000, clocks(10000));
|
||||
const policyState = () =>
|
||||
structuredClone({
|
||||
lag: track.lag,
|
||||
feedLag,
|
||||
targetDelayMs: track.targetDelayMs,
|
||||
acceptedAt: track.acceptedAt,
|
||||
acceptedMono: track.acceptedMono,
|
||||
resumePending: track.resumePending,
|
||||
displayT: track.displayT,
|
||||
wallAnchor: track.wallAnchor,
|
||||
monoAnchor: track.monoAnchor,
|
||||
resetReason: track.resetReason,
|
||||
resets: track.resets,
|
||||
});
|
||||
const before = policyState();
|
||||
assert.deepEqual(put(track, 20000, 20, 300000), {
|
||||
accepted: false,
|
||||
reason: 'capacity',
|
||||
});
|
||||
assert.deepEqual(policyState(), before);
|
||||
});
|
||||
|
||||
test('sampleAt writes the supplied output without replacing nested scratch', () => {
|
||||
const track = createTrack();
|
||||
put(track, 0, 0);
|
||||
put(track, 15000, 150);
|
||||
const out = {},
|
||||
from = track.from,
|
||||
to = track.to,
|
||||
metrics = track.metrics;
|
||||
for (let i = 0; i < 10000; i++) {
|
||||
assert.equal(sampleAt(track, i, out), out);
|
||||
assert.equal(track.from, from);
|
||||
assert.equal(track.to, to);
|
||||
assert.equal(track.metrics, metrics);
|
||||
}
|
||||
});
|
||||
|
||||
test('backfill pins the live bracket and quarantines outliers on either side', () => {
|
||||
const track = createTrack({
|
||||
policy: {
|
||||
accept: (a, b) =>
|
||||
Math.abs(b.lat - a.lat) * 111320 <= ((b.t - a.t) / 1000) * 36,
|
||||
},
|
||||
});
|
||||
put(track, 10000, 0);
|
||||
put(track, 25000, 100);
|
||||
seek(track, 17500, clocks(40000));
|
||||
const before = { ...sampleAt(track, 17500, {}) };
|
||||
mergeHistory(track, [fix(0, -111320), fix(17500, 25), fix(40000, 111320)]);
|
||||
const after = sampleAt(track, 17500, {});
|
||||
assert.equal(track.count, 2);
|
||||
assert.equal(after.fromT, before.fromT);
|
||||
assert.equal(after.toT, before.toT);
|
||||
assert.equal(after.lat, before.lat);
|
||||
mergeHistory(track, [fix(40000, 111320), fix(55000, 111420)]);
|
||||
assert.equal(
|
||||
track.count,
|
||||
4,
|
||||
'two coherent replacement fixes can be retained',
|
||||
);
|
||||
assert.ok(readFix(track, 2).flags & FIX_FLAGS.BREAK);
|
||||
assert.equal(sampleAt(track, 30000, {}).lat, fix(25000, 100).lat);
|
||||
});
|
||||
@@ -0,0 +1,229 @@
|
||||
import * as Cesium from 'cesium';
|
||||
|
||||
const ALPHA_STOPS = [
|
||||
[0, 0.8],
|
||||
[120000, 0.45],
|
||||
[600000, 0.2],
|
||||
[900000, 0.08],
|
||||
];
|
||||
export function trailAlpha(ageMs) {
|
||||
const stops = ALPHA_STOPS;
|
||||
for (let i = 1; i < stops.length; i++) {
|
||||
if (ageMs === stops[i][0]) return stops[i][1];
|
||||
if (ageMs < stops[i][0]) {
|
||||
const [a, x] = stops[i - 1],
|
||||
[b, y] = stops[i];
|
||||
return x + ((y - x) * Math.max(0, ageMs - a)) / (b - a);
|
||||
}
|
||||
}
|
||||
return 0.08;
|
||||
}
|
||||
|
||||
/** One static history batch and one frequently updated head. Never entities. */
|
||||
export function createContactTrailRenderer(scene) {
|
||||
const heads = scene.primitives.add(new Cesium.PolylineCollection());
|
||||
const backing = heads.add({
|
||||
positions: [],
|
||||
width: 4,
|
||||
show: false,
|
||||
material: Cesium.Material.fromType('Color', {
|
||||
color: new Cesium.Color(0.02, 0.03, 0.05, 0.8),
|
||||
}),
|
||||
});
|
||||
const head = heads.add({
|
||||
positions: [],
|
||||
width: 2,
|
||||
show: false,
|
||||
material: Cesium.Material.fromType('Color', {
|
||||
color: Cesium.Color.WHITE.clone(),
|
||||
}),
|
||||
});
|
||||
let body = null,
|
||||
segments = [],
|
||||
revision = -1,
|
||||
visible = true,
|
||||
style = '#5EF08A';
|
||||
let lastSecond = -1,
|
||||
completed = -1,
|
||||
active = null;
|
||||
const styleColor = Cesium.Color.fromCssColorString(style);
|
||||
const attributeIds = [];
|
||||
const color = new Cesium.Color(),
|
||||
endpoint = new Cesium.Cartesian3();
|
||||
const headPositions = [new Cesium.Cartesian3(), endpoint];
|
||||
let rebuilds = 0;
|
||||
function tint(alpha, dark = false) {
|
||||
if (dark) {
|
||||
color.red = 0.02;
|
||||
color.green = 0.03;
|
||||
color.blue = 0.05;
|
||||
} else Cesium.Color.clone(styleColor, color);
|
||||
color.alpha = alpha;
|
||||
return color;
|
||||
}
|
||||
function replaceHistory(next) {
|
||||
if (next.revision === revision) return;
|
||||
revision = next.revision;
|
||||
segments = next.segments;
|
||||
completed = -1;
|
||||
lastSecond = -1;
|
||||
active = null;
|
||||
if (body) scene.primitives.remove(body);
|
||||
body = null;
|
||||
const instances = [];
|
||||
attributeIds.length = 0;
|
||||
for (let i = 0; i < segments.length; i++) {
|
||||
const segment = segments[i];
|
||||
attributeIds.push([`${i}:0`, `${i}:1`]);
|
||||
if (segment.positions.length < 2) continue;
|
||||
for (let lane = 0; lane < 2; lane++) {
|
||||
instances.push(
|
||||
new Cesium.GeometryInstance({
|
||||
id: `${i}:${lane}`,
|
||||
geometry: new Cesium.PolylineGeometry({
|
||||
positions: segment.positions,
|
||||
width: lane ? 2 : 4,
|
||||
arcType: Cesium.ArcType.NONE,
|
||||
vertexFormat: Cesium.PolylineColorAppearance.VERTEX_FORMAT,
|
||||
}),
|
||||
attributes: {
|
||||
color: Cesium.ColorGeometryInstanceAttribute.fromColor(
|
||||
tint(0.8, !lane),
|
||||
),
|
||||
depthFailColor: Cesium.ColorGeometryInstanceAttribute.fromColor(
|
||||
tint(0.8, !lane),
|
||||
),
|
||||
show: new Cesium.ShowGeometryInstanceAttribute(false),
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
if (instances.length) {
|
||||
const depthFailAppearance = new Cesium.PolylineColorAppearance({
|
||||
translucent: true,
|
||||
fragmentShaderSource:
|
||||
'in vec4 v_color; void main() { out_FragColor = czm_gammaCorrect(vec4(v_color.rgb, v_color.a * 0.2)); }',
|
||||
});
|
||||
body = scene.primitives.add(
|
||||
new Cesium.Primitive({
|
||||
geometryInstances: instances,
|
||||
appearance: new Cesium.PolylineColorAppearance({ translucent: true }),
|
||||
depthFailAppearance,
|
||||
allowPicking: false,
|
||||
asynchronous: false,
|
||||
show: visible,
|
||||
}),
|
||||
);
|
||||
rebuilds++;
|
||||
}
|
||||
}
|
||||
function setDisplaySample(sample, renderedPosition) {
|
||||
if (!sample || !renderedPosition) {
|
||||
head.show = false;
|
||||
backing.show = false;
|
||||
return;
|
||||
}
|
||||
const second = Math.floor(sample.displayT / 1000);
|
||||
let lo = 0,
|
||||
hi = segments.length;
|
||||
while (lo < hi) {
|
||||
const mid = (lo + hi) >>> 1;
|
||||
if (segments[mid].toT <= sample.displayT) lo = mid + 1;
|
||||
else hi = mid;
|
||||
}
|
||||
const done = lo - 1;
|
||||
const candidate = segments[lo];
|
||||
const segment =
|
||||
candidate &&
|
||||
candidate.fromT <= sample.displayT &&
|
||||
candidate.fromSeq === sample.fromSeq &&
|
||||
candidate.toSeq === sample.toSeq
|
||||
? candidate
|
||||
: null;
|
||||
if (body?.ready && done !== completed) {
|
||||
for (
|
||||
let i = Math.min(done, completed) + 1;
|
||||
i <= Math.max(done, completed);
|
||||
i++
|
||||
) {
|
||||
for (let lane = 0; lane < 2; lane++) {
|
||||
const attributes = body.getGeometryInstanceAttributes(
|
||||
attributeIds[i][lane],
|
||||
);
|
||||
if (!attributes) continue;
|
||||
attributes.show[0] = i <= done ? 1 : 0;
|
||||
attributes.show = attributes.show;
|
||||
}
|
||||
}
|
||||
completed = done;
|
||||
}
|
||||
if (body?.ready && second !== lastSecond) {
|
||||
for (let i = 0; i < segments.length; i++) {
|
||||
const alpha = trailAlpha(sample.displayT - segments[i].toT);
|
||||
for (let lane = 0; lane < 2; lane++) {
|
||||
const attributes = body.getGeometryInstanceAttributes(
|
||||
attributeIds[i][lane],
|
||||
);
|
||||
if (!attributes) continue;
|
||||
Cesium.ColorGeometryInstanceAttribute.toValue(
|
||||
tint(alpha, !lane),
|
||||
attributes.color,
|
||||
);
|
||||
attributes.color = attributes.color;
|
||||
Cesium.ColorGeometryInstanceAttribute.toValue(
|
||||
tint(alpha, !lane),
|
||||
attributes.depthFailColor,
|
||||
);
|
||||
attributes.depthFailColor = attributes.depthFailColor;
|
||||
}
|
||||
}
|
||||
lastSecond = second;
|
||||
}
|
||||
const show = visible && !!segment && sample.fraction < 1;
|
||||
head.show = show;
|
||||
backing.show = show;
|
||||
if (!show) return;
|
||||
if (active !== segment) {
|
||||
active = segment;
|
||||
headPositions[0] = segment.positions[0];
|
||||
headPositions[1] = endpoint;
|
||||
}
|
||||
Cesium.Cartesian3.clone(renderedPosition, endpoint);
|
||||
head.positions = headPositions;
|
||||
backing.positions = headPositions;
|
||||
Cesium.Color.clone(tint(0.8), head.material.uniforms.color);
|
||||
}
|
||||
return {
|
||||
replaceHistory,
|
||||
setDisplaySample,
|
||||
setStyle(next) {
|
||||
if (next === style) return;
|
||||
style = next;
|
||||
Cesium.Color.fromCssColorString(style, styleColor);
|
||||
lastSecond = -1;
|
||||
},
|
||||
setVisible(next) {
|
||||
visible = next;
|
||||
heads.show = next;
|
||||
if (body) body.show = next;
|
||||
},
|
||||
destroy() {
|
||||
if (body) scene.primitives.remove(body);
|
||||
scene.primitives.remove(heads);
|
||||
body = null;
|
||||
segments = [];
|
||||
},
|
||||
diagnostics() {
|
||||
return {
|
||||
revision,
|
||||
rebuilds,
|
||||
segments: segments.length,
|
||||
body,
|
||||
head,
|
||||
backing,
|
||||
entitiesAdded: 0,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
createContactTrailRenderer,
|
||||
trailAlpha,
|
||||
} from './contactTrailRenderer.js';
|
||||
|
||||
function fixture(t) {
|
||||
for (const key of [
|
||||
'HTMLCanvasElement',
|
||||
'HTMLImageElement',
|
||||
'ImageBitmap',
|
||||
'OffscreenCanvas',
|
||||
]) {
|
||||
const prior = globalThis[key];
|
||||
globalThis[key] = class {};
|
||||
t.after(() => {
|
||||
if (prior === undefined) delete globalThis[key];
|
||||
else globalThis[key] = prior;
|
||||
});
|
||||
}
|
||||
const primitives = new Cesium.PrimitiveCollection();
|
||||
t.after(() => primitives.destroy());
|
||||
return { primitives };
|
||||
}
|
||||
test('body clips future subdivisions, head ends at exact marker, and frames retain geometry', (t) => {
|
||||
const scene = fixture(t),
|
||||
renderer = createContactTrailRenderer(scene);
|
||||
const positions = [0, 1, 2].map((i) =>
|
||||
Cesium.Cartesian3.fromDegrees(-71, 42 + i * 0.001, 11.5),
|
||||
);
|
||||
renderer.replaceHistory({
|
||||
revision: 1,
|
||||
segments: [
|
||||
{
|
||||
fromSeq: 0,
|
||||
toSeq: 1,
|
||||
fromT: 0,
|
||||
toT: 10000,
|
||||
positions: positions.slice(0, 2),
|
||||
},
|
||||
{
|
||||
fromSeq: 0,
|
||||
toSeq: 1,
|
||||
fromT: 10000,
|
||||
toT: 20000,
|
||||
positions: positions.slice(1, 3),
|
||||
},
|
||||
],
|
||||
});
|
||||
const { body } = renderer.diagnostics();
|
||||
assert.equal(body.allowPicking, false);
|
||||
// The constructor defers this check until shader preparation. Run the real
|
||||
// Cesium validator without WebGL, including a missing-attribute control.
|
||||
const source = body.depthFailAppearance.vertexShaderSource;
|
||||
body._batchTableAttributeIndices = { color: 0 };
|
||||
assert.throws(
|
||||
() => Cesium.Primitive._updateColorAttribute(body, source, true),
|
||||
/depthFailColor/,
|
||||
);
|
||||
for (const instance of body.geometryInstances) {
|
||||
assert.deepEqual(
|
||||
instance.attributes.depthFailColor.value,
|
||||
instance.attributes.color.value,
|
||||
);
|
||||
}
|
||||
body._batchTableAttributeIndices = { color: 0, depthFailColor: 1 };
|
||||
assert.doesNotThrow(() =>
|
||||
Cesium.Primitive._updateColorAttribute(body, source, true),
|
||||
);
|
||||
const attributes = new Map(
|
||||
body.geometryInstances.map((instance) => [
|
||||
instance.id,
|
||||
{
|
||||
show: new Uint8Array([0]),
|
||||
color: new Uint8Array(4),
|
||||
depthFailColor: new Uint8Array(4),
|
||||
},
|
||||
]),
|
||||
);
|
||||
// Simulate Cesium's completed upload; exercise public attribute writes.
|
||||
body._ready = true;
|
||||
body.getGeometryInstanceAttributes = (id) => attributes.get(id);
|
||||
const marker = Cesium.Cartesian3.lerp(
|
||||
positions[1],
|
||||
positions[2],
|
||||
0.5,
|
||||
new Cesium.Cartesian3(),
|
||||
);
|
||||
const sample = { displayT: 15000, fromSeq: 0, toSeq: 1, fraction: 0.75 };
|
||||
for (let i = 0; i < 120; i++) renderer.setDisplaySample(sample, marker);
|
||||
assert.deepEqual(
|
||||
[...attributes.values()].map((a) => a.show[0]),
|
||||
[1, 1, 0, 0],
|
||||
);
|
||||
for (const a of attributes.values())
|
||||
assert.deepEqual(a.depthFailColor, a.color);
|
||||
const result = renderer.diagnostics();
|
||||
assert.equal(result.body, body);
|
||||
assert.equal(result.rebuilds, 1);
|
||||
assert.ok(Cesium.Cartesian3.equals(result.head.positions.at(-1), marker));
|
||||
assert.equal(result.head.positions.length, 2);
|
||||
renderer.setDisplaySample(
|
||||
{ ...sample, displayT: 5000, fraction: 0.25 },
|
||||
positions[0],
|
||||
);
|
||||
assert.ok(
|
||||
[...attributes.values()].every((a) => a.show[0] === 0),
|
||||
'rewind hides future geometry again',
|
||||
);
|
||||
renderer.destroy();
|
||||
assert.equal(scene.primitives.length, 0);
|
||||
});
|
||||
test('trail age curve uses the specified four alpha anchors', () => {
|
||||
assert.equal(trailAlpha(0), 0.8);
|
||||
assert.equal(trailAlpha(120000), 0.45);
|
||||
assert.equal(trailAlpha(600000), 0.2);
|
||||
assert.equal(trailAlpha(900000), 0.08);
|
||||
assert.ok(Math.abs(trailAlpha(60000) - 0.625) < 1e-10);
|
||||
});
|
||||
|
||||
test('the maximum prepared body stays below the 2 MiB geometry budget', () => {
|
||||
const geometry = Cesium.PolylineGeometry.createGeometry(
|
||||
new Cesium.PolylineGeometry({
|
||||
positions: [
|
||||
Cesium.Cartesian3.fromDegrees(-71, 42, 11.5),
|
||||
Cesium.Cartesian3.fromDegrees(-71, 42.0002, 11.5),
|
||||
],
|
||||
width: 2,
|
||||
arcType: Cesium.ArcType.NONE,
|
||||
vertexFormat: Cesium.PolylineColorAppearance.VERTEX_FORMAT,
|
||||
}),
|
||||
);
|
||||
const bytes =
|
||||
Object.values(geometry.attributes).reduce(
|
||||
(total, attribute) => total + (attribute?.values?.byteLength || 0),
|
||||
0,
|
||||
) + geometry.indices.byteLength;
|
||||
assert.ok(bytes * 2 * 2047 < 2 * 1024 * 1024);
|
||||
});
|
||||
|
||||
test('subdivision crossings only write the changed show range and age colors once per second', (t) => {
|
||||
const renderer = createContactTrailRenderer(fixture(t));
|
||||
const positions = [
|
||||
Cesium.Cartesian3.fromDegrees(-71, 42),
|
||||
Cesium.Cartesian3.fromDegrees(-71, 42.001),
|
||||
];
|
||||
renderer.replaceHistory({
|
||||
revision: 1,
|
||||
segments: Array.from({ length: 100 }, (_, i) => ({
|
||||
fromSeq: 0,
|
||||
toSeq: 1,
|
||||
fromT: i * 500,
|
||||
toT: (i + 1) * 500,
|
||||
positions,
|
||||
})),
|
||||
});
|
||||
const { body } = renderer.diagnostics();
|
||||
const counts = { show: 0, color: 0, depthFailColor: 0 };
|
||||
const attributes = new Map(
|
||||
body.geometryInstances.map((instance) => {
|
||||
const result = {};
|
||||
for (const name of Object.keys(counts)) {
|
||||
let value = instance.attributes[name].value;
|
||||
Object.defineProperty(result, name, {
|
||||
get: () => value,
|
||||
set(next) {
|
||||
value = next;
|
||||
counts[name]++;
|
||||
},
|
||||
});
|
||||
}
|
||||
return [instance.id, result];
|
||||
}),
|
||||
);
|
||||
body._ready = true;
|
||||
body.getGeometryInstanceAttributes = (id) => attributes.get(id);
|
||||
const display = (displayT) =>
|
||||
renderer.setDisplaySample(
|
||||
{ displayT, fromSeq: 0, toSeq: 1, fraction: 0.1 },
|
||||
positions[0],
|
||||
);
|
||||
const reset = () => {
|
||||
for (const name of Object.keys(counts)) counts[name] = 0;
|
||||
};
|
||||
display(100);
|
||||
assert.deepEqual(counts, { show: 0, color: 200, depthFailColor: 200 });
|
||||
reset();
|
||||
display(600);
|
||||
assert.deepEqual(counts, { show: 2, color: 0, depthFailColor: 0 });
|
||||
reset();
|
||||
display(100);
|
||||
assert.deepEqual(counts, { show: 2, color: 0, depthFailColor: 0 });
|
||||
assert.ok(
|
||||
[...attributes.values()].every((a) => a.show[0] === 0),
|
||||
'backward seek hides the completed range',
|
||||
);
|
||||
reset();
|
||||
display(1100);
|
||||
assert.deepEqual(counts, { show: 4, color: 200, depthFailColor: 200 });
|
||||
reset();
|
||||
display(1200);
|
||||
assert.deepEqual(counts, { show: 0, color: 0, depthFailColor: 0 });
|
||||
renderer.setStyle('#FF4538');
|
||||
display(1200);
|
||||
assert.deepEqual(counts, { show: 0, color: 200, depthFailColor: 200 });
|
||||
for (const a of attributes.values())
|
||||
assert.deepEqual(a.color, a.depthFailColor);
|
||||
});
|
||||
@@ -183,6 +183,11 @@ export const DATA_CREDITS = [
|
||||
'<a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">© OpenStreetMap contributors</a> (ODbL) · ' +
|
||||
'<a href="https://www.openstreetmap.org/fixthemap" target="_blank" rel="noopener">fix the map</a>',
|
||||
},
|
||||
{
|
||||
key: 'gtfs-rt',
|
||||
html:
|
||||
'Transit vehicles: operator GTFS-Realtime feeds (each operator is credited below when its vehicles are shown)',
|
||||
},
|
||||
{
|
||||
key: 'radio-browser',
|
||||
html:
|
||||
@@ -287,6 +292,26 @@ export const NATURAL_EARTH_CREDIT = {
|
||||
'<a href="https://www.naturalearthdata.com" target="_blank" rel="noopener">Natural Earth</a> (public domain)',
|
||||
};
|
||||
|
||||
/**
|
||||
* Per-feed transit credit, registered the first time that feed's vehicles
|
||||
* render (see `src/data/transitFeeds.js` for the license of each).
|
||||
* @param {{ id: string, attribution: string, license: string, licenseUrl: string }} feed
|
||||
* @returns {{ key: string, html: string }}
|
||||
*/
|
||||
export function transitFeedCredit(feed) {
|
||||
const escape = (text) => String(text)
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"');
|
||||
return {
|
||||
key: `transit-${feed.id}`,
|
||||
html:
|
||||
`Transit (${escape(feed.attribution)}): ` +
|
||||
`<a href="${escape(feed.licenseUrl)}" target="_blank" rel="noopener">${escape(feed.license)}</a>`,
|
||||
};
|
||||
}
|
||||
|
||||
/** @type {Set<string>} Keys of dynamic credits already registered this session. */
|
||||
const _dynamicCreditKeys = new Set();
|
||||
|
||||
|
||||
+24
-3
@@ -1,3 +1,4 @@
|
||||
import { paintTransitBracket } from './detectionDraw.js';
|
||||
import * as Cesium from 'cesium';
|
||||
import { governorRequestRender } from '../renderGovernor.js';
|
||||
import {
|
||||
@@ -10,6 +11,7 @@ import {
|
||||
import {
|
||||
acquireAlpha,
|
||||
appendCornerBracket,
|
||||
appendTransitBracket,
|
||||
resolveTier,
|
||||
measureTrackLabel,
|
||||
nearFarScale,
|
||||
@@ -217,6 +219,8 @@ let _hostLane = null;
|
||||
* @type {{surface:HTMLCanvasElement|null,setActive:Function,requestPaint:Function,unregister:Function}|null}
|
||||
*/
|
||||
let _calloutLane = null;
|
||||
let _transitBracketPaths = new Map();
|
||||
let _transitBracketAlpha = 1;
|
||||
/**
|
||||
* Callouts solved by the sensor lane this frame, replayed by the callout lane.
|
||||
* The array and its rows are POOLED — `_calloutCount` is the live length — so a
|
||||
@@ -329,6 +333,7 @@ export function destroyDetection() {
|
||||
_hostLane = null;
|
||||
_calloutLane?.unregister?.();
|
||||
_calloutLane = null;
|
||||
_transitBracketPaths.clear();
|
||||
_calloutCount = 0;
|
||||
_viewer = null;
|
||||
_layers = [];
|
||||
@@ -517,7 +522,12 @@ export function markDetectionSourcesChanged(reason = 'sources-changed') {
|
||||
|
||||
/** Read-only diagnostics for unit/browser QA. */
|
||||
export function getDetectionDiagnostics() {
|
||||
return _lastDiagnostics ? JSON.parse(JSON.stringify(_lastDiagnostics)) : null;
|
||||
if (!_lastDiagnostics) return null;
|
||||
const result = JSON.parse(JSON.stringify(_lastDiagnostics));
|
||||
// Readback only: expose painted plates so pixel QA can exclude occluded
|
||||
// sprites/strokes. Nothing is copied on the production paint path.
|
||||
result.calloutRects = _calloutPool.slice(0, _calloutCount).map(({ x, y, w, h, alpha }) => ({ x, y, w, h, alpha }));
|
||||
return result;
|
||||
}
|
||||
|
||||
function _publishDiagnostics() {
|
||||
@@ -981,9 +991,14 @@ function _stashCallout(entry, acquireFade, keyhole) {
|
||||
* @param {Object} frame Host paint frame.
|
||||
*/
|
||||
function _paintCalloutLane(frame) {
|
||||
if (_mode === MODE_OFF || _suspended || _calloutCount === 0) return;
|
||||
if (_mode === MODE_OFF || _suspended) return;
|
||||
const ctx = frame.ctx;
|
||||
if (!ctx) return;
|
||||
for (const bands of _transitBracketPaths.values()) {
|
||||
for (const entry of bands) {
|
||||
if (entry) paintTransitBracket(ctx, entry.path, entry.color, entry.alpha * _transitBracketAlpha);
|
||||
}
|
||||
}
|
||||
ctx.textAlign = 'left';
|
||||
ctx.textBaseline = 'alphabetic';
|
||||
for (let i = 0; i < _calloutCount; i++) {
|
||||
@@ -1061,6 +1076,7 @@ function _materializeCandidate(obj, width, height, keyhole, occlusionRects, came
|
||||
* for a bounded cohort on solve ticks.
|
||||
*/
|
||||
function _drawOverlay(frame) {
|
||||
_transitBracketPaths.clear();
|
||||
const { width, height } = frame;
|
||||
if (!_ctx || width <= 0 || height <= 0) {
|
||||
return { didSolve: false, solveMs: 0, fadingCount: 0, animatingCount: 0, solvePending: false };
|
||||
@@ -1217,6 +1233,9 @@ function _drawOverlay(frame) {
|
||||
);
|
||||
halfW = _clamp((isTracked ? 14 : 9) * bscale, 7, 48);
|
||||
halfH = _clamp((isTracked ? 11 : 7) * bscale, 5, 38);
|
||||
} else if (obj.tier?.startsWith('transit_') && obj.bracketHalfWidth > 0) {
|
||||
halfW = obj.bracketHalfWidth;
|
||||
halfH = obj.bracketHalfHeight;
|
||||
} else {
|
||||
halfW = _mode === MODE_DENSE ? (isTracked ? 28 : 11) : 16;
|
||||
halfH = _mode === MODE_DENSE ? (isTracked ? 22 : 7) : 10;
|
||||
@@ -1230,7 +1249,8 @@ function _drawOverlay(frame) {
|
||||
const keyholeAlpha = keyholeLabelAlphaFromGeometry(sx, sy, keyhole);
|
||||
const bracketAlpha = detectionBracketAlpha(obj.type, keyholeAlpha, keyholeOutsideOpacity);
|
||||
if (bracketAlpha > 0) {
|
||||
appendCornerBracket(pathFor(bracketPaths, color, bracketAlpha), sx, sy, halfW, halfH);
|
||||
const transit = obj.tier?.startsWith('transit_');
|
||||
(transit ? appendTransitBracket : appendCornerBracket)(pathFor(transit ? _transitBracketPaths : bracketPaths, color, bracketAlpha), sx, sy, halfW, halfH);
|
||||
visibleCount++;
|
||||
if (obj.type === 'AIR') aircraftBracketSectors[detectionHorizontalSector(sx, width)]++;
|
||||
if (bracketAlpha >= 1) bracketOpacityCounts.full++;
|
||||
@@ -1346,6 +1366,7 @@ function _drawOverlay(frame) {
|
||||
|
||||
const fade = acquireAlpha(_enableTime, now, FADE_MS);
|
||||
const bracketWidth = _mode === MODE_DENSE ? 1 : 1.25;
|
||||
_transitBracketAlpha = fade * bracketPresentationOpacity;
|
||||
|
||||
// Brackets — batched by tier color and linear radial-opacity band.
|
||||
_ctx.lineWidth = bracketWidth;
|
||||
|
||||
@@ -237,3 +237,24 @@ export function nearFarScale(distance, near, nearValue, far, farValue) {
|
||||
const t = (distance - near) / (far - near);
|
||||
return nearValue + t * (farValue - nearValue);
|
||||
}
|
||||
|
||||
|
||||
/** Transit keeps literal mode colour on the normal-composite surface. */
|
||||
export function paintTransitBracket(ctx, path, color, alpha = 1) {
|
||||
ctx.save?.();
|
||||
ctx.globalCompositeOperation = 'source-over';
|
||||
ctx.globalAlpha = alpha;
|
||||
ctx.strokeStyle = '#05080C';
|
||||
ctx.lineWidth = 3.25;
|
||||
ctx.stroke(path);
|
||||
ctx.strokeStyle = color;
|
||||
ctx.lineWidth = 1.25;
|
||||
ctx.stroke(path);
|
||||
ctx.restore?.();
|
||||
}
|
||||
|
||||
/** Centre 1.25 px strokes on pixels, so their cores are fully covered. */
|
||||
export function appendTransitBracket(sink, sx, sy, halfW, halfH) {
|
||||
appendCornerBracket(sink, Math.floor(sx) + 0.5, Math.floor(sy) + 0.5,
|
||||
Math.round(halfW), Math.round(halfH));
|
||||
}
|
||||
@@ -161,3 +161,34 @@ test('nearFarScale interpolates by distance and clamps to the near/far values',
|
||||
const mid = nearFarScale((1000 + 8000000) / 2, 1000, 3.0, 8000000, 0.5);
|
||||
assert.ok(Math.abs(mid - 1.75) < 1e-6, `midpoint ~1.75, got ${mid}`);
|
||||
});
|
||||
|
||||
|
||||
test('transit brackets paint a 1.25 px mode stroke over 3.25 px dark backing', async () => {
|
||||
const { paintTransitBracket } = await import('./detectionDraw.js');
|
||||
const strokes = [];
|
||||
const ctx = { stroke(path) { strokes.push([path, this.lineWidth, this.strokeStyle, this.globalAlpha]); } };
|
||||
const path = {};
|
||||
paintTransitBracket(ctx, path, '#FF4538', 0.75);
|
||||
assert.deepEqual(strokes, [[path, 3.25, '#05080C', 0.75], [path, 1.25, '#FF4538', 0.75]]);
|
||||
});
|
||||
|
||||
test('transit bracket backing and colour use source-over under every inherited sensor theme', async () => {
|
||||
const {paintTransitBracket}=await import('./detectionDraw.js');
|
||||
for(const theme of ['normal','thermal','surveillance','noir','retro']) {
|
||||
const strokes=[];
|
||||
const ctx={globalCompositeOperation:'screen',globalAlpha:0.3,stroke(p){strokes.push([this.globalCompositeOperation,this.lineWidth,this.strokeStyle]);},save(){this.saved=[this.globalCompositeOperation,this.globalAlpha];},restore(){[this.globalCompositeOperation,this.globalAlpha]=this.saved;}};
|
||||
paintTransitBracket(ctx,{},'#FF4538',1);
|
||||
assert.deepEqual(strokes,[['source-over',3.25,'#05080C'],['source-over',1.25,'#FF4538']],theme);
|
||||
assert.equal(ctx.globalCompositeOperation,'screen');
|
||||
}
|
||||
});
|
||||
|
||||
test('transit bracket stroke centres have full pixel coverage at the failing fractional positions', async () => {
|
||||
const {appendTransitBracket}=await import('./detectionDraw.js');
|
||||
for(const sy of [305.076,490.54,279.56]) {
|
||||
const points=[],sink={moveTo:(x,y)=>points.push([x,y]),lineTo:(x,y)=>points.push([x,y])};
|
||||
appendTransitBracket(sink,759.016,sy,11,7);
|
||||
assert.equal(points.length,12);
|
||||
for(const [x,y] of points){assert.equal(x%1,0.5);assert.equal(y%1,0.5);}
|
||||
}
|
||||
});
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
setOverlayEntries,
|
||||
} from '../overlays/worldOverlay.js';
|
||||
import { DETECTION_THEME_MAP } from '../overlays/worldOverlayTokens.js';
|
||||
import { composeLabel, resolveTier } from './detectionDraw.js';
|
||||
|
||||
test('detection diagnostics count rendered fading rows instead of absent selected identities', () => {
|
||||
assert.equal(countFadingRenderEntries([
|
||||
@@ -97,7 +98,7 @@ function mockContext(target, trace) {
|
||||
// set at some point during the frame. globalAlpha rides along for the same
|
||||
// reason: the backdrop feather is an alpha, not a colour.
|
||||
fill(path) { record('fill', path, this.fillStyle, this.globalAlpha); },
|
||||
stroke(path) { record('stroke', path); },
|
||||
stroke(path) { record('stroke', path, this.strokeStyle, this.lineWidth); },
|
||||
fillRect(...args) { record('fillRect', ...args); },
|
||||
fillText(...args) { record('fillText', ...args); },
|
||||
drawImage(...args) { record('drawImage', ...args); },
|
||||
@@ -378,6 +379,12 @@ test('detection lifecycle re-hosts unchanged painters behind the sole host liste
|
||||
env.postRender.raise();
|
||||
const first = getDetectionDiagnostics();
|
||||
assert.equal(first.profile, 'DENSE');
|
||||
assert.ok(Array.isArray(first.calloutRects));
|
||||
if (first.calloutRects.length) {
|
||||
const x=first.calloutRects[0].x;
|
||||
first.calloutRects[0].x=NaN;
|
||||
assert.equal(getDetectionDiagnostics().calloutRects[0].x,x,'QA owns a copy, never a live plate');
|
||||
}
|
||||
assert.equal(first.observationCount, 2);
|
||||
assert.equal(first.didSolve, true);
|
||||
assert.ok(first.solveRevision > 0);
|
||||
@@ -874,3 +881,82 @@ test('the backdrop feather reaches the canvas as a lighter plate against sky', (
|
||||
env.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('a transit vehicle is a contact: box, route, mode and operator on the shared canvas', () => {
|
||||
// The owner's finding was that buses were "green dots moving around
|
||||
// clumsily" — the layer rendered them and the detection overlay ignored
|
||||
// them, because transit published no detectable objects at all. This drives
|
||||
// the real host with the shape the layer now returns.
|
||||
const env = installEnvironment();
|
||||
const objects = [
|
||||
{
|
||||
position: new Cesium.Cartesian3(0, 0, 6_356_752),
|
||||
sourceId: 'mbta:y1276',
|
||||
tier: 'transit_bus',
|
||||
id: '111',
|
||||
type: 'VEH',
|
||||
klass: 'MBTA',
|
||||
metric: 'BUS 34 KM/H',
|
||||
},
|
||||
{
|
||||
position: new Cesium.Cartesian3(0, 900, 6_356_752),
|
||||
sourceId: 'mbta:R-548BBB14',
|
||||
id: 'RED',
|
||||
type: 'VEH',
|
||||
klass: 'MBTA',
|
||||
metric: 'METRO STOPPED',
|
||||
skipLabel: true,
|
||||
},
|
||||
];
|
||||
try {
|
||||
initWorldOverlay(env.viewer);
|
||||
initDetection(
|
||||
env.viewer,
|
||||
[{ id: 'transit', getDetectableObjects: () => objects }],
|
||||
() => {},
|
||||
);
|
||||
setMode('DENSE');
|
||||
env.advance(250);
|
||||
env.postRender.raise();
|
||||
env.advance(250);
|
||||
env.postRender.raise();
|
||||
const painted = env.ctx.calls
|
||||
.filter(([name]) => name === 'fillText')
|
||||
.map(([, text]) => text);
|
||||
assert.ok(
|
||||
painted.includes('111'),
|
||||
`the route is the bright line; painted ${JSON.stringify(painted)}`,
|
||||
);
|
||||
// The host paints a single-line track label — bright id, one dim micro
|
||||
// field — so the mode and what the vehicle is doing share that field.
|
||||
assert.ok(
|
||||
painted.some((text) => String(text).includes('BUS')),
|
||||
`the mode rides along on the micro line; painted ${JSON.stringify(painted)}`,
|
||||
);
|
||||
assert.ok(
|
||||
painted.some((text) => String(text).includes('34 KM/H')),
|
||||
'and so does what the vehicle is doing',
|
||||
);
|
||||
assert.ok(env.ctx.calls.some(([name, , color, width]) => name === 'stroke' && color === '#5EF08A' && width === 1.25));
|
||||
assert.ok(env.ctx.calls.some(([name, , color, width]) => name === 'stroke' && color === '#05080C' && width === 3.25));
|
||||
assert.ok(!env.detectionCtx.calls.some(([name, , color]) => name === 'stroke' && color === '#5EF08A'));
|
||||
// The operator travels on the object for the two-tier card form, which is
|
||||
// where `klass` is read; the host's track label does not paint it.
|
||||
assert.equal(composeLabel(objects[0]).secondary, 'MBTA · BUS 34 KM/H');
|
||||
// The selected vehicle carries its own fuller card, so detection must not
|
||||
// label it a second time.
|
||||
assert.equal(
|
||||
painted.includes('RED'),
|
||||
false,
|
||||
'skipLabel keeps the selected vehicle from being labelled twice',
|
||||
);
|
||||
} finally {
|
||||
env.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('transit contacts take the vehicle tier, not the civil-air one', () => {
|
||||
assert.equal(resolveTier({ type: 'VEH' }), 'vehicle');
|
||||
assert.equal(resolveTier({ type: 'AIR' }), 'civil');
|
||||
assert.equal(resolveTier({ type: 'SEA' }), 'sea');
|
||||
});
|
||||
@@ -0,0 +1,274 @@
|
||||
/**
|
||||
* @module gtfsRealtime
|
||||
* @description Minimal GTFS-Realtime decoder for VehiclePosition feeds.
|
||||
*
|
||||
* Decodes only what the Transit layer needs from a `FeedMessage` — the header
|
||||
* and every entity's `VehiclePosition` — straight from the protobuf wire
|
||||
* format with `pbf` (already a dependency via the TomTom flow tiles). No
|
||||
* `gtfs-realtime-bindings`, no protobufjs, no generated code: the GTFS-RT
|
||||
* schema is stable, and field numbers are the contract.
|
||||
*
|
||||
* Field numbers (gtfs-realtime.proto, v2.0):
|
||||
* FeedMessage 1 header, 2 entity[]
|
||||
* FeedHeader 1 gtfs_realtime_version, 2 incrementality, 3 timestamp
|
||||
* FeedEntity 1 id, 2 is_deleted, 3 trip_update, 4 vehicle, 5 alert
|
||||
* VehiclePosition 1 trip, 2 position, 3 current_stop_sequence,
|
||||
* 4 current_status, 5 timestamp, 6 congestion_level,
|
||||
* 7 stop_id, 8 vehicle, 9 occupancy_status
|
||||
* TripDescriptor 1 trip_id, 2 start_time, 3 start_date,
|
||||
* 4 schedule_relationship, 5 route_id, 6 direction_id
|
||||
* Position 1 latitude, 2 longitude, 3 bearing, 4 odometer, 5 speed
|
||||
* VehicleDescriptor 1 id, 2 label, 3 license_plate
|
||||
*
|
||||
* Unknown fields (extensions, newer additions) are skipped by pbf, so a feed
|
||||
* that carries OVapi/NYCT extensions decodes the same as a plain one.
|
||||
*
|
||||
* Decoding is bounded, because a byte cap alone does not bound what a decode
|
||||
* COSTS: entities past `GTFS_MAX_ENTITIES` are left unread so pbf skips them
|
||||
* whole (the result says it truncated), and every string field — the feed
|
||||
* header's version included — is refused past `GTFS_MAX_STRING_CHARS` rather
|
||||
* than carried into the snapshot.
|
||||
*
|
||||
* Pure: safe to import from the browser layer, the Vite proxy, and node:test.
|
||||
*/
|
||||
|
||||
import { PbfReader } from 'pbf';
|
||||
|
||||
/** VehiclePosition.VehicleStopStatus enum → label. */
|
||||
export const VEHICLE_STOP_STATUS = Object.freeze({
|
||||
0: 'INCOMING_AT',
|
||||
1: 'STOPPED_AT',
|
||||
2: 'IN_TRANSIT_TO',
|
||||
});
|
||||
|
||||
/** VehiclePosition.OccupancyStatus enum → label. */
|
||||
export const OCCUPANCY_STATUS = Object.freeze({
|
||||
0: 'EMPTY',
|
||||
1: 'MANY_SEATS_AVAILABLE',
|
||||
2: 'FEW_SEATS_AVAILABLE',
|
||||
3: 'STANDING_ROOM_ONLY',
|
||||
4: 'CRUSHED_STANDING_ROOM_ONLY',
|
||||
5: 'FULL',
|
||||
6: 'NOT_ACCEPTING_PASSENGERS',
|
||||
7: 'NO_DATA_AVAILABLE',
|
||||
8: 'NOT_BOARDABLE',
|
||||
});
|
||||
|
||||
/**
|
||||
* Hard ceiling on decoded entities. Every registered feed is a metropolitan or
|
||||
* national fleet — the largest observed is a few thousand vehicles — so this is
|
||||
* two orders of magnitude above any honest payload.
|
||||
*/
|
||||
export const GTFS_MAX_ENTITIES = 50_000;
|
||||
/** Longest accepted string field. GTFS ids, labels and stop ids are short. */
|
||||
export const GTFS_MAX_STRING_CHARS = 256;
|
||||
/** FeedHeader.Incrementality: only FULL_DATASET carries a complete snapshot. */
|
||||
export const GTFS_INCREMENTALITY_FULL_DATASET = 0;
|
||||
export const GTFS_INCREMENTALITY_DIFFERENTIAL = 1;
|
||||
|
||||
/**
|
||||
* Read a length-delimited string, refusing one that is implausibly long.
|
||||
* An over-long field marks its containing message `oversize`, and the whole
|
||||
* record is then dropped — never truncated. Half an id is a different vehicle,
|
||||
* not a shorter name for the same one.
|
||||
* @param {object} pbf
|
||||
* @param {object} target Message being read, flagged when the field is refused.
|
||||
* @returns {string|null}
|
||||
*/
|
||||
function readBoundedString(pbf, target) {
|
||||
const value = pbf.readString();
|
||||
if (value.length <= GTFS_MAX_STRING_CHARS) return value;
|
||||
target.oversize = true;
|
||||
return null;
|
||||
}
|
||||
|
||||
function readFeedHeader(tag, header, pbf) {
|
||||
if (tag === 1) header.version = readBoundedString(pbf, header);
|
||||
else if (tag === 2) header.incrementality = pbf.readVarint();
|
||||
else if (tag === 3) header.timestamp = pbf.readVarint();
|
||||
}
|
||||
|
||||
function readTripDescriptor(tag, trip, pbf) {
|
||||
if (tag === 1) trip.tripId = readBoundedString(pbf, trip);
|
||||
else if (tag === 2) trip.startTime = readBoundedString(pbf, trip);
|
||||
else if (tag === 3) trip.startDate = readBoundedString(pbf, trip);
|
||||
else if (tag === 4) trip.scheduleRelationship = pbf.readVarint();
|
||||
else if (tag === 5) trip.routeId = readBoundedString(pbf, trip);
|
||||
else if (tag === 6) trip.directionId = pbf.readVarint();
|
||||
}
|
||||
|
||||
function readPosition(tag, position, pbf) {
|
||||
if (tag === 1) position.latitude = pbf.readFloat();
|
||||
else if (tag === 2) position.longitude = pbf.readFloat();
|
||||
else if (tag === 3) position.bearing = pbf.readFloat();
|
||||
else if (tag === 4) position.odometer = pbf.readDouble();
|
||||
else if (tag === 5) position.speed = pbf.readFloat();
|
||||
}
|
||||
|
||||
function readVehicleDescriptor(tag, descriptor, pbf) {
|
||||
if (tag === 1) descriptor.id = readBoundedString(pbf, descriptor);
|
||||
else if (tag === 2) descriptor.label = readBoundedString(pbf, descriptor);
|
||||
else if (tag === 3)
|
||||
descriptor.licensePlate = readBoundedString(pbf, descriptor);
|
||||
}
|
||||
|
||||
function readVehiclePosition(tag, vehicle, pbf) {
|
||||
if (tag === 1) vehicle.trip = pbf.readMessage(readTripDescriptor, {});
|
||||
else if (tag === 2) vehicle.position = pbf.readMessage(readPosition, {});
|
||||
else if (tag === 3) vehicle.currentStopSequence = pbf.readVarint();
|
||||
else if (tag === 4) vehicle.currentStatus = pbf.readVarint();
|
||||
else if (tag === 5) vehicle.timestamp = pbf.readVarint();
|
||||
else if (tag === 6) vehicle.congestionLevel = pbf.readVarint();
|
||||
else if (tag === 7) vehicle.stopId = readBoundedString(pbf, vehicle);
|
||||
else if (tag === 8)
|
||||
vehicle.vehicle = pbf.readMessage(readVehicleDescriptor, {});
|
||||
else if (tag === 9) vehicle.occupancyStatus = pbf.readVarint();
|
||||
}
|
||||
|
||||
function readFeedEntity(tag, entity, pbf) {
|
||||
if (tag === 1) entity.id = readBoundedString(pbf, entity);
|
||||
else if (tag === 2) entity.isDeleted = pbf.readBoolean();
|
||||
else if (tag === 4) entity.vehicle = pbf.readMessage(readVehiclePosition, {});
|
||||
// 3 (trip_update) and 5 (alert) are skipped: pbf advances past any tag the
|
||||
// reader leaves untouched.
|
||||
}
|
||||
|
||||
function readFeedMessage(tag, message, pbf) {
|
||||
if (tag === 1) message.header = pbf.readMessage(readFeedHeader, {});
|
||||
else if (tag === 2) {
|
||||
// Past the cap the field is left UNREAD. pbf skips any field its handler
|
||||
// did not consume, so the excess costs one length varint each instead of a
|
||||
// full nested decode that is then thrown away.
|
||||
if (message.entities.length >= GTFS_MAX_ENTITIES) {
|
||||
message.truncated = true;
|
||||
return;
|
||||
}
|
||||
message.entities.push(pbf.readMessage(readFeedEntity, {}));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a raw GTFS-Realtime FeedMessage.
|
||||
* @param {Uint8Array|ArrayBuffer} bytes Protobuf wire bytes.
|
||||
* @returns {{ header: {version?: string, incrementality?: number, timestamp?: number},
|
||||
* entities: object[], truncated: boolean }}
|
||||
*/
|
||||
export function decodeFeedMessage(bytes) {
|
||||
const view = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
|
||||
const pbf = new PbfReader(view);
|
||||
return pbf.readFields(readFeedMessage, {
|
||||
header: {},
|
||||
entities: [],
|
||||
truncated: false,
|
||||
});
|
||||
}
|
||||
|
||||
function finiteOrNull(value) {
|
||||
return Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
function nonEmptyString(value) {
|
||||
const text = typeof value === 'string' ? value.trim() : '';
|
||||
return text ? text : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when a lat/lon pair is a usable surface position. Rejects non-finite
|
||||
* values, out-of-range degrees, and the (0,0) null island a cold GPS reports.
|
||||
* @param {number} lat
|
||||
* @param {number} lon
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isPlausibleVehiclePosition(lat, lon) {
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return false;
|
||||
if (Math.abs(lat) > 90 || Math.abs(lon) > 180) return false;
|
||||
if (Math.abs(lat) < 1e-6 && Math.abs(lon) < 1e-6) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flatten one decoded FeedEntity into the record the Transit layer renders,
|
||||
* or null when the entity carries no usable vehicle position.
|
||||
* @param {object} entity Decoded FeedEntity.
|
||||
* @returns {object|null}
|
||||
*/
|
||||
export function normalizeVehicleEntity(entity) {
|
||||
if (!entity || entity.isDeleted === true) return null;
|
||||
const vehicle = entity.vehicle;
|
||||
const position = vehicle?.position;
|
||||
if (!vehicle || !position) return null;
|
||||
// Any refused (over-long) string anywhere in this entity makes the whole
|
||||
// record untrustworthy — drop it rather than render a half-identified dot.
|
||||
if (
|
||||
entity.oversize ||
|
||||
vehicle.oversize ||
|
||||
vehicle.trip?.oversize ||
|
||||
vehicle.vehicle?.oversize
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const lat = finiteOrNull(position.latitude);
|
||||
const lon = finiteOrNull(position.longitude);
|
||||
if (!isPlausibleVehiclePosition(lat, lon)) return null;
|
||||
const id = nonEmptyString(vehicle.vehicle?.id) || nonEmptyString(entity.id);
|
||||
if (!id) return null;
|
||||
const bearing = finiteOrNull(position.bearing);
|
||||
const speed = finiteOrNull(position.speed);
|
||||
const timestamp =
|
||||
Number.isFinite(vehicle.timestamp) && vehicle.timestamp > 0
|
||||
? vehicle.timestamp
|
||||
: null;
|
||||
return {
|
||||
id,
|
||||
lat: Number(lat.toFixed(6)),
|
||||
lon: Number(lon.toFixed(6)),
|
||||
bearing: bearing === null ? null : ((bearing % 360) + 360) % 360,
|
||||
speedMps: speed === null || speed < 0 ? null : speed,
|
||||
timestamp,
|
||||
routeId: nonEmptyString(vehicle.trip?.routeId),
|
||||
tripId: nonEmptyString(vehicle.trip?.tripId),
|
||||
directionId: Number.isInteger(vehicle.trip?.directionId)
|
||||
? vehicle.trip.directionId
|
||||
: null,
|
||||
label: nonEmptyString(vehicle.vehicle?.label),
|
||||
stopId: nonEmptyString(vehicle.stopId),
|
||||
status: VEHICLE_STOP_STATUS[vehicle.currentStatus] || null,
|
||||
occupancy: OCCUPANCY_STATUS[vehicle.occupancyStatus] || null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a VehiclePositions feed into the compact snapshot the proxy serves.
|
||||
* Duplicate vehicle ids keep the newest timestamp (feeds occasionally repeat
|
||||
* a vehicle across two trip entities during a handover).
|
||||
* @param {Uint8Array|ArrayBuffer} bytes Protobuf wire bytes.
|
||||
* @returns {{ version: string|null, timestamp: number|null, incrementality: number,
|
||||
* entityCount: number, truncated: boolean, vehicles: object[] }}
|
||||
*/
|
||||
export function decodeVehiclePositions(bytes) {
|
||||
const message = decodeFeedMessage(bytes);
|
||||
const byId = new Map();
|
||||
for (const entity of message.entities) {
|
||||
const record = normalizeVehicleEntity(entity);
|
||||
if (!record) continue;
|
||||
const existing = byId.get(record.id);
|
||||
if (!existing || (record.timestamp ?? 0) >= (existing.timestamp ?? 0)) {
|
||||
byId.set(record.id, record);
|
||||
}
|
||||
}
|
||||
const headerTimestamp = message.header?.timestamp;
|
||||
const incrementality = Number.isFinite(message.header?.incrementality)
|
||||
? message.header.incrementality
|
||||
: GTFS_INCREMENTALITY_FULL_DATASET;
|
||||
return {
|
||||
version: nonEmptyString(message.header?.version),
|
||||
timestamp:
|
||||
Number.isFinite(headerTimestamp) && headerTimestamp > 0
|
||||
? headerTimestamp
|
||||
: null,
|
||||
incrementality,
|
||||
entityCount: message.entities.length,
|
||||
truncated: message.truncated === true,
|
||||
vehicles: [...byId.values()],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { PbfWriter } from 'pbf';
|
||||
import {
|
||||
GTFS_MAX_ENTITIES,
|
||||
GTFS_MAX_STRING_CHARS,
|
||||
decodeFeedMessage,
|
||||
decodeVehiclePositions,
|
||||
isPlausibleVehiclePosition,
|
||||
normalizeVehicleEntity,
|
||||
} from './gtfsRealtime.js';
|
||||
|
||||
/** Encode a nested message with a fresh writer and embed it as a bytes field. */
|
||||
function embed(writer, tag, build) {
|
||||
const inner = new PbfWriter();
|
||||
build(inner);
|
||||
writer.writeBytesField(tag, inner.finish());
|
||||
}
|
||||
|
||||
/**
|
||||
* Hand-encode one FeedEntity carrying a VehiclePosition, from the proto field
|
||||
* numbers — the same contract the decoder reads, written independently.
|
||||
*/
|
||||
function writeVehicleEntity(
|
||||
writer,
|
||||
{
|
||||
entityId,
|
||||
vehicleId,
|
||||
label,
|
||||
lat,
|
||||
lon,
|
||||
bearing,
|
||||
speed,
|
||||
timestamp,
|
||||
routeId,
|
||||
tripId,
|
||||
directionId,
|
||||
stopId,
|
||||
status,
|
||||
occupancy,
|
||||
isDeleted,
|
||||
extraUnknownFields = false,
|
||||
},
|
||||
) {
|
||||
embed(writer, 2, (entity) => {
|
||||
if (entityId != null) entity.writeStringField(1, entityId);
|
||||
if (isDeleted) entity.writeBooleanField(2, true);
|
||||
embed(entity, 4, (vehicle) => {
|
||||
if (routeId != null || tripId != null || directionId != null) {
|
||||
embed(vehicle, 1, (trip) => {
|
||||
if (tripId != null) trip.writeStringField(1, tripId);
|
||||
if (routeId != null) trip.writeStringField(5, routeId);
|
||||
if (directionId != null) trip.writeVarintField(6, directionId);
|
||||
});
|
||||
}
|
||||
if (lat != null) {
|
||||
embed(vehicle, 2, (position) => {
|
||||
position.writeFloatField(1, lat);
|
||||
position.writeFloatField(2, lon);
|
||||
if (bearing != null) position.writeFloatField(3, bearing);
|
||||
if (speed != null) position.writeFloatField(5, speed);
|
||||
if (extraUnknownFields) position.writeDoubleField(4, 123456.5); // odometer
|
||||
});
|
||||
}
|
||||
if (status != null) vehicle.writeVarintField(4, status);
|
||||
if (timestamp != null) vehicle.writeVarintField(5, timestamp);
|
||||
if (stopId != null) vehicle.writeStringField(7, stopId);
|
||||
if (vehicleId != null || label != null) {
|
||||
embed(vehicle, 8, (descriptor) => {
|
||||
if (vehicleId != null) descriptor.writeStringField(1, vehicleId);
|
||||
if (label != null) descriptor.writeStringField(2, label);
|
||||
});
|
||||
}
|
||||
if (occupancy != null) vehicle.writeVarintField(9, occupancy);
|
||||
if (extraUnknownFields)
|
||||
vehicle.writeStringField(1001, 'operator-extension');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function encodeFeed(
|
||||
entities,
|
||||
{ version = '2.0', timestamp = 1_788_936_000 } = {},
|
||||
) {
|
||||
const writer = new PbfWriter();
|
||||
embed(writer, 1, (header) => {
|
||||
header.writeStringField(1, version);
|
||||
header.writeVarintField(2, 0);
|
||||
header.writeVarintField(3, timestamp);
|
||||
});
|
||||
for (const entity of entities) writeVehicleEntity(writer, entity);
|
||||
return writer.finish();
|
||||
}
|
||||
|
||||
test('decodes header and every VehiclePosition field the layer renders', () => {
|
||||
const bytes = encodeFeed([
|
||||
{
|
||||
entityId: 'e1',
|
||||
vehicleId: 'bus-17',
|
||||
label: '1557',
|
||||
lat: 44.927551,
|
||||
lon: -93.386711,
|
||||
bearing: 248,
|
||||
speed: 11.2,
|
||||
timestamp: 1_788_936_945,
|
||||
routeId: '17',
|
||||
tripId: '1134773',
|
||||
directionId: 0,
|
||||
stopId: '57458',
|
||||
status: 1,
|
||||
occupancy: 2,
|
||||
},
|
||||
]);
|
||||
const snapshot = decodeVehiclePositions(bytes);
|
||||
assert.equal(snapshot.version, '2.0');
|
||||
assert.equal(snapshot.timestamp, 1_788_936_000);
|
||||
assert.equal(snapshot.entityCount, 1);
|
||||
assert.equal(snapshot.vehicles.length, 1);
|
||||
const [v] = snapshot.vehicles;
|
||||
assert.equal(v.id, 'bus-17');
|
||||
assert.equal(v.label, '1557');
|
||||
assert.ok(Math.abs(v.lat - 44.927551) < 1e-5);
|
||||
assert.ok(Math.abs(v.lon + 93.386711) < 1e-5);
|
||||
assert.equal(v.bearing, 248);
|
||||
assert.ok(Math.abs(v.speedMps - 11.2) < 1e-5);
|
||||
assert.equal(v.timestamp, 1_788_936_945);
|
||||
assert.equal(v.routeId, '17');
|
||||
assert.equal(v.tripId, '1134773');
|
||||
assert.equal(v.directionId, 0);
|
||||
assert.equal(v.stopId, '57458');
|
||||
assert.equal(v.status, 'STOPPED_AT');
|
||||
assert.equal(v.occupancy, 'FEW_SEATS_AVAILABLE');
|
||||
});
|
||||
|
||||
test('unknown and extension fields are skipped, not misread', () => {
|
||||
const bytes = encodeFeed([
|
||||
{ entityId: 'x', lat: 51.3, lon: 5.33, extraUnknownFields: true },
|
||||
]);
|
||||
const snapshot = decodeVehiclePositions(bytes);
|
||||
assert.equal(snapshot.vehicles.length, 1);
|
||||
assert.equal(snapshot.vehicles[0].id, 'x');
|
||||
assert.ok(Math.abs(snapshot.vehicles[0].lat - 51.3) < 1e-5);
|
||||
});
|
||||
|
||||
test('entities without a usable position are dropped and never throw', () => {
|
||||
const bytes = encodeFeed([
|
||||
{ entityId: 'no-position' },
|
||||
{ entityId: 'deleted', lat: 1, lon: 1, isDeleted: true },
|
||||
{ entityId: 'null-island', lat: 0, lon: 0 },
|
||||
{ entityId: 'out-of-range', lat: 91, lon: 10 },
|
||||
{ entityId: 'nan', lat: Number.NaN, lon: 10 },
|
||||
{ entityId: 'keeper', lat: 60.17, lon: 24.94 },
|
||||
]);
|
||||
const snapshot = decodeVehiclePositions(bytes);
|
||||
assert.equal(snapshot.entityCount, 6);
|
||||
assert.deepEqual(
|
||||
snapshot.vehicles.map((v) => v.id),
|
||||
['keeper'],
|
||||
);
|
||||
});
|
||||
|
||||
test('vehicle id falls back to the entity id and the vehicle is dropped when neither exists', () => {
|
||||
const bytes = encodeFeed([
|
||||
{ entityId: 'entity-only', lat: 42.3, lon: -71.1 },
|
||||
{ lat: 42.3, lon: -71.1 },
|
||||
]);
|
||||
const snapshot = decodeVehiclePositions(bytes);
|
||||
assert.deepEqual(
|
||||
snapshot.vehicles.map((v) => v.id),
|
||||
['entity-only'],
|
||||
);
|
||||
});
|
||||
|
||||
test('duplicate vehicle ids keep the newest timestamp', () => {
|
||||
const bytes = encodeFeed([
|
||||
{ entityId: 'a', vehicleId: 'v1', lat: 42.3, lon: -71.1, timestamp: 100 },
|
||||
{ entityId: 'b', vehicleId: 'v1', lat: 42.31, lon: -71.11, timestamp: 200 },
|
||||
{ entityId: 'c', vehicleId: 'v1', lat: 42.32, lon: -71.12, timestamp: 150 },
|
||||
]);
|
||||
const snapshot = decodeVehiclePositions(bytes);
|
||||
assert.equal(snapshot.vehicles.length, 1);
|
||||
assert.equal(snapshot.vehicles[0].timestamp, 200);
|
||||
assert.ok(Math.abs(snapshot.vehicles[0].lat - 42.31) < 1e-5);
|
||||
});
|
||||
|
||||
test('bearing is normalized to [0, 360) and negative speed is treated as unknown', () => {
|
||||
const bytes = encodeFeed([
|
||||
{ entityId: 'neg', lat: 30.2, lon: -97.7, bearing: -90, speed: -1 },
|
||||
{ entityId: 'wrap', lat: 30.2, lon: -97.7, bearing: 450 },
|
||||
]);
|
||||
const [neg, wrap] = decodeVehiclePositions(bytes).vehicles;
|
||||
assert.equal(neg.bearing, 270);
|
||||
assert.equal(neg.speedMps, null);
|
||||
assert.equal(wrap.bearing, 90);
|
||||
});
|
||||
|
||||
test('missing header yields null version/timestamp rather than garbage', () => {
|
||||
const writer = new PbfWriter();
|
||||
writeVehicleEntity(writer, { entityId: 'solo', lat: 10, lon: 10 });
|
||||
const snapshot = decodeVehiclePositions(writer.finish());
|
||||
assert.equal(snapshot.version, null);
|
||||
assert.equal(snapshot.timestamp, null);
|
||||
assert.equal(snapshot.vehicles.length, 1);
|
||||
});
|
||||
|
||||
test('decodeFeedMessage accepts an ArrayBuffer and an empty feed', () => {
|
||||
const empty = decodeFeedMessage(new ArrayBuffer(0));
|
||||
assert.deepEqual(empty, { header: {}, entities: [], truncated: false });
|
||||
const bytes = encodeFeed([]);
|
||||
const asBuffer = bytes.buffer.slice(
|
||||
bytes.byteOffset,
|
||||
bytes.byteOffset + bytes.byteLength,
|
||||
);
|
||||
assert.equal(decodeFeedMessage(asBuffer).header.version, '2.0');
|
||||
});
|
||||
|
||||
test('isPlausibleVehiclePosition and normalizeVehicleEntity guard their inputs', () => {
|
||||
assert.equal(isPlausibleVehiclePosition(45, 90), true);
|
||||
assert.equal(isPlausibleVehiclePosition(0, 0), false);
|
||||
assert.equal(isPlausibleVehiclePosition(-90.0001, 0), false);
|
||||
assert.equal(isPlausibleVehiclePosition(1, Infinity), false);
|
||||
assert.equal(normalizeVehicleEntity(null), null);
|
||||
assert.equal(normalizeVehicleEntity({ id: 'no-vehicle' }), null);
|
||||
assert.equal(
|
||||
normalizeVehicleEntity({
|
||||
id: 'v',
|
||||
vehicle: { position: { latitude: 1, longitude: 2 }, currentStatus: 99 },
|
||||
})?.status,
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('the decode is bounded by entity count as well as by bytes', () => {
|
||||
const entities = [];
|
||||
for (let index = 0; index < GTFS_MAX_ENTITIES + 25; index += 1) {
|
||||
entities.push({
|
||||
entityId: `e${index}`,
|
||||
vehicleId: `v${index}`,
|
||||
lat: 42 + index * 1e-6,
|
||||
lon: -71,
|
||||
timestamp: 1_788_936_000,
|
||||
});
|
||||
}
|
||||
const decoded = decodeVehiclePositions(encodeFeed(entities));
|
||||
assert.equal(decoded.entityCount, GTFS_MAX_ENTITIES);
|
||||
assert.equal(decoded.truncated, true);
|
||||
assert.equal(decoded.vehicles.length, GTFS_MAX_ENTITIES);
|
||||
// A feed inside the cap is not flagged.
|
||||
assert.equal(
|
||||
decodeVehiclePositions(encodeFeed(entities.slice(0, 3))).truncated,
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('an implausibly long string drops the whole record rather than truncating an id', () => {
|
||||
const long = 'x'.repeat(GTFS_MAX_STRING_CHARS + 1);
|
||||
const decoded = decodeVehiclePositions(
|
||||
encodeFeed([
|
||||
{
|
||||
entityId: 'ok',
|
||||
vehicleId: 'good',
|
||||
lat: 42.1,
|
||||
lon: -71.1,
|
||||
timestamp: 1_788_936_000,
|
||||
},
|
||||
{
|
||||
entityId: 'bad',
|
||||
vehicleId: long,
|
||||
lat: 42.2,
|
||||
lon: -71.2,
|
||||
timestamp: 1_788_936_000,
|
||||
},
|
||||
{
|
||||
entityId: 'bad-route',
|
||||
vehicleId: 'v3',
|
||||
routeId: long,
|
||||
lat: 42.3,
|
||||
lon: -71.3,
|
||||
timestamp: 1_788_936_000,
|
||||
},
|
||||
]),
|
||||
);
|
||||
// The entity is still counted — the decoder had to walk past it — but it is
|
||||
// never rendered, because half an id is a different vehicle.
|
||||
assert.equal(decoded.entityCount, 3);
|
||||
assert.deepEqual(
|
||||
decoded.vehicles.map((vehicle) => vehicle.id),
|
||||
['good'],
|
||||
);
|
||||
// A string exactly at the cap is fine.
|
||||
const atCap = 'y'.repeat(GTFS_MAX_STRING_CHARS);
|
||||
const kept = decodeVehiclePositions(
|
||||
encodeFeed([
|
||||
{ entityId: 'e', vehicleId: atCap, lat: 42, lon: -71, timestamp: 1 },
|
||||
]),
|
||||
);
|
||||
assert.deepEqual(
|
||||
kept.vehicles.map((vehicle) => vehicle.id),
|
||||
[atCap],
|
||||
);
|
||||
});
|
||||
|
||||
test('incrementality is reported, not silently discarded', () => {
|
||||
assert.equal(decodeVehiclePositions(encodeFeed([])).incrementality, 0);
|
||||
const writer = new PbfWriter();
|
||||
embed(writer, 1, (header) => {
|
||||
header.writeStringField(1, '2.0');
|
||||
header.writeVarintField(2, 1);
|
||||
header.writeVarintField(3, 1_788_936_000);
|
||||
});
|
||||
assert.equal(decodeVehiclePositions(writer.finish()).incrementality, 1);
|
||||
});
|
||||
|
||||
test('the feed header version is bounded like every other string', () => {
|
||||
const writer = new PbfWriter();
|
||||
embed(writer, 1, (header) => {
|
||||
header.writeStringField(1, 'v'.repeat(GTFS_MAX_STRING_CHARS + 1));
|
||||
header.writeVarintField(2, 0);
|
||||
header.writeVarintField(3, 1_788_936_000);
|
||||
});
|
||||
const decoded = decodeVehiclePositions(writer.finish());
|
||||
// Refused, not carried: an unbounded header field is the one string a feed
|
||||
// can grow without the entity cap ever seeing it.
|
||||
assert.equal(decoded.version, null);
|
||||
assert.equal(decoded.timestamp, 1_788_936_000);
|
||||
});
|
||||
|
||||
test('entities past the cap are skipped whole rather than decoded and discarded', () => {
|
||||
const decoded = decodeVehiclePositions(
|
||||
encodeFeed(
|
||||
Array.from({ length: GTFS_MAX_ENTITIES + 5 }, (_, index) => ({
|
||||
entityId: `e${index}`,
|
||||
vehicleId: `v${index}`,
|
||||
lat: 42 + index * 1e-6,
|
||||
lon: -71,
|
||||
timestamp: 1_788_936_000,
|
||||
})),
|
||||
),
|
||||
);
|
||||
assert.equal(decoded.entityCount, GTFS_MAX_ENTITIES);
|
||||
assert.equal(decoded.truncated, true);
|
||||
// The skipped tail never becomes an object, so the last retained vehicle is
|
||||
// the one at the cap and nothing beyond it appears.
|
||||
assert.equal(
|
||||
decoded.vehicles.some((item) => item.id === `v${GTFS_MAX_ENTITIES}`),
|
||||
false,
|
||||
);
|
||||
});
|
||||
+150
-14
@@ -49,7 +49,12 @@ const _scratchWorldForward = new Cesium.Cartesian3();
|
||||
* unavailable (off-screen/behind camera/degenerate).
|
||||
* @returns {number|null} Rotation in radians, or `previous` when unknown.
|
||||
*/
|
||||
export function screenProjectedRotation(scene, position, courseDeg, previous = null) {
|
||||
export function screenProjectedRotation(
|
||||
scene,
|
||||
position,
|
||||
courseDeg,
|
||||
previous = null,
|
||||
) {
|
||||
const camera = scene?.camera;
|
||||
if (!camera?.rightWC || !camera?.upWC || !position) return previous;
|
||||
|
||||
@@ -58,20 +63,26 @@ export function screenProjectedRotation(scene, position, courseDeg, previous = n
|
||||
Math.sin(courseRad) * FORWARD_PROBE_M,
|
||||
Math.cos(courseRad) * FORWARD_PROBE_M,
|
||||
0,
|
||||
_scratchForward
|
||||
_scratchForward,
|
||||
);
|
||||
const enu = Cesium.Transforms.eastNorthUpToFixedFrame(
|
||||
position,
|
||||
Cesium.Ellipsoid.WGS84,
|
||||
_scratchEnu,
|
||||
);
|
||||
Cesium.Matrix4.multiplyByPointAsVector(
|
||||
enu,
|
||||
_scratchForward,
|
||||
_scratchWorldForward,
|
||||
);
|
||||
const enu = Cesium.Transforms.eastNorthUpToFixedFrame(position, Cesium.Ellipsoid.WGS84, _scratchEnu);
|
||||
Cesium.Matrix4.multiplyByPointAsVector(enu, _scratchForward, _scratchWorldForward);
|
||||
|
||||
// Screen x follows camera-right. Window y grows downward, the opposite of
|
||||
// camera-up. Projecting the vector itself avoids clipping/behind-camera
|
||||
// failure modes from the old forward-point worldToWindowCoordinates probe.
|
||||
const dx = Cesium.Cartesian3.dot(_scratchWorldForward, camera.rightWC);
|
||||
const dy = -Cesium.Cartesian3.dot(_scratchWorldForward, camera.upWC);
|
||||
if (
|
||||
(dx * dx + dy * dy)
|
||||
< MIN_SCREEN_COMPONENT_M * MIN_SCREEN_COMPONENT_M
|
||||
) return previous;
|
||||
if (dx * dx + dy * dy < MIN_SCREEN_COMPONENT_M * MIN_SCREEN_COMPONENT_M)
|
||||
return previous;
|
||||
|
||||
// Window y grows downward; rotation 0 = icon pointing screen-up.
|
||||
// Icon direction in window coords after CCW rotation r is (-sin r, -cos r),
|
||||
@@ -94,13 +105,20 @@ export function stabilizeScreenRotation(
|
||||
next,
|
||||
deadbandRad = ROTATION_DEADBAND_RAD,
|
||||
) {
|
||||
if (!Number.isFinite(next)) return Number.isFinite(previous) ? previous : null;
|
||||
if (!Number.isFinite(next))
|
||||
return Number.isFinite(previous) ? previous : null;
|
||||
if (!Number.isFinite(previous)) return next;
|
||||
const delta = Math.atan2(Math.sin(next - previous), Math.cos(next - previous));
|
||||
const delta = Math.atan2(
|
||||
Math.sin(next - previous),
|
||||
Math.cos(next - previous),
|
||||
);
|
||||
return Math.abs(delta) < Math.max(0, deadbandRad) ? previous : next;
|
||||
}
|
||||
|
||||
const _occluder = new Cesium.EllipsoidalOccluder(Cesium.Ellipsoid.WGS84, new Cesium.Cartesian3());
|
||||
const _occluder = new Cesium.EllipsoidalOccluder(
|
||||
Cesium.Ellipsoid.WGS84,
|
||||
new Cesium.Cartesian3(),
|
||||
);
|
||||
|
||||
/**
|
||||
* Half-width of the crossfade band centred on the ellipsoid silhouette, in
|
||||
@@ -169,7 +187,11 @@ const _wgs84OneOverRadii = Cesium.Ellipsoid.WGS84.oneOverRadii;
|
||||
* @param {number} [featherRad=HORIZON_FEATHER_RAD] Half-width of the blend band.
|
||||
* @returns {number} 0 (ground behind) … 1 (sky behind).
|
||||
*/
|
||||
export function skyBackdropFactor(cameraPosition, position, featherRad = HORIZON_FEATHER_RAD) {
|
||||
export function skyBackdropFactor(
|
||||
cameraPosition,
|
||||
position,
|
||||
featherRad = HORIZON_FEATHER_RAD,
|
||||
) {
|
||||
if (!cameraPosition || !position) return 0;
|
||||
|
||||
const s = _wgs84OneOverRadii;
|
||||
@@ -236,6 +258,120 @@ export function horizonOccluder(camera) {
|
||||
*/
|
||||
export function cameraPoseSignature(camera) {
|
||||
const p = camera.positionWC;
|
||||
return `${Math.round(p.x / 10)}:${Math.round(p.y / 10)}:${Math.round(p.z / 10)}:` +
|
||||
`${camera.heading.toFixed(3)}:${camera.pitch.toFixed(3)}:${camera.roll.toFixed(3)}`;
|
||||
return (
|
||||
`${Math.round(p.x / 10)}:${Math.round(p.y / 10)}:${Math.round(p.z / 10)}:` +
|
||||
`${camera.heading.toFixed(3)}:${camera.pitch.toFixed(3)}:${camera.roll.toFixed(3)}`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Meters ahead used by the exact projection below. Short on purpose: the point
|
||||
* of a perspective projection is that it is LOCAL to the contact, and a two
|
||||
* kilometre probe on a bus at six hundred metres would sample a part of the
|
||||
* frustum the vehicle is nowhere near.
|
||||
*/
|
||||
const NEAR_PROBE_M = 30;
|
||||
const _scratchProbe = new Cesium.Cartesian3();
|
||||
const _scratchWindowAt = new Cesium.Cartesian2();
|
||||
const _scratchWindowAhead = new Cesium.Cartesian2();
|
||||
|
||||
/**
|
||||
* Billboard rotation for a SURFACE contact, projected exactly — perspective
|
||||
* division included — instead of through the camera-basis approximation.
|
||||
*
|
||||
* `screenProjectedRotation` above deliberately projects the course vector onto
|
||||
* the camera's right/up basis, which is orthographic: exact at the viewport
|
||||
* centre and an approximation away from it. That is the right trade for
|
||||
* aircraft, where the alternative loses continuity through a tracked orbit and
|
||||
* the contacts are far enough away for the error to be small.
|
||||
*
|
||||
* It is the wrong trade for street-level vehicles. A probe with a real Cesium
|
||||
* camera at 600 m and −45° pitch, on a northbound contact 350 m right of
|
||||
* centre, put the orthographic answer at 0° where the true screen direction of
|
||||
* travel was 22.5°. At that pitch and that range the vehicles fill the frame
|
||||
* away from the centre, and every one of them points visibly wrong.
|
||||
*
|
||||
* So this projects two nearby world points through the actual view-projection
|
||||
* and reads the angle between them on screen. When either point cannot be
|
||||
* projected — behind the camera, off the frustum, degenerate — it falls back to
|
||||
* the camera-basis answer rather than inventing one.
|
||||
*
|
||||
* @param {Cesium.Scene} scene The scene (projection source).
|
||||
* @param {Cesium.Cartesian3} position Entity world position.
|
||||
* @param {number} courseDeg Course in degrees clockwise from north.
|
||||
* @param {number|null} previous Rotation to keep when projection is unavailable.
|
||||
* @returns {number|null} Rotation in radians, or the fallback.
|
||||
*/
|
||||
export function perspectiveProjectedRotation(
|
||||
scene,
|
||||
position,
|
||||
courseDeg,
|
||||
previous = null,
|
||||
) {
|
||||
if (!scene?.camera || !position || !Number.isFinite(courseDeg)) {
|
||||
return previous;
|
||||
}
|
||||
const courseRad = Cesium.Math.toRadians(courseDeg);
|
||||
Cesium.Cartesian3.fromElements(
|
||||
Math.sin(courseRad) * NEAR_PROBE_M,
|
||||
Math.cos(courseRad) * NEAR_PROBE_M,
|
||||
0,
|
||||
_scratchForward,
|
||||
);
|
||||
const enu = Cesium.Transforms.eastNorthUpToFixedFrame(
|
||||
position,
|
||||
Cesium.Ellipsoid.WGS84,
|
||||
_scratchEnu,
|
||||
);
|
||||
Cesium.Matrix4.multiplyByPointAsVector(
|
||||
enu,
|
||||
_scratchForward,
|
||||
_scratchWorldForward,
|
||||
);
|
||||
Cesium.Cartesian3.add(position, _scratchWorldForward, _scratchProbe);
|
||||
|
||||
const at = Cesium.SceneTransforms.worldToWindowCoordinates(
|
||||
scene,
|
||||
position,
|
||||
_scratchWindowAt,
|
||||
);
|
||||
const ahead = Cesium.SceneTransforms.worldToWindowCoordinates(
|
||||
scene,
|
||||
_scratchProbe,
|
||||
_scratchWindowAhead,
|
||||
);
|
||||
if (!at || !ahead) {
|
||||
return screenProjectedRotation(scene, position, courseDeg, previous);
|
||||
}
|
||||
const dx = ahead.x - at.x;
|
||||
const dy = ahead.y - at.y;
|
||||
const separation = Math.hypot(dx, dy);
|
||||
// Window y grows downward; rotation 0 = icon pointing screen-up.
|
||||
const exact = Math.atan2(-dx, -dy);
|
||||
if (separation >= PERSPECTIVE_BLEND_MAX_PX) return exact;
|
||||
// Below a pixel or so of separation the exact answer is dominated by
|
||||
// projection noise, and above it the camera-basis answer is the wrong one
|
||||
// by up to twenty-odd degrees. A hard cutoff between the two flipped a
|
||||
// sprite by 23° for a 20 m change of camera altitude; the two answers are
|
||||
// blended across the band instead, the short way round, so the rotation is
|
||||
// continuous through it.
|
||||
const basis = screenProjectedRotation(scene, position, courseDeg, previous);
|
||||
if (!Number.isFinite(basis)) return exact;
|
||||
if (separation <= PERSPECTIVE_BLEND_MIN_PX) return basis;
|
||||
const weight =
|
||||
(separation - PERSPECTIVE_BLEND_MIN_PX) /
|
||||
(PERSPECTIVE_BLEND_MAX_PX - PERSPECTIVE_BLEND_MIN_PX);
|
||||
const delta = Math.atan2(Math.sin(exact - basis), Math.cos(exact - basis));
|
||||
return basis + delta * weight;
|
||||
}
|
||||
|
||||
/**
|
||||
* Probe separations (px) between which the exact and camera-basis answers
|
||||
* are blended. At or below the floor the camera-basis answer is used alone;
|
||||
* at or above the ceiling the exact one.
|
||||
*/
|
||||
export const PERSPECTIVE_BLEND_MIN_PX = 0.5;
|
||||
export const PERSPECTIVE_BLEND_MAX_PX = 3;
|
||||
|
||||
/** Exported for tests: how far ahead the exact projection probes. */
|
||||
export const PERSPECTIVE_PROBE_M = NEAR_PROBE_M;
|
||||
@@ -47,8 +47,12 @@ test('off-center oblique contacts pin the documented perspective divergence', ()
|
||||
const right = new Cesium.Cartesian3(0, 1, 0);
|
||||
const up = new Cesium.Cartesian3(Math.sin(pitch), 0, Math.cos(pitch));
|
||||
const forward = new Cesium.Cartesian3(-Math.cos(pitch), 0, Math.sin(pitch));
|
||||
const basisRotation = screenProjectedRotation({ camera: { rightWC: right, upWC: up } },
|
||||
equatorPosition, 0, null);
|
||||
const basisRotation = screenProjectedRotation(
|
||||
{ camera: { rightWC: right, upWC: up } },
|
||||
equatorPosition,
|
||||
0,
|
||||
null,
|
||||
);
|
||||
|
||||
// Exact pinhole derivative for a contact 2 km right of center and 5 km in
|
||||
// front. North has a depth component under this oblique camera, so exact
|
||||
@@ -65,26 +69,29 @@ test('off-center oblique contacts pin the documented perspective divergence', ()
|
||||
const exactRotation = Math.atan2(-exactScreenX, exactScreenUp);
|
||||
|
||||
assert.ok(Math.abs(basisRotation) < 1e-12);
|
||||
assert.ok(Math.abs(wrappedDelta(exactRotation, basisRotation)) > Cesium.Math.toRadians(5));
|
||||
assert.ok(
|
||||
Math.abs(wrappedDelta(exactRotation, basisRotation)) >
|
||||
Cesium.Math.toRadians(5),
|
||||
);
|
||||
});
|
||||
|
||||
test('screen rotation holds sub-degree projection noise', () => {
|
||||
const previous = 1;
|
||||
assert.equal(
|
||||
stabilizeScreenRotation(previous, previous + (0.25 * Math.PI / 180)),
|
||||
stabilizeScreenRotation(previous, previous + (0.25 * Math.PI) / 180),
|
||||
previous,
|
||||
);
|
||||
});
|
||||
|
||||
test('screen rotation accepts deliberate camera-orbit movement', () => {
|
||||
const previous = 1;
|
||||
const next = previous + (2 * Math.PI / 180);
|
||||
const next = previous + (2 * Math.PI) / 180;
|
||||
assert.equal(stabilizeScreenRotation(previous, next), next);
|
||||
});
|
||||
|
||||
test('screen rotation compares across the wrapped angle boundary', () => {
|
||||
const previous = Math.PI - (0.1 * Math.PI / 180);
|
||||
const next = -Math.PI + (0.1 * Math.PI / 180);
|
||||
const previous = Math.PI - (0.1 * Math.PI) / 180;
|
||||
const next = -Math.PI + (0.1 * Math.PI) / 180;
|
||||
assert.equal(stabilizeScreenRotation(previous, next), previous);
|
||||
});
|
||||
|
||||
@@ -102,10 +109,12 @@ const cruiseCamera = Cesium.Cartesian3.fromDegrees(0, 0, CRUISE_M);
|
||||
* tangent condition is exact here and the expected answer is derived, not
|
||||
* guessed.
|
||||
*/
|
||||
const tangentLonDeg = Cesium.Math.toDegrees(Math.acos(
|
||||
Cesium.Ellipsoid.WGS84.maximumRadius
|
||||
/ (Cesium.Ellipsoid.WGS84.maximumRadius + CRUISE_M),
|
||||
));
|
||||
const tangentLonDeg = Cesium.Math.toDegrees(
|
||||
Math.acos(
|
||||
Cesium.Ellipsoid.WGS84.maximumRadius /
|
||||
(Cesium.Ellipsoid.WGS84.maximumRadius + CRUISE_M),
|
||||
),
|
||||
);
|
||||
|
||||
test('a contact above the silhouette is read against sky', () => {
|
||||
// Another aircraft at the same flight level 200 km ahead. The chord between
|
||||
@@ -124,7 +133,10 @@ test('a contact below the silhouette keeps ground behind it', () => {
|
||||
// the horizon, so the plate has real imagery to separate the text from.
|
||||
const below = Cesium.Cartesian3.fromDegrees(30_000 / 111_320, 0, 2_000);
|
||||
assert.equal(skyBackdropFactor(cruiseCamera, below), 0);
|
||||
assert.equal(skyBackdropFactor(cruiseCamera, Cesium.Cartesian3.fromDegrees(0.05, 0, 0)), 0);
|
||||
assert.equal(
|
||||
skyBackdropFactor(cruiseCamera, Cesium.Cartesian3.fromDegrees(0.05, 0, 0)),
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
test('the silhouette itself lands exactly mid-band', () => {
|
||||
@@ -144,10 +156,12 @@ test('the silhouette itself lands exactly mid-band', () => {
|
||||
function climbSweep(step = 500) {
|
||||
const samples = [];
|
||||
for (let height = 0; height <= 12_000; height += step) {
|
||||
samples.push(skyBackdropFactor(
|
||||
cruiseCamera,
|
||||
Cesium.Cartesian3.fromDegrees(100_000 / 111_320, 0, height),
|
||||
));
|
||||
samples.push(
|
||||
skyBackdropFactor(
|
||||
cruiseCamera,
|
||||
Cesium.Cartesian3.fromDegrees(100_000 / 111_320, 0, height),
|
||||
),
|
||||
);
|
||||
}
|
||||
return samples;
|
||||
}
|
||||
@@ -163,9 +177,16 @@ test('the band lerps monotonically and saturates on both sides', () => {
|
||||
);
|
||||
}
|
||||
assert.equal(samples[0], 0, 'well below the horizon is fully grounded');
|
||||
assert.equal(samples[samples.length - 1], 1, 'well above the horizon is fully sky');
|
||||
assert.equal(
|
||||
samples[samples.length - 1],
|
||||
1,
|
||||
'well above the horizon is fully sky',
|
||||
);
|
||||
const partial = samples.filter((value) => value > 0 && value < 1);
|
||||
assert.ok(partial.length >= 3, `expected a real blend band, saw ${partial.length} partial samples`);
|
||||
assert.ok(
|
||||
partial.length >= 3,
|
||||
`expected a real blend band, saw ${partial.length} partial samples`,
|
||||
);
|
||||
});
|
||||
|
||||
test('the band, not the geometry, is what smooths the crossing', () => {
|
||||
@@ -174,13 +195,19 @@ test('the band, not the geometry, is what smooths the crossing', () => {
|
||||
// if the two ever get conflated, only one of these two tests goes red.
|
||||
const hard = [];
|
||||
for (let height = 0; height <= 12_000; height += 500) {
|
||||
hard.push(skyBackdropFactor(
|
||||
cruiseCamera,
|
||||
Cesium.Cartesian3.fromDegrees(100_000 / 111_320, 0, height),
|
||||
0,
|
||||
));
|
||||
hard.push(
|
||||
skyBackdropFactor(
|
||||
cruiseCamera,
|
||||
Cesium.Cartesian3.fromDegrees(100_000 / 111_320, 0, height),
|
||||
0,
|
||||
),
|
||||
);
|
||||
}
|
||||
assert.deepEqual([...new Set(hard)], [0, 1], 'a zero band must produce only the two end states');
|
||||
assert.deepEqual(
|
||||
[...new Set(hard)],
|
||||
[0, 1],
|
||||
'a zero band must produce only the two end states',
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -200,12 +227,19 @@ test('below the ellipsoid, a contact against open sky still reads as sky', () =>
|
||||
// ~6.5° above eye level — the whole band is 1.09° per side, so this is not a
|
||||
// near call. Before the fix a sub-ellipsoid camera fell into the degenerate
|
||||
// guard and every one of these came back 0: a row of dark boxes on empty sky.
|
||||
const approach = Cesium.Cartesian3.fromDegrees(JFK_LON, JFK_LAT + 8_000 * DEG_PER_M_LAT, 900);
|
||||
const approach = Cesium.Cartesian3.fromDegrees(
|
||||
JFK_LON,
|
||||
JFK_LAT + 8_000 * DEG_PER_M_LAT,
|
||||
900,
|
||||
);
|
||||
assert.equal(skyBackdropFactor(jfkCockpit, approach), 1);
|
||||
// Straight overhead is the unambiguous case, from under the surface as much
|
||||
// as from cruise altitude.
|
||||
assert.equal(
|
||||
skyBackdropFactor(jfkCockpit, Cesium.Cartesian3.fromDegrees(JFK_LON, JFK_LAT, 10_000)),
|
||||
skyBackdropFactor(
|
||||
jfkCockpit,
|
||||
Cesium.Cartesian3.fromDegrees(JFK_LON, JFK_LAT, 10_000),
|
||||
),
|
||||
1,
|
||||
);
|
||||
});
|
||||
@@ -225,7 +259,11 @@ test('below the ellipsoid, a contact on the ramp still keeps its plate', () => {
|
||||
assert.equal(
|
||||
skyBackdropFactor(
|
||||
jfkCockpit,
|
||||
Cesium.Cartesian3.fromDegrees(JFK_LON, JFK_LAT + 400 * DEG_PER_M_LAT, JFK_RAMP_H - 5),
|
||||
Cesium.Cartesian3.fromDegrees(
|
||||
JFK_LON,
|
||||
JFK_LAT + 400 * DEG_PER_M_LAT,
|
||||
JFK_RAMP_H - 5,
|
||||
),
|
||||
),
|
||||
0,
|
||||
);
|
||||
@@ -246,14 +284,21 @@ test('at the surface the horizon is exactly eye level, geodetic not geocentric',
|
||||
new Cesium.Cartesian3(20_000, 0, 0),
|
||||
new Cesium.Cartesian3(),
|
||||
);
|
||||
const alongHorizontal = Cesium.Cartesian3.add(onSurface, east, new Cesium.Cartesian3());
|
||||
const alongHorizontal = Cesium.Cartesian3.add(
|
||||
onSurface,
|
||||
east,
|
||||
new Cesium.Cartesian3(),
|
||||
);
|
||||
assert.ok(
|
||||
Math.abs(skyBackdropFactor(onSurface, alongHorizontal) - 0.5) < 1e-5,
|
||||
`eye level must be the band centre, got ${skyBackdropFactor(onSurface, alongHorizontal)}`,
|
||||
);
|
||||
// The geocentric radial is NOT the answer: a contact perpendicular to it
|
||||
// lands measurably off centre, so this test cannot pass by accident.
|
||||
const radialUp = Cesium.Cartesian3.normalize(onSurface, new Cesium.Cartesian3());
|
||||
const radialUp = Cesium.Cartesian3.normalize(
|
||||
onSurface,
|
||||
new Cesium.Cartesian3(),
|
||||
);
|
||||
const perpToRadial = Cesium.Cartesian3.cross(
|
||||
radialUp,
|
||||
Cesium.Cartesian3.UNIT_Z,
|
||||
@@ -265,7 +310,11 @@ test('at the surface the horizon is exactly eye level, geodetic not geocentric',
|
||||
20_000,
|
||||
perpToRadial,
|
||||
);
|
||||
const alongRadialPlane = Cesium.Cartesian3.add(onSurface, perpToRadial, new Cesium.Cartesian3());
|
||||
const alongRadialPlane = Cesium.Cartesian3.add(
|
||||
onSurface,
|
||||
perpToRadial,
|
||||
new Cesium.Cartesian3(),
|
||||
);
|
||||
assert.ok(
|
||||
Math.abs(skyBackdropFactor(onSurface, alongRadialPlane) - 0.5) > 0.05,
|
||||
'a geocentric horizontal would be a different plane; the test must be able to tell',
|
||||
@@ -284,7 +333,9 @@ function surfaceCrossingSweep(from = -50, to = 50, step = 1) {
|
||||
const contact = Cesium.Cartesian3.fromDegrees(30_000 / 111_319.49, 0, 0);
|
||||
const samples = [];
|
||||
for (let height = from; height <= to; height += step) {
|
||||
samples.push(skyBackdropFactor(Cesium.Cartesian3.fromDegrees(0, 0, height), contact));
|
||||
samples.push(
|
||||
skyBackdropFactor(Cesium.Cartesian3.fromDegrees(0, 0, height), contact),
|
||||
);
|
||||
}
|
||||
return samples;
|
||||
}
|
||||
@@ -300,7 +351,10 @@ test('the answer stays continuous as the camera rises through the surface', () =
|
||||
for (let i = 1; i < samples.length; i++) {
|
||||
worst = Math.max(worst, Math.abs(samples[i] - samples[i - 1]));
|
||||
}
|
||||
assert.ok(worst < 0.05, `no step across the surface may pop; worst adjacent jump ${worst}`);
|
||||
assert.ok(
|
||||
worst < 0.05,
|
||||
`no step across the surface may pop; worst adjacent jump ${worst}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('the crossover has no seam at the surface itself', () => {
|
||||
@@ -308,9 +362,18 @@ test('the crossover has no seam at the surface itself', () => {
|
||||
// 1 cm is 5.6e-5 rad, a thousandth of the band, so any visible difference
|
||||
// here is a discontinuity in the code rather than in the geometry.
|
||||
const contact = Cesium.Cartesian3.fromDegrees(30_000 / 111_319.49, 0, 0);
|
||||
const under = skyBackdropFactor(Cesium.Cartesian3.fromDegrees(0, 0, -0.01), contact);
|
||||
const over = skyBackdropFactor(Cesium.Cartesian3.fromDegrees(0, 0, 0.01), contact);
|
||||
assert.ok(Math.abs(under - over) < 5e-3, `seam at the surface: ${under} vs ${over}`);
|
||||
const under = skyBackdropFactor(
|
||||
Cesium.Cartesian3.fromDegrees(0, 0, -0.01),
|
||||
contact,
|
||||
);
|
||||
const over = skyBackdropFactor(
|
||||
Cesium.Cartesian3.fromDegrees(0, 0, 0.01),
|
||||
contact,
|
||||
);
|
||||
assert.ok(
|
||||
Math.abs(under - over) < 5e-3,
|
||||
`seam at the surface: ${under} vs ${over}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('a sub-surface camera carries real information, not one frozen answer', () => {
|
||||
@@ -319,7 +382,10 @@ test('a sub-surface camera carries real information, not one frozen answer', ()
|
||||
// sub-surface camera heights must produce different answers.
|
||||
const belowOnly = surfaceCrossingSweep(-200, -1, 1);
|
||||
const spread = Math.max(...belowOnly) - Math.min(...belowOnly);
|
||||
assert.ok(spread > 0.05, `a sub-surface camera must still discriminate; spread ${spread}`);
|
||||
assert.ok(
|
||||
spread > 0.05,
|
||||
`a sub-surface camera must still discriminate; spread ${spread}`,
|
||||
);
|
||||
assert.ok(
|
||||
belowOnly.every((value) => value > 0 && value < 1),
|
||||
'this sweep is deliberately mid-band the whole way',
|
||||
@@ -334,7 +400,11 @@ test('a sub-surface camera carries real information, not one frozen answer', ()
|
||||
* a future edit to the shared lines could drift the cruise answer while every
|
||||
* sub-surface test stayed green.
|
||||
*/
|
||||
function legacyAboveSurfaceFactor(cameraPosition, position, featherRad = HORIZON_FEATHER_RAD) {
|
||||
function legacyAboveSurfaceFactor(
|
||||
cameraPosition,
|
||||
position,
|
||||
featherRad = HORIZON_FEATHER_RAD,
|
||||
) {
|
||||
const s = Cesium.Ellipsoid.WGS84.oneOverRadii;
|
||||
const cx = cameraPosition.x * s.x;
|
||||
const cy = cameraPosition.y * s.y;
|
||||
@@ -415,22 +485,39 @@ test('below the surface the backdrop test agrees with the occluder beside it', (
|
||||
const camera = Cesium.Cartesian3.fromDegrees(JFK_LON, JFK_LAT, -18);
|
||||
const occluder = horizonOccluder({ positionWC: camera });
|
||||
const enu = Cesium.Transforms.eastNorthUpToFixedFrame(camera);
|
||||
const offsetBy = (up) => Cesium.Cartesian3.add(
|
||||
camera,
|
||||
Cesium.Matrix4.multiplyByPointAsVector(
|
||||
enu,
|
||||
new Cesium.Cartesian3(0, 20_000, up),
|
||||
const offsetBy = (up) =>
|
||||
Cesium.Cartesian3.add(
|
||||
camera,
|
||||
Cesium.Matrix4.multiplyByPointAsVector(
|
||||
enu,
|
||||
new Cesium.Cartesian3(0, 20_000, up),
|
||||
new Cesium.Cartesian3(),
|
||||
),
|
||||
new Cesium.Cartesian3(),
|
||||
),
|
||||
new Cesium.Cartesian3(),
|
||||
);
|
||||
);
|
||||
// 20 km out and 600 m clear of eye level is well outside the 1.09° band.
|
||||
const aboveEye = offsetBy(600);
|
||||
const belowEye = offsetBy(-600);
|
||||
assert.equal(occluder.isPointVisible(aboveEye), true, 'the occluder keeps what is above eye level');
|
||||
assert.equal(skyBackdropFactor(camera, aboveEye), 1, 'and the backdrop test must call it sky');
|
||||
assert.equal(occluder.isPointVisible(belowEye), false, 'the occluder culls what is below it');
|
||||
assert.equal(skyBackdropFactor(camera, belowEye), 0, 'and the backdrop test must call it ground');
|
||||
assert.equal(
|
||||
occluder.isPointVisible(aboveEye),
|
||||
true,
|
||||
'the occluder keeps what is above eye level',
|
||||
);
|
||||
assert.equal(
|
||||
skyBackdropFactor(camera, aboveEye),
|
||||
1,
|
||||
'and the backdrop test must call it sky',
|
||||
);
|
||||
assert.equal(
|
||||
occluder.isPointVisible(belowEye),
|
||||
false,
|
||||
'the occluder culls what is below it',
|
||||
);
|
||||
assert.equal(
|
||||
skyBackdropFactor(camera, belowEye),
|
||||
0,
|
||||
'and the backdrop test must call it ground',
|
||||
);
|
||||
});
|
||||
|
||||
test('a degenerate camera or contact reports ground rather than throwing', () => {
|
||||
@@ -442,12 +529,24 @@ test('a degenerate camera or contact reports ground rather than throwing', () =>
|
||||
assert.equal(skyBackdropFactor(cruiseCamera, cruiseCamera), 0);
|
||||
// The planet's centre has no local vertical to measure against.
|
||||
assert.equal(skyBackdropFactor(Cesium.Cartesian3.ZERO, cruiseCamera), 0);
|
||||
assert.equal(skyBackdropFactor(new Cesium.Cartesian3(NaN, NaN, NaN), cruiseCamera), 0);
|
||||
assert.equal(skyBackdropFactor(cruiseCamera, new Cesium.Cartesian3(NaN, NaN, NaN)), 0);
|
||||
assert.equal(
|
||||
skyBackdropFactor(new Cesium.Cartesian3(NaN, NaN, NaN), cruiseCamera),
|
||||
0,
|
||||
);
|
||||
assert.equal(
|
||||
skyBackdropFactor(cruiseCamera, new Cesium.Cartesian3(NaN, NaN, NaN)),
|
||||
0,
|
||||
);
|
||||
// An infinite coordinate normalizes to NaN a few lines later, and a NaN plate
|
||||
// alpha reaches the canvas as an invisible label rather than a caught error.
|
||||
assert.equal(skyBackdropFactor(new Cesium.Cartesian3(Infinity, 0, 0), cruiseCamera), 0);
|
||||
assert.equal(skyBackdropFactor(cruiseCamera, new Cesium.Cartesian3(Infinity, 0, 0)), 0);
|
||||
assert.equal(
|
||||
skyBackdropFactor(new Cesium.Cartesian3(Infinity, 0, 0), cruiseCamera),
|
||||
0,
|
||||
);
|
||||
assert.equal(
|
||||
skyBackdropFactor(cruiseCamera, new Cesium.Cartesian3(Infinity, 0, 0)),
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
test('the feather band is a named constant in a sane screen-space range', () => {
|
||||
@@ -456,3 +555,193 @@ test('the feather band is a named constant in a sane screen-space range', () =>
|
||||
// band of arc-seconds would pop.
|
||||
assert.ok(HORIZON_FEATHER_RAD > 0.002 && HORIZON_FEATHER_RAD < 0.09);
|
||||
});
|
||||
|
||||
// --- Perspective projection through a real view-projection --------------------
|
||||
|
||||
import {
|
||||
PERSPECTIVE_BLEND_MAX_PX,
|
||||
PERSPECTIVE_BLEND_MIN_PX,
|
||||
PERSPECTIVE_PROBE_M,
|
||||
perspectiveProjectedRotation,
|
||||
} from './iconOrientation.js';
|
||||
|
||||
/**
|
||||
* A scene the exact projector accepts: 3D mode, a canvas, and a camera with a
|
||||
* real view matrix and perspective frustum. The camera sits `altitudeM` above
|
||||
* the origin and looks north, pitched down by `pitchDeg`, with world east as
|
||||
* screen right — the oblique street-level pose where the camera-basis answer
|
||||
* is wrong off-centre and the exact one is not.
|
||||
*/
|
||||
function obliqueScene(
|
||||
altitudeM,
|
||||
pitchDeg,
|
||||
{ width = 1280, height = 800 } = {},
|
||||
) {
|
||||
const position = Cesium.Cartesian3.fromDegrees(0, 0, altitudeM);
|
||||
const enu = Cesium.Transforms.eastNorthUpToFixedFrame(position);
|
||||
const east = Cesium.Matrix4.getColumn(enu, 0, new Cesium.Cartesian3());
|
||||
const north = Cesium.Matrix4.getColumn(enu, 1, new Cesium.Cartesian3());
|
||||
const up = Cesium.Matrix4.getColumn(enu, 2, new Cesium.Cartesian3());
|
||||
const pitch = Cesium.Math.toRadians(pitchDeg);
|
||||
const direction = Cesium.Cartesian3.normalize(
|
||||
Cesium.Cartesian3.add(
|
||||
Cesium.Cartesian3.multiplyByScalar(
|
||||
north,
|
||||
Math.cos(pitch),
|
||||
new Cesium.Cartesian3(),
|
||||
),
|
||||
Cesium.Cartesian3.multiplyByScalar(
|
||||
up,
|
||||
-Math.sin(pitch),
|
||||
new Cesium.Cartesian3(),
|
||||
),
|
||||
new Cesium.Cartesian3(),
|
||||
),
|
||||
new Cesium.Cartesian3(),
|
||||
);
|
||||
const cameraUp = Cesium.Cartesian3.normalize(
|
||||
Cesium.Cartesian3.add(
|
||||
Cesium.Cartesian3.multiplyByScalar(
|
||||
north,
|
||||
Math.sin(pitch),
|
||||
new Cesium.Cartesian3(),
|
||||
),
|
||||
Cesium.Cartesian3.multiplyByScalar(
|
||||
up,
|
||||
Math.cos(pitch),
|
||||
new Cesium.Cartesian3(),
|
||||
),
|
||||
new Cesium.Cartesian3(),
|
||||
),
|
||||
new Cesium.Cartesian3(),
|
||||
);
|
||||
const viewMatrix = Cesium.Matrix4.computeView(
|
||||
position,
|
||||
direction,
|
||||
cameraUp,
|
||||
east,
|
||||
new Cesium.Matrix4(),
|
||||
);
|
||||
const frustum = new Cesium.PerspectiveFrustum({
|
||||
fov: Cesium.Math.toRadians(60),
|
||||
aspectRatio: width / height,
|
||||
near: 1,
|
||||
far: 1e7,
|
||||
});
|
||||
return {
|
||||
frameState: { mode: Cesium.SceneMode.SCENE3D },
|
||||
canvas: { clientWidth: width, clientHeight: height },
|
||||
camera: {
|
||||
viewMatrix,
|
||||
frustum,
|
||||
rightWC: east,
|
||||
upWC: cameraUp,
|
||||
positionWC: position,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Probe separation (px) the helper sees for a contact and course in a scene. */
|
||||
function probeSeparation(scene, contact, courseDeg) {
|
||||
const courseRad = Cesium.Math.toRadians(courseDeg);
|
||||
const enu = Cesium.Transforms.eastNorthUpToFixedFrame(contact);
|
||||
const forward = Cesium.Matrix4.multiplyByPointAsVector(
|
||||
enu,
|
||||
new Cesium.Cartesian3(
|
||||
Math.sin(courseRad) * PERSPECTIVE_PROBE_M,
|
||||
Math.cos(courseRad) * PERSPECTIVE_PROBE_M,
|
||||
0,
|
||||
),
|
||||
new Cesium.Cartesian3(),
|
||||
);
|
||||
const probe = Cesium.Cartesian3.add(
|
||||
contact,
|
||||
forward,
|
||||
new Cesium.Cartesian3(),
|
||||
);
|
||||
const at = Cesium.SceneTransforms.worldToWindowCoordinates(scene, contact);
|
||||
const ahead = Cesium.SceneTransforms.worldToWindowCoordinates(scene, probe);
|
||||
const dx = ahead.x - at.x;
|
||||
const dy = ahead.y - at.y;
|
||||
return { separation: Math.hypot(dx, dy), exact: Math.atan2(-dx, -dy) };
|
||||
}
|
||||
|
||||
test('the perspective helper is continuous through the sub-pixel band, not a 23° flip', () => {
|
||||
// Codex's reproduction: a contact near the right edge of an oblique view,
|
||||
// the camera altitude walked so the probe separation crosses half a pixel.
|
||||
// The rotation jumped 23° → 0° between two adjacent altitudes. The exact
|
||||
// and camera-basis answers now blend across a band, the short way round.
|
||||
//
|
||||
// The contact keeps the SAME screen position across the walk — its ground
|
||||
// offsets scale with the altitude — so the two answers disagree by the
|
||||
// same twenty-odd degrees at every altitude while the separation alone
|
||||
// walks from well above the band to well below it.
|
||||
const course = 0; // northbound
|
||||
const contactFor = (altitude) =>
|
||||
Cesium.Cartesian3.fromDegrees(
|
||||
(0.7 * altitude) / 111_320,
|
||||
(1.2 * altitude) / 111_320,
|
||||
0,
|
||||
);
|
||||
const samples = [];
|
||||
// Geometric steps: separation scales with altitude, so equal ratios give
|
||||
// equal-sized steps in the quantity the band is defined on.
|
||||
for (let altitude = 500; altitude <= 600_000; altitude *= 1.02) {
|
||||
const scene = obliqueScene(altitude, 45);
|
||||
const contact = contactFor(altitude);
|
||||
if (!Cesium.SceneTransforms.worldToWindowCoordinates(scene, contact))
|
||||
continue;
|
||||
const { separation, exact } = probeSeparation(scene, contact, course);
|
||||
samples.push({
|
||||
altitude,
|
||||
separation,
|
||||
exact,
|
||||
rotation: perspectiveProjectedRotation(scene, contact, course, null),
|
||||
basis: screenProjectedRotation(scene, contact, course, null),
|
||||
});
|
||||
}
|
||||
const inBand = samples.filter(
|
||||
(s) =>
|
||||
s.separation > PERSPECTIVE_BLEND_MIN_PX &&
|
||||
s.separation < PERSPECTIVE_BLEND_MAX_PX,
|
||||
);
|
||||
const above = samples.filter((s) => s.separation >= PERSPECTIVE_BLEND_MAX_PX);
|
||||
const below = samples.filter((s) => s.separation <= PERSPECTIVE_BLEND_MIN_PX);
|
||||
assert.ok(
|
||||
inBand.length >= 5 && above.length >= 5 && below.length >= 5,
|
||||
`the walk crosses the whole band (${below.length}/${inBand.length}/${above.length})`,
|
||||
);
|
||||
|
||||
// Off-centre and oblique, the two answers genuinely disagree — that is the
|
||||
// divergence the exact projection exists to fix.
|
||||
const disagreement = Math.abs(
|
||||
wrappedDelta(above[0].rotation, above[0].basis),
|
||||
);
|
||||
assert.ok(
|
||||
disagreement > Cesium.Math.toRadians(5),
|
||||
`exact and camera-basis differ by ${Cesium.Math.toDegrees(disagreement).toFixed(1)}° above the band`,
|
||||
);
|
||||
|
||||
let worstStep = 0;
|
||||
for (let i = 1; i < samples.length; i += 1) {
|
||||
worstStep = Math.max(
|
||||
worstStep,
|
||||
Math.abs(wrappedDelta(samples[i].rotation, samples[i - 1].rotation)),
|
||||
);
|
||||
}
|
||||
assert.ok(
|
||||
worstStep < Cesium.Math.toRadians(3),
|
||||
`no step between neighbouring altitudes exceeds 3° (worst ${Cesium.Math.toDegrees(worstStep).toFixed(2)}°)`,
|
||||
);
|
||||
// Below the band it is the camera-basis answer; above it the exact one, unblended.
|
||||
for (const s of below)
|
||||
assert.ok(
|
||||
Math.abs(wrappedDelta(s.rotation, s.basis)) < 1e-6,
|
||||
`basis alone at ${s.separation.toFixed(2)} px`,
|
||||
);
|
||||
for (const s of above)
|
||||
assert.ok(
|
||||
Math.abs(wrappedDelta(s.rotation, s.exact)) < 1e-6,
|
||||
`exact alone at ${s.separation.toFixed(2)} px`,
|
||||
);
|
||||
});
|
||||
@@ -293,6 +293,7 @@ export const LAYER_STATE_REGISTRY = Object.freeze([
|
||||
Object.freeze({ id: 'satellites', token: 's', disposition: 'enabled+options', optionOwner: 'satellites' }),
|
||||
Object.freeze({ id: 'telegeography-submarine-cables', token: 'u', disposition: 'enabled-only' }),
|
||||
Object.freeze({ id: 'traffic', token: 't', disposition: 'enabled-only' }),
|
||||
Object.freeze({ id: 'transit', token: 'j', disposition: 'enabled-only' }),
|
||||
]);
|
||||
|
||||
export const REGISTERED_LAYER_IDS = Object.freeze(LAYER_STATE_REGISTRY.map((entry) => entry.id));
|
||||
|
||||
@@ -158,8 +158,8 @@ function encode(state) {
|
||||
|
||||
test('production registry is exact, canonical, and rejects incomplete contracts', async () => {
|
||||
assert.equal(validateLayerStateRegistry(), true);
|
||||
assert.equal(REGISTERED_LAYER_IDS.length, 18);
|
||||
assert.equal(new Set(REGISTERED_LAYER_IDS).size, 18);
|
||||
assert.equal(REGISTERED_LAYER_IDS.length, 19);
|
||||
assert.equal(new Set(REGISTERED_LAYER_IDS).size, 19);
|
||||
assert.deepEqual(REGISTERED_LAYER_IDS, [...REGISTERED_LAYER_IDS].sort());
|
||||
assert.throws(
|
||||
() => validateLayerStateRegistry([...LAYER_STATE_REGISTRY, LAYER_STATE_REGISTRY[0]]),
|
||||
|
||||
@@ -3,6 +3,8 @@ export const SPRITE_LAYER_ORDER = Object.freeze([
|
||||
'cctv',
|
||||
'firms',
|
||||
'bikeshare',
|
||||
'transit',
|
||||
'transit-motion',
|
||||
'directions',
|
||||
'ais',
|
||||
'military',
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { createTransitLayer } from '../layers/transit/index.js';
|
||||
import * as render from '../renderGovernor.js';
|
||||
import * as sprites from './spriteOrder.js';
|
||||
import * as picking from './pickRegistry.js';
|
||||
import * as overlays from '../overlays/worldOverlay.js';
|
||||
import { registerDynamicCredit, transitFeedCredit } from './dataCredits.js';
|
||||
import {
|
||||
GROUND_FLOOR_LIFT_M,
|
||||
cachedGroundFloor,
|
||||
coarseFloorCoord,
|
||||
neighborFloorM,
|
||||
warmGroundFloor,
|
||||
} from './groundFloor.js';
|
||||
import { sampleMeshFloorCells } from './meshFloorSampler.js';
|
||||
|
||||
const layer = createTransitLayer({
|
||||
services: {
|
||||
render,
|
||||
sprites,
|
||||
picking,
|
||||
overlays,
|
||||
credits: { registerDynamicCredit, transitFeedCredit },
|
||||
ground: {
|
||||
GROUND_FLOOR_LIFT_M,
|
||||
cachedGroundFloor,
|
||||
coarseFloorCoord,
|
||||
neighborFloorM,
|
||||
warmGroundFloor,
|
||||
},
|
||||
// The legacy sampler over the same legacy floor, so this standalone
|
||||
// module warms its own mesh floors the way the application layer does.
|
||||
mesh: { sampleMeshFloorCells },
|
||||
},
|
||||
});
|
||||
|
||||
export const _setTransitOverlayHostForTest =
|
||||
layer._setTransitOverlayHostForTest;
|
||||
export const _transitStateForTest = layer._transitStateForTest;
|
||||
export {
|
||||
TRANSIT_MODE_COLORS,
|
||||
TRANSIT_POLL_MS,
|
||||
TRANSIT_SELECTED_OVERLAY_SOURCE_ID,
|
||||
TRANSIT_SELECTED_OVERLAY_SOURCE_OPTIONS,
|
||||
aggregateTransitFeedHealth,
|
||||
buildTransitSelectionCopy,
|
||||
createTransitSelectedOverlayEntry,
|
||||
interpolatedVehiclePosition,
|
||||
isStaleVehicleFix,
|
||||
transitDetectionClass,
|
||||
transitDetectionId,
|
||||
transitDetectionMetric,
|
||||
transitVehicleKey,
|
||||
vehicleFixAgeMs,
|
||||
createTransitLayer,
|
||||
} from '../layers/transit/index.js';
|
||||
export default layer;
|
||||
@@ -0,0 +1,225 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import transitLayer, {
|
||||
TRANSIT_MODE_COLORS,
|
||||
TRANSIT_POLL_MS,
|
||||
TRANSIT_SELECTED_OVERLAY_SOURCE_OPTIONS,
|
||||
buildTransitSelectionCopy,
|
||||
createTransitSelectedOverlayEntry,
|
||||
interpolatedVehiclePosition,
|
||||
isStaleVehicleFix,
|
||||
transitVehicleKey,
|
||||
} from './transit.js';
|
||||
import { TRANSIT_MODES, getTransitFeed } from './transitFeeds.js';
|
||||
import { LAYER_STATE_REGISTRY } from './layerState.js';
|
||||
import { SPRITE_LAYER_ORDER } from './spriteOrder.js';
|
||||
|
||||
test('layer module declares the manager contract', () => {
|
||||
assert.equal(transitLayer.id, 'transit');
|
||||
assert.equal(typeof transitLayer.name, 'string');
|
||||
assert.equal(typeof transitLayer.icon, 'string');
|
||||
assert.equal(transitLayer.updateInterval, TRANSIT_POLL_MS);
|
||||
for (const method of [
|
||||
'init',
|
||||
'enable',
|
||||
'disable',
|
||||
'update',
|
||||
'getStats',
|
||||
'destroy',
|
||||
]) {
|
||||
assert.equal(
|
||||
typeof transitLayer[method],
|
||||
'function',
|
||||
`${method} is implemented`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('the layer is registered for share links and sprite stacking', () => {
|
||||
const entry = LAYER_STATE_REGISTRY.find((row) => row.id === 'transit');
|
||||
assert.ok(entry, 'transit has a share-link token');
|
||||
assert.equal(entry.disposition, 'enabled-only');
|
||||
assert.equal(
|
||||
LAYER_STATE_REGISTRY.filter((row) => row.token === entry.token).length,
|
||||
1,
|
||||
'token is unique',
|
||||
);
|
||||
const index = SPRITE_LAYER_ORDER.indexOf('transit');
|
||||
assert.ok(
|
||||
index > SPRITE_LAYER_ORDER.indexOf('bikeshare'),
|
||||
'vehicles draw above bikeshare stations',
|
||||
);
|
||||
assert.ok(
|
||||
index < SPRITE_LAYER_ORDER.indexOf('flights'),
|
||||
'aircraft stay on top',
|
||||
);
|
||||
});
|
||||
|
||||
test('every transit mode has a colour and the selected card uses it as accent', () => {
|
||||
for (const mode of TRANSIT_MODES) {
|
||||
assert.match(
|
||||
TRANSIT_MODE_COLORS[mode],
|
||||
/^#[0-9a-f]{6}$/i,
|
||||
`${mode} has a colour`,
|
||||
);
|
||||
}
|
||||
const position = Cesium.Cartesian3.fromDegrees(-71.06, 42.36, 3);
|
||||
const card = createTransitSelectedOverlayEntry(
|
||||
'mbta:1',
|
||||
position,
|
||||
{ title: 'T', details: ['d'] },
|
||||
'subway',
|
||||
);
|
||||
assert.equal(card.accent, TRANSIT_MODE_COLORS.subway);
|
||||
assert.equal(card.selected, true);
|
||||
assert.equal(card.protected, true);
|
||||
assert.equal(card.position, position);
|
||||
assert.equal(
|
||||
createTransitSelectedOverlayEntry(
|
||||
'',
|
||||
position,
|
||||
{ title: 'T', details: [] },
|
||||
'bus',
|
||||
),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
createTransitSelectedOverlayEntry(
|
||||
'k',
|
||||
null,
|
||||
{ title: 'T', details: [] },
|
||||
'bus',
|
||||
),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
TRANSIT_SELECTED_OVERLAY_SOURCE_OPTIONS.moving,
|
||||
true,
|
||||
'the card follows a moving vehicle',
|
||||
);
|
||||
});
|
||||
|
||||
test('position queries read the published display sample', () => {
|
||||
const entry = { track: {}, sample: { lat: 1, lon: 2, phase: 'playing' } };
|
||||
assert.deepEqual(interpolatedVehiclePosition(entry), {
|
||||
lat: 1,
|
||||
lon: 2,
|
||||
settled: false,
|
||||
});
|
||||
entry.sample.phase = 'held';
|
||||
assert.equal(interpolatedVehiclePosition(entry).settled, true);
|
||||
});
|
||||
test('fixes older than ten minutes are stale; feeds without timestamps are trusted', () => {
|
||||
const now = 1_700_000_000_000;
|
||||
assert.equal(isStaleVehicleFix({ timestamp: now / 1000 - 30 }, now), false);
|
||||
assert.equal(isStaleVehicleFix({ timestamp: now / 1000 - 601 }, now), true);
|
||||
assert.equal(isStaleVehicleFix({ timestamp: null }, now), false);
|
||||
assert.equal(isStaleVehicleFix({}, now), false);
|
||||
});
|
||||
|
||||
test('selection copy reads like a transit card and never leaks nulls', () => {
|
||||
const feed = getTransitFeed('metrotransit-msp');
|
||||
const now = 1_788_936_960_000;
|
||||
const full = buildTransitSelectionCopy(
|
||||
feed,
|
||||
{
|
||||
id: '1557',
|
||||
label: '1557',
|
||||
routeId: '17',
|
||||
lat: 44.9,
|
||||
lon: -93.4,
|
||||
bearing: 248,
|
||||
speedMps: 11.2,
|
||||
timestamp: 1_788_936_945,
|
||||
stopId: '57458',
|
||||
status: 'STOPPED_AT',
|
||||
occupancy: 'FEW_SEATS_AVAILABLE',
|
||||
timestampSource: 'vehicle',
|
||||
},
|
||||
'bus',
|
||||
now,
|
||||
);
|
||||
assert.equal(full.title, '🚌 Route 17');
|
||||
assert.deepEqual(full.details, [
|
||||
// The mode is said in words: a glyph and a route number do not tell a
|
||||
// reader whether the dot on the street is a bus or a subway train.
|
||||
'Bus · Metro Transit · Minneapolis–St Paul, MN',
|
||||
'Reported 15 s ago',
|
||||
// The operator's speed is labelled as a report: it describes the newest
|
||||
// fix, not the older segment the screen is drawing.
|
||||
'reported 40 km/h · reported hdg 248°',
|
||||
// The operator's claim is kept as a REPORT, not as the live state. It
|
||||
// describes the moment of the report, which the display may not have
|
||||
// reached yet — presenting it as "now" is what put STOPPED on a metro
|
||||
// gliding across the owner's screen.
|
||||
'Last report: stopped at stop 57458 · few seats available',
|
||||
'Vehicle 1557',
|
||||
]);
|
||||
// A time the feed gave for the SNAPSHOT rather than for this vehicle is
|
||||
// labelled, so the card never presents a borrowed time as the bus's own.
|
||||
const borrowed = buildTransitSelectionCopy(
|
||||
feed,
|
||||
{
|
||||
id: '9',
|
||||
lat: 1,
|
||||
lon: 1,
|
||||
timestamp: 1_788_936_900,
|
||||
timestampSource: 'header',
|
||||
},
|
||||
'bus',
|
||||
now,
|
||||
);
|
||||
assert.equal(borrowed.details[1], 'Reported 60 s ago (feed time)');
|
||||
|
||||
// A subway says where it is drawn, because a train on a road is otherwise
|
||||
// read as a bug rather than as a deliberate surface projection.
|
||||
const underground = buildTransitSelectionCopy(
|
||||
feed,
|
||||
{
|
||||
id: 'T1',
|
||||
routeId: 'Blue',
|
||||
lat: 44.9,
|
||||
lon: -93.4,
|
||||
timestamp: 1_788_936_945,
|
||||
},
|
||||
'subway',
|
||||
now,
|
||||
);
|
||||
assert.deepEqual(underground.details, [
|
||||
'Subway · Metro Transit · Minneapolis–St Paul, MN',
|
||||
'Reported 15 s ago',
|
||||
'Shown at street level · depth not in the feed',
|
||||
'Vehicle T1',
|
||||
]);
|
||||
// No timestamp at all is aged from the fetch, never from "now".
|
||||
const sparse = buildTransitSelectionCopy(
|
||||
feed,
|
||||
{ id: 'abc', lat: 1, lon: 1 },
|
||||
'rail',
|
||||
now,
|
||||
now - 120_000,
|
||||
);
|
||||
assert.equal(sparse.title, '🚆 Vehicle abc');
|
||||
assert.deepEqual(sparse.details, [
|
||||
'Train · Metro Transit · Minneapolis–St Paul, MN',
|
||||
'Reported 2 min ago (feed time)',
|
||||
'Vehicle abc',
|
||||
]);
|
||||
for (const line of [...full.details, ...sparse.details])
|
||||
assert.doesNotMatch(line, /null|undefined|NaN/);
|
||||
assert.equal(transitVehicleKey('mbta', '17'), 'mbta:17');
|
||||
});
|
||||
|
||||
test('the layer accepts a manager handle for out-of-tick panel repaints', () => {
|
||||
assert.equal(typeof transitLayer.attachDataManager, 'function');
|
||||
transitLayer.attachDataManager({ refreshLayerStats() {} });
|
||||
transitLayer.attachDataManager(null);
|
||||
});
|
||||
|
||||
test('stats before enable are an honest zero, not a fake feed state', () => {
|
||||
const stats = transitLayer.getStats();
|
||||
assert.equal(stats.count, 0);
|
||||
assert.equal(stats.source, 'GTFS-RT');
|
||||
assert.equal(stats.error, null);
|
||||
});
|
||||
@@ -0,0 +1,404 @@
|
||||
/**
|
||||
* @module transitFeeds
|
||||
* @description Registry of keyless, openly licensed GTFS-Realtime
|
||||
* VehiclePositions feeds the Transit layer can show.
|
||||
*
|
||||
* Every entry here is a URL the SERVER fetches — the browser only ever asks
|
||||
* `/api/transit/vehicles/<id>` for a registered id (see SECURITY.md: proxies
|
||||
* never fetch client-supplied URLs). Adding a feed means adding a row here,
|
||||
* a DATA_SOURCES.md row with its license, and a credit in dataCredits.js.
|
||||
*
|
||||
* Admission rules for a feed:
|
||||
* - No key, token, or registration required (identify-yourself headers are
|
||||
* fine — Entur asks for `ET-Client-Name`, OVapi for a User-Agent).
|
||||
* - An open license that permits display with attribution.
|
||||
* - Real coordinates in VehiclePosition.position — NYCT subway, for example,
|
||||
* publishes stop-relative positions only and is deliberately absent.
|
||||
*
|
||||
* `defaultEnabled` is separate from being registered. A feed ships switched ON
|
||||
* only when its operator's own published terms were READ and plainly cover this
|
||||
* use. A registered feed with `defaultEnabled: false` is never polled and never
|
||||
* offered to the browser; it is here so the decision is visible and reversible
|
||||
* in one line. `terms` records the passage that decision rests on, so the next
|
||||
* reader does not have to take the licence string on faith.
|
||||
*
|
||||
* Pure data + pure helpers: imported by the browser layer, the Vite proxy, and
|
||||
* node:test. No Cesium, no Node built-ins.
|
||||
*/
|
||||
|
||||
/** Transit modes the layer colors. `routeMode` hints refine a feed's default. */
|
||||
export const TRANSIT_MODES = Object.freeze([
|
||||
'bus',
|
||||
'tram',
|
||||
'subway',
|
||||
'rail',
|
||||
'ferry',
|
||||
'unknown',
|
||||
]);
|
||||
|
||||
/** Per-mode icon used in labels and the selection card. */
|
||||
export const TRANSIT_MODE_ICON = Object.freeze({
|
||||
bus: '🚌',
|
||||
tram: '🚊',
|
||||
subway: '🚇',
|
||||
rail: '🚆',
|
||||
ferry: '⛴️',
|
||||
unknown: '🚏',
|
||||
});
|
||||
|
||||
/**
|
||||
* MBTA route ids are human-readable and mode-typed: rapid-transit lines carry
|
||||
* colour names, commuter rail is prefixed `CR-`, ferries `Boat-`, buses are
|
||||
* numeric (or `SL`/`CT` express families).
|
||||
* @param {string|null} routeId
|
||||
* @returns {string}
|
||||
*/
|
||||
function mbtaRouteMode(routeId) {
|
||||
if (!routeId) return 'unknown';
|
||||
if (/^(Red|Orange|Blue)\b/.test(routeId)) return 'subway';
|
||||
if (/^(Green|Mattapan)/.test(routeId)) return 'tram';
|
||||
if (/^CR-/.test(routeId)) return 'rail';
|
||||
if (/^Boat-/.test(routeId)) return 'ferry';
|
||||
if (/^Shuttle/i.test(routeId)) return 'bus';
|
||||
return 'bus';
|
||||
}
|
||||
|
||||
/**
|
||||
* Entur (Norway) route ids are `<codespace>:Line:<local-id>`; the codespace
|
||||
* tells the operator, not the mode, but a few are single-mode operators.
|
||||
* @param {string|null} routeId
|
||||
* @returns {string}
|
||||
*/
|
||||
function enturRouteMode(routeId) {
|
||||
if (!routeId) return 'unknown';
|
||||
const codespace = routeId.split(':')[0];
|
||||
if (
|
||||
codespace === 'VYG' ||
|
||||
codespace === 'GJB' ||
|
||||
codespace === 'SJN' ||
|
||||
codespace === 'FLT' ||
|
||||
codespace === 'GOA' ||
|
||||
codespace === 'NSB' ||
|
||||
codespace === 'VYT'
|
||||
)
|
||||
return 'rail';
|
||||
if (codespace === 'FLB') return 'rail';
|
||||
return 'bus';
|
||||
}
|
||||
|
||||
/**
|
||||
* HSL route ids start with a four-digit code whose first digit is the mode
|
||||
* family in HSL's numbering: 1xxx/2xxx… are trams (1001–1010) for 4-digit ids
|
||||
* beginning with `10`, metro routes are `31M…`, ferries `1019`.
|
||||
* @param {string|null} routeId
|
||||
* @returns {string}
|
||||
*/
|
||||
function hslRouteMode(routeId) {
|
||||
if (!routeId) return 'unknown';
|
||||
if (/^31M/.test(routeId)) return 'subway';
|
||||
if (/^10(0[1-9]|10|15)/.test(routeId)) return 'tram';
|
||||
if (/^1019/.test(routeId)) return 'ferry';
|
||||
if (/^300[0-9A-Z]/.test(routeId)) return 'rail';
|
||||
return 'bus';
|
||||
}
|
||||
|
||||
/**
|
||||
* Metro Transit (Minneapolis–St Paul): light rail is the Blue/Green line
|
||||
* (route ids 901/902), Northstar commuter rail is 888.
|
||||
* @param {string|null} routeId
|
||||
* @returns {string}
|
||||
*/
|
||||
function metroTransitRouteMode(routeId) {
|
||||
if (!routeId) return 'unknown';
|
||||
if (routeId === '901' || routeId === '902') return 'tram';
|
||||
if (routeId === '888') return 'rail';
|
||||
return 'bus';
|
||||
}
|
||||
|
||||
/**
|
||||
* Registry of feeds. Order is presentation order in the stats/credit text.
|
||||
* `loadRadiusKm` is the distance from `center` inside which the feed is polled.
|
||||
* @type {ReadonlyArray<Readonly<{
|
||||
* id: string, name: string, operator: string, region: string,
|
||||
* center: {lat: number, lon: number}, loadRadiusKm: number,
|
||||
* url: string, headers?: Record<string, string>,
|
||||
* license: string, licenseUrl: string, attribution: string,
|
||||
* defaultMode: string, routeMode?: (routeId: string|null) => string,
|
||||
* }>>}
|
||||
*/
|
||||
export const TRANSIT_FEED_REGISTRY = Object.freeze([
|
||||
Object.freeze({
|
||||
id: 'mbta',
|
||||
historyRetention: true,
|
||||
name: 'MBTA',
|
||||
operator: 'Massachusetts Bay Transportation Authority',
|
||||
region: 'Boston, MA',
|
||||
center: Object.freeze({ lat: 42.3601, lon: -71.0589 }),
|
||||
loadRadiusKm: 70,
|
||||
url: 'https://cdn.mbta.com/realtime/VehiclePositions.pb',
|
||||
license: 'MassDOT Developers License Agreement',
|
||||
licenseUrl:
|
||||
'https://cdn.mbta.com/sites/default/files/2023-08/mbta-massdot-develop-license-agreement.pdf',
|
||||
attribution: 'MBTA / MassDOT',
|
||||
defaultEnabled: true,
|
||||
terms: Object.freeze({
|
||||
quote:
|
||||
'MassDOT ... hereby grants You (Licensee) non-exclusive, limited, and revocable rights to use, reproduce, and redistribute the Data. ... Clearly acknowledge MassDOT as the provider of the Data.',
|
||||
note: 'Credit is text only: the agreement forbids using MBTA/MassDOT logos or trademarks with the data.',
|
||||
}),
|
||||
defaultMode: 'bus',
|
||||
routeMode: mbtaRouteMode,
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'capmetro-austin',
|
||||
name: 'CapMetro',
|
||||
operator: 'Capital Metropolitan Transportation Authority',
|
||||
region: 'Austin, TX',
|
||||
center: Object.freeze({ lat: 30.2672, lon: -97.7431 }),
|
||||
loadRadiusKm: 60,
|
||||
url: 'https://data.texas.gov/download/eiei-9rpf/application%2Foctet-stream',
|
||||
license: 'CapMetro Developer Tools license',
|
||||
licenseUrl: 'https://www.capmetro.org/developertools',
|
||||
attribution:
|
||||
'Capital Metropolitan Transportation Authority — data.texas.gov',
|
||||
defaultEnabled: true,
|
||||
terms: Object.freeze({
|
||||
quote:
|
||||
'Capital Metropolitan Transportation Authority (CMTA) hereby grants you (Licensee) non-exclusive, limited and revocable rights to use, reproduce, and redistribute CMTA Data.',
|
||||
note: 'CMTA trademarks may not be used in association with the data, so the credit is the operator name as text.',
|
||||
}),
|
||||
defaultMode: 'bus',
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'metrotransit-msp',
|
||||
name: 'Metro Transit',
|
||||
operator: 'Metro Transit (Metropolitan Council)',
|
||||
region: 'Minneapolis–St Paul, MN',
|
||||
center: Object.freeze({ lat: 44.9778, lon: -93.265 }),
|
||||
loadRadiusKm: 70,
|
||||
url: 'https://svc.metrotransit.org/mtgtfs/vehiclepositions.pb',
|
||||
license: 'Public Metro Transit vehicle position data',
|
||||
licenseUrl: 'https://svc.metrotransit.org/',
|
||||
attribution: 'Metro Transit — Metropolitan Council',
|
||||
defaultEnabled: true,
|
||||
terms: Object.freeze({
|
||||
quote:
|
||||
"Metro Transit's GTFS-realtime feeds are refreshed every 5 seconds.",
|
||||
note: 'The realtime feed is published from a developer page carrying operational guidance and no licence text of its own. The public-domain statement below belongs to the Metropolitan Council metadata for the COMPANION SCHEDULE dataset, not to this feed: "None. This dataset is public domain under the Minnesota Government Data Practices Act". Credited as a courtesy, the way the other public agency feeds are.',
|
||||
}),
|
||||
defaultMode: 'bus',
|
||||
routeMode: metroTransitRouteMode,
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'hsl-helsinki',
|
||||
name: 'HSL',
|
||||
operator: 'Helsinki Region Transport (HSL)',
|
||||
region: 'Helsinki, Finland',
|
||||
center: Object.freeze({ lat: 60.1699, lon: 24.9384 }),
|
||||
loadRadiusKm: 70,
|
||||
url: 'https://realtime.hsl.fi/realtime/vehicle-positions/v2/hsl',
|
||||
license: 'CC BY 4.0',
|
||||
licenseUrl: 'https://www.hsl.fi/en/hsl/open-data',
|
||||
attribution: 'HSL (Helsinki Region Transport)',
|
||||
defaultEnabled: true,
|
||||
terms: Object.freeze({
|
||||
quote: null,
|
||||
note: 'The open-data page refuses automated readers (HTTP 403, bot protection), so this entry rests on the owner opening it and accepting it rather than on a passage quoted here. HSL publishes its open data under CC BY 4.0 and asks to be credited as the source; the feed needs no key.',
|
||||
}),
|
||||
defaultMode: 'bus',
|
||||
routeMode: hslRouteMode,
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'ovapi-nl',
|
||||
name: 'OVapi',
|
||||
operator: 'Stichting OpenGeo (NDOV data)',
|
||||
region: 'Netherlands',
|
||||
center: Object.freeze({ lat: 52.2, lon: 5.3 }),
|
||||
loadRadiusKm: 220,
|
||||
url: 'https://gtfs.ovapi.nl/nl/vehiclePositions.pb',
|
||||
license: 'Free to use per the OVapi README (best effort, no SLA)',
|
||||
licenseUrl: 'https://gtfs.ovapi.nl/README',
|
||||
attribution:
|
||||
'OVapi / Stichting OpenGeo — Dutch integrated real-time transit data',
|
||||
defaultEnabled: true,
|
||||
terms: Object.freeze({
|
||||
quote:
|
||||
'You are free to use this data, but there is no service level agreement (best-effort) nor are you allowed to say you represent or impersonate any of the transit agencies listed here.',
|
||||
note: 'The README asks consumers to identify themselves in the User-Agent, to send If-Modified-Since / If-None-Match when polling faster than once a minute, and to accept gzip. The proxy does all three.',
|
||||
}),
|
||||
defaultMode: 'bus',
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'entur-norway',
|
||||
name: 'Entur',
|
||||
operator: 'Entur AS (Norwegian national transit data)',
|
||||
region: 'Norway',
|
||||
// Circle chosen to hold Oslo, Bergen, Bodø and Tromsø while leaving
|
||||
// Helsinki (≈820 km) out — a national feed must not poll from next door.
|
||||
center: Object.freeze({ lat: 64.0, lon: 11.5 }),
|
||||
loadRadiusKm: 720,
|
||||
url: 'https://api.entur.io/realtime/v1/gtfs-rt/vehicle-positions',
|
||||
headers: Object.freeze({ 'ET-Client-Name': 'gods-eye-view-transit' }),
|
||||
license: 'Norwegian Licence for Open Government Data (NLOD)',
|
||||
licenseUrl: 'https://developer.entur.org/pages-intro-authentication',
|
||||
attribution: 'Entur — data under NLOD',
|
||||
defaultEnabled: true,
|
||||
terms: Object.freeze({
|
||||
quote:
|
||||
'This API is open under NLOD licence, however, it is required that all consumers identify themselves by using the header ET-Client-Name.',
|
||||
note: 'The header is mandatory — unidentified consumers may be rate-limited or blocked — and the proxy sends it on every request.',
|
||||
}),
|
||||
defaultMode: 'bus',
|
||||
routeMode: enturRouteMode,
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'translink-seq',
|
||||
name: 'TransLink',
|
||||
operator: 'TransLink (Queensland Government)',
|
||||
region: 'South East Queensland, Australia',
|
||||
center: Object.freeze({ lat: -27.4698, lon: 153.0251 }),
|
||||
loadRadiusKm: 150,
|
||||
url: 'https://gtfsrt.api.translink.com.au/api/realtime/seq/VehiclePositions',
|
||||
license: 'CC BY 4.0',
|
||||
licenseUrl: 'https://translink.com.au/about-translink/open-data',
|
||||
attribution: 'TransLink — Queensland Government (CC BY 4.0)',
|
||||
defaultEnabled: true,
|
||||
terms: Object.freeze({
|
||||
quote:
|
||||
'Our data is licensed under a Creative Commons Attribution 4.0 International License. ... You must not represent that the State in any way endorses your Application.',
|
||||
note: 'No key, no stated rate limit, no caching rule. Logos and network imagery need separate approval, so the credit is text only.',
|
||||
}),
|
||||
defaultMode: 'bus',
|
||||
}),
|
||||
]);
|
||||
|
||||
/**
|
||||
* The feeds this build actually polls. A registered feed that is not here is
|
||||
* inert everywhere: it resolves to no route, is never offered to the browser,
|
||||
* and never appears in a coverage check.
|
||||
*/
|
||||
export const TRANSIT_ENABLED_FEEDS = Object.freeze(
|
||||
TRANSIT_FEED_REGISTRY.filter((feed) => feed.defaultEnabled === true),
|
||||
);
|
||||
|
||||
const FEED_BY_ID = new Map(
|
||||
TRANSIT_ENABLED_FEEDS.map((feed) => [feed.id, feed]),
|
||||
);
|
||||
const ANY_FEED_BY_ID = new Map(
|
||||
TRANSIT_FEED_REGISTRY.map((feed) => [feed.id, feed]),
|
||||
);
|
||||
|
||||
/** Feed ids are path segments: lowercase letters, digits, hyphens only. */
|
||||
export const TRANSIT_FEED_ID_PATTERN = /^[a-z0-9][a-z0-9-]{1,63}$/;
|
||||
|
||||
/**
|
||||
* Look up a POLLABLE feed by id. Unknown ids, malformed ids, and feeds that are
|
||||
* registered but switched off all return null — this is the only door from a
|
||||
* request path to an upstream URL, so a feed the owner has not cleared must not
|
||||
* be reachable through it.
|
||||
* @param {string} id
|
||||
* @returns {object|null}
|
||||
*/
|
||||
export function getTransitFeed(id) {
|
||||
if (typeof id !== 'string' || !TRANSIT_FEED_ID_PATTERN.test(id)) return null;
|
||||
return FEED_BY_ID.get(id) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up any registered feed, enabled or not. For credits and documentation
|
||||
* only — never for resolving a request into an upstream fetch.
|
||||
* @param {string} id
|
||||
* @returns {object|null}
|
||||
*/
|
||||
export function getRegisteredTransitFeed(id) {
|
||||
if (typeof id !== 'string' || !TRANSIT_FEED_ID_PATTERN.test(id)) return null;
|
||||
return ANY_FEED_BY_ID.get(id) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Great-circle distance in km.
|
||||
* @param {number} aLat
|
||||
* @param {number} aLon
|
||||
* @param {number} bLat
|
||||
* @param {number} bLon
|
||||
* @returns {number}
|
||||
*/
|
||||
export function haversineKm(aLat, aLon, bLat, bLon) {
|
||||
const toRad = (deg) => (deg * Math.PI) / 180;
|
||||
const dLat = toRad(bLat - aLat);
|
||||
const dLon = toRad(bLon - aLon);
|
||||
const h =
|
||||
Math.sin(dLat / 2) ** 2 +
|
||||
Math.cos(toRad(aLat)) * Math.cos(toRad(bLat)) * Math.sin(dLon / 2) ** 2;
|
||||
return 2 * 6371 * Math.asin(Math.min(1, Math.sqrt(h)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Feeds whose coverage circle contains the point, nearest first.
|
||||
* @param {number} lat
|
||||
* @param {number} lon
|
||||
* @param {number} [slackKm=0] Extra radius (hysteresis) so a feed at the edge
|
||||
* of coverage does not flap on small camera moves.
|
||||
* @returns {object[]}
|
||||
*/
|
||||
export function transitFeedsInRange(lat, lon, slackKm = 0) {
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return [];
|
||||
return TRANSIT_ENABLED_FEEDS.map((feed) => ({
|
||||
feed,
|
||||
km: haversineKm(lat, lon, feed.center.lat, feed.center.lon),
|
||||
}))
|
||||
.filter(({ feed, km }) => km <= feed.loadRadiusKm + Math.max(0, slackKm))
|
||||
.sort((a, b) => a.km - b.km)
|
||||
.map(({ feed }) => feed);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mode for a vehicle: the feed's route hint when it has one, else its default.
|
||||
* @param {object} feed Registry entry.
|
||||
* @param {string|null} routeId GTFS route_id from the vehicle's trip.
|
||||
* @returns {string} One of TRANSIT_MODES.
|
||||
*/
|
||||
export function transitModeFor(feed, routeId) {
|
||||
const hinted =
|
||||
typeof feed?.routeMode === 'function' ? feed.routeMode(routeId) : null;
|
||||
const mode =
|
||||
hinted && hinted !== 'unknown' ? hinted : feed?.defaultMode || 'unknown';
|
||||
return TRANSIT_MODES.includes(mode) ? mode : 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the mode came from the route id rather than the feed's default. A
|
||||
* default is a guess about a fleet — TransLink's is "bus" while it publishes
|
||||
* rail in the same feed — so a consumer judging physical plausibility must
|
||||
* not hold a defaulted vehicle to a bus's limits.
|
||||
* @param {object} feed Registry entry.
|
||||
* @param {string|null} routeId
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function transitModeResolved(feed, routeId) {
|
||||
const hinted =
|
||||
typeof feed?.routeMode === 'function' ? feed.routeMode(routeId) : null;
|
||||
return (
|
||||
Boolean(hinted) && hinted !== 'unknown' && TRANSIT_MODES.includes(hinted)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Public catalog shape served by `/api/transit/feeds` — everything the browser
|
||||
* needs to gate polling and credit the source, and nothing it could misuse.
|
||||
* @returns {object[]}
|
||||
*/
|
||||
export function publicTransitCatalog() {
|
||||
return TRANSIT_ENABLED_FEEDS.map((feed) => ({
|
||||
id: feed.id,
|
||||
name: feed.name,
|
||||
operator: feed.operator,
|
||||
region: feed.region,
|
||||
center: { ...feed.center },
|
||||
loadRadiusKm: feed.loadRadiusKm,
|
||||
license: feed.license,
|
||||
licenseUrl: feed.licenseUrl,
|
||||
attribution: feed.attribution,
|
||||
historyRetention: feed.historyRetention === true,
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
TRANSIT_ENABLED_FEEDS,
|
||||
TRANSIT_FEED_REGISTRY,
|
||||
TRANSIT_FEED_ID_PATTERN,
|
||||
TRANSIT_MODES,
|
||||
getRegisteredTransitFeed,
|
||||
getTransitFeed,
|
||||
haversineKm,
|
||||
publicTransitCatalog,
|
||||
transitFeedsInRange,
|
||||
transitModeFor,
|
||||
transitModeResolved,
|
||||
} from './transitFeeds.js';
|
||||
|
||||
test('every registered feed is keyless, https, licensed, and uniquely identified', () => {
|
||||
const ids = new Set();
|
||||
for (const feed of TRANSIT_FEED_REGISTRY) {
|
||||
assert.match(
|
||||
feed.id,
|
||||
TRANSIT_FEED_ID_PATTERN,
|
||||
`${feed.id} is a valid path segment`,
|
||||
);
|
||||
assert.equal(ids.has(feed.id), false, `${feed.id} is unique`);
|
||||
ids.add(feed.id);
|
||||
const url = new URL(feed.url);
|
||||
assert.equal(url.protocol, 'https:', `${feed.id} fetches over https`);
|
||||
assert.equal(
|
||||
url.search.includes('key='),
|
||||
false,
|
||||
`${feed.id} carries no key in its URL`,
|
||||
);
|
||||
assert.ok(
|
||||
feed.license && feed.licenseUrl && feed.attribution,
|
||||
`${feed.id} names its license`,
|
||||
);
|
||||
assert.ok(
|
||||
feed.loadRadiusKm > 0 && feed.loadRadiusKm <= 1000,
|
||||
`${feed.id} radius is sane`,
|
||||
);
|
||||
assert.ok(
|
||||
Math.abs(feed.center.lat) <= 90 && Math.abs(feed.center.lon) <= 180,
|
||||
);
|
||||
assert.ok(
|
||||
TRANSIT_MODES.includes(feed.defaultMode),
|
||||
`${feed.id} default mode is known`,
|
||||
);
|
||||
assert.ok(Object.isFrozen(feed), `${feed.id} is immutable`);
|
||||
}
|
||||
});
|
||||
|
||||
test('getTransitFeed is the only door to an upstream URL and refuses anything unregistered', () => {
|
||||
assert.equal(
|
||||
getTransitFeed('mbta')?.url,
|
||||
'https://cdn.mbta.com/realtime/VehiclePositions.pb',
|
||||
);
|
||||
assert.equal(getTransitFeed('MBTA'), null);
|
||||
assert.equal(getTransitFeed('../etc/passwd'), null);
|
||||
assert.equal(getTransitFeed('https://evil.example'), null);
|
||||
assert.equal(getTransitFeed(''), null);
|
||||
assert.equal(getTransitFeed(null), null);
|
||||
assert.equal(getTransitFeed(42), null);
|
||||
});
|
||||
|
||||
test('haversine matches known city distances', () => {
|
||||
const bostonToNyc = haversineKm(42.3601, -71.0589, 40.7128, -74.006);
|
||||
assert.ok(
|
||||
Math.abs(bostonToNyc - 306) < 5,
|
||||
`Boston–NYC ≈ 306 km, got ${bostonToNyc}`,
|
||||
);
|
||||
assert.equal(haversineKm(0, 0, 0, 0), 0);
|
||||
});
|
||||
|
||||
test('feeds in range are nearest-first and honor slack as hysteresis', () => {
|
||||
// Camera over Cambridge, MA → MBTA only.
|
||||
const boston = transitFeedsInRange(42.37, -71.11);
|
||||
assert.deepEqual(
|
||||
boston.map((f) => f.id),
|
||||
['mbta'],
|
||||
);
|
||||
// Mid-Atlantic: nothing.
|
||||
assert.deepEqual(transitFeedsInRange(40, -40), []);
|
||||
// Just outside MBTA's 70 km circle (≈ 80 km south) — out without slack, in with 20 km slack.
|
||||
const farLat = 42.3601 - 80 / 111;
|
||||
assert.deepEqual(transitFeedsInRange(farLat, -71.0589), []);
|
||||
assert.deepEqual(
|
||||
transitFeedsInRange(farLat, -71.0589, 20).map((f) => f.id),
|
||||
['mbta'],
|
||||
);
|
||||
// Bad input never throws.
|
||||
assert.deepEqual(transitFeedsInRange(NaN, 1), []);
|
||||
// A national feed covers its own cities and not the neighbour's capital.
|
||||
for (const [city, lat, lon] of [
|
||||
['Oslo', 59.91, 10.75],
|
||||
['Bergen', 60.39, 5.32],
|
||||
['Tromsø', 69.65, 18.96],
|
||||
]) {
|
||||
assert.ok(
|
||||
transitFeedsInRange(lat, lon).some((f) => f.id === 'entur-norway'),
|
||||
`${city} is covered by Entur`,
|
||||
);
|
||||
}
|
||||
assert.deepEqual(
|
||||
transitFeedsInRange(60.17, 24.94).map((f) => f.id),
|
||||
['hsl-helsinki'],
|
||||
'Helsinki polls HSL only',
|
||||
);
|
||||
});
|
||||
|
||||
test('route hints refine a feed default and never escape the known modes', () => {
|
||||
const mbta = getTransitFeed('mbta');
|
||||
assert.equal(transitModeFor(mbta, 'Red'), 'subway');
|
||||
assert.equal(transitModeFor(mbta, 'Green-B'), 'tram');
|
||||
assert.equal(transitModeFor(mbta, 'CR-Fitchburg'), 'rail');
|
||||
assert.equal(transitModeFor(mbta, 'Boat-F1'), 'ferry');
|
||||
assert.equal(transitModeFor(mbta, '66'), 'bus');
|
||||
assert.equal(transitModeFor(mbta, null), 'bus');
|
||||
const hsl = getTransitFeed('hsl-helsinki');
|
||||
assert.equal(transitModeFor(hsl, '31M1'), 'subway');
|
||||
assert.equal(transitModeFor(hsl, '1006'), 'tram');
|
||||
assert.equal(transitModeFor(hsl, '9982'), 'bus');
|
||||
const msp = getTransitFeed('metrotransit-msp');
|
||||
assert.equal(transitModeFor(msp, '901'), 'tram');
|
||||
assert.equal(transitModeFor(msp, '17'), 'bus');
|
||||
const entur = getTransitFeed('entur-norway');
|
||||
assert.equal(transitModeFor(entur, 'VYG:Line:R10'), 'rail');
|
||||
assert.equal(transitModeFor(entur, 'TRO:Line:1_310'), 'bus');
|
||||
assert.equal(transitModeFor({ defaultMode: 'spaceship' }, 'x'), 'unknown');
|
||||
assert.equal(transitModeFor(null, 'x'), 'unknown');
|
||||
});
|
||||
|
||||
test('the public catalog exposes coverage and credit, never the upstream URL or headers', () => {
|
||||
const catalog = publicTransitCatalog();
|
||||
assert.equal(catalog.length, TRANSIT_ENABLED_FEEDS.length);
|
||||
for (const entry of catalog) {
|
||||
assert.equal('url' in entry, false);
|
||||
assert.equal('headers' in entry, false);
|
||||
assert.ok(entry.id && entry.name && entry.region && entry.attribution);
|
||||
assert.ok(
|
||||
Number.isFinite(entry.center.lat) && Number.isFinite(entry.loadRadiusKm),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('the enabled set is a gate on the registry, not a copy of it', () => {
|
||||
// Every registered feed is switched on today. The gate still has to be the
|
||||
// thing that decides that, because the moment an operator's terms change the
|
||||
// owner flips one flag and expects the feed to become unreachable — not to
|
||||
// stay routable because the filter had quietly become a no-op.
|
||||
assert.deepEqual(
|
||||
TRANSIT_ENABLED_FEEDS.map((feed) => feed.id),
|
||||
TRANSIT_FEED_REGISTRY.filter((feed) => feed.defaultEnabled === true).map(
|
||||
(feed) => feed.id,
|
||||
),
|
||||
);
|
||||
for (const feed of TRANSIT_FEED_REGISTRY) {
|
||||
assert.equal(
|
||||
typeof feed.defaultEnabled,
|
||||
'boolean',
|
||||
`${feed.id} states whether it ships on`,
|
||||
);
|
||||
assert.equal(
|
||||
Boolean(getTransitFeed(feed.id)),
|
||||
feed.defaultEnabled,
|
||||
`${feed.id} is routable exactly when it is enabled`,
|
||||
);
|
||||
assert.ok(getRegisteredTransitFeed(feed.id), `${feed.id} stays documented`);
|
||||
}
|
||||
});
|
||||
|
||||
test('a mode the route id established is resolved; a feed default is only a guess', () => {
|
||||
// TransLink publishes rail in a feed whose default is bus. A consumer that
|
||||
// judges physical plausibility must know the difference, or a 140 km/h
|
||||
// train is refused as an impossible bus.
|
||||
const translink = getTransitFeed('translink-seq');
|
||||
assert.equal(transitModeFor(translink, '600'), 'bus');
|
||||
assert.equal(transitModeResolved(translink, '600'), false, 'defaulted');
|
||||
const mbta = getTransitFeed('mbta');
|
||||
assert.equal(transitModeResolved(mbta, 'Red'), true);
|
||||
assert.equal(
|
||||
transitModeResolved(mbta, '66'),
|
||||
true,
|
||||
'a numeric MBTA route is a bus by rule, not by default',
|
||||
);
|
||||
assert.equal(transitModeResolved(mbta, null), false);
|
||||
assert.equal(transitModeResolved({ defaultMode: 'bus' }, 'x'), false);
|
||||
assert.equal(
|
||||
transitModeResolved({ routeMode: () => 'spaceship' }, 'x'),
|
||||
false,
|
||||
'an unknown hint resolves nothing',
|
||||
);
|
||||
});
|
||||
|
||||
test('only MBTA retains proxy history while every other registered feed stays live-enabled', () => {
|
||||
assert.deepEqual(
|
||||
publicTransitCatalog()
|
||||
.filter((feed) => feed.historyRetention)
|
||||
.map((feed) => feed.id),
|
||||
['mbta'],
|
||||
);
|
||||
assert.equal(getTransitFeed('capmetro-austin').defaultEnabled, true);
|
||||
assert.equal(getTransitFeed('mbta').attribution, 'MBTA / MassDOT');
|
||||
});
|
||||
|
||||
test('published transit history scope agrees with the catalog capability', () => {
|
||||
const retained = publicTransitCatalog().filter(
|
||||
(feed) => feed.historyRetention,
|
||||
);
|
||||
assert.deepEqual(
|
||||
retained.map((feed) => feed.id),
|
||||
['mbta'],
|
||||
);
|
||||
const sources = readFileSync(
|
||||
new URL('../../DATA_SOURCES.md', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
const row = sources
|
||||
.split('\n')
|
||||
.find((line) => line.startsWith('| **MBTA / MassDOT**'));
|
||||
assert.match(
|
||||
row,
|
||||
/live vehicles and up to 15 minutes of recently observed positions/,
|
||||
);
|
||||
assert.match(row, /MBTA \/ MassDOT.*courtesy/);
|
||||
assert.match(sources, /other feeds have no proxy retention/);
|
||||
const state = readFileSync(
|
||||
new URL('../../docs/CURRENT-STATE.md', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
const transit = state.slice(
|
||||
state.indexOf('Transit is off by default'),
|
||||
state.indexOf('`src/data/militaryAwareness.js` remains'),
|
||||
);
|
||||
assert.match(transit, /Restart clears it/);
|
||||
assert.doesNotMatch(
|
||||
transit,
|
||||
/four fixes|0\.95x|guaranteed lower|one poll interval behind/,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,214 @@
|
||||
import {
|
||||
presetSpriteScale,
|
||||
presetSpriteOutlinePx,
|
||||
transitStyleProfile,
|
||||
} from './transitPresetStyle.js';
|
||||
// src/data/transitIcons.js
|
||||
/**
|
||||
* Front-up transit-vehicle silhouettes, one per mode, as SVG data URIs for
|
||||
* Cesium billboards.
|
||||
*
|
||||
* Same house language as `aircraftIcons.js`: a 96-unit viewBox with the glyph
|
||||
* centred at (48,48), drawn nose toward -Y so the screen-projected rotation in
|
||||
* `iconOrientation.js` can spin the whole glyph, filled WHITE with a dark
|
||||
* hairline edge so the tint pipeline (billboard.color = the mode colour, plus
|
||||
* `.withAlpha` fades) keeps working. No glyph carries a colour of its own; a
|
||||
* hardcoded fill would fight the tint that tells a person what they are looking
|
||||
* at.
|
||||
*
|
||||
* These are plan views, the way a vehicle reads from a camera above a street —
|
||||
* not side elevations. Each mode has a DISTINCT outline so it is recognisable
|
||||
* at the ~18 px the fleet renders at, by shape and not only by colour: a bus is
|
||||
* short and blunt, a tram is long and slender with articulation joints, a metro
|
||||
* car is squared off, a mainline train has a tapered nose, and a ferry is a
|
||||
* hull with a bow.
|
||||
*
|
||||
* Interior detail is drawn with the same dark hairline as the edge — windscreen
|
||||
* lines, articulation joints, a deck line — because a solid dark panel would
|
||||
* darken the tint and a white-on-white panel would vanish.
|
||||
*/
|
||||
|
||||
const VIEW = 96;
|
||||
const C = VIEW / 2; // 48 — glyph centre
|
||||
|
||||
/** Hairline dark edge, matching the aircraft glyph weight at this box size. */
|
||||
const STROKE =
|
||||
'stroke="rgba(0,0,0,0.34)" stroke-width="1.5" stroke-linejoin="round"';
|
||||
/** Interior panel lines: the same ink, thinner, capped so joints read cleanly. */
|
||||
const DETAIL =
|
||||
'stroke="rgba(0,0,0,0.30)" stroke-width="1.5" stroke-linecap="round" fill="none"';
|
||||
|
||||
/**
|
||||
* Each body is drawn in a centred frame (origin 0,0 = glyph centre), front
|
||||
* toward -Y, in the 96-unit space (half-extent up to ~45).
|
||||
*/
|
||||
const BODIES = {
|
||||
// ── Bus: short, blunt, square-shouldered. The shape everything else reads
|
||||
// against, and the one most people will see most often.
|
||||
bus: `
|
||||
<path d="M-17,-28 C-17,-32 -14,-34 0,-34 C14,-34 17,-32 17,-28
|
||||
L17,28 C17,32 14,34 0,34 C-14,34 -17,32 -17,28 Z"
|
||||
fill="white" ${STROKE}/>
|
||||
<path d="M-11,-24 L11,-24" ${DETAIL}/>
|
||||
<path d="M-11,20 L11,20" ${DETAIL}/>`,
|
||||
|
||||
// ── Tram / light rail: longer and slimmer than a bus, with the two
|
||||
// articulation joints that make a modern low-floor tram unmistakable.
|
||||
tram: `
|
||||
<path d="M-10,-38 C-10,-41 -8,-42.5 0,-42.5 C8,-42.5 10,-41 10,-38
|
||||
L10,38 C10,41 8,42.5 0,42.5 C-8,42.5 -10,41 -10,38 Z"
|
||||
fill="white" ${STROKE}/>
|
||||
<path d="M-10,-13 L10,-13" ${DETAIL}/>
|
||||
<path d="M-10,13 L10,13" ${DETAIL}/>
|
||||
<path d="M-8,-34 L8,-34" ${DETAIL}/>`,
|
||||
|
||||
// ── Metro / subway: squared off at both ends, wider than a tram, a single
|
||||
// cab line at the front. Reads as rolling stock, not a road vehicle.
|
||||
subway: `
|
||||
<path d="M-11.5,-40 L11.5,-40 L11.5,40 L-11.5,40 Z"
|
||||
fill="white" ${STROKE}/>
|
||||
<path d="M-11.5,-32 L11.5,-32" ${DETAIL}/>
|
||||
<path d="M-11.5,0 L11.5,0" ${DETAIL}/>
|
||||
<path d="M-11.5,32 L11.5,32" ${DETAIL}/>`,
|
||||
|
||||
// ── Mainline / commuter rail: the tapered nose of an intercity set, longer
|
||||
// than anything else in the set.
|
||||
rail: `
|
||||
<path d="M0,-45 C5,-45 9.5,-41 10.5,-34 L10.5,40
|
||||
C10.5,43 8.5,44.5 0,44.5 C-8.5,44.5 -10.5,43 -10.5,40
|
||||
L-10.5,-34 C-9.5,-41 -5,-45 0,-45 Z"
|
||||
fill="white" ${STROKE}/>
|
||||
<path d="M-9,-30 L9,-30" ${DETAIL}/>
|
||||
<path d="M-10.5,6 L10.5,6" ${DETAIL}/>`,
|
||||
|
||||
// ── Ferry: a hull. Pointed bow, square stern, superstructure amidships —
|
||||
// the only glyph in the set that is not a box on rails or rubber.
|
||||
ferry: `
|
||||
<path d="M0,-44 C6,-38 12,-26 12.5,-14 L12.5,34
|
||||
C12.5,37 11,38 0,38 C-11,38 -12.5,37 -12.5,34
|
||||
L-12.5,-14 C-12,-26 -6,-38 0,-44 Z"
|
||||
fill="white" ${STROKE}/>
|
||||
<path d="M-7.5,-6 L7.5,-6 L7.5,20 L-7.5,20 Z" ${DETAIL}/>`,
|
||||
|
||||
// ── Unknown: a plain rounded marker. Deliberately featureless, so an
|
||||
// unclassified vehicle never pretends to be a mode it is not.
|
||||
unknown: `
|
||||
<path d="M-10,-26 C-10,-30 -7,-32 0,-32 C7,-32 10,-30 10,-26
|
||||
L10,26 C10,30 7,32 0,32 C-7,32 -10,30 -10,26 Z"
|
||||
fill="white" ${STROKE}/>`,
|
||||
};
|
||||
|
||||
/** The modes this module can draw. */
|
||||
export const TRANSIT_ICON_KINDS = Object.freeze(Object.keys(BODIES));
|
||||
|
||||
const _iconCache = new Map();
|
||||
|
||||
const _b64 = (s) =>
|
||||
typeof btoa === 'function'
|
||||
? btoa(s)
|
||||
: Buffer.from(s, 'utf8').toString('base64');
|
||||
|
||||
/**
|
||||
* Fleet raster size. Same reasoning as the aircraft fleet glyphs: Cesium's
|
||||
* billboard atlas has no mipmaps, so a texture far larger than its on-screen
|
||||
* footprint is GPU-minified into mush. Transit billboards render around 14–20
|
||||
* CSS px, so 48 px of source covers the band on a Retina display with very
|
||||
* little minification.
|
||||
*/
|
||||
const FLEET_RASTER_PX = 48;
|
||||
/** The selected vehicle draws bigger and deserves a crisper source. */
|
||||
export const SELECTED_ICON_PX = 96;
|
||||
|
||||
/**
|
||||
* On-screen size the fleet glyph is authored for. A halo width asked for in
|
||||
* screen pixels is converted to viewBox units against this, so "two pixels of
|
||||
* ring" means two pixels on an eighteen-pixel billboard.
|
||||
*/
|
||||
export const FLEET_SCREEN_PX = 20;
|
||||
|
||||
/**
|
||||
* How a halo changes the glyph's frame. A thick outer stroke would clip at
|
||||
* the 96-unit box (the tram already reaches 42.5 of 48), so the viewBox is
|
||||
* padded by the stroke and the billboard has to grow by the same ratio to
|
||||
* keep the body its shipped size on screen.
|
||||
*
|
||||
* @param {number} haloScreenPx Ring width in final screen pixels (0 = none).
|
||||
* @param {number} [screenPx] Billboard size the ring is specified against.
|
||||
* @returns {{units: number, pad: number, ratio: number}} Stroke width in
|
||||
* viewBox units (the visible ring is half of it, outside the body), the
|
||||
* padding added on every side, and the frame growth factor to apply to the
|
||||
* billboard's width and height.
|
||||
*/
|
||||
export function haloFrame(haloScreenPx, screenPx = FLEET_SCREEN_PX) {
|
||||
const ring = Number.isFinite(haloScreenPx) ? Math.max(0, haloScreenPx) : 0;
|
||||
if (ring === 0) return { units: 0, pad: 0, ratio: 1 };
|
||||
// Half the stroke lies outside the body, so a ring of R px needs 2R of
|
||||
// stroke. One screen pixel is VIEW/screenPx units.
|
||||
const units = (2 * ring * VIEW) / Math.max(1, screenPx);
|
||||
const pad = Math.ceil(units / 2) + 1;
|
||||
return { units, pad, ratio: (VIEW + 2 * pad) / VIEW };
|
||||
}
|
||||
|
||||
/** Number of raster variants ever built — a bound a test can hold the cache to. */
|
||||
export function transitIconCacheSize() {
|
||||
return _iconCache.size;
|
||||
}
|
||||
|
||||
/**
|
||||
* Data URI for one mode's silhouette, lazily built and cached per kind, size
|
||||
* and halo.
|
||||
*
|
||||
* With a halo, the body is drawn twice: first as a wide dark stroke, then as
|
||||
* the shipped white fill with its hairline edge on top, so the ring sits
|
||||
* OUTSIDE the silhouette and the interior detail is untouched. The dark ring
|
||||
* is what survives NVG's auto-gain and FLIR's luma mapping when the bright
|
||||
* core alone would merge with a bright road.
|
||||
*
|
||||
* @param {string} kind One of TRANSIT_ICON_KINDS; anything else draws `unknown`.
|
||||
* @param {number} [px] Raster size.
|
||||
* @param {{haloScreenPx?: number, screenPx?: number}} [options]
|
||||
* @returns {string} `data:image/svg+xml;base64,...`
|
||||
*/
|
||||
export function transitIcon(kind, px = FLEET_RASTER_PX, options = {}) {
|
||||
const k = BODIES[kind] ? kind : 'unknown';
|
||||
const selected = px > FLEET_RASTER_PX;
|
||||
px = selected ? SELECTED_ICON_PX : FLEET_RASTER_PX;
|
||||
const profile = transitStyleProfile(
|
||||
options.style ||
|
||||
(options.haloScreenPx >= 2
|
||||
? 'thermal'
|
||||
: options.haloScreenPx === 1.25
|
||||
? 'retro'
|
||||
: 'normal'),
|
||||
);
|
||||
const style =
|
||||
profile === 'mono' ? 'thermal' : profile === 'crt' ? 'retro' : 'normal';
|
||||
const displayPx = (selected ? 30 : 20) * presetSpriteScale(style, selected);
|
||||
const frame = haloFrame(presetSpriteOutlinePx(style, selected), displayPx);
|
||||
const key = `${k}:${selected ? 1 : 0}:${profile}`;
|
||||
let uri = _iconCache.get(key);
|
||||
if (!uri) {
|
||||
const size = VIEW + 2 * frame.pad;
|
||||
// Sensor signatures use a broad rounded envelope, without dark interior
|
||||
// joints or a second outline eating into the hot core at display resolution.
|
||||
const halfW = k === 'bus' || k === 'ferry' || k === 'unknown' ? 48 : 47;
|
||||
const halfH = 48;
|
||||
const radius = k === 'subway' ? 8 : k === 'rail' ? 12 : 14;
|
||||
const capsule = `<path d="M${-halfW + radius},${-halfH} H${halfW - radius} Q${halfW},${-halfH} ${halfW},${-halfH + radius} V${halfH - radius} Q${halfW},${halfH} ${halfW - radius},${halfH} H${-halfW + radius} Q${-halfW},${halfH} ${-halfW},${halfH - radius} V${-halfH + radius} Q${-halfW},${-halfH} ${-halfW + radius},${-halfH} Z" fill="white" />`;
|
||||
const body = profile === 'mono' ? capsule : BODIES[k];
|
||||
const halo =
|
||||
frame.units > 0
|
||||
? body.replace(
|
||||
/<path d="([^"]+)"\s+fill="white" [^/]*\/>/,
|
||||
(_m, d) =>
|
||||
`<path d="${d}" fill="none" stroke="${profile === 'mono' ? '#000000' : '#05080C'}" stroke-opacity="${profile === 'mono' ? 1 : 0.95}" stroke-width="${frame.units.toFixed(2)}" stroke-linejoin="round"/>`,
|
||||
)
|
||||
: '';
|
||||
const svg =
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="${Math.round(px * frame.ratio)}" height="${Math.round(px * frame.ratio)}" viewBox="${-frame.pad} ${-frame.pad} ${size} ${size}">` +
|
||||
`<g transform="translate(${C},${C})">${halo}${body}</g></svg>`;
|
||||
uri = 'data:image/svg+xml;base64,' + _b64(svg);
|
||||
_iconCache.set(key, uri);
|
||||
}
|
||||
return uri;
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
FLEET_SCREEN_PX,
|
||||
TRANSIT_ICON_KINDS,
|
||||
haloFrame,
|
||||
transitIcon,
|
||||
transitIconCacheSize,
|
||||
} from './transitIcons.js';
|
||||
|
||||
const decode = (uri) =>
|
||||
Buffer.from(uri.replace('data:image/svg+xml;base64,', ''), 'base64').toString(
|
||||
'utf8',
|
||||
);
|
||||
|
||||
test('a halo is sized in screen pixels and pads the frame so it cannot clip', () => {
|
||||
const none = haloFrame(0);
|
||||
assert.deepEqual(none, { units: 0, pad: 0, ratio: 1 });
|
||||
const two = haloFrame(2, FLEET_SCREEN_PX);
|
||||
// Two pixels of ring on an eighteen-pixel glyph is a 4-px stroke: 21.3 of 96 units.
|
||||
assert.ok(Math.abs(two.units - (4 * 96) / 20) < 1e-9);
|
||||
assert.ok(
|
||||
two.pad >= two.units / 2,
|
||||
'padding covers the half-stroke outside the body',
|
||||
);
|
||||
assert.ok(two.ratio > 1, 'and the billboard grows by the same ratio');
|
||||
});
|
||||
|
||||
test('a haloed glyph draws the dark ring under the shipped white body', () => {
|
||||
const plain = decode(transitIcon('bus'));
|
||||
const haloed = decode(transitIcon('bus', 48, { haloScreenPx: 2 }));
|
||||
assert.notEqual(plain, haloed);
|
||||
assert.match(
|
||||
haloed,
|
||||
/stroke="#000000" stroke-opacity="1" stroke-width="19\.20"/,
|
||||
);
|
||||
assert.ok(
|
||||
haloed.indexOf('#000000') < haloed.indexOf('fill="white"'),
|
||||
'ring first, body on top',
|
||||
);
|
||||
assert.match(haloed, /viewBox="-\d+ -\d+ \d+ \d+"/, 'padded frame');
|
||||
assert.match(plain, /stroke-width="9\.60"/, 'normal has a one-pixel halo');
|
||||
});
|
||||
|
||||
test('the raster cache is bounded by kind, size and halo — never by call count', () => {
|
||||
const before = transitIconCacheSize();
|
||||
for (let i = 0; i < 50; i += 1) {
|
||||
transitIcon('tram', 48, { haloScreenPx: 2 });
|
||||
transitIcon('tram', 48);
|
||||
transitIcon('tram', 128, { haloScreenPx: 1.25 });
|
||||
}
|
||||
assert.ok(
|
||||
transitIconCacheSize() - before <= 3,
|
||||
`three variants, not ${transitIconCacheSize() - before}`,
|
||||
);
|
||||
assert.equal(
|
||||
transitIcon('tram', 48, { haloScreenPx: 2 }),
|
||||
transitIcon('tram', 48, { haloScreenPx: 2 }),
|
||||
);
|
||||
for (const kind of TRANSIT_ICON_KINDS)
|
||||
transitIcon(kind, 48, { haloScreenPx: 2 });
|
||||
assert.ok(transitIconCacheSize() <= before + 3 + TRANSIT_ICON_KINDS.length);
|
||||
});
|
||||
|
||||
test('all 36 variants retain the specified final halo at both display sizes', async () => {
|
||||
const { presetSpriteScale, presetSpriteOutlinePx } =
|
||||
await import('./transitPresetStyle.js');
|
||||
for (const kind of TRANSIT_ICON_KINDS) {
|
||||
for (const selected of [false, true]) {
|
||||
for (const style of ['normal', 'thermal', 'retro']) {
|
||||
const display =
|
||||
(selected ? 30 : 20) * presetSpriteScale(style, selected);
|
||||
const svg = decode(transitIcon(kind, selected ? 96 : 48, { style }));
|
||||
const units = Number(
|
||||
svg.match(/stroke-opacity="(?:0.95|1)" stroke-width="([\d.]+)"/)[1],
|
||||
);
|
||||
assert.ok(
|
||||
Math.abs(
|
||||
(units * display) / 192 - presetSpriteOutlinePx(style, selected),
|
||||
) < 0.001,
|
||||
);
|
||||
const frame = haloFrame(
|
||||
presetSpriteOutlinePx(style, selected),
|
||||
display,
|
||||
);
|
||||
assert.match(
|
||||
svg,
|
||||
new RegExp(
|
||||
`width="${Math.round((selected ? 96 : 48) * frame.ratio)}"`,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < 100; i++)
|
||||
transitIcon('invalid', i, { haloScreenPx: i, screenPx: i });
|
||||
assert.equal(transitIconCacheSize(), 36);
|
||||
});
|
||||
|
||||
test('sensor bodies cover sixty percent of the padded display with opaque white', async () => {
|
||||
const sharp = (await import('sharp')).default;
|
||||
const { presetSpriteScale, presetSpriteOutlinePx } =
|
||||
await import('./transitPresetStyle.js');
|
||||
for (const selected of [false, true])
|
||||
for (const kind of TRANSIT_ICON_KINDS) {
|
||||
const display =
|
||||
(selected ? 30 : 20) * presetSpriteScale('thermal', selected);
|
||||
assert.equal(display, selected ? 36 : 30);
|
||||
assert.equal(presetSpriteOutlinePx('thermal', selected), 3);
|
||||
const svg = decode(
|
||||
transitIcon(kind, selected ? 96 : 48, { style: 'thermal' }),
|
||||
);
|
||||
const frame = haloFrame(3, display);
|
||||
const width = Math.round(display * frame.ratio);
|
||||
const { data, info } = await sharp(Buffer.from(svg))
|
||||
.resize(width, width)
|
||||
.ensureAlpha()
|
||||
.raw()
|
||||
.toBuffer({ resolveWithObject: true });
|
||||
let white = 0;
|
||||
for (let i = 0; i < data.length; i += 4)
|
||||
if (
|
||||
data[i] >= 240 &&
|
||||
data[i + 1] >= 240 &&
|
||||
data[i + 2] >= 240 &&
|
||||
data[i + 3] >= 240
|
||||
)
|
||||
white++;
|
||||
assert.ok(
|
||||
white / (info.width * info.height) >= 0.6,
|
||||
`${kind}/${display}: white ${white / (info.width * info.height)}`,
|
||||
);
|
||||
const c = (Math.floor(width / 2) * width + Math.floor(width / 2)) * 4;
|
||||
assert.deepEqual([...data.subarray(c, c + 4)], [255, 255, 255, 255]);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* @file Preset-aware transit sprite styling — pure lookup tables mapping the
|
||||
* active post-FX style to a sprite treatment the shaders cannot destroy, and
|
||||
* the detection tier that carries each mode's colour above them.
|
||||
*
|
||||
* Why: transit sprites are in-scene billboards, so they pass THROUGH the
|
||||
* post-FX chain. NVG and FLIR reduce the scene to luminance, and the mode
|
||||
* palette's luma is middling — a green bus, a red metro and an amber tram all
|
||||
* collapse to the same dim grey, on a road NVG's auto-gain has pushed to
|
||||
* white. The owner's Boston test: "you can barely see these guys".
|
||||
*
|
||||
* Encoding per profile, the traffic-dot precedent applied to a glyph:
|
||||
* - `mono` (surveillance/NVG, thermal/FLIR, noir, cockpit nvg/thermal): the
|
||||
* sprite is WHITE — the hottest end of grayscale FLIR and of the Ironbow
|
||||
* ramp, and the brightest phosphor under NVG — drawn larger, with a dark
|
||||
* halo sized in SCREEN pixels so it keeps local contrast on a bright road
|
||||
* through any luma mapping. Black-hot FLIR inverts it, deliberately: a
|
||||
* black sprite with a light ring is what a hot object looks like there.
|
||||
* - `crt` (retro): saturated per-mode colours that survive posterisation,
|
||||
* a size boost to out-shout the pixel grid, and a thinner halo.
|
||||
* - `normal` (normal/anime/snow/unknown): every lookup returns the shipped
|
||||
* value — mode colour, shipped size, hairline edge only.
|
||||
*
|
||||
* Brackets are a different matter. The detection canvas composites ABOVE the
|
||||
* post-FX chain, so a tier colour is literal screen RGB in every preset: the
|
||||
* brackets carry the mode colour the sprites can no longer encode, the way
|
||||
* traffic's brackets carry congestion. Tier colours per theme live in
|
||||
* `src/overlays/worldOverlayTokens.js`.
|
||||
*
|
||||
* Cesium-free so the tables are unit-testable; the layer maps them to Cesium
|
||||
* colours and billboard sizes at spawn and restyle time.
|
||||
*
|
||||
* @module data/transitPresetStyle
|
||||
*/
|
||||
|
||||
/** @const {Object<string,'mono'|'crt'>} Style name → non-normal profile. */
|
||||
const PROFILE_BY_STYLE = Object.freeze({
|
||||
surveillance: 'mono', // NVG — P43 phosphor × luma
|
||||
thermal: 'mono', // FLIR — grayscale/Ironbow × luma
|
||||
noir: 'mono', // full desaturation
|
||||
nvg: 'mono', // cockpit vision names its modes directly
|
||||
retro: 'crt', // CRT — hue survives, small glyphs don't
|
||||
});
|
||||
|
||||
/**
|
||||
* Per-profile sprite treatment. `rgba` is [r,g,b] 0–255 + alpha 0–1;
|
||||
* `scale` multiplies the shipped billboard size; `outlinePx` is the dark
|
||||
* halo's width in FINAL SCREEN PIXELS, which is the only unit that means
|
||||
* anything for a glyph rendered at eighteen pixels — the shipped 1.5-unit
|
||||
* hairline in a 96-unit box is 0.28 px on screen, and vanishes.
|
||||
*/
|
||||
const SPRITE_STYLE = Object.freeze({
|
||||
mono: Object.freeze({
|
||||
rgba: Object.freeze([255, 255, 255, 1]),
|
||||
scale: 1.5,
|
||||
outlinePx: 3,
|
||||
}),
|
||||
crt: Object.freeze({
|
||||
scale: 1.3,
|
||||
outlinePx: 1.25,
|
||||
rgbaByMode: Object.freeze({
|
||||
bus: Object.freeze([0, 255, 102, 1]),
|
||||
tram: Object.freeze([255, 179, 0, 1]),
|
||||
subway: Object.freeze([255, 59, 48, 1]),
|
||||
rail: Object.freeze([215, 139, 255, 1]),
|
||||
ferry: Object.freeze([58, 208, 255, 1]),
|
||||
unknown: Object.freeze([255, 255, 255, 1]),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
/** Modes with a bracket tier of their own. */
|
||||
const TIER_MODES = Object.freeze([
|
||||
'bus',
|
||||
'tram',
|
||||
'subway',
|
||||
'rail',
|
||||
'ferry',
|
||||
'unknown',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Classify a style name into a transit styling profile.
|
||||
* @param {string|null|undefined} styleName - Active style (e.g. 'thermal').
|
||||
* @returns {'normal'|'mono'|'crt'} Styling profile; unknown → 'normal'.
|
||||
*/
|
||||
export function transitStyleProfile(styleName) {
|
||||
return PROFILE_BY_STYLE[styleName] || 'normal';
|
||||
}
|
||||
|
||||
/**
|
||||
* Preset sprite colour, or null to keep the shipped mode colour.
|
||||
* @param {string|null|undefined} styleName
|
||||
* @param {string} mode Transit mode.
|
||||
* @returns {number[]|null} [r,g,b,a] with rgb 0–255, alpha 0–1, or null.
|
||||
*/
|
||||
export function presetSpriteRgba(styleName, mode) {
|
||||
const profile = transitStyleProfile(styleName);
|
||||
if (profile === 'mono') return SPRITE_STYLE.mono.rgba;
|
||||
if (profile === 'crt') {
|
||||
return (
|
||||
SPRITE_STYLE.crt.rgbaByMode[mode] || SPRITE_STYLE.crt.rgbaByMode.unknown
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Multiplier a preset applies to the shipped billboard size.
|
||||
* @param {string|null|undefined} styleName
|
||||
* @returns {number} 1 under the normal profile.
|
||||
*/
|
||||
export function presetSpriteScale(styleName, selected = false) {
|
||||
if (transitStyleProfile(styleName) === 'mono')
|
||||
return selected ? 36 / 30 : 30 / 20;
|
||||
return SPRITE_STYLE[transitStyleProfile(styleName)]?.scale ?? 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dark-halo width in final screen pixels, or 0 for the shipped hairline only.
|
||||
* @param {string|null|undefined} styleName
|
||||
* @returns {number}
|
||||
*/
|
||||
export function presetSpriteOutlinePx(styleName, selected = false) {
|
||||
return (
|
||||
SPRITE_STYLE[transitStyleProfile(styleName)]?.outlinePx ??
|
||||
(selected ? 1.5 : 1)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detection-overlay tier key for a transit mode. Every theme's `tiers`
|
||||
* table carries these six, so the bracket colour is per mode in every
|
||||
* preset. Unlike traffic, transit sets its tier keyless as well: the feeds
|
||||
* are keyless, and there is no separate keyless experience to keep
|
||||
* byte-identical.
|
||||
* @param {string} mode
|
||||
* @returns {string}
|
||||
*/
|
||||
export function transitModeTier(mode) {
|
||||
return `transit_${TIER_MODES.includes(mode) ? mode : 'unknown'}`;
|
||||
}
|
||||
|
||||
/** The tier keys this module can produce, for the tables that must carry them. */
|
||||
export const TRANSIT_TIER_KEYS = Object.freeze(
|
||||
TIER_MODES.map((mode) => `transit_${mode}`),
|
||||
);
|
||||
@@ -0,0 +1,96 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
TRANSIT_TIER_KEYS,
|
||||
presetSpriteOutlinePx,
|
||||
presetSpriteRgba,
|
||||
presetSpriteScale,
|
||||
transitModeTier,
|
||||
transitStyleProfile,
|
||||
} from './transitPresetStyle.js';
|
||||
import { TRANSIT_MODES } from './transitFeeds.js';
|
||||
|
||||
test('profile mapping: NVG/FLIR/noir and cockpit nvg are mono, retro is crt, everything else normal', () => {
|
||||
for (const mono of ['surveillance', 'thermal', 'noir', 'nvg'])
|
||||
assert.equal(transitStyleProfile(mono), 'mono', mono);
|
||||
assert.equal(transitStyleProfile('retro'), 'crt');
|
||||
for (const plain of [
|
||||
'normal',
|
||||
'anime',
|
||||
'snow',
|
||||
'minecraft',
|
||||
'',
|
||||
null,
|
||||
undefined,
|
||||
'optical',
|
||||
])
|
||||
assert.equal(transitStyleProfile(plain), 'normal', String(plain));
|
||||
});
|
||||
|
||||
test('normal uses mode colour, unit scale and a one-pixel halo', () => {
|
||||
for (const mode of TRANSIT_MODES) {
|
||||
assert.equal(presetSpriteRgba('normal', mode), null);
|
||||
assert.equal(presetSpriteRgba(undefined, mode), null);
|
||||
}
|
||||
assert.equal(presetSpriteScale('normal'), 1);
|
||||
assert.equal(presetSpriteOutlinePx('normal'), 1);
|
||||
});
|
||||
|
||||
test('mono: every mode is a white core, larger, with a dark halo in screen pixels', () => {
|
||||
for (const style of ['surveillance', 'thermal', 'noir', 'nvg']) {
|
||||
for (const mode of TRANSIT_MODES) {
|
||||
assert.deepEqual(
|
||||
presetSpriteRgba(style, mode),
|
||||
[255, 255, 255, 1],
|
||||
`${style} ${mode}`,
|
||||
);
|
||||
}
|
||||
assert.ok(presetSpriteScale(style) === 1.5, `${style} is drawn larger`);
|
||||
// At least a whole screen pixel and a half of ring around a five-pixel
|
||||
// body: thinner than that vanishes through blur and pixelation.
|
||||
assert.ok(
|
||||
presetSpriteOutlinePx(style) >= 1.5,
|
||||
`${style} halo is ${presetSpriteOutlinePx(style)} px`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('crt: saturated per-mode colours, distinct, upsized, thinner halo', () => {
|
||||
const seen = new Set();
|
||||
for (const mode of TRANSIT_MODES) {
|
||||
const rgba = presetSpriteRgba('retro', mode);
|
||||
assert.ok(Array.isArray(rgba) && rgba.length === 4, mode);
|
||||
seen.add(rgba.slice(0, 3).join(','));
|
||||
const [r, g, b] = rgba;
|
||||
assert.ok(
|
||||
Math.max(r, g, b) === 255,
|
||||
`${mode} is fully saturated on one channel`,
|
||||
);
|
||||
}
|
||||
assert.equal(seen.size, TRANSIT_MODES.length, 'no two modes share a colour');
|
||||
assert.ok(presetSpriteScale('retro') > 1.2);
|
||||
assert.ok(
|
||||
presetSpriteOutlinePx('retro') > 0 &&
|
||||
presetSpriteOutlinePx('retro') < presetSpriteOutlinePx('thermal'),
|
||||
);
|
||||
});
|
||||
|
||||
test('every mode has its own bracket tier, unknown modes fall to transit_unknown', () => {
|
||||
const tiers = new Set();
|
||||
for (const mode of TRANSIT_MODES) tiers.add(transitModeTier(mode));
|
||||
assert.equal(tiers.size, TRANSIT_MODES.length);
|
||||
assert.equal(transitModeTier('spaceship'), 'transit_unknown');
|
||||
assert.equal(transitModeTier(undefined), 'transit_unknown');
|
||||
assert.deepEqual([...tiers].sort(), [...TRANSIT_TIER_KEYS].sort());
|
||||
});
|
||||
|
||||
test('all styled tuples are valid rgba: channels 0–255 ints, alpha in (0, 1]', () => {
|
||||
for (const style of ['thermal', 'retro']) {
|
||||
for (const mode of TRANSIT_MODES) {
|
||||
const [r, g, b, a] = presetSpriteRgba(style, mode);
|
||||
for (const c of [r, g, b])
|
||||
assert.ok(Number.isInteger(c) && c >= 0 && c <= 255);
|
||||
assert.ok(a > 0 && a <= 1);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,401 @@
|
||||
/**
|
||||
* @module transitProxy
|
||||
* @description Pure server-side mechanics for the `/api/transit` proxy.
|
||||
*
|
||||
* Kept free of Vite/Node middleware state (the terrainHeightsProxy pattern)
|
||||
* so path resolution, snapshot shaping, and the cache/stale policy can be
|
||||
* exercised by the offline node:test suite. The middleware in vite.config.js
|
||||
* only does I/O: fetch the registered upstream, hand the bytes here, send.
|
||||
*/
|
||||
|
||||
import {
|
||||
GTFS_INCREMENTALITY_FULL_DATASET,
|
||||
decodeVehiclePositions,
|
||||
} from './gtfsRealtime.js';
|
||||
import { getTransitFeed } from './transitFeeds.js';
|
||||
|
||||
/** Fresh window: a snapshot younger than this is served without refetching. */
|
||||
export const TRANSIT_PROXY_TTL_MS = 15_000;
|
||||
/** Serve-stale window: after an upstream failure, a snapshot this old still ships (marked stale). */
|
||||
export const TRANSIT_PROXY_STALE_MAX_MS = 10 * 60_000;
|
||||
/** Upstream fetch timeout. National feeds (Entur ≈ 1.4 MB) need headroom. */
|
||||
export const TRANSIT_PROXY_TIMEOUT_MS = 15_000;
|
||||
/** Hard cap on upstream bytes: the largest registered feed is well under 1 MB. */
|
||||
export const TRANSIT_PROXY_MAX_BODY_BYTES = 8 * 1024 * 1024;
|
||||
/** Redirect hops followed before a feed is declared unreachable. */
|
||||
export const TRANSIT_MAX_REDIRECTS = 3;
|
||||
/**
|
||||
* The 3xx codes that actually mean "go somewhere else". 304 is NOT one of them:
|
||||
* it is the successful answer to a conditional request and carries no Location,
|
||||
* so treating the whole 3xx range as a redirect turns every healthy unchanged
|
||||
* feed into a failure and walks it up the backoff ladder.
|
||||
*/
|
||||
export const TRANSIT_REDIRECT_STATUSES = Object.freeze([
|
||||
301, 302, 303, 307, 308,
|
||||
]);
|
||||
|
||||
/**
|
||||
* Whether an upstream status is a redirect this proxy should follow.
|
||||
* @param {number} status
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isTransitRedirectStatus(status) {
|
||||
return TRANSIT_REDIRECT_STATUSES.includes(Number(status));
|
||||
}
|
||||
/**
|
||||
* Admission window for upstream requests. One browser polls a feed every 15 s
|
||||
* (4/min); the allowance leaves room for a second tab and a manual reload, and
|
||||
* the global figure bounds what this process can ask of ALL operators at once.
|
||||
*/
|
||||
export const TRANSIT_ADMISSION_WINDOW_MS = 60_000;
|
||||
export const TRANSIT_ADMISSION_MAX_PER_FEED = 8;
|
||||
export const TRANSIT_ADMISSION_MAX_GLOBAL = 40;
|
||||
/**
|
||||
* Cooldown ladder after consecutive upstream failures, in ms. A cold feed that
|
||||
* is simply down must not be re-asked once per poll for the whole session: the
|
||||
* first retry is quick, the last rung is five minutes, and the ladder resets on
|
||||
* the first success.
|
||||
*/
|
||||
export const TRANSIT_BACKOFF_LADDER_MS = Object.freeze([
|
||||
5_000, 15_000, 60_000, 300_000,
|
||||
]);
|
||||
|
||||
/**
|
||||
* Resolve `/vehicles/<feedId>` (the path after the `/api/transit` mount) to a
|
||||
* registered feed. Anything else — a different route, an unknown id, path
|
||||
* tricks, a query string — resolves to null and the caller 404s.
|
||||
* @param {string} url Request URL relative to the mount point.
|
||||
* @returns {{ route: 'feeds' } | { route: 'vehicles', feed: object } | null}
|
||||
*/
|
||||
/** Validate the entire decoded identifier, including UTF-8 length. */
|
||||
export function validTransitIdentifier(value) {
|
||||
return (
|
||||
typeof value === 'string' &&
|
||||
value.length > 0 &&
|
||||
!/[\u0000-\u001f\u007f/\\]/u.test(value) &&
|
||||
value !== '.' &&
|
||||
value !== '..' &&
|
||||
new TextEncoder().encode(value).length <= 256 &&
|
||||
!/[\uD800-\uDFFF]/u.test(value)
|
||||
);
|
||||
}
|
||||
export function resolveTransitRoute(url) {
|
||||
const pathname = String(url || '').split('?')[0];
|
||||
if (pathname === '/feeds' || pathname === '/feeds/')
|
||||
return { route: 'feeds' };
|
||||
const match = /^\/(vehicles|trail)\/([^/]+)(?:\/([^/]+))?\/?$/.exec(pathname);
|
||||
if (!match) return null;
|
||||
let id, vehicleId;
|
||||
try {
|
||||
id = decodeURIComponent(match[2]);
|
||||
vehicleId = match[3] === undefined ? null : decodeURIComponent(match[3]);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const feed = getTransitFeed(id);
|
||||
if (!feed) return null;
|
||||
if (match[1] === 'vehicles')
|
||||
return vehicleId === null ? { route: 'vehicles', feed } : null;
|
||||
return feed.historyRetention === true && validTransitIdentifier(vehicleId)
|
||||
? { route: 'trail', feed, vehicleId }
|
||||
: null;
|
||||
}
|
||||
|
||||
/** Selection reads are bounded and cancellable through body parsing. */
|
||||
export async function fetchTransitHistory(
|
||||
feedId,
|
||||
vehicleId,
|
||||
signal,
|
||||
fetchImpl = fetch,
|
||||
) {
|
||||
if (!validTransitIdentifier(vehicleId))
|
||||
throw new TypeError('Invalid transit vehicle identifier');
|
||||
const response = await fetchImpl(
|
||||
`/api/transit/trail/${encodeURIComponent(feedId)}/${encodeURIComponent(vehicleId)}`,
|
||||
{ signal, headers: { Accept: 'application/json' } },
|
||||
);
|
||||
if (!response.ok) throw new Error(`Transit history HTTP ${response.status}`);
|
||||
const reader = response.body?.getReader?.();
|
||||
let text = '';
|
||||
if (reader) {
|
||||
const decoder = new TextDecoder();
|
||||
let bytes = 0;
|
||||
try {
|
||||
while (true) {
|
||||
signal?.throwIfAborted();
|
||||
const chunk = await reader.read();
|
||||
if (chunk.done) break;
|
||||
bytes += chunk.value.byteLength;
|
||||
if (bytes > 32768)
|
||||
throw new RangeError('Transit history exceeds response limit');
|
||||
text += decoder.decode(chunk.value, { stream: true });
|
||||
}
|
||||
text += decoder.decode();
|
||||
} finally {
|
||||
await reader.cancel().catch(() => {});
|
||||
}
|
||||
} else {
|
||||
text = await response.text();
|
||||
if (new TextEncoder().encode(text).length > 32768)
|
||||
throw new RangeError('Transit history exceeds response limit');
|
||||
}
|
||||
signal?.throwIfAborted();
|
||||
const payload = JSON.parse(text);
|
||||
if (
|
||||
payload.version !== 1 ||
|
||||
payload.feedId !== feedId ||
|
||||
payload.vehicleId !== vehicleId ||
|
||||
!Array.isArray(payload.fixes) ||
|
||||
payload.fixes.length > 128 ||
|
||||
!Array.isArray(payload.epochs) ||
|
||||
payload.epochs.length > 8
|
||||
)
|
||||
throw new TypeError('Invalid transit history response');
|
||||
let previous = -Infinity;
|
||||
const epochs = new Set();
|
||||
for (const epoch of payload.epochs) {
|
||||
if (
|
||||
!Number.isInteger(epoch.id) ||
|
||||
epoch.id < 1 ||
|
||||
epoch.id > 0xffffffff ||
|
||||
epochs.has(epoch.id) ||
|
||||
!['trip', 'route', 'mode'].every(
|
||||
(key) =>
|
||||
typeof epoch[key] === 'string' &&
|
||||
new TextEncoder().encode(epoch[key]).length <= 256,
|
||||
)
|
||||
)
|
||||
throw new TypeError('Invalid transit history epoch');
|
||||
epochs.add(epoch.id);
|
||||
}
|
||||
for (const fix of payload.fixes) {
|
||||
if (
|
||||
!Array.isArray(fix) ||
|
||||
fix.length !== 5 ||
|
||||
!fix.every(Number.isFinite) ||
|
||||
fix[0] < 0 ||
|
||||
fix[0] <= previous ||
|
||||
Math.abs(fix[1]) > 90 ||
|
||||
Math.abs(fix[2]) > 180 ||
|
||||
!Number.isInteger(fix[3]) ||
|
||||
!epochs.has(fix[4])
|
||||
)
|
||||
throw new TypeError('Invalid transit history fix');
|
||||
previous = fix[0];
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* Request headers for one upstream fetch.
|
||||
*
|
||||
* Three of these are obligations, not politeness. Feeds that ask consumers to
|
||||
* identify themselves get their header from the registry (Entur requires
|
||||
* `ET-Client-Name`; OVapi asks that the User-Agent say who you are). OVapi also
|
||||
* asks anyone polling faster than once a minute to send conditional-request
|
||||
* validators and to accept gzip, so both are sent to every feed: harmless
|
||||
* where it is not asked for, and it spares each operator a full body whenever
|
||||
* nothing has changed.
|
||||
*
|
||||
* @param {object} feed Registry entry.
|
||||
* @param {{etag?: string|null, lastModified?: string|null}} [validators] From the cached snapshot.
|
||||
* @returns {Record<string, string>}
|
||||
*/
|
||||
export function transitUpstreamHeaders(feed, validators = null) {
|
||||
return {
|
||||
'User-Agent':
|
||||
'gods-eye-view-transit-proxy/1.0 (+https://github.com/bilawalsidhu/gods-eye-view)',
|
||||
Accept: 'application/x-protobuf, application/octet-stream;q=0.9, */*;q=0.1',
|
||||
'Accept-Encoding': 'gzip',
|
||||
...(validators?.etag ? { 'If-None-Match': validators.etag } : {}),
|
||||
...(validators?.lastModified
|
||||
? { 'If-Modified-Since': validators.lastModified }
|
||||
: {}),
|
||||
...(feed?.headers || {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Only https upstreams are fetched.
|
||||
* @param {string} url Request or response URL.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isAcceptableTransitUpstreamUrl(url) {
|
||||
try {
|
||||
return new URL(url).protocol === 'https:';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether one redirect hop may be followed, BEFORE it is requested.
|
||||
*
|
||||
* Checking the FINAL url after `redirect: 'follow'` is too late: by then the
|
||||
* disallowed host has already been contacted, has already seen this server's
|
||||
* request, and has already answered. So every hop is resolved here first and a
|
||||
* hop that leaves the feed's own origin, or drops to plain http, is refused —
|
||||
* the same rule the CCTV frame proxy applies to camera images.
|
||||
*
|
||||
* @param {string} originUrl The registered feed URL (defines the allowed origin).
|
||||
* @param {string} currentUrl The URL that produced this redirect (for relative Location).
|
||||
* @param {string|null} location Raw `Location` header value.
|
||||
* @returns {{ ok: true, url: string } | { ok: false, reason: string }}
|
||||
*/
|
||||
export function transitRedirectDecision(originUrl, currentUrl, location) {
|
||||
if (!location)
|
||||
return { ok: false, reason: 'redirect without a Location header' };
|
||||
let origin;
|
||||
let next;
|
||||
try {
|
||||
origin = new URL(originUrl).origin;
|
||||
next = new URL(location, currentUrl);
|
||||
} catch {
|
||||
return { ok: false, reason: 'redirect target is not a valid URL' };
|
||||
}
|
||||
if (next.protocol !== 'https:')
|
||||
return { ok: false, reason: 'redirect left https' };
|
||||
if (next.origin !== origin)
|
||||
return { ok: false, reason: `redirect left ${origin}` };
|
||||
return { ok: true, url: next.toString() };
|
||||
}
|
||||
|
||||
/**
|
||||
* Cooldown before the next upstream attempt after `failures` consecutive
|
||||
* failures. Zero while the feed is healthy.
|
||||
* @param {number} failures Consecutive failures (0 = none).
|
||||
* @returns {number} ms to wait before the next upstream attempt.
|
||||
*/
|
||||
export function nextTransitBackoffMs(failures) {
|
||||
const count = Number.isFinite(failures) ? Math.floor(failures) : 0;
|
||||
if (count <= 0) return 0;
|
||||
const index = Math.min(count, TRANSIT_BACKOFF_LADDER_MS.length) - 1;
|
||||
return TRANSIT_BACKOFF_LADDER_MS[index];
|
||||
}
|
||||
|
||||
/**
|
||||
* Error thrown when a feed's shape is unusable rather than merely unavailable.
|
||||
* Carries `transitReason` so the middleware can answer honestly.
|
||||
*/
|
||||
export class TransitFeedShapeError extends Error {
|
||||
constructor(message, reason) {
|
||||
super(message);
|
||||
this.name = 'TransitFeedShapeError';
|
||||
this.transitReason = reason;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Give every vehicle a best-available report time, and say WHERE it came from.
|
||||
*
|
||||
* A feed that omits `VehiclePosition.timestamp` is not reporting "now" — it is
|
||||
* reporting nothing, and treating the moment WE fetched as the moment the bus
|
||||
* was there is how a five-minute-old fix gets drawn as live. The ladder is
|
||||
* explicit instead: the vehicle's own timestamp, else the feed header's
|
||||
* timestamp (the operator's own statement about the snapshot), else fetch time,
|
||||
* which is labelled as a guess so the layer can age it conservatively.
|
||||
*
|
||||
* @param {object[]} vehicles Normalized vehicle records.
|
||||
* @param {number|null} headerTimestamp Feed header timestamp (epoch seconds).
|
||||
* @param {number} fetchedAtS Fetch time (epoch seconds).
|
||||
* @returns {object[]} Records with `timestamp` and `timestampSource` set.
|
||||
*/
|
||||
export function repairVehicleTimestamps(vehicles, headerTimestamp, fetchedAtS) {
|
||||
const header =
|
||||
Number.isFinite(headerTimestamp) && headerTimestamp > 0
|
||||
? headerTimestamp
|
||||
: null;
|
||||
return (vehicles || []).map((vehicle) => {
|
||||
if (Number.isFinite(vehicle.timestamp) && vehicle.timestamp > 0) {
|
||||
return { ...vehicle, timestampSource: 'vehicle' };
|
||||
}
|
||||
if (header !== null)
|
||||
return { ...vehicle, timestamp: header, timestampSource: 'header' };
|
||||
return { ...vehicle, timestamp: fetchedAtS, timestampSource: 'fetch' };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode upstream bytes into the JSON snapshot the browser consumes.
|
||||
*
|
||||
* A DIFFERENTIAL feed is refused outright. Differential GTFS-Realtime carries
|
||||
* only what changed, so reading one as if it were a full snapshot would make
|
||||
* every unmentioned vehicle look like it had vanished. Deletion semantics are
|
||||
* not implemented, so the honest answer is to decline the feed, not to render
|
||||
* a wrong one.
|
||||
*
|
||||
* @param {object} feed Registry entry.
|
||||
* @param {Uint8Array|ArrayBuffer} bytes Raw GTFS-RT FeedMessage.
|
||||
* @param {number} [now=Date.now()] Fetch time (ms epoch).
|
||||
* @returns {{ feedId: string, name: string, fetchedAt: number, feedTimestamp: number|null,
|
||||
* version: string|null, entityCount: number, truncated: boolean, count: number, vehicles: object[] }}
|
||||
*/
|
||||
export function buildTransitSnapshot(feed, bytes, now = Date.now()) {
|
||||
const decoded = decodeVehiclePositions(bytes);
|
||||
if (decoded.incrementality !== GTFS_INCREMENTALITY_FULL_DATASET) {
|
||||
throw new TransitFeedShapeError(
|
||||
`feed is differential (incrementality ${decoded.incrementality})`,
|
||||
'differential',
|
||||
);
|
||||
}
|
||||
return {
|
||||
feedId: feed.id,
|
||||
name: feed.name,
|
||||
fetchedAt: now,
|
||||
feedTimestamp: decoded.timestamp,
|
||||
version: decoded.version,
|
||||
entityCount: decoded.entityCount,
|
||||
truncated: decoded.truncated,
|
||||
count: decoded.vehicles.length,
|
||||
vehicles: repairVehicleTimestamps(
|
||||
decoded.vehicles,
|
||||
decoded.timestamp,
|
||||
Math.floor(now / 1000),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify a cache entry for the request policy.
|
||||
* @param {{ at: number }|null|undefined} entry Cached snapshot (`at` = fetch ms).
|
||||
* @param {number} now
|
||||
* @returns {'none'|'fresh'|'stale'|'expired'}
|
||||
*/
|
||||
export function transitCacheState(entry, now) {
|
||||
if (!entry || !Number.isFinite(entry.at)) return 'none';
|
||||
const age = now - entry.at;
|
||||
if (age < 0) return 'fresh';
|
||||
if (age < TRANSIT_PROXY_TTL_MS) return 'fresh';
|
||||
if (age < TRANSIT_PROXY_STALE_MAX_MS) return 'stale';
|
||||
return 'expired';
|
||||
}
|
||||
|
||||
/**
|
||||
* Response headers for a snapshot. `X-GEV-Cache` mirrors the other proxies
|
||||
* (HIT / MISS / INFLIGHT / STALE-ERROR) so the layer can surface staleness.
|
||||
* @param {'HIT'|'MISS'|'INFLIGHT'|'STALE-ERROR'} cacheState
|
||||
* @param {string} [upstreamHost]
|
||||
* @returns {Record<string, string>}
|
||||
*/
|
||||
export function transitResponseHeaders(
|
||||
cacheState,
|
||||
upstreamHost = '',
|
||||
contactedAt = null,
|
||||
) {
|
||||
return {
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
'Cache-Control':
|
||||
cacheState === 'STALE-ERROR'
|
||||
? 'no-store'
|
||||
: `public, max-age=${Math.floor(TRANSIT_PROXY_TTL_MS / 1000)}`,
|
||||
'X-GEV-Cache': cacheState,
|
||||
...(upstreamHost ? { 'X-Transit-Upstream': upstreamHost } : {}),
|
||||
// When the operator last ANSWERED, which is not when the body was fetched.
|
||||
// A feed whose file has not changed answers 304 forever, and the body we
|
||||
// keep serving carries its original fetch time; without this the browser
|
||||
// would read a healthy revalidated feed as one that had gone silent.
|
||||
...(Number.isFinite(contactedAt)
|
||||
? { 'X-Transit-Contact': String(Math.floor(contactedAt)) }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { PbfWriter } from 'pbf';
|
||||
import {
|
||||
TRANSIT_BACKOFF_LADDER_MS,
|
||||
TRANSIT_PROXY_STALE_MAX_MS,
|
||||
TRANSIT_PROXY_TTL_MS,
|
||||
TransitFeedShapeError,
|
||||
buildTransitSnapshot,
|
||||
isAcceptableTransitUpstreamUrl,
|
||||
nextTransitBackoffMs,
|
||||
repairVehicleTimestamps,
|
||||
resolveTransitRoute,
|
||||
transitCacheState,
|
||||
transitRedirectDecision,
|
||||
transitResponseHeaders,
|
||||
transitUpstreamHeaders,
|
||||
} from './transitProxy.js';
|
||||
import { getTransitFeed } from './transitFeeds.js';
|
||||
|
||||
test('route resolution admits only the catalog and registered feed ids', () => {
|
||||
assert.deepEqual(resolveTransitRoute('/feeds'), { route: 'feeds' });
|
||||
assert.deepEqual(resolveTransitRoute('/feeds/?x=1'), { route: 'feeds' });
|
||||
assert.equal(resolveTransitRoute('/vehicles/mbta')?.feed?.id, 'mbta');
|
||||
assert.equal(resolveTransitRoute('/vehicles/mbta/')?.feed?.id, 'mbta');
|
||||
assert.equal(resolveTransitRoute('/vehicles/mbta?trip=1')?.feed?.id, 'mbta');
|
||||
assert.equal(resolveTransitRoute('/vehicles/nope'), null);
|
||||
assert.equal(resolveTransitRoute('/vehicles/'), null);
|
||||
assert.equal(resolveTransitRoute('/vehicles/mbta/extra'), null);
|
||||
assert.equal(resolveTransitRoute('/vehicles/..%2F..%2Fetc'), null);
|
||||
assert.equal(resolveTransitRoute('/vehicles/%E0%A4%A'), null); // malformed escape never throws
|
||||
assert.equal(resolveTransitRoute('/'), null);
|
||||
assert.equal(resolveTransitRoute(''), null);
|
||||
assert.equal(resolveTransitRoute(undefined), null);
|
||||
});
|
||||
|
||||
test('upstream headers identify the proxy and carry feed-specific identification', () => {
|
||||
const plain = transitUpstreamHeaders(getTransitFeed('mbta'));
|
||||
assert.match(plain['User-Agent'], /gods-eye-view-transit-proxy/);
|
||||
assert.match(plain.Accept, /x-protobuf/);
|
||||
const entur = transitUpstreamHeaders(getTransitFeed('entur-norway'));
|
||||
assert.equal(entur['ET-Client-Name'], 'gods-eye-view-transit');
|
||||
assert.ok(transitUpstreamHeaders(null)['User-Agent']);
|
||||
});
|
||||
|
||||
test('only https upstreams are acceptable, including after a redirect', () => {
|
||||
assert.equal(
|
||||
isAcceptableTransitUpstreamUrl('https://cdn.mbta.com/x.pb'),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
isAcceptableTransitUpstreamUrl(
|
||||
'http://gtfs.ovapi.nl/nl/vehiclePositions.pb',
|
||||
),
|
||||
false,
|
||||
);
|
||||
assert.equal(isAcceptableTransitUpstreamUrl('ftp://x'), false);
|
||||
assert.equal(isAcceptableTransitUpstreamUrl('not a url'), false);
|
||||
});
|
||||
|
||||
test('snapshot shape carries provenance the layer displays', () => {
|
||||
const writer = new PbfWriter();
|
||||
const header = new PbfWriter();
|
||||
header.writeStringField(1, '2.0');
|
||||
header.writeVarintField(3, 1_700_000_000);
|
||||
writer.writeBytesField(1, header.finish());
|
||||
const snapshot = buildTransitSnapshot(
|
||||
getTransitFeed('mbta'),
|
||||
writer.finish(),
|
||||
12345,
|
||||
);
|
||||
assert.equal(snapshot.feedId, 'mbta');
|
||||
assert.equal(snapshot.name, 'MBTA');
|
||||
assert.equal(snapshot.fetchedAt, 12345);
|
||||
assert.equal(snapshot.feedTimestamp, 1_700_000_000);
|
||||
assert.equal(snapshot.version, '2.0');
|
||||
assert.equal(snapshot.count, 0);
|
||||
assert.deepEqual(snapshot.vehicles, []);
|
||||
});
|
||||
|
||||
test('cache policy: fresh within TTL, stale until the serve-stale window, expired after', () => {
|
||||
const now = 1_000_000;
|
||||
assert.equal(transitCacheState(null, now), 'none');
|
||||
assert.equal(transitCacheState({ at: Number.NaN }, now), 'none');
|
||||
assert.equal(transitCacheState({ at: now }, now), 'fresh');
|
||||
assert.equal(
|
||||
transitCacheState({ at: now - TRANSIT_PROXY_TTL_MS + 1 }, now),
|
||||
'fresh',
|
||||
);
|
||||
assert.equal(
|
||||
transitCacheState({ at: now - TRANSIT_PROXY_TTL_MS }, now),
|
||||
'stale',
|
||||
);
|
||||
assert.equal(
|
||||
transitCacheState({ at: now - TRANSIT_PROXY_STALE_MAX_MS + 1 }, now),
|
||||
'stale',
|
||||
);
|
||||
assert.equal(
|
||||
transitCacheState({ at: now - TRANSIT_PROXY_STALE_MAX_MS }, now),
|
||||
'expired',
|
||||
);
|
||||
assert.equal(transitCacheState({ at: now + 5000 }, now), 'fresh'); // clock skew never expires a fresh fetch
|
||||
});
|
||||
|
||||
test('response headers mark cache state and never let a stale-error response be cached downstream', () => {
|
||||
assert.equal(
|
||||
transitResponseHeaders('HIT', 'cdn.mbta.com')['X-Transit-Upstream'],
|
||||
'cdn.mbta.com',
|
||||
);
|
||||
assert.equal(
|
||||
transitResponseHeaders('HIT')['Cache-Control'],
|
||||
'public, max-age=15',
|
||||
);
|
||||
assert.equal(
|
||||
transitResponseHeaders('STALE-ERROR')['Cache-Control'],
|
||||
'no-store',
|
||||
);
|
||||
assert.equal(transitResponseHeaders('MISS')['X-GEV-Cache'], 'MISS');
|
||||
});
|
||||
|
||||
/** A minimal FeedMessage with the given header fields and vehicle entities. */
|
||||
function encodeFeed({
|
||||
incrementality = null,
|
||||
timestamp = 1_700_000_000,
|
||||
entities = [],
|
||||
}) {
|
||||
const writer = new PbfWriter();
|
||||
const header = new PbfWriter();
|
||||
header.writeStringField(1, '2.0');
|
||||
if (incrementality !== null) header.writeVarintField(2, incrementality);
|
||||
if (timestamp !== null) header.writeVarintField(3, timestamp);
|
||||
writer.writeBytesField(1, header.finish());
|
||||
for (const entity of entities) {
|
||||
const feedEntity = new PbfWriter();
|
||||
feedEntity.writeStringField(1, entity.id);
|
||||
const vehicle = new PbfWriter();
|
||||
const position = new PbfWriter();
|
||||
position.writeFloatField(1, entity.lat);
|
||||
position.writeFloatField(2, entity.lon);
|
||||
vehicle.writeBytesField(2, position.finish());
|
||||
if (entity.timestamp) vehicle.writeVarintField(5, entity.timestamp);
|
||||
const descriptor = new PbfWriter();
|
||||
descriptor.writeStringField(1, entity.vehicleId || entity.id);
|
||||
vehicle.writeBytesField(8, descriptor.finish());
|
||||
feedEntity.writeBytesField(4, vehicle.finish());
|
||||
writer.writeBytesField(2, feedEntity.finish());
|
||||
}
|
||||
return writer.finish();
|
||||
}
|
||||
|
||||
test('a redirect is judged before it is followed, not after it has been answered', () => {
|
||||
const feed = { url: 'https://data.example.gov/download/feed.pb' };
|
||||
// Same origin, absolute and relative, is the only case that may proceed.
|
||||
assert.deepEqual(
|
||||
transitRedirectDecision(
|
||||
feed.url,
|
||||
feed.url,
|
||||
'https://data.example.gov/files/a.pb',
|
||||
),
|
||||
{ ok: true, url: 'https://data.example.gov/files/a.pb' },
|
||||
);
|
||||
assert.deepEqual(transitRedirectDecision(feed.url, feed.url, '/files/b.pb'), {
|
||||
ok: true,
|
||||
url: 'https://data.example.gov/files/b.pb',
|
||||
});
|
||||
// Another host is refused even though it is https — this is the hop that used
|
||||
// to be contacted first and inspected afterwards.
|
||||
const offOrigin = transitRedirectDecision(
|
||||
feed.url,
|
||||
feed.url,
|
||||
'https://cdn.elsewhere.test/a.pb',
|
||||
);
|
||||
assert.equal(offOrigin.ok, false);
|
||||
assert.match(offOrigin.reason, /left https:\/\/data\.example\.gov/);
|
||||
// A downgrade to http, a missing Location, and a malformed target all refuse.
|
||||
assert.equal(
|
||||
transitRedirectDecision(feed.url, feed.url, 'http://data.example.gov/a.pb')
|
||||
.ok,
|
||||
false,
|
||||
);
|
||||
assert.equal(transitRedirectDecision(feed.url, feed.url, null).ok, false);
|
||||
assert.equal(
|
||||
transitRedirectDecision(feed.url, feed.url, 'javascript:alert(1)').ok,
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
transitRedirectDecision(feed.url, feed.url, 'data:text/plain,x').ok,
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
transitRedirectDecision('not-a-url', 'not-a-url', '/a.pb').ok,
|
||||
false,
|
||||
);
|
||||
// A sibling port is a different origin.
|
||||
assert.equal(
|
||||
transitRedirectDecision(
|
||||
feed.url,
|
||||
feed.url,
|
||||
'https://data.example.gov:8443/a.pb',
|
||||
).ok,
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('failure cooldown climbs and stops climbing, and a healthy feed waits for nothing', () => {
|
||||
assert.equal(nextTransitBackoffMs(0), 0);
|
||||
assert.equal(nextTransitBackoffMs(-3), 0);
|
||||
assert.deepEqual([1, 2, 3, 4].map(nextTransitBackoffMs), [
|
||||
...TRANSIT_BACKOFF_LADDER_MS,
|
||||
]);
|
||||
// Past the ladder the wait stays at its last rung rather than growing forever.
|
||||
assert.equal(nextTransitBackoffMs(9), TRANSIT_BACKOFF_LADDER_MS.at(-1));
|
||||
assert.equal(nextTransitBackoffMs(500), TRANSIT_BACKOFF_LADDER_MS.at(-1));
|
||||
});
|
||||
|
||||
test('a differential feed is refused rather than read as a full snapshot', () => {
|
||||
const feed = getTransitFeed('mbta');
|
||||
const differential = encodeFeed({
|
||||
incrementality: 1,
|
||||
entities: [{ id: 'v1', lat: 42.36, lon: -71.05, timestamp: 1_700_000_000 }],
|
||||
});
|
||||
assert.throws(
|
||||
() => buildTransitSnapshot(feed, differential, 1_700_000_000_000),
|
||||
(error) =>
|
||||
error instanceof TransitFeedShapeError &&
|
||||
error.transitReason === 'differential',
|
||||
);
|
||||
// The same bytes with FULL_DATASET decode normally, so the refusal is about
|
||||
// the header and not about the payload.
|
||||
const full = encodeFeed({
|
||||
incrementality: 0,
|
||||
entities: [{ id: 'v1', lat: 42.36, lon: -71.05, timestamp: 1_700_000_000 }],
|
||||
});
|
||||
assert.equal(buildTransitSnapshot(feed, full, 1_700_000_000_000).count, 1);
|
||||
// An absent incrementality field means FULL_DATASET by the spec's default.
|
||||
assert.equal(
|
||||
buildTransitSnapshot(feed, encodeFeed({ entities: [] }), 1).count,
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
test('a vehicle with no time of its own borrows the feed header time and says so', () => {
|
||||
const fetchedAtS = 1_700_000_500;
|
||||
const repaired = repairVehicleTimestamps(
|
||||
[
|
||||
{ id: 'own', timestamp: 1_700_000_400 },
|
||||
{ id: 'none' },
|
||||
{ id: 'zero', timestamp: 0 },
|
||||
],
|
||||
1_700_000_450,
|
||||
fetchedAtS,
|
||||
);
|
||||
assert.deepEqual(
|
||||
repaired.map((vehicle) => [
|
||||
vehicle.id,
|
||||
vehicle.timestamp,
|
||||
vehicle.timestampSource,
|
||||
]),
|
||||
[
|
||||
['own', 1_700_000_400, 'vehicle'],
|
||||
['none', 1_700_000_450, 'header'],
|
||||
['zero', 1_700_000_450, 'header'],
|
||||
],
|
||||
);
|
||||
// With no header either, fetch time is used and is labelled as such — never
|
||||
// presented as the vehicle's own report.
|
||||
const noHeader = repairVehicleTimestamps([{ id: 'none' }], null, fetchedAtS);
|
||||
assert.deepEqual(
|
||||
[noHeader[0].timestamp, noHeader[0].timestampSource],
|
||||
[fetchedAtS, 'fetch'],
|
||||
);
|
||||
assert.deepEqual(repairVehicleTimestamps(null, null, fetchedAtS), []);
|
||||
});
|
||||
|
||||
test('the snapshot builder fills timestamps from the header it decoded', () => {
|
||||
const feed = getTransitFeed('mbta');
|
||||
const bytes = encodeFeed({
|
||||
timestamp: 1_700_000_000,
|
||||
entities: [{ id: 'unstamped', lat: 42.36, lon: -71.05 }],
|
||||
});
|
||||
const snapshot = buildTransitSnapshot(feed, bytes, 1_700_000_900_000);
|
||||
assert.equal(snapshot.vehicles[0].timestamp, 1_700_000_000);
|
||||
assert.equal(snapshot.vehicles[0].timestampSource, 'header');
|
||||
assert.equal(snapshot.feedTimestamp, 1_700_000_000);
|
||||
});
|
||||
|
||||
test('conditional-request validators and gzip ride along with feed identification', () => {
|
||||
const entur = getTransitFeed('entur-norway');
|
||||
const plain = transitUpstreamHeaders(entur);
|
||||
assert.equal(plain['Accept-Encoding'], 'gzip');
|
||||
assert.equal(plain['ET-Client-Name'], 'gods-eye-view-transit');
|
||||
assert.equal('If-None-Match' in plain, false);
|
||||
const conditional = transitUpstreamHeaders(entur, {
|
||||
etag: 'W/"abc"',
|
||||
lastModified: 'Wed, 10 Sep 2026 12:00:00 GMT',
|
||||
});
|
||||
assert.equal(conditional['If-None-Match'], 'W/"abc"');
|
||||
assert.equal(
|
||||
conditional['If-Modified-Since'],
|
||||
'Wed, 10 Sep 2026 12:00:00 GMT',
|
||||
);
|
||||
assert.match(conditional['User-Agent'], /gods-eye-view/);
|
||||
});
|
||||
|
||||
test('retained routes validate the entire identifier and only MBTA is opted in', () => {
|
||||
assert.equal(resolveTransitRoute('/trail/mbta/bus%201').vehicleId, 'bus 1');
|
||||
for (const path of [
|
||||
'/trail/mbta/a%2Fb',
|
||||
'/trail/mbta/%00',
|
||||
'/trail/mbta/%5C',
|
||||
'/trail/mbta/%2E%2E',
|
||||
'/trail/mbta/%E0%A4%A',
|
||||
`/trail/mbta/${encodeURIComponent('é'.repeat(129))}`,
|
||||
'/trail/capmetro-austin/b',
|
||||
])
|
||||
assert.equal(resolveTransitRoute(path), null);
|
||||
});
|
||||
test('client history validates response bounds, epochs and cancellation through parsing', async () => {
|
||||
const { fetchTransitHistory } = await import('./transitProxy.js');
|
||||
const payload = {
|
||||
version: 1,
|
||||
feedId: 'mbta',
|
||||
vehicleId: 'b',
|
||||
epochs: [{ id: 1, trip: '', route: '1', mode: 'bus' }],
|
||||
fixes: [[1000, 42, -71, 1, 1]],
|
||||
};
|
||||
assert.deepEqual(
|
||||
await fetchTransitHistory(
|
||||
'mbta',
|
||||
'b',
|
||||
new AbortController().signal,
|
||||
async () => new Response(JSON.stringify(payload)),
|
||||
),
|
||||
payload,
|
||||
);
|
||||
for (const value of [
|
||||
{ ...payload, vehicleId: 'wrong' },
|
||||
{ ...payload, fixes: [[1000, 91, -71, 1, 1]] },
|
||||
{ ...payload, fixes: Array(129).fill(payload.fixes[0]) },
|
||||
{ ...payload, epochs: [] },
|
||||
])
|
||||
await assert.rejects(
|
||||
fetchTransitHistory(
|
||||
'mbta',
|
||||
'b',
|
||||
undefined,
|
||||
async () => new Response(JSON.stringify(value)),
|
||||
),
|
||||
);
|
||||
await assert.rejects(
|
||||
fetchTransitHistory(
|
||||
'mbta',
|
||||
'b',
|
||||
undefined,
|
||||
async () => new Response('x'.repeat(32769)),
|
||||
),
|
||||
/limit/,
|
||||
);
|
||||
const controller = new AbortController();
|
||||
await assert.rejects(
|
||||
fetchTransitHistory('mbta', 'b', controller.signal, async () => {
|
||||
controller.abort();
|
||||
return new Response(JSON.stringify(payload));
|
||||
}),
|
||||
{ name: 'AbortError' },
|
||||
);
|
||||
});
|
||||
@@ -1,6 +1,10 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { GBFS_CITY_REGISTRY } from './registry.js';
|
||||
import { BIKESHARE_SELECTED_OVERLAY_SOURCE_ID } from './policy.js';
|
||||
import {
|
||||
claimCameraSensitivity,
|
||||
releaseCameraSensitivity,
|
||||
} from '../../data/cameraSensitivity.js';
|
||||
|
||||
export function createLifecycle({
|
||||
state: layerState,
|
||||
@@ -85,10 +89,10 @@ export function createLifecycle({
|
||||
|
||||
if (!layerState._cameraChangedAttached) {
|
||||
viewer.camera.changed.addEventListener(parts.viewport.onCameraChanged);
|
||||
viewer.camera.percentageChanged = Math.min(
|
||||
viewer.camera.percentageChanged || 1,
|
||||
0.05,
|
||||
);
|
||||
// Through the shared ledger: this is a single number every layer's
|
||||
// camera.changed listener shares, and a layer leaving must not hand
|
||||
// back a coarse default while this one is still driving off it.
|
||||
claimCameraSensitivity(viewer.camera, 'bikeshare', 0.05);
|
||||
layerState._cameraChangedAttached = true;
|
||||
}
|
||||
|
||||
@@ -124,6 +128,7 @@ export function createLifecycle({
|
||||
viewer.camera.changed.removeEventListener(
|
||||
parts.viewport.onCameraChanged,
|
||||
);
|
||||
releaseCameraSensitivity(viewer.camera, 'bikeshare');
|
||||
layerState._cameraChangedAttached = false;
|
||||
}
|
||||
|
||||
@@ -156,6 +161,7 @@ export function createLifecycle({
|
||||
viewer.camera.changed.removeEventListener(
|
||||
parts.viewport.onCameraChanged,
|
||||
);
|
||||
releaseCameraSensitivity(viewer.camera, 'bikeshare');
|
||||
layerState._cameraChangedAttached = false;
|
||||
}
|
||||
parts.ingestion.abortAllInFlight();
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { TRAFFIC_TIMING_ENABLED } from './policy.js';
|
||||
import {
|
||||
claimCameraSensitivity,
|
||||
releaseCameraSensitivity,
|
||||
} from '../../data/cameraSensitivity.js';
|
||||
|
||||
export function createLifecycle({
|
||||
state: layerState,
|
||||
@@ -93,18 +97,14 @@ export function createLifecycle({
|
||||
);
|
||||
}
|
||||
|
||||
// Subscribe to camera changes with a 5% movement threshold. Save the prior
|
||||
// value so disable() can restore it — percentageChanged is a shared global
|
||||
// on the camera and leaving it mutated affects every other camera.changed
|
||||
// listener in the app.
|
||||
// Share the 5% movement threshold with other camera-driven layers.
|
||||
viewer.camera.changed.addEventListener(parts.viewport.onCameraChanged);
|
||||
// Always inspect the final view, even when the last flight step is below
|
||||
// camera.changed's movement threshold.
|
||||
layerState._arrivalRemover = viewer.camera.moveEnd.addEventListener(
|
||||
parts.viewport.onCameraChanged,
|
||||
);
|
||||
layerState._prevPercentageChanged = viewer.camera.percentageChanged;
|
||||
viewer.camera.percentageChanged = 0.05;
|
||||
claimCameraSensitivity(viewer.camera, 'traffic', 0.05);
|
||||
|
||||
// Kick off initial viewport check
|
||||
parts.viewport.onCameraChanged();
|
||||
@@ -169,12 +169,7 @@ export function createLifecycle({
|
||||
viewer.camera.changed.removeEventListener(parts.viewport.onCameraChanged);
|
||||
layerState._arrivalRemover?.();
|
||||
layerState._arrivalRemover = null;
|
||||
// Restore the camera's global percentageChanged so other layers/listeners
|
||||
// keep their expected sensitivity.
|
||||
if (layerState._prevPercentageChanged != null) {
|
||||
viewer.camera.percentageChanged = layerState._prevPercentageChanged;
|
||||
layerState._prevPercentageChanged = null;
|
||||
}
|
||||
releaseCameraSensitivity(viewer.camera, 'traffic');
|
||||
if (layerState._pointCollection) layerState._pointCollection.show = false;
|
||||
},
|
||||
|
||||
|
||||
Vendored
+18
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Manager wiring. Transit's data arrives from camera-driven proximity polls,
|
||||
* not only from the manager's own tick, so the layer keeps a handle it can use
|
||||
* to repaint its panel row the moment a snapshot lands.
|
||||
* @param {object} context
|
||||
* @returns {object}
|
||||
*/
|
||||
export function createControls({ state }) {
|
||||
const methods = {
|
||||
/**
|
||||
* @param {object} dataManager DataLayerManager instance.
|
||||
*/
|
||||
attachDataManager(dataManager) {
|
||||
state._dataManager = dataManager;
|
||||
},
|
||||
};
|
||||
return { methods };
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
FLOOR_REREAD_ATTEMPTS,
|
||||
FLOOR_REREAD_MS,
|
||||
FLOOR_WARM_PER_POLL,
|
||||
TRANSIT_POLL_MS,
|
||||
HEIGHT_SAMPLE_MAX_ALTITUDE_M,
|
||||
withinViewBounds,
|
||||
} from './policy.js';
|
||||
|
||||
/** Surface preparation off the render path, shared by markers and history. */
|
||||
export function createHeight({ state, services, parts }) {
|
||||
const { governorRequestRender } = services.render;
|
||||
const {
|
||||
cachedGroundFloor,
|
||||
neighborFloorM,
|
||||
warmGroundFloor,
|
||||
coarseFloorCoord,
|
||||
GROUND_FLOOR_LIFT_M,
|
||||
} = services.ground;
|
||||
// The application's rendered-mesh sampler. Optional so a layer built on a
|
||||
// bare floor stub still runs; the application always supplies it.
|
||||
const sampleMeshFloorCells = services.mesh?.sampleMeshFloorCells || null;
|
||||
|
||||
/** The camera's subpoint in degrees, for the sampler's proximity gate. */
|
||||
function viewerSubpoint() {
|
||||
const carto = state._viewer?.camera?.positionCartographic;
|
||||
if (!carto) return { viewerLat: undefined, viewerLon: undefined };
|
||||
return {
|
||||
viewerLat: (carto.latitude * 180) / Math.PI,
|
||||
viewerLon: (carto.longitude * 180) / Math.PI,
|
||||
};
|
||||
}
|
||||
|
||||
function cameraAltitude() {
|
||||
const carto = state._viewer?.camera?.positionCartographic;
|
||||
return carto && Number.isFinite(carto.height) ? carto.height : Infinity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether ground matters at this camera. Above the gate a few metres of
|
||||
* terrain are invisible, so the fleet renders on the ellipsoid and nothing
|
||||
* is warmed — a national view must not queue a country's worth of cells.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function nearGround() {
|
||||
return cameraAltitude() <= HEIGHT_SAMPLE_MAX_ALTITUDE_M;
|
||||
}
|
||||
|
||||
/**
|
||||
* The floor under a vehicle, and the best fallback if its own cell is cold.
|
||||
* @param {number} lat
|
||||
* @param {number} lon
|
||||
* @returns {{cell: string, height: number|null, prior: number|null}}
|
||||
*/
|
||||
const terrainPoint = new Cesium.Cartographic();
|
||||
function requestHeight(lat, lon, entry = null) {
|
||||
const coarse = coarseFloorCoord(lat, lon);
|
||||
const cell = `${coarse.lat},${coarse.lon}`;
|
||||
const fixtureFloor =
|
||||
entry?.qaFloorM ?? state._qaFixtureFloors?.get(entry?.key);
|
||||
if (Number.isFinite(fixtureFloor))
|
||||
return { cell, height: fixtureFloor, prior: null };
|
||||
// On a terrain globe, use the loaded surface under the marker. This is
|
||||
// synchronous cache work and does not launch a terrain request.
|
||||
const globe = state._viewer?.scene?.globe;
|
||||
if (globe?.show === true) {
|
||||
Cesium.Cartographic.fromDegrees(lon, lat, 0, terrainPoint);
|
||||
const rendered = globe.getHeight?.(terrainPoint);
|
||||
const flat =
|
||||
state._viewer?.terrainProvider instanceof
|
||||
Cesium.EllipsoidTerrainProvider;
|
||||
if (Number.isFinite(rendered) || flat)
|
||||
return {
|
||||
cell,
|
||||
height: (flat ? 0 : rendered) + GROUND_FLOOR_LIFT_M,
|
||||
prior: null,
|
||||
};
|
||||
}
|
||||
const floor = cachedGroundFloor(lat, lon);
|
||||
if (Number.isFinite(floor)) {
|
||||
return { cell, height: floor + GROUND_FLOOR_LIFT_M, prior: null };
|
||||
}
|
||||
if (!nearGround()) {
|
||||
// Far enough away that the ellipsoid is indistinguishable from the
|
||||
// street: draw, do not wait, do not warm.
|
||||
return { cell, height: null, prior: 0 };
|
||||
}
|
||||
const neighbour = neighborFloorM(coarse);
|
||||
return {
|
||||
cell,
|
||||
height: null,
|
||||
// A neighbouring floor is a coarse prior, never a clearance guarantee.
|
||||
prior: Number.isFinite(neighbour)
|
||||
? neighbour + GROUND_FLOOR_LIFT_M
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
/** A vehicle no longer rendered needs nothing released: cells are shared. */
|
||||
function releaseVehicle() {}
|
||||
|
||||
let cellCursors = new WeakMap();
|
||||
let budgetAt = -Infinity;
|
||||
let demUsed = new Set(),
|
||||
meshUsed = 0;
|
||||
function rereadFloors() {
|
||||
state._floorTimer = null;
|
||||
if (!state._enabled) return;
|
||||
const onGround = nearGround(),
|
||||
candidates = [];
|
||||
const entries = [...state._vehicles.values()].filter(
|
||||
(entry) =>
|
||||
entry.key === state._selectedKey ||
|
||||
state._visible.has(entry) ||
|
||||
withinViewBounds(state._viewBounds, entry.segment?.from || entry.to),
|
||||
);
|
||||
entries.sort(
|
||||
(a, b) =>
|
||||
Number(b.key === state._selectedKey) -
|
||||
Number(a.key === state._selectedKey),
|
||||
);
|
||||
for (const entry of entries) {
|
||||
const cells = new Map();
|
||||
const collect = (point, answer) => {
|
||||
if (!onGround) return;
|
||||
if (!cells.has(answer.cell))
|
||||
cells.set(answer.cell, {
|
||||
lat: point.lat,
|
||||
lon: point.lon,
|
||||
cell: answer.cell,
|
||||
warm: answer.height !== null,
|
||||
});
|
||||
};
|
||||
parts.trails.prepareEntry(entry, collect);
|
||||
const newest = entry.fixes.at(-1);
|
||||
const answer = requestHeight(newest.lat, newest.lon, entry);
|
||||
entry.heightCell = answer.cell;
|
||||
if (answer.height !== null) {
|
||||
entry.heightM = answer.height;
|
||||
entry.heightResolved = true;
|
||||
if (entry.heightPending) {
|
||||
entry.heightPending = false;
|
||||
state._visibilityDirty = true;
|
||||
}
|
||||
}
|
||||
candidates.push({
|
||||
entry,
|
||||
cells: [...cells.values()],
|
||||
cursor: 0,
|
||||
start: cellCursors.get(entry) || 0,
|
||||
});
|
||||
}
|
||||
const selectedCells =
|
||||
candidates[0]?.entry.key === state._selectedKey
|
||||
? candidates[0].cells.slice(0, 12)
|
||||
: [];
|
||||
const ordered = [...selectedCells],
|
||||
seen = new Set(selectedCells.map((cell) => cell.cell));
|
||||
const start = candidates.length
|
||||
? state._floorCursor % candidates.length
|
||||
: 0;
|
||||
let remaining = true;
|
||||
while (remaining) {
|
||||
remaining = false;
|
||||
for (let i = 0; i < candidates.length; i++) {
|
||||
const candidate = candidates[(start + i) % candidates.length];
|
||||
if (candidate.cursor >= candidate.cells.length) continue;
|
||||
const index =
|
||||
(candidate.start + candidate.cursor++) % candidate.cells.length;
|
||||
const cell = candidate.cells[index];
|
||||
remaining = true;
|
||||
if (!seen.has(cell.cell)) {
|
||||
seen.add(cell.cell);
|
||||
ordered.push({
|
||||
...cell,
|
||||
entry: candidate.entry,
|
||||
next: (index + 1) % candidate.cells.length,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
state._floorCursor += FLOOR_WARM_PER_POLL;
|
||||
const dem = [],
|
||||
mesh = [];
|
||||
for (const cell of ordered) {
|
||||
let admitted = false;
|
||||
if (
|
||||
!cell.warm &&
|
||||
(demUsed.has(cell.cell) || demUsed.size < FLOOR_WARM_PER_POLL)
|
||||
) {
|
||||
demUsed.add(cell.cell);
|
||||
dem.push(cell);
|
||||
admitted = true;
|
||||
}
|
||||
if (cell.warm && meshUsed + mesh.length < 40) {
|
||||
mesh.push(cell);
|
||||
admitted = true;
|
||||
}
|
||||
if (admitted && cell.entry) cellCursors.set(cell.entry, cell.next);
|
||||
}
|
||||
meshUsed += mesh.length;
|
||||
const markers = entries.map((entry) => entry.marker).filter(Boolean);
|
||||
for (let i = 0; i < dem.length; i += 8)
|
||||
warmGroundFloor(dem.slice(i, i + 8));
|
||||
for (let i = 0; i < mesh.length; i += 8)
|
||||
sampleMeshFloorCells?.(state._viewer?.scene, mesh.slice(i, i + 8), {
|
||||
excludeObjects: markers,
|
||||
...viewerSubpoint(),
|
||||
});
|
||||
// Cached completions can be adopted now; asynchronous completions are
|
||||
// adopted by the next bounded reread.
|
||||
for (const entry of entries) parts.trails.prepareEntry(entry);
|
||||
parts.rendering.requestVisibility();
|
||||
governorRequestRender('transit-floor');
|
||||
if (state._floorAttempts++ < FLOOR_REREAD_ATTEMPTS)
|
||||
state._floorTimer = setTimeout(rereadFloors, FLOOR_REREAD_MS);
|
||||
}
|
||||
|
||||
/** Start a fresh re-read cycle. Called once per poll, never per frame. */
|
||||
function anchorFloors() {
|
||||
if (state._floorTimer) {
|
||||
clearTimeout(state._floorTimer);
|
||||
state._floorTimer = null;
|
||||
}
|
||||
state._floorAttempts = 0;
|
||||
const now = performance.now();
|
||||
if (now - budgetAt >= TRANSIT_POLL_MS) {
|
||||
demUsed = new Set();
|
||||
meshUsed = 0;
|
||||
budgetAt = now;
|
||||
}
|
||||
rereadFloors();
|
||||
}
|
||||
|
||||
function clear() {
|
||||
budgetAt = -Infinity;
|
||||
cellCursors = new WeakMap();
|
||||
demUsed.clear();
|
||||
meshUsed = 0;
|
||||
if (state._floorTimer) {
|
||||
clearTimeout(state._floorTimer);
|
||||
state._floorTimer = null;
|
||||
}
|
||||
state._floorAttempts = 0;
|
||||
state._heightDirty.clear();
|
||||
}
|
||||
|
||||
return {
|
||||
nearGround,
|
||||
requestHeight,
|
||||
releaseVehicle,
|
||||
anchorFloors,
|
||||
rereadFloors,
|
||||
clear,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { createTrails } from './trails.js';
|
||||
import { createState } from './state.js';
|
||||
import { createHeight } from './height.js';
|
||||
import { createRendering } from './rendering.js';
|
||||
import { createSelection } from './selection.js';
|
||||
import { createIngestion } from './ingestion.js';
|
||||
import { createViewport } from './viewport.js';
|
||||
import { createQueries } from './queries.js';
|
||||
import { createControls } from './controls.js';
|
||||
import { createLifecycle } from './lifecycle.js';
|
||||
import { createTesting } from './testing.js';
|
||||
import { TRANSIT_POLL_MS } from './policy.js';
|
||||
|
||||
/**
|
||||
* Construct one Transit layer with its own scene state and supplied services.
|
||||
*
|
||||
* Nothing here is module-global: the viewer, the point collection, the vehicle
|
||||
* and height maps, the in-flight requests, the manager handle and the overlay
|
||||
* host all live on the instance this returns, and `destroy()` releases every
|
||||
* shared registration it took (sprite collection, pick owner, render hold,
|
||||
* camera sensitivity).
|
||||
*
|
||||
* @param {{services: object}} options
|
||||
* @returns {object} The data-layer module the manager registers.
|
||||
*/
|
||||
export function createTransitLayer({ services }) {
|
||||
if (!services?.overlays || !services?.render || !services?.sprites) {
|
||||
throw new TypeError(
|
||||
'A transit layer needs overlay, render and sprite services',
|
||||
);
|
||||
}
|
||||
const state = createState({ services });
|
||||
const parts = {};
|
||||
const context = { state, services, parts };
|
||||
parts.trails = createTrails(context);
|
||||
parts.height = createHeight(context);
|
||||
parts.selection = createSelection(context);
|
||||
parts.rendering = createRendering(context);
|
||||
parts.ingestion = createIngestion(context);
|
||||
parts.viewport = createViewport(context);
|
||||
parts.queries = createQueries(context);
|
||||
parts.controls = createControls(context);
|
||||
parts.lifecycle = createLifecycle(context);
|
||||
parts.testing = createTesting(context);
|
||||
|
||||
return Object.assign(
|
||||
{ updateInterval: TRANSIT_POLL_MS },
|
||||
parts.lifecycle.methods,
|
||||
parts.queries.methods,
|
||||
parts.controls.methods,
|
||||
parts.testing,
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
TRANSIT_MODE_COLORS,
|
||||
TRANSIT_POLL_MS,
|
||||
TRANSIT_SELECTED_OVERLAY_SOURCE_ID,
|
||||
TRANSIT_SELECTED_OVERLAY_SOURCE_OPTIONS,
|
||||
aggregateTransitFeedHealth,
|
||||
buildTransitSelectionCopy,
|
||||
createTransitSelectedOverlayEntry,
|
||||
interpolatedVehiclePosition,
|
||||
isStaleVehicleFix,
|
||||
transitDetectionClass,
|
||||
transitDetectionId,
|
||||
transitDetectionMetric,
|
||||
transitVehicleKey,
|
||||
vehicleFixAgeMs,
|
||||
} from './policy.js';
|
||||
@@ -0,0 +1,513 @@
|
||||
import {
|
||||
createLagReservoir,
|
||||
destroyTrack,
|
||||
FIX_FLAGS,
|
||||
trimTrack,
|
||||
} from '../../data/contactPlayback.js';
|
||||
import {
|
||||
FEED_EVICT_AFTER_MS,
|
||||
FEED_STALE_AFTER_MS,
|
||||
MAX_VEHICLES_TOTAL,
|
||||
MISSED_POLLS_TO_DROP,
|
||||
TRANSIT_POLL_MS,
|
||||
isStaleVehicleFix,
|
||||
transitVehicleKey,
|
||||
vehicleReportTimeMs,
|
||||
} from './policy.js';
|
||||
import {
|
||||
transitModeFor,
|
||||
transitModeResolved,
|
||||
} from '../../data/transitFeeds.js';
|
||||
import {
|
||||
attachFloorToPlace,
|
||||
initializePlayback,
|
||||
recordFix,
|
||||
updatePlayback,
|
||||
syncPlayback,
|
||||
} from './movement.js';
|
||||
|
||||
/**
|
||||
* How far ahead of the snapshot's own fetch time a vehicle stamp may sit
|
||||
* before it is a clock fault rather than a report. A stamp an hour ahead once
|
||||
* made every honest fix that followed read as out of order, so the marker
|
||||
* froze for as long as the clock stayed wrong. Beyond this the fetch time is
|
||||
* the honest bound.
|
||||
*/
|
||||
export const FUTURE_STAMP_TOLERANCE_MS = 10_000;
|
||||
/**
|
||||
* Consecutive out-of-order refusals after which the history is reset to the
|
||||
* incoming fix. A feed whose clock was corrected backwards would otherwise be
|
||||
* refused until real time caught up with its old mistake.
|
||||
*/
|
||||
export const ORDER_REJECTS_BEFORE_RESET = 3;
|
||||
|
||||
/**
|
||||
* Fetching feeds through the proxy and turning snapshots into rendered points.
|
||||
* @param {object} context
|
||||
* @returns {object}
|
||||
*/
|
||||
export function createIngestion({ state, services, parts }) {
|
||||
const { governorRequestRender } = services.render;
|
||||
const { registerDynamicCredit, transitFeedCredit } = services.credits;
|
||||
|
||||
function feedStatus(feedId) {
|
||||
let status = state._feedStatus.get(feedId);
|
||||
if (!status) {
|
||||
status = {
|
||||
count: 0,
|
||||
lastUpdate: null,
|
||||
upstreamAt: null,
|
||||
receivedAt: null,
|
||||
error: null,
|
||||
retryInSec: null,
|
||||
stale: false,
|
||||
pollSeq: 0,
|
||||
loading: false,
|
||||
};
|
||||
state._feedStatus.set(feedId, status);
|
||||
}
|
||||
return status;
|
||||
}
|
||||
|
||||
function removeVehicle(key) {
|
||||
const entry = state._vehicles.get(key);
|
||||
if (!entry) return;
|
||||
if (state._selectedKey === key) parts.selection.clearSelection();
|
||||
if (entry.marker) entry.markerCollection?.remove(entry.marker);
|
||||
entry.marker = null;
|
||||
entry.detectContact = null;
|
||||
state._visible.delete(entry);
|
||||
parts.height.releaseVehicle(key, entry.heightCell);
|
||||
// A vehicle removed mid-glide takes its share of the hold with it, so the
|
||||
// set stays true without waiting for a frame that may never be requested.
|
||||
state._moving.delete(entry);
|
||||
state._heightDirty.delete(entry);
|
||||
parts.rendering.cancelWake(entry);
|
||||
destroyTrack(entry.track);
|
||||
state._vehicles.delete(key);
|
||||
state._detectRevision += 1;
|
||||
}
|
||||
|
||||
function removeFeedVehicles(feedId) {
|
||||
let removed = 0;
|
||||
for (const [key, entry] of state._vehicles) {
|
||||
if (entry.feedId === feedId) {
|
||||
removeVehicle(key);
|
||||
removed += 1;
|
||||
}
|
||||
}
|
||||
if (removed > 0) parts.rendering.syncRenderHold();
|
||||
return removed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop what has aged out, whatever the reason.
|
||||
*
|
||||
* Two independent clocks matter. A single vehicle can go quiet inside a feed
|
||||
* that is otherwise healthy, and a whole feed can stop answering while its
|
||||
* last snapshot still sits on the globe. The proxy serves a stale snapshot
|
||||
* for up to ten minutes and then stops answering at all, and before this
|
||||
* sweep existed that silence left the last fleet frozen in place, indefinitely
|
||||
* and unlabelled. Both cases now end in removal.
|
||||
*
|
||||
* @param {number} nowMs
|
||||
* @returns {number} Vehicles removed.
|
||||
*/
|
||||
function sweepAgedVehicles(nowMs) {
|
||||
let removed = 0;
|
||||
const deadFeeds = new Set();
|
||||
for (const [feedId, status] of state._feedStatus) {
|
||||
// `upstreamAt` — when the OPERATOR last answered — not when this browser
|
||||
// last received bytes. The proxy hands back its cached copy every 15 s
|
||||
// during an outage, and counting those as updates meant a dead feed never
|
||||
// aged at all.
|
||||
if (
|
||||
Number.isFinite(status.upstreamAt) &&
|
||||
nowMs - status.upstreamAt > FEED_EVICT_AFTER_MS
|
||||
) {
|
||||
deadFeeds.add(feedId);
|
||||
}
|
||||
}
|
||||
for (const [key, entry] of state._vehicles) {
|
||||
if (
|
||||
deadFeeds.has(entry.feedId) ||
|
||||
isStaleVehicleFix(entry.record, nowMs, entry.fetchedAt)
|
||||
) {
|
||||
removeVehicle(key);
|
||||
removed += 1;
|
||||
}
|
||||
}
|
||||
if (removed > 0) {
|
||||
for (const feedId of deadFeeds) {
|
||||
const status = state._feedStatus.get(feedId);
|
||||
if (status) {
|
||||
status.count = 0;
|
||||
status.stale = true;
|
||||
}
|
||||
}
|
||||
// Removing the last vehicle is the moment the layer stops having work.
|
||||
// Nothing else would notice: the per-frame pass returns early on an empty
|
||||
// fleet, so the hold would sit there held by a fleet that no longer exists.
|
||||
parts.rendering.syncRenderHold();
|
||||
governorRequestRender('transit-evict');
|
||||
state._dataManager?.refreshLayerStats?.();
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
function applySnapshot(feed, snapshot, { stale, contactedAt = null }) {
|
||||
const now = Date.now();
|
||||
// When the OPERATOR produced these positions, not when we received the
|
||||
// bytes. A snapshot replayed from the proxy's cache carries its original
|
||||
// fetch time, and that is the only honest clock for "how old are these
|
||||
// buses".
|
||||
const fetchedAt = Math.min(
|
||||
Number.isFinite(snapshot?.fetchedAt) ? snapshot.fetchedAt : now,
|
||||
now,
|
||||
);
|
||||
// When the operator last ANSWERED, which is a different question. A feed
|
||||
// whose file has not changed answers 304, and the proxy rightly keeps
|
||||
// serving the body it already holds — but that body's fetch time stops
|
||||
// advancing, so reading it as "when did we last hear from them" aged a
|
||||
// perfectly healthy feed into DEGRADED at ninety seconds and emptied it at
|
||||
// five minutes while every single request was succeeding. The proxy
|
||||
// reports its last successful contact separately; absent that header the
|
||||
// fetch time is the best we know.
|
||||
const contacted = Math.min(
|
||||
Number.isFinite(contactedAt) ? contactedAt : fetchedAt,
|
||||
now,
|
||||
);
|
||||
const status = feedStatus(feed.id);
|
||||
const upstreamAgeMs = now - Math.max(contacted, fetchedAt);
|
||||
status.upstreamAt = Math.max(contacted, fetchedAt);
|
||||
status.receivedAt = now;
|
||||
status.loading = false;
|
||||
status.error = null;
|
||||
status.retryInSec = null;
|
||||
status.stale = stale === true || upstreamAgeMs > FEED_STALE_AFTER_MS;
|
||||
|
||||
// Past the eviction window the answer is bytes, not news. Drawing them
|
||||
// would put a five-minute-old fleet on the globe with a fresh-looking row.
|
||||
if (upstreamAgeMs > FEED_EVICT_AFTER_MS) {
|
||||
removeFeedVehicles(feed.id);
|
||||
status.count = 0;
|
||||
parts.rendering.syncRenderHold();
|
||||
governorRequestRender('transit-stale-refused');
|
||||
state._dataManager?.refreshLayerStats?.();
|
||||
return;
|
||||
}
|
||||
|
||||
status.pollSeq += 1;
|
||||
const pollSeq = status.pollSeq;
|
||||
let seen = 0;
|
||||
let rejectedFixes = 0;
|
||||
|
||||
const records = snapshot.vehicles || [];
|
||||
if (!status.lag) status.lag = createLagReservoir();
|
||||
const replayed = status.snapshotAt === fetchedAt || stale === true;
|
||||
status.snapshotAt = fetchedAt;
|
||||
const monoNow = performance.now();
|
||||
// Under pressure, release hidden retention before visible histories.
|
||||
if (
|
||||
state._historyBudget.allocatedBytes >
|
||||
state._historyBudget.maxBytes * 0.9
|
||||
) {
|
||||
for (const vehicle of state._vehicles.values()) {
|
||||
if (!vehicle.marker?.show && vehicle.key !== state._selectedKey)
|
||||
trimTrack(vehicle.track, now, 2);
|
||||
}
|
||||
}
|
||||
|
||||
for (const record of records) {
|
||||
if (isStaleVehicleFix(record, now, fetchedAt)) continue;
|
||||
const key = transitVehicleKey(feed.id, record.id);
|
||||
const mode = transitModeFor(feed, record.routeId);
|
||||
const modeInferred = !transitModeResolved(feed, record.routeId);
|
||||
// A stamp ahead of the fetch is a clock fault; the fetch time bounds it.
|
||||
const stamped = vehicleReportTimeMs(record, fetchedAt);
|
||||
const reportAt =
|
||||
stamped > fetchedAt + FUTURE_STAMP_TOLERANCE_MS ? fetchedAt : stamped;
|
||||
const fix = {
|
||||
t: reportAt,
|
||||
lat: record.lat,
|
||||
lon: record.lon,
|
||||
bearingDeg: Number.isFinite(record.bearing) ? record.bearing : NaN,
|
||||
flags:
|
||||
stamped !== reportAt
|
||||
? FIX_FLAGS.RECEIPT_TIME
|
||||
: record.timestampSource === 'vehicle'
|
||||
? FIX_FLAGS.VEHICLE_TIME
|
||||
: record.timestampSource === 'header'
|
||||
? FIX_FLAGS.FEED_TIME
|
||||
: FIX_FLAGS.RECEIPT_TIME,
|
||||
};
|
||||
let entry = state._vehicles.get(key);
|
||||
if (entry) {
|
||||
const heard = recordFix(entry, fix, {
|
||||
receivedAt: now,
|
||||
monoNowMs: monoNow,
|
||||
wallNowMs: now,
|
||||
replayed,
|
||||
modeInferred,
|
||||
context: {
|
||||
trip: record.tripId || '',
|
||||
route: record.routeId || '',
|
||||
mode,
|
||||
},
|
||||
});
|
||||
if (!heard.accepted) {
|
||||
entry.pollSeq = pollSeq;
|
||||
seen += 1;
|
||||
if (heard.reason !== 'repeat') rejectedFixes += 1;
|
||||
continue;
|
||||
}
|
||||
if (entry.mode !== mode) parts.rendering.paintMode(entry, mode);
|
||||
entry.modeInferred = modeInferred;
|
||||
updatePlayback(entry, now, monoNow);
|
||||
parts.rendering.schedulePlayback(entry);
|
||||
// Ground follows the vehicle, every poll: a mesh floor landing over a
|
||||
// DEM one, or a change of map regime, moves a parked vehicle too. A
|
||||
// cell still cold takes the lowest neighbour as a prior, as before.
|
||||
const priorHeightM = entry.heightM;
|
||||
const height = parts.height.requestHeight(record.lat, record.lon);
|
||||
if (height.height !== null) {
|
||||
entry.heightCell = height.cell;
|
||||
entry.heightM = height.height;
|
||||
entry.heightResolved = true;
|
||||
entry.heightPending = false;
|
||||
} else if (height.cell !== entry.heightCell || !entry.heightResolved) {
|
||||
entry.heightCell = height.cell;
|
||||
entry.heightResolved = false;
|
||||
if (height.prior !== null) {
|
||||
entry.heightM = height.prior;
|
||||
entry.heightPending = false;
|
||||
}
|
||||
}
|
||||
// The floor belongs to the FIX it was asked for: the display may still
|
||||
// be drawing an older fix on lower ground, and lifting that one onto
|
||||
// the newest fix's surface is the flew-up-then-travelled jump. The
|
||||
// frame pass owns the move, for a moving vehicle and a parked one
|
||||
// alike.
|
||||
const newest = entry.fixes[entry.fixes.length - 1];
|
||||
const stood = newest.h;
|
||||
if (height.height !== null) {
|
||||
attachFloorToPlace(entry, newest, height.height);
|
||||
}
|
||||
if (entry.heightM !== priorHeightM || newest.h !== stood) {
|
||||
state._heightDirty.add(entry);
|
||||
}
|
||||
} else {
|
||||
if (state._vehicles.size >= MAX_VEHICLES_TOTAL) {
|
||||
if (!state._limitWarned) {
|
||||
state._limitWarned = true;
|
||||
console.warn(
|
||||
`[Data:Transit] vehicle cap ${MAX_VEHICLES_TOTAL} reached — extra vehicles are not rendered`,
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const height = parts.height.requestHeight(record.lat, record.lon);
|
||||
// Keep the local prior separate from resolved historical floors.
|
||||
// A neighbouring cell is not a guaranteed lower bound.
|
||||
const prior = height.height === null ? height.prior : height.height;
|
||||
entry = {
|
||||
key,
|
||||
feedId: feed.id,
|
||||
marker: null,
|
||||
mode,
|
||||
modeInferred,
|
||||
heightM: prior === null ? 0 : prior,
|
||||
heightCell: height.cell,
|
||||
heightResolved: height.height !== null,
|
||||
// Nothing trustworthy to stand on and no prior to borrow: the
|
||||
// vehicle waits one sample rather than floating.
|
||||
heightPending: height.height === null && prior === null,
|
||||
from: null,
|
||||
to: null,
|
||||
fromCart: null,
|
||||
toCart: null,
|
||||
playT: NaN,
|
||||
resets: 0,
|
||||
courseDeg: Number.isFinite(record.bearing) ? record.bearing : null,
|
||||
courseAt: now,
|
||||
courseEvalAt: monoNow,
|
||||
record,
|
||||
fetchedAt,
|
||||
pollSeq,
|
||||
};
|
||||
initializePlayback(entry, state._historyBudget, status.lag);
|
||||
const first = recordFix(entry, fix, {
|
||||
receivedAt: now,
|
||||
monoNowMs: monoNow,
|
||||
wallNowMs: now,
|
||||
replayed,
|
||||
modeInferred,
|
||||
context: {
|
||||
trip: record.tripId || '',
|
||||
route: record.routeId || '',
|
||||
mode,
|
||||
},
|
||||
});
|
||||
if (!first.accepted) {
|
||||
destroyTrack(entry.track);
|
||||
continue;
|
||||
}
|
||||
if (height.height !== null)
|
||||
attachFloorToPlace(entry, fix, height.height);
|
||||
// A new vehicle holds at its only fix; its clock starts a full lag
|
||||
// behind, so by the time playback reaches the fix the next one has
|
||||
// arrived and the segment can be drawn at 1x.
|
||||
syncPlayback(entry, now, monoNow);
|
||||
entry.from = entry.segment.from;
|
||||
entry.to = entry.segment.to;
|
||||
parts.rendering.refreshEndpoints(entry);
|
||||
entry.marker = parts.rendering.addMarker(key, entry);
|
||||
state._vehicles.set(key, entry);
|
||||
parts.rendering.schedulePlayback(entry);
|
||||
if (entry.courseDeg !== null) state._rotationDirty = true;
|
||||
}
|
||||
entry.record = record;
|
||||
entry.fetchedAt = fetchedAt;
|
||||
entry.pollSeq = pollSeq;
|
||||
seen += 1;
|
||||
}
|
||||
|
||||
// Drop vehicles this feed stopped reporting.
|
||||
for (const [key, entry] of state._vehicles) {
|
||||
if (
|
||||
entry.feedId === feed.id &&
|
||||
pollSeq - entry.pollSeq >= MISSED_POLLS_TO_DROP
|
||||
) {
|
||||
removeVehicle(key);
|
||||
}
|
||||
}
|
||||
sweepAgedVehicles(now);
|
||||
|
||||
status.count = seen;
|
||||
status.lastUpdate = fetchedAt;
|
||||
status.receivedAt = now;
|
||||
// The layer-level timestamp is the freshest UPSTREAM time across feeds, so
|
||||
// the panel's "just now" means the positions are from just now.
|
||||
state._lastUpdate =
|
||||
Math.max(state._lastUpdate || 0, fetchedAt) || fetchedAt;
|
||||
state._error = null;
|
||||
if (rejectedFixes > 0 && !state._jumpWarned) {
|
||||
state._jumpWarned = true;
|
||||
console.warn(
|
||||
`[Data:Transit] ${feed.id}: ${rejectedFixes} fix(es) implied impossible travel and were not animated`,
|
||||
);
|
||||
}
|
||||
if (state._viewer)
|
||||
registerDynamicCredit(state._viewer, transitFeedCredit(feed));
|
||||
if (
|
||||
state._selectedKey &&
|
||||
state._vehicles.get(state._selectedKey)?.feedId === feed.id
|
||||
) {
|
||||
parts.selection.refreshSelectedCard(true);
|
||||
}
|
||||
// Records changed hands this poll, so DETECT's list is rebuilt once —
|
||||
// the visibility sweep below bumps the revision.
|
||||
// One floor cycle per poll, on its own timer: warming is a network round
|
||||
// trip and must never sit on the render path.
|
||||
parts.height.anchorFloors();
|
||||
state._detectRevision++;
|
||||
parts.rendering.requestVisibility();
|
||||
parts.rendering.syncRenderHold();
|
||||
governorRequestRender('transit-poll');
|
||||
state._dataManager?.refreshLayerStats?.();
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll one feed. A request already in flight is younger than one poll
|
||||
* interval, so a second caller (enable() and the manager's first update()
|
||||
* both ask within the same tick) awaits that request instead of aborting it —
|
||||
* the manager's first update then settles with data on the globe.
|
||||
* @param {object} feed Registry entry.
|
||||
* @param {number} generation Enable generation the poll belongs to.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
function pollFeed(feed, generation) {
|
||||
if (!state._enabled || generation !== state._generation)
|
||||
return Promise.resolve();
|
||||
const existing = state._inFlight.get(feed.id);
|
||||
if (existing) return existing.promise;
|
||||
const controller = new AbortController();
|
||||
const status = feedStatus(feed.id);
|
||||
status.loading = status.count === 0;
|
||||
const promise = (async () => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/transit/vehicles/${encodeURIComponent(feed.id)}`,
|
||||
{
|
||||
signal: controller.signal,
|
||||
headers: { Accept: 'application/json' },
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
// The proxy says when it will next try the operator; carrying that
|
||||
// through means the row can offer a time instead of just a shrug.
|
||||
const detail = await response.json().catch(() => null);
|
||||
const error = new Error(`transit proxy HTTP ${response.status}`);
|
||||
if (Number.isFinite(detail?.retryInSec)) {
|
||||
error.retryInSec = detail.retryInSec;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const snapshot = await response.json();
|
||||
if (
|
||||
!state._enabled ||
|
||||
generation !== state._generation ||
|
||||
!state._activeFeeds.has(feed.id)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const contactHeader = Number.parseInt(
|
||||
response.headers.get('x-transit-contact') || '',
|
||||
10,
|
||||
);
|
||||
applySnapshot(feed, snapshot, {
|
||||
stale: response.headers.get('x-gev-cache') === 'STALE-ERROR',
|
||||
contactedAt: Number.isFinite(contactHeader) ? contactHeader : null,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error?.name === 'AbortError') return;
|
||||
if (generation !== state._generation) return;
|
||||
console.warn(
|
||||
`[Data:Transit] ${feed.id} poll failed:`,
|
||||
error?.message || error,
|
||||
);
|
||||
status.error = `${feed.name} feed unavailable`;
|
||||
status.retryInSec = Number.isFinite(error?.retryInSec)
|
||||
? error.retryInSec
|
||||
: null;
|
||||
status.loading = false;
|
||||
state._error = status.error;
|
||||
// A failed poll is exactly when old vehicles must be re-examined:
|
||||
// nothing else will tell the globe this feed has gone quiet.
|
||||
sweepAgedVehicles(Date.now());
|
||||
state._dataManager?.refreshLayerStats?.();
|
||||
} finally {
|
||||
if (state._inFlight.get(feed.id)?.controller === controller) {
|
||||
state._inFlight.delete(feed.id);
|
||||
}
|
||||
}
|
||||
})();
|
||||
state._inFlight.set(feed.id, { controller, promise });
|
||||
return promise;
|
||||
}
|
||||
|
||||
function abortAllInFlight() {
|
||||
for (const { controller } of state._inFlight.values()) controller.abort();
|
||||
state._inFlight.clear();
|
||||
}
|
||||
|
||||
return {
|
||||
feedStatus,
|
||||
removeVehicle,
|
||||
removeFeedVehicles,
|
||||
sweepAgedVehicles,
|
||||
applySnapshot,
|
||||
pollFeed,
|
||||
abortAllInFlight,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
import { destroyTrack } from '../../data/contactPlayback.js';
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
claimCameraSensitivity,
|
||||
releaseCameraSensitivity,
|
||||
} from '../../data/cameraSensitivity.js';
|
||||
import {
|
||||
CAMERA_PERCENTAGE_CHANGED,
|
||||
TRANSIT_SELECTED_OVERLAY_SOURCE_ID,
|
||||
} from './policy.js';
|
||||
import { TRANSIT_ENABLED_FEEDS } from '../../data/transitFeeds.js';
|
||||
|
||||
/**
|
||||
* Init / enable / disable / update / destroy for one Transit layer instance.
|
||||
* Everything acquired here is released here — primitives, listeners, the sprite
|
||||
* registration, the pick owner, the render hold, and the camera sensitivity
|
||||
* this layer borrowed.
|
||||
* @param {object} context
|
||||
* @returns {object}
|
||||
*/
|
||||
export function createLifecycle({ state, services, parts }) {
|
||||
const {
|
||||
registerSpriteCollection,
|
||||
unregisterSpriteCollection,
|
||||
restoreSpriteOrder,
|
||||
} = services.sprites;
|
||||
const { registerPickOwner, unregisterPickOwner } = services.picking;
|
||||
const { releaseContinuousRender } = services.render;
|
||||
|
||||
/**
|
||||
* Borrow the camera's change sensitivity through the shared claim ledger.
|
||||
*
|
||||
* `camera.changed` fires on a percentage of the view moving, and this layer
|
||||
* needs a finer trigger than the default to notice it has entered a feed's
|
||||
* coverage. It is a single shared number, so the claim is registered by name
|
||||
* rather than written directly: comparing the VALUE on the way out could not
|
||||
* tell this layer's 0.05 from anybody else's, and handing back the coarse
|
||||
* default while Bikeshare was still relying on a fine one was exactly the
|
||||
* bug that produced.
|
||||
*/
|
||||
function borrowCameraSensitivity(viewer) {
|
||||
claimCameraSensitivity(
|
||||
viewer?.camera,
|
||||
'transit',
|
||||
CAMERA_PERCENTAGE_CHANGED,
|
||||
);
|
||||
}
|
||||
|
||||
/** Give the claim up. The camera keeps whatever anyone else still asks for. */
|
||||
function returnCameraSensitivity(viewer) {
|
||||
releaseCameraSensitivity(viewer?.camera, 'transit');
|
||||
}
|
||||
|
||||
/**
|
||||
* Follow the active post-FX style so the sprites can be restyled for it.
|
||||
* The map style arrives on `gev:style-change`; a cockpit vision override
|
||||
* (NVG/thermal inside the cockpit view, which sets no map style) arrives on
|
||||
* `gev:vision-change` with the effective style and wins while it lasts.
|
||||
* Bound once per instance and removed on destroy. Read the document's
|
||||
* current style first: a persisted style restores before layers register.
|
||||
*/
|
||||
function bindStyleEvents() {
|
||||
if (typeof window === 'undefined') return;
|
||||
if (state._styleListeners.length > 0) return;
|
||||
parts.rendering.setStylePreset(
|
||||
document?.documentElement?.dataset?.gevStyle || 'normal',
|
||||
);
|
||||
const onStyle = (event) => {
|
||||
if (state._cockpitVision) return;
|
||||
parts.rendering.setStylePreset(event?.detail?.style);
|
||||
};
|
||||
const onVision = (event) => {
|
||||
state._cockpitVision = event?.detail?.cockpit === true;
|
||||
parts.rendering.setStylePreset(event?.detail?.style);
|
||||
};
|
||||
window.addEventListener('gev:style-change', onStyle);
|
||||
window.addEventListener('gev:vision-change', onVision);
|
||||
state._styleListeners.push(
|
||||
['gev:style-change', onStyle],
|
||||
['gev:vision-change', onVision],
|
||||
);
|
||||
}
|
||||
|
||||
function unbindStyleEvents() {
|
||||
if (typeof window === 'undefined') return;
|
||||
for (const [name, listener] of state._styleListeners) {
|
||||
window.removeEventListener(name, listener);
|
||||
}
|
||||
state._styleListeners.length = 0;
|
||||
state._cockpitVision = false;
|
||||
}
|
||||
|
||||
let visibilityDocument = null,
|
||||
hidden = false;
|
||||
function onVisibilityChange() {
|
||||
if (visibilityDocument.visibilityState === 'hidden') hidden = true;
|
||||
else if (hidden) {
|
||||
hidden = false;
|
||||
parts.rendering.resumePlayback();
|
||||
}
|
||||
}
|
||||
function bindVisibility() {
|
||||
if (visibilityDocument || typeof document === 'undefined') return;
|
||||
visibilityDocument = document;
|
||||
hidden = document.visibilityState === 'hidden';
|
||||
visibilityDocument.addEventListener('visibilitychange', onVisibilityChange);
|
||||
}
|
||||
function unbindVisibility() {
|
||||
visibilityDocument?.removeEventListener(
|
||||
'visibilitychange',
|
||||
onVisibilityChange,
|
||||
);
|
||||
visibilityDocument = null;
|
||||
hidden = false;
|
||||
}
|
||||
|
||||
const methods = {
|
||||
id: 'transit',
|
||||
name: 'Transit',
|
||||
icon: '🚌',
|
||||
source: 'GTFS-RT',
|
||||
|
||||
/**
|
||||
* Create the point collection. Called once at bootstrap.
|
||||
* @param {Cesium.Viewer} viewer
|
||||
*/
|
||||
init(viewer) {
|
||||
state._viewer = viewer;
|
||||
state._markers = new Cesium.BillboardCollection({
|
||||
blendOption: Cesium.BlendOption.TRANSLUCENT,
|
||||
});
|
||||
viewer.scene.primitives.add(state._markers);
|
||||
registerSpriteCollection('transit', state._markers);
|
||||
state._animatedMarkers = new Cesium.BillboardCollection({
|
||||
blendOption: Cesium.BlendOption.TRANSLUCENT,
|
||||
});
|
||||
viewer.scene.primitives.add(state._animatedMarkers);
|
||||
registerSpriteCollection('transit-motion', state._animatedMarkers);
|
||||
state._animatedMarkers.show = false;
|
||||
state._markers.show = false;
|
||||
state._enabled = false;
|
||||
state._activeFeeds.clear();
|
||||
state._feedStatus.clear();
|
||||
state._inFlight.clear();
|
||||
for (const entry of state._vehicles.values()) {
|
||||
entry.detectContact = null;
|
||||
parts.rendering.cancelWake(entry);
|
||||
destroyTrack(entry.track);
|
||||
}
|
||||
state._vehicles.clear();
|
||||
parts.height.clear();
|
||||
state._selectedKey = null;
|
||||
state._lastUpdate = null;
|
||||
state._error = null;
|
||||
state._limitWarned = false;
|
||||
state._altitudeGateOpen = false;
|
||||
state._moving.clear();
|
||||
state._heightDirty.clear();
|
||||
state._rotationPose = null;
|
||||
state._rotationDirty = false;
|
||||
state._overlayHost.setVisible(TRANSIT_SELECTED_OVERLAY_SOURCE_ID, false);
|
||||
restoreSpriteOrder(viewer);
|
||||
bindStyleEvents();
|
||||
console.log(
|
||||
`[Data:Transit] Initialized with ${TRANSIT_ENABLED_FEEDS.length} GTFS-RT feeds`,
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* Show vehicles, watch the camera, and poll every feed in range.
|
||||
* @param {Cesium.Viewer} viewer
|
||||
*/
|
||||
enable(viewer) {
|
||||
state._enabled = true;
|
||||
bindVisibility();
|
||||
state._generation += 1;
|
||||
state._error = null;
|
||||
state._markers.show = true;
|
||||
state._animatedMarkers.show = true;
|
||||
state._maintenanceTimer = setInterval(
|
||||
parts.rendering.maintainPresentation,
|
||||
250,
|
||||
);
|
||||
state._overlayHost.setVisible(TRANSIT_SELECTED_OVERLAY_SOURCE_ID, true);
|
||||
parts.selection.installClickHandler(viewer);
|
||||
registerPickOwner('transit', (pickedId) => state._vehicles.has(pickedId));
|
||||
if (!state._cameraChangedAttached) {
|
||||
viewer.camera.changed.addEventListener(parts.viewport.onCameraChanged);
|
||||
borrowCameraSensitivity(viewer);
|
||||
state._cameraChangedAttached = true;
|
||||
}
|
||||
if (!state._preRenderRemove) {
|
||||
state._preRenderRemove = viewer.scene.preRender.addEventListener(
|
||||
parts.rendering.onPreRender,
|
||||
);
|
||||
}
|
||||
parts.viewport.runProximityCheck();
|
||||
restoreSpriteOrder(viewer);
|
||||
},
|
||||
|
||||
/**
|
||||
* Hide everything, stop polling, drop all vehicles.
|
||||
* @param {Cesium.Viewer} viewer
|
||||
*/
|
||||
disable(viewer) {
|
||||
state._enabled = false;
|
||||
unbindVisibility();
|
||||
parts.testing._stopTransitProbeForTest();
|
||||
state._qaFixtureFloors?.clear();
|
||||
state._generation += 1;
|
||||
clearInterval(state._maintenanceTimer);
|
||||
state._maintenanceTimer = null;
|
||||
clearTimeout(state._visibilityTimer);
|
||||
state._visibilityTimer = null;
|
||||
state._detectCache = null;
|
||||
state._detectBuiltAt = -Infinity;
|
||||
state._visible.clear();
|
||||
clearTimeout(state._cameraDebounceTimer);
|
||||
state._cameraDebounceTimer = null;
|
||||
parts.selection.clearSelection();
|
||||
state._overlayHost.setVisible(TRANSIT_SELECTED_OVERLAY_SOURCE_ID, false);
|
||||
parts.selection.removeClickHandler();
|
||||
unregisterPickOwner('transit');
|
||||
if (state._cameraChangedAttached) {
|
||||
viewer.camera.changed.removeEventListener(
|
||||
parts.viewport.onCameraChanged,
|
||||
);
|
||||
returnCameraSensitivity(viewer);
|
||||
state._cameraChangedAttached = false;
|
||||
}
|
||||
if (state._preRenderRemove) {
|
||||
state._preRenderRemove();
|
||||
state._preRenderRemove = null;
|
||||
}
|
||||
parts.ingestion.abortAllInFlight();
|
||||
state._activeFeeds.clear();
|
||||
state._feedStatus.clear();
|
||||
for (const entry of state._vehicles.values()) {
|
||||
entry.detectContact = null;
|
||||
parts.rendering.cancelWake(entry);
|
||||
destroyTrack(entry.track);
|
||||
}
|
||||
state._vehicles.clear();
|
||||
parts.height.clear();
|
||||
state._markers?.removeAll();
|
||||
state._animatedMarkers?.removeAll();
|
||||
if (state._animatedMarkers) state._animatedMarkers.show = false;
|
||||
if (state._markers) state._markers.show = false;
|
||||
state._altitudeGateOpen = false;
|
||||
state._moving.clear();
|
||||
state._heightDirty.clear();
|
||||
state._rotationPose = null;
|
||||
state._rotationDirty = false;
|
||||
if (state._renderHeld) {
|
||||
releaseContinuousRender('transit');
|
||||
state._renderHeld = false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Manager tick (every TRANSIT_POLL_MS): re-poll every active feed, and
|
||||
* sweep aged vehicles even when nothing is active, so a layer left over a
|
||||
* quiet region does not keep an old fleet on screen.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async update() {
|
||||
if (!state._enabled) return;
|
||||
parts.ingestion.sweepAgedVehicles(Date.now());
|
||||
if (state._activeFeeds.size === 0) return;
|
||||
const generation = state._generation;
|
||||
await Promise.all(
|
||||
[...state._activeFeeds.values()].map((feed) =>
|
||||
parts.ingestion.pollFeed(feed, generation),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* Tear down the collection entirely and release every shared registration.
|
||||
* @param {Cesium.Viewer} viewer
|
||||
*/
|
||||
destroy(viewer) {
|
||||
this.disable(viewer);
|
||||
if (state._markers) {
|
||||
unregisterSpriteCollection('transit', state._markers);
|
||||
viewer?.scene?.primitives?.remove(state._markers);
|
||||
state._markers = null;
|
||||
}
|
||||
if (state._animatedMarkers) {
|
||||
unregisterSpriteCollection('transit-motion', state._animatedMarkers);
|
||||
viewer?.scene?.primitives?.remove(state._animatedMarkers);
|
||||
state._animatedMarkers = null;
|
||||
}
|
||||
state._overlayHost.clearSource(TRANSIT_SELECTED_OVERLAY_SOURCE_ID);
|
||||
parts.height.clear();
|
||||
unbindStyleEvents();
|
||||
state._dataManager = null;
|
||||
state._viewer = null;
|
||||
},
|
||||
};
|
||||
|
||||
return { methods };
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,313 @@
|
||||
/** Transit admission and orientation policy; storage and clocks are portable. */
|
||||
import {
|
||||
createTrack,
|
||||
createSample,
|
||||
createFixSample,
|
||||
insertFix,
|
||||
advance,
|
||||
readFix,
|
||||
correctHeight,
|
||||
setTimeOffsetMs,
|
||||
} from '../../data/contactPlayback.js';
|
||||
export const FIX_HISTORY_MAX = 128;
|
||||
export const DISPLAY_LAG_MIN_MS = 25_000;
|
||||
export const DISPLAY_LAG_RESET_MS = 90_000;
|
||||
export const MODE_MAX_SPEED_MPS = Object.freeze({
|
||||
bus: 36, // ~130 km/h — a coach on a motorway
|
||||
tram: 28, // ~100 km/h — interurban light rail
|
||||
subway: 42, // ~150 km/h
|
||||
rail: 111, // ~400 km/h — high-speed services exist on some feeds
|
||||
ferry: 25, // ~90 km/h — fast catamarans
|
||||
unknown: 56, // ~200 km/h
|
||||
});
|
||||
|
||||
/**
|
||||
* The ceiling a fix is judged against. A mode a feed DEFAULTED to is a guess
|
||||
* about a fleet, not a fact about a vehicle: TransLink publishes rail in a
|
||||
* feed whose default is bus, and a bus ceiling refused its 140 km/h services
|
||||
* as impossible and placed them instead of animating them. An inferred mode
|
||||
* gets the permissive ceiling; only a mode the route id actually established
|
||||
* gets its own.
|
||||
* @param {string} mode
|
||||
* @param {boolean} [inferred] Whether the mode came from a feed default.
|
||||
* @returns {number} m/s
|
||||
*/
|
||||
export function speedCeilingMps(mode, inferred = false) {
|
||||
if (inferred) return MODE_MAX_SPEED_MPS.unknown;
|
||||
return MODE_MAX_SPEED_MPS[mode] ?? MODE_MAX_SPEED_MPS.unknown;
|
||||
}
|
||||
|
||||
/** Position deadband is disabled: even a short reported crawl is played. */
|
||||
export const FIX_NOISE_M = 0;
|
||||
|
||||
/**
|
||||
* Displacement (m) a glide must cover before its direction is worth believing.
|
||||
* Below this the course derived from it is noise; the last good one is held.
|
||||
*/
|
||||
export const COURSE_MIN_TRAVEL_M = 12;
|
||||
|
||||
/** Maximum display-course change per second (deg). A bus is not a gyroscope. */
|
||||
export const COURSE_MAX_RATE_DEG_PER_S = 120;
|
||||
/**
|
||||
* Longest stretch of elapsed time (s) one evaluation may spend. The limiter
|
||||
* used to bank the time since the course last CHANGED, so a vehicle that held
|
||||
* north for thirty seconds had thirty seconds of allowance and snapped through
|
||||
* a corner in one step. Elapsed time is now measured since the course was last
|
||||
* EVALUATED, and capped here so that even a first look after a long idle
|
||||
* sweeps visibly instead of snapping.
|
||||
*/
|
||||
export const COURSE_STEP_MAX_S = 0.25;
|
||||
|
||||
/**
|
||||
* Consecutive fixes at the same place before a vehicle is called STOPPED.
|
||||
* One is not enough: a fifteen-second gap between identical fixes is also what
|
||||
* a vehicle waiting at a light looks like, and so is a feed that repeated
|
||||
* itself.
|
||||
*/
|
||||
export const STOPPED_FIXES = 2;
|
||||
|
||||
/**
|
||||
* How long after its newest fix a vehicle stops being described as travelling.
|
||||
*
|
||||
* "En route" means the last two fixes are in different places, which is a fact
|
||||
* about the past. A bus that reported movement and then went quiet for three
|
||||
* minutes is not en route — we simply do not know what it is doing, and saying
|
||||
* otherwise is the same overreach as reading STOPPED off a stale status. Ninety
|
||||
* seconds matches the threshold at which a whole feed reads as gone quiet.
|
||||
*/
|
||||
export const MOTION_UNKNOWN_AFTER_MS = 90_000;
|
||||
|
||||
const EARTH_M_PER_DEG = 111_320;
|
||||
|
||||
/**
|
||||
* Metres between two lat/lon points, flat-earth. Over the few hundred metres a
|
||||
* surface vehicle covers between fixes this is exact to well under a metre.
|
||||
* @param {{lat:number, lon:number}} a
|
||||
* @param {{lat:number, lon:number}} b
|
||||
* @returns {number}
|
||||
*/
|
||||
export function fixDistanceM(a, b) {
|
||||
if (!a || !b) return 0;
|
||||
const dLat = (b.lat - a.lat) * EARTH_M_PER_DEG;
|
||||
const dLon =
|
||||
(b.lon - a.lon) * EARTH_M_PER_DEG * Math.cos((a.lat * Math.PI) / 180);
|
||||
return Math.hypot(dLat, dLon);
|
||||
}
|
||||
|
||||
/**
|
||||
* Course in degrees clockwise from north, from one point to another.
|
||||
* @param {{lat:number, lon:number}} from
|
||||
* @param {{lat:number, lon:number}} to
|
||||
* @returns {number} 0-360
|
||||
*/
|
||||
export function courseBetween(from, to) {
|
||||
const dLat = (to.lat - from.lat) * EARTH_M_PER_DEG;
|
||||
const dLon =
|
||||
(to.lon - from.lon) *
|
||||
EARTH_M_PER_DEG *
|
||||
Math.cos((from.lat * Math.PI) / 180);
|
||||
const deg = (Math.atan2(dLon, dLat) * 180) / Math.PI;
|
||||
return (deg + 360) % 360;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shortest signed difference between two courses, in degrees (-180, 180].
|
||||
* @param {number} from
|
||||
* @param {number} to
|
||||
* @returns {number}
|
||||
*/
|
||||
export function courseDelta(from, to) {
|
||||
let delta = ((to - from + 540) % 360) - 180;
|
||||
if (delta === -180) delta = 180;
|
||||
return delta;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a displacement between two fixes could have been travelled.
|
||||
* @param {string} mode
|
||||
* @param {number} meters
|
||||
* @param {number} seconds
|
||||
* @param {boolean} [inferred] The mode is a feed default, not a route fact.
|
||||
* @returns {boolean} False when the implied speed is impossible for the mode.
|
||||
*/
|
||||
export function displacementPlausible(mode, meters, seconds, inferred = false) {
|
||||
if (!(meters > 0)) return true;
|
||||
const ceiling = speedCeilingMps(mode, inferred);
|
||||
// Below a second of separation the implied speed is dominated by clock
|
||||
// resolution, so judge those against a flat displacement instead.
|
||||
if (!(seconds >= 1)) return meters <= ceiling;
|
||||
return meters / seconds <= ceiling;
|
||||
}
|
||||
|
||||
export function initializePlayback(entry, budget, feedLag) {
|
||||
entry.track = createTrack({
|
||||
budget,
|
||||
policy: {
|
||||
feedLag,
|
||||
courseMinTravelM: COURSE_MIN_TRAVEL_M,
|
||||
staleMs: MOTION_UNKNOWN_AFTER_MS,
|
||||
accept: (a, b) =>
|
||||
displacementPlausible(
|
||||
b.admissionMode ?? entry.mode,
|
||||
fixDistanceM(a, b),
|
||||
(b.t - a.t) / 1000,
|
||||
b.admissionModeInferred ?? entry.modeInferred,
|
||||
),
|
||||
},
|
||||
});
|
||||
entry.sample = createSample();
|
||||
entry.segment = {
|
||||
from: createFixSample(),
|
||||
to: createFixSample(),
|
||||
fraction: NaN,
|
||||
};
|
||||
entry.clocks = { wallNowMs: NaN, monoNowMs: NaN };
|
||||
// Inspection only. Scene animation reads the typed store into owned scratch.
|
||||
Object.defineProperty(entry, 'playT', {
|
||||
configurable: true,
|
||||
get: () => entry.sample.displayT,
|
||||
});
|
||||
Object.defineProperty(entry, 'lagTarget', {
|
||||
configurable: true,
|
||||
get: () => entry.track.targetDelayMs,
|
||||
});
|
||||
Object.defineProperty(entry, 'fixes', {
|
||||
configurable: true,
|
||||
get() {
|
||||
return Array.from({ length: entry.track.count }, (_, i) =>
|
||||
readFix(entry.track, i),
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
export function recordFix(entry, fix, observation = {}) {
|
||||
const context = observation.context;
|
||||
let epoch = entry.currentEpoch || 1;
|
||||
if (context) {
|
||||
const previous = entry.track.epochs.get(epoch);
|
||||
if (
|
||||
previous &&
|
||||
(previous.trip !== context.trip ||
|
||||
previous.route !== context.route ||
|
||||
previous.mode !== context.mode)
|
||||
)
|
||||
epoch = Math.max(0, ...entry.track.epochs.keys()) + 1;
|
||||
}
|
||||
const result = insertFix(
|
||||
entry.track,
|
||||
{
|
||||
...fix,
|
||||
epoch,
|
||||
admissionMode: context?.mode,
|
||||
admissionModeInferred: observation.modeInferred,
|
||||
},
|
||||
observation,
|
||||
);
|
||||
if (result.accepted) {
|
||||
const latest = readFix(
|
||||
entry.track,
|
||||
entry.track.count - 1,
|
||||
entry.track.latest,
|
||||
);
|
||||
entry.currentEpoch = latest.epoch;
|
||||
if (context) entry.track.epochs.set(latest.epoch, context);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
export function updatePlayback(entry, wallNowMs, monoNowMs) {
|
||||
entry.clocks.wallNowMs = wallNowMs;
|
||||
entry.clocks.monoNowMs = monoNowMs;
|
||||
advance(entry.track, entry.clocks, entry.sample);
|
||||
entry.resets = entry.track.resets;
|
||||
const segment = entry.segment;
|
||||
readFix(
|
||||
entry.track,
|
||||
entry.sample.fromSeq - entry.track.baseSeq,
|
||||
segment.from,
|
||||
);
|
||||
readFix(entry.track, entry.sample.toSeq - entry.track.baseSeq, segment.to);
|
||||
segment.fraction = entry.sample.fraction;
|
||||
return entry.sample;
|
||||
}
|
||||
export function syncPlayback(entry, wallNowMs, monoNowMs = performance.now()) {
|
||||
entry.clocks.wallNowMs = wallNowMs;
|
||||
entry.clocks.monoNowMs = monoNowMs;
|
||||
const resets = entry.track.resets;
|
||||
const reason = entry.track.resetReason;
|
||||
setTimeOffsetMs(entry.track, 0, entry.clocks);
|
||||
if (entry.hasRendered) entry.track.resetReason = 're-entry';
|
||||
else {
|
||||
entry.track.resets = resets;
|
||||
entry.track.resetReason = reason;
|
||||
}
|
||||
updatePlayback(entry, wallNowMs, monoNowMs);
|
||||
}
|
||||
export function playbackPosition(entry) {
|
||||
if (!entry?.track) return null;
|
||||
return {
|
||||
lat: entry.sample.lat,
|
||||
lon: entry.sample.lon,
|
||||
settled: entry.sample.phase !== 'playing',
|
||||
};
|
||||
}
|
||||
export function attachFloorToPlace(entry, fix, heightM) {
|
||||
if (!entry.track || !Number.isFinite(heightM)) return;
|
||||
const scratch = {};
|
||||
for (let i = 0; i < entry.track.count; i++) {
|
||||
readFix(entry.track, i, scratch);
|
||||
if (scratch.lat === fix.lat && scratch.lon === fix.lon)
|
||||
correctHeight(entry.track, scratch.seq, heightM);
|
||||
}
|
||||
}
|
||||
export function displayCourse(entry, nowMs) {
|
||||
if (!entry) return null;
|
||||
const target = Number.isFinite(entry.sample?.segmentCourseDeg)
|
||||
? entry.sample.segmentCourseDeg
|
||||
: null;
|
||||
if (target === null) return entry.courseDeg ?? null;
|
||||
if (entry.courseDeg === null || !Number.isFinite(entry.courseEvalAt)) {
|
||||
return target;
|
||||
}
|
||||
|
||||
// Rate-limit, the way the aircraft display course does: a fix arriving from
|
||||
// around a corner should sweep, not snap. Time since the last EVALUATION,
|
||||
// capped, so idle time is never banked into one big turn.
|
||||
const elapsedS = Math.min(
|
||||
COURSE_STEP_MAX_S,
|
||||
Math.max(0, (nowMs - entry.courseEvalAt) / 1000),
|
||||
);
|
||||
const allowed = COURSE_MAX_RATE_DEG_PER_S * elapsedS;
|
||||
const delta = courseDelta(entry.courseDeg, target);
|
||||
if (Math.abs(delta) <= allowed) return target;
|
||||
return (entry.courseDeg + Math.sign(delta) * allowed + 360) % 360;
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate the display course and write it back — the ONE writer both the
|
||||
* poll and the rotation pass go through, so the limiter sees every look.
|
||||
* @param {object} entry
|
||||
* @param {number} nowMs
|
||||
* @returns {boolean} Whether the course changed.
|
||||
*/
|
||||
export function applyDisplayCourse(entry, nowMs) {
|
||||
const course = displayCourse(entry, nowMs);
|
||||
entry.courseEvalAt = nowMs;
|
||||
if (course === entry.courseDeg) return false;
|
||||
entry.courseDeg = course;
|
||||
entry.courseAt = nowMs;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function displayMotion(entry, nowMs) {
|
||||
if (!entry) return { moving: false, word: '' };
|
||||
const sample = entry.sample;
|
||||
if (!sample) return { moving: false, word: 'WAITING' };
|
||||
if (sample.latestReportAgeMs > MOTION_UNKNOWN_AFTER_MS)
|
||||
return { moving: false, word: 'NO FIX' };
|
||||
if (sample.phase !== 'playing') return { moving: false, word: 'WAITING' };
|
||||
if (sample.motion === 'stopped') return { moving: false, word: 'STOPPED' };
|
||||
return {
|
||||
moving: sample.motion === 'moving',
|
||||
word: sample.motion === 'moving' ? 'EN ROUTE' : 'WAITING',
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
initializePlayback,
|
||||
recordFix,
|
||||
updatePlayback,
|
||||
displayMotion,
|
||||
applyDisplayCourse,
|
||||
displacementPlausible,
|
||||
} from './movement.js';
|
||||
import { buildTransitSelectionCopy } from './policy.js';
|
||||
import { seek, sampleAt } from '../../data/contactPlayback.js';
|
||||
const entry = () => {
|
||||
const e = { mode: 'bus', modeInferred: false, courseDeg: null };
|
||||
initializePlayback(e);
|
||||
return e;
|
||||
};
|
||||
const fix = (t, lat = 42) => ({ t, lat, lon: -71 });
|
||||
test('transit mode admission retains the permissive ceiling for inferred modes', () => {
|
||||
assert.equal(displacementPlausible('bus', 583, 15), false);
|
||||
assert.equal(displacementPlausible('bus', 583, 15, true), true);
|
||||
assert.equal(displacementPlausible('rail', 583, 15), true);
|
||||
});
|
||||
test('a lone implausible fix cannot teleport; two coherent replacement fixes break the path', () => {
|
||||
const e = entry();
|
||||
recordFix(e, fix(0));
|
||||
recordFix(e, fix(15000, 42.001));
|
||||
assert.equal(recordFix(e, fix(30000, 43)).accepted, false);
|
||||
assert.equal(e.track.count, 2);
|
||||
assert.equal(recordFix(e, fix(45000, 43.001)).accepted, true);
|
||||
assert.equal(e.track.count, 4);
|
||||
assert.equal(sampleAt(e.track, 22500, {}).segmentSpeedMps, 0);
|
||||
});
|
||||
test('clock correction requires three distinct advancing observations, never three cached packets', () => {
|
||||
const e = entry();
|
||||
recordFix(e, fix(100000));
|
||||
for (let i = 0; i < 10; i++) recordFix(e, fix(10000));
|
||||
assert.equal(e.track.resets, 0);
|
||||
recordFix(e, fix(20000));
|
||||
recordFix(e, fix(30000));
|
||||
assert.equal(e.track.resets, 1);
|
||||
assert.equal(e.track.count, 2);
|
||||
});
|
||||
test('orientation alone smooths at 120 degrees/s and waiting/stale override moving copy', () => {
|
||||
const e = entry();
|
||||
recordFix(e, { ...fix(0), bearingDeg: 90 });
|
||||
recordFix(e, { ...fix(15000, 42.00005), bearingDeg: 270 });
|
||||
updatePlayback(e, 30000, 0);
|
||||
e.courseDeg = 0;
|
||||
e.courseEvalAt = 0;
|
||||
applyDisplayCourse(e, 100);
|
||||
assert.equal(e.courseDeg, 12);
|
||||
applyDisplayCourse(e, 100);
|
||||
assert.equal(e.courseDeg, 12);
|
||||
updatePlayback(e, 45000, 15000);
|
||||
assert.equal(displayMotion(e, 45000).word, 'WAITING');
|
||||
updatePlayback(e, 200000, 170000);
|
||||
assert.equal(displayMotion(e, 200000).word, 'NO FIX');
|
||||
});
|
||||
|
||||
test('display copy never borrows a future bearing and follows anchored freshness after a wall jump', () => {
|
||||
const e = entry();
|
||||
recordFix(e, fix(10000));
|
||||
recordFix(e, { ...fix(25000, 42.000005), bearingDeg: 270 });
|
||||
updatePlayback(e, 35000, 0);
|
||||
assert.equal(e.sample.displayT, 10000);
|
||||
assert.ok(!Number.isFinite(e.sample.segmentCourseDeg));
|
||||
const record = { id: 'test', timestamp: 25, bearing: 270 };
|
||||
const copy = (wall) =>
|
||||
buildTransitSelectionCopy(
|
||||
{ name: 'Test', region: 'Test' },
|
||||
record,
|
||||
'bus',
|
||||
wall,
|
||||
null,
|
||||
e,
|
||||
);
|
||||
const before = copy(35000);
|
||||
assert.equal(before.details[1], 'Moving');
|
||||
assert.ok(before.details.includes('reported hdg 270°'));
|
||||
assert.ok(!before.details[1].includes('270'));
|
||||
updatePlayback(e, 3600000, 16);
|
||||
assert.equal(e.sample.motion, 'moving');
|
||||
assert.equal(e.sample.latestReportAgeMs, 10016);
|
||||
assert.equal(displayMotion(e, 3600000).word, 'EN ROUTE');
|
||||
assert.equal(copy(3600000).details[2], before.details[2]);
|
||||
assert.match(
|
||||
copy(3600000).details[2],
|
||||
/Reported 10 s ago · shown 25 s behind/,
|
||||
);
|
||||
});
|
||||
|
||||
test('mesh-floor sampling owns fully initialized numeric scratch before the first frame', async () => {
|
||||
const { correctHeight } = await import('../../data/contactPlayback.js');
|
||||
const e = entry();
|
||||
const scratch = [
|
||||
e.track.from,
|
||||
e.track.to,
|
||||
e.track.latest,
|
||||
e.track.metricsScratchA,
|
||||
e.track.metricsScratchB,
|
||||
e.segment.from,
|
||||
e.segment.to,
|
||||
];
|
||||
const fields = [
|
||||
't',
|
||||
'lat',
|
||||
'lon',
|
||||
'heightM',
|
||||
'h',
|
||||
'receivedAt',
|
||||
'bearingDeg',
|
||||
'flags',
|
||||
'epoch',
|
||||
'seq',
|
||||
];
|
||||
for (const value of scratch)
|
||||
for (const field of fields)
|
||||
assert.equal(
|
||||
typeof value[field],
|
||||
'number',
|
||||
`preallocated numeric ${field}`,
|
||||
);
|
||||
const shapes = scratch.map((value) => Object.keys(value));
|
||||
recordFix(e, fix(10000));
|
||||
recordFix(e, fix(25000, 42.001));
|
||||
correctHeight(e.track, e.track.baseSeq, 12.125);
|
||||
correctHeight(e.track, e.track.baseSeq + 1, 37.375);
|
||||
for (let i = 0; i < 10000; i++) {
|
||||
assert.equal(updatePlayback(e, 35000 + i, i), e.sample);
|
||||
assert.equal(sampleAt(e.track, 10000 + i, e.sample), e.sample);
|
||||
assert.ok(Number.isFinite(e.sample.heightM));
|
||||
}
|
||||
[
|
||||
e.track.from,
|
||||
e.track.to,
|
||||
e.track.latest,
|
||||
e.track.metricsScratchA,
|
||||
e.track.metricsScratchB,
|
||||
e.segment.from,
|
||||
e.segment.to,
|
||||
].forEach((value, i) => assert.equal(value, scratch[i]));
|
||||
scratch.forEach((value, i) =>
|
||||
assert.deepEqual(Object.keys(value), shapes[i]),
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,565 @@
|
||||
/**
|
||||
* @module layers/transit/policy
|
||||
* @description Constants and pure decisions for the Transit layer. No Cesium
|
||||
* scene state, no network: everything here is exercised by node:test.
|
||||
*/
|
||||
|
||||
import { TRANSIT_MODE_ICON } from '../../data/transitFeeds.js';
|
||||
import { displayMotion, playbackPosition } from './movement.js';
|
||||
|
||||
export const TRANSIT_SELECTED_OVERLAY_SOURCE_ID = 'transit-selected';
|
||||
export const TRANSIT_SELECTED_OVERLAY_SOURCE_OPTIONS = Object.freeze({
|
||||
cohortLimit: 1,
|
||||
collisionCapacity: 0,
|
||||
moving: true,
|
||||
});
|
||||
|
||||
// --- Polling / activation ---
|
||||
/** Poll interval (ms). */
|
||||
export const TRANSIT_POLL_MS = 15_000;
|
||||
/**
|
||||
* How a vehicle moves between fixes is delayed playback, not a glide: the
|
||||
* display clock runs a feed-derived lag behind real time and interpolates
|
||||
* between the two fixes bracketing it (`src/layers/transit/movement.js`).
|
||||
* A glide that started from the drawn position at receipt time and spanned the
|
||||
* report gap ran every second segment at 1.5x when MBTA vehicles reported
|
||||
* every ten seconds against this fifteen-second poll — buses plainly too fast.
|
||||
*/
|
||||
/** Camera altitude (m) above which the layer idles: a national fleet still reads at 3,000 km. */
|
||||
export const ACTIVATION_ALTITUDE_M = 3_000_000;
|
||||
export const ACTIVATION_ENTER_ALTITUDE_M = ACTIVATION_ALTITUDE_M - 150_000;
|
||||
export const ACTIVATION_EXIT_ALTITUDE_M = ACTIVATION_ALTITUDE_M + 150_000;
|
||||
/** Debounce (ms) for camera-change proximity checks. */
|
||||
export const CAMERA_DEBOUNCE_MS = 340;
|
||||
/** Camera sensitivity this layer asks for while it is enabled. */
|
||||
export const CAMERA_PERCENTAGE_CHANGED = 0.05;
|
||||
/** Extra coverage radius (km) granted to a feed that is already active, so it does not flap at the edge. */
|
||||
export const RANGE_SLACK_KM = 40;
|
||||
/** Hard cap on rendered vehicles across all active feeds. */
|
||||
export const MAX_VEHICLES_TOTAL = 15_000;
|
||||
/** A vehicle absent from this many consecutive polls is removed. */
|
||||
export const MISSED_POLLS_TO_DROP = 2;
|
||||
|
||||
// --- Age policy ---
|
||||
/**
|
||||
* Oldest fix still drawn, in ms. Past this a vehicle is REMOVED, not merely
|
||||
* skipped on arrival: a feed that stops answering must not leave the last
|
||||
* snapshot frozen on the globe pretending to be live traffic.
|
||||
*/
|
||||
export const VEHICLE_MAX_FIX_AGE_MS = 10 * 60_000;
|
||||
/** A feed with no successful poll for this long reads as stale in the panel. */
|
||||
export const FEED_STALE_AFTER_MS = 90_000;
|
||||
/**
|
||||
* A feed with no successful poll for this long has its vehicles dropped. It is
|
||||
* deliberately shorter than VEHICLE_MAX_FIX_AGE_MS so a dead feed empties even
|
||||
* when its last snapshot carried optimistic timestamps.
|
||||
*/
|
||||
export const FEED_EVICT_AFTER_MS = 5 * 60_000;
|
||||
|
||||
// --- Ground anchoring ---
|
||||
/**
|
||||
* Above this camera altitude (m) a few metres of terrain are invisible, so the
|
||||
* fleet renders on the ellipsoid and no floor cells are warmed. A national view
|
||||
* must not queue a country's worth of ground lookups.
|
||||
*/
|
||||
export const HEIGHT_SAMPLE_MAX_ALTITUDE_M = 60_000;
|
||||
/**
|
||||
* Floor cells warmed per poll. The shared resolver batches and chunks, but the
|
||||
* bound belongs here: a metropolitan fleet is hundreds of vehicles and only the
|
||||
* ones in view are worth a lookup at all.
|
||||
*/
|
||||
export const FLOOR_WARM_PER_POLL = 300;
|
||||
/** How soon after a warm the layer looks again to see which cells landed. */
|
||||
export const FLOOR_REREAD_MS = 900;
|
||||
/** Re-reads before a poll's cycle gives up and waits for the next poll. */
|
||||
export const FLOOR_REREAD_ATTEMPTS = 6;
|
||||
|
||||
// --- Rendering ---
|
||||
/** On-screen glyph size in CSS px at the near end of the distance ramp. */
|
||||
export const MARKER_PIXEL_SIZE = 20;
|
||||
export const SELECTED_PIXEL_SIZE = 30;
|
||||
/**
|
||||
* How often the fleet's screen-space rotations may be recomputed. Projecting a
|
||||
* course into screen space depends on the camera, so a moving camera would
|
||||
* otherwise re-solve every glyph every frame; a fifth of a second of rotation
|
||||
* lag on a bus icon is invisible, and the saving is not.
|
||||
*/
|
||||
export const ROTATION_REFRESH_MS = 200;
|
||||
/**
|
||||
* How often the shown/hidden sweep may run. `billboard.show` is a dirty flag,
|
||||
* so doing this on every camera event during a drag would re-upload the whole
|
||||
* fleet once a frame — which is the cost it exists to avoid.
|
||||
*/
|
||||
export const VISIBILITY_REFRESH_MS = 250;
|
||||
/**
|
||||
* How much wider than the camera's own view rectangle a vehicle still counts as
|
||||
* on screen. Generous on purpose: a vehicle that crosses the edge mid-glide
|
||||
* should already have been moving, not pop into motion at the border.
|
||||
*/
|
||||
export const VIEW_MARGIN_RATIO = 0.6;
|
||||
|
||||
/**
|
||||
* Whether a vehicle is close enough to the camera's look-at area to be worth
|
||||
* animating frame by frame.
|
||||
*
|
||||
* At street level the layer holds an entire metropolitan fleet — five hundred
|
||||
* and more vehicles — while the camera can see about fifteen of them. Gliding
|
||||
* the other five hundred means writing five hundred billboard positions every
|
||||
* frame, which makes Cesium re-upload the whole buffer, for motion nobody can
|
||||
* observe. Off-screen vehicles take their newest fix directly instead; they are
|
||||
* in exactly the right place, and the frame that would have drawn them moving
|
||||
* is spent on the ones a person is actually looking at.
|
||||
*
|
||||
* @param {{south:number,north:number,west:number,east:number}|null} bounds Inflated view bounds, degrees.
|
||||
* @param {{lat:number, lon:number}} at Vehicle position.
|
||||
* @returns {boolean} True when there are no bounds to apply, or it is inside them.
|
||||
*/
|
||||
export function withinViewBounds(bounds, at) {
|
||||
if (!bounds || !at) return true;
|
||||
if (at.lat < bounds.south || at.lat > bounds.north) return false;
|
||||
if (bounds.west <= bounds.east) {
|
||||
return at.lon >= bounds.west && at.lon <= bounds.east;
|
||||
}
|
||||
// The rectangle straddles the antimeridian, so "inside" is the union of the
|
||||
// two halves rather than the span between them.
|
||||
return at.lon >= bounds.west || at.lon <= bounds.east;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a gliding vehicle is near enough the view to be worth animating and
|
||||
* showing — judged on where it is NOW as well as where it is going.
|
||||
*
|
||||
* Testing only the destination hid a bus that was plainly on screen: a fix
|
||||
* arrives placing it just past the padded edge, and it disappeared from inside
|
||||
* the view instead of driving out of it, its glide thrown away in the same
|
||||
* frame. A vehicle counts as near the view while either end of its current
|
||||
* glide is inside the bounds, so it leaves the way a person expects — by
|
||||
* travelling out — and arrives the same way.
|
||||
*
|
||||
* @param {{south:number,north:number,west:number,east:number}|null} bounds
|
||||
* @param {{to:{lat:number,lon:number}, from:{lat:number,lon:number}|null}} entry
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function vehicleNearView(bounds, entry) {
|
||||
if (!bounds || !entry) return true;
|
||||
if (withinViewBounds(bounds, entry.to)) return true;
|
||||
return entry.from ? withinViewBounds(bounds, entry.from) : false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inflate a camera view rectangle (degrees) into the bounds above.
|
||||
*
|
||||
* Longitude is a circle, so padding it has a ceiling: once the padded span
|
||||
* reaches 360° the rectangle is every longitude there is, and wrapping each
|
||||
* endpoint on its own would turn that into a narrow band pointing the wrong
|
||||
* way. A high oblique view over Europe spans about 166°, pads to well past a
|
||||
* full turn, and used to come back as -177.8°..-172.2° — five degrees of the
|
||||
* Pacific — which hid an entire national fleet that was plainly on screen. So
|
||||
* a padded span that closes the circle is reported as the whole circle, and
|
||||
* only a span that stays under it is wrapped.
|
||||
*
|
||||
* @param {{south:number,north:number,west:number,east:number}|null} rect
|
||||
* @param {number} [ratio=VIEW_MARGIN_RATIO]
|
||||
* @returns {{south:number,north:number,west:number,east:number}|null}
|
||||
*/
|
||||
export function inflateViewBounds(rect, ratio = VIEW_MARGIN_RATIO) {
|
||||
if (!rect) return null;
|
||||
const { south, north, west, east } = rect;
|
||||
if (![south, north, west, east].every(Number.isFinite)) return null;
|
||||
const padLat = Math.max(0, (north - south) * ratio);
|
||||
const spanLon = west <= east ? east - west : 360 - west + east;
|
||||
const padLon = Math.max(0, spanLon * ratio);
|
||||
const bounds = {
|
||||
south: Math.max(-90, south - padLat),
|
||||
north: Math.min(90, north + padLat),
|
||||
west: -180,
|
||||
east: 180,
|
||||
};
|
||||
if (spanLon + padLon * 2 >= 360) return bounds;
|
||||
bounds.west = west - padLon < -180 ? west - padLon + 360 : west - padLon;
|
||||
bounds.east = east + padLon > 180 ? east + padLon - 360 : east + padLon;
|
||||
return bounds;
|
||||
}
|
||||
/** Throttle (ms) for re-anchoring the selected vehicle's card while it glides. */
|
||||
export const SELECTED_CARD_REFRESH_MS = 250;
|
||||
/** Mode palette: distinct at a glance, none reused by flights (white/cyan), military (amber), or vessels. */
|
||||
export const TRANSIT_MODE_COLORS = Object.freeze({
|
||||
bus: '#5EF08A',
|
||||
tram: '#FFC24A',
|
||||
subway: '#FF4538',
|
||||
rail: '#D9A6FF',
|
||||
ferry: '#5FD6FF',
|
||||
unknown: '#D8DDE5',
|
||||
});
|
||||
|
||||
/**
|
||||
* Stable key for a vehicle across polls.
|
||||
* @param {string} feedId
|
||||
* @param {string} vehicleId
|
||||
* @returns {string}
|
||||
*/
|
||||
export function transitVehicleKey(feedId, vehicleId) {
|
||||
return `${feedId}:${vehicleId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the vehicle is drawn: the display clock's position along its fix
|
||||
* history. `now` is accepted for the older callers and ignored — the clock is
|
||||
* advanced by the frame pass, and reading it here must not move it.
|
||||
* @param {{fixes?:Array, to?:{lat:number,lon:number}}} entry
|
||||
* @param {number} [now] Unused.
|
||||
* @returns {{lat:number, lon:number, settled:boolean}}
|
||||
*/
|
||||
export function interpolatedVehiclePosition(entry, now) {
|
||||
void now;
|
||||
const played = entry?.track ? playbackPosition(entry) : null;
|
||||
if (played) return played;
|
||||
const to = entry?.to || { lat: NaN, lon: NaN };
|
||||
return { lat: to.lat, lon: to.lon, settled: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* When a fix was REPORTED, in ms, from the best timestamp available.
|
||||
*
|
||||
* The ordering questions — is this fix newer than the one we drew, how much
|
||||
* time passed between them — need an absolute time, not an age. Same ladder as
|
||||
* the age helper: the vehicle's own stamp, else the snapshot's fetch time.
|
||||
*
|
||||
* @param {object} record Normalized vehicle record.
|
||||
* @param {number|null} [fetchedAtMs] Snapshot fetch time.
|
||||
* @returns {number} Report time in ms.
|
||||
*/
|
||||
export function vehicleReportTimeMs(record, fetchedAtMs = null) {
|
||||
const seconds = Number(record?.timestamp);
|
||||
if (Number.isFinite(seconds) && seconds > 0) return seconds * 1000;
|
||||
if (Number.isFinite(fetchedAtMs)) return fetchedAtMs;
|
||||
return Date.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* Age of a vehicle's reported fix in ms, from the best timestamp available.
|
||||
*
|
||||
* The proxy already resolves the ladder (the vehicle's own timestamp, else the
|
||||
* feed header's, else fetch time) and says which it used. A record that somehow
|
||||
* arrives with no usable timestamp at all is aged from the snapshot's fetch
|
||||
* time, never from "now" — treating an unstamped fix as fresh is exactly how a
|
||||
* ten-minute-old bus gets drawn as live.
|
||||
*
|
||||
* @param {object} record Normalized vehicle record.
|
||||
* @param {number} nowMs
|
||||
* @param {number|null} [fetchedAtMs] Snapshot fetch time.
|
||||
* @returns {number} Age in ms (never negative).
|
||||
*/
|
||||
export function vehicleFixAgeMs(record, nowMs, fetchedAtMs = null) {
|
||||
const seconds = Number(record?.timestamp);
|
||||
if (Number.isFinite(seconds) && seconds > 0) {
|
||||
return Math.max(0, nowMs - seconds * 1000);
|
||||
}
|
||||
if (Number.isFinite(fetchedAtMs)) return Math.max(0, nowMs - fetchedAtMs);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a feed fix is too old to draw.
|
||||
* @param {object} record Normalized vehicle record.
|
||||
* @param {number} nowMs
|
||||
* @param {number|null} [fetchedAtMs] Snapshot fetch time.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isStaleVehicleFix(record, nowMs, fetchedAtMs = null) {
|
||||
return vehicleFixAgeMs(record, nowMs, fetchedAtMs) > VEHICLE_MAX_FIX_AGE_MS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Roll per-feed poll health into the one honest row state the layer panel reads.
|
||||
*
|
||||
* `every(stale)` was the bug this replaces: with Boston fresh and Helsinki an
|
||||
* hour old, "not every feed is stale" read as nominal and the row claimed
|
||||
* everything was fine. One stale feed beside a fresh one is DEGRADED — the
|
||||
* view is part live and part not, and the row has to say so.
|
||||
*
|
||||
* @param {Array<{stale:boolean, error:string|null, retryInSec?:number|null,
|
||||
* upstreamAt:number|null, loading:boolean}>} statuses
|
||||
* @param {number} nowMs
|
||||
* @returns {{stale:boolean, degraded:boolean, error:string|null, retryInSec:number|null,
|
||||
* loading:boolean, staleFeeds:number}}
|
||||
*/
|
||||
export function aggregateTransitFeedHealth(statuses, nowMs) {
|
||||
const list = Array.isArray(statuses) ? statuses : [];
|
||||
if (list.length === 0) {
|
||||
return {
|
||||
stale: false,
|
||||
degraded: false,
|
||||
error: null,
|
||||
retryInSec: null,
|
||||
loading: false,
|
||||
staleFeeds: 0,
|
||||
};
|
||||
}
|
||||
// Age is measured from when the OPERATOR last answered, never from when this
|
||||
// browser last received bytes — the proxy replays its cached copy on a fixed
|
||||
// cadence during an outage, and that cadence is not freshness.
|
||||
const isStale = (status) =>
|
||||
status.stale === true ||
|
||||
(Number.isFinite(status.upstreamAt) &&
|
||||
nowMs - status.upstreamAt > FEED_STALE_AFTER_MS);
|
||||
const staleFeeds = list.filter(isStale).length;
|
||||
const failing = list.find((status) => status.error) || null;
|
||||
const error = failing?.error || null;
|
||||
return {
|
||||
stale: staleFeeds === list.length,
|
||||
degraded: (staleFeeds > 0 && staleFeeds < list.length) || Boolean(error),
|
||||
error,
|
||||
retryInSec: Number.isFinite(failing?.retryInSec)
|
||||
? failing.retryInSec
|
||||
: null,
|
||||
loading: list.some((status) => status.loading),
|
||||
staleFeeds,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the human lines of the selection card from a vehicle record.
|
||||
* @param {object} feed Registry entry.
|
||||
* @param {object} record Normalized vehicle record.
|
||||
* @param {string} mode Resolved transit mode.
|
||||
* @param {number} nowMs
|
||||
* @param {number|null} [fetchedAtMs] Snapshot fetch time.
|
||||
* @returns {{title:string, details:string[]}}
|
||||
*/
|
||||
export function buildTransitSelectionCopy(
|
||||
feed,
|
||||
record,
|
||||
mode,
|
||||
nowMs,
|
||||
fetchedAtMs = null,
|
||||
entry = null,
|
||||
) {
|
||||
const icon = TRANSIT_MODE_ICON[mode] || TRANSIT_MODE_ICON.unknown;
|
||||
const routeLabel = record.routeId
|
||||
? `Route ${record.routeId}`
|
||||
: record.label
|
||||
? `Vehicle ${record.label}`
|
||||
: `Vehicle ${record.id}`;
|
||||
const title = `${icon} ${routeLabel}`;
|
||||
// The mode is said in words on its own line. A glyph and a route id do not
|
||||
// tell a reader that the dot on Tremont Street is a subway train, and a
|
||||
// subway drawn on a street reads as a bug until the card says what it is.
|
||||
const kindWord = TRANSIT_MODE_WORD[mode] || TRANSIT_MODE_WORD.unknown;
|
||||
const details = [`${kindWord} · ${feed.name} · ${feed.region}`];
|
||||
const motion = [];
|
||||
// What the SCREEN is doing leads, because that is what the reader can check.
|
||||
const shown = entry ? displayMotion(entry, nowMs) : null;
|
||||
if (shown?.word === 'STOPPED') motion.push('Stopped');
|
||||
else if (shown?.word === 'NO FIX') motion.push('No recent fix');
|
||||
else if (shown?.word === 'WAITING') motion.push('Waiting for update');
|
||||
else if (shown) motion.push('Moving');
|
||||
if (motion.length) details.push(motion.join(' · '));
|
||||
const reportedMotion = [];
|
||||
if (Number.isFinite(record.speedMps))
|
||||
reportedMotion.push(`reported ${Math.round(record.speedMps * 3.6)} km/h`);
|
||||
if (Number.isFinite(record.bearing))
|
||||
reportedMotion.push(`reported hdg ${Math.round(record.bearing)}°`);
|
||||
// The operator's own claim, kept as a REPORT rather than as the live state.
|
||||
// It describes the moment of the report, which the display may not have
|
||||
// reached yet; presenting it as "now" is what put STOPPED on a moving metro.
|
||||
const state = [];
|
||||
if (record.status === 'STOPPED_AT' && record.stopId)
|
||||
state.push(`Last report: stopped at stop ${record.stopId}`);
|
||||
else if (record.status === 'INCOMING_AT' && record.stopId)
|
||||
state.push(`Last report: arriving at stop ${record.stopId}`);
|
||||
else if (record.status === 'IN_TRANSIT_TO' && record.stopId)
|
||||
state.push(`Last report: next stop ${record.stopId}`);
|
||||
if (record.occupancy && record.occupancy !== 'NO_DATA_AVAILABLE') {
|
||||
state.push(record.occupancy.toLowerCase().replaceAll('_', ' '));
|
||||
}
|
||||
if (reportedMotion.length) details.push(reportedMotion.join(' · '));
|
||||
if (state.length) details.push(state.join(' · '));
|
||||
// Underground services are drawn on the surface directly above themselves.
|
||||
// That is a deliberate choice — a train nobody can see is not worth drawing —
|
||||
// but it has to be stated, or a metro sitting on a road looks like an error.
|
||||
if (UNDERGROUND_MODES.has(mode)) {
|
||||
details.push('Shown at street level · depth not in the feed');
|
||||
}
|
||||
details.push(
|
||||
`Vehicle ${record.id}${record.label && record.label !== record.id ? ` · ${record.label}` : ''}`,
|
||||
);
|
||||
const ageS = Math.round(
|
||||
(Number.isFinite(entry?.sample?.latestReportAgeMs)
|
||||
? entry.sample.latestReportAgeMs
|
||||
: vehicleFixAgeMs(record, nowMs, fetchedAtMs)) / 1000,
|
||||
);
|
||||
const reported =
|
||||
ageS < 90
|
||||
? `Reported ${ageS} s ago`
|
||||
: `Reported ${Math.round(ageS / 60)} min ago`;
|
||||
// A time the feed did not give for THIS vehicle is labelled, so the card
|
||||
// never presents a borrowed or inferred time as the vehicle's own report.
|
||||
const ownTime =
|
||||
Number.isFinite(record.timestamp) &&
|
||||
record.timestamp > 0 &&
|
||||
(!record.timestampSource || record.timestampSource === 'vehicle');
|
||||
// How far behind real time the display is running, said separately from
|
||||
// how old the newest report is: the two are different facts.
|
||||
const lagS = Number.isFinite(entry?.sample?.actualDelayMs)
|
||||
? Math.max(0, Math.round(entry.sample.actualDelayMs / 1000))
|
||||
: null;
|
||||
const behind = lagS === null ? '' : ` · shown ${lagS} s behind`;
|
||||
details.splice(
|
||||
motion.length ? 2 : 1,
|
||||
0,
|
||||
(ownTime ? reported : `${reported} (feed time)`) + behind,
|
||||
);
|
||||
if (entry?.track?.count) {
|
||||
const duration = Math.max(
|
||||
0,
|
||||
Math.min(entry.playT, entry.fixes.at(-1).t) - entry.fixes[0].t,
|
||||
);
|
||||
details.push(
|
||||
`Available trail: ${Math.round(duration / 60000)} min${entry.track.truncated || entry.trailTruncated ? ' (limited)' : ''}`,
|
||||
);
|
||||
}
|
||||
return { title, details };
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared-host card for the selected vehicle.
|
||||
* @param {string} key
|
||||
* @param {object|(() => object|null)} position Cartesian position, or a
|
||||
* getter the host calls on every paint so the anchor follows the marker.
|
||||
* @param {{title:string, details:string[]}} copy
|
||||
* @param {string} mode
|
||||
* @returns {object|null}
|
||||
*/
|
||||
export function createTransitSelectedOverlayEntry(key, position, copy, mode) {
|
||||
if (!key || !position) return null;
|
||||
return {
|
||||
id: String(key),
|
||||
position,
|
||||
variant: 'selected',
|
||||
selected: true,
|
||||
protected: true,
|
||||
paintLane: 'selected',
|
||||
collisionGroup: 'ambient-card',
|
||||
priority: Number.MAX_SAFE_INTEGER,
|
||||
title: copy.title,
|
||||
details: copy.details,
|
||||
accent: TRANSIT_MODE_COLORS[mode] || TRANSIT_MODE_COLORS.unknown,
|
||||
interactive: false,
|
||||
anchorRadiusPx: 9,
|
||||
minAnchorGapPx: 11,
|
||||
verticalOnly: true,
|
||||
placement: 'above',
|
||||
edgeFade: 'keyhole',
|
||||
horizonCull: true,
|
||||
terrainOcclusion: false,
|
||||
};
|
||||
}
|
||||
|
||||
/** Modes that ordinarily run below the street, drawn on the surface above themselves. */
|
||||
export const UNDERGROUND_MODES = new Set(['subway']);
|
||||
|
||||
/** The mode in plain words, for a card a person reads rather than scans. */
|
||||
export const TRANSIT_MODE_WORD = Object.freeze({
|
||||
bus: 'Bus',
|
||||
tram: 'Tram',
|
||||
subway: 'Subway',
|
||||
rail: 'Train',
|
||||
ferry: 'Ferry',
|
||||
unknown: 'Transit vehicle',
|
||||
});
|
||||
|
||||
/** Short, fixed-width mode word for the detection card's class field. */
|
||||
export const TRANSIT_MODE_ABBR = Object.freeze({
|
||||
bus: 'BUS',
|
||||
tram: 'TRAM',
|
||||
subway: 'METRO',
|
||||
rail: 'RAIL',
|
||||
ferry: 'FERRY',
|
||||
unknown: 'TRANSIT',
|
||||
});
|
||||
|
||||
/**
|
||||
* The bright first line of a vehicle's detection label: its route when the feed
|
||||
* names one, else the fleet number, else the raw id. What a person standing at
|
||||
* the stop would call it.
|
||||
* @param {object} record Normalized vehicle record.
|
||||
* @returns {string}
|
||||
*/
|
||||
export function transitDetectionId(record) {
|
||||
const route =
|
||||
typeof record?.routeId === 'string' ? record.routeId.trim() : '';
|
||||
if (route) return elideRouteText(route);
|
||||
const label = typeof record?.label === 'string' ? record.label.trim() : '';
|
||||
if (label) return elideRouteText(label);
|
||||
return elideRouteText(String(record?.id || 'TRANSIT'));
|
||||
}
|
||||
|
||||
/** Longest route text the single-line detection label can carry. */
|
||||
export const DETECTION_ID_MAX_CHARS = 14;
|
||||
|
||||
/**
|
||||
* Cut long route text with an ellipsis, so a reader can see it was cut.
|
||||
*
|
||||
* `Shuttle-Generic` used to reach the screen as `SHUTTLE-GENERI`, which does
|
||||
* not read as an abbreviation — it reads as a typo, or as a route that exists.
|
||||
* A trailing ellipsis costs one character and says plainly that there is more.
|
||||
*
|
||||
* @param {string} text
|
||||
* @returns {string} Uppercase, at most DETECTION_ID_MAX_CHARS characters.
|
||||
*/
|
||||
export function elideRouteText(text) {
|
||||
const value = String(text || '').toUpperCase();
|
||||
if (value.length <= DETECTION_ID_MAX_CHARS) return value;
|
||||
return `${value.slice(0, DETECTION_ID_MAX_CHARS - 1)}\u2026`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The dim second line: what kind of vehicle it is and who runs it.
|
||||
* @param {string} mode Resolved transit mode.
|
||||
* @param {object} feed Registry entry.
|
||||
* @returns {string}
|
||||
*/
|
||||
export function transitDetectionClass(mode, feed) {
|
||||
const kind = TRANSIT_MODE_ABBR[mode] || TRANSIT_MODE_ABBR.unknown;
|
||||
const operator =
|
||||
typeof feed?.name === 'string' ? feed.name.toUpperCase() : '';
|
||||
return operator ? `${kind} ${operator}`.slice(0, 20) : kind;
|
||||
}
|
||||
|
||||
/**
|
||||
* The micro field the detection host actually paints beside the route.
|
||||
*
|
||||
* The host draws a single-line track label — the bright id and one dim
|
||||
* micro-field — so mode and status have to share that field or go unseen. Mode
|
||||
* leads because it tells you what you are looking at (and matches the glyph
|
||||
* shape), then what the vehicle is doing: a stopped bus reporting 0 km/h is
|
||||
* worth saying out loud, it is the most common state in a city at rush hour.
|
||||
*
|
||||
* @param {object} record Normalized vehicle record.
|
||||
* @param {string} mode Resolved transit mode.
|
||||
* @returns {string}
|
||||
*/
|
||||
export function transitDetectionMetric(entry, mode, nowMs = Date.now()) {
|
||||
const kind = TRANSIT_MODE_ABBR[mode] || TRANSIT_MODE_ABBR.unknown;
|
||||
const state = transitVehicleState(entry, nowMs);
|
||||
return state ? `${kind} ${state}` : kind;
|
||||
}
|
||||
|
||||
/**
|
||||
* What the vehicle is doing right now, in as few characters as it takes —
|
||||
* judged on what the screen is showing, not on what the feed claims.
|
||||
*
|
||||
* This used to read `current_status` straight out of the newest record, which
|
||||
* is how a metro came to glide across the owner's screen with STOPPED printed
|
||||
* beside it: the status describes the moment of the report, and the display is
|
||||
* still animating the segment before it. The feed is not lying; it is early.
|
||||
* Its claim is kept, verbatim and dated, on the selected card.
|
||||
*
|
||||
* @param {object} entry Vehicle runtime entry.
|
||||
* @param {number} [nowMs]
|
||||
* @returns {string}
|
||||
*/
|
||||
export function transitVehicleState(entry, nowMs = Date.now()) {
|
||||
return displayMotion(entry, nowMs).word;
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
/** Acceptance reducers. Missing, non-finite or unexercised evidence never passes. */
|
||||
export function percentile(values, fraction = 0.95) {
|
||||
if (!values.length || values.some((v) => !Number.isFinite(v)))
|
||||
return Infinity;
|
||||
const sorted = [...values].sort((a, b) => a - b);
|
||||
return sorted[Math.max(0, Math.ceil(sorted.length * fraction) - 1)];
|
||||
}
|
||||
|
||||
export function reduceMotion(rows, tolerance = 0.02) {
|
||||
let tested = 0,
|
||||
moving = 0,
|
||||
worstRelativeError = 0,
|
||||
distance = 0,
|
||||
worstFrame = null;
|
||||
for (let i = 1; i < rows.length; i++) {
|
||||
const a = rows[i - 1],
|
||||
b = rows[i];
|
||||
if (
|
||||
!(a.expectedMps > 0) ||
|
||||
a.segment !== b.segment ||
|
||||
a.toSeq !== b.toSeq ||
|
||||
(a.phase && a.phase !== 'playing') ||
|
||||
(b.phase && b.phase !== 'playing')
|
||||
)
|
||||
continue;
|
||||
const seconds =
|
||||
(Number.isFinite(a.monoMs) && Number.isFinite(b.monoMs)
|
||||
? b.monoMs - a.monoMs
|
||||
: b.displayT - a.displayT) / 1000;
|
||||
if (!(seconds > 0)) {
|
||||
tested++;
|
||||
worstRelativeError = Infinity;
|
||||
continue;
|
||||
}
|
||||
const metres = Math.hypot(b.x - a.x, b.y - a.y, b.z - a.z);
|
||||
const error = Math.abs(metres / seconds / a.expectedMps - 1);
|
||||
tested++;
|
||||
if (error > worstRelativeError || !Number.isFinite(error))
|
||||
worstFrame = {
|
||||
key: b.key,
|
||||
fromIndex: i - 1,
|
||||
toIndex: i,
|
||||
from: a,
|
||||
to: b,
|
||||
seconds,
|
||||
metres,
|
||||
error,
|
||||
};
|
||||
if (metres > 0.00001) moving++;
|
||||
distance += metres;
|
||||
worstRelativeError = Math.max(
|
||||
worstRelativeError,
|
||||
Number.isFinite(error) ? error : Infinity,
|
||||
);
|
||||
}
|
||||
return {
|
||||
pass:
|
||||
tested > 30 &&
|
||||
moving === tested &&
|
||||
distance > 1 &&
|
||||
worstRelativeError <= tolerance,
|
||||
tested,
|
||||
moving,
|
||||
distance,
|
||||
worstRelativeError,
|
||||
worstFrame,
|
||||
tolerance,
|
||||
};
|
||||
}
|
||||
|
||||
export function reduceAnchor(rows) {
|
||||
const hiddenFrames = rows.filter((r) => r.markerVisible === false).length;
|
||||
const invalidWorld = rows.filter((r) => r.worldFinite === false);
|
||||
rows = rows.filter((r) => r.markerVisible !== false);
|
||||
// The host acquires its first card asynchronously. Only its leading acquisition
|
||||
// frames are outside the anchor test; any later missing anchor is a failure.
|
||||
const acquired = rows.findIndex((r) =>
|
||||
[r.mx, r.my, r.ax, r.ay].every(Number.isFinite),
|
||||
);
|
||||
const acquisitionFrames = acquired < 0 ? rows.length : acquired;
|
||||
rows = rows.slice(acquisitionFrames);
|
||||
let samples = 0,
|
||||
moving = 0,
|
||||
maxErrorPx = 0;
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const r = rows[i];
|
||||
if (![r.mx, r.my, r.ax, r.ay].every(Number.isFinite)) continue;
|
||||
samples++;
|
||||
maxErrorPx = Math.max(maxErrorPx, Math.hypot(r.mx - r.ax, r.my - r.ay));
|
||||
const a = rows[i - 1];
|
||||
if (a && Math.hypot(r.mx - a.mx, r.my - a.my) > 0.0001) moving++;
|
||||
}
|
||||
const polls = new Set(rows.map((r) => r.poll).filter(Number.isFinite)).size;
|
||||
const conditions = {
|
||||
enoughSamples: samples > 30,
|
||||
allFinite: samples === rows.length && invalidWorld.length === 0,
|
||||
moving: moving > 30,
|
||||
withinOnePixel: maxErrorPx <= 1,
|
||||
pollTransition: polls >= 2,
|
||||
};
|
||||
return {
|
||||
pass: Object.values(conditions).every(Boolean),
|
||||
conditions,
|
||||
hiddenFrames,
|
||||
invalidSamples: rows
|
||||
.filter((r) => ![r.mx, r.my, r.ax, r.ay].every(Number.isFinite))
|
||||
.slice(0, 8),
|
||||
invalidWorld: invalidWorld.slice(0, 8),
|
||||
acquisitionFrames,
|
||||
inputFrames: rows.length + acquisitionFrames + hiddenFrames,
|
||||
polls,
|
||||
samples,
|
||||
moving,
|
||||
maxErrorPx,
|
||||
};
|
||||
}
|
||||
|
||||
export function reduceFleet(
|
||||
rows,
|
||||
count,
|
||||
allocationBytesPerFrame,
|
||||
retainedBytes,
|
||||
fixBytes,
|
||||
) {
|
||||
const street = count === 800;
|
||||
const limits = {
|
||||
cpuMs: street ? 1 : 4,
|
||||
intervalMs: street ? 20 : 25,
|
||||
uploadBytes: street ? 262144 : 1048576,
|
||||
allocationBytes: 8192,
|
||||
retainedBytes: (street ? 24 : 48) * 1048576,
|
||||
fixBytes: count * 128 * 48,
|
||||
};
|
||||
const metrics = {
|
||||
frames: rows.length,
|
||||
cpuP95: percentile(rows.map((r) => r.cpuMs)),
|
||||
intervalP95: percentile(rows.map((r) => r.intervalMs)),
|
||||
uploadMax: rows.length
|
||||
? Math.max(...rows.map((r) => r.uploadBytes))
|
||||
: Infinity,
|
||||
movingMin: rows.length ? Math.min(...rows.map((r) => r.moving)) : 0,
|
||||
allocationBytesPerFrame,
|
||||
retainedBytes,
|
||||
fixBytes,
|
||||
};
|
||||
const motion = reduceMotion(rows);
|
||||
return {
|
||||
pass:
|
||||
rows.length >= 300 &&
|
||||
motion.pass &&
|
||||
metrics.movingMin === count &&
|
||||
metrics.cpuP95 <= limits.cpuMs &&
|
||||
metrics.intervalP95 <= limits.intervalMs &&
|
||||
metrics.uploadMax > 0 &&
|
||||
metrics.uploadMax <= limits.uploadBytes &&
|
||||
Number.isFinite(allocationBytesPerFrame) &&
|
||||
allocationBytesPerFrame <= limits.allocationBytes &&
|
||||
Number.isFinite(retainedBytes) &&
|
||||
retainedBytes > 0 &&
|
||||
retainedBytes <= limits.retainedBytes &&
|
||||
fixBytes > 0 &&
|
||||
fixBytes <= limits.fixBytes,
|
||||
limits,
|
||||
metrics,
|
||||
motion,
|
||||
};
|
||||
}
|
||||
|
||||
export function reduceScenario(trace, key) {
|
||||
const keys = key.startsWith('mbta:') ? key : `mbta:sim-${key}`;
|
||||
const rows = trace.out[keys] || [];
|
||||
let moved = 0;
|
||||
let total = 0;
|
||||
let worstJump = 0;
|
||||
let speedWorst = 0;
|
||||
let wallFaster = 0;
|
||||
let lagMax = 0;
|
||||
let lagMin = Infinity;
|
||||
const initialResets = (rows.find((r) => r[5] === 1) || rows[0])?.[4] || 0;
|
||||
let resets = 0;
|
||||
let backwards = 0;
|
||||
for (let i = 1; i < rows.length; i += 1) {
|
||||
const a = rows[i - 1];
|
||||
const b = rows[i];
|
||||
const d = Math.hypot(
|
||||
(b[1] - a[1]) * 111320,
|
||||
(b[2] - a[2]) * 111320 * Math.cos((a[1] * Math.PI) / 180),
|
||||
);
|
||||
// Position and playback time were sampled in preRender; GPU completion
|
||||
// jitter in column zero must not masquerade as acceleration or catch-up.
|
||||
const dWall =
|
||||
(Number.isFinite(a[16]) && Number.isFinite(b[16])
|
||||
? b[16] - a[16]
|
||||
: b[0] - a[0]) / 1000;
|
||||
const dPlay = (b[3] - a[3]) / 1000;
|
||||
total += d;
|
||||
if (d > 0.05) moved += 1;
|
||||
worstJump = Math.max(worstJump, d / Math.max(dWall, 1 / 120));
|
||||
if (dPlay > dWall * 1.02 + 0.002) wallFaster += 1;
|
||||
if (dPlay > 0 && d > 0.001) speedWorst = Math.max(speedWorst, d / dPlay);
|
||||
if (b[1] < a[1] - 0.5 / 111320) backwards += 1;
|
||||
resets = Math.max(resets, b[4] - initialResets);
|
||||
const lag = (b[7] - b[3]) / 1000;
|
||||
lagMax = Math.max(lagMax, lag);
|
||||
lagMin = Math.min(lagMin, lag);
|
||||
}
|
||||
return {
|
||||
keys,
|
||||
lagValid:
|
||||
rows.length > 1 && Number.isFinite(lagMin) && Number.isFinite(lagMax),
|
||||
rows: rows.length,
|
||||
moved,
|
||||
total,
|
||||
worstJump,
|
||||
speedWorst,
|
||||
wallFaster,
|
||||
lagMax,
|
||||
lagMin,
|
||||
resets,
|
||||
initialResets,
|
||||
backwards,
|
||||
last: rows.at(-1),
|
||||
first: rows[0],
|
||||
};
|
||||
}
|
||||
|
||||
/** Choose actual sampled backgrounds, never infer brightness from a place name. */
|
||||
export function reduceBackground(name, pixels) {
|
||||
const values = pixels.map((p) => p.background);
|
||||
const luma = values.length
|
||||
? values.reduce((a, b) => a + b, 0) / values.length
|
||||
: NaN;
|
||||
const threshold = name === 'bright' ? 0.55 : 0.35;
|
||||
return {
|
||||
pass:
|
||||
values.length >= 4 &&
|
||||
values.every(Number.isFinite) &&
|
||||
(name === 'bright' ? luma >= threshold : luma <= threshold),
|
||||
luma,
|
||||
samples: values.length,
|
||||
threshold,
|
||||
comparison: name === 'bright' ? '>=' : '<=',
|
||||
};
|
||||
}
|
||||
|
||||
/** Convert captured tuples without confusing paint completion with the playback clock. */
|
||||
export function reduceScriptedMotion(key, rows) {
|
||||
return reduceMotion(
|
||||
rows.map((r) => ({
|
||||
key,
|
||||
monoMs: r[16],
|
||||
x: r[11],
|
||||
y: r[12],
|
||||
z: r[13],
|
||||
displayT: r[3],
|
||||
expectedMps: r[9],
|
||||
segment: r[10],
|
||||
toSeq: r[14],
|
||||
phase: r[15],
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* In-scene sensor signatures retain the active shader. Surveillance peak white
|
||||
* is P43 phosphor (0.16, 1, 0.22): Rec.601 luma 0.65992. D1 requires 90% of
|
||||
* that peak and a ring <= 0.25. Local background delta is diagnostic.
|
||||
* White-hot, Ironbow and noir require 0.85; black-hot keeps the inverse.
|
||||
* Six verified, unobscured sprites are required; absence is UNEXERCISED.
|
||||
*/
|
||||
export function reduceSensorContrast(pixels, polarity = 'white', preset = '') {
|
||||
const limits =
|
||||
polarity === 'black'
|
||||
? { centreMax: 0.15, ringMin: 0.75 }
|
||||
: {
|
||||
centreMin:
|
||||
preset === 'surveillance'
|
||||
? 0.9 * (0.299 * 0.16 + 0.587 + 0.114 * 0.22)
|
||||
: 0.85,
|
||||
ringMax: 0.25,
|
||||
backgroundDeltaMin: null,
|
||||
};
|
||||
const judged = pixels
|
||||
.filter((p) => p.verified === true)
|
||||
.map((p) => ({
|
||||
key: p.key,
|
||||
centre: p.centre,
|
||||
background: p.background,
|
||||
backgroundDelta: p.centre - p.background,
|
||||
ring: polarity === 'black' ? p.ringMax : p.ringMin,
|
||||
pass:
|
||||
polarity === 'black'
|
||||
? p.centre <= limits.centreMax && p.ringMax >= limits.ringMin
|
||||
: p.centre >= limits.centreMin &&
|
||||
p.ringMin <= limits.ringMax &&
|
||||
(limits.backgroundDeltaMin === null ||
|
||||
p.centre - p.background >= limits.backgroundDeltaMin),
|
||||
}));
|
||||
return {
|
||||
pass: judged.length >= 6 && judged.every((p) => p.pass),
|
||||
unexercised: judged.length < 6,
|
||||
reason:
|
||||
judged.length < 6
|
||||
? `Only ${judged.length} verified unobscured sprites; need 6`
|
||||
: null,
|
||||
limits,
|
||||
judged,
|
||||
};
|
||||
}
|
||||
|
||||
/** Selected trail evidence stays inspectable even when the click failed. */
|
||||
export function reduceTrailHead(anchor, target) {
|
||||
const conditions = {
|
||||
selected: anchor?.selected === true,
|
||||
enoughHeadSamples:
|
||||
Number.isFinite(anchor?.headSamples) && anchor.headSamples > 30,
|
||||
aligned: Number.isFinite(anchor?.headErrorPx) && anchor.headErrorPx <= 1,
|
||||
stableBody: anchor?.bodyMutation === 0,
|
||||
stableEntityCount:
|
||||
Number.isFinite(anchor?.entityCount) &&
|
||||
anchor.entityCount === target?.entityCount,
|
||||
};
|
||||
return {
|
||||
pass: Object.values(conditions).every(Boolean),
|
||||
conditions,
|
||||
headSamples: anchor?.headSamples ?? null,
|
||||
errorPx: anchor?.headErrorPx ?? null,
|
||||
bodyMutation: anchor?.bodyMutation ?? null,
|
||||
selection: anchor?.selection ?? null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
reduceMotion,
|
||||
reduceAnchor,
|
||||
reduceFleet,
|
||||
percentile,
|
||||
} from './qaMetrics.js';
|
||||
const rows = () =>
|
||||
Array.from({ length: 400 }, (_, i) => ({
|
||||
x: i / 6,
|
||||
y: 0,
|
||||
z: 0,
|
||||
displayT: (i * 1000) / 60,
|
||||
expectedMps: 10,
|
||||
segment: 1,
|
||||
mx: i,
|
||||
my: 20,
|
||||
ax: i,
|
||||
ay: 20,
|
||||
cpuMs: 0.5,
|
||||
intervalMs: 1000 / 60,
|
||||
uploadBytes: 128000,
|
||||
moving: 800,
|
||||
poll: Math.floor(i / 200),
|
||||
}));
|
||||
test('ordinary 60 Hz motion passes; frozen motion, time and overspeed fail', () => {
|
||||
assert.equal(reduceMotion(rows()).pass, true);
|
||||
assert.equal(reduceMotion(rows().map((r) => ({ ...r, x: 0 }))).pass, false);
|
||||
assert.equal(
|
||||
reduceMotion(rows().map((r) => ({ ...r, displayT: 0 }))).pass,
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
reduceMotion(rows().map((r) => ({ ...r, x: r.x * 1.03 }))).pass,
|
||||
false,
|
||||
);
|
||||
});
|
||||
test('actual anchor agreement passes; equal travel with a fixed offset fails', () => {
|
||||
assert.equal(reduceAnchor(rows()).pass, true);
|
||||
assert.equal(
|
||||
reduceAnchor(rows().map((r) => ({ ...r, ax: r.ax + 2 }))).pass,
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
reduceAnchor(rows().map((r) => ({ ...r, ax: null }))).pass,
|
||||
false,
|
||||
);
|
||||
});
|
||||
test('empty samples and unmeasured allocation never pass acceptance', () => {
|
||||
assert.equal(percentile([]), Infinity);
|
||||
assert.equal(reduceMotion([]).pass, false);
|
||||
assert.equal(reduceAnchor([]).pass, false);
|
||||
assert.equal(reduceFleet([], 800, 0, 1, 1).pass, false);
|
||||
assert.equal(reduceFleet(rows(), 800, undefined, 1, 1).pass, false);
|
||||
assert.equal(reduceFleet(rows(), 800, 100, 10000, 800 * 128 * 48).pass, true);
|
||||
assert.equal(
|
||||
reduceFleet(
|
||||
rows().map((r) => ({ ...r, moving: 1 })),
|
||||
800,
|
||||
100,
|
||||
10000,
|
||||
10000,
|
||||
).pass,
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('ordinary frames judge drawn speed against elapsed time and name the offending pair', () => {
|
||||
const slow = rows().map((r, i) => ({
|
||||
...r,
|
||||
key: 'mbta:sim-dup',
|
||||
monoMs: (i * 1000) / 60,
|
||||
displayT: r.displayT * 0.8858664517,
|
||||
x: r.x * 0.8858664517,
|
||||
}));
|
||||
const result = reduceMotion(slow);
|
||||
assert.equal(result.pass, false);
|
||||
assert.equal(result.worstFrame.key, 'mbta:sim-dup');
|
||||
assert.ok(result.worstRelativeError > 0.114);
|
||||
});
|
||||
|
||||
test('per-scenario reducer resolves full vehicle keys and cannot pass empty lag', async () => {
|
||||
const { reduceScenario } = await import('./qaMetrics.js');
|
||||
const trace = {
|
||||
out: {
|
||||
'mbta:sim-straight': Array.from({ length: 5645 }, (_, i) => [
|
||||
i * 17,
|
||||
42 + i / 111320,
|
||||
-71,
|
||||
i * 17,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
i * 17 + 25000,
|
||||
]),
|
||||
},
|
||||
};
|
||||
const result = reduceScenario(trace, 'straight');
|
||||
assert.equal(result.rows, 5645);
|
||||
assert.equal(result.keys, 'mbta:sim-straight');
|
||||
assert.equal(result.lagMin, 25);
|
||||
assert.equal(reduceScenario({ out: {} }, 'gap').lagValid, false);
|
||||
});
|
||||
|
||||
test('anchor names its judged conditions and accepts 1128 aligned frames across two polls', () => {
|
||||
const fixture = Array.from({ length: 1128 }, (_, i) => ({
|
||||
mx: i,
|
||||
my: 0,
|
||||
ax: i + 4e-9,
|
||||
ay: 0,
|
||||
poll: i < 564 ? 1 : 2,
|
||||
}));
|
||||
const result = reduceAnchor([
|
||||
{ mx: 0, my: 0, ax: null, ay: null },
|
||||
...fixture,
|
||||
]);
|
||||
assert.equal(result.pass, true);
|
||||
assert.equal(result.conditions.pollTransition, true);
|
||||
assert.equal(result.polls, 2);
|
||||
assert.equal(
|
||||
reduceAnchor(fixture.map((r) => ({ ...r, poll: 1 }))).pass,
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('Boston mid-luma fixtures cannot qualify as bright or dark', async () => {
|
||||
const { reduceBackground } = await import('./qaMetrics.js');
|
||||
assert.equal(
|
||||
reduceBackground('bright', Array(4).fill({ background: 0.423 })).pass,
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
reduceBackground('dark', Array(4).fill({ background: 0.403 })).pass,
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
reduceBackground('bright', Array(4).fill({ background: 0.65 })).pass,
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
reduceBackground('dark', Array(4).fill({ background: 0.2 })).pass,
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('variable GPU work after sampling cannot change the judged playback speed', async () => {
|
||||
const { reduceScriptedMotion } = await import('./qaMetrics.js');
|
||||
const frames = Array.from({ length: 400 }, (_, i) => {
|
||||
const r = Array(17).fill(0),
|
||||
stamp = (i * 1000) / 60;
|
||||
r[0] = stamp + (i % 2 ? 4 : 0);
|
||||
r[3] = stamp;
|
||||
r[9] = 10;
|
||||
r[10] = 1;
|
||||
r[11] = i / 6;
|
||||
r[14] = 2;
|
||||
r[15] = 'playing';
|
||||
r[16] = stamp;
|
||||
return r;
|
||||
});
|
||||
assert.equal(reduceScriptedMotion('mbta:sim-straight', frames).pass, true);
|
||||
});
|
||||
|
||||
test('sensor thresholds retain in-scene NVG peak, halo and six-sprite minimum', async () => {
|
||||
const { reduceSensorContrast } = await import('./qaMetrics.js');
|
||||
const pixels = (p, n = 6) =>
|
||||
Array.from({ length: n }, (_, i) => ({
|
||||
key: `bus-${i}`,
|
||||
verified: true,
|
||||
...p,
|
||||
}));
|
||||
assert.equal(
|
||||
reduceSensorContrast(
|
||||
pixels({ centre: 0.64, ringMin: 0.03, background: 0.3 }),
|
||||
'white',
|
||||
'surveillance',
|
||||
).pass,
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
reduceSensorContrast(
|
||||
pixels({ centre: 0.64, ringMin: 0.03, background: 0.9 }),
|
||||
'white',
|
||||
'surveillance',
|
||||
).pass,
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
reduceSensorContrast(
|
||||
pixels({ centre: 0.59, ringMin: 0.03, background: 0.1 }),
|
||||
'white',
|
||||
'surveillance',
|
||||
).pass,
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
reduceSensorContrast(pixels({ centre: 0.64, ringMin: 0.03 })).pass,
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
reduceSensorContrast(pixels({ centre: 0.86, ringMin: 0.2 })).pass,
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
reduceSensorContrast(pixels({ centre: 0.14, ringMax: 0.8 }), 'black').pass,
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
reduceSensorContrast(pixels({ centre: 1, ringMin: 0.1 }, 5)).unexercised,
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('initial scripted synchronization is disclosed but subsequent resets fail', async () => {
|
||||
const { reduceScenario } = await import('./qaMetrics.js');
|
||||
const trace = {
|
||||
out: {
|
||||
'mbta:sim-straight': Array.from({ length: 100 }, (_, i) => [
|
||||
i * 17,
|
||||
42 + i / 111320,
|
||||
-71,
|
||||
i * 17,
|
||||
i < 2 ? 0 : 1,
|
||||
i < 2 ? 0 : 1,
|
||||
0,
|
||||
i * 17 + 25000,
|
||||
]),
|
||||
},
|
||||
};
|
||||
assert.equal(reduceScenario(trace, 'straight').resets, 0);
|
||||
assert.equal(reduceScenario(trace, 'straight').initialResets, 1);
|
||||
trace.out['mbta:sim-straight'][50][4] = 2;
|
||||
assert.equal(reduceScenario(trace, 'straight').resets, 1);
|
||||
});
|
||||
|
||||
test('D2 and D3 gate authored allocation inclusively and metro CPU at four milliseconds', () => {
|
||||
const fleet = rows().map((r) => ({ ...r, moving: 3000, cpuMs: 3.4 }));
|
||||
assert.equal(
|
||||
reduceFleet(fleet, 3000, 8192, 10000, 3000 * 128 * 48).pass,
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
reduceFleet(fleet, 3000, 8193, 10000, 3000 * 128 * 48).pass,
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
reduceFleet(
|
||||
fleet.map((r) => ({ ...r, cpuMs: 4.1 })),
|
||||
3000,
|
||||
0,
|
||||
10000,
|
||||
10000,
|
||||
).pass,
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('anchor ignores explicitly hidden markers but exposes missing painted cards and nonfinite world positions', () => {
|
||||
const samples = rows();
|
||||
samples.splice(100, 0, {
|
||||
markerVisible: false,
|
||||
mx: 0,
|
||||
my: 0,
|
||||
ax: null,
|
||||
ay: null,
|
||||
worldFinite: true,
|
||||
});
|
||||
assert.equal(reduceAnchor(samples).pass, true);
|
||||
assert.equal(reduceAnchor(samples).hiddenFrames, 1);
|
||||
samples[100].markerVisible = true;
|
||||
assert.equal(reduceAnchor(samples).pass, false);
|
||||
assert.equal(reduceAnchor(samples).invalidSamples.length, 1);
|
||||
samples[100].markerVisible = false;
|
||||
samples[100].worldFinite = false;
|
||||
assert.equal(reduceAnchor(samples).pass, false);
|
||||
});
|
||||
|
||||
test('scenario speed and catch-up use sampling time, not variable GPU completion time', async () => {
|
||||
const { reduceScenario } = await import('./qaMetrics.js');
|
||||
const rows = Array.from({ length: 100 }, (_, i) => {
|
||||
const r = Array(17).fill(0);
|
||||
r[0] = i * 17 + (i % 2 ? 4 : 0);
|
||||
r[1] = 42 + (i * 0.17) / 111320;
|
||||
r[2] = -71;
|
||||
r[3] = i * 17;
|
||||
r[5] = 1;
|
||||
r[7] = r[3] + 25000;
|
||||
r[16] = i * 17;
|
||||
return r;
|
||||
});
|
||||
const result = reduceScenario(
|
||||
{ out: { 'mbta:sim-straight': rows } },
|
||||
'straight',
|
||||
);
|
||||
assert.ok(Math.abs(result.worstJump - 10) < 1e-6);
|
||||
assert.equal(result.wallFaster, 0);
|
||||
for (const r of rows) r[16] *= 0.8;
|
||||
const fast = reduceScenario(
|
||||
{ out: { 'mbta:sim-straight': rows } },
|
||||
'straight',
|
||||
);
|
||||
assert.ok(fast.worstJump > 12);
|
||||
assert.ok(fast.wallFaster > 0);
|
||||
});
|
||||
|
||||
test('trail-head acceptance names every missing and failed condition', async () => {
|
||||
const { reduceTrailHead } = await import('./qaMetrics.js');
|
||||
const missing = reduceTrailHead({ selected: false }, { entityCount: 0 });
|
||||
assert.equal(missing.pass, false);
|
||||
assert.deepEqual(missing.conditions, {
|
||||
selected: false,
|
||||
enoughHeadSamples: false,
|
||||
aligned: false,
|
||||
stableBody: false,
|
||||
stableEntityCount: false,
|
||||
});
|
||||
const good = {
|
||||
selected: true,
|
||||
headSamples: 100,
|
||||
headErrorPx: 0,
|
||||
bodyMutation: 0,
|
||||
entityCount: 0,
|
||||
};
|
||||
assert.equal(reduceTrailHead(good, { entityCount: 0 }).pass, true);
|
||||
assert.equal(
|
||||
reduceTrailHead({ ...good, headErrorPx: 2 }, { entityCount: 0 }).pass,
|
||||
false,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,237 @@
|
||||
import {
|
||||
ACTIVATION_ALTITUDE_M,
|
||||
FEED_STALE_AFTER_MS,
|
||||
aggregateTransitFeedHealth,
|
||||
transitDetectionClass,
|
||||
transitDetectionId,
|
||||
transitDetectionMetric,
|
||||
} from './policy.js';
|
||||
import {
|
||||
TRANSIT_ENABLED_FEEDS,
|
||||
getRegisteredTransitFeed,
|
||||
} from '../../data/transitFeeds.js';
|
||||
import { transitModeTier } from '../../data/transitPresetStyle.js';
|
||||
|
||||
/**
|
||||
* The layer row's answer to "what is on screen and is it honest?".
|
||||
* @param {object} context
|
||||
* @returns {object}
|
||||
*/
|
||||
export function createQueries({ state, parts }) {
|
||||
const empty = [];
|
||||
const defaults = {};
|
||||
let requestedMax = Infinity,
|
||||
requestedSeed = 0;
|
||||
/**
|
||||
* A stable 32-bit hash of a vehicle key, mixed with the caller's seed.
|
||||
*
|
||||
* Selection has to survive ordinary churn. Striding by position in the map
|
||||
* meant that one bus leaving at the front renumbered every bus behind it, so
|
||||
* a single removal swapped the whole labelled set for its neighbours —
|
||||
* visible as every callout jumping one vehicle sideways. A hash of the key
|
||||
* belongs to the vehicle, not to its place in a list, so a removal anywhere
|
||||
* leaves every other vehicle's standing untouched.
|
||||
*
|
||||
* @param {string} key
|
||||
* @param {number} seed
|
||||
* @returns {number} Unsigned 32-bit.
|
||||
*/
|
||||
function selectionRank(key, seed) {
|
||||
// FNV-1a, seeded. Cheap, no allocation, and well spread for short ids.
|
||||
let hash = (2166136261 ^ seed) >>> 0;
|
||||
for (let i = 0; i < key.length; i += 1) {
|
||||
hash ^= key.charCodeAt(i);
|
||||
hash = Math.imul(hash, 16777619) >>> 0;
|
||||
}
|
||||
return hash >>> 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* The vehicles the detection overlay may box and label this frame.
|
||||
*
|
||||
* Detection PULLS this on every paint, not only on label solves, so it must
|
||||
* not be a fleet scan: the answer is CACHED and rebuilt only when something
|
||||
* that could change it has happened — a poll, a visibility sweep, a
|
||||
* removal, a selection — which the rest of the layer signals by bumping
|
||||
* `state._detectRevision`. Between those, every paint gets the same array
|
||||
* back. Positions inside it are the markers' own Cartesians, which the frame
|
||||
* pass writes in place, so they are live without a copy; only the motion
|
||||
* word is re-read per paint, and only for the entries actually handed over.
|
||||
*
|
||||
* Two older rules stand. Visibility is applied FIRST: sampling the whole
|
||||
* fleet and then discarding the hidden ones meant a single visible bus in a
|
||||
* fleet of five thousand could fall outside the sample and go unlabelled
|
||||
* while the overlay had thousands of slots free. And selection among the
|
||||
* visible is by hash, not by position, so the labelled set is stable as
|
||||
* vehicles come and go. The selected vehicle is handed over with
|
||||
* `skipLabel` — it has its own fuller card.
|
||||
*
|
||||
* @param {{maxCount?: number, seed?: number}} [options]
|
||||
* @returns {Array<object>}
|
||||
*/
|
||||
function collectDetectableVehicles(options = defaults) {
|
||||
if (!state._enabled || !state._markers?.show) return empty;
|
||||
if (state._vehicles.size === 0) return empty;
|
||||
const maxCount = Number.isFinite(options.maxCount)
|
||||
? Math.max(1, Math.floor(options.maxCount))
|
||||
: Infinity;
|
||||
const seed = Number.isFinite(options.seed) ? Math.floor(options.seed) : 0;
|
||||
|
||||
requestedMax = maxCount;
|
||||
requestedSeed = seed;
|
||||
if (!state._detectCache) refreshDetectCache();
|
||||
return state._detectCache?.contacts || empty;
|
||||
}
|
||||
function refreshDetectCache() {
|
||||
if (!state._enabled) return;
|
||||
const cache = state._detectCache;
|
||||
const now = Date.now();
|
||||
const maxCount = requestedMax,
|
||||
seed = requestedSeed;
|
||||
if (now - state._detectBuiltAt < 250) return;
|
||||
state._detectBuiltAt = now;
|
||||
if (
|
||||
cache &&
|
||||
cache.revision === state._detectRevision &&
|
||||
cache.maxCount === maxCount &&
|
||||
cache.seed === seed
|
||||
) {
|
||||
for (const contact of cache.contacts)
|
||||
contact.metric = transitDetectionMetric(
|
||||
contact._entry,
|
||||
contact._entry.mode,
|
||||
now,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (cache)
|
||||
for (const contact of cache.contacts) contact._entry.detectContact = null;
|
||||
const visible = [];
|
||||
for (const entry of state._visible) {
|
||||
const marker = entry.marker;
|
||||
if (!marker?.position || marker.show === false) continue;
|
||||
visible.push(entry);
|
||||
}
|
||||
let chosen = visible;
|
||||
if (visible.length > maxCount) {
|
||||
// Take the lowest-ranked keys. Removing an unselected vehicle cannot
|
||||
// change this set at all, and removing a selected one promotes exactly
|
||||
// one replacement instead of reshuffling everybody.
|
||||
chosen = visible
|
||||
.map((entry) => ({ entry, rank: selectionRank(entry.key, seed) }))
|
||||
.sort((a, b) => a.rank - b.rank || (a.entry.key < b.entry.key ? -1 : 1))
|
||||
.slice(0, maxCount)
|
||||
.map((scored) => scored.entry);
|
||||
}
|
||||
const contacts = [];
|
||||
for (const entry of chosen) {
|
||||
// Route and operator text change with the record and the mode, both of
|
||||
// which arrive on polls — the same events that rebuild this list.
|
||||
if (!entry.labelId || entry.labelRecord !== entry.record) {
|
||||
entry.labelId = transitDetectionId(entry.record);
|
||||
entry.labelRecord = entry.record;
|
||||
}
|
||||
if (!entry.labelClass || entry.labelMode !== entry.mode) {
|
||||
const feed =
|
||||
state._activeFeeds.get(entry.feedId) ||
|
||||
getRegisteredTransitFeed(entry.feedId);
|
||||
entry.labelClass = transitDetectionClass(entry.mode, feed);
|
||||
entry.labelMode = entry.mode;
|
||||
}
|
||||
const contact = {
|
||||
position: entry.marker.position,
|
||||
bracketHalfWidth: Math.ceil(entry.marker.width / 2) + 2,
|
||||
bracketHalfHeight: Math.ceil(entry.marker.height / 2) + 2,
|
||||
sourceId: entry.key,
|
||||
id: entry.labelId,
|
||||
type: 'VEH',
|
||||
klass: entry.labelClass,
|
||||
metric: transitDetectionMetric(entry, entry.mode, now),
|
||||
// The bracket carries the mode colour in every preset: the detection
|
||||
// canvas sits above the post-FX chain, where the sprites' own colour
|
||||
// is lost under NVG and FLIR. Keyless too — the feeds are keyless.
|
||||
tier: transitModeTier(entry.mode),
|
||||
skipLabel: entry.key === state._selectedKey,
|
||||
_entry: entry,
|
||||
};
|
||||
entry.detectContact = contact;
|
||||
contacts.push(contact);
|
||||
}
|
||||
state._detectCache = {
|
||||
revision: state._detectRevision,
|
||||
maxCount,
|
||||
seed,
|
||||
contacts,
|
||||
};
|
||||
return contacts;
|
||||
}
|
||||
|
||||
const methods = {
|
||||
/**
|
||||
* Detection contract: buses, trams and trains are contacts like any other.
|
||||
* @param {{maxCount?: number, seed?: number}} [options]
|
||||
* @returns {Array<object>}
|
||||
*/
|
||||
getDetectableObjects(options = defaults) {
|
||||
return collectDetectableVehicles(options);
|
||||
},
|
||||
|
||||
getStats() {
|
||||
const active = [...state._activeFeeds.values()];
|
||||
const statuses = active.map((feed) =>
|
||||
parts.ingestion.feedStatus(feed.id),
|
||||
);
|
||||
const now = Date.now();
|
||||
const health = aggregateTransitFeedHealth(statuses, now);
|
||||
const count = state._vehicles.size;
|
||||
if (state._enabled && active.length === 0) {
|
||||
return {
|
||||
count: 0,
|
||||
lastUpdate: state._lastUpdate,
|
||||
error: null,
|
||||
source: 'GTFS-RT',
|
||||
status: 'zoom-in',
|
||||
coverage: state._altitudeGateOpen
|
||||
? `No feed here yet · ${TRANSIT_ENABLED_FEEDS.length} regions available`
|
||||
: `Fly below ${Math.round(ACTIVATION_ALTITUDE_M / 1000).toLocaleString()} km to a covered region`,
|
||||
};
|
||||
}
|
||||
// Age is shown by the same rule the row state uses, so the text and the
|
||||
// chip can never disagree about which feed has gone quiet.
|
||||
const coverage = active
|
||||
.map((feed) => {
|
||||
const status = parts.ingestion.feedStatus(feed.id);
|
||||
const quiet =
|
||||
status.stale === true ||
|
||||
(Number.isFinite(status.upstreamAt) &&
|
||||
now - status.upstreamAt > FEED_STALE_AFTER_MS);
|
||||
const aged =
|
||||
quiet && Number.isFinite(status.upstreamAt)
|
||||
? ` (${Math.round((now - status.upstreamAt) / 1000)}s old)`
|
||||
: '';
|
||||
return `${feed.name} ${status.count}${aged}`;
|
||||
})
|
||||
.join(' · ');
|
||||
return {
|
||||
count,
|
||||
lastUpdate: state._lastUpdate,
|
||||
error: count === 0 ? health.error : null,
|
||||
...(count === 0 && Number.isFinite(health.retryInSec)
|
||||
? { retryInSec: health.retryInSec }
|
||||
: {}),
|
||||
degraded: count > 0 && health.degraded,
|
||||
loading: health.loading && count === 0,
|
||||
loadingLabel:
|
||||
health.loading && count === 0
|
||||
? `Loading ${active.map((feed) => feed.name).join(', ')}`
|
||||
: undefined,
|
||||
stale: health.stale,
|
||||
source: 'GTFS-RT',
|
||||
coverage,
|
||||
feeds: active.map((feed) => feed.id),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
return { methods, refreshDetectCache };
|
||||
}
|
||||
@@ -0,0 +1,598 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { perspectiveProjectedRotation } from '../../data/iconOrientation.js';
|
||||
import {
|
||||
updatePlayback,
|
||||
applyDisplayCourse,
|
||||
syncPlayback,
|
||||
} from './movement.js';
|
||||
import {
|
||||
SELECTED_ICON_PX,
|
||||
haloFrame,
|
||||
transitIcon,
|
||||
} from '../../data/transitIcons.js';
|
||||
import {
|
||||
presetSpriteOutlinePx,
|
||||
presetSpriteRgba,
|
||||
presetSpriteScale,
|
||||
transitStyleProfile,
|
||||
} from '../../data/transitPresetStyle.js';
|
||||
import {
|
||||
MARKER_PIXEL_SIZE,
|
||||
ROTATION_REFRESH_MS,
|
||||
SELECTED_CARD_REFRESH_MS,
|
||||
VISIBILITY_REFRESH_MS,
|
||||
SELECTED_PIXEL_SIZE,
|
||||
TRANSIT_MODE_COLORS,
|
||||
vehicleNearView,
|
||||
} from './policy.js';
|
||||
|
||||
export const MODE_CESIUM_COLORS = Object.fromEntries(
|
||||
Object.entries(TRANSIT_MODE_COLORS).map(([mode, css]) => [
|
||||
mode,
|
||||
Cesium.Color.fromCssColorString(css),
|
||||
]),
|
||||
);
|
||||
|
||||
/**
|
||||
* Billboards and the per-frame glide.
|
||||
* @param {object} context
|
||||
* @returns {object}
|
||||
*/
|
||||
export function createRendering({ state, services, parts }) {
|
||||
const {
|
||||
governorRequestRender,
|
||||
holdContinuousRender,
|
||||
releaseContinuousRender,
|
||||
} = services.render;
|
||||
|
||||
/**
|
||||
* Cartesian position for a lat/lon at a height. Called on POLL, not per
|
||||
* frame: `fromDegrees` runs trigonometry, and doing that for every vehicle on
|
||||
* every frame was most of what made this layer heavy.
|
||||
*/
|
||||
function cartesianFor(lat, lon, heightM, out) {
|
||||
// `heightM` already carries the shared floor's own lift, so nothing is
|
||||
// added here: two lifts stacked is a vehicle hovering.
|
||||
return Cesium.Cartesian3.fromDegrees(
|
||||
lon,
|
||||
lat,
|
||||
heightM || 0,
|
||||
undefined,
|
||||
out,
|
||||
);
|
||||
}
|
||||
|
||||
/** Heights are aligned to work with Google 3D tiles. Rebuild both ends. */
|
||||
function refreshEndpoints(entry) {
|
||||
if (!entry.sample || entry.sample.fromSeq < 0) return;
|
||||
entry.from = entry.segment.from;
|
||||
entry.to = entry.segment.to;
|
||||
entry.fromCart = cartesianFor(
|
||||
entry.from.lat,
|
||||
entry.from.lon,
|
||||
entry.from.h,
|
||||
entry.fromCart || new Cesium.Cartesian3(),
|
||||
);
|
||||
entry.toCart = cartesianFor(
|
||||
entry.to.lat,
|
||||
entry.to.lon,
|
||||
entry.to.h,
|
||||
entry.toCart || new Cesium.Cartesian3(),
|
||||
);
|
||||
entry.endpointRevision = entry.sample.revision;
|
||||
entry.endpointFromSeq = entry.sample.fromSeq;
|
||||
entry.endpointToSeq = entry.sample.toSeq;
|
||||
}
|
||||
function placeSample(entry, nearGround = parts.height.nearGround()) {
|
||||
if (
|
||||
entry.endpointRevision !== entry.sample.revision ||
|
||||
entry.endpointFromSeq !== entry.sample.fromSeq ||
|
||||
entry.endpointToSeq !== entry.sample.toSeq
|
||||
)
|
||||
refreshEndpoints(entry);
|
||||
const known = parts.trails.samplePosition(
|
||||
entry,
|
||||
entry.sample,
|
||||
state._scratchCartesian,
|
||||
nearGround,
|
||||
);
|
||||
entry.surfaceReady = !!known;
|
||||
entry.marker.show = !!known && vehicleInView(entry);
|
||||
if (known) {
|
||||
entry.marker.position = state._scratchCartesian;
|
||||
entry.hasRendered = true;
|
||||
}
|
||||
}
|
||||
function sampleIdle(entry) {
|
||||
if (!state._moving.has(entry)) {
|
||||
updatePlayback(entry, Date.now(), performance.now());
|
||||
placeSample(entry);
|
||||
}
|
||||
}
|
||||
|
||||
function cancelWake(entry) {
|
||||
if (entry.wakeTimer != null) clearTimeout(entry.wakeTimer);
|
||||
entry.wakeTimer = null;
|
||||
}
|
||||
function migrateMarker(entry, animated) {
|
||||
const collection = animated ? state._animatedMarkers : state._markers;
|
||||
if (!entry.marker || entry.markerCollection === collection) return;
|
||||
const old = entry.marker;
|
||||
const marker = collection.add({
|
||||
id: entry.key,
|
||||
position: old.position,
|
||||
image: old.image,
|
||||
width: old.width,
|
||||
height: old.height,
|
||||
color: old.color,
|
||||
scaleByDistance: old.scaleByDistance,
|
||||
rotation: old.rotation,
|
||||
alignedAxis: old.alignedAxis,
|
||||
show: old.show,
|
||||
disableDepthTestDistance: old.disableDepthTestDistance,
|
||||
});
|
||||
entry.markerCollection.remove(old);
|
||||
entry.marker = marker;
|
||||
entry.markerCollection = collection;
|
||||
entry.markerRevision = (entry.markerRevision || 0) + 1;
|
||||
// Cached contacts and card getters must follow the replacement billboard.
|
||||
if (entry.detectContact) entry.detectContact.position = marker.position;
|
||||
}
|
||||
function schedulePlayback(entry) {
|
||||
cancelWake(entry);
|
||||
if (!entry.marker || !vehicleInView(entry)) {
|
||||
state._moving.delete(entry);
|
||||
migrateMarker(entry, false);
|
||||
return;
|
||||
}
|
||||
const sample = entry.sample;
|
||||
if (!sample || entry.heightPending || entry.surfaceReady === false) {
|
||||
state._moving.delete(entry);
|
||||
migrateMarker(entry, false);
|
||||
return;
|
||||
}
|
||||
const positional = sample.phase === 'playing' && sample.segmentSpeedMps > 0;
|
||||
const turning =
|
||||
Number.isFinite(sample.segmentCourseDeg) &&
|
||||
Number.isFinite(entry.courseDeg) &&
|
||||
Math.abs(
|
||||
((sample.segmentCourseDeg - entry.courseDeg + 540) % 360) - 180,
|
||||
) > 0.01;
|
||||
if (positional || turning) {
|
||||
state._moving.add(entry);
|
||||
migrateMarker(entry, true);
|
||||
} else {
|
||||
state._moving.delete(entry);
|
||||
migrateMarker(entry, false);
|
||||
if (Number.isFinite(sample.nextWakeMonoMs)) {
|
||||
entry.wakeTimer = setTimeout(
|
||||
() => {
|
||||
entry.wakeTimer = null;
|
||||
if (!state._enabled || !state._vehicles.has(entry.key)) return;
|
||||
updatePlayback(entry, Date.now(), performance.now());
|
||||
placeSample(entry);
|
||||
schedulePlayback(entry);
|
||||
syncRenderHold();
|
||||
governorRequestRender('transit-motion-boundary');
|
||||
},
|
||||
Math.max(1, sample.nextWakeMonoMs - performance.now()),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether either end of the segment being drawn is near the view — the
|
||||
* test visibility, animation and rotation all share.
|
||||
* @param {object} entry
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function vehicleInView(entry) {
|
||||
return state._visible.has(entry);
|
||||
}
|
||||
|
||||
/** Cesium colour for a preset tuple, cached per profile+mode. */
|
||||
const _presetColors = new Map();
|
||||
function presetColor(style, mode) {
|
||||
const rgba = presetSpriteRgba(style, mode);
|
||||
if (!rgba) return null;
|
||||
const key = `${transitStyleProfile(style)}:${mode}`;
|
||||
let color = _presetColors.get(key);
|
||||
if (!color) {
|
||||
color = new Cesium.Color(
|
||||
rgba[0] / 255,
|
||||
rgba[1] / 255,
|
||||
rgba[2] / 255,
|
||||
rgba[3],
|
||||
);
|
||||
_presetColors.set(key, color);
|
||||
}
|
||||
return color;
|
||||
}
|
||||
|
||||
/**
|
||||
* The ONE place a sprite's look is decided: creation, a mode change, select,
|
||||
* deselect and a preset change all come here, so no path can undo another.
|
||||
* Under a mono preset (NVG, FLIR, noir) the sprite is white-hot with a dark
|
||||
* halo sized in screen pixels; under CRT a saturated mode colour with a
|
||||
* thinner halo; otherwise the shipped mode colour and hairline. Selection
|
||||
* grows the glyph and retains the mode colour; sensors use opaque white.
|
||||
* @param {object} entry
|
||||
*/
|
||||
function applySpriteStyle(entry) {
|
||||
const marker = entry.marker;
|
||||
if (!marker) return;
|
||||
const style = state._stylePreset;
|
||||
const selected = state._selectedKey === entry.key;
|
||||
const basePx = selected ? SELECTED_PIXEL_SIZE : MARKER_PIXEL_SIZE;
|
||||
const halo = presetSpriteOutlinePx(style, selected);
|
||||
const displayPx = basePx * presetSpriteScale(style, selected);
|
||||
const frame = haloFrame(halo, displayPx);
|
||||
const px = displayPx * frame.ratio;
|
||||
marker.image = transitIcon(
|
||||
entry.mode,
|
||||
selected ? SELECTED_ICON_PX : undefined,
|
||||
{
|
||||
style,
|
||||
},
|
||||
);
|
||||
marker.width = px;
|
||||
marker.height = px;
|
||||
if (entry.detectContact) {
|
||||
entry.detectContact.bracketHalfWidth = Math.ceil(px / 2) + 2;
|
||||
entry.detectContact.bracketHalfHeight = Math.ceil(px / 2) + 2;
|
||||
}
|
||||
const preset = presetColor(style, entry.mode);
|
||||
if (preset) marker.color = preset;
|
||||
else
|
||||
marker.color =
|
||||
MODE_CESIUM_COLORS[entry.mode] || MODE_CESIUM_COLORS.unknown;
|
||||
}
|
||||
|
||||
function addMarker(key, entry) {
|
||||
const marker = state._markers.add({
|
||||
id: key,
|
||||
position: entry.toCart,
|
||||
image: transitIcon(entry.mode),
|
||||
width: MARKER_PIXEL_SIZE,
|
||||
height: MARKER_PIXEL_SIZE,
|
||||
color: MODE_CESIUM_COLORS[entry.mode],
|
||||
rotation: 0,
|
||||
alignedAxis: Cesium.Cartesian3.ZERO,
|
||||
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
||||
});
|
||||
entry.marker = marker;
|
||||
entry.markerCollection = state._markers;
|
||||
marker.show = false;
|
||||
applySpriteStyle(entry);
|
||||
return marker;
|
||||
}
|
||||
|
||||
function paintMode(entry, mode) {
|
||||
entry.mode = mode;
|
||||
if (entry.key === state._selectedKey) {
|
||||
parts.trails.style();
|
||||
parts.selection.refreshSelectedCard(true);
|
||||
}
|
||||
state._detectRevision += 1;
|
||||
applySpriteStyle(entry);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adopt a post-FX style. One pass over the fleet, on the event, never per
|
||||
* frame; a style already applied is a no-op.
|
||||
* @param {string|null|undefined} styleName
|
||||
*/
|
||||
function setStylePreset(styleName) {
|
||||
const next = styleName || 'normal';
|
||||
if (next === state._stylePreset) return;
|
||||
state._stylePreset = next;
|
||||
for (const entry of state._vehicles.values()) applySpriteStyle(entry);
|
||||
parts.trails.style();
|
||||
governorRequestRender('transit-style');
|
||||
}
|
||||
|
||||
/**
|
||||
* Point every glyph along its reported course.
|
||||
*
|
||||
* Billboards are camera-facing quads, so a course has to be projected into
|
||||
* screen space — which depends on where the camera is, not only on the
|
||||
* vehicle. Doing that for the whole fleet on every frame is exactly the kind
|
||||
* of cost that made this layer heavy, and it buys nothing: a bus icon whose
|
||||
* rotation is a fifth of a second stale is indistinguishable. So the pass is
|
||||
* throttled, and runs only when the camera has actually moved or a poll
|
||||
* changed somebody's heading.
|
||||
*/
|
||||
function refreshRotations(now) {
|
||||
const scene = state._viewer?.scene;
|
||||
if (!scene?.camera) return;
|
||||
if (now - state._rotationAt < ROTATION_REFRESH_MS) return;
|
||||
if (
|
||||
state._rotationRevision === state._cameraRevision &&
|
||||
!state._rotationDirty &&
|
||||
state._moving.size === 0
|
||||
)
|
||||
return;
|
||||
state._rotationAt = now;
|
||||
const cameraChanged = state._rotationRevision !== state._cameraRevision;
|
||||
state._rotationRevision = state._cameraRevision;
|
||||
state._rotationDirty = false;
|
||||
for (const entry of state._visible) {
|
||||
if (entry.key === state._selectedKey) continue;
|
||||
const course = entry.courseDeg;
|
||||
if (course === null) continue;
|
||||
const position = entry.marker.position;
|
||||
if (
|
||||
!cameraChanged &&
|
||||
entry.rotationCourse === course &&
|
||||
Math.abs(position.x - entry.rotationX) +
|
||||
Math.abs(position.y - entry.rotationY) +
|
||||
Math.abs(position.z - entry.rotationZ) <
|
||||
10
|
||||
)
|
||||
continue;
|
||||
entry.rotationCourse = course;
|
||||
entry.rotationX = position.x;
|
||||
entry.rotationY = position.y;
|
||||
entry.rotationZ = position.z;
|
||||
// Exact projection, perspective division included. The shared
|
||||
// camera-basis helper aircraft use is orthographic — fine at altitude,
|
||||
// and wrong by twenty-odd degrees for a street-level contact away from
|
||||
// the centre of an obliquely pitched view, which is most of them.
|
||||
entry.marker.rotation = perspectiveProjectedRotation(
|
||||
scene,
|
||||
entry.marker.position,
|
||||
course,
|
||||
entry.marker.rotation,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Publish final motion wording when the selected contact settles. */
|
||||
function settleSelectedCard(entry) {
|
||||
if (state._selectedKey && state._selectedKey === entry.key) {
|
||||
parts.selection.refreshSelectedCard(true);
|
||||
}
|
||||
}
|
||||
|
||||
/** Hold only for visible position or orientation animation. */
|
||||
function syncRenderHold() {
|
||||
// Held for motion only. Ground resolution runs on its own timer off the
|
||||
// render path, so a fleet waiting for floors is not a fleet worth burning
|
||||
// frames on — it is not moving.
|
||||
const working = state._enabled && state._moving.size > 0;
|
||||
if (working && !state._renderHeld) {
|
||||
holdContinuousRender('transit');
|
||||
state._renderHeld = true;
|
||||
} else if (!working && state._renderHeld) {
|
||||
releaseContinuousRender('transit');
|
||||
state._renderHeld = false;
|
||||
governorRequestRender('transit-settled');
|
||||
}
|
||||
}
|
||||
|
||||
function onPreRender() {
|
||||
if (
|
||||
typeof document !== 'undefined' &&
|
||||
document.visibilityState === 'hidden'
|
||||
)
|
||||
return;
|
||||
if (!state._enabled) return;
|
||||
const now = Date.now();
|
||||
if (state._vehicles.size === 0) {
|
||||
state._moving.clear();
|
||||
syncRenderHold();
|
||||
return;
|
||||
}
|
||||
|
||||
const monoNow = performance.now();
|
||||
const nearGround = parts.height.nearGround();
|
||||
let dirtyCount = 0;
|
||||
for (const entry of state._heightDirty) {
|
||||
if (dirtyCount++ >= 64) break;
|
||||
state._heightDirty.delete(entry);
|
||||
if (!entry.marker) continue;
|
||||
updatePlayback(entry, now, monoNow);
|
||||
placeSample(entry, nearGround);
|
||||
schedulePlayback(entry);
|
||||
}
|
||||
for (const entry of state._moving) {
|
||||
if (!entry.marker || !vehicleInView(entry)) {
|
||||
state._moving.delete(entry);
|
||||
continue;
|
||||
}
|
||||
updatePlayback(entry, now, monoNow);
|
||||
placeSample(entry, nearGround);
|
||||
if (applyDisplayCourse(entry, monoNow)) state._rotationDirty = true;
|
||||
if (
|
||||
!entry.surfaceReady ||
|
||||
entry.sample.phase !== 'playing' ||
|
||||
entry.sample.segmentSpeedMps === 0
|
||||
)
|
||||
schedulePlayback(entry);
|
||||
if (!state._moving.has(entry)) settleSelectedCard(entry);
|
||||
}
|
||||
|
||||
parts.trails.update();
|
||||
refreshRotations(now);
|
||||
const selected = state._vehicles.get(state._selectedKey);
|
||||
if (
|
||||
selected &&
|
||||
state._visible.has(selected) &&
|
||||
selected.courseDeg !== null
|
||||
) {
|
||||
selected.marker.rotation = perspectiveProjectedRotation(
|
||||
state._viewer.scene,
|
||||
selected.marker.position,
|
||||
selected.courseDeg,
|
||||
selected.marker.rotation,
|
||||
);
|
||||
}
|
||||
if (state._heightDirty.size) governorRequestRender('transit-height-batch');
|
||||
syncRenderHold();
|
||||
}
|
||||
|
||||
const occluder = new Cesium.EllipsoidalOccluder(Cesium.Ellipsoid.WGS84);
|
||||
const sphere = new Cesium.BoundingSphere(undefined, 5);
|
||||
const cameraPosition = new Cesium.Cartesian3();
|
||||
const candidatePosition = new Cesium.Cartesian3();
|
||||
/** Visibility work runs off the frame path, coalesced at four sweeps/second. */
|
||||
function requestVisibility() {
|
||||
state._visibilityDirty = true;
|
||||
if (!state._enabled || state._visibilityTimer !== null) return;
|
||||
state._visibilityTimer = setTimeout(
|
||||
() => {
|
||||
state._visibilityTimer = null;
|
||||
refreshVisibility();
|
||||
},
|
||||
Math.max(0, VISIBILITY_REFRESH_MS - (Date.now() - state._visibilityAt)),
|
||||
);
|
||||
}
|
||||
function refreshVisibility() {
|
||||
if (!state._enabled) return 0;
|
||||
const now = Date.now();
|
||||
if (now - state._visibilityAt < VISIBILITY_REFRESH_MS) {
|
||||
requestVisibility();
|
||||
return state._shownCount;
|
||||
}
|
||||
state._visibilityAt = now;
|
||||
state._visibilityDirty = false;
|
||||
const viewer = state._viewer,
|
||||
camera = viewer?.camera;
|
||||
if (!camera) return 0;
|
||||
const position =
|
||||
camera.positionWC ||
|
||||
Cesium.Ellipsoid.WGS84.cartographicToCartesian(
|
||||
camera.positionCartographic,
|
||||
cameraPosition,
|
||||
);
|
||||
occluder.cameraPosition = position;
|
||||
const frustum = camera.frustum?.computeCullingVolume(
|
||||
position,
|
||||
camera.directionWC,
|
||||
camera.upWC,
|
||||
);
|
||||
const planes = frustum?.planes;
|
||||
let changed = false;
|
||||
for (const entry of state._vehicles.values()) {
|
||||
if (!entry.marker) continue;
|
||||
// Refresh hidden clocks before testing admission, even if no frame has
|
||||
// ever sampled them. Do not write the billboard until it is admitted.
|
||||
const noSample = !entry.sample || !Number.isFinite(entry.sample.lat);
|
||||
if (entry.track?.count && (!state._visible.has(entry) || noSample)) {
|
||||
entry.sample ||= {};
|
||||
updatePlayback(entry, now, performance.now());
|
||||
}
|
||||
sphere.center =
|
||||
state._visible.has(entry) || !entry.sample
|
||||
? entry.marker.position
|
||||
: cartesianFor(
|
||||
entry.sample.lat,
|
||||
entry.sample.lon,
|
||||
entry.heightM,
|
||||
candidatePosition,
|
||||
);
|
||||
// A settled geographic rectangle can lag setView or miss an oblique
|
||||
// view. The current frustum is authoritative; the horizon still rejects
|
||||
// the far side when the rectangle is missing or stale.
|
||||
const bounds = vehicleNearView(state._viewBounds, entry);
|
||||
const horizon = occluder.isPointVisible(sphere.center);
|
||||
let inFrustum = true;
|
||||
if (planes) {
|
||||
for (let i = 0; i < planes.length; i++) {
|
||||
const p = planes[i],
|
||||
c = sphere.center;
|
||||
if (p.x * c.x + p.y * c.y + p.z * c.z + p.w < -sphere.radius) {
|
||||
inFrustum = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else if (frustum)
|
||||
inFrustum =
|
||||
frustum.computeVisibility(sphere) !== Cesium.Intersect.OUTSIDE;
|
||||
const visible = (frustum || bounds) && horizon && inFrustum;
|
||||
const visibility = (entry.visibility ||= {});
|
||||
visibility.bounds = bounds;
|
||||
visibility.boundsApplied = !frustum;
|
||||
visibility.occluder = horizon;
|
||||
visibility.frustum = inFrustum;
|
||||
visibility.noSample = noSample;
|
||||
const wasShown = entry.marker.show;
|
||||
const wasVisible = state._visible.has(entry);
|
||||
if (visible) state._visible.add(entry);
|
||||
else state._visible.delete(entry);
|
||||
if (visible && entry.track?.count) {
|
||||
if (!wasVisible && !entry.qaFixture) syncPlayback(entry, now);
|
||||
// Surface completion must recover while requestRenderMode is idle.
|
||||
// Otherwise surfaceReady=false prevents the very frame that clears it.
|
||||
if (!state._moving.has(entry)) {
|
||||
updatePlayback(entry, now, performance.now());
|
||||
placeSample(entry);
|
||||
}
|
||||
}
|
||||
entry.marker.show =
|
||||
visible && !entry.heightPending && entry.surfaceReady !== false;
|
||||
schedulePlayback(entry);
|
||||
changed ||= visible !== wasVisible || wasShown !== entry.marker.show;
|
||||
}
|
||||
state._shownCount = state._visible.size;
|
||||
if (changed) state._detectRevision++;
|
||||
syncRenderHold();
|
||||
governorRequestRender('transit-visibility');
|
||||
return state._shownCount;
|
||||
}
|
||||
function resumePlayback() {
|
||||
if (!state._enabled) return;
|
||||
const now = Date.now(),
|
||||
mono = performance.now();
|
||||
const entries = new Set(state._visible);
|
||||
const selected = state._vehicles.get(state._selectedKey);
|
||||
if (selected) entries.add(selected);
|
||||
for (const entry of entries) {
|
||||
if (!entry.track?.count) continue;
|
||||
syncPlayback(entry, now, mono);
|
||||
parts.trails.prepareEntry(entry);
|
||||
placeSample(entry);
|
||||
schedulePlayback(entry);
|
||||
}
|
||||
parts.trails.update();
|
||||
parts.selection.refreshSelectedCard(true);
|
||||
state._detectRevision++;
|
||||
syncRenderHold();
|
||||
governorRequestRender('transit-re-entry');
|
||||
}
|
||||
// Text and membership refreshes are bounded timer work, never paint work.
|
||||
function maintainPresentation() {
|
||||
if (!state._enabled) return;
|
||||
if (state._moving.size) requestVisibility();
|
||||
parts.queries.refreshDetectCache();
|
||||
parts.selection.refreshSelectedCard(false);
|
||||
}
|
||||
|
||||
/** Grow the selected glyph (or shrink it back) through the one styling path. */
|
||||
function paintSelected(entry, selected) {
|
||||
void selected; // decided from state._selectedKey, which the caller has set
|
||||
applySpriteStyle(entry);
|
||||
}
|
||||
|
||||
return {
|
||||
resumePlayback,
|
||||
maintainPresentation,
|
||||
requestVisibility,
|
||||
sampleIdle,
|
||||
cancelWake,
|
||||
schedulePlayback,
|
||||
placeSample,
|
||||
cartesianFor,
|
||||
refreshEndpoints,
|
||||
refreshVisibility,
|
||||
vehicleInView,
|
||||
addMarker,
|
||||
paintMode,
|
||||
paintSelected,
|
||||
applySpriteStyle,
|
||||
setStylePreset,
|
||||
onPreRender,
|
||||
syncRenderHold,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
SELECTED_CARD_REFRESH_MS,
|
||||
TRANSIT_SELECTED_OVERLAY_SOURCE_ID,
|
||||
TRANSIT_SELECTED_OVERLAY_SOURCE_OPTIONS,
|
||||
buildTransitSelectionCopy,
|
||||
createTransitSelectedOverlayEntry,
|
||||
} from './policy.js';
|
||||
import { getRegisteredTransitFeed } from '../../data/transitFeeds.js';
|
||||
import { isPointerFree } from '../../data/inputOwnership.js';
|
||||
|
||||
/**
|
||||
* Click-to-select one vehicle and a card that follows it.
|
||||
* @param {object} context
|
||||
* @returns {object}
|
||||
*/
|
||||
export function createSelection({ state, services, parts }) {
|
||||
const { governorRequestRender } = services.render;
|
||||
|
||||
/**
|
||||
* The card's anchor: the selected marker's CURRENT position, read by the
|
||||
* overlay host on every paint. A getter, not a clone — the host accepts one
|
||||
* (the tracked-aircraft readout uses the same), so the anchor moves with the
|
||||
* sprite at frame rate, through poll arrivals, settling, a late floor and a
|
||||
* reset alike, with no per-frame work here. Null when there is nothing to
|
||||
* anchor to: a hidden or removed marker draws no card.
|
||||
* @param {object} entry
|
||||
* @returns {() => Cesium.Cartesian3|null}
|
||||
*/
|
||||
function anchorFor(entry) {
|
||||
return () => {
|
||||
const marker = entry.marker;
|
||||
const p = marker?.show !== false ? marker?.position : null;
|
||||
return p &&
|
||||
Number.isFinite(p.x) &&
|
||||
Number.isFinite(p.y) &&
|
||||
Number.isFinite(p.z)
|
||||
? p
|
||||
: null;
|
||||
};
|
||||
}
|
||||
|
||||
function refreshSelectedCard(force) {
|
||||
const entry = state._selectedKey
|
||||
? state._vehicles.get(state._selectedKey)
|
||||
: null;
|
||||
if (!entry) return;
|
||||
const now = Date.now();
|
||||
if (!force && now - state._selectedCardAt < SELECTED_CARD_REFRESH_MS)
|
||||
return;
|
||||
state._selectedCardAt = now;
|
||||
const feed =
|
||||
state._activeFeeds.get(entry.feedId) ||
|
||||
getRegisteredTransitFeed(entry.feedId);
|
||||
if (!feed) return;
|
||||
const copy = buildTransitSelectionCopy(
|
||||
feed,
|
||||
entry.record,
|
||||
entry.mode,
|
||||
now,
|
||||
entry.fetchedAt,
|
||||
entry,
|
||||
);
|
||||
// The TEXT is what the throttle is for, and it is republished only when
|
||||
// it has changed: the anchor is live through its getter, so re-sending
|
||||
// an identical card would only make the host re-solve its layout.
|
||||
const text = `${entry.key}\u0000${copy.title}\u0000${copy.details.join('\u0000')}`;
|
||||
if (!force && text === state._selectedCardText) return;
|
||||
const card = createTransitSelectedOverlayEntry(
|
||||
entry.key,
|
||||
anchorFor(entry),
|
||||
copy,
|
||||
entry.mode,
|
||||
);
|
||||
if (card) {
|
||||
state._selectedCardText = text;
|
||||
state._overlayHost.setEntries(
|
||||
TRANSIT_SELECTED_OVERLAY_SOURCE_ID,
|
||||
[card],
|
||||
TRANSIT_SELECTED_OVERLAY_SOURCE_OPTIONS,
|
||||
);
|
||||
state._cardPublications = (state._cardPublications || 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
const entry = state._selectedKey
|
||||
? state._vehicles.get(state._selectedKey)
|
||||
: null;
|
||||
parts.trails.clear();
|
||||
state._selectedKey = null;
|
||||
state._selectedCardText = null;
|
||||
state._detectRevision += 1;
|
||||
// Cleared first: the styling path reads the selection from state.
|
||||
if (entry) parts.rendering.paintSelected(entry, false);
|
||||
state._overlayHost.clearSource(TRANSIT_SELECTED_OVERLAY_SOURCE_ID);
|
||||
}
|
||||
|
||||
function selectVehicle(key) {
|
||||
clearSelection();
|
||||
const entry = state._vehicles.get(key);
|
||||
if (!entry?.marker) return;
|
||||
state._selectedKey = key;
|
||||
state._detectRevision += 1;
|
||||
parts.rendering.paintSelected(entry, true);
|
||||
parts.trails.select(entry);
|
||||
refreshSelectedCard(true);
|
||||
governorRequestRender('transit-select');
|
||||
}
|
||||
|
||||
function onKeyDown(event) {
|
||||
if (event.key === 'Escape' && state._selectedKey) clearSelection();
|
||||
}
|
||||
|
||||
function installClickHandler(viewer) {
|
||||
if (state._clickHandler) return;
|
||||
state._clickHandler = new Cesium.ScreenSpaceEventHandler(
|
||||
viewer.scene.canvas,
|
||||
);
|
||||
state._clickHandler.setInputAction((click) => {
|
||||
// Ambient selection never competes for a pointer a tool has claimed:
|
||||
// placing a draw vertex on top of a bus must not also select the bus.
|
||||
if (!isPointerFree()) return;
|
||||
const scene = viewer.scene;
|
||||
// Pick buffers precede post-processing. Map a sensor pixel back through
|
||||
// the active shader's barrel distortion and quantization before picking.
|
||||
const position = Cesium.Cartesian2.clone(click.position);
|
||||
const style = state._stylePreset;
|
||||
const stage = scene.postProcessStages?.getStageByName?.(
|
||||
`godsEyeView_${style}`,
|
||||
);
|
||||
if (stage?.enabled && (style === 'thermal' || style === 'surveillance')) {
|
||||
const intensity = stage.uniforms.intensity;
|
||||
const canvas = scene.canvas,
|
||||
w = canvas.clientWidth,
|
||||
h = canvas.clientHeight;
|
||||
let u = position.x / w,
|
||||
v = 1 - position.y / h;
|
||||
if (style === 'surveillance') {
|
||||
const x = u * 2 - 1,
|
||||
y = v * 2 - 1,
|
||||
r2 = x * x + y * y;
|
||||
const d = 1 + r2 * intensity * 0.25 + r2 * r2 * intensity * 0.075;
|
||||
u = (x * d + 1) / 2;
|
||||
v = (y * d + 1) / 2;
|
||||
}
|
||||
const grid = 1 + (stage.uniforms.pixelation - 1) * intensity;
|
||||
u +=
|
||||
((Math.floor((u * canvas.width) / grid) * grid) / canvas.width - u) *
|
||||
intensity;
|
||||
v +=
|
||||
((Math.floor((v * canvas.height) / grid) * grid) / canvas.height -
|
||||
v) *
|
||||
intensity;
|
||||
position.x = u * w;
|
||||
position.y = (1 - v) * h;
|
||||
}
|
||||
const picked = scene.pick(position);
|
||||
state._lastPickForTest = {
|
||||
id: typeof picked?.id === 'string' ? picked.id : null,
|
||||
primitiveId:
|
||||
typeof picked?.primitive?.id === 'string'
|
||||
? picked.primitive.id
|
||||
: null,
|
||||
x: position.x,
|
||||
y: position.y,
|
||||
collection:
|
||||
picked?.primitive?._billboardCollection === state._animatedMarkers
|
||||
? 'animated'
|
||||
: picked?.primitive?._billboardCollection === state._markers
|
||||
? 'stationary'
|
||||
: null,
|
||||
};
|
||||
if (picked) {
|
||||
const primitiveId = picked.primitive?.id;
|
||||
if (
|
||||
typeof primitiveId === 'string' &&
|
||||
state._vehicles.has(primitiveId)
|
||||
) {
|
||||
selectVehicle(primitiveId);
|
||||
return;
|
||||
}
|
||||
if (typeof picked.id === 'string' && state._vehicles.has(picked.id)) {
|
||||
selectVehicle(picked.id);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (state._selectedKey) clearSelection();
|
||||
}, Cesium.ScreenSpaceEventType.LEFT_CLICK);
|
||||
document.addEventListener('keydown', onKeyDown);
|
||||
}
|
||||
|
||||
function removeClickHandler() {
|
||||
if (state._clickHandler) {
|
||||
state._clickHandler.destroy();
|
||||
state._clickHandler = null;
|
||||
}
|
||||
document.removeEventListener('keydown', onKeyDown);
|
||||
}
|
||||
|
||||
return {
|
||||
refreshSelectedCard,
|
||||
clearSelection,
|
||||
selectVehicle,
|
||||
onKeyDown,
|
||||
installClickHandler,
|
||||
removeClickHandler,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { createHistoryBudget } from '../../data/contactPlayback.js';
|
||||
|
||||
/**
|
||||
* One Transit layer's mutable scene state. Every field lives on the instance,
|
||||
* so two layers built from this factory never share a viewer, a primitive
|
||||
* collection, a request map or a height cache.
|
||||
* @param {{services: object}} context
|
||||
* @returns {object}
|
||||
*/
|
||||
export function createState({ services }) {
|
||||
const { setOverlayEntries, setOverlaySourceVisible, clearOverlaySource } =
|
||||
services.overlays;
|
||||
|
||||
const state = {};
|
||||
state._historyBudget = createHistoryBudget();
|
||||
|
||||
state.DEFAULT_OVERLAY_HOST = Object.freeze({
|
||||
setEntries: setOverlayEntries,
|
||||
setVisible: setOverlaySourceVisible,
|
||||
clearSource: clearOverlaySource,
|
||||
});
|
||||
state._overlayHost = state.DEFAULT_OVERLAY_HOST;
|
||||
|
||||
/** @type {Cesium.Viewer|null} */
|
||||
state._viewer = null;
|
||||
/** @type {Cesium.BillboardCollection|null} */
|
||||
state._markers = null;
|
||||
state._animatedMarkers = null;
|
||||
state._visible = new Set();
|
||||
state._visibilityTimer = null;
|
||||
state._maintenanceTimer = null;
|
||||
state._detectBuiltAt = -Infinity;
|
||||
state._cameraRevision = 0;
|
||||
state._rotationRevision = -1;
|
||||
state._enabled = false;
|
||||
state._generation = 0;
|
||||
|
||||
state._cameraChangedAttached = false;
|
||||
state._cameraDebounceTimer = null;
|
||||
state._altitudeGateOpen = false;
|
||||
/** Camera sensitivity as we found it, so disable() can hand it back. */
|
||||
state._priorPercentageChanged = null;
|
||||
/** The value this layer actually wrote, for an identity-guarded restore. */
|
||||
state._appliedPercentageChanged = null;
|
||||
|
||||
/** @type {(() => void)|null} */
|
||||
state._preRenderRemove = null;
|
||||
state._renderHeld = false;
|
||||
/** @type {Cesium.ScreenSpaceEventHandler|null} */
|
||||
state._clickHandler = null;
|
||||
/**
|
||||
* The vehicles currently mid-glide. The per-frame pass walks THIS, not the
|
||||
* whole fleet, and the render hold is derived from it together with the
|
||||
* height queue — so both track work that exists rather than a window of time
|
||||
* the layer hoped would contain some.
|
||||
* @type {Set<object>}
|
||||
*/
|
||||
state._moving = new Set();
|
||||
/** Vehicles whose sampled height landed after they were drawn. @type {Set<object>} */
|
||||
state._heightDirty = new Set();
|
||||
/**
|
||||
* Inflated camera view bounds in degrees, or null before the first camera
|
||||
* read. Decides which vehicles are worth animating frame by frame.
|
||||
* @type {{south:number,north:number,west:number,east:number}|null}
|
||||
*/
|
||||
state._viewBounds = null;
|
||||
/** How many vehicles are currently drawn, as opposed to held. */
|
||||
state._shownCount = 0;
|
||||
/** Deferred shown/hidden sweep bookkeeping. */
|
||||
state._visibilityDirty = true;
|
||||
state._visibilityAt = 0;
|
||||
/** Throttle bookkeeping for the screen-space rotation pass. */
|
||||
state._rotationAt = 0;
|
||||
state._rotationPose = null;
|
||||
state._rotationDirty = false;
|
||||
|
||||
/** @type {Map<string, object>} feedId → registry entry currently polled */
|
||||
state._activeFeeds = new Map();
|
||||
/** @type {Map<string, {count:number, lastUpdate:number|null, error:string|null, stale:boolean, pollSeq:number, loading:boolean}>} */
|
||||
state._feedStatus = new Map();
|
||||
/** @type {Map<string, {controller: AbortController, promise: Promise<void>}>} */
|
||||
state._inFlight = new Map();
|
||||
/** @type {Map<string, object>} vehicle key → runtime entry */
|
||||
state._vehicles = new Map();
|
||||
|
||||
/** Timer for the floor re-read cycle, and how many it has spent. */
|
||||
state._floorTimer = null;
|
||||
state._floorAttempts = 0;
|
||||
/** Where the next warm window starts among the cold cells: no cell waits for ever. */
|
||||
state._floorCursor = 0;
|
||||
|
||||
/** @type {string|null} */
|
||||
state._selectedKey = null;
|
||||
state._selectedCardAt = 0;
|
||||
/** The card text last published, so an unchanged card is not re-sent. */
|
||||
state._selectedCardText = null;
|
||||
/**
|
||||
* Bumped whenever the set of vehicles DETECT may see could have changed —
|
||||
* a poll, a visibility sweep, a removal, a selection — so the candidate
|
||||
* list is rebuilt on change rather than on every paint.
|
||||
*/
|
||||
state._detectRevision = 0;
|
||||
/** The candidate list DETECT was last handed, and the revision it was built at. */
|
||||
state._detectCache = null;
|
||||
/**
|
||||
* Active post-FX style the sprites are drawn for. Read from the document
|
||||
* at init and followed through the style and cockpit-vision events; a
|
||||
* cockpit override wins over the map style while it lasts.
|
||||
*/
|
||||
state._stylePreset = 'normal';
|
||||
state._cockpitVision = false;
|
||||
/** @type {Array<[string, EventListener]>} window listeners to remove on destroy. */
|
||||
state._styleListeners = [];
|
||||
/** @type {{ refreshLayerStats?: () => void }|null} */
|
||||
state._dataManager = null;
|
||||
/** @type {number|null} */
|
||||
state._lastUpdate = null;
|
||||
/** @type {string|null} */
|
||||
state._error = null;
|
||||
state._limitWarned = false;
|
||||
|
||||
state._scratchCartesian = new Cesium.Cartesian3();
|
||||
|
||||
return state;
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { recordFix, updatePlayback, attachFloorToPlace } from './movement.js';
|
||||
import { seek, setRate, mergeHistory } from '../../data/contactPlayback.js';
|
||||
import { getRegisteredTransitFeed } from '../../data/transitFeeds.js';
|
||||
/**
|
||||
* Test seams. Each one reaches into THIS instance's state, so a test that
|
||||
* swaps the overlay host or inspects vehicles cannot leak into another layer.
|
||||
* @param {object} context
|
||||
* @returns {object}
|
||||
*/
|
||||
import { transitIconCacheSize } from '../../data/transitIcons.js';
|
||||
|
||||
export function createTesting({ state, parts }) {
|
||||
let stopProbe = null;
|
||||
return {
|
||||
_setTransitFixtureFloorsForTest(keys, heightM) {
|
||||
state._qaFixtureFloors = new Map(keys.map((key) => [key, heightM]));
|
||||
},
|
||||
_stopTransitProbeForTest() {
|
||||
stopProbe?.();
|
||||
},
|
||||
/** Deterministic observed-position fixtures, independent of upstream availability. */
|
||||
_loadTransitFleetForTest(count, origin, spacingM = 6, routes = ['741']) {
|
||||
parts.selection.clearSelection();
|
||||
parts.ingestion.abortAllInFlight();
|
||||
state._activeFeeds.clear();
|
||||
for (const key of state._vehicles.keys())
|
||||
parts.ingestion.removeVehicle(key);
|
||||
clearTimeout(state._cameraDebounceTimer);
|
||||
state._cameraDebounceTimer = null;
|
||||
const feed = getRegisteredTransitFeed('mbta');
|
||||
const now = Date.now(),
|
||||
mono = performance.now();
|
||||
const cols = Math.ceil(Math.sqrt(count));
|
||||
const vehicles = Array.from({ length: count }, (_, i) => ({
|
||||
id: `qa-${i}`,
|
||||
routeId: routes[i % routes.length],
|
||||
tripId: 'qa',
|
||||
lat:
|
||||
origin.lat + ((Math.floor(i / cols) - cols / 2) * spacingM) / 111320,
|
||||
lon:
|
||||
origin.lon +
|
||||
(((i % cols) - cols / 2) * spacingM) /
|
||||
(111320 * Math.cos((origin.lat * Math.PI) / 180)),
|
||||
timestamp: (now - 120000) / 1000,
|
||||
timestampSource: 'vehicle',
|
||||
bearing: 0,
|
||||
}));
|
||||
parts.ingestion.applySnapshot(
|
||||
feed,
|
||||
{ fetchedAt: now, vehicles },
|
||||
{ stale: false },
|
||||
);
|
||||
let fixtureIndex = 0;
|
||||
for (const entry of state._vehicles.values()) {
|
||||
entry.qaIndex = fixtureIndex++;
|
||||
entry.qaFixture = true;
|
||||
entry.qaFloorM = 20;
|
||||
for (let j = 1; j <= 8; j++) {
|
||||
const t = now - 120000 + j * 15000;
|
||||
recordFix(
|
||||
entry,
|
||||
{
|
||||
t,
|
||||
lat: entry.record.lat + (j * 15) / 111320,
|
||||
lon: entry.record.lon,
|
||||
bearingDeg: 0,
|
||||
},
|
||||
{
|
||||
receivedAt: t + 1000,
|
||||
wallNowMs: t + 1000,
|
||||
monoNowMs: mono - now + t + 1000,
|
||||
},
|
||||
);
|
||||
}
|
||||
mergeHistory(
|
||||
entry.track,
|
||||
Array.from({ length: 119 }, (_, i) => {
|
||||
const t = now - 899000 + i * (764000 / 118);
|
||||
return {
|
||||
t,
|
||||
lat: entry.record.lat + (t - now + 120000) / 1000 / 111320,
|
||||
lon: entry.record.lon,
|
||||
bearingDeg: 0,
|
||||
epoch: entry.currentEpoch,
|
||||
};
|
||||
}),
|
||||
);
|
||||
// This fixture deliberately provides a flat, known surface for load measurements.
|
||||
for (const fix of entry.fixes) attachFloorToPlace(entry, fix, 20);
|
||||
entry.heightPending = false;
|
||||
entry.surfaceReady = true;
|
||||
entry.displayPaths = new Map();
|
||||
const fixes = entry.fixes;
|
||||
for (let i = 0; i + 1 < fixes.length; i++) {
|
||||
if (fixes[i + 1].t < now - 72000) continue;
|
||||
entry.displayPaths.set(fixes[i].seq, {
|
||||
toSeq: fixes[i + 1].seq,
|
||||
positions: [
|
||||
Cesium.Cartesian3.fromDegrees(fixes[i].lon, fixes[i].lat, 20),
|
||||
Cesium.Cartesian3.fromDegrees(
|
||||
fixes[i + 1].lon,
|
||||
fixes[i + 1].lat,
|
||||
20,
|
||||
),
|
||||
],
|
||||
});
|
||||
}
|
||||
entry.clocks.wallNowMs = now;
|
||||
entry.clocks.monoNowMs = mono;
|
||||
seek(entry.track, now - 72000, entry.clocks);
|
||||
setRate(entry.track, 1, entry.clocks);
|
||||
updatePlayback(entry, now, mono);
|
||||
parts.rendering.placeSample(entry);
|
||||
}
|
||||
clearTimeout(state._floorTimer);
|
||||
state._floorTimer = null;
|
||||
// Establish the fixture clock without the live re-entry reset.
|
||||
for (const entry of state._vehicles.values()) state._visible.add(entry);
|
||||
state._visibilityAt = 0;
|
||||
parts.rendering.refreshVisibility();
|
||||
parts.rendering.maintainPresentation();
|
||||
return {
|
||||
count: state._vehicles.size,
|
||||
moving: state._moving.size,
|
||||
fixBytes: state._historyBudget.allocatedBytes,
|
||||
};
|
||||
},
|
||||
_resetTransitFixtureClockForTest() {
|
||||
const now = Date.now(),
|
||||
mono = performance.now();
|
||||
for (const entry of state._vehicles.values()) {
|
||||
// A backwards QA seek revisits paths pruned during the timing run.
|
||||
// Rebuild the same explicitly flat fixture surface before allocation QA.
|
||||
const fixes = entry.fixes;
|
||||
entry.displayPaths = new Map();
|
||||
for (let i = 0; i + 1 < fixes.length; i++) {
|
||||
if (fixes[i + 1].t < fixes.at(-1).t - 72000) continue;
|
||||
entry.displayPaths.set(fixes[i].seq, {
|
||||
toSeq: fixes[i + 1].seq,
|
||||
positions: [
|
||||
Cesium.Cartesian3.fromDegrees(fixes[i].lon, fixes[i].lat, 20),
|
||||
Cesium.Cartesian3.fromDegrees(
|
||||
fixes[i + 1].lon,
|
||||
fixes[i + 1].lat,
|
||||
20,
|
||||
),
|
||||
],
|
||||
});
|
||||
}
|
||||
entry.clocks.wallNowMs = now;
|
||||
entry.clocks.monoNowMs = mono;
|
||||
seek(entry.track, entry.fixes.at(-1).t - 72000, entry.clocks);
|
||||
setRate(entry.track, 1, entry.clocks);
|
||||
updatePlayback(entry, now, mono);
|
||||
parts.rendering.placeSample(entry);
|
||||
parts.rendering.schedulePlayback(entry);
|
||||
}
|
||||
parts.rendering.syncRenderHold();
|
||||
},
|
||||
/** Measure inside Cesium postRender with fixed typed storage; no sample objects per frame. */
|
||||
_measureTransitFramesForTest(durationMs = 12000) {
|
||||
stopProbe?.();
|
||||
const scene = state._viewer.scene;
|
||||
const capacity = 20000,
|
||||
stride = 12,
|
||||
data = new Float64Array(capacity * stride);
|
||||
const previousPositions = new Float64Array(state._vehicles.size * 3);
|
||||
const gl = scene.context._gl;
|
||||
let active = false,
|
||||
upload = 0,
|
||||
cpu = 0,
|
||||
count = 0,
|
||||
previous = 0;
|
||||
const saved = [];
|
||||
for (const name of ['bufferData', 'bufferSubData']) {
|
||||
const original = gl[name];
|
||||
gl[name] = function (...args) {
|
||||
if (active) {
|
||||
const value = args[name === 'bufferData' ? 1 : 2];
|
||||
const offset = args[name === 'bufferData' ? 3 : 3] || 0;
|
||||
const length = args[4];
|
||||
upload +=
|
||||
typeof value === 'number'
|
||||
? value
|
||||
: value
|
||||
? length !== undefined
|
||||
? length * (value.BYTES_PER_ELEMENT || 1)
|
||||
: value.byteLength - offset * (value.BYTES_PER_ELEMENT || 1)
|
||||
: 0;
|
||||
}
|
||||
return original.apply(this, args);
|
||||
};
|
||||
saved.push(() => {
|
||||
gl[name] = original;
|
||||
});
|
||||
}
|
||||
for (const collection of [state._markers, state._animatedMarkers]) {
|
||||
const original = collection.update;
|
||||
collection.update = function (frame) {
|
||||
active = true;
|
||||
try {
|
||||
return original.call(this, frame);
|
||||
} finally {
|
||||
active = false;
|
||||
}
|
||||
};
|
||||
saved.push(() => {
|
||||
collection.update = original;
|
||||
});
|
||||
}
|
||||
state._preRenderRemove?.();
|
||||
const removePre = scene.preRender.addEventListener(() => {
|
||||
upload = 0;
|
||||
const start = performance.now();
|
||||
parts.rendering.onPreRender();
|
||||
cpu = performance.now() - start;
|
||||
});
|
||||
let removePost, timer;
|
||||
return new Promise((resolve) => {
|
||||
const finish = () => {
|
||||
clearTimeout(timer);
|
||||
removePre();
|
||||
removePost?.();
|
||||
for (const restore of saved) restore();
|
||||
if (state._enabled)
|
||||
state._preRenderRemove = scene.preRender.addEventListener(
|
||||
parts.rendering.onPreRender,
|
||||
);
|
||||
stopProbe = null;
|
||||
const rows = [];
|
||||
for (let i = 1; i < count; i++) {
|
||||
const k = i * stride;
|
||||
rows.push({
|
||||
intervalMs: data[k],
|
||||
cpuMs: data[k + 1],
|
||||
uploadBytes: data[k + 2],
|
||||
moving: data[k + 3],
|
||||
x: data[k + 4],
|
||||
y: data[k + 5],
|
||||
z: data[k + 6],
|
||||
displayT: data[k + 7],
|
||||
expectedMps: data[k + 8],
|
||||
segment: data[k + 9],
|
||||
});
|
||||
}
|
||||
resolve({
|
||||
rows,
|
||||
dpr: devicePixelRatio,
|
||||
width: scene.canvas.clientWidth,
|
||||
height: scene.canvas.clientHeight,
|
||||
});
|
||||
};
|
||||
stopProbe = finish;
|
||||
removePost = scene.postRender.addEventListener(() => {
|
||||
const now = performance.now();
|
||||
if (count >= capacity) return;
|
||||
const k = count++ * stride;
|
||||
const entry = state._vehicles.get('mbta:qa-0');
|
||||
data[k] = previous ? now - previous : 0;
|
||||
previous = now;
|
||||
data[k + 1] = cpu;
|
||||
data[k + 2] = upload;
|
||||
let actuallyMoved = 0;
|
||||
for (const vehicle of state._visible) {
|
||||
const p = vehicle.marker.position,
|
||||
index = vehicle.qaIndex * 3;
|
||||
if (
|
||||
count > 1 &&
|
||||
Math.hypot(
|
||||
p.x - previousPositions[index],
|
||||
p.y - previousPositions[index + 1],
|
||||
p.z - previousPositions[index + 2],
|
||||
) > 0.00001
|
||||
)
|
||||
actuallyMoved++;
|
||||
previousPositions[index] = p.x;
|
||||
previousPositions[index + 1] = p.y;
|
||||
previousPositions[index + 2] = p.z;
|
||||
}
|
||||
data[k + 3] = actuallyMoved;
|
||||
if (entry?.marker) {
|
||||
const p = entry.marker.position;
|
||||
data[k + 4] = p.x;
|
||||
data[k + 5] = p.y;
|
||||
data[k + 6] = p.z;
|
||||
data[k + 7] = entry.sample.displayT;
|
||||
data[k + 8] = entry.sample.segmentSpeedMps;
|
||||
data[k + 9] = entry.sample.fromSeq;
|
||||
}
|
||||
});
|
||||
timer = setTimeout(finish, durationMs);
|
||||
});
|
||||
},
|
||||
_transitVisibilityForTest() {
|
||||
return [...state._vehicles.values()].map((entry) => {
|
||||
const gate = entry.visibility || {};
|
||||
const why = [];
|
||||
if (!entry.marker) why.push('no marker');
|
||||
if (gate.bounds === false && gate.boundsApplied) why.push('bounds');
|
||||
if (gate.occluder === false) why.push('occluder');
|
||||
if (gate.frustum === false) why.push('frustum');
|
||||
if (entry.heightPending) why.push('heightPending');
|
||||
if (entry.surfaceReady === false) why.push('surfaceReady');
|
||||
if (!entry.sample || !Number.isFinite(entry.sample.lat))
|
||||
why.push('no sample');
|
||||
if (!state._enabled) why.push('disabled');
|
||||
return {
|
||||
key: entry.key,
|
||||
shown: entry.marker?.show === true,
|
||||
why,
|
||||
...gate,
|
||||
heightPending: !!entry.heightPending,
|
||||
surfaceReady: entry.surfaceReady ?? null,
|
||||
moving: state._moving.has(entry),
|
||||
held: state._renderHeld,
|
||||
heightM: entry.heightM,
|
||||
phase: entry.sample?.phase,
|
||||
};
|
||||
});
|
||||
},
|
||||
/** What the sprites are currently styled for, and how many rasters exist. */
|
||||
_transitStylingForTest() {
|
||||
return {
|
||||
stylePreset: state._stylePreset,
|
||||
cockpitVision: state._cockpitVision,
|
||||
iconCacheSize: transitIconCacheSize(),
|
||||
};
|
||||
},
|
||||
_setTransitOverlayHostForTest(host) {
|
||||
state._overlayHost = host || state.DEFAULT_OVERLAY_HOST;
|
||||
},
|
||||
_transitStateForTest() {
|
||||
return state;
|
||||
},
|
||||
_transitPartsForTest() {
|
||||
return parts;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
import { fetchTransitHistory } from '../../data/transitProxy.js';
|
||||
import { getRegisteredTransitFeed } from '../../data/transitFeeds.js';
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
readFix,
|
||||
correctHeight,
|
||||
FIX_FLAGS,
|
||||
mergeHistory,
|
||||
} from '../../data/contactPlayback.js';
|
||||
import { createContactTrailRenderer } from '../../data/contactTrailRenderer.js';
|
||||
import { fixDistanceM } from './movement.js';
|
||||
import { transitStyleProfile } from '../../data/transitPresetStyle.js';
|
||||
|
||||
const MODE_COLOR = {
|
||||
bus: '#5EF08A',
|
||||
tram: '#FFC24A',
|
||||
subway: '#FF4538',
|
||||
rail: '#D9A6FF',
|
||||
ferry: '#5FD6FF',
|
||||
unknown: '#D8DDE5',
|
||||
};
|
||||
export const TRAIL_VERTEX_LIMIT = 2048;
|
||||
const STEP_M = 25;
|
||||
|
||||
/** Shared prepared paths. Floors are aligned to work with Google 3D tiles. */
|
||||
export function createTrails({ state, services, parts }) {
|
||||
let historyRequest = null;
|
||||
let requestCount = 0,
|
||||
abortCount = 0;
|
||||
let renderer = null,
|
||||
selected = null,
|
||||
fadeTimer = null,
|
||||
revision = 0;
|
||||
function ensureRenderer() {
|
||||
if (!renderer && state._viewer)
|
||||
renderer = createContactTrailRenderer(state._viewer.scene);
|
||||
return renderer;
|
||||
}
|
||||
function style() {
|
||||
renderer?.setStyle(
|
||||
transitStyleProfile(state._stylePreset) === 'mono'
|
||||
? '#FFFFFF'
|
||||
: MODE_COLOR[selected?.mode] || MODE_COLOR.unknown,
|
||||
);
|
||||
}
|
||||
function releaseSelectionGeometry(entry) {
|
||||
entry.trailSegments = [];
|
||||
entry.trailVertices = 0;
|
||||
entry.trailTruncated = false;
|
||||
entry.pathRevision = -1;
|
||||
entry.markerPathSeq = NaN;
|
||||
pruneMarkerPaths(entry, entry.sample);
|
||||
}
|
||||
function pruneMarkerPaths(entry, sample) {
|
||||
if (entry === selected || entry.markerPathSeq === sample.fromSeq) return;
|
||||
entry.markerPathSeq = sample.fromSeq;
|
||||
for (const seq of entry.displayPaths?.keys() || [])
|
||||
if (seq < sample.fromSeq) entry.displayPaths.delete(seq);
|
||||
}
|
||||
/** Runs only after reports, selection, or surface rereads. */
|
||||
function prepareEntry(entry, collect = null) {
|
||||
if (!entry.track?.count) return;
|
||||
if (entry !== selected) {
|
||||
entry.trailSegments = [];
|
||||
entry.trailVertices = 0;
|
||||
}
|
||||
const paths = new Map(),
|
||||
all = [],
|
||||
a = {},
|
||||
b = {};
|
||||
let vertices = 0,
|
||||
signature = '';
|
||||
const floor = (point) => {
|
||||
const answer = parts.height.requestHeight(point.lat, point.lon, entry);
|
||||
if (collect) collect(point, answer);
|
||||
return answer.height;
|
||||
};
|
||||
// Selected active corridor gets first access to both surface budgets.
|
||||
if (collect && entry.key === state._selectedKey) {
|
||||
readFix(
|
||||
entry.track,
|
||||
Math.max(0, entry.sample.fromSeq - entry.track.baseSeq),
|
||||
a,
|
||||
);
|
||||
readFix(
|
||||
entry.track,
|
||||
Math.max(0, entry.sample.toSeq - entry.track.baseSeq),
|
||||
b,
|
||||
);
|
||||
const n = Math.min(
|
||||
TRAIL_VERTEX_LIMIT - 1,
|
||||
Math.max(1, Math.ceil(fixDistanceM(a, b) / STEP_M)),
|
||||
);
|
||||
for (let i = 0; i <= n; i++)
|
||||
floor({
|
||||
lat: a.lat + ((b.lat - a.lat) * i) / n,
|
||||
lon: a.lon + ((b.lon - a.lon) * i) / n,
|
||||
});
|
||||
}
|
||||
for (let i = 0; i < entry.track.count; i++) {
|
||||
readFix(entry.track, i, a);
|
||||
const h = floor(a);
|
||||
if (h !== null) correctHeight(entry.track, a.seq, h);
|
||||
}
|
||||
const order = [];
|
||||
const activeIndex = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
entry.track.count - 2,
|
||||
entry.sample.fromSeq - entry.track.baseSeq,
|
||||
),
|
||||
);
|
||||
for (let i = activeIndex; i + 1 < entry.track.count; i++) order.push(i);
|
||||
if (entry === selected)
|
||||
for (let i = activeIndex - 1; i >= 0; i--) order.push(i);
|
||||
entry.trailTruncated = false;
|
||||
for (const i of order) {
|
||||
readFix(entry.track, i, a);
|
||||
readFix(entry.track, i + 1, b);
|
||||
if (a.epoch !== b.epoch || b.flags & FIX_FLAGS.BREAK) continue;
|
||||
if (entry !== selected && b.t < entry.sample.displayT) continue;
|
||||
const n = Math.max(1, Math.ceil(fixDistanceM(a, b) / STEP_M));
|
||||
if (vertices + n + 1 > TRAIL_VERTEX_LIMIT) {
|
||||
entry.trailTruncated = true;
|
||||
continue;
|
||||
}
|
||||
const positions = [],
|
||||
heights = [];
|
||||
let valid = true;
|
||||
for (let k = 0; k <= n; k++) {
|
||||
const fraction = k / n;
|
||||
const point = {
|
||||
lat: a.lat + (b.lat - a.lat) * fraction,
|
||||
lon: a.lon + (((b.lon - a.lon + 540) % 360) - 180) * fraction,
|
||||
};
|
||||
const h = floor(point);
|
||||
heights.push(h);
|
||||
if (h === null) {
|
||||
valid = false;
|
||||
positions.push(null);
|
||||
} else
|
||||
positions.push(
|
||||
Cesium.Cartesian3.fromDegrees(point.lon, point.lat, h),
|
||||
);
|
||||
}
|
||||
signature += `${a.seq}/${b.seq}:${heights.join(',')};`;
|
||||
vertices += n + 1;
|
||||
if (!valid) continue;
|
||||
paths.set(a.seq, { positions, toSeq: b.seq });
|
||||
if (entry === selected && fixDistanceM(a, b) > 0) {
|
||||
for (let k = 0; k < n; k++)
|
||||
all.push({
|
||||
fromSeq: a.seq,
|
||||
toSeq: b.seq,
|
||||
fromT: a.t + ((b.t - a.t) * k) / n,
|
||||
toT: a.t + ((b.t - a.t) * (k + 1)) / n,
|
||||
positions: [positions[k], positions[k + 1]],
|
||||
});
|
||||
}
|
||||
}
|
||||
const changed =
|
||||
entry.pathSignature !== signature ||
|
||||
entry.pathRevision !== entry.track.revision;
|
||||
if (changed) {
|
||||
entry.displayPaths = paths;
|
||||
entry.pathSignature = signature;
|
||||
entry.pathRevision = entry.track.revision;
|
||||
state._heightDirty.add(entry);
|
||||
if (entry === selected) {
|
||||
all.sort((a, b) => a.fromT - b.fromT);
|
||||
ensureRenderer()?.replaceHistory({
|
||||
segments: all,
|
||||
revision: ++revision,
|
||||
});
|
||||
entry.trailVertices = vertices;
|
||||
entry.trailSegments = all;
|
||||
services.render.governorRequestRender('transit-trail-history');
|
||||
}
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
/** Allocation-free sampling of the exact path also used by the trail. */
|
||||
function samplePosition(
|
||||
entry,
|
||||
sample,
|
||||
out,
|
||||
nearGround = parts.height.nearGround(),
|
||||
) {
|
||||
pruneMarkerPaths(entry, sample);
|
||||
if (!nearGround) {
|
||||
const a = entry.fromCart,
|
||||
b = entry.toCart,
|
||||
f = sample.fraction;
|
||||
out.x = a.x + (b.x - a.x) * f;
|
||||
out.y = a.y + (b.y - a.y) * f;
|
||||
out.z = a.z + (b.z - a.z) * f;
|
||||
return out;
|
||||
}
|
||||
if (sample.fromSeq === sample.toSeq) {
|
||||
if (!Number.isFinite(entry.segment.from.h)) return null;
|
||||
Cesium.Cartesian3.clone(entry.fromCart, out);
|
||||
return out;
|
||||
}
|
||||
const path = entry.displayPaths?.get(sample.fromSeq);
|
||||
if (!path || path.toSeq !== sample.toSeq) return null;
|
||||
const scaled = sample.fraction * (path.positions.length - 1);
|
||||
const index = Math.min(path.positions.length - 2, Math.floor(scaled));
|
||||
// Keep scalar interpolation in this frame function. Passing the changing
|
||||
// fraction to the general Cesium helper boxes it in sampled V8 stacks.
|
||||
const a = path.positions[index],
|
||||
b = path.positions[index + 1],
|
||||
f = scaled - index;
|
||||
out.x = a.x + (b.x - a.x) * f;
|
||||
out.y = a.y + (b.y - a.y) * f;
|
||||
out.z = a.z + (b.z - a.z) * f;
|
||||
return out;
|
||||
}
|
||||
function update() {
|
||||
if (!selected || !renderer) return;
|
||||
renderer.setVisible(state._enabled && selected.marker?.show !== false);
|
||||
renderer.setDisplaySample(selected.sample, selected.marker?.position);
|
||||
}
|
||||
function select(entry) {
|
||||
clear();
|
||||
selected = entry;
|
||||
ensureRenderer();
|
||||
style();
|
||||
entry.pathRevision = -1;
|
||||
prepareEntry(entry);
|
||||
update();
|
||||
const feed = getRegisteredTransitFeed(entry.feedId);
|
||||
if (feed?.historyRetention) {
|
||||
const controller = new AbortController();
|
||||
historyRequest = controller;
|
||||
requestCount++;
|
||||
fetchTransitHistory(entry.feedId, entry.record.id, controller.signal)
|
||||
.then((payload) => {
|
||||
if (
|
||||
controller.signal.aborted ||
|
||||
!state._enabled ||
|
||||
selected !== entry ||
|
||||
historyRequest !== controller
|
||||
)
|
||||
return;
|
||||
const mapping = new Map();
|
||||
for (const epoch of payload.epochs) {
|
||||
let id = null;
|
||||
for (const [candidate, context] of entry.track.epochs) {
|
||||
if (
|
||||
context.trip === epoch.trip &&
|
||||
context.route === epoch.route &&
|
||||
context.mode === epoch.mode
|
||||
) {
|
||||
id = candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (id === null) {
|
||||
id = Math.max(0, ...entry.track.epochs.keys()) + 1;
|
||||
entry.track.epochs.set(id, {
|
||||
trip: epoch.trip,
|
||||
route: epoch.route,
|
||||
mode: epoch.mode,
|
||||
});
|
||||
}
|
||||
mapping.set(epoch.id, id);
|
||||
}
|
||||
mergeHistory(
|
||||
entry.track,
|
||||
payload.fixes.map(([t, lat, lon, flags, epoch]) => ({
|
||||
t,
|
||||
lat,
|
||||
lon,
|
||||
flags,
|
||||
epoch: mapping.get(epoch),
|
||||
})),
|
||||
);
|
||||
entry.track.truncated ||= payload.truncated;
|
||||
entry.pathRevision = -1;
|
||||
parts.height.anchorFloors();
|
||||
prepareEntry(entry);
|
||||
update();
|
||||
services.render.governorRequestRender('transit-trail-backfill');
|
||||
})
|
||||
.catch(() => {
|
||||
// Session history remains usable if the optional retained read fails.
|
||||
})
|
||||
.finally(() => {
|
||||
if (historyRequest === controller) historyRequest = null;
|
||||
});
|
||||
}
|
||||
fadeTimer = setInterval(() => {
|
||||
if (!state._enabled || selected !== entry) return;
|
||||
parts.rendering.sampleIdle(entry);
|
||||
update();
|
||||
services.render.governorRequestRender('transit-trail-age');
|
||||
}, 1000);
|
||||
}
|
||||
function clear() {
|
||||
if (historyRequest) {
|
||||
historyRequest.abort();
|
||||
abortCount++;
|
||||
historyRequest = null;
|
||||
}
|
||||
if (fadeTimer != null) clearInterval(fadeTimer);
|
||||
fadeTimer = null;
|
||||
const previous = selected;
|
||||
selected = null;
|
||||
if (previous) releaseSelectionGeometry(previous);
|
||||
renderer?.destroy();
|
||||
renderer = null;
|
||||
}
|
||||
return {
|
||||
prepareEntry,
|
||||
samplePosition,
|
||||
select,
|
||||
clear,
|
||||
update,
|
||||
style,
|
||||
destroy: clear,
|
||||
requestDiagnostics: () => ({
|
||||
requestCount,
|
||||
abortCount,
|
||||
pending: historyRequest !== null,
|
||||
}),
|
||||
diagnostics: () => renderer?.diagnostics() || null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
ACTIVATION_ENTER_ALTITUDE_M,
|
||||
ACTIVATION_EXIT_ALTITUDE_M,
|
||||
CAMERA_DEBOUNCE_MS,
|
||||
RANGE_SLACK_KM,
|
||||
inflateViewBounds,
|
||||
} from './policy.js';
|
||||
import { transitFeedsInRange } from '../../data/transitFeeds.js';
|
||||
|
||||
/**
|
||||
* Camera-driven feed activation: only the feeds whose coverage circle contains
|
||||
* the look-at point are polled, and only below the altitude gate.
|
||||
* @param {object} context
|
||||
* @returns {object}
|
||||
*/
|
||||
export function createViewport({ state, services, parts }) {
|
||||
const { governorRequestRender } = services.render;
|
||||
|
||||
function getCameraAltitude(viewer = state._viewer) {
|
||||
const carto = viewer?.camera?.positionCartographic;
|
||||
return carto && Number.isFinite(carto.height) ? carto.height : Infinity;
|
||||
}
|
||||
|
||||
function getCameraCenterLatLon(viewer = state._viewer) {
|
||||
const rect = viewer?.camera?.computeViewRectangle?.(
|
||||
viewer.scene.globe?.ellipsoid,
|
||||
);
|
||||
if (rect) {
|
||||
const center = Cesium.Rectangle.center(rect);
|
||||
return {
|
||||
lat: Cesium.Math.toDegrees(center.latitude),
|
||||
lon: Cesium.Math.toDegrees(center.longitude),
|
||||
};
|
||||
}
|
||||
const carto = viewer?.camera?.positionCartographic;
|
||||
if (carto) {
|
||||
return {
|
||||
lat: Cesium.Math.toDegrees(carto.latitude),
|
||||
lon: Cesium.Math.toDegrees(carto.longitude),
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function altitudeGateOpen(altitude) {
|
||||
if (state._altitudeGateOpen) return altitude <= ACTIVATION_EXIT_ALTITUDE_M;
|
||||
return altitude <= ACTIVATION_ENTER_ALTITUDE_M;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh the bounds the per-frame pass uses to decide what is worth
|
||||
* animating. Cheap, and only on a settled camera — never per frame.
|
||||
*/
|
||||
function refreshViewBounds() {
|
||||
const viewer = state._viewer;
|
||||
const rect = viewer?.camera?.computeViewRectangle?.(
|
||||
viewer.scene.globe?.ellipsoid,
|
||||
);
|
||||
state._viewBounds = rect
|
||||
? inflateViewBounds({
|
||||
south: Cesium.Math.toDegrees(rect.south),
|
||||
north: Cesium.Math.toDegrees(rect.north),
|
||||
west: Cesium.Math.toDegrees(rect.west),
|
||||
east: Cesium.Math.toDegrees(rect.east),
|
||||
})
|
||||
: null;
|
||||
// The sweep itself is deferred: `marker.show` is a dirty flag, and a drag
|
||||
// fires this on every frame, so re-deciding visibility inline would put the
|
||||
// whole fleet back into the vertex buffer once a frame — the exact cost
|
||||
// this is meant to remove.
|
||||
state._visibilityDirty = true;
|
||||
state._cameraRevision++;
|
||||
parts.rendering.requestVisibility();
|
||||
}
|
||||
|
||||
function runProximityCheck() {
|
||||
if (!state._enabled || !state._viewer) return;
|
||||
refreshViewBounds();
|
||||
const altitude = getCameraAltitude();
|
||||
state._altitudeGateOpen = altitudeGateOpen(altitude);
|
||||
const center = state._altitudeGateOpen ? getCameraCenterLatLon() : null;
|
||||
const desired = new Map();
|
||||
if (center) {
|
||||
for (const feed of transitFeedsInRange(center.lat, center.lon)) {
|
||||
desired.set(feed.id, feed);
|
||||
}
|
||||
// Hysteresis: a feed already active stays active a little past its edge.
|
||||
for (const feed of transitFeedsInRange(
|
||||
center.lat,
|
||||
center.lon,
|
||||
RANGE_SLACK_KM,
|
||||
)) {
|
||||
if (state._activeFeeds.has(feed.id)) desired.set(feed.id, feed);
|
||||
}
|
||||
}
|
||||
for (const feedId of [...state._activeFeeds.keys()]) {
|
||||
if (!desired.has(feedId)) {
|
||||
state._inFlight.get(feedId)?.controller.abort();
|
||||
state._inFlight.delete(feedId);
|
||||
state._activeFeeds.delete(feedId);
|
||||
state._feedStatus.delete(feedId);
|
||||
parts.ingestion.removeFeedVehicles(feedId);
|
||||
}
|
||||
}
|
||||
for (const [feedId, feed] of desired) {
|
||||
if (!state._activeFeeds.has(feedId)) {
|
||||
state._activeFeeds.set(feedId, feed);
|
||||
void parts.ingestion.pollFeed(feed, state._generation);
|
||||
}
|
||||
}
|
||||
parts.ingestion.sweepAgedVehicles(Date.now());
|
||||
parts.rendering.syncRenderHold();
|
||||
governorRequestRender('transit-proximity');
|
||||
}
|
||||
|
||||
function onCameraChanged() {
|
||||
// The bounds update immediately; the feed proximity check stays debounced.
|
||||
// A person dragging the globe should not wait a third of a second for the
|
||||
// vehicles under the cursor to start moving again.
|
||||
refreshViewBounds();
|
||||
clearTimeout(state._cameraDebounceTimer);
|
||||
state._cameraDebounceTimer = setTimeout(() => {
|
||||
state._cameraDebounceTimer = null;
|
||||
runProximityCheck();
|
||||
}, CAMERA_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
return {
|
||||
refreshViewBounds,
|
||||
getCameraAltitude,
|
||||
getCameraCenterLatLon,
|
||||
altitudeGateOpen,
|
||||
runProximityCheck,
|
||||
onCameraChanged,
|
||||
};
|
||||
}
|
||||
@@ -1962,6 +1962,8 @@ function publishPaintRect(item) {
|
||||
const { record, placement } = item;
|
||||
const rect = _paintRectPool[_paintRectCount] || (_paintRectPool[_paintRectCount] = {});
|
||||
_paintRectCount++;
|
||||
rect.anchorX = placement.anchorX;
|
||||
rect.anchorY = placement.anchorY;
|
||||
rect.x = placement.rect.x;
|
||||
rect.y = placement.rect.y;
|
||||
rect.w = placement.rect.w;
|
||||
|
||||
@@ -1299,6 +1299,8 @@ test('UI exclusions stay per-rect: overlapping chrome never merges into a boundi
|
||||
|
||||
assert.equal(getWorldOverlayDiagnostics().paintedCount, 1);
|
||||
const painted = getOverlayPaintRect('ambient', 'CLEAR-OF-BOTH');
|
||||
assert.ok(Number.isFinite(painted.anchorX));
|
||||
assert.ok(Number.isFinite(painted.anchorY));
|
||||
assert.ok(painted, 'the entry published a paint rectangle');
|
||||
for (const [name, rect] of Object.entries(chrome)) {
|
||||
assert.equal(rectsIntersect(painted, inflatedRect(rect)), false,
|
||||
|
||||
@@ -114,8 +114,21 @@ export const DETECTION_THEME_MAP = Object.freeze({
|
||||
filter: 'contrast(1.08) saturate(1.04)',
|
||||
scanline: 0.085,
|
||||
tiers: {
|
||||
civil: '#ffd27a', military: '#ff8a3c', sea: '#ffc06a', space: '#ffe0a0', vehicle: '#d0a060',
|
||||
veh_jam: '#ff3b30', veh_slow: '#ffb300', veh_free: '#00ff66', veh_nodata: '#c9c9c9',
|
||||
civil: '#ffd27a',
|
||||
military: '#ff8a3c',
|
||||
sea: '#ffc06a',
|
||||
space: '#ffe0a0',
|
||||
vehicle: '#d0a060',
|
||||
veh_jam: '#ff3b30',
|
||||
veh_slow: '#ffb300',
|
||||
veh_free: '#00ff66',
|
||||
veh_nodata: '#c9c9c9',
|
||||
transit_bus: '#5EF08A',
|
||||
transit_tram: '#FFC24A',
|
||||
transit_subway: '#FF4538',
|
||||
transit_rail: '#D9A6FF',
|
||||
transit_ferry: '#5FD6FF',
|
||||
transit_unknown: '#D8DDE5',
|
||||
},
|
||||
},
|
||||
surveillance: {
|
||||
@@ -131,8 +144,21 @@ export const DETECTION_THEME_MAP = Object.freeze({
|
||||
filter: 'contrast(1.12) saturate(1.12)',
|
||||
scanline: 0.09,
|
||||
tiers: {
|
||||
civil: '#8fe89a', military: '#ff5a47', sea: '#a6f0c0', space: '#9fe8ff', vehicle: '#ffc24a',
|
||||
veh_jam: '#ff4538', veh_slow: '#ffc24a', veh_free: '#45d8ff', veh_nodata: '#dcdcdc',
|
||||
civil: '#8fe89a',
|
||||
military: '#ff5a47',
|
||||
sea: '#a6f0c0',
|
||||
space: '#9fe8ff',
|
||||
vehicle: '#ffc24a',
|
||||
veh_jam: '#ff4538',
|
||||
veh_slow: '#ffc24a',
|
||||
veh_free: '#45d8ff',
|
||||
veh_nodata: '#dcdcdc',
|
||||
transit_bus: '#5EF08A',
|
||||
transit_tram: '#FFC24A',
|
||||
transit_subway: '#FF4538',
|
||||
transit_rail: '#D9A6FF',
|
||||
transit_ferry: '#5FD6FF',
|
||||
transit_unknown: '#D8DDE5',
|
||||
},
|
||||
},
|
||||
thermal: {
|
||||
@@ -148,8 +174,21 @@ export const DETECTION_THEME_MAP = Object.freeze({
|
||||
filter: 'contrast(1.1) saturate(1.08)',
|
||||
scanline: 0.04,
|
||||
tiers: {
|
||||
civil: '#ffffff', military: '#ff7a5c', sea: '#ffd0b0', space: '#d0e0ff', vehicle: '#ffcf9f',
|
||||
veh_jam: '#ff4538', veh_slow: '#ffc24a', veh_free: '#2ecc71', veh_nodata: '#d8d8d8',
|
||||
civil: '#ffffff',
|
||||
military: '#ff7a5c',
|
||||
sea: '#ffd0b0',
|
||||
space: '#d0e0ff',
|
||||
vehicle: '#ffcf9f',
|
||||
veh_jam: '#ff4538',
|
||||
veh_slow: '#ffc24a',
|
||||
veh_free: '#2ecc71',
|
||||
veh_nodata: '#d8d8d8',
|
||||
transit_bus: '#5EF08A',
|
||||
transit_tram: '#FFC24A',
|
||||
transit_subway: '#FF4538',
|
||||
transit_rail: '#D9A6FF',
|
||||
transit_ferry: '#5FD6FF',
|
||||
transit_unknown: '#D8DDE5',
|
||||
},
|
||||
},
|
||||
_default: {
|
||||
@@ -165,8 +204,21 @@ export const DETECTION_THEME_MAP = Object.freeze({
|
||||
filter: 'contrast(1.05) saturate(1.05)',
|
||||
scanline: 0.05,
|
||||
tiers: {
|
||||
civil: '#22e0ff', military: '#ffb347', sea: '#3fe0c8', space: '#bda4ff', vehicle: '#8fa6b4',
|
||||
veh_jam: '#e05252', veh_slow: '#f0b23e', veh_free: '#2ecc71', veh_nodata: '#c9c9c9',
|
||||
civil: '#22e0ff',
|
||||
military: '#ffb347',
|
||||
sea: '#3fe0c8',
|
||||
space: '#bda4ff',
|
||||
vehicle: '#8fa6b4',
|
||||
veh_jam: '#e05252',
|
||||
veh_slow: '#f0b23e',
|
||||
veh_free: '#2ecc71',
|
||||
veh_nodata: '#c9c9c9',
|
||||
transit_bus: '#5EF08A',
|
||||
transit_tram: '#FFC24A',
|
||||
transit_subway: '#FF4538',
|
||||
transit_rail: '#D9A6FF',
|
||||
transit_ferry: '#5FD6FF',
|
||||
transit_unknown: '#D8DDE5',
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { DETECTION_THEME_MAP } from './worldOverlayTokens.js';
|
||||
import { TRANSIT_TIER_KEYS } from '../data/transitPresetStyle.js';
|
||||
|
||||
test('every detection theme carries a distinct bracket colour for every transit mode', () => {
|
||||
// The detection canvas composites above the post-FX chain, so these are
|
||||
// literal screen colours in every preset — the mode signal the sprites
|
||||
// lose under NVG and FLIR. A missing key would silently fall back to the
|
||||
// theme's line colour and every mode would bracket alike.
|
||||
for (const [name, theme] of Object.entries(DETECTION_THEME_MAP)) {
|
||||
const seen = new Set();
|
||||
for (const key of TRANSIT_TIER_KEYS) {
|
||||
const colour = theme.tiers?.[key];
|
||||
assert.match(colour || '', /^#[0-9a-f]{6}$/i, `${name} ${key}`);
|
||||
if (key !== 'transit_unknown') seen.add(colour.toLowerCase());
|
||||
}
|
||||
assert.equal(
|
||||
seen.size,
|
||||
TRANSIT_TIER_KEYS.length - 1,
|
||||
`${name}: the five real modes differ`,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,386 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { PbfWriter } from 'pbf';
|
||||
import { transitProxy } from 'gods-eye-view/server/providers/transit';
|
||||
import {
|
||||
TRANSIT_BACKOFF_LADDER_MS,
|
||||
TRANSIT_PROXY_TTL_MS,
|
||||
} from 'gods-eye-view/sources/transit';
|
||||
|
||||
/** Mount the plugin and return a caller for its single route. */
|
||||
function install(plugin) {
|
||||
const routes = new Map();
|
||||
plugin.configureServer({
|
||||
middlewares: {
|
||||
use(route, handler) {
|
||||
routes.set(route, handler);
|
||||
},
|
||||
},
|
||||
});
|
||||
assert.equal(routes.size, 1);
|
||||
return async (url = '/', method = 'GET') => {
|
||||
const res = {
|
||||
headersSent: false,
|
||||
writeHead(status, headers) {
|
||||
Object.assign(this, { status, headers, headersSent: true });
|
||||
},
|
||||
end(body) {
|
||||
this.body = body;
|
||||
},
|
||||
};
|
||||
await [...routes.values()][0]({ url, method }, res);
|
||||
return res;
|
||||
};
|
||||
}
|
||||
|
||||
/** One GTFS-RT FeedMessage with the given header and vehicles. */
|
||||
function feedBytes({
|
||||
incrementality = 0,
|
||||
timestamp = 1_700_000_000,
|
||||
vehicles = [],
|
||||
}) {
|
||||
const writer = new PbfWriter();
|
||||
const header = new PbfWriter();
|
||||
header.writeStringField(1, '2.0');
|
||||
header.writeVarintField(2, incrementality);
|
||||
header.writeVarintField(3, timestamp);
|
||||
writer.writeBytesField(1, header.finish());
|
||||
for (const item of vehicles) {
|
||||
const entity = new PbfWriter();
|
||||
entity.writeStringField(1, item.id);
|
||||
const vehicle = new PbfWriter();
|
||||
const position = new PbfWriter();
|
||||
position.writeFloatField(1, item.lat);
|
||||
position.writeFloatField(2, item.lon);
|
||||
vehicle.writeBytesField(2, position.finish());
|
||||
vehicle.writeVarintField(5, item.timestamp ?? timestamp);
|
||||
const descriptor = new PbfWriter();
|
||||
descriptor.writeStringField(1, item.id);
|
||||
vehicle.writeBytesField(8, descriptor.finish());
|
||||
entity.writeBytesField(4, vehicle.finish());
|
||||
writer.writeBytesField(2, entity.finish());
|
||||
}
|
||||
return writer.finish();
|
||||
}
|
||||
|
||||
function upstreamResponse({ status = 200, headers = {}, bytes = null }) {
|
||||
const map = new Map(
|
||||
Object.entries(headers).map(([name, value]) => [name.toLowerCase(), value]),
|
||||
);
|
||||
return {
|
||||
status,
|
||||
ok: status >= 200 && status < 300,
|
||||
headers: { get: (name) => map.get(String(name).toLowerCase()) ?? null },
|
||||
body: null,
|
||||
async arrayBuffer() {
|
||||
const view = bytes || new Uint8Array(0);
|
||||
return view.buffer.slice(
|
||||
view.byteOffset,
|
||||
view.byteOffset + view.byteLength,
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** A scripted upstream that records every request it is actually given. */
|
||||
function scriptedUpstream(steps) {
|
||||
const calls = [];
|
||||
let index = 0;
|
||||
const fetchImpl = async (url, init) => {
|
||||
calls.push({ url, headers: init?.headers || {}, redirect: init?.redirect });
|
||||
const step = steps[Math.min(index, steps.length - 1)];
|
||||
index += 1;
|
||||
if (typeof step === 'function') return step(url, init);
|
||||
return step;
|
||||
};
|
||||
return { fetchImpl, calls };
|
||||
}
|
||||
|
||||
const FULL = feedBytes({
|
||||
vehicles: [{ id: 'bus-1', lat: 42.36, lon: -71.06 }],
|
||||
});
|
||||
|
||||
test('a 304 revalidation is a success, not a redirect with a missing Location', async (t) => {
|
||||
t.mock.timers.enable({ apis: ['Date'], now: 1_700_000_000_000 });
|
||||
const { fetchImpl, calls } = scriptedUpstream([
|
||||
upstreamResponse({ headers: { etag: 'W/"v1"' }, bytes: FULL }),
|
||||
upstreamResponse({ status: 304, headers: { etag: 'W/"v1"' } }),
|
||||
upstreamResponse({ status: 304, headers: { etag: 'W/"v1"' } }),
|
||||
]);
|
||||
const call = install(transitProxy({ fetchImpl }));
|
||||
|
||||
const first = await call('/vehicles/mbta');
|
||||
assert.equal(first.status, 200);
|
||||
assert.equal(first.headers['X-GEV-Cache'], 'MISS');
|
||||
assert.equal(JSON.parse(first.body).count, 1);
|
||||
|
||||
// Past the freshness window the proxy asks again, conditionally.
|
||||
t.mock.timers.setTime(Date.now() + TRANSIT_PROXY_TTL_MS + 1_000);
|
||||
const second = await call('/vehicles/mbta');
|
||||
assert.equal(calls[1].headers['If-None-Match'], 'W/"v1"');
|
||||
assert.equal(second.status, 200, 'an unchanged feed is not a failure');
|
||||
// MISS, not STALE-ERROR: the refresh SUCCEEDED. Treating 304 as a redirect
|
||||
// made this a failure that happened to be masked by the serve-stale path.
|
||||
assert.equal(second.headers['X-GEV-Cache'], 'MISS');
|
||||
assert.equal(JSON.parse(second.body).count, 1);
|
||||
|
||||
// And the third one too: a 304 must not have started a backoff ladder.
|
||||
t.mock.timers.setTime(Date.now() + TRANSIT_PROXY_TTL_MS + 1_000);
|
||||
const third = await call('/vehicles/mbta');
|
||||
assert.equal(third.status, 200);
|
||||
assert.equal(third.headers['X-GEV-Cache'], 'MISS');
|
||||
assert.equal(calls.length, 3, 'every poll past the TTL reached the operator');
|
||||
assert.equal(third.headers['Retry-After'], undefined);
|
||||
assert.equal(
|
||||
third.headers['X-Transit-Backoff'],
|
||||
undefined,
|
||||
'no ladder was started',
|
||||
);
|
||||
});
|
||||
|
||||
test('a differential feed condemns the snapshot that preceded it', async (t) => {
|
||||
t.mock.timers.enable({ apis: ['Date'], now: 1_700_000_000_000 });
|
||||
const differential = feedBytes({
|
||||
incrementality: 1,
|
||||
vehicles: [{ id: 'bus-1', lat: 42.36, lon: -71.06 }],
|
||||
});
|
||||
const { fetchImpl } = scriptedUpstream([
|
||||
upstreamResponse({ bytes: FULL }),
|
||||
upstreamResponse({ bytes: differential }),
|
||||
upstreamResponse({ bytes: differential }),
|
||||
]);
|
||||
const call = install(transitProxy({ fetchImpl }));
|
||||
|
||||
assert.equal((await call('/vehicles/mbta')).status, 200);
|
||||
|
||||
t.mock.timers.setTime(Date.now() + TRANSIT_PROXY_TTL_MS + 1_000);
|
||||
const refused = await call('/vehicles/mbta');
|
||||
assert.equal(refused.status, 502);
|
||||
assert.match(JSON.parse(refused.body).error, /differential/);
|
||||
|
||||
// The next caller arrives INSIDE the cooldown the failure just started, which
|
||||
// is precisely the path that used to hand back the condemned snapshot as a
|
||||
// 200 because it was still inside the serve-stale window.
|
||||
t.mock.timers.setTime(Date.now() + 2_000);
|
||||
const after = await call('/vehicles/mbta');
|
||||
assert.notEqual(
|
||||
after.status,
|
||||
200,
|
||||
`served ${after.status} with ${after.body}`,
|
||||
);
|
||||
assert.equal(after.headers['X-GEV-Cache'], 'NONE');
|
||||
assert.equal(after.headers['X-Transit-Backoff'], 'cooldown');
|
||||
});
|
||||
|
||||
test('a failing operator is put on a cooldown instead of being re-asked every poll', async (t) => {
|
||||
t.mock.timers.enable({ apis: ['Date'], now: 1_700_000_000_000 });
|
||||
const { fetchImpl, calls } = scriptedUpstream([
|
||||
upstreamResponse({ status: 500 }),
|
||||
upstreamResponse({ status: 500 }),
|
||||
]);
|
||||
const call = install(transitProxy({ fetchImpl }));
|
||||
|
||||
const failed = await call('/vehicles/mbta');
|
||||
assert.equal(failed.status, 502);
|
||||
assert.equal(
|
||||
failed.headers['Retry-After'],
|
||||
String(TRANSIT_BACKOFF_LADDER_MS[0] / 1000),
|
||||
);
|
||||
assert.equal(calls.length, 1);
|
||||
|
||||
// Inside the cooldown the operator is not contacted at all.
|
||||
t.mock.timers.setTime(Date.now() + 1_000);
|
||||
const held = await call('/vehicles/mbta');
|
||||
assert.equal(held.status, 503);
|
||||
assert.equal(held.headers['X-Transit-Backoff'], 'cooldown');
|
||||
assert.equal(calls.length, 1, 'the operator was left alone');
|
||||
|
||||
// Past the first rung it is tried again, and a second failure waits longer.
|
||||
t.mock.timers.setTime(Date.now() + TRANSIT_BACKOFF_LADDER_MS[0]);
|
||||
const retried = await call('/vehicles/mbta');
|
||||
assert.equal(calls.length, 2);
|
||||
assert.equal(
|
||||
retried.headers['Retry-After'],
|
||||
String(TRANSIT_BACKOFF_LADDER_MS[1] / 1000),
|
||||
);
|
||||
});
|
||||
|
||||
test('a redirect is followed only within the feed origin, and never contacted first', async (t) => {
|
||||
t.mock.timers.enable({ apis: ['Date'], now: 1_700_000_000_000 });
|
||||
const sameOrigin = scriptedUpstream([
|
||||
upstreamResponse({
|
||||
status: 302,
|
||||
headers: { location: '/files/current.pb' },
|
||||
}),
|
||||
upstreamResponse({ bytes: FULL }),
|
||||
]);
|
||||
const call = install(transitProxy({ fetchImpl: sameOrigin.fetchImpl }));
|
||||
const ok = await call('/vehicles/capmetro-austin');
|
||||
assert.equal(ok.status, 200);
|
||||
assert.equal(sameOrigin.calls.length, 2);
|
||||
assert.equal(
|
||||
sameOrigin.calls[1].url,
|
||||
'https://data.texas.gov/files/current.pb',
|
||||
);
|
||||
assert.equal(sameOrigin.calls[0].redirect, 'manual');
|
||||
|
||||
const offOrigin = scriptedUpstream([
|
||||
upstreamResponse({
|
||||
status: 302,
|
||||
headers: { location: 'https://cdn.elsewhere.test/current.pb' },
|
||||
}),
|
||||
upstreamResponse({ bytes: FULL }),
|
||||
]);
|
||||
const refuse = install(transitProxy({ fetchImpl: offOrigin.fetchImpl }));
|
||||
const blocked = await refuse('/vehicles/capmetro-austin');
|
||||
assert.equal(blocked.status, 504);
|
||||
assert.equal(
|
||||
offOrigin.calls.length,
|
||||
1,
|
||||
'the disallowed host was never contacted, only its Location read',
|
||||
);
|
||||
});
|
||||
|
||||
test('only registered feed ids reach an operator', async (t) => {
|
||||
const { fetchImpl, calls } = scriptedUpstream([
|
||||
upstreamResponse({ bytes: FULL }),
|
||||
]);
|
||||
const call = install(transitProxy({ fetchImpl }));
|
||||
for (const path of [
|
||||
'/vehicles/not-a-feed',
|
||||
'/vehicles/..%2F..%2Fetc',
|
||||
'/vehicles/',
|
||||
'/elsewhere',
|
||||
]) {
|
||||
const missing = await call(path);
|
||||
assert.equal(missing.status, 404, `${path} is not a door to an upstream`);
|
||||
}
|
||||
assert.equal(calls.length, 0, 'no operator was contacted for any of them');
|
||||
const catalog = JSON.parse((await call('/feeds')).body);
|
||||
assert.ok(catalog.feeds.length > 0);
|
||||
for (const feed of catalog.feeds) {
|
||||
assert.equal('url' in feed, false);
|
||||
assert.equal('headers' in feed, false);
|
||||
}
|
||||
});
|
||||
|
||||
test('a 304 reports that the operator answered, not that the body is new', async (t) => {
|
||||
// The body a revalidated feed keeps serving carries the fetch time of the
|
||||
// payload it holds, which stops advancing the moment the operator's file
|
||||
// stops changing. That is the right answer for "how old are these positions"
|
||||
// and the wrong one for "when did we last hear from them" — read as the
|
||||
// latter it aged a feed whose every request succeeded into silence.
|
||||
t.mock.timers.enable({ apis: ['Date'], now: 1_700_000_000_000 });
|
||||
const firstAt = Date.now();
|
||||
const { fetchImpl } = scriptedUpstream([
|
||||
upstreamResponse({ headers: { etag: 'W/"v1"' }, bytes: FULL }),
|
||||
upstreamResponse({ status: 304, headers: { etag: 'W/"v1"' } }),
|
||||
upstreamResponse({ status: 304, headers: { etag: 'W/"v1"' } }),
|
||||
]);
|
||||
const call = install(transitProxy({ fetchImpl }));
|
||||
|
||||
const first = await call('/vehicles/mbta');
|
||||
assert.equal(Number(first.headers['X-Transit-Contact']), firstAt);
|
||||
assert.equal(JSON.parse(first.body).fetchedAt, firstAt);
|
||||
|
||||
t.mock.timers.setTime(Date.now() + TRANSIT_PROXY_TTL_MS + 1_000);
|
||||
const revalidatedAt = Date.now();
|
||||
const second = await call('/vehicles/mbta');
|
||||
assert.equal(
|
||||
JSON.parse(second.body).fetchedAt,
|
||||
firstAt,
|
||||
'the positions are as old as they are',
|
||||
);
|
||||
assert.equal(
|
||||
Number(second.headers['X-Transit-Contact']),
|
||||
revalidatedAt,
|
||||
'but the operator answered just now',
|
||||
);
|
||||
|
||||
// Served from the fresh cache, the contact time is the last real contact —
|
||||
// not the moment this request happened to arrive.
|
||||
const third = await call('/vehicles/mbta');
|
||||
assert.equal(third.headers['X-GEV-Cache'], 'HIT');
|
||||
assert.equal(Number(third.headers['X-Transit-Contact']), revalidatedAt);
|
||||
});
|
||||
|
||||
test('a feed served from cache during an outage does not claim fresh contact', async (t) => {
|
||||
// The other half: STALE-ERROR must carry the LAST SUCCESSFUL contact, so the
|
||||
// browser can tell a revalidated feed from a dead one.
|
||||
t.mock.timers.enable({ apis: ['Date'], now: 1_700_000_000_000 });
|
||||
const contactedAt = Date.now();
|
||||
const { fetchImpl } = scriptedUpstream([
|
||||
upstreamResponse({ bytes: FULL }),
|
||||
upstreamResponse({ status: 500 }),
|
||||
upstreamResponse({ status: 500 }),
|
||||
]);
|
||||
const call = install(transitProxy({ fetchImpl }));
|
||||
await call('/vehicles/mbta');
|
||||
|
||||
t.mock.timers.setTime(Date.now() + TRANSIT_PROXY_TTL_MS + 1_000);
|
||||
const during = await call('/vehicles/mbta');
|
||||
assert.equal(during.headers['X-GEV-Cache'], 'STALE-ERROR');
|
||||
assert.equal(
|
||||
Number(during.headers['X-Transit-Contact']),
|
||||
contactedAt,
|
||||
'silence does not count as an answer',
|
||||
);
|
||||
});
|
||||
|
||||
// Include the provider-local storage contract in the repository's src test discovery.
|
||||
import '../../server/providers/transitHistory.test.mjs';
|
||||
|
||||
test('history reads never fetch upstream, and 304/cache/failure replays never append', async (t) => {
|
||||
t.mock.timers.enable({ apis: ['Date'], now: 1700000000000 });
|
||||
const { calls, fetchImpl } = scriptedUpstream([
|
||||
upstreamResponse({ bytes: FULL }),
|
||||
upstreamResponse({ status: 304 }),
|
||||
upstreamResponse({ status: 503 }),
|
||||
]);
|
||||
const plugin = transitProxy({ fetchImpl });
|
||||
t.after(() => plugin.closeBundle());
|
||||
const call = install(plugin);
|
||||
const read = () => call('/trail/mbta/bus-1');
|
||||
assert.equal(JSON.parse((await read()).body).fixes.length, 0);
|
||||
assert.equal(calls.length, 0);
|
||||
await call('/vehicles/mbta');
|
||||
const initial = JSON.parse((await read()).body);
|
||||
assert.equal(initial.fixes.length, 1);
|
||||
await call('/vehicles/mbta');
|
||||
assert.equal(calls.length, 1);
|
||||
t.mock.timers.setTime(Date.now() + TRANSIT_PROXY_TTL_MS + 1);
|
||||
await call('/vehicles/mbta');
|
||||
assert.deepEqual(JSON.parse((await read()).body), initial);
|
||||
t.mock.timers.setTime(Date.now() + TRANSIT_PROXY_TTL_MS + 1);
|
||||
await call('/vehicles/mbta');
|
||||
assert.deepEqual(JSON.parse((await read()).body), initial);
|
||||
assert.equal(calls.length, 3);
|
||||
for (const path of [
|
||||
'/trail/mbta/%2Fescape',
|
||||
'/trail/mbta/abc%00bad',
|
||||
'/trail/mbta/%E0%A4%A',
|
||||
`/trail/mbta/${'a'.repeat(257)}`,
|
||||
'/trail/capmetro-austin/bus-1',
|
||||
])
|
||||
assert.equal((await call(path)).status, 404);
|
||||
assert.equal(
|
||||
calls.length,
|
||||
3,
|
||||
'malformed and non-retained reads cannot reach an operator',
|
||||
);
|
||||
});
|
||||
test('history read admission is separate from upstream admission and closes with the server', async (t) => {
|
||||
t.mock.timers.enable({ apis: ['Date'], now: 1700000000000 });
|
||||
const { calls, fetchImpl } = scriptedUpstream([
|
||||
upstreamResponse({ bytes: FULL }),
|
||||
]);
|
||||
const plugin = transitProxy({ fetchImpl });
|
||||
t.after(() => plugin.closeBundle());
|
||||
const call = install(plugin);
|
||||
for (let i = 0; i < 30; i++)
|
||||
assert.equal((await call('/trail/mbta/bus-1')).status, 200);
|
||||
assert.equal((await call('/trail/mbta/bus-1')).status, 429);
|
||||
assert.equal(calls.length, 0);
|
||||
assert.equal((await call('/vehicles/mbta')).status, 200);
|
||||
assert.equal(calls.length, 1);
|
||||
});
|
||||
@@ -0,0 +1,631 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import {
|
||||
chooseLiveCities,
|
||||
selectStyle,
|
||||
detectOn,
|
||||
} from '../../scripts/qa-transit-controls.mjs';
|
||||
import { createStyleParameters } from '../ui/styleParameters.js';
|
||||
import { bindDisplayControls } from '../ui/displayControls.js';
|
||||
import { thermalShader } from '../styles/thermal.js';
|
||||
import {
|
||||
cycleMode,
|
||||
setMode,
|
||||
getMode,
|
||||
setDetectionTuning,
|
||||
} from '../data/detection.js';
|
||||
|
||||
const pause = async () => {};
|
||||
class Element extends EventTarget {
|
||||
constructor() {
|
||||
super();
|
||||
this.children = [];
|
||||
this.attributes = new Map();
|
||||
this.classList = { contains: () => false };
|
||||
}
|
||||
setAttribute(name, value) {
|
||||
this.attributes.set(name, value);
|
||||
}
|
||||
getAttribute(name) {
|
||||
return this.attributes.get(name);
|
||||
}
|
||||
append(...children) {
|
||||
this.children.push(...children);
|
||||
}
|
||||
appendChild(child) {
|
||||
this.children.push(child);
|
||||
}
|
||||
replaceChildren() {
|
||||
this.children = [];
|
||||
}
|
||||
scrollIntoView() {}
|
||||
getBoundingClientRect() {
|
||||
return { width: 100 };
|
||||
}
|
||||
}
|
||||
function pageFixture(t) {
|
||||
const prior = { document: globalThis.document, window: globalThis.window };
|
||||
const elements = new Map();
|
||||
const document = {
|
||||
documentElement: { dataset: { gevStyle: 'normal' } },
|
||||
createElement: () => new Element(),
|
||||
getElementById: (id) => elements.get(id),
|
||||
querySelector: () => null,
|
||||
};
|
||||
globalThis.document = document;
|
||||
globalThis.window = {};
|
||||
t.after(() => {
|
||||
for (const [name, value] of Object.entries(prior)) {
|
||||
if (value === undefined) delete globalThis[name];
|
||||
else globalThis[name] = value;
|
||||
}
|
||||
});
|
||||
const find = (selector) => {
|
||||
if (selector.startsWith('#param-sliders ')) {
|
||||
const label = selector.match(/aria-label="([^"]+)"/)[1];
|
||||
return elements
|
||||
.get('param-sliders')
|
||||
.children.flatMap((row) => row.children)
|
||||
.find((el) => el.getAttribute('aria-label') === label);
|
||||
}
|
||||
return elements.get(selector.slice(1));
|
||||
};
|
||||
const page = {
|
||||
evaluate: async (fn, ...args) => fn(...args),
|
||||
waitForFunction: async (fn, options, ...args) =>
|
||||
assert.ok(fn(...args), 'UI condition must hold'),
|
||||
waitForSelector: async (selector) =>
|
||||
assert.ok(find(selector), `missing ${selector}`),
|
||||
$eval: async (selector, fn, ...args) => {
|
||||
const el = find(selector);
|
||||
assert.ok(el, `missing ${selector}`);
|
||||
return fn(el, ...args);
|
||||
},
|
||||
click: async (selector) =>
|
||||
find(selector)?.dispatchEvent(new Event('click')),
|
||||
};
|
||||
return { page, elements, document };
|
||||
}
|
||||
|
||||
test('live visual cities follow local operating hours in summer and winter', () => {
|
||||
for (const date of ['2026-09-15T06:30:00Z', '2026-01-15T06:30:00Z']) {
|
||||
assert.equal(chooseLiveCities(new Date(date))[0].id, 'helsinki');
|
||||
}
|
||||
assert.deepEqual(
|
||||
chooseLiveCities(new Date('2026-09-15T17:00:00Z')).map((c) => c.id),
|
||||
['boston', 'austin'],
|
||||
);
|
||||
for (let hour = 0; hour < 24; hour++) {
|
||||
const now = new Date(`2026-09-15T${String(hour).padStart(2, '0')}:00:00Z`);
|
||||
const cities = chooseLiveCities(now);
|
||||
assert.ok(cities.length >= 1);
|
||||
for (const city of cities) {
|
||||
const local = Number(
|
||||
new Intl.DateTimeFormat('en', {
|
||||
timeZone: city.zone,
|
||||
hour: 'numeric',
|
||||
hourCycle: 'h23',
|
||||
}).format(now),
|
||||
);
|
||||
assert.ok(local >= 6 && local < 22);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('thermal controls wait for activation and use the real generated parameter rows', async (t) => {
|
||||
const { page, document, elements } = pageFixture(t);
|
||||
const container = new Element();
|
||||
container.ownerDocument = document;
|
||||
elements.set('param-sliders', container);
|
||||
const parameters = createStyleParameters({ container });
|
||||
t.after(() => parameters.destroy());
|
||||
const values = Object.fromEntries(
|
||||
Object.entries(thermalShader.uniforms).map(([key, meta]) => [
|
||||
key,
|
||||
meta.default,
|
||||
]),
|
||||
);
|
||||
window.__godsEyeView = {
|
||||
styleManager: {
|
||||
setPanelCollapsed() {},
|
||||
setStyle(name) {
|
||||
document.documentElement.dataset.gevStyle = name;
|
||||
parameters.render({
|
||||
uniforms: thermalShader.uniforms,
|
||||
readValue: (key) => values[key],
|
||||
writeValue: (key, value) => {
|
||||
values[key] = value;
|
||||
},
|
||||
onChange() {},
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
// A swallowed click resolves normally; the helper must notice no activation.
|
||||
page.$eval = async (selector, fn, ...args) => {
|
||||
if (selector.startsWith('.style-btn')) return fn(new Element(), ...args);
|
||||
const label = selector.match(/aria-label="([^"]+)"/)[1];
|
||||
const slider = container.children
|
||||
.flatMap((row) => row.children)
|
||||
.find((el) => el.getAttribute('aria-label') === label);
|
||||
assert.ok(slider);
|
||||
return fn(slider, ...args);
|
||||
};
|
||||
const how = await selectStyle(
|
||||
page,
|
||||
'thermal',
|
||||
{ 'WHOT/BHOT': 1, Ironbow: 1 },
|
||||
pause,
|
||||
);
|
||||
assert.match(how, /button did not activate/);
|
||||
assert.equal(values.mode, 1);
|
||||
assert.equal(values.palette, 1);
|
||||
});
|
||||
|
||||
test('DETECT uses the real OFF/restore button and density input to reach DENSE', async (t) => {
|
||||
const { page, elements } = pageFixture(t);
|
||||
const button = new Element(),
|
||||
slider = new Element();
|
||||
elements.set('detection-toggle', button);
|
||||
elements.set('detection-density-slider', slider);
|
||||
button.getAttribute = (name) =>
|
||||
name === 'aria-pressed'
|
||||
? String(getMode() !== 'OFF')
|
||||
: `Detection overlay: ${getMode().toLowerCase()}`;
|
||||
setMode('BALANCED');
|
||||
setMode('OFF');
|
||||
const modes = [];
|
||||
const controls = bindDisplayControls({
|
||||
elements: { detectionButton: button, densitySlider: slider },
|
||||
actions: {
|
||||
cycleDetection() {
|
||||
cycleMode();
|
||||
modes.push(getMode());
|
||||
},
|
||||
setDensity(value) {
|
||||
setDetectionTuning({ densityPct: Number(value) });
|
||||
},
|
||||
},
|
||||
});
|
||||
t.after(() => {
|
||||
controls.destroy();
|
||||
setMode('OFF');
|
||||
});
|
||||
assert.equal(await detectOn(page, pause), 'DENSE');
|
||||
assert.deepEqual(modes, ['BALANCED', 'OFF', 'DENSE']);
|
||||
const template = readFileSync(
|
||||
new URL('../ui/templates/display-controls.html', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
assert.match(template, /id="detection-density-slider"/);
|
||||
assert.match(template, /id="detection-toggle"/);
|
||||
});
|
||||
|
||||
test('city motion checks load deterministic playback before reading rendered samples', () => {
|
||||
const source = readFileSync(
|
||||
new URL('../../scripts/qa-transit.mjs', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
const fixture = source.indexOf('_loadTransitFleetForTest(16');
|
||||
assert.ok(fixture >= 0);
|
||||
assert.ok(
|
||||
source.indexOf('const motionFirst = await sampleVisible(page)', fixture) >
|
||||
fixture,
|
||||
);
|
||||
assert.ok(
|
||||
source.indexOf('const glideA = await sampleVisible(page)', fixture) >
|
||||
fixture,
|
||||
);
|
||||
assert.ok(
|
||||
source.indexOf('const headingA = await sampleScreen(page)', fixture) >
|
||||
fixture,
|
||||
);
|
||||
assert.match(source, /scene.renderError.addEventListener/);
|
||||
});
|
||||
|
||||
const { boundPageEvaluations, restoreRetainedTransit, sampleTransitPixels } =
|
||||
await import('../../scripts/qa-transit-browser.mjs');
|
||||
|
||||
test('page promise deadline rejects a stuck enable or teardown and preserves ordinary results', async () => {
|
||||
const page = { evaluate: async (fn, ...args) => fn(...args) };
|
||||
boundPageEvaluations(page, 15);
|
||||
assert.equal(await page.evaluate((a, b) => a + b, 3, 4), 7);
|
||||
await assert.rejects(
|
||||
page.evaluate(() => new Promise(() => {})),
|
||||
/Transit QA page wait timed out after 15 ms/,
|
||||
);
|
||||
});
|
||||
|
||||
test('reload picks a surviving primed vehicle and reports unavailable snapshots as unexercised', async (t) => {
|
||||
const previous = globalThis.window;
|
||||
t.after(() => {
|
||||
globalThis.window = previous;
|
||||
});
|
||||
let selected;
|
||||
const entry = { key: 'mbta:survivor', record: { lon: -71.06, lat: 42.36 } };
|
||||
const vehicles = new Map([[entry.key, entry]]);
|
||||
globalThis.window = {
|
||||
__godsEyeView: {
|
||||
viewer: {
|
||||
camera: {
|
||||
positionCartographic: {
|
||||
constructor: { fromDegrees: (...v) => v, toCartesian: (v) => v },
|
||||
},
|
||||
setView() {},
|
||||
},
|
||||
},
|
||||
dataManager: {
|
||||
setEnabled: async () => {},
|
||||
layers: new Map([
|
||||
[
|
||||
'transit',
|
||||
{
|
||||
module: {
|
||||
update: async () => {},
|
||||
_transitStateForTest: () => ({ _vehicles: vehicles }),
|
||||
_transitPartsForTest: () => ({
|
||||
selection: {
|
||||
selectVehicle: (key) => {
|
||||
selected = key;
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
],
|
||||
]),
|
||||
},
|
||||
},
|
||||
};
|
||||
const primed = [
|
||||
{ vehicleId: 'departed', oldestT: 10 },
|
||||
{ vehicleId: 'survivor', oldestT: 20 },
|
||||
];
|
||||
assert.deepEqual(await restoreRetainedTransit(primed), {
|
||||
key: entry.key,
|
||||
oldest: 20,
|
||||
});
|
||||
assert.equal(selected, entry.key);
|
||||
vehicles.clear();
|
||||
const manager = window.__godsEyeView.dataManager;
|
||||
const layer = manager.layers.get('transit').module;
|
||||
layer._transitStateForTest = () => ({
|
||||
_vehicles: vehicles,
|
||||
_activeFeeds: new Map([['mbta', {}]]),
|
||||
});
|
||||
manager.setEnabled = async () => {
|
||||
setTimeout(() => vehicles.set(entry.key, entry), 5);
|
||||
};
|
||||
assert.equal(
|
||||
(await restoreRetainedTransit(primed)).key,
|
||||
entry.key,
|
||||
'wait for discovery instead of declaring every vehicle departed',
|
||||
);
|
||||
manager.setEnabled = async () => {};
|
||||
assert.equal(
|
||||
(
|
||||
await restoreRetainedTransit([
|
||||
{ ...primed[1], feedId: 'mbta', primedAt: Date.now() - 60000 },
|
||||
])
|
||||
).unexercised,
|
||||
true,
|
||||
'expired priming cannot pass',
|
||||
);
|
||||
layer._transitStateForTest = () => ({ _vehicles: vehicles });
|
||||
vehicles.clear();
|
||||
assert.equal((await restoreRetainedTransit(primed)).unexercised, true);
|
||||
assert.equal((await restoreRetainedTransit([])).unexercised, true);
|
||||
});
|
||||
|
||||
test('sensor sampler waits for postRender and reads framebuffer pixels; stopped rendering rejects and removes listener', async (t) => {
|
||||
const previousWindow = globalThis.window,
|
||||
previousDocument = globalThis.document,
|
||||
previousImage = globalThis.Image;
|
||||
t.after(() => {
|
||||
globalThis.window = previousWindow;
|
||||
globalThis.document = previousDocument;
|
||||
globalThis.Image = previousImage;
|
||||
});
|
||||
const identity = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
|
||||
let listener,
|
||||
reads = 0,
|
||||
render = true;
|
||||
const gl = {
|
||||
RGBA: 1,
|
||||
UNSIGNED_BYTE: 2,
|
||||
readPixels(x, y, w, h, format, type, buffer) {
|
||||
reads++;
|
||||
buffer.set([255, 255, 255, 255]);
|
||||
},
|
||||
};
|
||||
const scene = {
|
||||
camera: { viewMatrix: identity, frustum: { projectionMatrix: identity } },
|
||||
canvas: {
|
||||
width: 400,
|
||||
height: 400,
|
||||
clientWidth: 400,
|
||||
clientHeight: 400,
|
||||
getContext: () => gl,
|
||||
},
|
||||
postRender: {
|
||||
addEventListener(fn) {
|
||||
listener = fn;
|
||||
return () => {
|
||||
listener = null;
|
||||
};
|
||||
},
|
||||
},
|
||||
requestRender() {
|
||||
if (render) queueMicrotask(() => listener?.());
|
||||
},
|
||||
};
|
||||
const raster = new Uint8ClampedArray(16 * 16 * 4).fill(255);
|
||||
globalThis.Image = class {
|
||||
width = 16;
|
||||
height = 16;
|
||||
async decode() {}
|
||||
};
|
||||
globalThis.document = {
|
||||
getElementById: () => null,
|
||||
createElement: () => ({
|
||||
getContext: () => ({
|
||||
drawImage() {},
|
||||
getImageData: () => ({ data: raster }),
|
||||
}),
|
||||
}),
|
||||
};
|
||||
globalThis.window = {
|
||||
__godsEyeView: {
|
||||
viewer: { scene },
|
||||
dataManager: {
|
||||
layers: new Map([
|
||||
[
|
||||
'transit',
|
||||
{
|
||||
module: {
|
||||
_transitStateForTest: () => ({
|
||||
_vehicles: new Map([
|
||||
[
|
||||
'bus',
|
||||
{
|
||||
key: 'bus',
|
||||
mode: 'bus',
|
||||
marker: {
|
||||
show: true,
|
||||
image: 'raster',
|
||||
position: { x: 0, y: 0, z: 0 },
|
||||
},
|
||||
},
|
||||
],
|
||||
]),
|
||||
}),
|
||||
},
|
||||
},
|
||||
],
|
||||
]),
|
||||
},
|
||||
},
|
||||
};
|
||||
const page = { evaluate: async (fn, ...args) => fn(...args) };
|
||||
const pixels = await sampleTransitPixels(page, {});
|
||||
assert.ok(
|
||||
reads >= 13,
|
||||
'centre, rings and background come from WebGL readPixels',
|
||||
);
|
||||
assert.ok(Math.abs(pixels[0].centre - 1) < 1e-6);
|
||||
assert.equal(listener, null);
|
||||
scene.pick = () => ({ id: 'overlapping-label' });
|
||||
assert.equal(
|
||||
(await sampleTransitPixels(page, {})).length,
|
||||
0,
|
||||
'a foreign pick across the sampled core disqualifies an obscured sprite',
|
||||
);
|
||||
scene.pick = ({ x }) => ({ id: x < 200 ? 'overlapping-sprite' : 'bus' });
|
||||
assert.equal(
|
||||
(await sampleTransitPixels(page, {})).length,
|
||||
0,
|
||||
'a clear centre cannot qualify a partially obscured core',
|
||||
);
|
||||
delete scene.pick;
|
||||
const module = window.__godsEyeView.dataManager.layers.get('transit').module;
|
||||
const getState = module._transitStateForTest;
|
||||
module._transitStateForTest = () => {
|
||||
const state = getState();
|
||||
state._stylePreset = 'noir';
|
||||
state._vehicles.get('bus').marker.position.x = 0.65;
|
||||
return state;
|
||||
};
|
||||
assert.equal(
|
||||
(await sampleTransitPixels(page, {})).length,
|
||||
0,
|
||||
'the intentional noir edge vignette is outside unobscured-core acceptance',
|
||||
);
|
||||
module._transitStateForTest = getState;
|
||||
// The fixture returns fresh wrapper objects, so exercise raster and DOM
|
||||
// eligibility independently of framebuffer brightness.
|
||||
raster.fill(0);
|
||||
assert.equal((await sampleTransitPixels(page, {})).length, 0);
|
||||
raster.fill(255);
|
||||
document.elementFromPoint = () => ({ tagName: 'DIV', closest: () => null });
|
||||
assert.equal((await sampleTransitPixels(page, {})).length, 0);
|
||||
delete document.elementFromPoint;
|
||||
document.getElementById = () => ({
|
||||
width: 400,
|
||||
clientWidth: 400,
|
||||
getContext: () => ({
|
||||
getImageData: () => ({ data: new Uint8ClampedArray(144).fill(255) }),
|
||||
}),
|
||||
});
|
||||
assert.equal((await sampleTransitPixels(page, {})).length, 0);
|
||||
document.getElementById = () => null;
|
||||
window.__godsEyeView.styleManager = {
|
||||
getDetectionDiagnostics: () => ({
|
||||
calloutRects: [{ x: 205, y: 195, w: 10, h: 10, alpha: 1 }],
|
||||
}),
|
||||
};
|
||||
assert.equal(
|
||||
(await sampleTransitPixels(page, {})).length,
|
||||
0,
|
||||
'plate over the edge excludes the whole sprite even with a clear centre',
|
||||
);
|
||||
delete window.__godsEyeView.styleManager;
|
||||
document.getElementById = () => ({
|
||||
width: 400,
|
||||
clientWidth: 400,
|
||||
getContext: () => ({
|
||||
getImageData: (x, y, w = 1, h = 1) => ({
|
||||
data:
|
||||
w > 1
|
||||
? new Uint8ClampedArray(w * h * 4)
|
||||
: new Uint8ClampedArray(
|
||||
x === 191 && [190, 193, 196].includes(y)
|
||||
? [20, 30, 40, 255]
|
||||
: x === 191 && y === 207
|
||||
? [94, 240, 138, 255]
|
||||
: [0, 0, 0, 0],
|
||||
),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
assert.deepEqual(
|
||||
(await sampleTransitPixels(page, {}))[0].bracket,
|
||||
[94, 240, 138],
|
||||
'stroke centre is sampled away from the top label plate',
|
||||
);
|
||||
document.getElementById = () => null;
|
||||
render = false;
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
const pending = sampleTransitPixels(page, {});
|
||||
const rejected = assert.rejects(pending, /no postRender within 5000 ms/);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
t.mock.timers.tick(5000);
|
||||
await rejected;
|
||||
assert.equal(listener, null);
|
||||
});
|
||||
|
||||
test('preset baseline clears a previous city selection before comparing fleet sizes', async (t) => {
|
||||
const { readTransitFleetStyle } =
|
||||
await import('../../scripts/qa-transit-browser.mjs');
|
||||
const prior = globalThis.window;
|
||||
t.after(() => {
|
||||
globalThis.window = prior;
|
||||
});
|
||||
const entry = {
|
||||
key: 'mbta:tram',
|
||||
marker: {
|
||||
show: true,
|
||||
width: 33.75,
|
||||
image: 'raster-selected',
|
||||
color: { toCssColorString: () => 'rgb(255,194,74)' },
|
||||
},
|
||||
};
|
||||
const state = {
|
||||
_selectedKey: entry.key,
|
||||
_vehicles: new Map([[entry.key, entry]]),
|
||||
};
|
||||
globalThis.window = {
|
||||
__godsEyeView: {
|
||||
dataManager: {
|
||||
layers: new Map([
|
||||
[
|
||||
'transit',
|
||||
{
|
||||
module: {
|
||||
_transitStateForTest: () => state,
|
||||
_transitPartsForTest: () => ({
|
||||
selection: {
|
||||
clearSelection() {
|
||||
state._selectedKey = null;
|
||||
entry.marker.width = 22.5;
|
||||
entry.marker.image = 'raster-fleet';
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
],
|
||||
]),
|
||||
},
|
||||
},
|
||||
};
|
||||
const baseline = readTransitFleetStyle({ resetSelection: true });
|
||||
assert.equal(baseline.width, 22.5);
|
||||
assert.equal(baseline.selected, false);
|
||||
assert.deepEqual(readTransitFleetStyle({ key: entry.key }), baseline);
|
||||
});
|
||||
|
||||
test('allocation accounting includes Cesium render aliases and excludes QA helper subtrees', async () => {
|
||||
const { allocationTotals } =
|
||||
await import('../../scripts/qa-transit-scenes.mjs');
|
||||
const node = (functionName, url, selfSize, children = []) => ({
|
||||
callFrame: { functionName, url },
|
||||
selfSize,
|
||||
children,
|
||||
});
|
||||
const profile = {
|
||||
head: node('(root)', '', 0, [
|
||||
node('render2', 'cesium.js', 0, [
|
||||
node('Scene4.render', 'cesium.js', 10, [
|
||||
node('_drawOverlay', '/src/data/detection.js', 100),
|
||||
node('', '/src/layers/transit/testing.js', 200, [
|
||||
node('hypot', '', 300),
|
||||
node('onPreRender', '/src/layers/transit/rendering.js', 20, [
|
||||
node('sampleAt', '/src/data/contactPlayback.js', 30),
|
||||
node('style', '/src/data/transitPresetStyle.js', 10),
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
};
|
||||
const { topAllocators, ...totals } = allocationTotals(profile);
|
||||
assert.equal(topAllocators[0].bytes, 100);
|
||||
assert.deepEqual(totals, {
|
||||
frameBytes: 170,
|
||||
layerFrameBytes: 60,
|
||||
sharedOverlayBytes: 100,
|
||||
qaBytes: 500,
|
||||
});
|
||||
});
|
||||
|
||||
test('public transit documentation uses courtesy attribution and the approved ground sentence', () => {
|
||||
const data = readFileSync(
|
||||
new URL('../../DATA_SOURCES.md', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
const rows = data
|
||||
.split('\n')
|
||||
.filter((line) =>
|
||||
/^\| \*\*(MBTA|CapMetro|Metro Transit|OVapi|Entur|TransLink|HSL)/.test(
|
||||
line,
|
||||
),
|
||||
);
|
||||
assert.equal(rows.length, 7);
|
||||
for (const row of rows) {
|
||||
assert.doesNotMatch(
|
||||
row,
|
||||
/licen[cs]e|unlicensed|unstated|grants|permits|public.domain|CC BY|NLOD|terms|trademarks/i,
|
||||
);
|
||||
assert.match(row, /open realtime vehicle data/);
|
||||
assert.match(row, /published for developer use and not against their use/);
|
||||
}
|
||||
const readme = readFileSync(
|
||||
new URL('../../README.md', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
assert.ok(
|
||||
readme.includes(
|
||||
'Fifteen layers and map sources. **Thirteen have a keyless path.**',
|
||||
),
|
||||
);
|
||||
assert.ok(
|
||||
readme.includes(
|
||||
'**Sits on the real ground.** Entity heights are aligned to work with Google 3D tiles, so aircraft park on aprons and cameras stand on street corners instead of floating.',
|
||||
),
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
readme,
|
||||
/geoid-aware|sampled against the _rendered_ terrain mesh/,
|
||||
);
|
||||
});
|
||||
@@ -98,6 +98,7 @@ export class StyleManager {
|
||||
satellitesLayer,
|
||||
cctvLayer,
|
||||
bikeshareLayer,
|
||||
transitLayer,
|
||||
aisLiveVesselsLayer,
|
||||
militaryAwarenessLayer,
|
||||
cachedGroundFloor,
|
||||
@@ -570,6 +571,7 @@ export class StyleManager {
|
||||
satellitesLayer,
|
||||
cctvLayer,
|
||||
bikeshareLayer,
|
||||
transitLayer,
|
||||
aisLiveVesselsLayer,
|
||||
],
|
||||
(modeLabel) => {
|
||||
|
||||
@@ -253,6 +253,17 @@ export class VisualSettings {
|
||||
effective === 'nvg';
|
||||
this._dataManager?.setLayerParams('flights', { irBoost });
|
||||
this._dataManager?.setLayerParams('military', { irBoost });
|
||||
// Layers whose in-scene sprites restyle for a sensor preset need the
|
||||
// EFFECTIVE style — a cockpit vision override sets no map style, so the
|
||||
// map's own style event never fires for it.
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('gev:vision-change', {
|
||||
detail: {
|
||||
style: effective,
|
||||
cockpit: Boolean(cockpitMode && cockpitMode !== 'optical'),
|
||||
},
|
||||
}),
|
||||
);
|
||||
// Fog blends distant geometry toward an effectively-BLACK color in this
|
||||
// app (the Cesium globe is hidden), so beyond ~100 km every 3D aircraft
|
||||
// fogs to a black silhouette — lighting and shaders can't reach past it
|
||||
|
||||
Reference in new issue
Block a user