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:
Bilawal Sidhu authored and GitHub committed 2026-09-15 05:18:30 -05:00
1 parent 1ad565c8af
commit 76c20be277
78 files changed
+19842 -186

No files matched your search

+10 -4
View File
@@ -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();
+7 -12
View File
@@ -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;
},
+18
View File
@@ -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 };
}
+257
View File
@@ -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,
};
}
+70
View File
@@ -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';
+513
View File
@@ -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,
};
}
+303
View File
@@ -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
+313
View File
@@ -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',
};
}
+146
View File
@@ -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]),
);
});
+565
View File
@@ -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;
}
+331
View File
@@ -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,
};
}
+330
View File
@@ -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,
);
});
+237
View File
@@ -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 };
}
+598
View File
@@ -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,
};
}
+209
View File
@@ -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,
};
}
+126
View File
@@ -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;
}
+340
View File
@@ -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;
},
};
}
+328
View File
@@ -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,
};
}
+137
View File
@@ -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,
};
}