mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
feat(transit): live public-transit vehicles from open GTFS-Realtime feeds (#587)
Supersedes #218 by @spcpza, ported onto the current component layout with fixes from review. Verified before merge: format, package boundaries, unit suite, build, and the browser tracking suite; a browser harness for the change where one applies; adversarial review applied. Contributor: @spcpza. His original commit is the base of this branch, and his own re-port onto main (2026-09-15, `5b69011`) was consulted while finishing the height work. That port reads every vehicle's surface from the app's shared ground-floor system (`cachedGroundFloor` / `warmGroundFloor` / `GROUND_FLOOR_LIFT_M`) rather than sampling the scene per layer, keeps a vehicle hidden until its cell answers, warms a bounded batch per poll on a timer instead of per frame, and renders at the ellipsoid above the altitude gate. That is the sounder primitive — one floor system for the app, with the rooftop-versus-street question already settled against a datum-correct DEM prior — and this branch's height work was rebuilt on it, replacing a bespoke cell cache, ground prior and rooftop rule written here. Credit for that direction is his. Future work (not in this PR), both written up in docs/CURRENT-STATE.md: - Readable route names. The realtime feeds carry only `route_id`; `route_short_name` / `route_long_name` live in each operator's static GTFS `routes.txt`, and headsigns in `trips.txt`. Doing it properly wants a server-side metadata loader beside the transit proxy with fixed registered static sources, bounded indexes built once and persisted, a slow conditional refresh, and a versioned route dictionary the browser caches. Shipped here: ids are never rewritten, and a cut one carries an ellipsis instead of reading as a typo. - Below-ground rendering. Subway vehicles are drawn on the surface directly above themselves, because the feeds carry no depth and a train nobody can see is not worth drawing; the card says so plainly. Real treatment wants OSM tunnel geometry per city and a ghosted sprite tethered to the surface. The owner recorded it and left it. Round four and after (2026-09-15). The owner tested round three under FLIR and NVG, and then asked for the design to be taken further: elegant in performance, beautiful on camera, trails with a cache for public transport, Cesium-native, and a core other moving layers can adopt. Codex Astra reviewed the plan (it confirmed the speed fault: a glide started from the drawn position at receipt time but spanned the gap between report times, so with MBTA reporting every ~10 s against a 15 s poll every second glide ran at 1.5x; it rejected a queued-segment patch), then wrote the design, then implemented it in commit-sized passes with adversarial review between them and real-browser harness runs by the integrator. What this PR now contains beyond the contributor's original port: - Delayed playback in a contact-agnostic core (`src/data/contactPlayback.js`): each vehicle keeps a typed, bounded fix history and a display clock that runs at one second per second through it, a feed-derived lag behind real time, holding at its newest fix rather than extrapolating and never sprinting to catch up. Marker, course, motion word, card and trail all read the same displayed sample. The clock has an explicit time-offset and seek API so a rewind is a later feature, not a rewrite; flights and vessels are untouched. - A selected-vehicle trail (`src/data/contactTrailRenderer.js`, `src/layers/transit/trails.js`): the retained history drawn with batched Cesium primitives, clipped at display time so the head is exactly the sprite, faded by age, in the mode colour (white under sensor presets), on the same surface path as the marker. - Bounded proxy-side history (`server/providers/transitHistory.js`): fifteen minutes and at most 128 fixes per vehicle with hard byte ceilings, MBTA only for now (opt-in per feed), served by a small endpoint that never fetches upstream, so a selected trail survives a browser reload while the server keeps running. - Scene work bounded to what is visible and moving, an occluder, cached DETECT candidates, holds on the render loop only while something visible moves. - Sensor presets: white-hot haloed sprites under NVG/FLIR/noir (black-hot inverts), saturated mode colours under CRT, per-mode bracket tiers in every theme, the selected card keeping its mode colour. - A discriminating browser harness (`scripts/qa-transit.mjs`) that samples postRender frames, reads the real card anchor, and measures fleet budgets at 800 and 3,000 vehicles. Not built, recorded as follow-ups: curated route display names (lowest priority per the owner), fleet-wide tails, route snapping, a rewind UI, adoption of the playback core by flights and vessels. Verified at merge (2026-09-15 morning): unit suite 3,896 passing, build clean, format and boundary gates clean, tracking regression 109/109, browser harness keyless 200 pass / 0 fail (13 sensor checks unexercised because the live view offered fewer than six isolated sprites; every judged sprite passed), keyed on Google 3D tiles 198 pass with the per-frame allocation gate as the only failure. Follow-ups recorded: the keyed build's playback sampler still allocates per frame (CPU and frame time stay within budget; the 3,000-vehicle metro view sits at a 27.6 ms frame interval against a 25 ms budget), the shared detection overlay's per-paint allocation, friendlier route names, fleet-wide tails, a rewind UI, and adopting the playback core for vessels and flights. Co-authored-by: spcpza <[email protected]>
This commit is contained in:
1 parent
1ad565c8af
commit
76c20be277
78 files changed
+19842
-186
No files matched your search
@@ -0,0 +1,93 @@
|
||||
// src/data/cameraSensitivity.js
|
||||
/**
|
||||
* Shared claims on `camera.percentageChanged`.
|
||||
*
|
||||
* `camera.changed` fires once the view has moved by this fraction, and it is a
|
||||
* single number on the camera that every listener in the app shares. Several
|
||||
* layers want it finer than the default — traffic, bikeshare and transit all
|
||||
* ask for 0.05 so they notice they have entered a coverage area — and each of
|
||||
* them used to write the number directly and hand back whatever it remembered.
|
||||
*
|
||||
* That cannot work, because a number is not an owner. Two layers asking for the
|
||||
* same 0.05 write the same value, so when the first one leaves it sees its own
|
||||
* number still in place, concludes nothing else has claimed it, and restores
|
||||
* the coarse default out from under a layer that is still running. The symptom
|
||||
* is a camera-driven layer that quietly stops noticing the camera: Transit is
|
||||
* turned off and Bikeshare stops loading new cities until something else nudges
|
||||
* it.
|
||||
*
|
||||
* So ownership is tracked explicitly. Claims are named, the camera gets the
|
||||
* finest value anyone has asked for, and the original value returns only when
|
||||
* the last claim is gone. Claiming twice under one name replaces that claim.
|
||||
*
|
||||
* This is bookkeeping, not policy: it never decides what a layer should ask
|
||||
* for, only that nobody's answer is taken away while they still need it.
|
||||
*/
|
||||
|
||||
/** @type {WeakMap<object, {base: number, claims: Map<string, number>}>} */
|
||||
const _cameras = new WeakMap();
|
||||
|
||||
/** The value Cesium starts a camera at, used when the camera reports nothing. */
|
||||
const DEFAULT_PERCENTAGE_CHANGED = 0.5;
|
||||
|
||||
function ledgerFor(camera) {
|
||||
let ledger = _cameras.get(camera);
|
||||
if (!ledger) {
|
||||
const base = Number.isFinite(camera.percentageChanged)
|
||||
? camera.percentageChanged
|
||||
: DEFAULT_PERCENTAGE_CHANGED;
|
||||
ledger = { base, claims: new Map() };
|
||||
_cameras.set(camera, ledger);
|
||||
}
|
||||
return ledger;
|
||||
}
|
||||
|
||||
function applyLedger(camera, ledger) {
|
||||
let value = ledger.base;
|
||||
for (const claimed of ledger.claims.values()) {
|
||||
if (claimed < value) value = claimed;
|
||||
}
|
||||
camera.percentageChanged = value;
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask for a camera-change sensitivity at least as fine as `value`.
|
||||
* @param {object|null|undefined} camera A Cesium camera (or anything with `percentageChanged`).
|
||||
* @param {string} owner Stable claim name, usually the layer id.
|
||||
* @param {number} value Requested `percentageChanged`.
|
||||
* @returns {number|null} The value now in force, or null when there is no camera.
|
||||
*/
|
||||
export function claimCameraSensitivity(camera, owner, value) {
|
||||
if (!camera || !owner || !Number.isFinite(value)) return null;
|
||||
const ledger = ledgerFor(camera);
|
||||
ledger.claims.set(owner, value);
|
||||
return applyLedger(camera, ledger);
|
||||
}
|
||||
|
||||
/**
|
||||
* Give up one claim. The camera keeps the finest value still claimed by
|
||||
* somebody else, and returns to its original only when none are left.
|
||||
* @param {object|null|undefined} camera
|
||||
* @param {string} owner
|
||||
* @returns {number|null} The value now in force, or null when there is no camera.
|
||||
*/
|
||||
export function releaseCameraSensitivity(camera, owner) {
|
||||
if (!camera || !owner) return null;
|
||||
const ledger = _cameras.get(camera);
|
||||
if (!ledger) return null;
|
||||
if (!ledger.claims.delete(owner)) return camera.percentageChanged;
|
||||
const applied = applyLedger(camera, ledger);
|
||||
if (ledger.claims.size === 0) _cameras.delete(camera);
|
||||
return applied;
|
||||
}
|
||||
|
||||
/**
|
||||
* Who currently holds a claim. For tests and diagnostics only.
|
||||
* @param {object|null|undefined} camera
|
||||
* @returns {Array<string>}
|
||||
*/
|
||||
export function cameraSensitivityClaims(camera) {
|
||||
const ledger = camera ? _cameras.get(camera) : null;
|
||||
return ledger ? [...ledger.claims.keys()] : [];
|
||||
}
|
||||
@@ -0,0 +1,691 @@
|
||||
/** Portable retained report-time playback. No scene or scheduling ownership. */
|
||||
export const FIX_BYTES = 48;
|
||||
export const FIX_FLAGS = Object.freeze({
|
||||
VEHICLE_TIME: 1,
|
||||
FEED_TIME: 2,
|
||||
RECEIPT_TIME: 4,
|
||||
FLOOR: 8,
|
||||
BREAK: 16,
|
||||
LIVE: 32,
|
||||
});
|
||||
const clampDelay = (n) => Math.max(25_000, Math.min(120_000, n));
|
||||
|
||||
export function createLagReservoir(capacity = 256) {
|
||||
return { values: new Float64Array(capacity), count: 0, cursor: 0 };
|
||||
}
|
||||
function addLag(reservoir, value) {
|
||||
if (!reservoir || !Number.isFinite(value) || value < 0) return;
|
||||
reservoir.values[reservoir.cursor] = value;
|
||||
reservoir.cursor = (reservoir.cursor + 1) % reservoir.values.length;
|
||||
reservoir.count = Math.min(reservoir.count + 1, reservoir.values.length);
|
||||
}
|
||||
function percentile(reservoir, rank) {
|
||||
if (!reservoir?.count) return 0;
|
||||
const sorted = reservoir.values.slice(0, reservoir.count).sort();
|
||||
return sorted[Math.ceil(sorted.length * rank) - 1];
|
||||
}
|
||||
|
||||
/** Optional shared payload budget. Call trimTrack on lower-priority tracks first. */
|
||||
export function createHistoryBudget(maxBytes = 32 * 1024 * 1024) {
|
||||
return { maxBytes, allocatedBytes: 0 };
|
||||
}
|
||||
/** Numeric fields are reserved up front, including unknown mesh heights. */
|
||||
export function createFixSample() {
|
||||
return {
|
||||
t: NaN,
|
||||
lat: NaN,
|
||||
lon: NaN,
|
||||
heightM: NaN,
|
||||
h: NaN,
|
||||
receivedAt: NaN,
|
||||
bearingDeg: NaN,
|
||||
flags: 0,
|
||||
epoch: 0,
|
||||
seq: -1,
|
||||
};
|
||||
}
|
||||
export function createTrack({
|
||||
capacity = 128,
|
||||
retentionMs = 900_000,
|
||||
policy = {},
|
||||
budget = null,
|
||||
} = {}) {
|
||||
capacity = Math.max(2, Math.min(128, Math.floor(capacity)));
|
||||
return {
|
||||
capacity,
|
||||
retentionMs,
|
||||
policy,
|
||||
budget,
|
||||
metricsScratchA: createFixSample(),
|
||||
metricsScratchB: createFixSample(),
|
||||
storage: new DataView(new ArrayBuffer(0)),
|
||||
slots: 0,
|
||||
head: 0,
|
||||
count: 0,
|
||||
baseSeq: 0,
|
||||
revision: 0,
|
||||
truncated: false,
|
||||
epochs: new Map(),
|
||||
lag: createLagReservoir(32),
|
||||
targetDelayMs: 25_000,
|
||||
displayT: NaN,
|
||||
wallAnchor: NaN,
|
||||
monoAnchor: NaN,
|
||||
lastMono: NaN,
|
||||
offset: 0,
|
||||
rate: 1,
|
||||
phase: 'buffering',
|
||||
resets: 0,
|
||||
resetReason: null,
|
||||
acceptedAt: NaN,
|
||||
acceptedMono: NaN,
|
||||
resumePending: false,
|
||||
bracket: 0,
|
||||
metricsRevision: -1,
|
||||
metricsIndex: -1,
|
||||
from: createFixSample(),
|
||||
to: createFixSample(),
|
||||
metrics: { speed: NaN, course: NaN, nextMotionT: Infinity },
|
||||
latest: createFixSample(),
|
||||
pending: null,
|
||||
pendingCount: 0,
|
||||
};
|
||||
}
|
||||
function address(track, index) {
|
||||
return ((track.head + index) % track.slots) * FIX_BYTES;
|
||||
}
|
||||
export function readFix(track, index, out = {}) {
|
||||
if (index < 0 || index >= track.count) return null;
|
||||
const v = track.storage,
|
||||
o = address(track, index);
|
||||
out.t = v.getFloat64(o);
|
||||
out.lat = v.getFloat64(o + 8);
|
||||
out.lon = v.getFloat64(o + 16);
|
||||
out.heightM = v.getFloat32(o + 24);
|
||||
out.h = out.heightM;
|
||||
out.receivedAt = v.getFloat64(o + 28);
|
||||
out.bearingDeg = v.getFloat32(o + 36);
|
||||
out.flags = v.getUint32(o + 40);
|
||||
out.epoch = v.getUint32(o + 44);
|
||||
out.seq = track.baseSeq + index;
|
||||
return out;
|
||||
}
|
||||
function writeFix(track, index, fix) {
|
||||
const v = track.storage,
|
||||
o = address(track, index);
|
||||
v.setFloat64(o, fix.t);
|
||||
v.setFloat64(o + 8, fix.lat);
|
||||
v.setFloat64(o + 16, fix.lon);
|
||||
v.setFloat32(o + 24, fix.heightM ?? fix.h ?? NaN);
|
||||
v.setFloat64(o + 28, fix.receivedAt ?? NaN);
|
||||
v.setFloat32(o + 36, fix.bearingDeg ?? NaN);
|
||||
v.setUint32(o + 40, fix.flags || 0);
|
||||
v.setUint32(o + 44, fix.epoch || 0);
|
||||
}
|
||||
function resize(track, slots) {
|
||||
const bytes = slots * FIX_BYTES,
|
||||
delta = bytes - track.storage.byteLength;
|
||||
if (
|
||||
delta > 0 &&
|
||||
track.budget &&
|
||||
track.budget.allocatedBytes + delta > track.budget.maxBytes
|
||||
)
|
||||
return false;
|
||||
const old = track.storage,
|
||||
head = track.head,
|
||||
oldSlots = track.slots;
|
||||
const next = new Uint8Array(bytes),
|
||||
previous = new Uint8Array(old.buffer);
|
||||
for (let i = 0; i < track.count; i++) {
|
||||
const o = ((head + i) % oldSlots) * FIX_BYTES;
|
||||
next.set(previous.subarray(o, o + FIX_BYTES), i * FIX_BYTES);
|
||||
}
|
||||
track.storage = new DataView(next.buffer);
|
||||
track.slots = slots;
|
||||
track.head = 0;
|
||||
if (track.budget) track.budget.allocatedBytes += delta;
|
||||
return true;
|
||||
}
|
||||
export function destroyTrack(track) {
|
||||
if (track.budget) track.budget.allocatedBytes -= track.storage.byteLength;
|
||||
track.storage = new DataView(new ArrayBuffer(0));
|
||||
track.count = 0;
|
||||
track.slots = 0;
|
||||
track.epochs.clear();
|
||||
}
|
||||
function timeAt(track, i) {
|
||||
return track.storage.getFloat64(address(track, i));
|
||||
}
|
||||
function dropOldest(track) {
|
||||
track.head = (track.head + 1) % track.slots;
|
||||
track.count--;
|
||||
track.baseSeq++;
|
||||
track.revision++;
|
||||
track.truncated = true;
|
||||
}
|
||||
/** Retention never evicts the bracket currently on screen. */
|
||||
export function trimTrack(track, nowMs, keep = track.capacity) {
|
||||
while (
|
||||
track.count > 2 &&
|
||||
(track.count > keep || timeAt(track, 1) < nowMs - track.retentionMs)
|
||||
) {
|
||||
if (Number.isFinite(track.displayT) && timeAt(track, 1) > track.displayT)
|
||||
break;
|
||||
dropOldest(track);
|
||||
}
|
||||
if (keep < track.slots && track.count <= keep)
|
||||
resize(track, Math.max(2, track.count));
|
||||
const used = new Set();
|
||||
for (let i = 0; i < track.count; i++)
|
||||
used.add(track.storage.getUint32(address(track, i) + 44));
|
||||
for (const id of track.epochs.keys())
|
||||
if (!used.has(id)) track.epochs.delete(id);
|
||||
}
|
||||
export function validFix(fix) {
|
||||
return (
|
||||
Number.isFinite(fix?.t) &&
|
||||
fix.t >= 0 &&
|
||||
Number.isFinite(fix.lat) &&
|
||||
Math.abs(fix.lat) <= 90 &&
|
||||
Number.isFinite(fix.lon) &&
|
||||
Math.abs(fix.lon) <= 180
|
||||
);
|
||||
}
|
||||
function distance(a, b) {
|
||||
const lon = ((b.lon - a.lon + 540) % 360) - 180;
|
||||
return Math.hypot(
|
||||
(b.lat - a.lat) * 111320,
|
||||
lon * 111320 * Math.cos((a.lat * Math.PI) / 180),
|
||||
);
|
||||
}
|
||||
function append(track, fix) {
|
||||
if (track.count === track.slots && track.slots < track.capacity)
|
||||
resize(track, Math.min(track.capacity, Math.max(8, track.slots * 2)));
|
||||
if (track.count === track.slots) {
|
||||
if (track.count < 2) return false;
|
||||
if (Number.isFinite(track.displayT) && timeAt(track, 1) > track.displayT) {
|
||||
// Pin both active ends; drop the next future observation and mark the gap.
|
||||
if (track.count < 4) return false;
|
||||
const scratch = {};
|
||||
for (let i = 2; i < track.count - 1; i++) {
|
||||
readFix(track, i + 1, scratch);
|
||||
if (i === 2) scratch.flags |= FIX_FLAGS.BREAK;
|
||||
writeFix(track, i, scratch);
|
||||
}
|
||||
track.count--;
|
||||
track.truncated = true;
|
||||
} else dropOldest(track);
|
||||
}
|
||||
writeFix(track, track.count++, fix);
|
||||
track.revision++;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function insertFix(track, fix, observation = {}) {
|
||||
if (
|
||||
!validFix(fix) ||
|
||||
(Number.isFinite(observation.wallNowMs) &&
|
||||
fix.t > observation.wallNowMs + 10_000)
|
||||
)
|
||||
return { accepted: false, reason: 'malformed' };
|
||||
const last = readFix(track, track.count - 1, track.latest);
|
||||
if (last && fix.t === last.t)
|
||||
return {
|
||||
accepted: false,
|
||||
reason:
|
||||
fix.lat === last.lat && fix.lon === last.lon ? 'repeat' : 'conflict',
|
||||
};
|
||||
let reason = last ? 'moved' : 'first';
|
||||
if (
|
||||
last &&
|
||||
(fix.t < last.t || (track.policy.accept && !track.policy.accept(last, fix)))
|
||||
) {
|
||||
reason = fix.t < last.t ? 'out-of-order' : 'implausible';
|
||||
const prior = track.pending;
|
||||
if (
|
||||
!prior ||
|
||||
prior.reason !== reason ||
|
||||
fix.t <= prior.t ||
|
||||
(track.policy.accept && !track.policy.accept(prior, fix))
|
||||
) {
|
||||
if (!prior || fix.t !== prior.t) {
|
||||
track.pending = { ...fix, reason };
|
||||
track.pendingCount = 1;
|
||||
}
|
||||
return { accepted: false, reason };
|
||||
}
|
||||
track.pendingCount++;
|
||||
const required = reason === 'out-of-order' ? 3 : 2;
|
||||
if (track.pendingCount < required) {
|
||||
track.pending = { ...fix, reason };
|
||||
return { accepted: false, reason };
|
||||
}
|
||||
// Clock correction starts a new time domain. A spatial replacement retains a visible gap.
|
||||
if (reason === 'out-of-order') {
|
||||
track.count = 0;
|
||||
track.head = 0;
|
||||
track.baseSeq += 128;
|
||||
}
|
||||
const epoch = (last.epoch + 1) >>> 0;
|
||||
append(track, {
|
||||
...prior,
|
||||
epoch,
|
||||
flags: (prior.flags || 0) | FIX_FLAGS.BREAK,
|
||||
});
|
||||
fix = { ...fix, epoch };
|
||||
if (reason === 'out-of-order') track.displayT = NaN;
|
||||
track.resetReason = reason;
|
||||
track.resets++;
|
||||
}
|
||||
const receivedAt =
|
||||
observation.receivedAt ?? fix.receivedAt ?? observation.wallNowMs ?? fix.t;
|
||||
trimTrack(track, receivedAt);
|
||||
const accepted = append(track, {
|
||||
...fix,
|
||||
receivedAt,
|
||||
flags: (fix.flags || 0) | FIX_FLAGS.LIVE,
|
||||
});
|
||||
if (accepted) {
|
||||
if (!observation.replayed) {
|
||||
if (last && fix.t > last.t && reason === 'moved') {
|
||||
addLag(track.lag, receivedAt - last.t);
|
||||
addLag(track.policy.feedLag, receivedAt - last.t);
|
||||
track.targetDelayMs = clampDelay(
|
||||
Math.max(
|
||||
percentile(track.lag, 0.95),
|
||||
percentile(track.policy.feedLag, 0.9),
|
||||
) + 5000,
|
||||
);
|
||||
} else if (!last)
|
||||
track.targetDelayMs = clampDelay(
|
||||
Math.max(0, receivedAt - fix.t) + 20_000,
|
||||
);
|
||||
}
|
||||
track.acceptedAt = receivedAt;
|
||||
track.acceptedMono = observation.monoNowMs ?? NaN;
|
||||
track.resumePending = true;
|
||||
track.pending = null;
|
||||
track.pendingCount = 0;
|
||||
}
|
||||
return { accepted, reason: accepted ? reason : 'capacity' };
|
||||
}
|
||||
|
||||
/** Bounded backfill; live positions win conflicts and the clock/health are untouched. */
|
||||
export function mergeHistory(track, fixes) {
|
||||
const merged = new Map();
|
||||
for (const fix of fixes.slice(-128))
|
||||
if (
|
||||
validFix(fix) &&
|
||||
(!Number.isFinite(track.acceptedAt) ||
|
||||
fix.t >= track.acceptedAt - track.retentionMs)
|
||||
)
|
||||
merged.set(fix.t, { ...fix, flags: (fix.flags || 0) & ~FIX_FLAGS.LIVE });
|
||||
for (let i = 0; i < track.count; i++) {
|
||||
const fix = readFix(track, i);
|
||||
merged.set(fix.t, fix);
|
||||
}
|
||||
const retained = Array.from({ length: track.count }, (_, i) =>
|
||||
readFix(track, i),
|
||||
);
|
||||
const active = Number.isFinite(track.displayT)
|
||||
? retained.findLastIndex((f) => f.t <= track.displayT)
|
||||
: -1;
|
||||
const pinnedFrom = retained[active],
|
||||
pinnedTo = retained[active + 1];
|
||||
const trusted = new Set(retained.map((f) => f.t));
|
||||
const candidates = [...merged.values()]
|
||||
.sort((a, b) => a.t - b.t)
|
||||
.filter(
|
||||
(f) =>
|
||||
trusted.has(f.t) ||
|
||||
!pinnedFrom ||
|
||||
!pinnedTo ||
|
||||
f.t < pinnedFrom.t ||
|
||||
f.t > pinnedTo.t,
|
||||
);
|
||||
const ordered = [];
|
||||
// Start from an admitted live position, including when walking backwards
|
||||
// into retained history. A lone outlier is quarantined in either direction.
|
||||
// Never borrow the live pending observation as a second vote for itself.
|
||||
function admit(sequence, anchor, backwards = false) {
|
||||
let last = anchor,
|
||||
pending = null;
|
||||
const plausible = (a, b) =>
|
||||
!track.policy.accept ||
|
||||
(backwards ? track.policy.accept(b, a) : track.policy.accept(a, b));
|
||||
for (const fix of sequence) {
|
||||
if (trusted.has(fix.t) || !last || plausible(last, fix)) {
|
||||
ordered.push(fix);
|
||||
last = fix;
|
||||
pending = null;
|
||||
} else if (pending && plausible(pending, fix)) {
|
||||
// Two distinct coherent observations establish a replacement segment.
|
||||
// Keep a visible gap between it and the authoritative live path.
|
||||
if (backwards) last.flags |= FIX_FLAGS.BREAK;
|
||||
else pending.flags |= FIX_FLAGS.BREAK;
|
||||
ordered.push(pending, fix);
|
||||
last = fix;
|
||||
pending = null;
|
||||
} else pending = fix;
|
||||
}
|
||||
}
|
||||
const first = retained[0];
|
||||
if (first) {
|
||||
admit(
|
||||
candidates.filter((f) => f.t < first.t).reverse(),
|
||||
merged.get(first.t),
|
||||
true,
|
||||
);
|
||||
admit(
|
||||
candidates.filter((f) => f.t >= first.t),
|
||||
null,
|
||||
);
|
||||
} else admit(candidates, null);
|
||||
ordered.sort((a, b) => a.t - b.t);
|
||||
while (ordered.length > track.capacity) {
|
||||
const i = ordered.findIndex(
|
||||
(f) => f.t !== pinnedFrom?.t && f.t !== pinnedTo?.t,
|
||||
);
|
||||
if (i > 0 && i + 1 < ordered.length)
|
||||
ordered[i + 1].flags |= FIX_FLAGS.BREAK;
|
||||
ordered.splice(Math.max(0, i), 1);
|
||||
track.truncated = true;
|
||||
}
|
||||
for (let i = 1; i < ordered.length; i++) {
|
||||
if (track.policy.accept && !track.policy.accept(ordered[i - 1], ordered[i]))
|
||||
ordered[i].flags |= FIX_FLAGS.BREAK;
|
||||
}
|
||||
track.count = 0;
|
||||
track.head = 0;
|
||||
track.baseSeq += 128;
|
||||
for (const fix of ordered) append(track, fix);
|
||||
track.revision++;
|
||||
track.bracket = 0;
|
||||
}
|
||||
export function correctHeight(track, seq, heightM) {
|
||||
const i = seq - track.baseSeq;
|
||||
if (i < 0 || i >= track.count || !Number.isFinite(heightM)) return false;
|
||||
const o = address(track, i),
|
||||
value = Math.fround(heightM);
|
||||
if (track.storage.getFloat32(o + 24) === value) return false;
|
||||
track.storage.setFloat32(o + 24, value);
|
||||
track.storage.setUint32(
|
||||
o + 40,
|
||||
track.storage.getUint32(o + 40) | FIX_FLAGS.FLOOR,
|
||||
);
|
||||
track.revision++;
|
||||
return true;
|
||||
}
|
||||
function anchoredWall(track, clocks) {
|
||||
if (!Number.isFinite(track.wallAnchor)) {
|
||||
track.wallAnchor = clocks.wallNowMs;
|
||||
track.monoAnchor = clocks.monoNowMs;
|
||||
}
|
||||
return track.wallAnchor + Math.max(0, clocks.monoNowMs - track.monoAnchor);
|
||||
}
|
||||
/** Stable numeric fields avoid dictionary properties and boxed doubles in hot samples. */
|
||||
export function createSample() {
|
||||
return {
|
||||
displayT: NaN,
|
||||
revision: 0,
|
||||
nextWakeMonoMs: Infinity,
|
||||
phase: 'buffering',
|
||||
motion: 'unknown',
|
||||
fromSeq: -1,
|
||||
toSeq: -1,
|
||||
lat: NaN,
|
||||
lon: NaN,
|
||||
heightM: NaN,
|
||||
segmentCourseDeg: NaN,
|
||||
segmentSpeedMps: NaN,
|
||||
fraction: NaN,
|
||||
fromT: NaN,
|
||||
toT: NaN,
|
||||
nextMotionT: Infinity,
|
||||
latestReportAgeMs: NaN,
|
||||
actualDelayMs: NaN,
|
||||
};
|
||||
}
|
||||
/** Sample an explicit report time into caller-owned storage. */
|
||||
export function sampleAt(track, displayTimeMs, out) {
|
||||
out.displayT = displayTimeMs;
|
||||
return sampleCurrent(track, out);
|
||||
}
|
||||
/** The frame path keeps its changing double in storage across this call. */
|
||||
function sampleCurrent(track, out) {
|
||||
const sampleTime = out.displayT;
|
||||
out.revision = track.revision;
|
||||
out.nextWakeMonoMs = Infinity;
|
||||
out.phase = track.phase;
|
||||
out.motion = 'unknown';
|
||||
out.fromSeq = -1;
|
||||
out.toSeq = -1;
|
||||
out.lat = NaN;
|
||||
out.lon = NaN;
|
||||
out.heightM = NaN;
|
||||
out.segmentCourseDeg = NaN;
|
||||
out.segmentSpeedMps = 0;
|
||||
if (!track.count) return out;
|
||||
let i = Math.min(track.bracket, track.count - 1);
|
||||
if (timeAt(track, i) > sampleTime) i = 0;
|
||||
while (i + 1 < track.count && timeAt(track, i + 1) <= sampleTime) i++;
|
||||
track.bracket = i;
|
||||
const a = readFix(track, i, track.from);
|
||||
const j = sampleTime < a.t ? i : Math.min(i + 1, track.count - 1);
|
||||
const b = readFix(track, j, track.to);
|
||||
const continuous =
|
||||
i !== j && a.epoch === b.epoch && !(b.flags & FIX_FLAGS.BREAK);
|
||||
out.fromSeq = a.seq;
|
||||
out.toSeq = continuous ? b.seq : a.seq;
|
||||
out.fraction = continuous
|
||||
? Math.max(0, Math.min(1, (sampleTime - a.t) / (b.t - a.t)))
|
||||
: 1;
|
||||
out.fromT = a.t;
|
||||
out.toT = continuous ? b.t : a.t;
|
||||
if (
|
||||
track.metricsRevision !== track.revision ||
|
||||
track.metricsIndex !== i ||
|
||||
track.metricsTo !== j
|
||||
) {
|
||||
const metres = continuous ? distance(a, b) : 0;
|
||||
const metrics = track.metrics;
|
||||
metrics.speed = continuous ? (metres * 1000) / (b.t - a.t) : 0;
|
||||
metrics.course = NaN;
|
||||
if (metres >= (track.policy.courseMinTravelM ?? 12)) {
|
||||
metrics.course =
|
||||
((Math.atan2(
|
||||
(((b.lon - a.lon + 540) % 360) - 180) *
|
||||
Math.cos((a.lat * Math.PI) / 180),
|
||||
b.lat - a.lat,
|
||||
) *
|
||||
180) /
|
||||
Math.PI +
|
||||
360) %
|
||||
360;
|
||||
} else {
|
||||
// Tiny displacement holds the last reliable course in this epoch.
|
||||
// With no reliable displacement yet, use the earliest known bearing.
|
||||
const p = track.metricsScratchA,
|
||||
q = track.metricsScratchB;
|
||||
for (let k = i; k >= 0; k--) {
|
||||
readFix(track, k, q);
|
||||
if (q.epoch !== a.epoch) break;
|
||||
if (Number.isFinite(q.bearingDeg)) metrics.course = q.bearingDeg;
|
||||
if (k === 0 || q.flags & FIX_FLAGS.BREAK) continue;
|
||||
readFix(track, k - 1, p);
|
||||
if (
|
||||
p.epoch === q.epoch &&
|
||||
distance(p, q) >= (track.policy.courseMinTravelM ?? 12)
|
||||
) {
|
||||
metrics.course =
|
||||
((Math.atan2(
|
||||
(((q.lon - p.lon + 540) % 360) - 180) *
|
||||
Math.cos((p.lat * Math.PI) / 180),
|
||||
q.lat - p.lat,
|
||||
) *
|
||||
180) /
|
||||
Math.PI +
|
||||
360) %
|
||||
360;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
metrics.nextMotionT = Infinity;
|
||||
const p = track.metricsScratchA,
|
||||
q = track.metricsScratchB;
|
||||
for (let k = i; k + 1 < track.count; k++) {
|
||||
readFix(track, k, p);
|
||||
readFix(track, k + 1, q);
|
||||
if (
|
||||
p.lat !== q.lat ||
|
||||
p.lon !== q.lon ||
|
||||
p.epoch !== q.epoch ||
|
||||
q.flags & FIX_FLAGS.BREAK
|
||||
) {
|
||||
metrics.nextMotionT =
|
||||
p.epoch !== q.epoch || q.flags & FIX_FLAGS.BREAK ? q.t : p.t;
|
||||
break;
|
||||
}
|
||||
}
|
||||
track.metricsRevision = track.revision;
|
||||
track.metricsIndex = i;
|
||||
track.metricsTo = j;
|
||||
}
|
||||
const f = out.fraction;
|
||||
out.lat = continuous ? a.lat + (b.lat - a.lat) * f : a.lat;
|
||||
out.lon = continuous
|
||||
? a.lon + (((b.lon - a.lon + 540) % 360) - 180) * f
|
||||
: a.lon;
|
||||
if (out.lon > 180) out.lon -= 360;
|
||||
if (out.lon < -180) out.lon += 360;
|
||||
out.heightM = continuous
|
||||
? a.heightM + (b.heightM - a.heightM) * f
|
||||
: a.heightM;
|
||||
out.segmentCourseDeg = track.metrics.course;
|
||||
out.segmentSpeedMps = track.metrics.speed;
|
||||
out.motion = continuous
|
||||
? track.metrics.speed > 0
|
||||
? 'moving'
|
||||
: 'stopped'
|
||||
: 'unknown';
|
||||
out.nextMotionT = track.metrics.nextMotionT;
|
||||
out.latestReportAgeMs = Number.isFinite(track.wallAnchor)
|
||||
? track.wallAnchor +
|
||||
track.lastMono -
|
||||
track.monoAnchor -
|
||||
timeAt(track, track.count - 1)
|
||||
: NaN;
|
||||
out.actualDelayMs = Number.isFinite(track.wallAnchor)
|
||||
? track.wallAnchor + track.lastMono - track.monoAnchor - sampleTime
|
||||
: NaN;
|
||||
return out;
|
||||
}
|
||||
export function advance(track, clocks, out) {
|
||||
const wall = anchoredWall(track, clocks),
|
||||
previousMono = track.lastMono;
|
||||
const dt = Number.isFinite(previousMono)
|
||||
? Math.max(0, clocks.monoNowMs - previousMono)
|
||||
: 0;
|
||||
track.lastMono = clocks.monoNowMs;
|
||||
if (!track.count) return sampleAt(track, NaN, out);
|
||||
const newest = timeAt(track, track.count - 1),
|
||||
oldest = timeAt(track, 0);
|
||||
const ceiling = Math.min(newest, wall - track.targetDelayMs + track.offset);
|
||||
const delayBlocked =
|
||||
track.offset === 0 &&
|
||||
track.phase !== 'playing' &&
|
||||
ceiling < track.displayT &&
|
||||
track.displayT < newest;
|
||||
if (!Number.isFinite(track.displayT))
|
||||
track.displayT = Math.min(
|
||||
newest,
|
||||
wall - track.targetDelayMs + track.offset,
|
||||
);
|
||||
else if (
|
||||
track.offset === 0 &&
|
||||
track.rate !== 0 &&
|
||||
(ceiling - track.displayT > 90_000 ||
|
||||
(track.truncated && track.displayT < oldest))
|
||||
) {
|
||||
track.resetReason =
|
||||
ceiling - track.displayT > 90_000 ? 'backlog' : 'unavailable-bracket';
|
||||
track.displayT = ceiling;
|
||||
track.resets++;
|
||||
} else if (track.rate !== 0) {
|
||||
// While playing, finish the active segment at 1x even if lag grows.
|
||||
const activeCeiling =
|
||||
track.offset < 0 || track.phase === 'playing' ? newest : ceiling;
|
||||
let next = track.displayT + dt * track.rate;
|
||||
if (track.phase === 'held' && track.resumePending) {
|
||||
const sinceReceipt = Number.isFinite(track.acceptedMono)
|
||||
? Math.max(0, clocks.monoNowMs - track.acceptedMono)
|
||||
: 0;
|
||||
next = track.displayT + Math.min(dt, sinceReceipt) * track.rate;
|
||||
}
|
||||
track.displayT = Math.max(track.displayT, Math.min(next, activeCeiling));
|
||||
}
|
||||
track.phase =
|
||||
track.rate === 0
|
||||
? 'paused'
|
||||
: track.displayT < oldest || delayBlocked
|
||||
? 'buffering'
|
||||
: track.displayT >= newest
|
||||
? 'held'
|
||||
: 'playing';
|
||||
track.resumePending = false;
|
||||
out.displayT = track.displayT;
|
||||
sampleCurrent(track, out);
|
||||
if (out.motion !== 'moving' || track.phase !== 'playing') {
|
||||
const boundary = Math.max(oldest, out.nextMotionT);
|
||||
if (
|
||||
Number.isFinite(boundary) &&
|
||||
boundary > track.displayT &&
|
||||
track.rate > 0
|
||||
)
|
||||
out.nextWakeMonoMs =
|
||||
clocks.monoNowMs + (boundary - track.displayT) / track.rate;
|
||||
}
|
||||
if (delayBlocked && track.rate > 0)
|
||||
out.nextWakeMonoMs =
|
||||
clocks.monoNowMs + Math.max(1, track.displayT - ceiling);
|
||||
if (out.latestReportAgeMs > (track.policy.staleMs ?? 90_000))
|
||||
out.motion = 'stale';
|
||||
return out;
|
||||
}
|
||||
export function seek(track, displayTimeMs, clocks) {
|
||||
if (!Number.isFinite(displayTimeMs))
|
||||
throw new TypeError('A seek needs a finite time');
|
||||
anchoredWall(track, clocks);
|
||||
track.displayT = displayTimeMs;
|
||||
track.offset = -1;
|
||||
track.rate = 0;
|
||||
track.phase = 'paused';
|
||||
track.lastMono = clocks.monoNowMs;
|
||||
track.bracket = 0;
|
||||
track.resets++;
|
||||
track.resetReason = 'seek';
|
||||
}
|
||||
export function setTimeOffsetMs(track, offset, clocks) {
|
||||
if (!Number.isFinite(offset) || offset > 0)
|
||||
throw new RangeError('Offset must be zero or negative');
|
||||
if (Number.isFinite(track.displayT)) {
|
||||
track.resets++;
|
||||
track.resetReason = 'time-offset';
|
||||
}
|
||||
track.offset = offset;
|
||||
track.rate = 1;
|
||||
track.displayT = anchoredWall(track, clocks) - track.targetDelayMs + offset;
|
||||
track.lastMono = clocks.monoNowMs;
|
||||
track.phase = 'buffering';
|
||||
track.bracket = 0;
|
||||
}
|
||||
export function setRate(track, rate, clocks) {
|
||||
if (!Number.isFinite(rate) || rate < 0 || rate > 16)
|
||||
throw new RangeError('Rate must be in [0, 16]');
|
||||
if (track.offset === 0 && rate !== 0 && rate !== 1)
|
||||
throw new RangeError('Live playback uses 1x or hold');
|
||||
track.rate = rate;
|
||||
track.lastMono = clocks.monoNowMs;
|
||||
track.phase = rate === 0 ? 'paused' : 'buffering';
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
createTrack,
|
||||
createLagReservoir,
|
||||
createHistoryBudget,
|
||||
insertFix,
|
||||
readFix,
|
||||
advance,
|
||||
sampleAt,
|
||||
seek,
|
||||
setRate,
|
||||
setTimeOffsetMs,
|
||||
mergeHistory,
|
||||
correctHeight,
|
||||
trimTrack,
|
||||
destroyTrack,
|
||||
FIX_BYTES,
|
||||
FIX_FLAGS,
|
||||
} from './contactPlayback.js';
|
||||
const fix = (t, metres = t / 100) => ({
|
||||
t,
|
||||
lat: 42 + metres / 111320,
|
||||
lon: -71,
|
||||
bearingDeg: 0,
|
||||
});
|
||||
const clocks = (t) => ({ wallNowMs: t, monoNowMs: t });
|
||||
const put = (track, t, metres, receipt = t) =>
|
||||
insertFix(track, fix(t, metres), { receivedAt: receipt, wallNowMs: receipt });
|
||||
|
||||
test('10 s reports / 15 s polls and a 6 m crawl move at report speed on ordinary frames', () => {
|
||||
for (const speed of [10, 0.6]) {
|
||||
const track = createTrack(),
|
||||
out = {};
|
||||
let prior = null,
|
||||
moving = 0,
|
||||
pollJumps = 0;
|
||||
for (let now = 5000; now <= 180000; now += 16) {
|
||||
if (
|
||||
!prior ||
|
||||
Math.floor((now - 5000) / 15000) !== Math.floor((now - 5016) / 15000)
|
||||
) {
|
||||
const t = Math.floor((now - 5000) / 10000) * 10000;
|
||||
put(track, t, (t / 1000) * speed, now);
|
||||
}
|
||||
advance(track, clocks(now), out);
|
||||
if (prior && out.displayT >= 0 && prior.displayT >= 0) {
|
||||
const d = (out.lat - prior.lat) * 111320;
|
||||
const dt = out.displayT - prior.displayT;
|
||||
assert.ok(dt >= 0 && dt <= 16.0001);
|
||||
if (dt > 0 && d > 0) {
|
||||
assert.ok(Math.abs(d / (dt / 1000) - speed) < speed * 0.02);
|
||||
moving++;
|
||||
}
|
||||
if (d > speed * 0.0161) pollJumps++;
|
||||
}
|
||||
prior = { ...out };
|
||||
}
|
||||
assert.ok(
|
||||
moving > 5000,
|
||||
`non-vacuous ${speed} m/s trace: ${moving} moving frames`,
|
||||
);
|
||||
assert.equal(pollJumps, 0);
|
||||
}
|
||||
});
|
||||
test('stationary observations survive, schedule departure, and distinguish underflow', () => {
|
||||
const track = createTrack(),
|
||||
out = {};
|
||||
put(track, 0, 0);
|
||||
put(track, 15000, 0);
|
||||
put(track, 30000, 6);
|
||||
setTimeOffsetMs(track, 0, clocks(35000));
|
||||
advance(track, clocks(35000), out);
|
||||
assert.equal(out.motion, 'stopped');
|
||||
assert.equal(out.nextWakeMonoMs, 40000);
|
||||
advance(track, clocks(40000), out);
|
||||
assert.equal(out.motion, 'moving');
|
||||
advance(track, clocks(55000), out);
|
||||
assert.equal(out.phase, 'held');
|
||||
assert.equal(out.segmentSpeedMps, 0);
|
||||
});
|
||||
test('same-time conflicts and invalid coordinates never make zero-duration segments', () => {
|
||||
const track = createTrack();
|
||||
put(track, 0, 0);
|
||||
assert.equal(put(track, 0, 0).reason, 'repeat');
|
||||
assert.equal(put(track, 0, 1).reason, 'conflict');
|
||||
for (const value of [
|
||||
{ t: 1, lat: 91, lon: 0 },
|
||||
{ t: 1, lat: 0, lon: NaN },
|
||||
{ t: -1, lat: 0, lon: 0 },
|
||||
])
|
||||
assert.equal(insertFix(track, value).accepted, false);
|
||||
assert.equal(track.count, 1);
|
||||
assert.equal(put(track, 15000, 0).accepted, true);
|
||||
});
|
||||
test('ring wrap retains 15 minutes and pins an active bracket under capacity pressure', () => {
|
||||
const track = createTrack({ capacity: 8 });
|
||||
for (let i = 0; i < 8; i++) put(track, i * 10000, i);
|
||||
seek(track, 5000, clocks(80000));
|
||||
for (let i = 8; i < 30; i++) put(track, i * 10000, i);
|
||||
const sample = sampleAt(track, 5000, {});
|
||||
assert.equal(sample.fromT, 0);
|
||||
assert.equal(sample.toT, 10000);
|
||||
assert.equal(track.count, 8);
|
||||
assert.equal(track.storage.byteLength, 8 * FIX_BYTES);
|
||||
assert.equal(track.truncated, true);
|
||||
assert.ok(readFix(track, 2).flags & FIX_FLAGS.BREAK);
|
||||
seek(track, 250000, clocks(300000));
|
||||
trimTrack(track, 300000, 2);
|
||||
assert.ok(track.count <= 5);
|
||||
});
|
||||
test('backward seek and history merge leave clock and live health alone', () => {
|
||||
const track = createTrack(),
|
||||
out = {};
|
||||
put(track, 30000, 30);
|
||||
put(track, 45000, 45);
|
||||
seek(track, 37500, clocks(80000));
|
||||
const health = track.acceptedAt;
|
||||
mergeHistory(track, [fix(0, 0), fix(15000, 15), fix(30000, 999)]);
|
||||
assert.equal(track.displayT, 37500);
|
||||
assert.equal(track.acceptedAt, health);
|
||||
assert.equal(readFix(track, 2).lat, fix(30000, 30).lat);
|
||||
seek(track, 7500, clocks(80000));
|
||||
advance(track, clocks(90000), out);
|
||||
assert.equal(out.displayT, 7500);
|
||||
assert.equal(out.phase, 'paused');
|
||||
setRate(track, 1, clocks(90000));
|
||||
advance(track, clocks(91000), out);
|
||||
assert.equal(out.displayT, 8500);
|
||||
});
|
||||
test('system-clock changes do not alter animation elapsed time', () => {
|
||||
const track = createTrack(),
|
||||
out = {};
|
||||
put(track, 0, 0);
|
||||
put(track, 60000, 600);
|
||||
advance(track, clocks(60000), out);
|
||||
const before = out.displayT;
|
||||
advance(track, { wallNowMs: 999999999, monoNowMs: 60016 }, out);
|
||||
assert.equal(out.displayT - before, 16);
|
||||
setTimeOffsetMs(track, -10000, clocks(60016));
|
||||
assert.equal(track.resetReason, 'time-offset');
|
||||
assert.equal(track.resets, 1);
|
||||
});
|
||||
test('a 90 s outage holds, then resumes without a sprint; backlog reset adds no second delay', () => {
|
||||
const track = createTrack(),
|
||||
out = {};
|
||||
put(track, 0, 0);
|
||||
put(track, 15000, 15);
|
||||
for (let t = 25000; t <= 120000; t += 100) advance(track, clocks(t), out);
|
||||
assert.equal(out.displayT, 15000);
|
||||
put(track, 105000, 105, 120000);
|
||||
advance(track, clocks(120100), out);
|
||||
assert.ok(out.displayT - 15000 <= 100);
|
||||
track.targetDelayMs = 25000;
|
||||
put(track, 300000, 300, 300000);
|
||||
track.targetDelayMs = 25000;
|
||||
advance(track, clocks(300000), out);
|
||||
assert.equal(track.resets, 1);
|
||||
assert.equal(track.resetReason, 'backlog');
|
||||
assert.equal(out.displayT, 275000);
|
||||
});
|
||||
test('lag measures receipt of current minus report of previous and ignores cached deliveries', () => {
|
||||
const feedLag = createLagReservoir(),
|
||||
track = createTrack({ policy: { feedLag } });
|
||||
put(track, 0, 0, 5000);
|
||||
put(track, 10000, 100, 20000);
|
||||
assert.equal(track.lag.values[0], 20000);
|
||||
put(track, 10000, 100, 80000);
|
||||
assert.equal(track.lag.count, 1);
|
||||
put(track, 20000, 200, 50000);
|
||||
assert.equal(track.targetDelayMs, 45000);
|
||||
});
|
||||
test('displayed bearing never reads a future fix; height corrections revise typed storage', () => {
|
||||
const track = createTrack();
|
||||
insertFix(track, { ...fix(0, 0), bearingDeg: 90 });
|
||||
insertFix(track, { ...fix(15000, 6), bearingDeg: 270 });
|
||||
const out = sampleAt(track, 7500, {});
|
||||
assert.equal(out.segmentCourseDeg, 90);
|
||||
assert.ok(Number.isNaN(out.heightM));
|
||||
correctHeight(track, out.fromSeq, 11.5);
|
||||
correctHeight(track, out.toSeq, 21.5);
|
||||
assert.equal(sampleAt(track, 7500, out).heightM, 16.5);
|
||||
});
|
||||
test('allocation budget is hard and destruction returns payload bytes', () => {
|
||||
const budget = createHistoryBudget(48 * 16),
|
||||
tracks = Array.from({ length: 3 }, () => createTrack({ budget }));
|
||||
for (const track of tracks)
|
||||
for (let t = 0; t < 100; t++) put(track, t * 1000, t);
|
||||
assert.ok(budget.allocatedBytes <= budget.maxBytes);
|
||||
for (const track of tracks) destroyTrack(track);
|
||||
assert.equal(budget.allocatedBytes, 0);
|
||||
});
|
||||
|
||||
test('tiny displacement holds a preceding reliable course instead of a changed bearing', () => {
|
||||
const track = createTrack();
|
||||
insertFix(track, { ...fix(0, 0), bearingDeg: 90 });
|
||||
insertFix(track, { ...fix(15000, 100), bearingDeg: 270 });
|
||||
insertFix(track, { ...fix(30000, 106), bearingDeg: 180 });
|
||||
assert.equal(sampleAt(track, 20000, {}).segmentCourseDeg, 0);
|
||||
});
|
||||
test('an epoch gap wakes at its far boundary without drawing a connecting segment', () => {
|
||||
const track = createTrack(),
|
||||
out = {};
|
||||
insertFix(track, { ...fix(0, 0), epoch: 1 });
|
||||
insertFix(track, { ...fix(30000, 100), epoch: 2 });
|
||||
advance(track, clocks(40000), out);
|
||||
assert.equal(out.segmentSpeedMps, 0);
|
||||
assert.equal(out.nextWakeMonoMs, 65000);
|
||||
});
|
||||
|
||||
test('increased lag is adopted while rebuffering, with one monotonic wake and no slow playback', () => {
|
||||
const track = createTrack(),
|
||||
out = {};
|
||||
put(track, 0, 0);
|
||||
put(track, 15000, 15);
|
||||
for (let now = 25000; now <= 120000; now += 100)
|
||||
advance(track, clocks(now), out);
|
||||
insertFix(track, fix(105000, 105), {
|
||||
receivedAt: 120000,
|
||||
wallNowMs: 120000,
|
||||
monoNowMs: 120000,
|
||||
});
|
||||
advance(track, clocks(120100), out);
|
||||
assert.equal(out.displayT, 15000);
|
||||
assert.equal(out.phase, 'buffering');
|
||||
assert.equal(out.nextWakeMonoMs, 125000);
|
||||
advance(track, { wallNowMs: -9999999, monoNowMs: 125000 }, out);
|
||||
assert.equal(out.displayT, 15000);
|
||||
advance(track, { wallNowMs: 9999999, monoNowMs: 125016 }, out);
|
||||
assert.equal(out.displayT, 15016);
|
||||
});
|
||||
|
||||
test('capacity rejection leaves both lag reservoirs and clock policy unchanged', () => {
|
||||
const feedLag = createLagReservoir();
|
||||
const track = createTrack({ capacity: 2, policy: { feedLag } });
|
||||
put(track, 0, 0);
|
||||
put(track, 10000, 10);
|
||||
seek(track, 5000, clocks(10000));
|
||||
const policyState = () =>
|
||||
structuredClone({
|
||||
lag: track.lag,
|
||||
feedLag,
|
||||
targetDelayMs: track.targetDelayMs,
|
||||
acceptedAt: track.acceptedAt,
|
||||
acceptedMono: track.acceptedMono,
|
||||
resumePending: track.resumePending,
|
||||
displayT: track.displayT,
|
||||
wallAnchor: track.wallAnchor,
|
||||
monoAnchor: track.monoAnchor,
|
||||
resetReason: track.resetReason,
|
||||
resets: track.resets,
|
||||
});
|
||||
const before = policyState();
|
||||
assert.deepEqual(put(track, 20000, 20, 300000), {
|
||||
accepted: false,
|
||||
reason: 'capacity',
|
||||
});
|
||||
assert.deepEqual(policyState(), before);
|
||||
});
|
||||
|
||||
test('sampleAt writes the supplied output without replacing nested scratch', () => {
|
||||
const track = createTrack();
|
||||
put(track, 0, 0);
|
||||
put(track, 15000, 150);
|
||||
const out = {},
|
||||
from = track.from,
|
||||
to = track.to,
|
||||
metrics = track.metrics;
|
||||
for (let i = 0; i < 10000; i++) {
|
||||
assert.equal(sampleAt(track, i, out), out);
|
||||
assert.equal(track.from, from);
|
||||
assert.equal(track.to, to);
|
||||
assert.equal(track.metrics, metrics);
|
||||
}
|
||||
});
|
||||
|
||||
test('backfill pins the live bracket and quarantines outliers on either side', () => {
|
||||
const track = createTrack({
|
||||
policy: {
|
||||
accept: (a, b) =>
|
||||
Math.abs(b.lat - a.lat) * 111320 <= ((b.t - a.t) / 1000) * 36,
|
||||
},
|
||||
});
|
||||
put(track, 10000, 0);
|
||||
put(track, 25000, 100);
|
||||
seek(track, 17500, clocks(40000));
|
||||
const before = { ...sampleAt(track, 17500, {}) };
|
||||
mergeHistory(track, [fix(0, -111320), fix(17500, 25), fix(40000, 111320)]);
|
||||
const after = sampleAt(track, 17500, {});
|
||||
assert.equal(track.count, 2);
|
||||
assert.equal(after.fromT, before.fromT);
|
||||
assert.equal(after.toT, before.toT);
|
||||
assert.equal(after.lat, before.lat);
|
||||
mergeHistory(track, [fix(40000, 111320), fix(55000, 111420)]);
|
||||
assert.equal(
|
||||
track.count,
|
||||
4,
|
||||
'two coherent replacement fixes can be retained',
|
||||
);
|
||||
assert.ok(readFix(track, 2).flags & FIX_FLAGS.BREAK);
|
||||
assert.equal(sampleAt(track, 30000, {}).lat, fix(25000, 100).lat);
|
||||
});
|
||||
@@ -0,0 +1,229 @@
|
||||
import * as Cesium from 'cesium';
|
||||
|
||||
const ALPHA_STOPS = [
|
||||
[0, 0.8],
|
||||
[120000, 0.45],
|
||||
[600000, 0.2],
|
||||
[900000, 0.08],
|
||||
];
|
||||
export function trailAlpha(ageMs) {
|
||||
const stops = ALPHA_STOPS;
|
||||
for (let i = 1; i < stops.length; i++) {
|
||||
if (ageMs === stops[i][0]) return stops[i][1];
|
||||
if (ageMs < stops[i][0]) {
|
||||
const [a, x] = stops[i - 1],
|
||||
[b, y] = stops[i];
|
||||
return x + ((y - x) * Math.max(0, ageMs - a)) / (b - a);
|
||||
}
|
||||
}
|
||||
return 0.08;
|
||||
}
|
||||
|
||||
/** One static history batch and one frequently updated head. Never entities. */
|
||||
export function createContactTrailRenderer(scene) {
|
||||
const heads = scene.primitives.add(new Cesium.PolylineCollection());
|
||||
const backing = heads.add({
|
||||
positions: [],
|
||||
width: 4,
|
||||
show: false,
|
||||
material: Cesium.Material.fromType('Color', {
|
||||
color: new Cesium.Color(0.02, 0.03, 0.05, 0.8),
|
||||
}),
|
||||
});
|
||||
const head = heads.add({
|
||||
positions: [],
|
||||
width: 2,
|
||||
show: false,
|
||||
material: Cesium.Material.fromType('Color', {
|
||||
color: Cesium.Color.WHITE.clone(),
|
||||
}),
|
||||
});
|
||||
let body = null,
|
||||
segments = [],
|
||||
revision = -1,
|
||||
visible = true,
|
||||
style = '#5EF08A';
|
||||
let lastSecond = -1,
|
||||
completed = -1,
|
||||
active = null;
|
||||
const styleColor = Cesium.Color.fromCssColorString(style);
|
||||
const attributeIds = [];
|
||||
const color = new Cesium.Color(),
|
||||
endpoint = new Cesium.Cartesian3();
|
||||
const headPositions = [new Cesium.Cartesian3(), endpoint];
|
||||
let rebuilds = 0;
|
||||
function tint(alpha, dark = false) {
|
||||
if (dark) {
|
||||
color.red = 0.02;
|
||||
color.green = 0.03;
|
||||
color.blue = 0.05;
|
||||
} else Cesium.Color.clone(styleColor, color);
|
||||
color.alpha = alpha;
|
||||
return color;
|
||||
}
|
||||
function replaceHistory(next) {
|
||||
if (next.revision === revision) return;
|
||||
revision = next.revision;
|
||||
segments = next.segments;
|
||||
completed = -1;
|
||||
lastSecond = -1;
|
||||
active = null;
|
||||
if (body) scene.primitives.remove(body);
|
||||
body = null;
|
||||
const instances = [];
|
||||
attributeIds.length = 0;
|
||||
for (let i = 0; i < segments.length; i++) {
|
||||
const segment = segments[i];
|
||||
attributeIds.push([`${i}:0`, `${i}:1`]);
|
||||
if (segment.positions.length < 2) continue;
|
||||
for (let lane = 0; lane < 2; lane++) {
|
||||
instances.push(
|
||||
new Cesium.GeometryInstance({
|
||||
id: `${i}:${lane}`,
|
||||
geometry: new Cesium.PolylineGeometry({
|
||||
positions: segment.positions,
|
||||
width: lane ? 2 : 4,
|
||||
arcType: Cesium.ArcType.NONE,
|
||||
vertexFormat: Cesium.PolylineColorAppearance.VERTEX_FORMAT,
|
||||
}),
|
||||
attributes: {
|
||||
color: Cesium.ColorGeometryInstanceAttribute.fromColor(
|
||||
tint(0.8, !lane),
|
||||
),
|
||||
depthFailColor: Cesium.ColorGeometryInstanceAttribute.fromColor(
|
||||
tint(0.8, !lane),
|
||||
),
|
||||
show: new Cesium.ShowGeometryInstanceAttribute(false),
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
if (instances.length) {
|
||||
const depthFailAppearance = new Cesium.PolylineColorAppearance({
|
||||
translucent: true,
|
||||
fragmentShaderSource:
|
||||
'in vec4 v_color; void main() { out_FragColor = czm_gammaCorrect(vec4(v_color.rgb, v_color.a * 0.2)); }',
|
||||
});
|
||||
body = scene.primitives.add(
|
||||
new Cesium.Primitive({
|
||||
geometryInstances: instances,
|
||||
appearance: new Cesium.PolylineColorAppearance({ translucent: true }),
|
||||
depthFailAppearance,
|
||||
allowPicking: false,
|
||||
asynchronous: false,
|
||||
show: visible,
|
||||
}),
|
||||
);
|
||||
rebuilds++;
|
||||
}
|
||||
}
|
||||
function setDisplaySample(sample, renderedPosition) {
|
||||
if (!sample || !renderedPosition) {
|
||||
head.show = false;
|
||||
backing.show = false;
|
||||
return;
|
||||
}
|
||||
const second = Math.floor(sample.displayT / 1000);
|
||||
let lo = 0,
|
||||
hi = segments.length;
|
||||
while (lo < hi) {
|
||||
const mid = (lo + hi) >>> 1;
|
||||
if (segments[mid].toT <= sample.displayT) lo = mid + 1;
|
||||
else hi = mid;
|
||||
}
|
||||
const done = lo - 1;
|
||||
const candidate = segments[lo];
|
||||
const segment =
|
||||
candidate &&
|
||||
candidate.fromT <= sample.displayT &&
|
||||
candidate.fromSeq === sample.fromSeq &&
|
||||
candidate.toSeq === sample.toSeq
|
||||
? candidate
|
||||
: null;
|
||||
if (body?.ready && done !== completed) {
|
||||
for (
|
||||
let i = Math.min(done, completed) + 1;
|
||||
i <= Math.max(done, completed);
|
||||
i++
|
||||
) {
|
||||
for (let lane = 0; lane < 2; lane++) {
|
||||
const attributes = body.getGeometryInstanceAttributes(
|
||||
attributeIds[i][lane],
|
||||
);
|
||||
if (!attributes) continue;
|
||||
attributes.show[0] = i <= done ? 1 : 0;
|
||||
attributes.show = attributes.show;
|
||||
}
|
||||
}
|
||||
completed = done;
|
||||
}
|
||||
if (body?.ready && second !== lastSecond) {
|
||||
for (let i = 0; i < segments.length; i++) {
|
||||
const alpha = trailAlpha(sample.displayT - segments[i].toT);
|
||||
for (let lane = 0; lane < 2; lane++) {
|
||||
const attributes = body.getGeometryInstanceAttributes(
|
||||
attributeIds[i][lane],
|
||||
);
|
||||
if (!attributes) continue;
|
||||
Cesium.ColorGeometryInstanceAttribute.toValue(
|
||||
tint(alpha, !lane),
|
||||
attributes.color,
|
||||
);
|
||||
attributes.color = attributes.color;
|
||||
Cesium.ColorGeometryInstanceAttribute.toValue(
|
||||
tint(alpha, !lane),
|
||||
attributes.depthFailColor,
|
||||
);
|
||||
attributes.depthFailColor = attributes.depthFailColor;
|
||||
}
|
||||
}
|
||||
lastSecond = second;
|
||||
}
|
||||
const show = visible && !!segment && sample.fraction < 1;
|
||||
head.show = show;
|
||||
backing.show = show;
|
||||
if (!show) return;
|
||||
if (active !== segment) {
|
||||
active = segment;
|
||||
headPositions[0] = segment.positions[0];
|
||||
headPositions[1] = endpoint;
|
||||
}
|
||||
Cesium.Cartesian3.clone(renderedPosition, endpoint);
|
||||
head.positions = headPositions;
|
||||
backing.positions = headPositions;
|
||||
Cesium.Color.clone(tint(0.8), head.material.uniforms.color);
|
||||
}
|
||||
return {
|
||||
replaceHistory,
|
||||
setDisplaySample,
|
||||
setStyle(next) {
|
||||
if (next === style) return;
|
||||
style = next;
|
||||
Cesium.Color.fromCssColorString(style, styleColor);
|
||||
lastSecond = -1;
|
||||
},
|
||||
setVisible(next) {
|
||||
visible = next;
|
||||
heads.show = next;
|
||||
if (body) body.show = next;
|
||||
},
|
||||
destroy() {
|
||||
if (body) scene.primitives.remove(body);
|
||||
scene.primitives.remove(heads);
|
||||
body = null;
|
||||
segments = [];
|
||||
},
|
||||
diagnostics() {
|
||||
return {
|
||||
revision,
|
||||
rebuilds,
|
||||
segments: segments.length,
|
||||
body,
|
||||
head,
|
||||
backing,
|
||||
entitiesAdded: 0,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
createContactTrailRenderer,
|
||||
trailAlpha,
|
||||
} from './contactTrailRenderer.js';
|
||||
|
||||
function fixture(t) {
|
||||
for (const key of [
|
||||
'HTMLCanvasElement',
|
||||
'HTMLImageElement',
|
||||
'ImageBitmap',
|
||||
'OffscreenCanvas',
|
||||
]) {
|
||||
const prior = globalThis[key];
|
||||
globalThis[key] = class {};
|
||||
t.after(() => {
|
||||
if (prior === undefined) delete globalThis[key];
|
||||
else globalThis[key] = prior;
|
||||
});
|
||||
}
|
||||
const primitives = new Cesium.PrimitiveCollection();
|
||||
t.after(() => primitives.destroy());
|
||||
return { primitives };
|
||||
}
|
||||
test('body clips future subdivisions, head ends at exact marker, and frames retain geometry', (t) => {
|
||||
const scene = fixture(t),
|
||||
renderer = createContactTrailRenderer(scene);
|
||||
const positions = [0, 1, 2].map((i) =>
|
||||
Cesium.Cartesian3.fromDegrees(-71, 42 + i * 0.001, 11.5),
|
||||
);
|
||||
renderer.replaceHistory({
|
||||
revision: 1,
|
||||
segments: [
|
||||
{
|
||||
fromSeq: 0,
|
||||
toSeq: 1,
|
||||
fromT: 0,
|
||||
toT: 10000,
|
||||
positions: positions.slice(0, 2),
|
||||
},
|
||||
{
|
||||
fromSeq: 0,
|
||||
toSeq: 1,
|
||||
fromT: 10000,
|
||||
toT: 20000,
|
||||
positions: positions.slice(1, 3),
|
||||
},
|
||||
],
|
||||
});
|
||||
const { body } = renderer.diagnostics();
|
||||
assert.equal(body.allowPicking, false);
|
||||
// The constructor defers this check until shader preparation. Run the real
|
||||
// Cesium validator without WebGL, including a missing-attribute control.
|
||||
const source = body.depthFailAppearance.vertexShaderSource;
|
||||
body._batchTableAttributeIndices = { color: 0 };
|
||||
assert.throws(
|
||||
() => Cesium.Primitive._updateColorAttribute(body, source, true),
|
||||
/depthFailColor/,
|
||||
);
|
||||
for (const instance of body.geometryInstances) {
|
||||
assert.deepEqual(
|
||||
instance.attributes.depthFailColor.value,
|
||||
instance.attributes.color.value,
|
||||
);
|
||||
}
|
||||
body._batchTableAttributeIndices = { color: 0, depthFailColor: 1 };
|
||||
assert.doesNotThrow(() =>
|
||||
Cesium.Primitive._updateColorAttribute(body, source, true),
|
||||
);
|
||||
const attributes = new Map(
|
||||
body.geometryInstances.map((instance) => [
|
||||
instance.id,
|
||||
{
|
||||
show: new Uint8Array([0]),
|
||||
color: new Uint8Array(4),
|
||||
depthFailColor: new Uint8Array(4),
|
||||
},
|
||||
]),
|
||||
);
|
||||
// Simulate Cesium's completed upload; exercise public attribute writes.
|
||||
body._ready = true;
|
||||
body.getGeometryInstanceAttributes = (id) => attributes.get(id);
|
||||
const marker = Cesium.Cartesian3.lerp(
|
||||
positions[1],
|
||||
positions[2],
|
||||
0.5,
|
||||
new Cesium.Cartesian3(),
|
||||
);
|
||||
const sample = { displayT: 15000, fromSeq: 0, toSeq: 1, fraction: 0.75 };
|
||||
for (let i = 0; i < 120; i++) renderer.setDisplaySample(sample, marker);
|
||||
assert.deepEqual(
|
||||
[...attributes.values()].map((a) => a.show[0]),
|
||||
[1, 1, 0, 0],
|
||||
);
|
||||
for (const a of attributes.values())
|
||||
assert.deepEqual(a.depthFailColor, a.color);
|
||||
const result = renderer.diagnostics();
|
||||
assert.equal(result.body, body);
|
||||
assert.equal(result.rebuilds, 1);
|
||||
assert.ok(Cesium.Cartesian3.equals(result.head.positions.at(-1), marker));
|
||||
assert.equal(result.head.positions.length, 2);
|
||||
renderer.setDisplaySample(
|
||||
{ ...sample, displayT: 5000, fraction: 0.25 },
|
||||
positions[0],
|
||||
);
|
||||
assert.ok(
|
||||
[...attributes.values()].every((a) => a.show[0] === 0),
|
||||
'rewind hides future geometry again',
|
||||
);
|
||||
renderer.destroy();
|
||||
assert.equal(scene.primitives.length, 0);
|
||||
});
|
||||
test('trail age curve uses the specified four alpha anchors', () => {
|
||||
assert.equal(trailAlpha(0), 0.8);
|
||||
assert.equal(trailAlpha(120000), 0.45);
|
||||
assert.equal(trailAlpha(600000), 0.2);
|
||||
assert.equal(trailAlpha(900000), 0.08);
|
||||
assert.ok(Math.abs(trailAlpha(60000) - 0.625) < 1e-10);
|
||||
});
|
||||
|
||||
test('the maximum prepared body stays below the 2 MiB geometry budget', () => {
|
||||
const geometry = Cesium.PolylineGeometry.createGeometry(
|
||||
new Cesium.PolylineGeometry({
|
||||
positions: [
|
||||
Cesium.Cartesian3.fromDegrees(-71, 42, 11.5),
|
||||
Cesium.Cartesian3.fromDegrees(-71, 42.0002, 11.5),
|
||||
],
|
||||
width: 2,
|
||||
arcType: Cesium.ArcType.NONE,
|
||||
vertexFormat: Cesium.PolylineColorAppearance.VERTEX_FORMAT,
|
||||
}),
|
||||
);
|
||||
const bytes =
|
||||
Object.values(geometry.attributes).reduce(
|
||||
(total, attribute) => total + (attribute?.values?.byteLength || 0),
|
||||
0,
|
||||
) + geometry.indices.byteLength;
|
||||
assert.ok(bytes * 2 * 2047 < 2 * 1024 * 1024);
|
||||
});
|
||||
|
||||
test('subdivision crossings only write the changed show range and age colors once per second', (t) => {
|
||||
const renderer = createContactTrailRenderer(fixture(t));
|
||||
const positions = [
|
||||
Cesium.Cartesian3.fromDegrees(-71, 42),
|
||||
Cesium.Cartesian3.fromDegrees(-71, 42.001),
|
||||
];
|
||||
renderer.replaceHistory({
|
||||
revision: 1,
|
||||
segments: Array.from({ length: 100 }, (_, i) => ({
|
||||
fromSeq: 0,
|
||||
toSeq: 1,
|
||||
fromT: i * 500,
|
||||
toT: (i + 1) * 500,
|
||||
positions,
|
||||
})),
|
||||
});
|
||||
const { body } = renderer.diagnostics();
|
||||
const counts = { show: 0, color: 0, depthFailColor: 0 };
|
||||
const attributes = new Map(
|
||||
body.geometryInstances.map((instance) => {
|
||||
const result = {};
|
||||
for (const name of Object.keys(counts)) {
|
||||
let value = instance.attributes[name].value;
|
||||
Object.defineProperty(result, name, {
|
||||
get: () => value,
|
||||
set(next) {
|
||||
value = next;
|
||||
counts[name]++;
|
||||
},
|
||||
});
|
||||
}
|
||||
return [instance.id, result];
|
||||
}),
|
||||
);
|
||||
body._ready = true;
|
||||
body.getGeometryInstanceAttributes = (id) => attributes.get(id);
|
||||
const display = (displayT) =>
|
||||
renderer.setDisplaySample(
|
||||
{ displayT, fromSeq: 0, toSeq: 1, fraction: 0.1 },
|
||||
positions[0],
|
||||
);
|
||||
const reset = () => {
|
||||
for (const name of Object.keys(counts)) counts[name] = 0;
|
||||
};
|
||||
display(100);
|
||||
assert.deepEqual(counts, { show: 0, color: 200, depthFailColor: 200 });
|
||||
reset();
|
||||
display(600);
|
||||
assert.deepEqual(counts, { show: 2, color: 0, depthFailColor: 0 });
|
||||
reset();
|
||||
display(100);
|
||||
assert.deepEqual(counts, { show: 2, color: 0, depthFailColor: 0 });
|
||||
assert.ok(
|
||||
[...attributes.values()].every((a) => a.show[0] === 0),
|
||||
'backward seek hides the completed range',
|
||||
);
|
||||
reset();
|
||||
display(1100);
|
||||
assert.deepEqual(counts, { show: 4, color: 200, depthFailColor: 200 });
|
||||
reset();
|
||||
display(1200);
|
||||
assert.deepEqual(counts, { show: 0, color: 0, depthFailColor: 0 });
|
||||
renderer.setStyle('#FF4538');
|
||||
display(1200);
|
||||
assert.deepEqual(counts, { show: 0, color: 200, depthFailColor: 200 });
|
||||
for (const a of attributes.values())
|
||||
assert.deepEqual(a.color, a.depthFailColor);
|
||||
});
|
||||
@@ -183,6 +183,11 @@ export const DATA_CREDITS = [
|
||||
'<a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">© OpenStreetMap contributors</a> (ODbL) · ' +
|
||||
'<a href="https://www.openstreetmap.org/fixthemap" target="_blank" rel="noopener">fix the map</a>',
|
||||
},
|
||||
{
|
||||
key: 'gtfs-rt',
|
||||
html:
|
||||
'Transit vehicles: operator GTFS-Realtime feeds (each operator is credited below when its vehicles are shown)',
|
||||
},
|
||||
{
|
||||
key: 'radio-browser',
|
||||
html:
|
||||
@@ -287,6 +292,26 @@ export const NATURAL_EARTH_CREDIT = {
|
||||
'<a href="https://www.naturalearthdata.com" target="_blank" rel="noopener">Natural Earth</a> (public domain)',
|
||||
};
|
||||
|
||||
/**
|
||||
* Per-feed transit credit, registered the first time that feed's vehicles
|
||||
* render (see `src/data/transitFeeds.js` for the license of each).
|
||||
* @param {{ id: string, attribution: string, license: string, licenseUrl: string }} feed
|
||||
* @returns {{ key: string, html: string }}
|
||||
*/
|
||||
export function transitFeedCredit(feed) {
|
||||
const escape = (text) => String(text)
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"');
|
||||
return {
|
||||
key: `transit-${feed.id}`,
|
||||
html:
|
||||
`Transit (${escape(feed.attribution)}): ` +
|
||||
`<a href="${escape(feed.licenseUrl)}" target="_blank" rel="noopener">${escape(feed.license)}</a>`,
|
||||
};
|
||||
}
|
||||
|
||||
/** @type {Set<string>} Keys of dynamic credits already registered this session. */
|
||||
const _dynamicCreditKeys = new Set();
|
||||
|
||||
|
||||
+24
-3
@@ -1,3 +1,4 @@
|
||||
import { paintTransitBracket } from './detectionDraw.js';
|
||||
import * as Cesium from 'cesium';
|
||||
import { governorRequestRender } from '../renderGovernor.js';
|
||||
import {
|
||||
@@ -10,6 +11,7 @@ import {
|
||||
import {
|
||||
acquireAlpha,
|
||||
appendCornerBracket,
|
||||
appendTransitBracket,
|
||||
resolveTier,
|
||||
measureTrackLabel,
|
||||
nearFarScale,
|
||||
@@ -217,6 +219,8 @@ let _hostLane = null;
|
||||
* @type {{surface:HTMLCanvasElement|null,setActive:Function,requestPaint:Function,unregister:Function}|null}
|
||||
*/
|
||||
let _calloutLane = null;
|
||||
let _transitBracketPaths = new Map();
|
||||
let _transitBracketAlpha = 1;
|
||||
/**
|
||||
* Callouts solved by the sensor lane this frame, replayed by the callout lane.
|
||||
* The array and its rows are POOLED — `_calloutCount` is the live length — so a
|
||||
@@ -329,6 +333,7 @@ export function destroyDetection() {
|
||||
_hostLane = null;
|
||||
_calloutLane?.unregister?.();
|
||||
_calloutLane = null;
|
||||
_transitBracketPaths.clear();
|
||||
_calloutCount = 0;
|
||||
_viewer = null;
|
||||
_layers = [];
|
||||
@@ -517,7 +522,12 @@ export function markDetectionSourcesChanged(reason = 'sources-changed') {
|
||||
|
||||
/** Read-only diagnostics for unit/browser QA. */
|
||||
export function getDetectionDiagnostics() {
|
||||
return _lastDiagnostics ? JSON.parse(JSON.stringify(_lastDiagnostics)) : null;
|
||||
if (!_lastDiagnostics) return null;
|
||||
const result = JSON.parse(JSON.stringify(_lastDiagnostics));
|
||||
// Readback only: expose painted plates so pixel QA can exclude occluded
|
||||
// sprites/strokes. Nothing is copied on the production paint path.
|
||||
result.calloutRects = _calloutPool.slice(0, _calloutCount).map(({ x, y, w, h, alpha }) => ({ x, y, w, h, alpha }));
|
||||
return result;
|
||||
}
|
||||
|
||||
function _publishDiagnostics() {
|
||||
@@ -981,9 +991,14 @@ function _stashCallout(entry, acquireFade, keyhole) {
|
||||
* @param {Object} frame Host paint frame.
|
||||
*/
|
||||
function _paintCalloutLane(frame) {
|
||||
if (_mode === MODE_OFF || _suspended || _calloutCount === 0) return;
|
||||
if (_mode === MODE_OFF || _suspended) return;
|
||||
const ctx = frame.ctx;
|
||||
if (!ctx) return;
|
||||
for (const bands of _transitBracketPaths.values()) {
|
||||
for (const entry of bands) {
|
||||
if (entry) paintTransitBracket(ctx, entry.path, entry.color, entry.alpha * _transitBracketAlpha);
|
||||
}
|
||||
}
|
||||
ctx.textAlign = 'left';
|
||||
ctx.textBaseline = 'alphabetic';
|
||||
for (let i = 0; i < _calloutCount; i++) {
|
||||
@@ -1061,6 +1076,7 @@ function _materializeCandidate(obj, width, height, keyhole, occlusionRects, came
|
||||
* for a bounded cohort on solve ticks.
|
||||
*/
|
||||
function _drawOverlay(frame) {
|
||||
_transitBracketPaths.clear();
|
||||
const { width, height } = frame;
|
||||
if (!_ctx || width <= 0 || height <= 0) {
|
||||
return { didSolve: false, solveMs: 0, fadingCount: 0, animatingCount: 0, solvePending: false };
|
||||
@@ -1217,6 +1233,9 @@ function _drawOverlay(frame) {
|
||||
);
|
||||
halfW = _clamp((isTracked ? 14 : 9) * bscale, 7, 48);
|
||||
halfH = _clamp((isTracked ? 11 : 7) * bscale, 5, 38);
|
||||
} else if (obj.tier?.startsWith('transit_') && obj.bracketHalfWidth > 0) {
|
||||
halfW = obj.bracketHalfWidth;
|
||||
halfH = obj.bracketHalfHeight;
|
||||
} else {
|
||||
halfW = _mode === MODE_DENSE ? (isTracked ? 28 : 11) : 16;
|
||||
halfH = _mode === MODE_DENSE ? (isTracked ? 22 : 7) : 10;
|
||||
@@ -1230,7 +1249,8 @@ function _drawOverlay(frame) {
|
||||
const keyholeAlpha = keyholeLabelAlphaFromGeometry(sx, sy, keyhole);
|
||||
const bracketAlpha = detectionBracketAlpha(obj.type, keyholeAlpha, keyholeOutsideOpacity);
|
||||
if (bracketAlpha > 0) {
|
||||
appendCornerBracket(pathFor(bracketPaths, color, bracketAlpha), sx, sy, halfW, halfH);
|
||||
const transit = obj.tier?.startsWith('transit_');
|
||||
(transit ? appendTransitBracket : appendCornerBracket)(pathFor(transit ? _transitBracketPaths : bracketPaths, color, bracketAlpha), sx, sy, halfW, halfH);
|
||||
visibleCount++;
|
||||
if (obj.type === 'AIR') aircraftBracketSectors[detectionHorizontalSector(sx, width)]++;
|
||||
if (bracketAlpha >= 1) bracketOpacityCounts.full++;
|
||||
@@ -1346,6 +1366,7 @@ function _drawOverlay(frame) {
|
||||
|
||||
const fade = acquireAlpha(_enableTime, now, FADE_MS);
|
||||
const bracketWidth = _mode === MODE_DENSE ? 1 : 1.25;
|
||||
_transitBracketAlpha = fade * bracketPresentationOpacity;
|
||||
|
||||
// Brackets — batched by tier color and linear radial-opacity band.
|
||||
_ctx.lineWidth = bracketWidth;
|
||||
|
||||
@@ -237,3 +237,24 @@ export function nearFarScale(distance, near, nearValue, far, farValue) {
|
||||
const t = (distance - near) / (far - near);
|
||||
return nearValue + t * (farValue - nearValue);
|
||||
}
|
||||
|
||||
|
||||
/** Transit keeps literal mode colour on the normal-composite surface. */
|
||||
export function paintTransitBracket(ctx, path, color, alpha = 1) {
|
||||
ctx.save?.();
|
||||
ctx.globalCompositeOperation = 'source-over';
|
||||
ctx.globalAlpha = alpha;
|
||||
ctx.strokeStyle = '#05080C';
|
||||
ctx.lineWidth = 3.25;
|
||||
ctx.stroke(path);
|
||||
ctx.strokeStyle = color;
|
||||
ctx.lineWidth = 1.25;
|
||||
ctx.stroke(path);
|
||||
ctx.restore?.();
|
||||
}
|
||||
|
||||
/** Centre 1.25 px strokes on pixels, so their cores are fully covered. */
|
||||
export function appendTransitBracket(sink, sx, sy, halfW, halfH) {
|
||||
appendCornerBracket(sink, Math.floor(sx) + 0.5, Math.floor(sy) + 0.5,
|
||||
Math.round(halfW), Math.round(halfH));
|
||||
}
|
||||
@@ -161,3 +161,34 @@ test('nearFarScale interpolates by distance and clamps to the near/far values',
|
||||
const mid = nearFarScale((1000 + 8000000) / 2, 1000, 3.0, 8000000, 0.5);
|
||||
assert.ok(Math.abs(mid - 1.75) < 1e-6, `midpoint ~1.75, got ${mid}`);
|
||||
});
|
||||
|
||||
|
||||
test('transit brackets paint a 1.25 px mode stroke over 3.25 px dark backing', async () => {
|
||||
const { paintTransitBracket } = await import('./detectionDraw.js');
|
||||
const strokes = [];
|
||||
const ctx = { stroke(path) { strokes.push([path, this.lineWidth, this.strokeStyle, this.globalAlpha]); } };
|
||||
const path = {};
|
||||
paintTransitBracket(ctx, path, '#FF4538', 0.75);
|
||||
assert.deepEqual(strokes, [[path, 3.25, '#05080C', 0.75], [path, 1.25, '#FF4538', 0.75]]);
|
||||
});
|
||||
|
||||
test('transit bracket backing and colour use source-over under every inherited sensor theme', async () => {
|
||||
const {paintTransitBracket}=await import('./detectionDraw.js');
|
||||
for(const theme of ['normal','thermal','surveillance','noir','retro']) {
|
||||
const strokes=[];
|
||||
const ctx={globalCompositeOperation:'screen',globalAlpha:0.3,stroke(p){strokes.push([this.globalCompositeOperation,this.lineWidth,this.strokeStyle]);},save(){this.saved=[this.globalCompositeOperation,this.globalAlpha];},restore(){[this.globalCompositeOperation,this.globalAlpha]=this.saved;}};
|
||||
paintTransitBracket(ctx,{},'#FF4538',1);
|
||||
assert.deepEqual(strokes,[['source-over',3.25,'#05080C'],['source-over',1.25,'#FF4538']],theme);
|
||||
assert.equal(ctx.globalCompositeOperation,'screen');
|
||||
}
|
||||
});
|
||||
|
||||
test('transit bracket stroke centres have full pixel coverage at the failing fractional positions', async () => {
|
||||
const {appendTransitBracket}=await import('./detectionDraw.js');
|
||||
for(const sy of [305.076,490.54,279.56]) {
|
||||
const points=[],sink={moveTo:(x,y)=>points.push([x,y]),lineTo:(x,y)=>points.push([x,y])};
|
||||
appendTransitBracket(sink,759.016,sy,11,7);
|
||||
assert.equal(points.length,12);
|
||||
for(const [x,y] of points){assert.equal(x%1,0.5);assert.equal(y%1,0.5);}
|
||||
}
|
||||
});
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
setOverlayEntries,
|
||||
} from '../overlays/worldOverlay.js';
|
||||
import { DETECTION_THEME_MAP } from '../overlays/worldOverlayTokens.js';
|
||||
import { composeLabel, resolveTier } from './detectionDraw.js';
|
||||
|
||||
test('detection diagnostics count rendered fading rows instead of absent selected identities', () => {
|
||||
assert.equal(countFadingRenderEntries([
|
||||
@@ -97,7 +98,7 @@ function mockContext(target, trace) {
|
||||
// set at some point during the frame. globalAlpha rides along for the same
|
||||
// reason: the backdrop feather is an alpha, not a colour.
|
||||
fill(path) { record('fill', path, this.fillStyle, this.globalAlpha); },
|
||||
stroke(path) { record('stroke', path); },
|
||||
stroke(path) { record('stroke', path, this.strokeStyle, this.lineWidth); },
|
||||
fillRect(...args) { record('fillRect', ...args); },
|
||||
fillText(...args) { record('fillText', ...args); },
|
||||
drawImage(...args) { record('drawImage', ...args); },
|
||||
@@ -378,6 +379,12 @@ test('detection lifecycle re-hosts unchanged painters behind the sole host liste
|
||||
env.postRender.raise();
|
||||
const first = getDetectionDiagnostics();
|
||||
assert.equal(first.profile, 'DENSE');
|
||||
assert.ok(Array.isArray(first.calloutRects));
|
||||
if (first.calloutRects.length) {
|
||||
const x=first.calloutRects[0].x;
|
||||
first.calloutRects[0].x=NaN;
|
||||
assert.equal(getDetectionDiagnostics().calloutRects[0].x,x,'QA owns a copy, never a live plate');
|
||||
}
|
||||
assert.equal(first.observationCount, 2);
|
||||
assert.equal(first.didSolve, true);
|
||||
assert.ok(first.solveRevision > 0);
|
||||
@@ -874,3 +881,82 @@ test('the backdrop feather reaches the canvas as a lighter plate against sky', (
|
||||
env.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('a transit vehicle is a contact: box, route, mode and operator on the shared canvas', () => {
|
||||
// The owner's finding was that buses were "green dots moving around
|
||||
// clumsily" — the layer rendered them and the detection overlay ignored
|
||||
// them, because transit published no detectable objects at all. This drives
|
||||
// the real host with the shape the layer now returns.
|
||||
const env = installEnvironment();
|
||||
const objects = [
|
||||
{
|
||||
position: new Cesium.Cartesian3(0, 0, 6_356_752),
|
||||
sourceId: 'mbta:y1276',
|
||||
tier: 'transit_bus',
|
||||
id: '111',
|
||||
type: 'VEH',
|
||||
klass: 'MBTA',
|
||||
metric: 'BUS 34 KM/H',
|
||||
},
|
||||
{
|
||||
position: new Cesium.Cartesian3(0, 900, 6_356_752),
|
||||
sourceId: 'mbta:R-548BBB14',
|
||||
id: 'RED',
|
||||
type: 'VEH',
|
||||
klass: 'MBTA',
|
||||
metric: 'METRO STOPPED',
|
||||
skipLabel: true,
|
||||
},
|
||||
];
|
||||
try {
|
||||
initWorldOverlay(env.viewer);
|
||||
initDetection(
|
||||
env.viewer,
|
||||
[{ id: 'transit', getDetectableObjects: () => objects }],
|
||||
() => {},
|
||||
);
|
||||
setMode('DENSE');
|
||||
env.advance(250);
|
||||
env.postRender.raise();
|
||||
env.advance(250);
|
||||
env.postRender.raise();
|
||||
const painted = env.ctx.calls
|
||||
.filter(([name]) => name === 'fillText')
|
||||
.map(([, text]) => text);
|
||||
assert.ok(
|
||||
painted.includes('111'),
|
||||
`the route is the bright line; painted ${JSON.stringify(painted)}`,
|
||||
);
|
||||
// The host paints a single-line track label — bright id, one dim micro
|
||||
// field — so the mode and what the vehicle is doing share that field.
|
||||
assert.ok(
|
||||
painted.some((text) => String(text).includes('BUS')),
|
||||
`the mode rides along on the micro line; painted ${JSON.stringify(painted)}`,
|
||||
);
|
||||
assert.ok(
|
||||
painted.some((text) => String(text).includes('34 KM/H')),
|
||||
'and so does what the vehicle is doing',
|
||||
);
|
||||
assert.ok(env.ctx.calls.some(([name, , color, width]) => name === 'stroke' && color === '#5EF08A' && width === 1.25));
|
||||
assert.ok(env.ctx.calls.some(([name, , color, width]) => name === 'stroke' && color === '#05080C' && width === 3.25));
|
||||
assert.ok(!env.detectionCtx.calls.some(([name, , color]) => name === 'stroke' && color === '#5EF08A'));
|
||||
// The operator travels on the object for the two-tier card form, which is
|
||||
// where `klass` is read; the host's track label does not paint it.
|
||||
assert.equal(composeLabel(objects[0]).secondary, 'MBTA · BUS 34 KM/H');
|
||||
// The selected vehicle carries its own fuller card, so detection must not
|
||||
// label it a second time.
|
||||
assert.equal(
|
||||
painted.includes('RED'),
|
||||
false,
|
||||
'skipLabel keeps the selected vehicle from being labelled twice',
|
||||
);
|
||||
} finally {
|
||||
env.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('transit contacts take the vehicle tier, not the civil-air one', () => {
|
||||
assert.equal(resolveTier({ type: 'VEH' }), 'vehicle');
|
||||
assert.equal(resolveTier({ type: 'AIR' }), 'civil');
|
||||
assert.equal(resolveTier({ type: 'SEA' }), 'sea');
|
||||
});
|
||||
@@ -0,0 +1,274 @@
|
||||
/**
|
||||
* @module gtfsRealtime
|
||||
* @description Minimal GTFS-Realtime decoder for VehiclePosition feeds.
|
||||
*
|
||||
* Decodes only what the Transit layer needs from a `FeedMessage` — the header
|
||||
* and every entity's `VehiclePosition` — straight from the protobuf wire
|
||||
* format with `pbf` (already a dependency via the TomTom flow tiles). No
|
||||
* `gtfs-realtime-bindings`, no protobufjs, no generated code: the GTFS-RT
|
||||
* schema is stable, and field numbers are the contract.
|
||||
*
|
||||
* Field numbers (gtfs-realtime.proto, v2.0):
|
||||
* FeedMessage 1 header, 2 entity[]
|
||||
* FeedHeader 1 gtfs_realtime_version, 2 incrementality, 3 timestamp
|
||||
* FeedEntity 1 id, 2 is_deleted, 3 trip_update, 4 vehicle, 5 alert
|
||||
* VehiclePosition 1 trip, 2 position, 3 current_stop_sequence,
|
||||
* 4 current_status, 5 timestamp, 6 congestion_level,
|
||||
* 7 stop_id, 8 vehicle, 9 occupancy_status
|
||||
* TripDescriptor 1 trip_id, 2 start_time, 3 start_date,
|
||||
* 4 schedule_relationship, 5 route_id, 6 direction_id
|
||||
* Position 1 latitude, 2 longitude, 3 bearing, 4 odometer, 5 speed
|
||||
* VehicleDescriptor 1 id, 2 label, 3 license_plate
|
||||
*
|
||||
* Unknown fields (extensions, newer additions) are skipped by pbf, so a feed
|
||||
* that carries OVapi/NYCT extensions decodes the same as a plain one.
|
||||
*
|
||||
* Decoding is bounded, because a byte cap alone does not bound what a decode
|
||||
* COSTS: entities past `GTFS_MAX_ENTITIES` are left unread so pbf skips them
|
||||
* whole (the result says it truncated), and every string field — the feed
|
||||
* header's version included — is refused past `GTFS_MAX_STRING_CHARS` rather
|
||||
* than carried into the snapshot.
|
||||
*
|
||||
* Pure: safe to import from the browser layer, the Vite proxy, and node:test.
|
||||
*/
|
||||
|
||||
import { PbfReader } from 'pbf';
|
||||
|
||||
/** VehiclePosition.VehicleStopStatus enum → label. */
|
||||
export const VEHICLE_STOP_STATUS = Object.freeze({
|
||||
0: 'INCOMING_AT',
|
||||
1: 'STOPPED_AT',
|
||||
2: 'IN_TRANSIT_TO',
|
||||
});
|
||||
|
||||
/** VehiclePosition.OccupancyStatus enum → label. */
|
||||
export const OCCUPANCY_STATUS = Object.freeze({
|
||||
0: 'EMPTY',
|
||||
1: 'MANY_SEATS_AVAILABLE',
|
||||
2: 'FEW_SEATS_AVAILABLE',
|
||||
3: 'STANDING_ROOM_ONLY',
|
||||
4: 'CRUSHED_STANDING_ROOM_ONLY',
|
||||
5: 'FULL',
|
||||
6: 'NOT_ACCEPTING_PASSENGERS',
|
||||
7: 'NO_DATA_AVAILABLE',
|
||||
8: 'NOT_BOARDABLE',
|
||||
});
|
||||
|
||||
/**
|
||||
* Hard ceiling on decoded entities. Every registered feed is a metropolitan or
|
||||
* national fleet — the largest observed is a few thousand vehicles — so this is
|
||||
* two orders of magnitude above any honest payload.
|
||||
*/
|
||||
export const GTFS_MAX_ENTITIES = 50_000;
|
||||
/** Longest accepted string field. GTFS ids, labels and stop ids are short. */
|
||||
export const GTFS_MAX_STRING_CHARS = 256;
|
||||
/** FeedHeader.Incrementality: only FULL_DATASET carries a complete snapshot. */
|
||||
export const GTFS_INCREMENTALITY_FULL_DATASET = 0;
|
||||
export const GTFS_INCREMENTALITY_DIFFERENTIAL = 1;
|
||||
|
||||
/**
|
||||
* Read a length-delimited string, refusing one that is implausibly long.
|
||||
* An over-long field marks its containing message `oversize`, and the whole
|
||||
* record is then dropped — never truncated. Half an id is a different vehicle,
|
||||
* not a shorter name for the same one.
|
||||
* @param {object} pbf
|
||||
* @param {object} target Message being read, flagged when the field is refused.
|
||||
* @returns {string|null}
|
||||
*/
|
||||
function readBoundedString(pbf, target) {
|
||||
const value = pbf.readString();
|
||||
if (value.length <= GTFS_MAX_STRING_CHARS) return value;
|
||||
target.oversize = true;
|
||||
return null;
|
||||
}
|
||||
|
||||
function readFeedHeader(tag, header, pbf) {
|
||||
if (tag === 1) header.version = readBoundedString(pbf, header);
|
||||
else if (tag === 2) header.incrementality = pbf.readVarint();
|
||||
else if (tag === 3) header.timestamp = pbf.readVarint();
|
||||
}
|
||||
|
||||
function readTripDescriptor(tag, trip, pbf) {
|
||||
if (tag === 1) trip.tripId = readBoundedString(pbf, trip);
|
||||
else if (tag === 2) trip.startTime = readBoundedString(pbf, trip);
|
||||
else if (tag === 3) trip.startDate = readBoundedString(pbf, trip);
|
||||
else if (tag === 4) trip.scheduleRelationship = pbf.readVarint();
|
||||
else if (tag === 5) trip.routeId = readBoundedString(pbf, trip);
|
||||
else if (tag === 6) trip.directionId = pbf.readVarint();
|
||||
}
|
||||
|
||||
function readPosition(tag, position, pbf) {
|
||||
if (tag === 1) position.latitude = pbf.readFloat();
|
||||
else if (tag === 2) position.longitude = pbf.readFloat();
|
||||
else if (tag === 3) position.bearing = pbf.readFloat();
|
||||
else if (tag === 4) position.odometer = pbf.readDouble();
|
||||
else if (tag === 5) position.speed = pbf.readFloat();
|
||||
}
|
||||
|
||||
function readVehicleDescriptor(tag, descriptor, pbf) {
|
||||
if (tag === 1) descriptor.id = readBoundedString(pbf, descriptor);
|
||||
else if (tag === 2) descriptor.label = readBoundedString(pbf, descriptor);
|
||||
else if (tag === 3)
|
||||
descriptor.licensePlate = readBoundedString(pbf, descriptor);
|
||||
}
|
||||
|
||||
function readVehiclePosition(tag, vehicle, pbf) {
|
||||
if (tag === 1) vehicle.trip = pbf.readMessage(readTripDescriptor, {});
|
||||
else if (tag === 2) vehicle.position = pbf.readMessage(readPosition, {});
|
||||
else if (tag === 3) vehicle.currentStopSequence = pbf.readVarint();
|
||||
else if (tag === 4) vehicle.currentStatus = pbf.readVarint();
|
||||
else if (tag === 5) vehicle.timestamp = pbf.readVarint();
|
||||
else if (tag === 6) vehicle.congestionLevel = pbf.readVarint();
|
||||
else if (tag === 7) vehicle.stopId = readBoundedString(pbf, vehicle);
|
||||
else if (tag === 8)
|
||||
vehicle.vehicle = pbf.readMessage(readVehicleDescriptor, {});
|
||||
else if (tag === 9) vehicle.occupancyStatus = pbf.readVarint();
|
||||
}
|
||||
|
||||
function readFeedEntity(tag, entity, pbf) {
|
||||
if (tag === 1) entity.id = readBoundedString(pbf, entity);
|
||||
else if (tag === 2) entity.isDeleted = pbf.readBoolean();
|
||||
else if (tag === 4) entity.vehicle = pbf.readMessage(readVehiclePosition, {});
|
||||
// 3 (trip_update) and 5 (alert) are skipped: pbf advances past any tag the
|
||||
// reader leaves untouched.
|
||||
}
|
||||
|
||||
function readFeedMessage(tag, message, pbf) {
|
||||
if (tag === 1) message.header = pbf.readMessage(readFeedHeader, {});
|
||||
else if (tag === 2) {
|
||||
// Past the cap the field is left UNREAD. pbf skips any field its handler
|
||||
// did not consume, so the excess costs one length varint each instead of a
|
||||
// full nested decode that is then thrown away.
|
||||
if (message.entities.length >= GTFS_MAX_ENTITIES) {
|
||||
message.truncated = true;
|
||||
return;
|
||||
}
|
||||
message.entities.push(pbf.readMessage(readFeedEntity, {}));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a raw GTFS-Realtime FeedMessage.
|
||||
* @param {Uint8Array|ArrayBuffer} bytes Protobuf wire bytes.
|
||||
* @returns {{ header: {version?: string, incrementality?: number, timestamp?: number},
|
||||
* entities: object[], truncated: boolean }}
|
||||
*/
|
||||
export function decodeFeedMessage(bytes) {
|
||||
const view = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
|
||||
const pbf = new PbfReader(view);
|
||||
return pbf.readFields(readFeedMessage, {
|
||||
header: {},
|
||||
entities: [],
|
||||
truncated: false,
|
||||
});
|
||||
}
|
||||
|
||||
function finiteOrNull(value) {
|
||||
return Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
function nonEmptyString(value) {
|
||||
const text = typeof value === 'string' ? value.trim() : '';
|
||||
return text ? text : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when a lat/lon pair is a usable surface position. Rejects non-finite
|
||||
* values, out-of-range degrees, and the (0,0) null island a cold GPS reports.
|
||||
* @param {number} lat
|
||||
* @param {number} lon
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isPlausibleVehiclePosition(lat, lon) {
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return false;
|
||||
if (Math.abs(lat) > 90 || Math.abs(lon) > 180) return false;
|
||||
if (Math.abs(lat) < 1e-6 && Math.abs(lon) < 1e-6) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flatten one decoded FeedEntity into the record the Transit layer renders,
|
||||
* or null when the entity carries no usable vehicle position.
|
||||
* @param {object} entity Decoded FeedEntity.
|
||||
* @returns {object|null}
|
||||
*/
|
||||
export function normalizeVehicleEntity(entity) {
|
||||
if (!entity || entity.isDeleted === true) return null;
|
||||
const vehicle = entity.vehicle;
|
||||
const position = vehicle?.position;
|
||||
if (!vehicle || !position) return null;
|
||||
// Any refused (over-long) string anywhere in this entity makes the whole
|
||||
// record untrustworthy — drop it rather than render a half-identified dot.
|
||||
if (
|
||||
entity.oversize ||
|
||||
vehicle.oversize ||
|
||||
vehicle.trip?.oversize ||
|
||||
vehicle.vehicle?.oversize
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const lat = finiteOrNull(position.latitude);
|
||||
const lon = finiteOrNull(position.longitude);
|
||||
if (!isPlausibleVehiclePosition(lat, lon)) return null;
|
||||
const id = nonEmptyString(vehicle.vehicle?.id) || nonEmptyString(entity.id);
|
||||
if (!id) return null;
|
||||
const bearing = finiteOrNull(position.bearing);
|
||||
const speed = finiteOrNull(position.speed);
|
||||
const timestamp =
|
||||
Number.isFinite(vehicle.timestamp) && vehicle.timestamp > 0
|
||||
? vehicle.timestamp
|
||||
: null;
|
||||
return {
|
||||
id,
|
||||
lat: Number(lat.toFixed(6)),
|
||||
lon: Number(lon.toFixed(6)),
|
||||
bearing: bearing === null ? null : ((bearing % 360) + 360) % 360,
|
||||
speedMps: speed === null || speed < 0 ? null : speed,
|
||||
timestamp,
|
||||
routeId: nonEmptyString(vehicle.trip?.routeId),
|
||||
tripId: nonEmptyString(vehicle.trip?.tripId),
|
||||
directionId: Number.isInteger(vehicle.trip?.directionId)
|
||||
? vehicle.trip.directionId
|
||||
: null,
|
||||
label: nonEmptyString(vehicle.vehicle?.label),
|
||||
stopId: nonEmptyString(vehicle.stopId),
|
||||
status: VEHICLE_STOP_STATUS[vehicle.currentStatus] || null,
|
||||
occupancy: OCCUPANCY_STATUS[vehicle.occupancyStatus] || null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a VehiclePositions feed into the compact snapshot the proxy serves.
|
||||
* Duplicate vehicle ids keep the newest timestamp (feeds occasionally repeat
|
||||
* a vehicle across two trip entities during a handover).
|
||||
* @param {Uint8Array|ArrayBuffer} bytes Protobuf wire bytes.
|
||||
* @returns {{ version: string|null, timestamp: number|null, incrementality: number,
|
||||
* entityCount: number, truncated: boolean, vehicles: object[] }}
|
||||
*/
|
||||
export function decodeVehiclePositions(bytes) {
|
||||
const message = decodeFeedMessage(bytes);
|
||||
const byId = new Map();
|
||||
for (const entity of message.entities) {
|
||||
const record = normalizeVehicleEntity(entity);
|
||||
if (!record) continue;
|
||||
const existing = byId.get(record.id);
|
||||
if (!existing || (record.timestamp ?? 0) >= (existing.timestamp ?? 0)) {
|
||||
byId.set(record.id, record);
|
||||
}
|
||||
}
|
||||
const headerTimestamp = message.header?.timestamp;
|
||||
const incrementality = Number.isFinite(message.header?.incrementality)
|
||||
? message.header.incrementality
|
||||
: GTFS_INCREMENTALITY_FULL_DATASET;
|
||||
return {
|
||||
version: nonEmptyString(message.header?.version),
|
||||
timestamp:
|
||||
Number.isFinite(headerTimestamp) && headerTimestamp > 0
|
||||
? headerTimestamp
|
||||
: null,
|
||||
incrementality,
|
||||
entityCount: message.entities.length,
|
||||
truncated: message.truncated === true,
|
||||
vehicles: [...byId.values()],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { PbfWriter } from 'pbf';
|
||||
import {
|
||||
GTFS_MAX_ENTITIES,
|
||||
GTFS_MAX_STRING_CHARS,
|
||||
decodeFeedMessage,
|
||||
decodeVehiclePositions,
|
||||
isPlausibleVehiclePosition,
|
||||
normalizeVehicleEntity,
|
||||
} from './gtfsRealtime.js';
|
||||
|
||||
/** Encode a nested message with a fresh writer and embed it as a bytes field. */
|
||||
function embed(writer, tag, build) {
|
||||
const inner = new PbfWriter();
|
||||
build(inner);
|
||||
writer.writeBytesField(tag, inner.finish());
|
||||
}
|
||||
|
||||
/**
|
||||
* Hand-encode one FeedEntity carrying a VehiclePosition, from the proto field
|
||||
* numbers — the same contract the decoder reads, written independently.
|
||||
*/
|
||||
function writeVehicleEntity(
|
||||
writer,
|
||||
{
|
||||
entityId,
|
||||
vehicleId,
|
||||
label,
|
||||
lat,
|
||||
lon,
|
||||
bearing,
|
||||
speed,
|
||||
timestamp,
|
||||
routeId,
|
||||
tripId,
|
||||
directionId,
|
||||
stopId,
|
||||
status,
|
||||
occupancy,
|
||||
isDeleted,
|
||||
extraUnknownFields = false,
|
||||
},
|
||||
) {
|
||||
embed(writer, 2, (entity) => {
|
||||
if (entityId != null) entity.writeStringField(1, entityId);
|
||||
if (isDeleted) entity.writeBooleanField(2, true);
|
||||
embed(entity, 4, (vehicle) => {
|
||||
if (routeId != null || tripId != null || directionId != null) {
|
||||
embed(vehicle, 1, (trip) => {
|
||||
if (tripId != null) trip.writeStringField(1, tripId);
|
||||
if (routeId != null) trip.writeStringField(5, routeId);
|
||||
if (directionId != null) trip.writeVarintField(6, directionId);
|
||||
});
|
||||
}
|
||||
if (lat != null) {
|
||||
embed(vehicle, 2, (position) => {
|
||||
position.writeFloatField(1, lat);
|
||||
position.writeFloatField(2, lon);
|
||||
if (bearing != null) position.writeFloatField(3, bearing);
|
||||
if (speed != null) position.writeFloatField(5, speed);
|
||||
if (extraUnknownFields) position.writeDoubleField(4, 123456.5); // odometer
|
||||
});
|
||||
}
|
||||
if (status != null) vehicle.writeVarintField(4, status);
|
||||
if (timestamp != null) vehicle.writeVarintField(5, timestamp);
|
||||
if (stopId != null) vehicle.writeStringField(7, stopId);
|
||||
if (vehicleId != null || label != null) {
|
||||
embed(vehicle, 8, (descriptor) => {
|
||||
if (vehicleId != null) descriptor.writeStringField(1, vehicleId);
|
||||
if (label != null) descriptor.writeStringField(2, label);
|
||||
});
|
||||
}
|
||||
if (occupancy != null) vehicle.writeVarintField(9, occupancy);
|
||||
if (extraUnknownFields)
|
||||
vehicle.writeStringField(1001, 'operator-extension');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function encodeFeed(
|
||||
entities,
|
||||
{ version = '2.0', timestamp = 1_788_936_000 } = {},
|
||||
) {
|
||||
const writer = new PbfWriter();
|
||||
embed(writer, 1, (header) => {
|
||||
header.writeStringField(1, version);
|
||||
header.writeVarintField(2, 0);
|
||||
header.writeVarintField(3, timestamp);
|
||||
});
|
||||
for (const entity of entities) writeVehicleEntity(writer, entity);
|
||||
return writer.finish();
|
||||
}
|
||||
|
||||
test('decodes header and every VehiclePosition field the layer renders', () => {
|
||||
const bytes = encodeFeed([
|
||||
{
|
||||
entityId: 'e1',
|
||||
vehicleId: 'bus-17',
|
||||
label: '1557',
|
||||
lat: 44.927551,
|
||||
lon: -93.386711,
|
||||
bearing: 248,
|
||||
speed: 11.2,
|
||||
timestamp: 1_788_936_945,
|
||||
routeId: '17',
|
||||
tripId: '1134773',
|
||||
directionId: 0,
|
||||
stopId: '57458',
|
||||
status: 1,
|
||||
occupancy: 2,
|
||||
},
|
||||
]);
|
||||
const snapshot = decodeVehiclePositions(bytes);
|
||||
assert.equal(snapshot.version, '2.0');
|
||||
assert.equal(snapshot.timestamp, 1_788_936_000);
|
||||
assert.equal(snapshot.entityCount, 1);
|
||||
assert.equal(snapshot.vehicles.length, 1);
|
||||
const [v] = snapshot.vehicles;
|
||||
assert.equal(v.id, 'bus-17');
|
||||
assert.equal(v.label, '1557');
|
||||
assert.ok(Math.abs(v.lat - 44.927551) < 1e-5);
|
||||
assert.ok(Math.abs(v.lon + 93.386711) < 1e-5);
|
||||
assert.equal(v.bearing, 248);
|
||||
assert.ok(Math.abs(v.speedMps - 11.2) < 1e-5);
|
||||
assert.equal(v.timestamp, 1_788_936_945);
|
||||
assert.equal(v.routeId, '17');
|
||||
assert.equal(v.tripId, '1134773');
|
||||
assert.equal(v.directionId, 0);
|
||||
assert.equal(v.stopId, '57458');
|
||||
assert.equal(v.status, 'STOPPED_AT');
|
||||
assert.equal(v.occupancy, 'FEW_SEATS_AVAILABLE');
|
||||
});
|
||||
|
||||
test('unknown and extension fields are skipped, not misread', () => {
|
||||
const bytes = encodeFeed([
|
||||
{ entityId: 'x', lat: 51.3, lon: 5.33, extraUnknownFields: true },
|
||||
]);
|
||||
const snapshot = decodeVehiclePositions(bytes);
|
||||
assert.equal(snapshot.vehicles.length, 1);
|
||||
assert.equal(snapshot.vehicles[0].id, 'x');
|
||||
assert.ok(Math.abs(snapshot.vehicles[0].lat - 51.3) < 1e-5);
|
||||
});
|
||||
|
||||
test('entities without a usable position are dropped and never throw', () => {
|
||||
const bytes = encodeFeed([
|
||||
{ entityId: 'no-position' },
|
||||
{ entityId: 'deleted', lat: 1, lon: 1, isDeleted: true },
|
||||
{ entityId: 'null-island', lat: 0, lon: 0 },
|
||||
{ entityId: 'out-of-range', lat: 91, lon: 10 },
|
||||
{ entityId: 'nan', lat: Number.NaN, lon: 10 },
|
||||
{ entityId: 'keeper', lat: 60.17, lon: 24.94 },
|
||||
]);
|
||||
const snapshot = decodeVehiclePositions(bytes);
|
||||
assert.equal(snapshot.entityCount, 6);
|
||||
assert.deepEqual(
|
||||
snapshot.vehicles.map((v) => v.id),
|
||||
['keeper'],
|
||||
);
|
||||
});
|
||||
|
||||
test('vehicle id falls back to the entity id and the vehicle is dropped when neither exists', () => {
|
||||
const bytes = encodeFeed([
|
||||
{ entityId: 'entity-only', lat: 42.3, lon: -71.1 },
|
||||
{ lat: 42.3, lon: -71.1 },
|
||||
]);
|
||||
const snapshot = decodeVehiclePositions(bytes);
|
||||
assert.deepEqual(
|
||||
snapshot.vehicles.map((v) => v.id),
|
||||
['entity-only'],
|
||||
);
|
||||
});
|
||||
|
||||
test('duplicate vehicle ids keep the newest timestamp', () => {
|
||||
const bytes = encodeFeed([
|
||||
{ entityId: 'a', vehicleId: 'v1', lat: 42.3, lon: -71.1, timestamp: 100 },
|
||||
{ entityId: 'b', vehicleId: 'v1', lat: 42.31, lon: -71.11, timestamp: 200 },
|
||||
{ entityId: 'c', vehicleId: 'v1', lat: 42.32, lon: -71.12, timestamp: 150 },
|
||||
]);
|
||||
const snapshot = decodeVehiclePositions(bytes);
|
||||
assert.equal(snapshot.vehicles.length, 1);
|
||||
assert.equal(snapshot.vehicles[0].timestamp, 200);
|
||||
assert.ok(Math.abs(snapshot.vehicles[0].lat - 42.31) < 1e-5);
|
||||
});
|
||||
|
||||
test('bearing is normalized to [0, 360) and negative speed is treated as unknown', () => {
|
||||
const bytes = encodeFeed([
|
||||
{ entityId: 'neg', lat: 30.2, lon: -97.7, bearing: -90, speed: -1 },
|
||||
{ entityId: 'wrap', lat: 30.2, lon: -97.7, bearing: 450 },
|
||||
]);
|
||||
const [neg, wrap] = decodeVehiclePositions(bytes).vehicles;
|
||||
assert.equal(neg.bearing, 270);
|
||||
assert.equal(neg.speedMps, null);
|
||||
assert.equal(wrap.bearing, 90);
|
||||
});
|
||||
|
||||
test('missing header yields null version/timestamp rather than garbage', () => {
|
||||
const writer = new PbfWriter();
|
||||
writeVehicleEntity(writer, { entityId: 'solo', lat: 10, lon: 10 });
|
||||
const snapshot = decodeVehiclePositions(writer.finish());
|
||||
assert.equal(snapshot.version, null);
|
||||
assert.equal(snapshot.timestamp, null);
|
||||
assert.equal(snapshot.vehicles.length, 1);
|
||||
});
|
||||
|
||||
test('decodeFeedMessage accepts an ArrayBuffer and an empty feed', () => {
|
||||
const empty = decodeFeedMessage(new ArrayBuffer(0));
|
||||
assert.deepEqual(empty, { header: {}, entities: [], truncated: false });
|
||||
const bytes = encodeFeed([]);
|
||||
const asBuffer = bytes.buffer.slice(
|
||||
bytes.byteOffset,
|
||||
bytes.byteOffset + bytes.byteLength,
|
||||
);
|
||||
assert.equal(decodeFeedMessage(asBuffer).header.version, '2.0');
|
||||
});
|
||||
|
||||
test('isPlausibleVehiclePosition and normalizeVehicleEntity guard their inputs', () => {
|
||||
assert.equal(isPlausibleVehiclePosition(45, 90), true);
|
||||
assert.equal(isPlausibleVehiclePosition(0, 0), false);
|
||||
assert.equal(isPlausibleVehiclePosition(-90.0001, 0), false);
|
||||
assert.equal(isPlausibleVehiclePosition(1, Infinity), false);
|
||||
assert.equal(normalizeVehicleEntity(null), null);
|
||||
assert.equal(normalizeVehicleEntity({ id: 'no-vehicle' }), null);
|
||||
assert.equal(
|
||||
normalizeVehicleEntity({
|
||||
id: 'v',
|
||||
vehicle: { position: { latitude: 1, longitude: 2 }, currentStatus: 99 },
|
||||
})?.status,
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('the decode is bounded by entity count as well as by bytes', () => {
|
||||
const entities = [];
|
||||
for (let index = 0; index < GTFS_MAX_ENTITIES + 25; index += 1) {
|
||||
entities.push({
|
||||
entityId: `e${index}`,
|
||||
vehicleId: `v${index}`,
|
||||
lat: 42 + index * 1e-6,
|
||||
lon: -71,
|
||||
timestamp: 1_788_936_000,
|
||||
});
|
||||
}
|
||||
const decoded = decodeVehiclePositions(encodeFeed(entities));
|
||||
assert.equal(decoded.entityCount, GTFS_MAX_ENTITIES);
|
||||
assert.equal(decoded.truncated, true);
|
||||
assert.equal(decoded.vehicles.length, GTFS_MAX_ENTITIES);
|
||||
// A feed inside the cap is not flagged.
|
||||
assert.equal(
|
||||
decodeVehiclePositions(encodeFeed(entities.slice(0, 3))).truncated,
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('an implausibly long string drops the whole record rather than truncating an id', () => {
|
||||
const long = 'x'.repeat(GTFS_MAX_STRING_CHARS + 1);
|
||||
const decoded = decodeVehiclePositions(
|
||||
encodeFeed([
|
||||
{
|
||||
entityId: 'ok',
|
||||
vehicleId: 'good',
|
||||
lat: 42.1,
|
||||
lon: -71.1,
|
||||
timestamp: 1_788_936_000,
|
||||
},
|
||||
{
|
||||
entityId: 'bad',
|
||||
vehicleId: long,
|
||||
lat: 42.2,
|
||||
lon: -71.2,
|
||||
timestamp: 1_788_936_000,
|
||||
},
|
||||
{
|
||||
entityId: 'bad-route',
|
||||
vehicleId: 'v3',
|
||||
routeId: long,
|
||||
lat: 42.3,
|
||||
lon: -71.3,
|
||||
timestamp: 1_788_936_000,
|
||||
},
|
||||
]),
|
||||
);
|
||||
// The entity is still counted — the decoder had to walk past it — but it is
|
||||
// never rendered, because half an id is a different vehicle.
|
||||
assert.equal(decoded.entityCount, 3);
|
||||
assert.deepEqual(
|
||||
decoded.vehicles.map((vehicle) => vehicle.id),
|
||||
['good'],
|
||||
);
|
||||
// A string exactly at the cap is fine.
|
||||
const atCap = 'y'.repeat(GTFS_MAX_STRING_CHARS);
|
||||
const kept = decodeVehiclePositions(
|
||||
encodeFeed([
|
||||
{ entityId: 'e', vehicleId: atCap, lat: 42, lon: -71, timestamp: 1 },
|
||||
]),
|
||||
);
|
||||
assert.deepEqual(
|
||||
kept.vehicles.map((vehicle) => vehicle.id),
|
||||
[atCap],
|
||||
);
|
||||
});
|
||||
|
||||
test('incrementality is reported, not silently discarded', () => {
|
||||
assert.equal(decodeVehiclePositions(encodeFeed([])).incrementality, 0);
|
||||
const writer = new PbfWriter();
|
||||
embed(writer, 1, (header) => {
|
||||
header.writeStringField(1, '2.0');
|
||||
header.writeVarintField(2, 1);
|
||||
header.writeVarintField(3, 1_788_936_000);
|
||||
});
|
||||
assert.equal(decodeVehiclePositions(writer.finish()).incrementality, 1);
|
||||
});
|
||||
|
||||
test('the feed header version is bounded like every other string', () => {
|
||||
const writer = new PbfWriter();
|
||||
embed(writer, 1, (header) => {
|
||||
header.writeStringField(1, 'v'.repeat(GTFS_MAX_STRING_CHARS + 1));
|
||||
header.writeVarintField(2, 0);
|
||||
header.writeVarintField(3, 1_788_936_000);
|
||||
});
|
||||
const decoded = decodeVehiclePositions(writer.finish());
|
||||
// Refused, not carried: an unbounded header field is the one string a feed
|
||||
// can grow without the entity cap ever seeing it.
|
||||
assert.equal(decoded.version, null);
|
||||
assert.equal(decoded.timestamp, 1_788_936_000);
|
||||
});
|
||||
|
||||
test('entities past the cap are skipped whole rather than decoded and discarded', () => {
|
||||
const decoded = decodeVehiclePositions(
|
||||
encodeFeed(
|
||||
Array.from({ length: GTFS_MAX_ENTITIES + 5 }, (_, index) => ({
|
||||
entityId: `e${index}`,
|
||||
vehicleId: `v${index}`,
|
||||
lat: 42 + index * 1e-6,
|
||||
lon: -71,
|
||||
timestamp: 1_788_936_000,
|
||||
})),
|
||||
),
|
||||
);
|
||||
assert.equal(decoded.entityCount, GTFS_MAX_ENTITIES);
|
||||
assert.equal(decoded.truncated, true);
|
||||
// The skipped tail never becomes an object, so the last retained vehicle is
|
||||
// the one at the cap and nothing beyond it appears.
|
||||
assert.equal(
|
||||
decoded.vehicles.some((item) => item.id === `v${GTFS_MAX_ENTITIES}`),
|
||||
false,
|
||||
);
|
||||
});
|
||||
+150
-14
@@ -49,7 +49,12 @@ const _scratchWorldForward = new Cesium.Cartesian3();
|
||||
* unavailable (off-screen/behind camera/degenerate).
|
||||
* @returns {number|null} Rotation in radians, or `previous` when unknown.
|
||||
*/
|
||||
export function screenProjectedRotation(scene, position, courseDeg, previous = null) {
|
||||
export function screenProjectedRotation(
|
||||
scene,
|
||||
position,
|
||||
courseDeg,
|
||||
previous = null,
|
||||
) {
|
||||
const camera = scene?.camera;
|
||||
if (!camera?.rightWC || !camera?.upWC || !position) return previous;
|
||||
|
||||
@@ -58,20 +63,26 @@ export function screenProjectedRotation(scene, position, courseDeg, previous = n
|
||||
Math.sin(courseRad) * FORWARD_PROBE_M,
|
||||
Math.cos(courseRad) * FORWARD_PROBE_M,
|
||||
0,
|
||||
_scratchForward
|
||||
_scratchForward,
|
||||
);
|
||||
const enu = Cesium.Transforms.eastNorthUpToFixedFrame(
|
||||
position,
|
||||
Cesium.Ellipsoid.WGS84,
|
||||
_scratchEnu,
|
||||
);
|
||||
Cesium.Matrix4.multiplyByPointAsVector(
|
||||
enu,
|
||||
_scratchForward,
|
||||
_scratchWorldForward,
|
||||
);
|
||||
const enu = Cesium.Transforms.eastNorthUpToFixedFrame(position, Cesium.Ellipsoid.WGS84, _scratchEnu);
|
||||
Cesium.Matrix4.multiplyByPointAsVector(enu, _scratchForward, _scratchWorldForward);
|
||||
|
||||
// Screen x follows camera-right. Window y grows downward, the opposite of
|
||||
// camera-up. Projecting the vector itself avoids clipping/behind-camera
|
||||
// failure modes from the old forward-point worldToWindowCoordinates probe.
|
||||
const dx = Cesium.Cartesian3.dot(_scratchWorldForward, camera.rightWC);
|
||||
const dy = -Cesium.Cartesian3.dot(_scratchWorldForward, camera.upWC);
|
||||
if (
|
||||
(dx * dx + dy * dy)
|
||||
< MIN_SCREEN_COMPONENT_M * MIN_SCREEN_COMPONENT_M
|
||||
) return previous;
|
||||
if (dx * dx + dy * dy < MIN_SCREEN_COMPONENT_M * MIN_SCREEN_COMPONENT_M)
|
||||
return previous;
|
||||
|
||||
// Window y grows downward; rotation 0 = icon pointing screen-up.
|
||||
// Icon direction in window coords after CCW rotation r is (-sin r, -cos r),
|
||||
@@ -94,13 +105,20 @@ export function stabilizeScreenRotation(
|
||||
next,
|
||||
deadbandRad = ROTATION_DEADBAND_RAD,
|
||||
) {
|
||||
if (!Number.isFinite(next)) return Number.isFinite(previous) ? previous : null;
|
||||
if (!Number.isFinite(next))
|
||||
return Number.isFinite(previous) ? previous : null;
|
||||
if (!Number.isFinite(previous)) return next;
|
||||
const delta = Math.atan2(Math.sin(next - previous), Math.cos(next - previous));
|
||||
const delta = Math.atan2(
|
||||
Math.sin(next - previous),
|
||||
Math.cos(next - previous),
|
||||
);
|
||||
return Math.abs(delta) < Math.max(0, deadbandRad) ? previous : next;
|
||||
}
|
||||
|
||||
const _occluder = new Cesium.EllipsoidalOccluder(Cesium.Ellipsoid.WGS84, new Cesium.Cartesian3());
|
||||
const _occluder = new Cesium.EllipsoidalOccluder(
|
||||
Cesium.Ellipsoid.WGS84,
|
||||
new Cesium.Cartesian3(),
|
||||
);
|
||||
|
||||
/**
|
||||
* Half-width of the crossfade band centred on the ellipsoid silhouette, in
|
||||
@@ -169,7 +187,11 @@ const _wgs84OneOverRadii = Cesium.Ellipsoid.WGS84.oneOverRadii;
|
||||
* @param {number} [featherRad=HORIZON_FEATHER_RAD] Half-width of the blend band.
|
||||
* @returns {number} 0 (ground behind) … 1 (sky behind).
|
||||
*/
|
||||
export function skyBackdropFactor(cameraPosition, position, featherRad = HORIZON_FEATHER_RAD) {
|
||||
export function skyBackdropFactor(
|
||||
cameraPosition,
|
||||
position,
|
||||
featherRad = HORIZON_FEATHER_RAD,
|
||||
) {
|
||||
if (!cameraPosition || !position) return 0;
|
||||
|
||||
const s = _wgs84OneOverRadii;
|
||||
@@ -236,6 +258,120 @@ export function horizonOccluder(camera) {
|
||||
*/
|
||||
export function cameraPoseSignature(camera) {
|
||||
const p = camera.positionWC;
|
||||
return `${Math.round(p.x / 10)}:${Math.round(p.y / 10)}:${Math.round(p.z / 10)}:` +
|
||||
`${camera.heading.toFixed(3)}:${camera.pitch.toFixed(3)}:${camera.roll.toFixed(3)}`;
|
||||
return (
|
||||
`${Math.round(p.x / 10)}:${Math.round(p.y / 10)}:${Math.round(p.z / 10)}:` +
|
||||
`${camera.heading.toFixed(3)}:${camera.pitch.toFixed(3)}:${camera.roll.toFixed(3)}`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Meters ahead used by the exact projection below. Short on purpose: the point
|
||||
* of a perspective projection is that it is LOCAL to the contact, and a two
|
||||
* kilometre probe on a bus at six hundred metres would sample a part of the
|
||||
* frustum the vehicle is nowhere near.
|
||||
*/
|
||||
const NEAR_PROBE_M = 30;
|
||||
const _scratchProbe = new Cesium.Cartesian3();
|
||||
const _scratchWindowAt = new Cesium.Cartesian2();
|
||||
const _scratchWindowAhead = new Cesium.Cartesian2();
|
||||
|
||||
/**
|
||||
* Billboard rotation for a SURFACE contact, projected exactly — perspective
|
||||
* division included — instead of through the camera-basis approximation.
|
||||
*
|
||||
* `screenProjectedRotation` above deliberately projects the course vector onto
|
||||
* the camera's right/up basis, which is orthographic: exact at the viewport
|
||||
* centre and an approximation away from it. That is the right trade for
|
||||
* aircraft, where the alternative loses continuity through a tracked orbit and
|
||||
* the contacts are far enough away for the error to be small.
|
||||
*
|
||||
* It is the wrong trade for street-level vehicles. A probe with a real Cesium
|
||||
* camera at 600 m and −45° pitch, on a northbound contact 350 m right of
|
||||
* centre, put the orthographic answer at 0° where the true screen direction of
|
||||
* travel was 22.5°. At that pitch and that range the vehicles fill the frame
|
||||
* away from the centre, and every one of them points visibly wrong.
|
||||
*
|
||||
* So this projects two nearby world points through the actual view-projection
|
||||
* and reads the angle between them on screen. When either point cannot be
|
||||
* projected — behind the camera, off the frustum, degenerate — it falls back to
|
||||
* the camera-basis answer rather than inventing one.
|
||||
*
|
||||
* @param {Cesium.Scene} scene The scene (projection source).
|
||||
* @param {Cesium.Cartesian3} position Entity world position.
|
||||
* @param {number} courseDeg Course in degrees clockwise from north.
|
||||
* @param {number|null} previous Rotation to keep when projection is unavailable.
|
||||
* @returns {number|null} Rotation in radians, or the fallback.
|
||||
*/
|
||||
export function perspectiveProjectedRotation(
|
||||
scene,
|
||||
position,
|
||||
courseDeg,
|
||||
previous = null,
|
||||
) {
|
||||
if (!scene?.camera || !position || !Number.isFinite(courseDeg)) {
|
||||
return previous;
|
||||
}
|
||||
const courseRad = Cesium.Math.toRadians(courseDeg);
|
||||
Cesium.Cartesian3.fromElements(
|
||||
Math.sin(courseRad) * NEAR_PROBE_M,
|
||||
Math.cos(courseRad) * NEAR_PROBE_M,
|
||||
0,
|
||||
_scratchForward,
|
||||
);
|
||||
const enu = Cesium.Transforms.eastNorthUpToFixedFrame(
|
||||
position,
|
||||
Cesium.Ellipsoid.WGS84,
|
||||
_scratchEnu,
|
||||
);
|
||||
Cesium.Matrix4.multiplyByPointAsVector(
|
||||
enu,
|
||||
_scratchForward,
|
||||
_scratchWorldForward,
|
||||
);
|
||||
Cesium.Cartesian3.add(position, _scratchWorldForward, _scratchProbe);
|
||||
|
||||
const at = Cesium.SceneTransforms.worldToWindowCoordinates(
|
||||
scene,
|
||||
position,
|
||||
_scratchWindowAt,
|
||||
);
|
||||
const ahead = Cesium.SceneTransforms.worldToWindowCoordinates(
|
||||
scene,
|
||||
_scratchProbe,
|
||||
_scratchWindowAhead,
|
||||
);
|
||||
if (!at || !ahead) {
|
||||
return screenProjectedRotation(scene, position, courseDeg, previous);
|
||||
}
|
||||
const dx = ahead.x - at.x;
|
||||
const dy = ahead.y - at.y;
|
||||
const separation = Math.hypot(dx, dy);
|
||||
// Window y grows downward; rotation 0 = icon pointing screen-up.
|
||||
const exact = Math.atan2(-dx, -dy);
|
||||
if (separation >= PERSPECTIVE_BLEND_MAX_PX) return exact;
|
||||
// Below a pixel or so of separation the exact answer is dominated by
|
||||
// projection noise, and above it the camera-basis answer is the wrong one
|
||||
// by up to twenty-odd degrees. A hard cutoff between the two flipped a
|
||||
// sprite by 23° for a 20 m change of camera altitude; the two answers are
|
||||
// blended across the band instead, the short way round, so the rotation is
|
||||
// continuous through it.
|
||||
const basis = screenProjectedRotation(scene, position, courseDeg, previous);
|
||||
if (!Number.isFinite(basis)) return exact;
|
||||
if (separation <= PERSPECTIVE_BLEND_MIN_PX) return basis;
|
||||
const weight =
|
||||
(separation - PERSPECTIVE_BLEND_MIN_PX) /
|
||||
(PERSPECTIVE_BLEND_MAX_PX - PERSPECTIVE_BLEND_MIN_PX);
|
||||
const delta = Math.atan2(Math.sin(exact - basis), Math.cos(exact - basis));
|
||||
return basis + delta * weight;
|
||||
}
|
||||
|
||||
/**
|
||||
* Probe separations (px) between which the exact and camera-basis answers
|
||||
* are blended. At or below the floor the camera-basis answer is used alone;
|
||||
* at or above the ceiling the exact one.
|
||||
*/
|
||||
export const PERSPECTIVE_BLEND_MIN_PX = 0.5;
|
||||
export const PERSPECTIVE_BLEND_MAX_PX = 3;
|
||||
|
||||
/** Exported for tests: how far ahead the exact projection probes. */
|
||||
export const PERSPECTIVE_PROBE_M = NEAR_PROBE_M;
|
||||
@@ -47,8 +47,12 @@ test('off-center oblique contacts pin the documented perspective divergence', ()
|
||||
const right = new Cesium.Cartesian3(0, 1, 0);
|
||||
const up = new Cesium.Cartesian3(Math.sin(pitch), 0, Math.cos(pitch));
|
||||
const forward = new Cesium.Cartesian3(-Math.cos(pitch), 0, Math.sin(pitch));
|
||||
const basisRotation = screenProjectedRotation({ camera: { rightWC: right, upWC: up } },
|
||||
equatorPosition, 0, null);
|
||||
const basisRotation = screenProjectedRotation(
|
||||
{ camera: { rightWC: right, upWC: up } },
|
||||
equatorPosition,
|
||||
0,
|
||||
null,
|
||||
);
|
||||
|
||||
// Exact pinhole derivative for a contact 2 km right of center and 5 km in
|
||||
// front. North has a depth component under this oblique camera, so exact
|
||||
@@ -65,26 +69,29 @@ test('off-center oblique contacts pin the documented perspective divergence', ()
|
||||
const exactRotation = Math.atan2(-exactScreenX, exactScreenUp);
|
||||
|
||||
assert.ok(Math.abs(basisRotation) < 1e-12);
|
||||
assert.ok(Math.abs(wrappedDelta(exactRotation, basisRotation)) > Cesium.Math.toRadians(5));
|
||||
assert.ok(
|
||||
Math.abs(wrappedDelta(exactRotation, basisRotation)) >
|
||||
Cesium.Math.toRadians(5),
|
||||
);
|
||||
});
|
||||
|
||||
test('screen rotation holds sub-degree projection noise', () => {
|
||||
const previous = 1;
|
||||
assert.equal(
|
||||
stabilizeScreenRotation(previous, previous + (0.25 * Math.PI / 180)),
|
||||
stabilizeScreenRotation(previous, previous + (0.25 * Math.PI) / 180),
|
||||
previous,
|
||||
);
|
||||
});
|
||||
|
||||
test('screen rotation accepts deliberate camera-orbit movement', () => {
|
||||
const previous = 1;
|
||||
const next = previous + (2 * Math.PI / 180);
|
||||
const next = previous + (2 * Math.PI) / 180;
|
||||
assert.equal(stabilizeScreenRotation(previous, next), next);
|
||||
});
|
||||
|
||||
test('screen rotation compares across the wrapped angle boundary', () => {
|
||||
const previous = Math.PI - (0.1 * Math.PI / 180);
|
||||
const next = -Math.PI + (0.1 * Math.PI / 180);
|
||||
const previous = Math.PI - (0.1 * Math.PI) / 180;
|
||||
const next = -Math.PI + (0.1 * Math.PI) / 180;
|
||||
assert.equal(stabilizeScreenRotation(previous, next), previous);
|
||||
});
|
||||
|
||||
@@ -102,10 +109,12 @@ const cruiseCamera = Cesium.Cartesian3.fromDegrees(0, 0, CRUISE_M);
|
||||
* tangent condition is exact here and the expected answer is derived, not
|
||||
* guessed.
|
||||
*/
|
||||
const tangentLonDeg = Cesium.Math.toDegrees(Math.acos(
|
||||
Cesium.Ellipsoid.WGS84.maximumRadius
|
||||
/ (Cesium.Ellipsoid.WGS84.maximumRadius + CRUISE_M),
|
||||
));
|
||||
const tangentLonDeg = Cesium.Math.toDegrees(
|
||||
Math.acos(
|
||||
Cesium.Ellipsoid.WGS84.maximumRadius /
|
||||
(Cesium.Ellipsoid.WGS84.maximumRadius + CRUISE_M),
|
||||
),
|
||||
);
|
||||
|
||||
test('a contact above the silhouette is read against sky', () => {
|
||||
// Another aircraft at the same flight level 200 km ahead. The chord between
|
||||
@@ -124,7 +133,10 @@ test('a contact below the silhouette keeps ground behind it', () => {
|
||||
// the horizon, so the plate has real imagery to separate the text from.
|
||||
const below = Cesium.Cartesian3.fromDegrees(30_000 / 111_320, 0, 2_000);
|
||||
assert.equal(skyBackdropFactor(cruiseCamera, below), 0);
|
||||
assert.equal(skyBackdropFactor(cruiseCamera, Cesium.Cartesian3.fromDegrees(0.05, 0, 0)), 0);
|
||||
assert.equal(
|
||||
skyBackdropFactor(cruiseCamera, Cesium.Cartesian3.fromDegrees(0.05, 0, 0)),
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
test('the silhouette itself lands exactly mid-band', () => {
|
||||
@@ -144,10 +156,12 @@ test('the silhouette itself lands exactly mid-band', () => {
|
||||
function climbSweep(step = 500) {
|
||||
const samples = [];
|
||||
for (let height = 0; height <= 12_000; height += step) {
|
||||
samples.push(skyBackdropFactor(
|
||||
cruiseCamera,
|
||||
Cesium.Cartesian3.fromDegrees(100_000 / 111_320, 0, height),
|
||||
));
|
||||
samples.push(
|
||||
skyBackdropFactor(
|
||||
cruiseCamera,
|
||||
Cesium.Cartesian3.fromDegrees(100_000 / 111_320, 0, height),
|
||||
),
|
||||
);
|
||||
}
|
||||
return samples;
|
||||
}
|
||||
@@ -163,9 +177,16 @@ test('the band lerps monotonically and saturates on both sides', () => {
|
||||
);
|
||||
}
|
||||
assert.equal(samples[0], 0, 'well below the horizon is fully grounded');
|
||||
assert.equal(samples[samples.length - 1], 1, 'well above the horizon is fully sky');
|
||||
assert.equal(
|
||||
samples[samples.length - 1],
|
||||
1,
|
||||
'well above the horizon is fully sky',
|
||||
);
|
||||
const partial = samples.filter((value) => value > 0 && value < 1);
|
||||
assert.ok(partial.length >= 3, `expected a real blend band, saw ${partial.length} partial samples`);
|
||||
assert.ok(
|
||||
partial.length >= 3,
|
||||
`expected a real blend band, saw ${partial.length} partial samples`,
|
||||
);
|
||||
});
|
||||
|
||||
test('the band, not the geometry, is what smooths the crossing', () => {
|
||||
@@ -174,13 +195,19 @@ test('the band, not the geometry, is what smooths the crossing', () => {
|
||||
// if the two ever get conflated, only one of these two tests goes red.
|
||||
const hard = [];
|
||||
for (let height = 0; height <= 12_000; height += 500) {
|
||||
hard.push(skyBackdropFactor(
|
||||
cruiseCamera,
|
||||
Cesium.Cartesian3.fromDegrees(100_000 / 111_320, 0, height),
|
||||
0,
|
||||
));
|
||||
hard.push(
|
||||
skyBackdropFactor(
|
||||
cruiseCamera,
|
||||
Cesium.Cartesian3.fromDegrees(100_000 / 111_320, 0, height),
|
||||
0,
|
||||
),
|
||||
);
|
||||
}
|
||||
assert.deepEqual([...new Set(hard)], [0, 1], 'a zero band must produce only the two end states');
|
||||
assert.deepEqual(
|
||||
[...new Set(hard)],
|
||||
[0, 1],
|
||||
'a zero band must produce only the two end states',
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -200,12 +227,19 @@ test('below the ellipsoid, a contact against open sky still reads as sky', () =>
|
||||
// ~6.5° above eye level — the whole band is 1.09° per side, so this is not a
|
||||
// near call. Before the fix a sub-ellipsoid camera fell into the degenerate
|
||||
// guard and every one of these came back 0: a row of dark boxes on empty sky.
|
||||
const approach = Cesium.Cartesian3.fromDegrees(JFK_LON, JFK_LAT + 8_000 * DEG_PER_M_LAT, 900);
|
||||
const approach = Cesium.Cartesian3.fromDegrees(
|
||||
JFK_LON,
|
||||
JFK_LAT + 8_000 * DEG_PER_M_LAT,
|
||||
900,
|
||||
);
|
||||
assert.equal(skyBackdropFactor(jfkCockpit, approach), 1);
|
||||
// Straight overhead is the unambiguous case, from under the surface as much
|
||||
// as from cruise altitude.
|
||||
assert.equal(
|
||||
skyBackdropFactor(jfkCockpit, Cesium.Cartesian3.fromDegrees(JFK_LON, JFK_LAT, 10_000)),
|
||||
skyBackdropFactor(
|
||||
jfkCockpit,
|
||||
Cesium.Cartesian3.fromDegrees(JFK_LON, JFK_LAT, 10_000),
|
||||
),
|
||||
1,
|
||||
);
|
||||
});
|
||||
@@ -225,7 +259,11 @@ test('below the ellipsoid, a contact on the ramp still keeps its plate', () => {
|
||||
assert.equal(
|
||||
skyBackdropFactor(
|
||||
jfkCockpit,
|
||||
Cesium.Cartesian3.fromDegrees(JFK_LON, JFK_LAT + 400 * DEG_PER_M_LAT, JFK_RAMP_H - 5),
|
||||
Cesium.Cartesian3.fromDegrees(
|
||||
JFK_LON,
|
||||
JFK_LAT + 400 * DEG_PER_M_LAT,
|
||||
JFK_RAMP_H - 5,
|
||||
),
|
||||
),
|
||||
0,
|
||||
);
|
||||
@@ -246,14 +284,21 @@ test('at the surface the horizon is exactly eye level, geodetic not geocentric',
|
||||
new Cesium.Cartesian3(20_000, 0, 0),
|
||||
new Cesium.Cartesian3(),
|
||||
);
|
||||
const alongHorizontal = Cesium.Cartesian3.add(onSurface, east, new Cesium.Cartesian3());
|
||||
const alongHorizontal = Cesium.Cartesian3.add(
|
||||
onSurface,
|
||||
east,
|
||||
new Cesium.Cartesian3(),
|
||||
);
|
||||
assert.ok(
|
||||
Math.abs(skyBackdropFactor(onSurface, alongHorizontal) - 0.5) < 1e-5,
|
||||
`eye level must be the band centre, got ${skyBackdropFactor(onSurface, alongHorizontal)}`,
|
||||
);
|
||||
// The geocentric radial is NOT the answer: a contact perpendicular to it
|
||||
// lands measurably off centre, so this test cannot pass by accident.
|
||||
const radialUp = Cesium.Cartesian3.normalize(onSurface, new Cesium.Cartesian3());
|
||||
const radialUp = Cesium.Cartesian3.normalize(
|
||||
onSurface,
|
||||
new Cesium.Cartesian3(),
|
||||
);
|
||||
const perpToRadial = Cesium.Cartesian3.cross(
|
||||
radialUp,
|
||||
Cesium.Cartesian3.UNIT_Z,
|
||||
@@ -265,7 +310,11 @@ test('at the surface the horizon is exactly eye level, geodetic not geocentric',
|
||||
20_000,
|
||||
perpToRadial,
|
||||
);
|
||||
const alongRadialPlane = Cesium.Cartesian3.add(onSurface, perpToRadial, new Cesium.Cartesian3());
|
||||
const alongRadialPlane = Cesium.Cartesian3.add(
|
||||
onSurface,
|
||||
perpToRadial,
|
||||
new Cesium.Cartesian3(),
|
||||
);
|
||||
assert.ok(
|
||||
Math.abs(skyBackdropFactor(onSurface, alongRadialPlane) - 0.5) > 0.05,
|
||||
'a geocentric horizontal would be a different plane; the test must be able to tell',
|
||||
@@ -284,7 +333,9 @@ function surfaceCrossingSweep(from = -50, to = 50, step = 1) {
|
||||
const contact = Cesium.Cartesian3.fromDegrees(30_000 / 111_319.49, 0, 0);
|
||||
const samples = [];
|
||||
for (let height = from; height <= to; height += step) {
|
||||
samples.push(skyBackdropFactor(Cesium.Cartesian3.fromDegrees(0, 0, height), contact));
|
||||
samples.push(
|
||||
skyBackdropFactor(Cesium.Cartesian3.fromDegrees(0, 0, height), contact),
|
||||
);
|
||||
}
|
||||
return samples;
|
||||
}
|
||||
@@ -300,7 +351,10 @@ test('the answer stays continuous as the camera rises through the surface', () =
|
||||
for (let i = 1; i < samples.length; i++) {
|
||||
worst = Math.max(worst, Math.abs(samples[i] - samples[i - 1]));
|
||||
}
|
||||
assert.ok(worst < 0.05, `no step across the surface may pop; worst adjacent jump ${worst}`);
|
||||
assert.ok(
|
||||
worst < 0.05,
|
||||
`no step across the surface may pop; worst adjacent jump ${worst}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('the crossover has no seam at the surface itself', () => {
|
||||
@@ -308,9 +362,18 @@ test('the crossover has no seam at the surface itself', () => {
|
||||
// 1 cm is 5.6e-5 rad, a thousandth of the band, so any visible difference
|
||||
// here is a discontinuity in the code rather than in the geometry.
|
||||
const contact = Cesium.Cartesian3.fromDegrees(30_000 / 111_319.49, 0, 0);
|
||||
const under = skyBackdropFactor(Cesium.Cartesian3.fromDegrees(0, 0, -0.01), contact);
|
||||
const over = skyBackdropFactor(Cesium.Cartesian3.fromDegrees(0, 0, 0.01), contact);
|
||||
assert.ok(Math.abs(under - over) < 5e-3, `seam at the surface: ${under} vs ${over}`);
|
||||
const under = skyBackdropFactor(
|
||||
Cesium.Cartesian3.fromDegrees(0, 0, -0.01),
|
||||
contact,
|
||||
);
|
||||
const over = skyBackdropFactor(
|
||||
Cesium.Cartesian3.fromDegrees(0, 0, 0.01),
|
||||
contact,
|
||||
);
|
||||
assert.ok(
|
||||
Math.abs(under - over) < 5e-3,
|
||||
`seam at the surface: ${under} vs ${over}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('a sub-surface camera carries real information, not one frozen answer', () => {
|
||||
@@ -319,7 +382,10 @@ test('a sub-surface camera carries real information, not one frozen answer', ()
|
||||
// sub-surface camera heights must produce different answers.
|
||||
const belowOnly = surfaceCrossingSweep(-200, -1, 1);
|
||||
const spread = Math.max(...belowOnly) - Math.min(...belowOnly);
|
||||
assert.ok(spread > 0.05, `a sub-surface camera must still discriminate; spread ${spread}`);
|
||||
assert.ok(
|
||||
spread > 0.05,
|
||||
`a sub-surface camera must still discriminate; spread ${spread}`,
|
||||
);
|
||||
assert.ok(
|
||||
belowOnly.every((value) => value > 0 && value < 1),
|
||||
'this sweep is deliberately mid-band the whole way',
|
||||
@@ -334,7 +400,11 @@ test('a sub-surface camera carries real information, not one frozen answer', ()
|
||||
* a future edit to the shared lines could drift the cruise answer while every
|
||||
* sub-surface test stayed green.
|
||||
*/
|
||||
function legacyAboveSurfaceFactor(cameraPosition, position, featherRad = HORIZON_FEATHER_RAD) {
|
||||
function legacyAboveSurfaceFactor(
|
||||
cameraPosition,
|
||||
position,
|
||||
featherRad = HORIZON_FEATHER_RAD,
|
||||
) {
|
||||
const s = Cesium.Ellipsoid.WGS84.oneOverRadii;
|
||||
const cx = cameraPosition.x * s.x;
|
||||
const cy = cameraPosition.y * s.y;
|
||||
@@ -415,22 +485,39 @@ test('below the surface the backdrop test agrees with the occluder beside it', (
|
||||
const camera = Cesium.Cartesian3.fromDegrees(JFK_LON, JFK_LAT, -18);
|
||||
const occluder = horizonOccluder({ positionWC: camera });
|
||||
const enu = Cesium.Transforms.eastNorthUpToFixedFrame(camera);
|
||||
const offsetBy = (up) => Cesium.Cartesian3.add(
|
||||
camera,
|
||||
Cesium.Matrix4.multiplyByPointAsVector(
|
||||
enu,
|
||||
new Cesium.Cartesian3(0, 20_000, up),
|
||||
const offsetBy = (up) =>
|
||||
Cesium.Cartesian3.add(
|
||||
camera,
|
||||
Cesium.Matrix4.multiplyByPointAsVector(
|
||||
enu,
|
||||
new Cesium.Cartesian3(0, 20_000, up),
|
||||
new Cesium.Cartesian3(),
|
||||
),
|
||||
new Cesium.Cartesian3(),
|
||||
),
|
||||
new Cesium.Cartesian3(),
|
||||
);
|
||||
);
|
||||
// 20 km out and 600 m clear of eye level is well outside the 1.09° band.
|
||||
const aboveEye = offsetBy(600);
|
||||
const belowEye = offsetBy(-600);
|
||||
assert.equal(occluder.isPointVisible(aboveEye), true, 'the occluder keeps what is above eye level');
|
||||
assert.equal(skyBackdropFactor(camera, aboveEye), 1, 'and the backdrop test must call it sky');
|
||||
assert.equal(occluder.isPointVisible(belowEye), false, 'the occluder culls what is below it');
|
||||
assert.equal(skyBackdropFactor(camera, belowEye), 0, 'and the backdrop test must call it ground');
|
||||
assert.equal(
|
||||
occluder.isPointVisible(aboveEye),
|
||||
true,
|
||||
'the occluder keeps what is above eye level',
|
||||
);
|
||||
assert.equal(
|
||||
skyBackdropFactor(camera, aboveEye),
|
||||
1,
|
||||
'and the backdrop test must call it sky',
|
||||
);
|
||||
assert.equal(
|
||||
occluder.isPointVisible(belowEye),
|
||||
false,
|
||||
'the occluder culls what is below it',
|
||||
);
|
||||
assert.equal(
|
||||
skyBackdropFactor(camera, belowEye),
|
||||
0,
|
||||
'and the backdrop test must call it ground',
|
||||
);
|
||||
});
|
||||
|
||||
test('a degenerate camera or contact reports ground rather than throwing', () => {
|
||||
@@ -442,12 +529,24 @@ test('a degenerate camera or contact reports ground rather than throwing', () =>
|
||||
assert.equal(skyBackdropFactor(cruiseCamera, cruiseCamera), 0);
|
||||
// The planet's centre has no local vertical to measure against.
|
||||
assert.equal(skyBackdropFactor(Cesium.Cartesian3.ZERO, cruiseCamera), 0);
|
||||
assert.equal(skyBackdropFactor(new Cesium.Cartesian3(NaN, NaN, NaN), cruiseCamera), 0);
|
||||
assert.equal(skyBackdropFactor(cruiseCamera, new Cesium.Cartesian3(NaN, NaN, NaN)), 0);
|
||||
assert.equal(
|
||||
skyBackdropFactor(new Cesium.Cartesian3(NaN, NaN, NaN), cruiseCamera),
|
||||
0,
|
||||
);
|
||||
assert.equal(
|
||||
skyBackdropFactor(cruiseCamera, new Cesium.Cartesian3(NaN, NaN, NaN)),
|
||||
0,
|
||||
);
|
||||
// An infinite coordinate normalizes to NaN a few lines later, and a NaN plate
|
||||
// alpha reaches the canvas as an invisible label rather than a caught error.
|
||||
assert.equal(skyBackdropFactor(new Cesium.Cartesian3(Infinity, 0, 0), cruiseCamera), 0);
|
||||
assert.equal(skyBackdropFactor(cruiseCamera, new Cesium.Cartesian3(Infinity, 0, 0)), 0);
|
||||
assert.equal(
|
||||
skyBackdropFactor(new Cesium.Cartesian3(Infinity, 0, 0), cruiseCamera),
|
||||
0,
|
||||
);
|
||||
assert.equal(
|
||||
skyBackdropFactor(cruiseCamera, new Cesium.Cartesian3(Infinity, 0, 0)),
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
test('the feather band is a named constant in a sane screen-space range', () => {
|
||||
@@ -456,3 +555,193 @@ test('the feather band is a named constant in a sane screen-space range', () =>
|
||||
// band of arc-seconds would pop.
|
||||
assert.ok(HORIZON_FEATHER_RAD > 0.002 && HORIZON_FEATHER_RAD < 0.09);
|
||||
});
|
||||
|
||||
// --- Perspective projection through a real view-projection --------------------
|
||||
|
||||
import {
|
||||
PERSPECTIVE_BLEND_MAX_PX,
|
||||
PERSPECTIVE_BLEND_MIN_PX,
|
||||
PERSPECTIVE_PROBE_M,
|
||||
perspectiveProjectedRotation,
|
||||
} from './iconOrientation.js';
|
||||
|
||||
/**
|
||||
* A scene the exact projector accepts: 3D mode, a canvas, and a camera with a
|
||||
* real view matrix and perspective frustum. The camera sits `altitudeM` above
|
||||
* the origin and looks north, pitched down by `pitchDeg`, with world east as
|
||||
* screen right — the oblique street-level pose where the camera-basis answer
|
||||
* is wrong off-centre and the exact one is not.
|
||||
*/
|
||||
function obliqueScene(
|
||||
altitudeM,
|
||||
pitchDeg,
|
||||
{ width = 1280, height = 800 } = {},
|
||||
) {
|
||||
const position = Cesium.Cartesian3.fromDegrees(0, 0, altitudeM);
|
||||
const enu = Cesium.Transforms.eastNorthUpToFixedFrame(position);
|
||||
const east = Cesium.Matrix4.getColumn(enu, 0, new Cesium.Cartesian3());
|
||||
const north = Cesium.Matrix4.getColumn(enu, 1, new Cesium.Cartesian3());
|
||||
const up = Cesium.Matrix4.getColumn(enu, 2, new Cesium.Cartesian3());
|
||||
const pitch = Cesium.Math.toRadians(pitchDeg);
|
||||
const direction = Cesium.Cartesian3.normalize(
|
||||
Cesium.Cartesian3.add(
|
||||
Cesium.Cartesian3.multiplyByScalar(
|
||||
north,
|
||||
Math.cos(pitch),
|
||||
new Cesium.Cartesian3(),
|
||||
),
|
||||
Cesium.Cartesian3.multiplyByScalar(
|
||||
up,
|
||||
-Math.sin(pitch),
|
||||
new Cesium.Cartesian3(),
|
||||
),
|
||||
new Cesium.Cartesian3(),
|
||||
),
|
||||
new Cesium.Cartesian3(),
|
||||
);
|
||||
const cameraUp = Cesium.Cartesian3.normalize(
|
||||
Cesium.Cartesian3.add(
|
||||
Cesium.Cartesian3.multiplyByScalar(
|
||||
north,
|
||||
Math.sin(pitch),
|
||||
new Cesium.Cartesian3(),
|
||||
),
|
||||
Cesium.Cartesian3.multiplyByScalar(
|
||||
up,
|
||||
Math.cos(pitch),
|
||||
new Cesium.Cartesian3(),
|
||||
),
|
||||
new Cesium.Cartesian3(),
|
||||
),
|
||||
new Cesium.Cartesian3(),
|
||||
);
|
||||
const viewMatrix = Cesium.Matrix4.computeView(
|
||||
position,
|
||||
direction,
|
||||
cameraUp,
|
||||
east,
|
||||
new Cesium.Matrix4(),
|
||||
);
|
||||
const frustum = new Cesium.PerspectiveFrustum({
|
||||
fov: Cesium.Math.toRadians(60),
|
||||
aspectRatio: width / height,
|
||||
near: 1,
|
||||
far: 1e7,
|
||||
});
|
||||
return {
|
||||
frameState: { mode: Cesium.SceneMode.SCENE3D },
|
||||
canvas: { clientWidth: width, clientHeight: height },
|
||||
camera: {
|
||||
viewMatrix,
|
||||
frustum,
|
||||
rightWC: east,
|
||||
upWC: cameraUp,
|
||||
positionWC: position,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Probe separation (px) the helper sees for a contact and course in a scene. */
|
||||
function probeSeparation(scene, contact, courseDeg) {
|
||||
const courseRad = Cesium.Math.toRadians(courseDeg);
|
||||
const enu = Cesium.Transforms.eastNorthUpToFixedFrame(contact);
|
||||
const forward = Cesium.Matrix4.multiplyByPointAsVector(
|
||||
enu,
|
||||
new Cesium.Cartesian3(
|
||||
Math.sin(courseRad) * PERSPECTIVE_PROBE_M,
|
||||
Math.cos(courseRad) * PERSPECTIVE_PROBE_M,
|
||||
0,
|
||||
),
|
||||
new Cesium.Cartesian3(),
|
||||
);
|
||||
const probe = Cesium.Cartesian3.add(
|
||||
contact,
|
||||
forward,
|
||||
new Cesium.Cartesian3(),
|
||||
);
|
||||
const at = Cesium.SceneTransforms.worldToWindowCoordinates(scene, contact);
|
||||
const ahead = Cesium.SceneTransforms.worldToWindowCoordinates(scene, probe);
|
||||
const dx = ahead.x - at.x;
|
||||
const dy = ahead.y - at.y;
|
||||
return { separation: Math.hypot(dx, dy), exact: Math.atan2(-dx, -dy) };
|
||||
}
|
||||
|
||||
test('the perspective helper is continuous through the sub-pixel band, not a 23° flip', () => {
|
||||
// Codex's reproduction: a contact near the right edge of an oblique view,
|
||||
// the camera altitude walked so the probe separation crosses half a pixel.
|
||||
// The rotation jumped 23° → 0° between two adjacent altitudes. The exact
|
||||
// and camera-basis answers now blend across a band, the short way round.
|
||||
//
|
||||
// The contact keeps the SAME screen position across the walk — its ground
|
||||
// offsets scale with the altitude — so the two answers disagree by the
|
||||
// same twenty-odd degrees at every altitude while the separation alone
|
||||
// walks from well above the band to well below it.
|
||||
const course = 0; // northbound
|
||||
const contactFor = (altitude) =>
|
||||
Cesium.Cartesian3.fromDegrees(
|
||||
(0.7 * altitude) / 111_320,
|
||||
(1.2 * altitude) / 111_320,
|
||||
0,
|
||||
);
|
||||
const samples = [];
|
||||
// Geometric steps: separation scales with altitude, so equal ratios give
|
||||
// equal-sized steps in the quantity the band is defined on.
|
||||
for (let altitude = 500; altitude <= 600_000; altitude *= 1.02) {
|
||||
const scene = obliqueScene(altitude, 45);
|
||||
const contact = contactFor(altitude);
|
||||
if (!Cesium.SceneTransforms.worldToWindowCoordinates(scene, contact))
|
||||
continue;
|
||||
const { separation, exact } = probeSeparation(scene, contact, course);
|
||||
samples.push({
|
||||
altitude,
|
||||
separation,
|
||||
exact,
|
||||
rotation: perspectiveProjectedRotation(scene, contact, course, null),
|
||||
basis: screenProjectedRotation(scene, contact, course, null),
|
||||
});
|
||||
}
|
||||
const inBand = samples.filter(
|
||||
(s) =>
|
||||
s.separation > PERSPECTIVE_BLEND_MIN_PX &&
|
||||
s.separation < PERSPECTIVE_BLEND_MAX_PX,
|
||||
);
|
||||
const above = samples.filter((s) => s.separation >= PERSPECTIVE_BLEND_MAX_PX);
|
||||
const below = samples.filter((s) => s.separation <= PERSPECTIVE_BLEND_MIN_PX);
|
||||
assert.ok(
|
||||
inBand.length >= 5 && above.length >= 5 && below.length >= 5,
|
||||
`the walk crosses the whole band (${below.length}/${inBand.length}/${above.length})`,
|
||||
);
|
||||
|
||||
// Off-centre and oblique, the two answers genuinely disagree — that is the
|
||||
// divergence the exact projection exists to fix.
|
||||
const disagreement = Math.abs(
|
||||
wrappedDelta(above[0].rotation, above[0].basis),
|
||||
);
|
||||
assert.ok(
|
||||
disagreement > Cesium.Math.toRadians(5),
|
||||
`exact and camera-basis differ by ${Cesium.Math.toDegrees(disagreement).toFixed(1)}° above the band`,
|
||||
);
|
||||
|
||||
let worstStep = 0;
|
||||
for (let i = 1; i < samples.length; i += 1) {
|
||||
worstStep = Math.max(
|
||||
worstStep,
|
||||
Math.abs(wrappedDelta(samples[i].rotation, samples[i - 1].rotation)),
|
||||
);
|
||||
}
|
||||
assert.ok(
|
||||
worstStep < Cesium.Math.toRadians(3),
|
||||
`no step between neighbouring altitudes exceeds 3° (worst ${Cesium.Math.toDegrees(worstStep).toFixed(2)}°)`,
|
||||
);
|
||||
// Below the band it is the camera-basis answer; above it the exact one, unblended.
|
||||
for (const s of below)
|
||||
assert.ok(
|
||||
Math.abs(wrappedDelta(s.rotation, s.basis)) < 1e-6,
|
||||
`basis alone at ${s.separation.toFixed(2)} px`,
|
||||
);
|
||||
for (const s of above)
|
||||
assert.ok(
|
||||
Math.abs(wrappedDelta(s.rotation, s.exact)) < 1e-6,
|
||||
`exact alone at ${s.separation.toFixed(2)} px`,
|
||||
);
|
||||
});
|
||||
@@ -293,6 +293,7 @@ export const LAYER_STATE_REGISTRY = Object.freeze([
|
||||
Object.freeze({ id: 'satellites', token: 's', disposition: 'enabled+options', optionOwner: 'satellites' }),
|
||||
Object.freeze({ id: 'telegeography-submarine-cables', token: 'u', disposition: 'enabled-only' }),
|
||||
Object.freeze({ id: 'traffic', token: 't', disposition: 'enabled-only' }),
|
||||
Object.freeze({ id: 'transit', token: 'j', disposition: 'enabled-only' }),
|
||||
]);
|
||||
|
||||
export const REGISTERED_LAYER_IDS = Object.freeze(LAYER_STATE_REGISTRY.map((entry) => entry.id));
|
||||
|
||||
@@ -158,8 +158,8 @@ function encode(state) {
|
||||
|
||||
test('production registry is exact, canonical, and rejects incomplete contracts', async () => {
|
||||
assert.equal(validateLayerStateRegistry(), true);
|
||||
assert.equal(REGISTERED_LAYER_IDS.length, 18);
|
||||
assert.equal(new Set(REGISTERED_LAYER_IDS).size, 18);
|
||||
assert.equal(REGISTERED_LAYER_IDS.length, 19);
|
||||
assert.equal(new Set(REGISTERED_LAYER_IDS).size, 19);
|
||||
assert.deepEqual(REGISTERED_LAYER_IDS, [...REGISTERED_LAYER_IDS].sort());
|
||||
assert.throws(
|
||||
() => validateLayerStateRegistry([...LAYER_STATE_REGISTRY, LAYER_STATE_REGISTRY[0]]),
|
||||
|
||||
@@ -3,6 +3,8 @@ export const SPRITE_LAYER_ORDER = Object.freeze([
|
||||
'cctv',
|
||||
'firms',
|
||||
'bikeshare',
|
||||
'transit',
|
||||
'transit-motion',
|
||||
'directions',
|
||||
'ais',
|
||||
'military',
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { createTransitLayer } from '../layers/transit/index.js';
|
||||
import * as render from '../renderGovernor.js';
|
||||
import * as sprites from './spriteOrder.js';
|
||||
import * as picking from './pickRegistry.js';
|
||||
import * as overlays from '../overlays/worldOverlay.js';
|
||||
import { registerDynamicCredit, transitFeedCredit } from './dataCredits.js';
|
||||
import {
|
||||
GROUND_FLOOR_LIFT_M,
|
||||
cachedGroundFloor,
|
||||
coarseFloorCoord,
|
||||
neighborFloorM,
|
||||
warmGroundFloor,
|
||||
} from './groundFloor.js';
|
||||
import { sampleMeshFloorCells } from './meshFloorSampler.js';
|
||||
|
||||
const layer = createTransitLayer({
|
||||
services: {
|
||||
render,
|
||||
sprites,
|
||||
picking,
|
||||
overlays,
|
||||
credits: { registerDynamicCredit, transitFeedCredit },
|
||||
ground: {
|
||||
GROUND_FLOOR_LIFT_M,
|
||||
cachedGroundFloor,
|
||||
coarseFloorCoord,
|
||||
neighborFloorM,
|
||||
warmGroundFloor,
|
||||
},
|
||||
// The legacy sampler over the same legacy floor, so this standalone
|
||||
// module warms its own mesh floors the way the application layer does.
|
||||
mesh: { sampleMeshFloorCells },
|
||||
},
|
||||
});
|
||||
|
||||
export const _setTransitOverlayHostForTest =
|
||||
layer._setTransitOverlayHostForTest;
|
||||
export const _transitStateForTest = layer._transitStateForTest;
|
||||
export {
|
||||
TRANSIT_MODE_COLORS,
|
||||
TRANSIT_POLL_MS,
|
||||
TRANSIT_SELECTED_OVERLAY_SOURCE_ID,
|
||||
TRANSIT_SELECTED_OVERLAY_SOURCE_OPTIONS,
|
||||
aggregateTransitFeedHealth,
|
||||
buildTransitSelectionCopy,
|
||||
createTransitSelectedOverlayEntry,
|
||||
interpolatedVehiclePosition,
|
||||
isStaleVehicleFix,
|
||||
transitDetectionClass,
|
||||
transitDetectionId,
|
||||
transitDetectionMetric,
|
||||
transitVehicleKey,
|
||||
vehicleFixAgeMs,
|
||||
createTransitLayer,
|
||||
} from '../layers/transit/index.js';
|
||||
export default layer;
|
||||
@@ -0,0 +1,225 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import transitLayer, {
|
||||
TRANSIT_MODE_COLORS,
|
||||
TRANSIT_POLL_MS,
|
||||
TRANSIT_SELECTED_OVERLAY_SOURCE_OPTIONS,
|
||||
buildTransitSelectionCopy,
|
||||
createTransitSelectedOverlayEntry,
|
||||
interpolatedVehiclePosition,
|
||||
isStaleVehicleFix,
|
||||
transitVehicleKey,
|
||||
} from './transit.js';
|
||||
import { TRANSIT_MODES, getTransitFeed } from './transitFeeds.js';
|
||||
import { LAYER_STATE_REGISTRY } from './layerState.js';
|
||||
import { SPRITE_LAYER_ORDER } from './spriteOrder.js';
|
||||
|
||||
test('layer module declares the manager contract', () => {
|
||||
assert.equal(transitLayer.id, 'transit');
|
||||
assert.equal(typeof transitLayer.name, 'string');
|
||||
assert.equal(typeof transitLayer.icon, 'string');
|
||||
assert.equal(transitLayer.updateInterval, TRANSIT_POLL_MS);
|
||||
for (const method of [
|
||||
'init',
|
||||
'enable',
|
||||
'disable',
|
||||
'update',
|
||||
'getStats',
|
||||
'destroy',
|
||||
]) {
|
||||
assert.equal(
|
||||
typeof transitLayer[method],
|
||||
'function',
|
||||
`${method} is implemented`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('the layer is registered for share links and sprite stacking', () => {
|
||||
const entry = LAYER_STATE_REGISTRY.find((row) => row.id === 'transit');
|
||||
assert.ok(entry, 'transit has a share-link token');
|
||||
assert.equal(entry.disposition, 'enabled-only');
|
||||
assert.equal(
|
||||
LAYER_STATE_REGISTRY.filter((row) => row.token === entry.token).length,
|
||||
1,
|
||||
'token is unique',
|
||||
);
|
||||
const index = SPRITE_LAYER_ORDER.indexOf('transit');
|
||||
assert.ok(
|
||||
index > SPRITE_LAYER_ORDER.indexOf('bikeshare'),
|
||||
'vehicles draw above bikeshare stations',
|
||||
);
|
||||
assert.ok(
|
||||
index < SPRITE_LAYER_ORDER.indexOf('flights'),
|
||||
'aircraft stay on top',
|
||||
);
|
||||
});
|
||||
|
||||
test('every transit mode has a colour and the selected card uses it as accent', () => {
|
||||
for (const mode of TRANSIT_MODES) {
|
||||
assert.match(
|
||||
TRANSIT_MODE_COLORS[mode],
|
||||
/^#[0-9a-f]{6}$/i,
|
||||
`${mode} has a colour`,
|
||||
);
|
||||
}
|
||||
const position = Cesium.Cartesian3.fromDegrees(-71.06, 42.36, 3);
|
||||
const card = createTransitSelectedOverlayEntry(
|
||||
'mbta:1',
|
||||
position,
|
||||
{ title: 'T', details: ['d'] },
|
||||
'subway',
|
||||
);
|
||||
assert.equal(card.accent, TRANSIT_MODE_COLORS.subway);
|
||||
assert.equal(card.selected, true);
|
||||
assert.equal(card.protected, true);
|
||||
assert.equal(card.position, position);
|
||||
assert.equal(
|
||||
createTransitSelectedOverlayEntry(
|
||||
'',
|
||||
position,
|
||||
{ title: 'T', details: [] },
|
||||
'bus',
|
||||
),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
createTransitSelectedOverlayEntry(
|
||||
'k',
|
||||
null,
|
||||
{ title: 'T', details: [] },
|
||||
'bus',
|
||||
),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
TRANSIT_SELECTED_OVERLAY_SOURCE_OPTIONS.moving,
|
||||
true,
|
||||
'the card follows a moving vehicle',
|
||||
);
|
||||
});
|
||||
|
||||
test('position queries read the published display sample', () => {
|
||||
const entry = { track: {}, sample: { lat: 1, lon: 2, phase: 'playing' } };
|
||||
assert.deepEqual(interpolatedVehiclePosition(entry), {
|
||||
lat: 1,
|
||||
lon: 2,
|
||||
settled: false,
|
||||
});
|
||||
entry.sample.phase = 'held';
|
||||
assert.equal(interpolatedVehiclePosition(entry).settled, true);
|
||||
});
|
||||
test('fixes older than ten minutes are stale; feeds without timestamps are trusted', () => {
|
||||
const now = 1_700_000_000_000;
|
||||
assert.equal(isStaleVehicleFix({ timestamp: now / 1000 - 30 }, now), false);
|
||||
assert.equal(isStaleVehicleFix({ timestamp: now / 1000 - 601 }, now), true);
|
||||
assert.equal(isStaleVehicleFix({ timestamp: null }, now), false);
|
||||
assert.equal(isStaleVehicleFix({}, now), false);
|
||||
});
|
||||
|
||||
test('selection copy reads like a transit card and never leaks nulls', () => {
|
||||
const feed = getTransitFeed('metrotransit-msp');
|
||||
const now = 1_788_936_960_000;
|
||||
const full = buildTransitSelectionCopy(
|
||||
feed,
|
||||
{
|
||||
id: '1557',
|
||||
label: '1557',
|
||||
routeId: '17',
|
||||
lat: 44.9,
|
||||
lon: -93.4,
|
||||
bearing: 248,
|
||||
speedMps: 11.2,
|
||||
timestamp: 1_788_936_945,
|
||||
stopId: '57458',
|
||||
status: 'STOPPED_AT',
|
||||
occupancy: 'FEW_SEATS_AVAILABLE',
|
||||
timestampSource: 'vehicle',
|
||||
},
|
||||
'bus',
|
||||
now,
|
||||
);
|
||||
assert.equal(full.title, '🚌 Route 17');
|
||||
assert.deepEqual(full.details, [
|
||||
// The mode is said in words: a glyph and a route number do not tell a
|
||||
// reader whether the dot on the street is a bus or a subway train.
|
||||
'Bus · Metro Transit · Minneapolis–St Paul, MN',
|
||||
'Reported 15 s ago',
|
||||
// The operator's speed is labelled as a report: it describes the newest
|
||||
// fix, not the older segment the screen is drawing.
|
||||
'reported 40 km/h · reported hdg 248°',
|
||||
// The operator's claim is kept as a REPORT, not as the live state. It
|
||||
// describes the moment of the report, which the display may not have
|
||||
// reached yet — presenting it as "now" is what put STOPPED on a metro
|
||||
// gliding across the owner's screen.
|
||||
'Last report: stopped at stop 57458 · few seats available',
|
||||
'Vehicle 1557',
|
||||
]);
|
||||
// A time the feed gave for the SNAPSHOT rather than for this vehicle is
|
||||
// labelled, so the card never presents a borrowed time as the bus's own.
|
||||
const borrowed = buildTransitSelectionCopy(
|
||||
feed,
|
||||
{
|
||||
id: '9',
|
||||
lat: 1,
|
||||
lon: 1,
|
||||
timestamp: 1_788_936_900,
|
||||
timestampSource: 'header',
|
||||
},
|
||||
'bus',
|
||||
now,
|
||||
);
|
||||
assert.equal(borrowed.details[1], 'Reported 60 s ago (feed time)');
|
||||
|
||||
// A subway says where it is drawn, because a train on a road is otherwise
|
||||
// read as a bug rather than as a deliberate surface projection.
|
||||
const underground = buildTransitSelectionCopy(
|
||||
feed,
|
||||
{
|
||||
id: 'T1',
|
||||
routeId: 'Blue',
|
||||
lat: 44.9,
|
||||
lon: -93.4,
|
||||
timestamp: 1_788_936_945,
|
||||
},
|
||||
'subway',
|
||||
now,
|
||||
);
|
||||
assert.deepEqual(underground.details, [
|
||||
'Subway · Metro Transit · Minneapolis–St Paul, MN',
|
||||
'Reported 15 s ago',
|
||||
'Shown at street level · depth not in the feed',
|
||||
'Vehicle T1',
|
||||
]);
|
||||
// No timestamp at all is aged from the fetch, never from "now".
|
||||
const sparse = buildTransitSelectionCopy(
|
||||
feed,
|
||||
{ id: 'abc', lat: 1, lon: 1 },
|
||||
'rail',
|
||||
now,
|
||||
now - 120_000,
|
||||
);
|
||||
assert.equal(sparse.title, '🚆 Vehicle abc');
|
||||
assert.deepEqual(sparse.details, [
|
||||
'Train · Metro Transit · Minneapolis–St Paul, MN',
|
||||
'Reported 2 min ago (feed time)',
|
||||
'Vehicle abc',
|
||||
]);
|
||||
for (const line of [...full.details, ...sparse.details])
|
||||
assert.doesNotMatch(line, /null|undefined|NaN/);
|
||||
assert.equal(transitVehicleKey('mbta', '17'), 'mbta:17');
|
||||
});
|
||||
|
||||
test('the layer accepts a manager handle for out-of-tick panel repaints', () => {
|
||||
assert.equal(typeof transitLayer.attachDataManager, 'function');
|
||||
transitLayer.attachDataManager({ refreshLayerStats() {} });
|
||||
transitLayer.attachDataManager(null);
|
||||
});
|
||||
|
||||
test('stats before enable are an honest zero, not a fake feed state', () => {
|
||||
const stats = transitLayer.getStats();
|
||||
assert.equal(stats.count, 0);
|
||||
assert.equal(stats.source, 'GTFS-RT');
|
||||
assert.equal(stats.error, null);
|
||||
});
|
||||
@@ -0,0 +1,404 @@
|
||||
/**
|
||||
* @module transitFeeds
|
||||
* @description Registry of keyless, openly licensed GTFS-Realtime
|
||||
* VehiclePositions feeds the Transit layer can show.
|
||||
*
|
||||
* Every entry here is a URL the SERVER fetches — the browser only ever asks
|
||||
* `/api/transit/vehicles/<id>` for a registered id (see SECURITY.md: proxies
|
||||
* never fetch client-supplied URLs). Adding a feed means adding a row here,
|
||||
* a DATA_SOURCES.md row with its license, and a credit in dataCredits.js.
|
||||
*
|
||||
* Admission rules for a feed:
|
||||
* - No key, token, or registration required (identify-yourself headers are
|
||||
* fine — Entur asks for `ET-Client-Name`, OVapi for a User-Agent).
|
||||
* - An open license that permits display with attribution.
|
||||
* - Real coordinates in VehiclePosition.position — NYCT subway, for example,
|
||||
* publishes stop-relative positions only and is deliberately absent.
|
||||
*
|
||||
* `defaultEnabled` is separate from being registered. A feed ships switched ON
|
||||
* only when its operator's own published terms were READ and plainly cover this
|
||||
* use. A registered feed with `defaultEnabled: false` is never polled and never
|
||||
* offered to the browser; it is here so the decision is visible and reversible
|
||||
* in one line. `terms` records the passage that decision rests on, so the next
|
||||
* reader does not have to take the licence string on faith.
|
||||
*
|
||||
* Pure data + pure helpers: imported by the browser layer, the Vite proxy, and
|
||||
* node:test. No Cesium, no Node built-ins.
|
||||
*/
|
||||
|
||||
/** Transit modes the layer colors. `routeMode` hints refine a feed's default. */
|
||||
export const TRANSIT_MODES = Object.freeze([
|
||||
'bus',
|
||||
'tram',
|
||||
'subway',
|
||||
'rail',
|
||||
'ferry',
|
||||
'unknown',
|
||||
]);
|
||||
|
||||
/** Per-mode icon used in labels and the selection card. */
|
||||
export const TRANSIT_MODE_ICON = Object.freeze({
|
||||
bus: '🚌',
|
||||
tram: '🚊',
|
||||
subway: '🚇',
|
||||
rail: '🚆',
|
||||
ferry: '⛴️',
|
||||
unknown: '🚏',
|
||||
});
|
||||
|
||||
/**
|
||||
* MBTA route ids are human-readable and mode-typed: rapid-transit lines carry
|
||||
* colour names, commuter rail is prefixed `CR-`, ferries `Boat-`, buses are
|
||||
* numeric (or `SL`/`CT` express families).
|
||||
* @param {string|null} routeId
|
||||
* @returns {string}
|
||||
*/
|
||||
function mbtaRouteMode(routeId) {
|
||||
if (!routeId) return 'unknown';
|
||||
if (/^(Red|Orange|Blue)\b/.test(routeId)) return 'subway';
|
||||
if (/^(Green|Mattapan)/.test(routeId)) return 'tram';
|
||||
if (/^CR-/.test(routeId)) return 'rail';
|
||||
if (/^Boat-/.test(routeId)) return 'ferry';
|
||||
if (/^Shuttle/i.test(routeId)) return 'bus';
|
||||
return 'bus';
|
||||
}
|
||||
|
||||
/**
|
||||
* Entur (Norway) route ids are `<codespace>:Line:<local-id>`; the codespace
|
||||
* tells the operator, not the mode, but a few are single-mode operators.
|
||||
* @param {string|null} routeId
|
||||
* @returns {string}
|
||||
*/
|
||||
function enturRouteMode(routeId) {
|
||||
if (!routeId) return 'unknown';
|
||||
const codespace = routeId.split(':')[0];
|
||||
if (
|
||||
codespace === 'VYG' ||
|
||||
codespace === 'GJB' ||
|
||||
codespace === 'SJN' ||
|
||||
codespace === 'FLT' ||
|
||||
codespace === 'GOA' ||
|
||||
codespace === 'NSB' ||
|
||||
codespace === 'VYT'
|
||||
)
|
||||
return 'rail';
|
||||
if (codespace === 'FLB') return 'rail';
|
||||
return 'bus';
|
||||
}
|
||||
|
||||
/**
|
||||
* HSL route ids start with a four-digit code whose first digit is the mode
|
||||
* family in HSL's numbering: 1xxx/2xxx… are trams (1001–1010) for 4-digit ids
|
||||
* beginning with `10`, metro routes are `31M…`, ferries `1019`.
|
||||
* @param {string|null} routeId
|
||||
* @returns {string}
|
||||
*/
|
||||
function hslRouteMode(routeId) {
|
||||
if (!routeId) return 'unknown';
|
||||
if (/^31M/.test(routeId)) return 'subway';
|
||||
if (/^10(0[1-9]|10|15)/.test(routeId)) return 'tram';
|
||||
if (/^1019/.test(routeId)) return 'ferry';
|
||||
if (/^300[0-9A-Z]/.test(routeId)) return 'rail';
|
||||
return 'bus';
|
||||
}
|
||||
|
||||
/**
|
||||
* Metro Transit (Minneapolis–St Paul): light rail is the Blue/Green line
|
||||
* (route ids 901/902), Northstar commuter rail is 888.
|
||||
* @param {string|null} routeId
|
||||
* @returns {string}
|
||||
*/
|
||||
function metroTransitRouteMode(routeId) {
|
||||
if (!routeId) return 'unknown';
|
||||
if (routeId === '901' || routeId === '902') return 'tram';
|
||||
if (routeId === '888') return 'rail';
|
||||
return 'bus';
|
||||
}
|
||||
|
||||
/**
|
||||
* Registry of feeds. Order is presentation order in the stats/credit text.
|
||||
* `loadRadiusKm` is the distance from `center` inside which the feed is polled.
|
||||
* @type {ReadonlyArray<Readonly<{
|
||||
* id: string, name: string, operator: string, region: string,
|
||||
* center: {lat: number, lon: number}, loadRadiusKm: number,
|
||||
* url: string, headers?: Record<string, string>,
|
||||
* license: string, licenseUrl: string, attribution: string,
|
||||
* defaultMode: string, routeMode?: (routeId: string|null) => string,
|
||||
* }>>}
|
||||
*/
|
||||
export const TRANSIT_FEED_REGISTRY = Object.freeze([
|
||||
Object.freeze({
|
||||
id: 'mbta',
|
||||
historyRetention: true,
|
||||
name: 'MBTA',
|
||||
operator: 'Massachusetts Bay Transportation Authority',
|
||||
region: 'Boston, MA',
|
||||
center: Object.freeze({ lat: 42.3601, lon: -71.0589 }),
|
||||
loadRadiusKm: 70,
|
||||
url: 'https://cdn.mbta.com/realtime/VehiclePositions.pb',
|
||||
license: 'MassDOT Developers License Agreement',
|
||||
licenseUrl:
|
||||
'https://cdn.mbta.com/sites/default/files/2023-08/mbta-massdot-develop-license-agreement.pdf',
|
||||
attribution: 'MBTA / MassDOT',
|
||||
defaultEnabled: true,
|
||||
terms: Object.freeze({
|
||||
quote:
|
||||
'MassDOT ... hereby grants You (Licensee) non-exclusive, limited, and revocable rights to use, reproduce, and redistribute the Data. ... Clearly acknowledge MassDOT as the provider of the Data.',
|
||||
note: 'Credit is text only: the agreement forbids using MBTA/MassDOT logos or trademarks with the data.',
|
||||
}),
|
||||
defaultMode: 'bus',
|
||||
routeMode: mbtaRouteMode,
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'capmetro-austin',
|
||||
name: 'CapMetro',
|
||||
operator: 'Capital Metropolitan Transportation Authority',
|
||||
region: 'Austin, TX',
|
||||
center: Object.freeze({ lat: 30.2672, lon: -97.7431 }),
|
||||
loadRadiusKm: 60,
|
||||
url: 'https://data.texas.gov/download/eiei-9rpf/application%2Foctet-stream',
|
||||
license: 'CapMetro Developer Tools license',
|
||||
licenseUrl: 'https://www.capmetro.org/developertools',
|
||||
attribution:
|
||||
'Capital Metropolitan Transportation Authority — data.texas.gov',
|
||||
defaultEnabled: true,
|
||||
terms: Object.freeze({
|
||||
quote:
|
||||
'Capital Metropolitan Transportation Authority (CMTA) hereby grants you (Licensee) non-exclusive, limited and revocable rights to use, reproduce, and redistribute CMTA Data.',
|
||||
note: 'CMTA trademarks may not be used in association with the data, so the credit is the operator name as text.',
|
||||
}),
|
||||
defaultMode: 'bus',
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'metrotransit-msp',
|
||||
name: 'Metro Transit',
|
||||
operator: 'Metro Transit (Metropolitan Council)',
|
||||
region: 'Minneapolis–St Paul, MN',
|
||||
center: Object.freeze({ lat: 44.9778, lon: -93.265 }),
|
||||
loadRadiusKm: 70,
|
||||
url: 'https://svc.metrotransit.org/mtgtfs/vehiclepositions.pb',
|
||||
license: 'Public Metro Transit vehicle position data',
|
||||
licenseUrl: 'https://svc.metrotransit.org/',
|
||||
attribution: 'Metro Transit — Metropolitan Council',
|
||||
defaultEnabled: true,
|
||||
terms: Object.freeze({
|
||||
quote:
|
||||
"Metro Transit's GTFS-realtime feeds are refreshed every 5 seconds.",
|
||||
note: 'The realtime feed is published from a developer page carrying operational guidance and no licence text of its own. The public-domain statement below belongs to the Metropolitan Council metadata for the COMPANION SCHEDULE dataset, not to this feed: "None. This dataset is public domain under the Minnesota Government Data Practices Act". Credited as a courtesy, the way the other public agency feeds are.',
|
||||
}),
|
||||
defaultMode: 'bus',
|
||||
routeMode: metroTransitRouteMode,
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'hsl-helsinki',
|
||||
name: 'HSL',
|
||||
operator: 'Helsinki Region Transport (HSL)',
|
||||
region: 'Helsinki, Finland',
|
||||
center: Object.freeze({ lat: 60.1699, lon: 24.9384 }),
|
||||
loadRadiusKm: 70,
|
||||
url: 'https://realtime.hsl.fi/realtime/vehicle-positions/v2/hsl',
|
||||
license: 'CC BY 4.0',
|
||||
licenseUrl: 'https://www.hsl.fi/en/hsl/open-data',
|
||||
attribution: 'HSL (Helsinki Region Transport)',
|
||||
defaultEnabled: true,
|
||||
terms: Object.freeze({
|
||||
quote: null,
|
||||
note: 'The open-data page refuses automated readers (HTTP 403, bot protection), so this entry rests on the owner opening it and accepting it rather than on a passage quoted here. HSL publishes its open data under CC BY 4.0 and asks to be credited as the source; the feed needs no key.',
|
||||
}),
|
||||
defaultMode: 'bus',
|
||||
routeMode: hslRouteMode,
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'ovapi-nl',
|
||||
name: 'OVapi',
|
||||
operator: 'Stichting OpenGeo (NDOV data)',
|
||||
region: 'Netherlands',
|
||||
center: Object.freeze({ lat: 52.2, lon: 5.3 }),
|
||||
loadRadiusKm: 220,
|
||||
url: 'https://gtfs.ovapi.nl/nl/vehiclePositions.pb',
|
||||
license: 'Free to use per the OVapi README (best effort, no SLA)',
|
||||
licenseUrl: 'https://gtfs.ovapi.nl/README',
|
||||
attribution:
|
||||
'OVapi / Stichting OpenGeo — Dutch integrated real-time transit data',
|
||||
defaultEnabled: true,
|
||||
terms: Object.freeze({
|
||||
quote:
|
||||
'You are free to use this data, but there is no service level agreement (best-effort) nor are you allowed to say you represent or impersonate any of the transit agencies listed here.',
|
||||
note: 'The README asks consumers to identify themselves in the User-Agent, to send If-Modified-Since / If-None-Match when polling faster than once a minute, and to accept gzip. The proxy does all three.',
|
||||
}),
|
||||
defaultMode: 'bus',
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'entur-norway',
|
||||
name: 'Entur',
|
||||
operator: 'Entur AS (Norwegian national transit data)',
|
||||
region: 'Norway',
|
||||
// Circle chosen to hold Oslo, Bergen, Bodø and Tromsø while leaving
|
||||
// Helsinki (≈820 km) out — a national feed must not poll from next door.
|
||||
center: Object.freeze({ lat: 64.0, lon: 11.5 }),
|
||||
loadRadiusKm: 720,
|
||||
url: 'https://api.entur.io/realtime/v1/gtfs-rt/vehicle-positions',
|
||||
headers: Object.freeze({ 'ET-Client-Name': 'gods-eye-view-transit' }),
|
||||
license: 'Norwegian Licence for Open Government Data (NLOD)',
|
||||
licenseUrl: 'https://developer.entur.org/pages-intro-authentication',
|
||||
attribution: 'Entur — data under NLOD',
|
||||
defaultEnabled: true,
|
||||
terms: Object.freeze({
|
||||
quote:
|
||||
'This API is open under NLOD licence, however, it is required that all consumers identify themselves by using the header ET-Client-Name.',
|
||||
note: 'The header is mandatory — unidentified consumers may be rate-limited or blocked — and the proxy sends it on every request.',
|
||||
}),
|
||||
defaultMode: 'bus',
|
||||
routeMode: enturRouteMode,
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'translink-seq',
|
||||
name: 'TransLink',
|
||||
operator: 'TransLink (Queensland Government)',
|
||||
region: 'South East Queensland, Australia',
|
||||
center: Object.freeze({ lat: -27.4698, lon: 153.0251 }),
|
||||
loadRadiusKm: 150,
|
||||
url: 'https://gtfsrt.api.translink.com.au/api/realtime/seq/VehiclePositions',
|
||||
license: 'CC BY 4.0',
|
||||
licenseUrl: 'https://translink.com.au/about-translink/open-data',
|
||||
attribution: 'TransLink — Queensland Government (CC BY 4.0)',
|
||||
defaultEnabled: true,
|
||||
terms: Object.freeze({
|
||||
quote:
|
||||
'Our data is licensed under a Creative Commons Attribution 4.0 International License. ... You must not represent that the State in any way endorses your Application.',
|
||||
note: 'No key, no stated rate limit, no caching rule. Logos and network imagery need separate approval, so the credit is text only.',
|
||||
}),
|
||||
defaultMode: 'bus',
|
||||
}),
|
||||
]);
|
||||
|
||||
/**
|
||||
* The feeds this build actually polls. A registered feed that is not here is
|
||||
* inert everywhere: it resolves to no route, is never offered to the browser,
|
||||
* and never appears in a coverage check.
|
||||
*/
|
||||
export const TRANSIT_ENABLED_FEEDS = Object.freeze(
|
||||
TRANSIT_FEED_REGISTRY.filter((feed) => feed.defaultEnabled === true),
|
||||
);
|
||||
|
||||
const FEED_BY_ID = new Map(
|
||||
TRANSIT_ENABLED_FEEDS.map((feed) => [feed.id, feed]),
|
||||
);
|
||||
const ANY_FEED_BY_ID = new Map(
|
||||
TRANSIT_FEED_REGISTRY.map((feed) => [feed.id, feed]),
|
||||
);
|
||||
|
||||
/** Feed ids are path segments: lowercase letters, digits, hyphens only. */
|
||||
export const TRANSIT_FEED_ID_PATTERN = /^[a-z0-9][a-z0-9-]{1,63}$/;
|
||||
|
||||
/**
|
||||
* Look up a POLLABLE feed by id. Unknown ids, malformed ids, and feeds that are
|
||||
* registered but switched off all return null — this is the only door from a
|
||||
* request path to an upstream URL, so a feed the owner has not cleared must not
|
||||
* be reachable through it.
|
||||
* @param {string} id
|
||||
* @returns {object|null}
|
||||
*/
|
||||
export function getTransitFeed(id) {
|
||||
if (typeof id !== 'string' || !TRANSIT_FEED_ID_PATTERN.test(id)) return null;
|
||||
return FEED_BY_ID.get(id) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up any registered feed, enabled or not. For credits and documentation
|
||||
* only — never for resolving a request into an upstream fetch.
|
||||
* @param {string} id
|
||||
* @returns {object|null}
|
||||
*/
|
||||
export function getRegisteredTransitFeed(id) {
|
||||
if (typeof id !== 'string' || !TRANSIT_FEED_ID_PATTERN.test(id)) return null;
|
||||
return ANY_FEED_BY_ID.get(id) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Great-circle distance in km.
|
||||
* @param {number} aLat
|
||||
* @param {number} aLon
|
||||
* @param {number} bLat
|
||||
* @param {number} bLon
|
||||
* @returns {number}
|
||||
*/
|
||||
export function haversineKm(aLat, aLon, bLat, bLon) {
|
||||
const toRad = (deg) => (deg * Math.PI) / 180;
|
||||
const dLat = toRad(bLat - aLat);
|
||||
const dLon = toRad(bLon - aLon);
|
||||
const h =
|
||||
Math.sin(dLat / 2) ** 2 +
|
||||
Math.cos(toRad(aLat)) * Math.cos(toRad(bLat)) * Math.sin(dLon / 2) ** 2;
|
||||
return 2 * 6371 * Math.asin(Math.min(1, Math.sqrt(h)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Feeds whose coverage circle contains the point, nearest first.
|
||||
* @param {number} lat
|
||||
* @param {number} lon
|
||||
* @param {number} [slackKm=0] Extra radius (hysteresis) so a feed at the edge
|
||||
* of coverage does not flap on small camera moves.
|
||||
* @returns {object[]}
|
||||
*/
|
||||
export function transitFeedsInRange(lat, lon, slackKm = 0) {
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return [];
|
||||
return TRANSIT_ENABLED_FEEDS.map((feed) => ({
|
||||
feed,
|
||||
km: haversineKm(lat, lon, feed.center.lat, feed.center.lon),
|
||||
}))
|
||||
.filter(({ feed, km }) => km <= feed.loadRadiusKm + Math.max(0, slackKm))
|
||||
.sort((a, b) => a.km - b.km)
|
||||
.map(({ feed }) => feed);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mode for a vehicle: the feed's route hint when it has one, else its default.
|
||||
* @param {object} feed Registry entry.
|
||||
* @param {string|null} routeId GTFS route_id from the vehicle's trip.
|
||||
* @returns {string} One of TRANSIT_MODES.
|
||||
*/
|
||||
export function transitModeFor(feed, routeId) {
|
||||
const hinted =
|
||||
typeof feed?.routeMode === 'function' ? feed.routeMode(routeId) : null;
|
||||
const mode =
|
||||
hinted && hinted !== 'unknown' ? hinted : feed?.defaultMode || 'unknown';
|
||||
return TRANSIT_MODES.includes(mode) ? mode : 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the mode came from the route id rather than the feed's default. A
|
||||
* default is a guess about a fleet — TransLink's is "bus" while it publishes
|
||||
* rail in the same feed — so a consumer judging physical plausibility must
|
||||
* not hold a defaulted vehicle to a bus's limits.
|
||||
* @param {object} feed Registry entry.
|
||||
* @param {string|null} routeId
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function transitModeResolved(feed, routeId) {
|
||||
const hinted =
|
||||
typeof feed?.routeMode === 'function' ? feed.routeMode(routeId) : null;
|
||||
return (
|
||||
Boolean(hinted) && hinted !== 'unknown' && TRANSIT_MODES.includes(hinted)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Public catalog shape served by `/api/transit/feeds` — everything the browser
|
||||
* needs to gate polling and credit the source, and nothing it could misuse.
|
||||
* @returns {object[]}
|
||||
*/
|
||||
export function publicTransitCatalog() {
|
||||
return TRANSIT_ENABLED_FEEDS.map((feed) => ({
|
||||
id: feed.id,
|
||||
name: feed.name,
|
||||
operator: feed.operator,
|
||||
region: feed.region,
|
||||
center: { ...feed.center },
|
||||
loadRadiusKm: feed.loadRadiusKm,
|
||||
license: feed.license,
|
||||
licenseUrl: feed.licenseUrl,
|
||||
attribution: feed.attribution,
|
||||
historyRetention: feed.historyRetention === true,
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
TRANSIT_ENABLED_FEEDS,
|
||||
TRANSIT_FEED_REGISTRY,
|
||||
TRANSIT_FEED_ID_PATTERN,
|
||||
TRANSIT_MODES,
|
||||
getRegisteredTransitFeed,
|
||||
getTransitFeed,
|
||||
haversineKm,
|
||||
publicTransitCatalog,
|
||||
transitFeedsInRange,
|
||||
transitModeFor,
|
||||
transitModeResolved,
|
||||
} from './transitFeeds.js';
|
||||
|
||||
test('every registered feed is keyless, https, licensed, and uniquely identified', () => {
|
||||
const ids = new Set();
|
||||
for (const feed of TRANSIT_FEED_REGISTRY) {
|
||||
assert.match(
|
||||
feed.id,
|
||||
TRANSIT_FEED_ID_PATTERN,
|
||||
`${feed.id} is a valid path segment`,
|
||||
);
|
||||
assert.equal(ids.has(feed.id), false, `${feed.id} is unique`);
|
||||
ids.add(feed.id);
|
||||
const url = new URL(feed.url);
|
||||
assert.equal(url.protocol, 'https:', `${feed.id} fetches over https`);
|
||||
assert.equal(
|
||||
url.search.includes('key='),
|
||||
false,
|
||||
`${feed.id} carries no key in its URL`,
|
||||
);
|
||||
assert.ok(
|
||||
feed.license && feed.licenseUrl && feed.attribution,
|
||||
`${feed.id} names its license`,
|
||||
);
|
||||
assert.ok(
|
||||
feed.loadRadiusKm > 0 && feed.loadRadiusKm <= 1000,
|
||||
`${feed.id} radius is sane`,
|
||||
);
|
||||
assert.ok(
|
||||
Math.abs(feed.center.lat) <= 90 && Math.abs(feed.center.lon) <= 180,
|
||||
);
|
||||
assert.ok(
|
||||
TRANSIT_MODES.includes(feed.defaultMode),
|
||||
`${feed.id} default mode is known`,
|
||||
);
|
||||
assert.ok(Object.isFrozen(feed), `${feed.id} is immutable`);
|
||||
}
|
||||
});
|
||||
|
||||
test('getTransitFeed is the only door to an upstream URL and refuses anything unregistered', () => {
|
||||
assert.equal(
|
||||
getTransitFeed('mbta')?.url,
|
||||
'https://cdn.mbta.com/realtime/VehiclePositions.pb',
|
||||
);
|
||||
assert.equal(getTransitFeed('MBTA'), null);
|
||||
assert.equal(getTransitFeed('../etc/passwd'), null);
|
||||
assert.equal(getTransitFeed('https://evil.example'), null);
|
||||
assert.equal(getTransitFeed(''), null);
|
||||
assert.equal(getTransitFeed(null), null);
|
||||
assert.equal(getTransitFeed(42), null);
|
||||
});
|
||||
|
||||
test('haversine matches known city distances', () => {
|
||||
const bostonToNyc = haversineKm(42.3601, -71.0589, 40.7128, -74.006);
|
||||
assert.ok(
|
||||
Math.abs(bostonToNyc - 306) < 5,
|
||||
`Boston–NYC ≈ 306 km, got ${bostonToNyc}`,
|
||||
);
|
||||
assert.equal(haversineKm(0, 0, 0, 0), 0);
|
||||
});
|
||||
|
||||
test('feeds in range are nearest-first and honor slack as hysteresis', () => {
|
||||
// Camera over Cambridge, MA → MBTA only.
|
||||
const boston = transitFeedsInRange(42.37, -71.11);
|
||||
assert.deepEqual(
|
||||
boston.map((f) => f.id),
|
||||
['mbta'],
|
||||
);
|
||||
// Mid-Atlantic: nothing.
|
||||
assert.deepEqual(transitFeedsInRange(40, -40), []);
|
||||
// Just outside MBTA's 70 km circle (≈ 80 km south) — out without slack, in with 20 km slack.
|
||||
const farLat = 42.3601 - 80 / 111;
|
||||
assert.deepEqual(transitFeedsInRange(farLat, -71.0589), []);
|
||||
assert.deepEqual(
|
||||
transitFeedsInRange(farLat, -71.0589, 20).map((f) => f.id),
|
||||
['mbta'],
|
||||
);
|
||||
// Bad input never throws.
|
||||
assert.deepEqual(transitFeedsInRange(NaN, 1), []);
|
||||
// A national feed covers its own cities and not the neighbour's capital.
|
||||
for (const [city, lat, lon] of [
|
||||
['Oslo', 59.91, 10.75],
|
||||
['Bergen', 60.39, 5.32],
|
||||
['Tromsø', 69.65, 18.96],
|
||||
]) {
|
||||
assert.ok(
|
||||
transitFeedsInRange(lat, lon).some((f) => f.id === 'entur-norway'),
|
||||
`${city} is covered by Entur`,
|
||||
);
|
||||
}
|
||||
assert.deepEqual(
|
||||
transitFeedsInRange(60.17, 24.94).map((f) => f.id),
|
||||
['hsl-helsinki'],
|
||||
'Helsinki polls HSL only',
|
||||
);
|
||||
});
|
||||
|
||||
test('route hints refine a feed default and never escape the known modes', () => {
|
||||
const mbta = getTransitFeed('mbta');
|
||||
assert.equal(transitModeFor(mbta, 'Red'), 'subway');
|
||||
assert.equal(transitModeFor(mbta, 'Green-B'), 'tram');
|
||||
assert.equal(transitModeFor(mbta, 'CR-Fitchburg'), 'rail');
|
||||
assert.equal(transitModeFor(mbta, 'Boat-F1'), 'ferry');
|
||||
assert.equal(transitModeFor(mbta, '66'), 'bus');
|
||||
assert.equal(transitModeFor(mbta, null), 'bus');
|
||||
const hsl = getTransitFeed('hsl-helsinki');
|
||||
assert.equal(transitModeFor(hsl, '31M1'), 'subway');
|
||||
assert.equal(transitModeFor(hsl, '1006'), 'tram');
|
||||
assert.equal(transitModeFor(hsl, '9982'), 'bus');
|
||||
const msp = getTransitFeed('metrotransit-msp');
|
||||
assert.equal(transitModeFor(msp, '901'), 'tram');
|
||||
assert.equal(transitModeFor(msp, '17'), 'bus');
|
||||
const entur = getTransitFeed('entur-norway');
|
||||
assert.equal(transitModeFor(entur, 'VYG:Line:R10'), 'rail');
|
||||
assert.equal(transitModeFor(entur, 'TRO:Line:1_310'), 'bus');
|
||||
assert.equal(transitModeFor({ defaultMode: 'spaceship' }, 'x'), 'unknown');
|
||||
assert.equal(transitModeFor(null, 'x'), 'unknown');
|
||||
});
|
||||
|
||||
test('the public catalog exposes coverage and credit, never the upstream URL or headers', () => {
|
||||
const catalog = publicTransitCatalog();
|
||||
assert.equal(catalog.length, TRANSIT_ENABLED_FEEDS.length);
|
||||
for (const entry of catalog) {
|
||||
assert.equal('url' in entry, false);
|
||||
assert.equal('headers' in entry, false);
|
||||
assert.ok(entry.id && entry.name && entry.region && entry.attribution);
|
||||
assert.ok(
|
||||
Number.isFinite(entry.center.lat) && Number.isFinite(entry.loadRadiusKm),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('the enabled set is a gate on the registry, not a copy of it', () => {
|
||||
// Every registered feed is switched on today. The gate still has to be the
|
||||
// thing that decides that, because the moment an operator's terms change the
|
||||
// owner flips one flag and expects the feed to become unreachable — not to
|
||||
// stay routable because the filter had quietly become a no-op.
|
||||
assert.deepEqual(
|
||||
TRANSIT_ENABLED_FEEDS.map((feed) => feed.id),
|
||||
TRANSIT_FEED_REGISTRY.filter((feed) => feed.defaultEnabled === true).map(
|
||||
(feed) => feed.id,
|
||||
),
|
||||
);
|
||||
for (const feed of TRANSIT_FEED_REGISTRY) {
|
||||
assert.equal(
|
||||
typeof feed.defaultEnabled,
|
||||
'boolean',
|
||||
`${feed.id} states whether it ships on`,
|
||||
);
|
||||
assert.equal(
|
||||
Boolean(getTransitFeed(feed.id)),
|
||||
feed.defaultEnabled,
|
||||
`${feed.id} is routable exactly when it is enabled`,
|
||||
);
|
||||
assert.ok(getRegisteredTransitFeed(feed.id), `${feed.id} stays documented`);
|
||||
}
|
||||
});
|
||||
|
||||
test('a mode the route id established is resolved; a feed default is only a guess', () => {
|
||||
// TransLink publishes rail in a feed whose default is bus. A consumer that
|
||||
// judges physical plausibility must know the difference, or a 140 km/h
|
||||
// train is refused as an impossible bus.
|
||||
const translink = getTransitFeed('translink-seq');
|
||||
assert.equal(transitModeFor(translink, '600'), 'bus');
|
||||
assert.equal(transitModeResolved(translink, '600'), false, 'defaulted');
|
||||
const mbta = getTransitFeed('mbta');
|
||||
assert.equal(transitModeResolved(mbta, 'Red'), true);
|
||||
assert.equal(
|
||||
transitModeResolved(mbta, '66'),
|
||||
true,
|
||||
'a numeric MBTA route is a bus by rule, not by default',
|
||||
);
|
||||
assert.equal(transitModeResolved(mbta, null), false);
|
||||
assert.equal(transitModeResolved({ defaultMode: 'bus' }, 'x'), false);
|
||||
assert.equal(
|
||||
transitModeResolved({ routeMode: () => 'spaceship' }, 'x'),
|
||||
false,
|
||||
'an unknown hint resolves nothing',
|
||||
);
|
||||
});
|
||||
|
||||
test('only MBTA retains proxy history while every other registered feed stays live-enabled', () => {
|
||||
assert.deepEqual(
|
||||
publicTransitCatalog()
|
||||
.filter((feed) => feed.historyRetention)
|
||||
.map((feed) => feed.id),
|
||||
['mbta'],
|
||||
);
|
||||
assert.equal(getTransitFeed('capmetro-austin').defaultEnabled, true);
|
||||
assert.equal(getTransitFeed('mbta').attribution, 'MBTA / MassDOT');
|
||||
});
|
||||
|
||||
test('published transit history scope agrees with the catalog capability', () => {
|
||||
const retained = publicTransitCatalog().filter(
|
||||
(feed) => feed.historyRetention,
|
||||
);
|
||||
assert.deepEqual(
|
||||
retained.map((feed) => feed.id),
|
||||
['mbta'],
|
||||
);
|
||||
const sources = readFileSync(
|
||||
new URL('../../DATA_SOURCES.md', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
const row = sources
|
||||
.split('\n')
|
||||
.find((line) => line.startsWith('| **MBTA / MassDOT**'));
|
||||
assert.match(
|
||||
row,
|
||||
/live vehicles and up to 15 minutes of recently observed positions/,
|
||||
);
|
||||
assert.match(row, /MBTA \/ MassDOT.*courtesy/);
|
||||
assert.match(sources, /other feeds have no proxy retention/);
|
||||
const state = readFileSync(
|
||||
new URL('../../docs/CURRENT-STATE.md', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
const transit = state.slice(
|
||||
state.indexOf('Transit is off by default'),
|
||||
state.indexOf('`src/data/militaryAwareness.js` remains'),
|
||||
);
|
||||
assert.match(transit, /Restart clears it/);
|
||||
assert.doesNotMatch(
|
||||
transit,
|
||||
/four fixes|0\.95x|guaranteed lower|one poll interval behind/,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,214 @@
|
||||
import {
|
||||
presetSpriteScale,
|
||||
presetSpriteOutlinePx,
|
||||
transitStyleProfile,
|
||||
} from './transitPresetStyle.js';
|
||||
// src/data/transitIcons.js
|
||||
/**
|
||||
* Front-up transit-vehicle silhouettes, one per mode, as SVG data URIs for
|
||||
* Cesium billboards.
|
||||
*
|
||||
* Same house language as `aircraftIcons.js`: a 96-unit viewBox with the glyph
|
||||
* centred at (48,48), drawn nose toward -Y so the screen-projected rotation in
|
||||
* `iconOrientation.js` can spin the whole glyph, filled WHITE with a dark
|
||||
* hairline edge so the tint pipeline (billboard.color = the mode colour, plus
|
||||
* `.withAlpha` fades) keeps working. No glyph carries a colour of its own; a
|
||||
* hardcoded fill would fight the tint that tells a person what they are looking
|
||||
* at.
|
||||
*
|
||||
* These are plan views, the way a vehicle reads from a camera above a street —
|
||||
* not side elevations. Each mode has a DISTINCT outline so it is recognisable
|
||||
* at the ~18 px the fleet renders at, by shape and not only by colour: a bus is
|
||||
* short and blunt, a tram is long and slender with articulation joints, a metro
|
||||
* car is squared off, a mainline train has a tapered nose, and a ferry is a
|
||||
* hull with a bow.
|
||||
*
|
||||
* Interior detail is drawn with the same dark hairline as the edge — windscreen
|
||||
* lines, articulation joints, a deck line — because a solid dark panel would
|
||||
* darken the tint and a white-on-white panel would vanish.
|
||||
*/
|
||||
|
||||
const VIEW = 96;
|
||||
const C = VIEW / 2; // 48 — glyph centre
|
||||
|
||||
/** Hairline dark edge, matching the aircraft glyph weight at this box size. */
|
||||
const STROKE =
|
||||
'stroke="rgba(0,0,0,0.34)" stroke-width="1.5" stroke-linejoin="round"';
|
||||
/** Interior panel lines: the same ink, thinner, capped so joints read cleanly. */
|
||||
const DETAIL =
|
||||
'stroke="rgba(0,0,0,0.30)" stroke-width="1.5" stroke-linecap="round" fill="none"';
|
||||
|
||||
/**
|
||||
* Each body is drawn in a centred frame (origin 0,0 = glyph centre), front
|
||||
* toward -Y, in the 96-unit space (half-extent up to ~45).
|
||||
*/
|
||||
const BODIES = {
|
||||
// ── Bus: short, blunt, square-shouldered. The shape everything else reads
|
||||
// against, and the one most people will see most often.
|
||||
bus: `
|
||||
<path d="M-17,-28 C-17,-32 -14,-34 0,-34 C14,-34 17,-32 17,-28
|
||||
L17,28 C17,32 14,34 0,34 C-14,34 -17,32 -17,28 Z"
|
||||
fill="white" ${STROKE}/>
|
||||
<path d="M-11,-24 L11,-24" ${DETAIL}/>
|
||||
<path d="M-11,20 L11,20" ${DETAIL}/>`,
|
||||
|
||||
// ── Tram / light rail: longer and slimmer than a bus, with the two
|
||||
// articulation joints that make a modern low-floor tram unmistakable.
|
||||
tram: `
|
||||
<path d="M-10,-38 C-10,-41 -8,-42.5 0,-42.5 C8,-42.5 10,-41 10,-38
|
||||
L10,38 C10,41 8,42.5 0,42.5 C-8,42.5 -10,41 -10,38 Z"
|
||||
fill="white" ${STROKE}/>
|
||||
<path d="M-10,-13 L10,-13" ${DETAIL}/>
|
||||
<path d="M-10,13 L10,13" ${DETAIL}/>
|
||||
<path d="M-8,-34 L8,-34" ${DETAIL}/>`,
|
||||
|
||||
// ── Metro / subway: squared off at both ends, wider than a tram, a single
|
||||
// cab line at the front. Reads as rolling stock, not a road vehicle.
|
||||
subway: `
|
||||
<path d="M-11.5,-40 L11.5,-40 L11.5,40 L-11.5,40 Z"
|
||||
fill="white" ${STROKE}/>
|
||||
<path d="M-11.5,-32 L11.5,-32" ${DETAIL}/>
|
||||
<path d="M-11.5,0 L11.5,0" ${DETAIL}/>
|
||||
<path d="M-11.5,32 L11.5,32" ${DETAIL}/>`,
|
||||
|
||||
// ── Mainline / commuter rail: the tapered nose of an intercity set, longer
|
||||
// than anything else in the set.
|
||||
rail: `
|
||||
<path d="M0,-45 C5,-45 9.5,-41 10.5,-34 L10.5,40
|
||||
C10.5,43 8.5,44.5 0,44.5 C-8.5,44.5 -10.5,43 -10.5,40
|
||||
L-10.5,-34 C-9.5,-41 -5,-45 0,-45 Z"
|
||||
fill="white" ${STROKE}/>
|
||||
<path d="M-9,-30 L9,-30" ${DETAIL}/>
|
||||
<path d="M-10.5,6 L10.5,6" ${DETAIL}/>`,
|
||||
|
||||
// ── Ferry: a hull. Pointed bow, square stern, superstructure amidships —
|
||||
// the only glyph in the set that is not a box on rails or rubber.
|
||||
ferry: `
|
||||
<path d="M0,-44 C6,-38 12,-26 12.5,-14 L12.5,34
|
||||
C12.5,37 11,38 0,38 C-11,38 -12.5,37 -12.5,34
|
||||
L-12.5,-14 C-12,-26 -6,-38 0,-44 Z"
|
||||
fill="white" ${STROKE}/>
|
||||
<path d="M-7.5,-6 L7.5,-6 L7.5,20 L-7.5,20 Z" ${DETAIL}/>`,
|
||||
|
||||
// ── Unknown: a plain rounded marker. Deliberately featureless, so an
|
||||
// unclassified vehicle never pretends to be a mode it is not.
|
||||
unknown: `
|
||||
<path d="M-10,-26 C-10,-30 -7,-32 0,-32 C7,-32 10,-30 10,-26
|
||||
L10,26 C10,30 7,32 0,32 C-7,32 -10,30 -10,26 Z"
|
||||
fill="white" ${STROKE}/>`,
|
||||
};
|
||||
|
||||
/** The modes this module can draw. */
|
||||
export const TRANSIT_ICON_KINDS = Object.freeze(Object.keys(BODIES));
|
||||
|
||||
const _iconCache = new Map();
|
||||
|
||||
const _b64 = (s) =>
|
||||
typeof btoa === 'function'
|
||||
? btoa(s)
|
||||
: Buffer.from(s, 'utf8').toString('base64');
|
||||
|
||||
/**
|
||||
* Fleet raster size. Same reasoning as the aircraft fleet glyphs: Cesium's
|
||||
* billboard atlas has no mipmaps, so a texture far larger than its on-screen
|
||||
* footprint is GPU-minified into mush. Transit billboards render around 14–20
|
||||
* CSS px, so 48 px of source covers the band on a Retina display with very
|
||||
* little minification.
|
||||
*/
|
||||
const FLEET_RASTER_PX = 48;
|
||||
/** The selected vehicle draws bigger and deserves a crisper source. */
|
||||
export const SELECTED_ICON_PX = 96;
|
||||
|
||||
/**
|
||||
* On-screen size the fleet glyph is authored for. A halo width asked for in
|
||||
* screen pixels is converted to viewBox units against this, so "two pixels of
|
||||
* ring" means two pixels on an eighteen-pixel billboard.
|
||||
*/
|
||||
export const FLEET_SCREEN_PX = 20;
|
||||
|
||||
/**
|
||||
* How a halo changes the glyph's frame. A thick outer stroke would clip at
|
||||
* the 96-unit box (the tram already reaches 42.5 of 48), so the viewBox is
|
||||
* padded by the stroke and the billboard has to grow by the same ratio to
|
||||
* keep the body its shipped size on screen.
|
||||
*
|
||||
* @param {number} haloScreenPx Ring width in final screen pixels (0 = none).
|
||||
* @param {number} [screenPx] Billboard size the ring is specified against.
|
||||
* @returns {{units: number, pad: number, ratio: number}} Stroke width in
|
||||
* viewBox units (the visible ring is half of it, outside the body), the
|
||||
* padding added on every side, and the frame growth factor to apply to the
|
||||
* billboard's width and height.
|
||||
*/
|
||||
export function haloFrame(haloScreenPx, screenPx = FLEET_SCREEN_PX) {
|
||||
const ring = Number.isFinite(haloScreenPx) ? Math.max(0, haloScreenPx) : 0;
|
||||
if (ring === 0) return { units: 0, pad: 0, ratio: 1 };
|
||||
// Half the stroke lies outside the body, so a ring of R px needs 2R of
|
||||
// stroke. One screen pixel is VIEW/screenPx units.
|
||||
const units = (2 * ring * VIEW) / Math.max(1, screenPx);
|
||||
const pad = Math.ceil(units / 2) + 1;
|
||||
return { units, pad, ratio: (VIEW + 2 * pad) / VIEW };
|
||||
}
|
||||
|
||||
/** Number of raster variants ever built — a bound a test can hold the cache to. */
|
||||
export function transitIconCacheSize() {
|
||||
return _iconCache.size;
|
||||
}
|
||||
|
||||
/**
|
||||
* Data URI for one mode's silhouette, lazily built and cached per kind, size
|
||||
* and halo.
|
||||
*
|
||||
* With a halo, the body is drawn twice: first as a wide dark stroke, then as
|
||||
* the shipped white fill with its hairline edge on top, so the ring sits
|
||||
* OUTSIDE the silhouette and the interior detail is untouched. The dark ring
|
||||
* is what survives NVG's auto-gain and FLIR's luma mapping when the bright
|
||||
* core alone would merge with a bright road.
|
||||
*
|
||||
* @param {string} kind One of TRANSIT_ICON_KINDS; anything else draws `unknown`.
|
||||
* @param {number} [px] Raster size.
|
||||
* @param {{haloScreenPx?: number, screenPx?: number}} [options]
|
||||
* @returns {string} `data:image/svg+xml;base64,...`
|
||||
*/
|
||||
export function transitIcon(kind, px = FLEET_RASTER_PX, options = {}) {
|
||||
const k = BODIES[kind] ? kind : 'unknown';
|
||||
const selected = px > FLEET_RASTER_PX;
|
||||
px = selected ? SELECTED_ICON_PX : FLEET_RASTER_PX;
|
||||
const profile = transitStyleProfile(
|
||||
options.style ||
|
||||
(options.haloScreenPx >= 2
|
||||
? 'thermal'
|
||||
: options.haloScreenPx === 1.25
|
||||
? 'retro'
|
||||
: 'normal'),
|
||||
);
|
||||
const style =
|
||||
profile === 'mono' ? 'thermal' : profile === 'crt' ? 'retro' : 'normal';
|
||||
const displayPx = (selected ? 30 : 20) * presetSpriteScale(style, selected);
|
||||
const frame = haloFrame(presetSpriteOutlinePx(style, selected), displayPx);
|
||||
const key = `${k}:${selected ? 1 : 0}:${profile}`;
|
||||
let uri = _iconCache.get(key);
|
||||
if (!uri) {
|
||||
const size = VIEW + 2 * frame.pad;
|
||||
// Sensor signatures use a broad rounded envelope, without dark interior
|
||||
// joints or a second outline eating into the hot core at display resolution.
|
||||
const halfW = k === 'bus' || k === 'ferry' || k === 'unknown' ? 48 : 47;
|
||||
const halfH = 48;
|
||||
const radius = k === 'subway' ? 8 : k === 'rail' ? 12 : 14;
|
||||
const capsule = `<path d="M${-halfW + radius},${-halfH} H${halfW - radius} Q${halfW},${-halfH} ${halfW},${-halfH + radius} V${halfH - radius} Q${halfW},${halfH} ${halfW - radius},${halfH} H${-halfW + radius} Q${-halfW},${halfH} ${-halfW},${halfH - radius} V${-halfH + radius} Q${-halfW},${-halfH} ${-halfW + radius},${-halfH} Z" fill="white" />`;
|
||||
const body = profile === 'mono' ? capsule : BODIES[k];
|
||||
const halo =
|
||||
frame.units > 0
|
||||
? body.replace(
|
||||
/<path d="([^"]+)"\s+fill="white" [^/]*\/>/,
|
||||
(_m, d) =>
|
||||
`<path d="${d}" fill="none" stroke="${profile === 'mono' ? '#000000' : '#05080C'}" stroke-opacity="${profile === 'mono' ? 1 : 0.95}" stroke-width="${frame.units.toFixed(2)}" stroke-linejoin="round"/>`,
|
||||
)
|
||||
: '';
|
||||
const svg =
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="${Math.round(px * frame.ratio)}" height="${Math.round(px * frame.ratio)}" viewBox="${-frame.pad} ${-frame.pad} ${size} ${size}">` +
|
||||
`<g transform="translate(${C},${C})">${halo}${body}</g></svg>`;
|
||||
uri = 'data:image/svg+xml;base64,' + _b64(svg);
|
||||
_iconCache.set(key, uri);
|
||||
}
|
||||
return uri;
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
FLEET_SCREEN_PX,
|
||||
TRANSIT_ICON_KINDS,
|
||||
haloFrame,
|
||||
transitIcon,
|
||||
transitIconCacheSize,
|
||||
} from './transitIcons.js';
|
||||
|
||||
const decode = (uri) =>
|
||||
Buffer.from(uri.replace('data:image/svg+xml;base64,', ''), 'base64').toString(
|
||||
'utf8',
|
||||
);
|
||||
|
||||
test('a halo is sized in screen pixels and pads the frame so it cannot clip', () => {
|
||||
const none = haloFrame(0);
|
||||
assert.deepEqual(none, { units: 0, pad: 0, ratio: 1 });
|
||||
const two = haloFrame(2, FLEET_SCREEN_PX);
|
||||
// Two pixels of ring on an eighteen-pixel glyph is a 4-px stroke: 21.3 of 96 units.
|
||||
assert.ok(Math.abs(two.units - (4 * 96) / 20) < 1e-9);
|
||||
assert.ok(
|
||||
two.pad >= two.units / 2,
|
||||
'padding covers the half-stroke outside the body',
|
||||
);
|
||||
assert.ok(two.ratio > 1, 'and the billboard grows by the same ratio');
|
||||
});
|
||||
|
||||
test('a haloed glyph draws the dark ring under the shipped white body', () => {
|
||||
const plain = decode(transitIcon('bus'));
|
||||
const haloed = decode(transitIcon('bus', 48, { haloScreenPx: 2 }));
|
||||
assert.notEqual(plain, haloed);
|
||||
assert.match(
|
||||
haloed,
|
||||
/stroke="#000000" stroke-opacity="1" stroke-width="19\.20"/,
|
||||
);
|
||||
assert.ok(
|
||||
haloed.indexOf('#000000') < haloed.indexOf('fill="white"'),
|
||||
'ring first, body on top',
|
||||
);
|
||||
assert.match(haloed, /viewBox="-\d+ -\d+ \d+ \d+"/, 'padded frame');
|
||||
assert.match(plain, /stroke-width="9\.60"/, 'normal has a one-pixel halo');
|
||||
});
|
||||
|
||||
test('the raster cache is bounded by kind, size and halo — never by call count', () => {
|
||||
const before = transitIconCacheSize();
|
||||
for (let i = 0; i < 50; i += 1) {
|
||||
transitIcon('tram', 48, { haloScreenPx: 2 });
|
||||
transitIcon('tram', 48);
|
||||
transitIcon('tram', 128, { haloScreenPx: 1.25 });
|
||||
}
|
||||
assert.ok(
|
||||
transitIconCacheSize() - before <= 3,
|
||||
`three variants, not ${transitIconCacheSize() - before}`,
|
||||
);
|
||||
assert.equal(
|
||||
transitIcon('tram', 48, { haloScreenPx: 2 }),
|
||||
transitIcon('tram', 48, { haloScreenPx: 2 }),
|
||||
);
|
||||
for (const kind of TRANSIT_ICON_KINDS)
|
||||
transitIcon(kind, 48, { haloScreenPx: 2 });
|
||||
assert.ok(transitIconCacheSize() <= before + 3 + TRANSIT_ICON_KINDS.length);
|
||||
});
|
||||
|
||||
test('all 36 variants retain the specified final halo at both display sizes', async () => {
|
||||
const { presetSpriteScale, presetSpriteOutlinePx } =
|
||||
await import('./transitPresetStyle.js');
|
||||
for (const kind of TRANSIT_ICON_KINDS) {
|
||||
for (const selected of [false, true]) {
|
||||
for (const style of ['normal', 'thermal', 'retro']) {
|
||||
const display =
|
||||
(selected ? 30 : 20) * presetSpriteScale(style, selected);
|
||||
const svg = decode(transitIcon(kind, selected ? 96 : 48, { style }));
|
||||
const units = Number(
|
||||
svg.match(/stroke-opacity="(?:0.95|1)" stroke-width="([\d.]+)"/)[1],
|
||||
);
|
||||
assert.ok(
|
||||
Math.abs(
|
||||
(units * display) / 192 - presetSpriteOutlinePx(style, selected),
|
||||
) < 0.001,
|
||||
);
|
||||
const frame = haloFrame(
|
||||
presetSpriteOutlinePx(style, selected),
|
||||
display,
|
||||
);
|
||||
assert.match(
|
||||
svg,
|
||||
new RegExp(
|
||||
`width="${Math.round((selected ? 96 : 48) * frame.ratio)}"`,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < 100; i++)
|
||||
transitIcon('invalid', i, { haloScreenPx: i, screenPx: i });
|
||||
assert.equal(transitIconCacheSize(), 36);
|
||||
});
|
||||
|
||||
test('sensor bodies cover sixty percent of the padded display with opaque white', async () => {
|
||||
const sharp = (await import('sharp')).default;
|
||||
const { presetSpriteScale, presetSpriteOutlinePx } =
|
||||
await import('./transitPresetStyle.js');
|
||||
for (const selected of [false, true])
|
||||
for (const kind of TRANSIT_ICON_KINDS) {
|
||||
const display =
|
||||
(selected ? 30 : 20) * presetSpriteScale('thermal', selected);
|
||||
assert.equal(display, selected ? 36 : 30);
|
||||
assert.equal(presetSpriteOutlinePx('thermal', selected), 3);
|
||||
const svg = decode(
|
||||
transitIcon(kind, selected ? 96 : 48, { style: 'thermal' }),
|
||||
);
|
||||
const frame = haloFrame(3, display);
|
||||
const width = Math.round(display * frame.ratio);
|
||||
const { data, info } = await sharp(Buffer.from(svg))
|
||||
.resize(width, width)
|
||||
.ensureAlpha()
|
||||
.raw()
|
||||
.toBuffer({ resolveWithObject: true });
|
||||
let white = 0;
|
||||
for (let i = 0; i < data.length; i += 4)
|
||||
if (
|
||||
data[i] >= 240 &&
|
||||
data[i + 1] >= 240 &&
|
||||
data[i + 2] >= 240 &&
|
||||
data[i + 3] >= 240
|
||||
)
|
||||
white++;
|
||||
assert.ok(
|
||||
white / (info.width * info.height) >= 0.6,
|
||||
`${kind}/${display}: white ${white / (info.width * info.height)}`,
|
||||
);
|
||||
const c = (Math.floor(width / 2) * width + Math.floor(width / 2)) * 4;
|
||||
assert.deepEqual([...data.subarray(c, c + 4)], [255, 255, 255, 255]);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* @file Preset-aware transit sprite styling — pure lookup tables mapping the
|
||||
* active post-FX style to a sprite treatment the shaders cannot destroy, and
|
||||
* the detection tier that carries each mode's colour above them.
|
||||
*
|
||||
* Why: transit sprites are in-scene billboards, so they pass THROUGH the
|
||||
* post-FX chain. NVG and FLIR reduce the scene to luminance, and the mode
|
||||
* palette's luma is middling — a green bus, a red metro and an amber tram all
|
||||
* collapse to the same dim grey, on a road NVG's auto-gain has pushed to
|
||||
* white. The owner's Boston test: "you can barely see these guys".
|
||||
*
|
||||
* Encoding per profile, the traffic-dot precedent applied to a glyph:
|
||||
* - `mono` (surveillance/NVG, thermal/FLIR, noir, cockpit nvg/thermal): the
|
||||
* sprite is WHITE — the hottest end of grayscale FLIR and of the Ironbow
|
||||
* ramp, and the brightest phosphor under NVG — drawn larger, with a dark
|
||||
* halo sized in SCREEN pixels so it keeps local contrast on a bright road
|
||||
* through any luma mapping. Black-hot FLIR inverts it, deliberately: a
|
||||
* black sprite with a light ring is what a hot object looks like there.
|
||||
* - `crt` (retro): saturated per-mode colours that survive posterisation,
|
||||
* a size boost to out-shout the pixel grid, and a thinner halo.
|
||||
* - `normal` (normal/anime/snow/unknown): every lookup returns the shipped
|
||||
* value — mode colour, shipped size, hairline edge only.
|
||||
*
|
||||
* Brackets are a different matter. The detection canvas composites ABOVE the
|
||||
* post-FX chain, so a tier colour is literal screen RGB in every preset: the
|
||||
* brackets carry the mode colour the sprites can no longer encode, the way
|
||||
* traffic's brackets carry congestion. Tier colours per theme live in
|
||||
* `src/overlays/worldOverlayTokens.js`.
|
||||
*
|
||||
* Cesium-free so the tables are unit-testable; the layer maps them to Cesium
|
||||
* colours and billboard sizes at spawn and restyle time.
|
||||
*
|
||||
* @module data/transitPresetStyle
|
||||
*/
|
||||
|
||||
/** @const {Object<string,'mono'|'crt'>} Style name → non-normal profile. */
|
||||
const PROFILE_BY_STYLE = Object.freeze({
|
||||
surveillance: 'mono', // NVG — P43 phosphor × luma
|
||||
thermal: 'mono', // FLIR — grayscale/Ironbow × luma
|
||||
noir: 'mono', // full desaturation
|
||||
nvg: 'mono', // cockpit vision names its modes directly
|
||||
retro: 'crt', // CRT — hue survives, small glyphs don't
|
||||
});
|
||||
|
||||
/**
|
||||
* Per-profile sprite treatment. `rgba` is [r,g,b] 0–255 + alpha 0–1;
|
||||
* `scale` multiplies the shipped billboard size; `outlinePx` is the dark
|
||||
* halo's width in FINAL SCREEN PIXELS, which is the only unit that means
|
||||
* anything for a glyph rendered at eighteen pixels — the shipped 1.5-unit
|
||||
* hairline in a 96-unit box is 0.28 px on screen, and vanishes.
|
||||
*/
|
||||
const SPRITE_STYLE = Object.freeze({
|
||||
mono: Object.freeze({
|
||||
rgba: Object.freeze([255, 255, 255, 1]),
|
||||
scale: 1.5,
|
||||
outlinePx: 3,
|
||||
}),
|
||||
crt: Object.freeze({
|
||||
scale: 1.3,
|
||||
outlinePx: 1.25,
|
||||
rgbaByMode: Object.freeze({
|
||||
bus: Object.freeze([0, 255, 102, 1]),
|
||||
tram: Object.freeze([255, 179, 0, 1]),
|
||||
subway: Object.freeze([255, 59, 48, 1]),
|
||||
rail: Object.freeze([215, 139, 255, 1]),
|
||||
ferry: Object.freeze([58, 208, 255, 1]),
|
||||
unknown: Object.freeze([255, 255, 255, 1]),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
/** Modes with a bracket tier of their own. */
|
||||
const TIER_MODES = Object.freeze([
|
||||
'bus',
|
||||
'tram',
|
||||
'subway',
|
||||
'rail',
|
||||
'ferry',
|
||||
'unknown',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Classify a style name into a transit styling profile.
|
||||
* @param {string|null|undefined} styleName - Active style (e.g. 'thermal').
|
||||
* @returns {'normal'|'mono'|'crt'} Styling profile; unknown → 'normal'.
|
||||
*/
|
||||
export function transitStyleProfile(styleName) {
|
||||
return PROFILE_BY_STYLE[styleName] || 'normal';
|
||||
}
|
||||
|
||||
/**
|
||||
* Preset sprite colour, or null to keep the shipped mode colour.
|
||||
* @param {string|null|undefined} styleName
|
||||
* @param {string} mode Transit mode.
|
||||
* @returns {number[]|null} [r,g,b,a] with rgb 0–255, alpha 0–1, or null.
|
||||
*/
|
||||
export function presetSpriteRgba(styleName, mode) {
|
||||
const profile = transitStyleProfile(styleName);
|
||||
if (profile === 'mono') return SPRITE_STYLE.mono.rgba;
|
||||
if (profile === 'crt') {
|
||||
return (
|
||||
SPRITE_STYLE.crt.rgbaByMode[mode] || SPRITE_STYLE.crt.rgbaByMode.unknown
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Multiplier a preset applies to the shipped billboard size.
|
||||
* @param {string|null|undefined} styleName
|
||||
* @returns {number} 1 under the normal profile.
|
||||
*/
|
||||
export function presetSpriteScale(styleName, selected = false) {
|
||||
if (transitStyleProfile(styleName) === 'mono')
|
||||
return selected ? 36 / 30 : 30 / 20;
|
||||
return SPRITE_STYLE[transitStyleProfile(styleName)]?.scale ?? 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dark-halo width in final screen pixels, or 0 for the shipped hairline only.
|
||||
* @param {string|null|undefined} styleName
|
||||
* @returns {number}
|
||||
*/
|
||||
export function presetSpriteOutlinePx(styleName, selected = false) {
|
||||
return (
|
||||
SPRITE_STYLE[transitStyleProfile(styleName)]?.outlinePx ??
|
||||
(selected ? 1.5 : 1)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detection-overlay tier key for a transit mode. Every theme's `tiers`
|
||||
* table carries these six, so the bracket colour is per mode in every
|
||||
* preset. Unlike traffic, transit sets its tier keyless as well: the feeds
|
||||
* are keyless, and there is no separate keyless experience to keep
|
||||
* byte-identical.
|
||||
* @param {string} mode
|
||||
* @returns {string}
|
||||
*/
|
||||
export function transitModeTier(mode) {
|
||||
return `transit_${TIER_MODES.includes(mode) ? mode : 'unknown'}`;
|
||||
}
|
||||
|
||||
/** The tier keys this module can produce, for the tables that must carry them. */
|
||||
export const TRANSIT_TIER_KEYS = Object.freeze(
|
||||
TIER_MODES.map((mode) => `transit_${mode}`),
|
||||
);
|
||||
@@ -0,0 +1,96 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
TRANSIT_TIER_KEYS,
|
||||
presetSpriteOutlinePx,
|
||||
presetSpriteRgba,
|
||||
presetSpriteScale,
|
||||
transitModeTier,
|
||||
transitStyleProfile,
|
||||
} from './transitPresetStyle.js';
|
||||
import { TRANSIT_MODES } from './transitFeeds.js';
|
||||
|
||||
test('profile mapping: NVG/FLIR/noir and cockpit nvg are mono, retro is crt, everything else normal', () => {
|
||||
for (const mono of ['surveillance', 'thermal', 'noir', 'nvg'])
|
||||
assert.equal(transitStyleProfile(mono), 'mono', mono);
|
||||
assert.equal(transitStyleProfile('retro'), 'crt');
|
||||
for (const plain of [
|
||||
'normal',
|
||||
'anime',
|
||||
'snow',
|
||||
'minecraft',
|
||||
'',
|
||||
null,
|
||||
undefined,
|
||||
'optical',
|
||||
])
|
||||
assert.equal(transitStyleProfile(plain), 'normal', String(plain));
|
||||
});
|
||||
|
||||
test('normal uses mode colour, unit scale and a one-pixel halo', () => {
|
||||
for (const mode of TRANSIT_MODES) {
|
||||
assert.equal(presetSpriteRgba('normal', mode), null);
|
||||
assert.equal(presetSpriteRgba(undefined, mode), null);
|
||||
}
|
||||
assert.equal(presetSpriteScale('normal'), 1);
|
||||
assert.equal(presetSpriteOutlinePx('normal'), 1);
|
||||
});
|
||||
|
||||
test('mono: every mode is a white core, larger, with a dark halo in screen pixels', () => {
|
||||
for (const style of ['surveillance', 'thermal', 'noir', 'nvg']) {
|
||||
for (const mode of TRANSIT_MODES) {
|
||||
assert.deepEqual(
|
||||
presetSpriteRgba(style, mode),
|
||||
[255, 255, 255, 1],
|
||||
`${style} ${mode}`,
|
||||
);
|
||||
}
|
||||
assert.ok(presetSpriteScale(style) === 1.5, `${style} is drawn larger`);
|
||||
// At least a whole screen pixel and a half of ring around a five-pixel
|
||||
// body: thinner than that vanishes through blur and pixelation.
|
||||
assert.ok(
|
||||
presetSpriteOutlinePx(style) >= 1.5,
|
||||
`${style} halo is ${presetSpriteOutlinePx(style)} px`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('crt: saturated per-mode colours, distinct, upsized, thinner halo', () => {
|
||||
const seen = new Set();
|
||||
for (const mode of TRANSIT_MODES) {
|
||||
const rgba = presetSpriteRgba('retro', mode);
|
||||
assert.ok(Array.isArray(rgba) && rgba.length === 4, mode);
|
||||
seen.add(rgba.slice(0, 3).join(','));
|
||||
const [r, g, b] = rgba;
|
||||
assert.ok(
|
||||
Math.max(r, g, b) === 255,
|
||||
`${mode} is fully saturated on one channel`,
|
||||
);
|
||||
}
|
||||
assert.equal(seen.size, TRANSIT_MODES.length, 'no two modes share a colour');
|
||||
assert.ok(presetSpriteScale('retro') > 1.2);
|
||||
assert.ok(
|
||||
presetSpriteOutlinePx('retro') > 0 &&
|
||||
presetSpriteOutlinePx('retro') < presetSpriteOutlinePx('thermal'),
|
||||
);
|
||||
});
|
||||
|
||||
test('every mode has its own bracket tier, unknown modes fall to transit_unknown', () => {
|
||||
const tiers = new Set();
|
||||
for (const mode of TRANSIT_MODES) tiers.add(transitModeTier(mode));
|
||||
assert.equal(tiers.size, TRANSIT_MODES.length);
|
||||
assert.equal(transitModeTier('spaceship'), 'transit_unknown');
|
||||
assert.equal(transitModeTier(undefined), 'transit_unknown');
|
||||
assert.deepEqual([...tiers].sort(), [...TRANSIT_TIER_KEYS].sort());
|
||||
});
|
||||
|
||||
test('all styled tuples are valid rgba: channels 0–255 ints, alpha in (0, 1]', () => {
|
||||
for (const style of ['thermal', 'retro']) {
|
||||
for (const mode of TRANSIT_MODES) {
|
||||
const [r, g, b, a] = presetSpriteRgba(style, mode);
|
||||
for (const c of [r, g, b])
|
||||
assert.ok(Number.isInteger(c) && c >= 0 && c <= 255);
|
||||
assert.ok(a > 0 && a <= 1);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,401 @@
|
||||
/**
|
||||
* @module transitProxy
|
||||
* @description Pure server-side mechanics for the `/api/transit` proxy.
|
||||
*
|
||||
* Kept free of Vite/Node middleware state (the terrainHeightsProxy pattern)
|
||||
* so path resolution, snapshot shaping, and the cache/stale policy can be
|
||||
* exercised by the offline node:test suite. The middleware in vite.config.js
|
||||
* only does I/O: fetch the registered upstream, hand the bytes here, send.
|
||||
*/
|
||||
|
||||
import {
|
||||
GTFS_INCREMENTALITY_FULL_DATASET,
|
||||
decodeVehiclePositions,
|
||||
} from './gtfsRealtime.js';
|
||||
import { getTransitFeed } from './transitFeeds.js';
|
||||
|
||||
/** Fresh window: a snapshot younger than this is served without refetching. */
|
||||
export const TRANSIT_PROXY_TTL_MS = 15_000;
|
||||
/** Serve-stale window: after an upstream failure, a snapshot this old still ships (marked stale). */
|
||||
export const TRANSIT_PROXY_STALE_MAX_MS = 10 * 60_000;
|
||||
/** Upstream fetch timeout. National feeds (Entur ≈ 1.4 MB) need headroom. */
|
||||
export const TRANSIT_PROXY_TIMEOUT_MS = 15_000;
|
||||
/** Hard cap on upstream bytes: the largest registered feed is well under 1 MB. */
|
||||
export const TRANSIT_PROXY_MAX_BODY_BYTES = 8 * 1024 * 1024;
|
||||
/** Redirect hops followed before a feed is declared unreachable. */
|
||||
export const TRANSIT_MAX_REDIRECTS = 3;
|
||||
/**
|
||||
* The 3xx codes that actually mean "go somewhere else". 304 is NOT one of them:
|
||||
* it is the successful answer to a conditional request and carries no Location,
|
||||
* so treating the whole 3xx range as a redirect turns every healthy unchanged
|
||||
* feed into a failure and walks it up the backoff ladder.
|
||||
*/
|
||||
export const TRANSIT_REDIRECT_STATUSES = Object.freeze([
|
||||
301, 302, 303, 307, 308,
|
||||
]);
|
||||
|
||||
/**
|
||||
* Whether an upstream status is a redirect this proxy should follow.
|
||||
* @param {number} status
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isTransitRedirectStatus(status) {
|
||||
return TRANSIT_REDIRECT_STATUSES.includes(Number(status));
|
||||
}
|
||||
/**
|
||||
* Admission window for upstream requests. One browser polls a feed every 15 s
|
||||
* (4/min); the allowance leaves room for a second tab and a manual reload, and
|
||||
* the global figure bounds what this process can ask of ALL operators at once.
|
||||
*/
|
||||
export const TRANSIT_ADMISSION_WINDOW_MS = 60_000;
|
||||
export const TRANSIT_ADMISSION_MAX_PER_FEED = 8;
|
||||
export const TRANSIT_ADMISSION_MAX_GLOBAL = 40;
|
||||
/**
|
||||
* Cooldown ladder after consecutive upstream failures, in ms. A cold feed that
|
||||
* is simply down must not be re-asked once per poll for the whole session: the
|
||||
* first retry is quick, the last rung is five minutes, and the ladder resets on
|
||||
* the first success.
|
||||
*/
|
||||
export const TRANSIT_BACKOFF_LADDER_MS = Object.freeze([
|
||||
5_000, 15_000, 60_000, 300_000,
|
||||
]);
|
||||
|
||||
/**
|
||||
* Resolve `/vehicles/<feedId>` (the path after the `/api/transit` mount) to a
|
||||
* registered feed. Anything else — a different route, an unknown id, path
|
||||
* tricks, a query string — resolves to null and the caller 404s.
|
||||
* @param {string} url Request URL relative to the mount point.
|
||||
* @returns {{ route: 'feeds' } | { route: 'vehicles', feed: object } | null}
|
||||
*/
|
||||
/** Validate the entire decoded identifier, including UTF-8 length. */
|
||||
export function validTransitIdentifier(value) {
|
||||
return (
|
||||
typeof value === 'string' &&
|
||||
value.length > 0 &&
|
||||
!/[\u0000-\u001f\u007f/\\]/u.test(value) &&
|
||||
value !== '.' &&
|
||||
value !== '..' &&
|
||||
new TextEncoder().encode(value).length <= 256 &&
|
||||
!/[\uD800-\uDFFF]/u.test(value)
|
||||
);
|
||||
}
|
||||
export function resolveTransitRoute(url) {
|
||||
const pathname = String(url || '').split('?')[0];
|
||||
if (pathname === '/feeds' || pathname === '/feeds/')
|
||||
return { route: 'feeds' };
|
||||
const match = /^\/(vehicles|trail)\/([^/]+)(?:\/([^/]+))?\/?$/.exec(pathname);
|
||||
if (!match) return null;
|
||||
let id, vehicleId;
|
||||
try {
|
||||
id = decodeURIComponent(match[2]);
|
||||
vehicleId = match[3] === undefined ? null : decodeURIComponent(match[3]);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const feed = getTransitFeed(id);
|
||||
if (!feed) return null;
|
||||
if (match[1] === 'vehicles')
|
||||
return vehicleId === null ? { route: 'vehicles', feed } : null;
|
||||
return feed.historyRetention === true && validTransitIdentifier(vehicleId)
|
||||
? { route: 'trail', feed, vehicleId }
|
||||
: null;
|
||||
}
|
||||
|
||||
/** Selection reads are bounded and cancellable through body parsing. */
|
||||
export async function fetchTransitHistory(
|
||||
feedId,
|
||||
vehicleId,
|
||||
signal,
|
||||
fetchImpl = fetch,
|
||||
) {
|
||||
if (!validTransitIdentifier(vehicleId))
|
||||
throw new TypeError('Invalid transit vehicle identifier');
|
||||
const response = await fetchImpl(
|
||||
`/api/transit/trail/${encodeURIComponent(feedId)}/${encodeURIComponent(vehicleId)}`,
|
||||
{ signal, headers: { Accept: 'application/json' } },
|
||||
);
|
||||
if (!response.ok) throw new Error(`Transit history HTTP ${response.status}`);
|
||||
const reader = response.body?.getReader?.();
|
||||
let text = '';
|
||||
if (reader) {
|
||||
const decoder = new TextDecoder();
|
||||
let bytes = 0;
|
||||
try {
|
||||
while (true) {
|
||||
signal?.throwIfAborted();
|
||||
const chunk = await reader.read();
|
||||
if (chunk.done) break;
|
||||
bytes += chunk.value.byteLength;
|
||||
if (bytes > 32768)
|
||||
throw new RangeError('Transit history exceeds response limit');
|
||||
text += decoder.decode(chunk.value, { stream: true });
|
||||
}
|
||||
text += decoder.decode();
|
||||
} finally {
|
||||
await reader.cancel().catch(() => {});
|
||||
}
|
||||
} else {
|
||||
text = await response.text();
|
||||
if (new TextEncoder().encode(text).length > 32768)
|
||||
throw new RangeError('Transit history exceeds response limit');
|
||||
}
|
||||
signal?.throwIfAborted();
|
||||
const payload = JSON.parse(text);
|
||||
if (
|
||||
payload.version !== 1 ||
|
||||
payload.feedId !== feedId ||
|
||||
payload.vehicleId !== vehicleId ||
|
||||
!Array.isArray(payload.fixes) ||
|
||||
payload.fixes.length > 128 ||
|
||||
!Array.isArray(payload.epochs) ||
|
||||
payload.epochs.length > 8
|
||||
)
|
||||
throw new TypeError('Invalid transit history response');
|
||||
let previous = -Infinity;
|
||||
const epochs = new Set();
|
||||
for (const epoch of payload.epochs) {
|
||||
if (
|
||||
!Number.isInteger(epoch.id) ||
|
||||
epoch.id < 1 ||
|
||||
epoch.id > 0xffffffff ||
|
||||
epochs.has(epoch.id) ||
|
||||
!['trip', 'route', 'mode'].every(
|
||||
(key) =>
|
||||
typeof epoch[key] === 'string' &&
|
||||
new TextEncoder().encode(epoch[key]).length <= 256,
|
||||
)
|
||||
)
|
||||
throw new TypeError('Invalid transit history epoch');
|
||||
epochs.add(epoch.id);
|
||||
}
|
||||
for (const fix of payload.fixes) {
|
||||
if (
|
||||
!Array.isArray(fix) ||
|
||||
fix.length !== 5 ||
|
||||
!fix.every(Number.isFinite) ||
|
||||
fix[0] < 0 ||
|
||||
fix[0] <= previous ||
|
||||
Math.abs(fix[1]) > 90 ||
|
||||
Math.abs(fix[2]) > 180 ||
|
||||
!Number.isInteger(fix[3]) ||
|
||||
!epochs.has(fix[4])
|
||||
)
|
||||
throw new TypeError('Invalid transit history fix');
|
||||
previous = fix[0];
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* Request headers for one upstream fetch.
|
||||
*
|
||||
* Three of these are obligations, not politeness. Feeds that ask consumers to
|
||||
* identify themselves get their header from the registry (Entur requires
|
||||
* `ET-Client-Name`; OVapi asks that the User-Agent say who you are). OVapi also
|
||||
* asks anyone polling faster than once a minute to send conditional-request
|
||||
* validators and to accept gzip, so both are sent to every feed: harmless
|
||||
* where it is not asked for, and it spares each operator a full body whenever
|
||||
* nothing has changed.
|
||||
*
|
||||
* @param {object} feed Registry entry.
|
||||
* @param {{etag?: string|null, lastModified?: string|null}} [validators] From the cached snapshot.
|
||||
* @returns {Record<string, string>}
|
||||
*/
|
||||
export function transitUpstreamHeaders(feed, validators = null) {
|
||||
return {
|
||||
'User-Agent':
|
||||
'gods-eye-view-transit-proxy/1.0 (+https://github.com/bilawalsidhu/gods-eye-view)',
|
||||
Accept: 'application/x-protobuf, application/octet-stream;q=0.9, */*;q=0.1',
|
||||
'Accept-Encoding': 'gzip',
|
||||
...(validators?.etag ? { 'If-None-Match': validators.etag } : {}),
|
||||
...(validators?.lastModified
|
||||
? { 'If-Modified-Since': validators.lastModified }
|
||||
: {}),
|
||||
...(feed?.headers || {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Only https upstreams are fetched.
|
||||
* @param {string} url Request or response URL.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isAcceptableTransitUpstreamUrl(url) {
|
||||
try {
|
||||
return new URL(url).protocol === 'https:';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether one redirect hop may be followed, BEFORE it is requested.
|
||||
*
|
||||
* Checking the FINAL url after `redirect: 'follow'` is too late: by then the
|
||||
* disallowed host has already been contacted, has already seen this server's
|
||||
* request, and has already answered. So every hop is resolved here first and a
|
||||
* hop that leaves the feed's own origin, or drops to plain http, is refused —
|
||||
* the same rule the CCTV frame proxy applies to camera images.
|
||||
*
|
||||
* @param {string} originUrl The registered feed URL (defines the allowed origin).
|
||||
* @param {string} currentUrl The URL that produced this redirect (for relative Location).
|
||||
* @param {string|null} location Raw `Location` header value.
|
||||
* @returns {{ ok: true, url: string } | { ok: false, reason: string }}
|
||||
*/
|
||||
export function transitRedirectDecision(originUrl, currentUrl, location) {
|
||||
if (!location)
|
||||
return { ok: false, reason: 'redirect without a Location header' };
|
||||
let origin;
|
||||
let next;
|
||||
try {
|
||||
origin = new URL(originUrl).origin;
|
||||
next = new URL(location, currentUrl);
|
||||
} catch {
|
||||
return { ok: false, reason: 'redirect target is not a valid URL' };
|
||||
}
|
||||
if (next.protocol !== 'https:')
|
||||
return { ok: false, reason: 'redirect left https' };
|
||||
if (next.origin !== origin)
|
||||
return { ok: false, reason: `redirect left ${origin}` };
|
||||
return { ok: true, url: next.toString() };
|
||||
}
|
||||
|
||||
/**
|
||||
* Cooldown before the next upstream attempt after `failures` consecutive
|
||||
* failures. Zero while the feed is healthy.
|
||||
* @param {number} failures Consecutive failures (0 = none).
|
||||
* @returns {number} ms to wait before the next upstream attempt.
|
||||
*/
|
||||
export function nextTransitBackoffMs(failures) {
|
||||
const count = Number.isFinite(failures) ? Math.floor(failures) : 0;
|
||||
if (count <= 0) return 0;
|
||||
const index = Math.min(count, TRANSIT_BACKOFF_LADDER_MS.length) - 1;
|
||||
return TRANSIT_BACKOFF_LADDER_MS[index];
|
||||
}
|
||||
|
||||
/**
|
||||
* Error thrown when a feed's shape is unusable rather than merely unavailable.
|
||||
* Carries `transitReason` so the middleware can answer honestly.
|
||||
*/
|
||||
export class TransitFeedShapeError extends Error {
|
||||
constructor(message, reason) {
|
||||
super(message);
|
||||
this.name = 'TransitFeedShapeError';
|
||||
this.transitReason = reason;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Give every vehicle a best-available report time, and say WHERE it came from.
|
||||
*
|
||||
* A feed that omits `VehiclePosition.timestamp` is not reporting "now" — it is
|
||||
* reporting nothing, and treating the moment WE fetched as the moment the bus
|
||||
* was there is how a five-minute-old fix gets drawn as live. The ladder is
|
||||
* explicit instead: the vehicle's own timestamp, else the feed header's
|
||||
* timestamp (the operator's own statement about the snapshot), else fetch time,
|
||||
* which is labelled as a guess so the layer can age it conservatively.
|
||||
*
|
||||
* @param {object[]} vehicles Normalized vehicle records.
|
||||
* @param {number|null} headerTimestamp Feed header timestamp (epoch seconds).
|
||||
* @param {number} fetchedAtS Fetch time (epoch seconds).
|
||||
* @returns {object[]} Records with `timestamp` and `timestampSource` set.
|
||||
*/
|
||||
export function repairVehicleTimestamps(vehicles, headerTimestamp, fetchedAtS) {
|
||||
const header =
|
||||
Number.isFinite(headerTimestamp) && headerTimestamp > 0
|
||||
? headerTimestamp
|
||||
: null;
|
||||
return (vehicles || []).map((vehicle) => {
|
||||
if (Number.isFinite(vehicle.timestamp) && vehicle.timestamp > 0) {
|
||||
return { ...vehicle, timestampSource: 'vehicle' };
|
||||
}
|
||||
if (header !== null)
|
||||
return { ...vehicle, timestamp: header, timestampSource: 'header' };
|
||||
return { ...vehicle, timestamp: fetchedAtS, timestampSource: 'fetch' };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode upstream bytes into the JSON snapshot the browser consumes.
|
||||
*
|
||||
* A DIFFERENTIAL feed is refused outright. Differential GTFS-Realtime carries
|
||||
* only what changed, so reading one as if it were a full snapshot would make
|
||||
* every unmentioned vehicle look like it had vanished. Deletion semantics are
|
||||
* not implemented, so the honest answer is to decline the feed, not to render
|
||||
* a wrong one.
|
||||
*
|
||||
* @param {object} feed Registry entry.
|
||||
* @param {Uint8Array|ArrayBuffer} bytes Raw GTFS-RT FeedMessage.
|
||||
* @param {number} [now=Date.now()] Fetch time (ms epoch).
|
||||
* @returns {{ feedId: string, name: string, fetchedAt: number, feedTimestamp: number|null,
|
||||
* version: string|null, entityCount: number, truncated: boolean, count: number, vehicles: object[] }}
|
||||
*/
|
||||
export function buildTransitSnapshot(feed, bytes, now = Date.now()) {
|
||||
const decoded = decodeVehiclePositions(bytes);
|
||||
if (decoded.incrementality !== GTFS_INCREMENTALITY_FULL_DATASET) {
|
||||
throw new TransitFeedShapeError(
|
||||
`feed is differential (incrementality ${decoded.incrementality})`,
|
||||
'differential',
|
||||
);
|
||||
}
|
||||
return {
|
||||
feedId: feed.id,
|
||||
name: feed.name,
|
||||
fetchedAt: now,
|
||||
feedTimestamp: decoded.timestamp,
|
||||
version: decoded.version,
|
||||
entityCount: decoded.entityCount,
|
||||
truncated: decoded.truncated,
|
||||
count: decoded.vehicles.length,
|
||||
vehicles: repairVehicleTimestamps(
|
||||
decoded.vehicles,
|
||||
decoded.timestamp,
|
||||
Math.floor(now / 1000),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify a cache entry for the request policy.
|
||||
* @param {{ at: number }|null|undefined} entry Cached snapshot (`at` = fetch ms).
|
||||
* @param {number} now
|
||||
* @returns {'none'|'fresh'|'stale'|'expired'}
|
||||
*/
|
||||
export function transitCacheState(entry, now) {
|
||||
if (!entry || !Number.isFinite(entry.at)) return 'none';
|
||||
const age = now - entry.at;
|
||||
if (age < 0) return 'fresh';
|
||||
if (age < TRANSIT_PROXY_TTL_MS) return 'fresh';
|
||||
if (age < TRANSIT_PROXY_STALE_MAX_MS) return 'stale';
|
||||
return 'expired';
|
||||
}
|
||||
|
||||
/**
|
||||
* Response headers for a snapshot. `X-GEV-Cache` mirrors the other proxies
|
||||
* (HIT / MISS / INFLIGHT / STALE-ERROR) so the layer can surface staleness.
|
||||
* @param {'HIT'|'MISS'|'INFLIGHT'|'STALE-ERROR'} cacheState
|
||||
* @param {string} [upstreamHost]
|
||||
* @returns {Record<string, string>}
|
||||
*/
|
||||
export function transitResponseHeaders(
|
||||
cacheState,
|
||||
upstreamHost = '',
|
||||
contactedAt = null,
|
||||
) {
|
||||
return {
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
'Cache-Control':
|
||||
cacheState === 'STALE-ERROR'
|
||||
? 'no-store'
|
||||
: `public, max-age=${Math.floor(TRANSIT_PROXY_TTL_MS / 1000)}`,
|
||||
'X-GEV-Cache': cacheState,
|
||||
...(upstreamHost ? { 'X-Transit-Upstream': upstreamHost } : {}),
|
||||
// When the operator last ANSWERED, which is not when the body was fetched.
|
||||
// A feed whose file has not changed answers 304 forever, and the body we
|
||||
// keep serving carries its original fetch time; without this the browser
|
||||
// would read a healthy revalidated feed as one that had gone silent.
|
||||
...(Number.isFinite(contactedAt)
|
||||
? { 'X-Transit-Contact': String(Math.floor(contactedAt)) }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { PbfWriter } from 'pbf';
|
||||
import {
|
||||
TRANSIT_BACKOFF_LADDER_MS,
|
||||
TRANSIT_PROXY_STALE_MAX_MS,
|
||||
TRANSIT_PROXY_TTL_MS,
|
||||
TransitFeedShapeError,
|
||||
buildTransitSnapshot,
|
||||
isAcceptableTransitUpstreamUrl,
|
||||
nextTransitBackoffMs,
|
||||
repairVehicleTimestamps,
|
||||
resolveTransitRoute,
|
||||
transitCacheState,
|
||||
transitRedirectDecision,
|
||||
transitResponseHeaders,
|
||||
transitUpstreamHeaders,
|
||||
} from './transitProxy.js';
|
||||
import { getTransitFeed } from './transitFeeds.js';
|
||||
|
||||
test('route resolution admits only the catalog and registered feed ids', () => {
|
||||
assert.deepEqual(resolveTransitRoute('/feeds'), { route: 'feeds' });
|
||||
assert.deepEqual(resolveTransitRoute('/feeds/?x=1'), { route: 'feeds' });
|
||||
assert.equal(resolveTransitRoute('/vehicles/mbta')?.feed?.id, 'mbta');
|
||||
assert.equal(resolveTransitRoute('/vehicles/mbta/')?.feed?.id, 'mbta');
|
||||
assert.equal(resolveTransitRoute('/vehicles/mbta?trip=1')?.feed?.id, 'mbta');
|
||||
assert.equal(resolveTransitRoute('/vehicles/nope'), null);
|
||||
assert.equal(resolveTransitRoute('/vehicles/'), null);
|
||||
assert.equal(resolveTransitRoute('/vehicles/mbta/extra'), null);
|
||||
assert.equal(resolveTransitRoute('/vehicles/..%2F..%2Fetc'), null);
|
||||
assert.equal(resolveTransitRoute('/vehicles/%E0%A4%A'), null); // malformed escape never throws
|
||||
assert.equal(resolveTransitRoute('/'), null);
|
||||
assert.equal(resolveTransitRoute(''), null);
|
||||
assert.equal(resolveTransitRoute(undefined), null);
|
||||
});
|
||||
|
||||
test('upstream headers identify the proxy and carry feed-specific identification', () => {
|
||||
const plain = transitUpstreamHeaders(getTransitFeed('mbta'));
|
||||
assert.match(plain['User-Agent'], /gods-eye-view-transit-proxy/);
|
||||
assert.match(plain.Accept, /x-protobuf/);
|
||||
const entur = transitUpstreamHeaders(getTransitFeed('entur-norway'));
|
||||
assert.equal(entur['ET-Client-Name'], 'gods-eye-view-transit');
|
||||
assert.ok(transitUpstreamHeaders(null)['User-Agent']);
|
||||
});
|
||||
|
||||
test('only https upstreams are acceptable, including after a redirect', () => {
|
||||
assert.equal(
|
||||
isAcceptableTransitUpstreamUrl('https://cdn.mbta.com/x.pb'),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
isAcceptableTransitUpstreamUrl(
|
||||
'http://gtfs.ovapi.nl/nl/vehiclePositions.pb',
|
||||
),
|
||||
false,
|
||||
);
|
||||
assert.equal(isAcceptableTransitUpstreamUrl('ftp://x'), false);
|
||||
assert.equal(isAcceptableTransitUpstreamUrl('not a url'), false);
|
||||
});
|
||||
|
||||
test('snapshot shape carries provenance the layer displays', () => {
|
||||
const writer = new PbfWriter();
|
||||
const header = new PbfWriter();
|
||||
header.writeStringField(1, '2.0');
|
||||
header.writeVarintField(3, 1_700_000_000);
|
||||
writer.writeBytesField(1, header.finish());
|
||||
const snapshot = buildTransitSnapshot(
|
||||
getTransitFeed('mbta'),
|
||||
writer.finish(),
|
||||
12345,
|
||||
);
|
||||
assert.equal(snapshot.feedId, 'mbta');
|
||||
assert.equal(snapshot.name, 'MBTA');
|
||||
assert.equal(snapshot.fetchedAt, 12345);
|
||||
assert.equal(snapshot.feedTimestamp, 1_700_000_000);
|
||||
assert.equal(snapshot.version, '2.0');
|
||||
assert.equal(snapshot.count, 0);
|
||||
assert.deepEqual(snapshot.vehicles, []);
|
||||
});
|
||||
|
||||
test('cache policy: fresh within TTL, stale until the serve-stale window, expired after', () => {
|
||||
const now = 1_000_000;
|
||||
assert.equal(transitCacheState(null, now), 'none');
|
||||
assert.equal(transitCacheState({ at: Number.NaN }, now), 'none');
|
||||
assert.equal(transitCacheState({ at: now }, now), 'fresh');
|
||||
assert.equal(
|
||||
transitCacheState({ at: now - TRANSIT_PROXY_TTL_MS + 1 }, now),
|
||||
'fresh',
|
||||
);
|
||||
assert.equal(
|
||||
transitCacheState({ at: now - TRANSIT_PROXY_TTL_MS }, now),
|
||||
'stale',
|
||||
);
|
||||
assert.equal(
|
||||
transitCacheState({ at: now - TRANSIT_PROXY_STALE_MAX_MS + 1 }, now),
|
||||
'stale',
|
||||
);
|
||||
assert.equal(
|
||||
transitCacheState({ at: now - TRANSIT_PROXY_STALE_MAX_MS }, now),
|
||||
'expired',
|
||||
);
|
||||
assert.equal(transitCacheState({ at: now + 5000 }, now), 'fresh'); // clock skew never expires a fresh fetch
|
||||
});
|
||||
|
||||
test('response headers mark cache state and never let a stale-error response be cached downstream', () => {
|
||||
assert.equal(
|
||||
transitResponseHeaders('HIT', 'cdn.mbta.com')['X-Transit-Upstream'],
|
||||
'cdn.mbta.com',
|
||||
);
|
||||
assert.equal(
|
||||
transitResponseHeaders('HIT')['Cache-Control'],
|
||||
'public, max-age=15',
|
||||
);
|
||||
assert.equal(
|
||||
transitResponseHeaders('STALE-ERROR')['Cache-Control'],
|
||||
'no-store',
|
||||
);
|
||||
assert.equal(transitResponseHeaders('MISS')['X-GEV-Cache'], 'MISS');
|
||||
});
|
||||
|
||||
/** A minimal FeedMessage with the given header fields and vehicle entities. */
|
||||
function encodeFeed({
|
||||
incrementality = null,
|
||||
timestamp = 1_700_000_000,
|
||||
entities = [],
|
||||
}) {
|
||||
const writer = new PbfWriter();
|
||||
const header = new PbfWriter();
|
||||
header.writeStringField(1, '2.0');
|
||||
if (incrementality !== null) header.writeVarintField(2, incrementality);
|
||||
if (timestamp !== null) header.writeVarintField(3, timestamp);
|
||||
writer.writeBytesField(1, header.finish());
|
||||
for (const entity of entities) {
|
||||
const feedEntity = new PbfWriter();
|
||||
feedEntity.writeStringField(1, entity.id);
|
||||
const vehicle = new PbfWriter();
|
||||
const position = new PbfWriter();
|
||||
position.writeFloatField(1, entity.lat);
|
||||
position.writeFloatField(2, entity.lon);
|
||||
vehicle.writeBytesField(2, position.finish());
|
||||
if (entity.timestamp) vehicle.writeVarintField(5, entity.timestamp);
|
||||
const descriptor = new PbfWriter();
|
||||
descriptor.writeStringField(1, entity.vehicleId || entity.id);
|
||||
vehicle.writeBytesField(8, descriptor.finish());
|
||||
feedEntity.writeBytesField(4, vehicle.finish());
|
||||
writer.writeBytesField(2, feedEntity.finish());
|
||||
}
|
||||
return writer.finish();
|
||||
}
|
||||
|
||||
test('a redirect is judged before it is followed, not after it has been answered', () => {
|
||||
const feed = { url: 'https://data.example.gov/download/feed.pb' };
|
||||
// Same origin, absolute and relative, is the only case that may proceed.
|
||||
assert.deepEqual(
|
||||
transitRedirectDecision(
|
||||
feed.url,
|
||||
feed.url,
|
||||
'https://data.example.gov/files/a.pb',
|
||||
),
|
||||
{ ok: true, url: 'https://data.example.gov/files/a.pb' },
|
||||
);
|
||||
assert.deepEqual(transitRedirectDecision(feed.url, feed.url, '/files/b.pb'), {
|
||||
ok: true,
|
||||
url: 'https://data.example.gov/files/b.pb',
|
||||
});
|
||||
// Another host is refused even though it is https — this is the hop that used
|
||||
// to be contacted first and inspected afterwards.
|
||||
const offOrigin = transitRedirectDecision(
|
||||
feed.url,
|
||||
feed.url,
|
||||
'https://cdn.elsewhere.test/a.pb',
|
||||
);
|
||||
assert.equal(offOrigin.ok, false);
|
||||
assert.match(offOrigin.reason, /left https:\/\/data\.example\.gov/);
|
||||
// A downgrade to http, a missing Location, and a malformed target all refuse.
|
||||
assert.equal(
|
||||
transitRedirectDecision(feed.url, feed.url, 'http://data.example.gov/a.pb')
|
||||
.ok,
|
||||
false,
|
||||
);
|
||||
assert.equal(transitRedirectDecision(feed.url, feed.url, null).ok, false);
|
||||
assert.equal(
|
||||
transitRedirectDecision(feed.url, feed.url, 'javascript:alert(1)').ok,
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
transitRedirectDecision(feed.url, feed.url, 'data:text/plain,x').ok,
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
transitRedirectDecision('not-a-url', 'not-a-url', '/a.pb').ok,
|
||||
false,
|
||||
);
|
||||
// A sibling port is a different origin.
|
||||
assert.equal(
|
||||
transitRedirectDecision(
|
||||
feed.url,
|
||||
feed.url,
|
||||
'https://data.example.gov:8443/a.pb',
|
||||
).ok,
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('failure cooldown climbs and stops climbing, and a healthy feed waits for nothing', () => {
|
||||
assert.equal(nextTransitBackoffMs(0), 0);
|
||||
assert.equal(nextTransitBackoffMs(-3), 0);
|
||||
assert.deepEqual([1, 2, 3, 4].map(nextTransitBackoffMs), [
|
||||
...TRANSIT_BACKOFF_LADDER_MS,
|
||||
]);
|
||||
// Past the ladder the wait stays at its last rung rather than growing forever.
|
||||
assert.equal(nextTransitBackoffMs(9), TRANSIT_BACKOFF_LADDER_MS.at(-1));
|
||||
assert.equal(nextTransitBackoffMs(500), TRANSIT_BACKOFF_LADDER_MS.at(-1));
|
||||
});
|
||||
|
||||
test('a differential feed is refused rather than read as a full snapshot', () => {
|
||||
const feed = getTransitFeed('mbta');
|
||||
const differential = encodeFeed({
|
||||
incrementality: 1,
|
||||
entities: [{ id: 'v1', lat: 42.36, lon: -71.05, timestamp: 1_700_000_000 }],
|
||||
});
|
||||
assert.throws(
|
||||
() => buildTransitSnapshot(feed, differential, 1_700_000_000_000),
|
||||
(error) =>
|
||||
error instanceof TransitFeedShapeError &&
|
||||
error.transitReason === 'differential',
|
||||
);
|
||||
// The same bytes with FULL_DATASET decode normally, so the refusal is about
|
||||
// the header and not about the payload.
|
||||
const full = encodeFeed({
|
||||
incrementality: 0,
|
||||
entities: [{ id: 'v1', lat: 42.36, lon: -71.05, timestamp: 1_700_000_000 }],
|
||||
});
|
||||
assert.equal(buildTransitSnapshot(feed, full, 1_700_000_000_000).count, 1);
|
||||
// An absent incrementality field means FULL_DATASET by the spec's default.
|
||||
assert.equal(
|
||||
buildTransitSnapshot(feed, encodeFeed({ entities: [] }), 1).count,
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
test('a vehicle with no time of its own borrows the feed header time and says so', () => {
|
||||
const fetchedAtS = 1_700_000_500;
|
||||
const repaired = repairVehicleTimestamps(
|
||||
[
|
||||
{ id: 'own', timestamp: 1_700_000_400 },
|
||||
{ id: 'none' },
|
||||
{ id: 'zero', timestamp: 0 },
|
||||
],
|
||||
1_700_000_450,
|
||||
fetchedAtS,
|
||||
);
|
||||
assert.deepEqual(
|
||||
repaired.map((vehicle) => [
|
||||
vehicle.id,
|
||||
vehicle.timestamp,
|
||||
vehicle.timestampSource,
|
||||
]),
|
||||
[
|
||||
['own', 1_700_000_400, 'vehicle'],
|
||||
['none', 1_700_000_450, 'header'],
|
||||
['zero', 1_700_000_450, 'header'],
|
||||
],
|
||||
);
|
||||
// With no header either, fetch time is used and is labelled as such — never
|
||||
// presented as the vehicle's own report.
|
||||
const noHeader = repairVehicleTimestamps([{ id: 'none' }], null, fetchedAtS);
|
||||
assert.deepEqual(
|
||||
[noHeader[0].timestamp, noHeader[0].timestampSource],
|
||||
[fetchedAtS, 'fetch'],
|
||||
);
|
||||
assert.deepEqual(repairVehicleTimestamps(null, null, fetchedAtS), []);
|
||||
});
|
||||
|
||||
test('the snapshot builder fills timestamps from the header it decoded', () => {
|
||||
const feed = getTransitFeed('mbta');
|
||||
const bytes = encodeFeed({
|
||||
timestamp: 1_700_000_000,
|
||||
entities: [{ id: 'unstamped', lat: 42.36, lon: -71.05 }],
|
||||
});
|
||||
const snapshot = buildTransitSnapshot(feed, bytes, 1_700_000_900_000);
|
||||
assert.equal(snapshot.vehicles[0].timestamp, 1_700_000_000);
|
||||
assert.equal(snapshot.vehicles[0].timestampSource, 'header');
|
||||
assert.equal(snapshot.feedTimestamp, 1_700_000_000);
|
||||
});
|
||||
|
||||
test('conditional-request validators and gzip ride along with feed identification', () => {
|
||||
const entur = getTransitFeed('entur-norway');
|
||||
const plain = transitUpstreamHeaders(entur);
|
||||
assert.equal(plain['Accept-Encoding'], 'gzip');
|
||||
assert.equal(plain['ET-Client-Name'], 'gods-eye-view-transit');
|
||||
assert.equal('If-None-Match' in plain, false);
|
||||
const conditional = transitUpstreamHeaders(entur, {
|
||||
etag: 'W/"abc"',
|
||||
lastModified: 'Wed, 10 Sep 2026 12:00:00 GMT',
|
||||
});
|
||||
assert.equal(conditional['If-None-Match'], 'W/"abc"');
|
||||
assert.equal(
|
||||
conditional['If-Modified-Since'],
|
||||
'Wed, 10 Sep 2026 12:00:00 GMT',
|
||||
);
|
||||
assert.match(conditional['User-Agent'], /gods-eye-view/);
|
||||
});
|
||||
|
||||
test('retained routes validate the entire identifier and only MBTA is opted in', () => {
|
||||
assert.equal(resolveTransitRoute('/trail/mbta/bus%201').vehicleId, 'bus 1');
|
||||
for (const path of [
|
||||
'/trail/mbta/a%2Fb',
|
||||
'/trail/mbta/%00',
|
||||
'/trail/mbta/%5C',
|
||||
'/trail/mbta/%2E%2E',
|
||||
'/trail/mbta/%E0%A4%A',
|
||||
`/trail/mbta/${encodeURIComponent('é'.repeat(129))}`,
|
||||
'/trail/capmetro-austin/b',
|
||||
])
|
||||
assert.equal(resolveTransitRoute(path), null);
|
||||
});
|
||||
test('client history validates response bounds, epochs and cancellation through parsing', async () => {
|
||||
const { fetchTransitHistory } = await import('./transitProxy.js');
|
||||
const payload = {
|
||||
version: 1,
|
||||
feedId: 'mbta',
|
||||
vehicleId: 'b',
|
||||
epochs: [{ id: 1, trip: '', route: '1', mode: 'bus' }],
|
||||
fixes: [[1000, 42, -71, 1, 1]],
|
||||
};
|
||||
assert.deepEqual(
|
||||
await fetchTransitHistory(
|
||||
'mbta',
|
||||
'b',
|
||||
new AbortController().signal,
|
||||
async () => new Response(JSON.stringify(payload)),
|
||||
),
|
||||
payload,
|
||||
);
|
||||
for (const value of [
|
||||
{ ...payload, vehicleId: 'wrong' },
|
||||
{ ...payload, fixes: [[1000, 91, -71, 1, 1]] },
|
||||
{ ...payload, fixes: Array(129).fill(payload.fixes[0]) },
|
||||
{ ...payload, epochs: [] },
|
||||
])
|
||||
await assert.rejects(
|
||||
fetchTransitHistory(
|
||||
'mbta',
|
||||
'b',
|
||||
undefined,
|
||||
async () => new Response(JSON.stringify(value)),
|
||||
),
|
||||
);
|
||||
await assert.rejects(
|
||||
fetchTransitHistory(
|
||||
'mbta',
|
||||
'b',
|
||||
undefined,
|
||||
async () => new Response('x'.repeat(32769)),
|
||||
),
|
||||
/limit/,
|
||||
);
|
||||
const controller = new AbortController();
|
||||
await assert.rejects(
|
||||
fetchTransitHistory('mbta', 'b', controller.signal, async () => {
|
||||
controller.abort();
|
||||
return new Response(JSON.stringify(payload));
|
||||
}),
|
||||
{ name: 'AbortError' },
|
||||
);
|
||||
});
|
||||
Reference in new issue
Block a user