mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-11 13:52:19 +00:00
Merge branch 'main' into sameh/draw-shape
This commit is contained in:
105 files changed
+3555
-298
No files matched your search
+24
-4
@@ -673,20 +673,40 @@ export const OSM_PLACES_CREDIT = {
|
||||
/**
|
||||
* 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
|
||||
*
|
||||
* Two optional registry fields serve licences that ask for more than a name:
|
||||
* `sourceUrl` links the attribution to the publisher (Warsaw asks for a link
|
||||
* to its API), and `creditDated` adds the UTC date of the data, from the feed
|
||||
* header or else the fetch time (Warsaw and Poznań ask for the download or
|
||||
* update date). A dated credit is keyed by its date, so a new day registers a
|
||||
* new line instead of keeping yesterday's.
|
||||
* @param {{ id: string, attribution: string, license: string, licenseUrl: string,
|
||||
* sourceUrl?: string, creditDated?: boolean }} feed
|
||||
* @param {{ feedTimestamp?: number|null, fetchedAt?: number|null }} [snapshot]
|
||||
* `feedTimestamp` in epoch seconds, `fetchedAt` in epoch ms.
|
||||
* @returns {{ key: string, html: string }}
|
||||
*/
|
||||
export function transitFeedCredit(feed) {
|
||||
export function transitFeedCredit(feed, snapshot = null) {
|
||||
const escape = (text) =>
|
||||
String(text)
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"');
|
||||
const source = feed.sourceUrl
|
||||
? `<a href="${escape(feed.sourceUrl)}" target="_blank" rel="noopener">${escape(feed.attribution)}</a>`
|
||||
: escape(feed.attribution);
|
||||
const dataMs = Number.isFinite(snapshot?.feedTimestamp)
|
||||
? snapshot.feedTimestamp * 1000
|
||||
: snapshot?.fetchedAt;
|
||||
const date =
|
||||
feed.creditDated === true && Number.isFinite(dataMs) && dataMs > 0
|
||||
? new Date(dataMs).toISOString().slice(0, 10)
|
||||
: null;
|
||||
return {
|
||||
key: `transit-${feed.id}`,
|
||||
key: date ? `transit-${feed.id}-${date}` : `transit-${feed.id}`,
|
||||
html:
|
||||
`Transit (${escape(feed.attribution)}): ` +
|
||||
`Transit (${source}${date ? `, data of ${date}` : ''}): ` +
|
||||
`<a href="${escape(feed.licenseUrl)}" target="_blank" rel="noopener">${escape(feed.license)}</a>`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { DATA_CREDITS, NYC_DOT_DISCLAIMER } from './dataCredits.js';
|
||||
import {
|
||||
DATA_CREDITS,
|
||||
NYC_DOT_DISCLAIMER,
|
||||
transitFeedCredit,
|
||||
} from './dataCredits.js';
|
||||
import { getTransitFeed } from './transitFeeds.js';
|
||||
|
||||
test('every credit carries a unique key and some markup to render', () => {
|
||||
const keys = DATA_CREDITS.map((entry) => entry.key);
|
||||
@@ -190,3 +195,24 @@ test('the PR #1506 camera packs are credited with their sources and terms', () =
|
||||
/href="https:\/\/www\.river\.go\.jp\//,
|
||||
);
|
||||
});
|
||||
|
||||
test('Warsaw and Poznań credits carry the data date their licences ask for', () => {
|
||||
const warsaw = getTransitFeed('ztm-warszawa');
|
||||
// 2026-11-09T06:00:00Z as a GTFS-RT header timestamp (seconds).
|
||||
const credit = transitFeedCredit(warsaw, { feedTimestamp: 1_794_204_000 });
|
||||
assert.match(credit.html, /, data of 2026-11-09\)/);
|
||||
assert.match(credit.html, /href="http:\/\/api\.um\.warszawa\.pl"/);
|
||||
assert.equal(credit.key, 'transit-ztm-warszawa-2026-11-09');
|
||||
// Without a feed timestamp the fetch time is the download date.
|
||||
const poznan = transitFeedCredit(getTransitFeed('ztm-poznan'), {
|
||||
feedTimestamp: null,
|
||||
fetchedAt: Date.UTC(2026, 10, 10, 12),
|
||||
});
|
||||
assert.match(poznan.html, /data of 2026-11-10/);
|
||||
// Undated feeds keep one credit line for the session.
|
||||
const mbta = transitFeedCredit(getTransitFeed('mbta'), {
|
||||
feedTimestamp: 1_794_204_000,
|
||||
});
|
||||
assert.equal(mbta.key, 'transit-mbta');
|
||||
assert.doesNotMatch(mbta.html, /data of/);
|
||||
});
|
||||
@@ -120,9 +120,10 @@ test('fire detection key survives a refetch that renumbers the index', () => {
|
||||
});
|
||||
|
||||
test('two satellites over the same pixel at the same time stay distinct', () => {
|
||||
// The proxy merges SNPP + NOAA-20 + NOAA-21 without dedup and their orbits
|
||||
// overlap, so position and time alone are NOT unique. Collapsing them let a
|
||||
// restored selection land on the other satellite's record.
|
||||
// The proxy merges NOAA-20 + NOAA-21 without dedup and their orbits overlap,
|
||||
// so position and time alone are NOT unique. Collapsing them let a restored
|
||||
// selection land on the other satellite's record. Archived Suomi NPP (`N`)
|
||||
// rows must stay distinct too.
|
||||
const base = makeFire({ satellite: 'N20' });
|
||||
const twin = makeFire({ satellite: 'N21' });
|
||||
const suomi = makeFire({ satellite: 'N' });
|
||||
|
||||
@@ -21,7 +21,10 @@ assert.notEqual(start, -1, 'FIRMS refresh function exists');
|
||||
const end = config.indexOf('\n }', start);
|
||||
assert.notEqual(end, -1, 'FIRMS refresh function closes');
|
||||
const refreshSource = config.slice(start, end + 4);
|
||||
const SOURCES = ['VIIRS_NOAA20_NRT', 'VIIRS_NOAA21_NRT', 'VIIRS_SNPP_NRT', 'MODIS_NRT'];
|
||||
// Read the list from production so these tests follow it.
|
||||
const listed = config.match(/const SOURCES = \[([^\]]*)\];/);
|
||||
assert.ok(listed, 'FIRMS source list exists');
|
||||
const SOURCES = [...listed[1].matchAll(/'([A-Z0-9_]+)'/g)].map((m) => m[1]);
|
||||
const NOW = Date.UTC(2026, 8, 11, 12);
|
||||
const recent = { acqDate: '2026-09-11', acqTime: '1100' };
|
||||
|
||||
@@ -54,7 +57,7 @@ test('FIRMS retains large sources in order and filters expired rows', async () =
|
||||
assert.equal(result.fires[199_999], large.at(-1));
|
||||
assert.equal(result.fires.at(-1), last);
|
||||
assert.deepEqual(result.sources, SOURCES.map((source, index) => ({
|
||||
source, count: [200_000, 1, 0, 0][index], ok: true,
|
||||
source, count: [200_000, 1, 0][index], ok: true,
|
||||
})));
|
||||
});
|
||||
|
||||
@@ -63,7 +66,7 @@ test('FIRMS keeps successful sources when another upstream fails', async () => {
|
||||
if (source === SOURCES[1]) throw new Error('upstream unavailable');
|
||||
return [recent];
|
||||
})('fixture');
|
||||
assert.equal(result.fires.length, 3);
|
||||
assert.equal(result.fires.length, SOURCES.length - 1);
|
||||
assert.deepEqual(result.sources, SOURCES.map((source, index) => ({
|
||||
source, count: index === 1 ? 0 : 1, ok: index !== 1,
|
||||
})));
|
||||
@@ -77,7 +80,7 @@ test('FIRMS reports one failure if consuming a source throws before append', asy
|
||||
// Fault injection for aggregation; ordinary parsed CSV returns an array.
|
||||
return { length: 1, [Symbol.iterator]() { throw new Error('aggregation failed'); } };
|
||||
})('fixture');
|
||||
assert.equal(result.fires.length, 3);
|
||||
assert.equal(result.fires.length, SOURCES.length - 1);
|
||||
assert.deepEqual(result.sources, SOURCES.map((source, index) => ({
|
||||
source, count: index === 0 ? 0 : 1, ok: index !== 0,
|
||||
})));
|
||||
@@ -107,7 +110,7 @@ function wrappedAddressSelectionFailure() {
|
||||
{ name: 'ConnectTimeoutError', code: 'UND_ERR_CONNECT_TIMEOUT', cause: inner });
|
||||
return new TypeError('fetch failed', { cause });
|
||||
}
|
||||
const URL_WITH_KEY = 'https://firms.modaps.eosdis.nasa.gov/api/area/csv/SECRET/VIIRS_SNPP_NRT/world/2';
|
||||
const URL_WITH_KEY = 'https://firms.modaps.eosdis.nasa.gov/api/area/csv/SECRET/VIIRS_NOAA20_NRT/world/2';
|
||||
|
||||
test('FIRMS failures log the socket cause, never the keyed URL', () => {
|
||||
const described = describeFetchError(addressSelectionFailure());
|
||||
@@ -307,3 +310,9 @@ test('every FIRMS upstream request goes through the IPv4 fallback', async () =>
|
||||
assert.ok(calls[0][2] instanceof AbortSignal);
|
||||
assert.deepEqual(calls[1], ['read', response, calls[0][2]], 'the body read shares the request deadline');
|
||||
});
|
||||
|
||||
test('FIRMS requests only the satellites NASA still delivers', () => {
|
||||
// NASA stopped delivering Suomi NPP on 2026-11-02; asking for it only spends
|
||||
// a MAP_KEY transaction.
|
||||
assert.ok(!SOURCES.includes('VIIRS_SNPP_NRT'));
|
||||
});
|
||||
@@ -20,9 +20,11 @@ import { setMilitaryLayerActive } from './militaryRegistry.js';
|
||||
import {
|
||||
GROUND_FLOOR_LIFT_M,
|
||||
reportMeshFloorCell,
|
||||
reportValidatedMeshFloorCell,
|
||||
setMeshFloorPreferred,
|
||||
_clearMeshFloorCellsForTest,
|
||||
} from './groundFloor.js';
|
||||
import { resolveEllipsoidalGround } from './terrainHeights.js';
|
||||
import {
|
||||
_setTrackedOverlayHostForTest,
|
||||
destroyTrackedReadout,
|
||||
@@ -1181,6 +1183,39 @@ test('display floor: a grounded contact that drifted onto a higher cell is lifte
|
||||
assert.ok(Math.abs(Cesium.Math.toDegrees(c.longitude) + 97.6604) < 1e-6);
|
||||
});
|
||||
|
||||
test('display floor: reads the reading nearest the contact, not the cell centre', async () => {
|
||||
_clearDisplayFloorStateForTest();
|
||||
_clearMeshFloorCellsForTest();
|
||||
setMeshFloorPreferred(true);
|
||||
const realFetch = globalThis.fetch;
|
||||
globalThis.fetch = async () =>
|
||||
new Response(JSON.stringify({ results: [{ ellipsoid: 120 }] }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
try {
|
||||
await resolveEllipsoidalGround([{ lat: 30.317, lon: -97.717 }]);
|
||||
} finally {
|
||||
globalThis.fetch = realFetch;
|
||||
}
|
||||
// A hangar roof over the cell's centre; the apron where the contact is.
|
||||
assert.equal(reportValidatedMeshFloorCell(30.317, -97.717, 142), true);
|
||||
assert.equal(reportValidatedMeshFloorCell(30.3173, -97.7173, 121), true);
|
||||
const pos = Cesium.Cartesian3.fromDegrees(-97.7173, 30.3173, 100);
|
||||
const out = _floorGroundedDisplayPositionForTest(
|
||||
{ onGround: true },
|
||||
pos,
|
||||
false,
|
||||
);
|
||||
const c = _floorCarto(out);
|
||||
assert.ok(
|
||||
Math.abs(c.height - (121 + GROUND_FLOOR_LIFT_M)) < 0.05,
|
||||
`height ${c.height} should be the apron's 121 + lift`,
|
||||
);
|
||||
_clearMeshFloorCellsForTest();
|
||||
_clearDisplayFloorStateForTest();
|
||||
});
|
||||
|
||||
test('display floor: an AIRBORNE contact over the same cell is never lifted', () => {
|
||||
_clearDisplayFloorStateForTest();
|
||||
_clearMeshFloorCellsForTest();
|
||||
|
||||
@@ -13,7 +13,7 @@ import { isCalibratedAllocationRuntime } from '../../scripts/run-unit-tests.mjs'
|
||||
*/
|
||||
test('converged focus treatment stays within the GC-bracketed allocation budget', (t) => {
|
||||
if (!isCalibratedAllocationRuntime()) {
|
||||
return t.skip(`allocation budgets are calibrated for Node 24; running ${process.versions.node}`);
|
||||
return t.skip(`allocation budgets are calibrated for Node 24 or 26; running ${process.versions.node}`);
|
||||
}
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
|
||||
@@ -22,6 +22,7 @@ export const {
|
||||
reportMeshFloorCell,
|
||||
meshFloorSampleWithinPrior,
|
||||
reportValidatedMeshFloorCell,
|
||||
meshFloorPriorReady,
|
||||
cachedMeshFloor,
|
||||
_clearMeshFloorCellsForTest,
|
||||
cachedGroundFloor,
|
||||
|
||||
+17
-1
@@ -2385,11 +2385,27 @@ export class LayerLifecycle {
|
||||
/**
|
||||
* Ask presentation to repaint layer rows now, for data that lands outside
|
||||
* the manager tick (Transit's proximity polls, Directions' route steps).
|
||||
* @param {string} [layerId] - The layer whose row changed.
|
||||
*/
|
||||
refreshLayerStats() {
|
||||
refreshLayerStats(layerId) {
|
||||
this._dropRecoveredRefreshError(layerId);
|
||||
this._publishActivity({ type: 'status' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop a tick failure the layer's stats no longer report (the camera left
|
||||
* that feed) instead of showing it until the next tick.
|
||||
*/
|
||||
_dropRecoveredRefreshError(layerId) {
|
||||
const entry = layerId ? this.layers.get(layerId) : null;
|
||||
if (!entry?.managerRefreshError || entry.refreshing) return;
|
||||
const failure = refreshFailureFromStats(
|
||||
this._moduleStats(entry),
|
||||
entry.module.name || layerId,
|
||||
);
|
||||
if (!failure) entry.managerRefreshError = null;
|
||||
}
|
||||
|
||||
_publishActivity(change) {
|
||||
for (const callback of this._activityListeners) {
|
||||
try {
|
||||
|
||||
@@ -15,16 +15,6 @@ export class DataLayerManager extends LayerLifecycle {
|
||||
this._presentation.refresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* Repaint the toggle panel now. For layers whose data arrives outside their
|
||||
* manager tick (camera-driven loads such as Transit's proximity polls), so a
|
||||
* row shows its count when the data lands instead of at the next interval.
|
||||
* One DOM pass; skipped while the document is hidden.
|
||||
*/
|
||||
refreshLayerStats() {
|
||||
this._refreshTogglePanel();
|
||||
}
|
||||
|
||||
_buildMetaText(layer) {
|
||||
return this._presentation.panel._buildMetaText(layer);
|
||||
}
|
||||
|
||||
@@ -2677,6 +2677,31 @@ test('refreshLayerStats reaches presentation through the bare lifecycle', async
|
||||
assert.deepEqual(changes, ['status']);
|
||||
});
|
||||
|
||||
test('refreshLayerStats(layerId) drops a tick failure the layer no longer reports', async () => {
|
||||
const { LayerLifecycle } = await import('./lifecycle.js');
|
||||
const lifecycle = new LayerLifecycle({});
|
||||
let stats = { count: 0, error: 'Feed unavailable' };
|
||||
lifecycle.register({
|
||||
id: 'feed',
|
||||
name: 'Feed',
|
||||
getStats: () => stats,
|
||||
async update() {},
|
||||
});
|
||||
const entry = lifecycle.layers.get('feed');
|
||||
entry.enabled = true;
|
||||
entry.initialized = true;
|
||||
entry.managerRefreshError = 'Feed unavailable';
|
||||
|
||||
lifecycle.refreshLayerStats('feed');
|
||||
assert.equal(entry.managerRefreshError, 'Feed unavailable');
|
||||
|
||||
stats = { count: 0 };
|
||||
lifecycle.refreshLayerStats();
|
||||
assert.equal(entry.managerRefreshError, 'Feed unavailable');
|
||||
lifecycle.refreshLayerStats('feed');
|
||||
assert.equal(entry.managerRefreshError, null);
|
||||
});
|
||||
|
||||
test('periodic polls pause hidden, catch up once when due, and dispose visibility listeners', async (t) => {
|
||||
const listeners = new Set();
|
||||
const doc = {
|
||||
|
||||
@@ -24,7 +24,7 @@ import { sampleMeshFloorCells } from './meshFloorSampler.js';
|
||||
import { resolveEllipsoidalGround } from './terrainHeights.js';
|
||||
import {
|
||||
cachedGroundFloor, cachedMeshFloor, setMeshFloorPreferred,
|
||||
_clearMeshFloorCellsForTest,
|
||||
_clearMeshFloorCellsForTest, reportValidatedMeshFloorCell,
|
||||
} from './groundFloor.js';
|
||||
|
||||
/** Distinct site per test — terrainHeights' cache is module-global and warm
|
||||
@@ -137,3 +137,180 @@ test('sampler: a cell just across the antimeridian is still near the viewer', ()
|
||||
sampleMeshFloorCells(scene, [{ lat: -16.81, lon: 179.999 }], { viewerLat: -16.81, viewerLon: -179.999 });
|
||||
assert.equal(calls, 2, 'about 200 m apart, so both cells are probed');
|
||||
});
|
||||
|
||||
test('sampler: the probe goes where the point is, not to the cell centre', async () => {
|
||||
reset();
|
||||
const cell = nextSite();
|
||||
await seedDem(cell, 120.0);
|
||||
const asked = [];
|
||||
const scene = fakeScene((carto) => {
|
||||
asked.push(Cesium.Math.toDegrees(carto.longitude));
|
||||
return 122.0;
|
||||
});
|
||||
const point = { lat: cell.lat + 0.0003, lon: cell.lon + 0.0004 };
|
||||
sampleMeshFloorCells(scene, [point], { viewerLat: cell.lat, viewerLon: cell.lon });
|
||||
assert.equal(asked.length, 1);
|
||||
assert.ok(Math.abs(asked[0] - point.lon) < 1e-9, `probed at ${asked[0]}, not ${point.lon}`);
|
||||
});
|
||||
|
||||
test('sampler: a viaduct and the street beside it in one cell keep their own heights', async () => {
|
||||
reset();
|
||||
const cell = nextSite();
|
||||
await seedDem(cell, 120.0);
|
||||
// A viaduct deck 9 m up along one line of the cell, the street elsewhere.
|
||||
const deckLat = cell.lat + 0.0003;
|
||||
let calls = 0;
|
||||
const scene = fakeScene((carto) => {
|
||||
calls += 1;
|
||||
return Math.abs(Cesium.Math.toDegrees(carto.latitude) - deckLat) < 1e-7 ? 129.0 : 121.0;
|
||||
});
|
||||
const opts = { viewerLat: cell.lat, viewerLon: cell.lon };
|
||||
const onDeck = { lat: deckLat, lon: cell.lon };
|
||||
const street = { lat: cell.lat - 0.0003, lon: cell.lon };
|
||||
sampleMeshFloorCells(scene, [onDeck], opts);
|
||||
sampleMeshFloorCells(scene, [street], opts);
|
||||
assert.equal(calls, 2);
|
||||
assert.equal(cachedMeshFloor(onDeck.lat, onDeck.lon + 0.0001), 129.0, 'the train stays on the deck');
|
||||
assert.equal(cachedMeshFloor(street.lat, street.lon - 0.0001), 121.0, 'the bus stays on the street');
|
||||
assert.equal(cachedGroundFloor(street.lat, street.lon), 121.0);
|
||||
sampleMeshFloorCells(scene, [{ lat: deckLat, lon: cell.lon + 0.00001 }], opts);
|
||||
assert.equal(calls, 2, 'a point next to one already probed is not probed again');
|
||||
});
|
||||
|
||||
test('sampler: a roof over the cell centre does not lift a bus on the street', async () => {
|
||||
reset();
|
||||
const cell = nextSite();
|
||||
await seedDem(cell, 120.0);
|
||||
// A roof 22 m up covering the cell's centre; the street 30 m west of it.
|
||||
const scene = fakeScene((carto) =>
|
||||
Math.abs(Cesium.Math.toDegrees(carto.longitude) - cell.lon) < 0.0001 ? 142.0 : 121.0);
|
||||
const bus = { lat: cell.lat, lon: cell.lon - 0.0003 };
|
||||
sampleMeshFloorCells(scene, [bus], { viewerLat: cell.lat, viewerLon: cell.lon });
|
||||
assert.equal(cachedGroundFloor(bus.lat, bus.lon), 121.0);
|
||||
});
|
||||
|
||||
test('sampler: a second reading at the same spot keeps the lower one', async () => {
|
||||
reset();
|
||||
const cell = nextSite();
|
||||
await seedDem(cell, 120.0);
|
||||
assert.equal(reportValidatedMeshFloorCell(cell.lat, cell.lon, 131.0), true);
|
||||
assert.equal(reportValidatedMeshFloorCell(cell.lat + 0.00002, cell.lon, 122.0), true);
|
||||
assert.equal(cachedMeshFloor(cell.lat, cell.lon), 122.0);
|
||||
assert.equal(reportValidatedMeshFloorCell(cell.lat, cell.lon + 0.00002, 140.0), true);
|
||||
assert.equal(cachedMeshFloor(cell.lat, cell.lon), 122.0, 'a higher one never raises it');
|
||||
});
|
||||
|
||||
test('sampler: a cell with a reading is probed a few times at most', async () => {
|
||||
reset();
|
||||
const cell = nextSite();
|
||||
await seedDem(cell, 120.0);
|
||||
let calls = 0;
|
||||
const scene = fakeScene(() => {
|
||||
calls += 1;
|
||||
return 121.0;
|
||||
});
|
||||
const opts = { viewerLat: cell.lat, viewerLon: cell.lon };
|
||||
for (let i = 0; i < 20; i++)
|
||||
sampleMeshFloorCells(
|
||||
scene,
|
||||
[{ lat: cell.lat - 0.0004 + (i % 5) * 0.0002, lon: cell.lon - 0.00045 + Math.floor(i / 5) * 0.0003 }],
|
||||
opts,
|
||||
);
|
||||
assert.equal(calls, 8);
|
||||
});
|
||||
|
||||
test('sampler: a cell with no accepted reading is probed again on every call', async () => {
|
||||
reset();
|
||||
const cell = nextSite();
|
||||
let calls = 0;
|
||||
const scene = fakeScene(() => {
|
||||
calls += 1;
|
||||
return 138.0;
|
||||
});
|
||||
const opts = { viewerLat: cell.lat, viewerLon: cell.lon };
|
||||
for (let i = 0; i < 3; i++) sampleMeshFloorCells(scene, [cell], opts);
|
||||
assert.equal(calls, 3, 'no DEM prior yet, so nothing is kept and the cell is retried');
|
||||
await seedDem(cell, 120.0);
|
||||
sampleMeshFloorCells(scene, [cell], opts);
|
||||
assert.equal(cachedMeshFloor(cell.lat, cell.lon), 138.0, 'and lands once the prior is warm');
|
||||
});
|
||||
|
||||
test('sampler: a spot whose reading is refused does not use up the cell', async () => {
|
||||
reset();
|
||||
const cell = nextSite();
|
||||
await seedDem(cell, 120.0);
|
||||
// A tower 260 m up where the first vehicle waits; the street elsewhere and
|
||||
// a deck 9 m up along one line of the cell.
|
||||
const towerLon = cell.lon + 0.0004;
|
||||
const deckLat = cell.lat + 0.0003;
|
||||
const scene = fakeScene((carto) => {
|
||||
if (Math.abs(Cesium.Math.toDegrees(carto.longitude) - towerLon) < 1e-7) return 380.0;
|
||||
return Math.abs(Cesium.Math.toDegrees(carto.latitude) - deckLat) < 1e-7 ? 129.0 : 121.0;
|
||||
});
|
||||
const opts = { viewerLat: cell.lat, viewerLon: cell.lon };
|
||||
for (let i = 0; i < 10; i++) sampleMeshFloorCells(scene, [{ lat: cell.lat, lon: towerLon }], opts);
|
||||
assert.equal(cachedMeshFloor(cell.lat, cell.lon), null, 'the tower is refused every time');
|
||||
sampleMeshFloorCells(scene, [{ lat: cell.lat - 0.0003, lon: cell.lon }], opts);
|
||||
sampleMeshFloorCells(scene, [{ lat: deckLat, lon: cell.lon - 0.0003 }], opts);
|
||||
assert.equal(cachedMeshFloor(deckLat, cell.lon - 0.0003), 129.0, 'the deck is still probed');
|
||||
});
|
||||
|
||||
test('ground floor: a cell keeps at most sixteen readings', async () => {
|
||||
reset();
|
||||
const cell = nextSite();
|
||||
await seedDem(cell, 120.0);
|
||||
for (let i = 0; i < 40; i++) {
|
||||
const lat = cell.lat - 0.00045 + (i % 8) * 0.0001;
|
||||
const lon = cell.lon - 0.00045 + Math.floor(i / 8) * 0.0002;
|
||||
reportValidatedMeshFloorCell(lat, lon, 121.0 + i);
|
||||
}
|
||||
// Readings 0..15 are kept; reading 39 (at the far corner) is not, so that
|
||||
// spot answers from the nearest kept one.
|
||||
assert.equal(cachedMeshFloor(cell.lat - 0.00045, cell.lon - 0.00045), 121.0);
|
||||
assert.equal(cachedMeshFloor(cell.lat + 0.00025, cell.lon + 0.00035), 121.0 + 15);
|
||||
});
|
||||
|
||||
test('sampler: a spot refused while the DEM was cold is probed again once it is warm', async () => {
|
||||
reset();
|
||||
const cell = nextSite();
|
||||
const deckLat = cell.lat + 0.0003;
|
||||
let calls = 0;
|
||||
const scene = fakeScene((carto) => {
|
||||
calls += 1;
|
||||
return Math.abs(Cesium.Math.toDegrees(carto.latitude) - deckLat) < 1e-7 ? 129.0 : 121.0;
|
||||
});
|
||||
const opts = { viewerLat: cell.lat, viewerLon: cell.lon };
|
||||
const onDeck = { lat: deckLat, lon: cell.lon };
|
||||
sampleMeshFloorCells(scene, [onDeck], opts);
|
||||
assert.equal(cachedMeshFloor(onDeck.lat, onDeck.lon), null, 'no prior, nothing kept');
|
||||
await seedDem(cell, 120.0);
|
||||
sampleMeshFloorCells(scene, [{ lat: cell.lat - 0.0003, lon: cell.lon }], opts);
|
||||
sampleMeshFloorCells(scene, [onDeck], opts);
|
||||
assert.equal(calls, 3);
|
||||
assert.equal(cachedMeshFloor(onDeck.lat, onDeck.lon), 129.0);
|
||||
});
|
||||
|
||||
test('sampler: a spot refused while the DEM prior was evicted is probed again', async () => {
|
||||
const { createGroundFloor } = await import('../services/groundFloor.js');
|
||||
const { createMeshFloorSampler } = await import('../services/meshFloorSampler.js');
|
||||
let prior = 120.0;
|
||||
const terrain = {
|
||||
cachedRealEllipsoidalGround: () => prior,
|
||||
resolveEllipsoidalGround: async () => {},
|
||||
};
|
||||
const groundFloor = createGroundFloor({ terrain });
|
||||
const sampler = createMeshFloorSampler({ groundFloor });
|
||||
const cell = { lat: 41.5, lon: -97.66 };
|
||||
const deckLat = cell.lat + 0.0003;
|
||||
const scene = fakeScene((carto) =>
|
||||
Math.abs(Cesium.Math.toDegrees(carto.latitude) - deckLat) < 1e-7 ? 129.0 : 121.0);
|
||||
const opts = { viewerLat: cell.lat, viewerLon: cell.lon };
|
||||
const onDeck = { lat: deckLat, lon: cell.lon };
|
||||
sampler.sampleMeshFloorCells(scene, [{ lat: cell.lat - 0.0003, lon: cell.lon }], opts);
|
||||
prior = null; // the cell's DEM entry is evicted; the street reading stays
|
||||
sampler.sampleMeshFloorCells(scene, [onDeck], opts);
|
||||
assert.equal(groundFloor.cachedMeshFloor(onDeck.lat, onDeck.lon), 121.0);
|
||||
prior = 120.0;
|
||||
sampler.sampleMeshFloorCells(scene, [onDeck], opts);
|
||||
assert.equal(groundFloor.cachedMeshFloor(onDeck.lat, onDeck.lon), 129.0);
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* @file Upstream-request accounting shared by the keyed tile proxies.
|
||||
*
|
||||
* A soft cap per UTC period, persisted as `{date, count}` so it survives a
|
||||
* restart. Nothing here is provider-specific: the caller picks the period by
|
||||
* the key it passes, and decides what happens over the cap.
|
||||
*
|
||||
* Zero dependencies and Cesium-free, so providers and browser helpers can
|
||||
* import it and node:test can exercise it directly.
|
||||
*
|
||||
* @module data/tileBudget
|
||||
*/
|
||||
|
||||
/**
|
||||
* UTC calendar-day key for budget bucketing.
|
||||
*
|
||||
* @param {number} [epochMs=Date.now()] - Timestamp in ms.
|
||||
* @returns {string} 'YYYY-MM-DD' in UTC.
|
||||
*/
|
||||
export function utcDayKey(epochMs = Date.now()) {
|
||||
return new Date(epochMs).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a persisted budget state against the current period key.
|
||||
* Rolls the counter to zero when the period changes; replaces missing/corrupt
|
||||
* state. Returns the SAME object when it is already valid for `periodKey`
|
||||
* (cheap to call on every request).
|
||||
*
|
||||
* @param {{date:string, count:number}|null|undefined} state - Persisted state.
|
||||
* @param {string} periodKey - Current period key, e.g. from `utcDayKey`.
|
||||
* @returns {{date:string, count:number}} Valid state for `periodKey`.
|
||||
*/
|
||||
export function normalizeBudget(state, periodKey) {
|
||||
const valid =
|
||||
Boolean(state) &&
|
||||
state.date === periodKey &&
|
||||
Number.isFinite(state.count) &&
|
||||
state.count >= 0;
|
||||
return valid ? state : { date: periodKey, count: 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the soft cap for the current period has been reached.
|
||||
*
|
||||
* @param {{count:number}} state - Normalized budget state.
|
||||
* @param {number} limit - Budget per period; non-positive/invalid never blocks.
|
||||
* @returns {boolean} True when `count >= limit`.
|
||||
*/
|
||||
export function isOverBudget(state, limit) {
|
||||
if (!Number.isFinite(limit) || limit <= 0) return false;
|
||||
return state.count >= limit;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { isOverBudget, normalizeBudget, utcDayKey } from './tileBudget.js';
|
||||
import * as tomtom from './tomtomTiles.js';
|
||||
|
||||
test('tile budget helpers live in one module and TomTom re-exports them', () => {
|
||||
assert.equal(tomtom.utcDayKey, utcDayKey);
|
||||
assert.equal(tomtom.normalizeBudget, normalizeBudget);
|
||||
assert.equal(tomtom.isOverBudget, isOverBudget);
|
||||
});
|
||||
+6
-40
@@ -1,9 +1,10 @@
|
||||
/**
|
||||
* @file Pure tile math + budget accounting for the TomTom traffic-flow proxy.
|
||||
* @file Pure tile math for the TomTom traffic-flow proxy; budget helpers are
|
||||
* re-exported from `./tileBudget.js`.
|
||||
*
|
||||
* Shared by the `/api/tomtom` vite plugin (server-side: coordinate validation,
|
||||
* daily budget governor) and `src/layers/traffic/flowSource.js` (client-side: which tiles
|
||||
* cover the current traffic fetch bounds). Zero dependencies, Cesium-free, so
|
||||
* cover the current traffic fetch bounds). No external dependencies, Cesium-free, so
|
||||
* both sides can unit-test against it with node:test.
|
||||
*
|
||||
* Slippy scheme: standard Web Mercator XYZ, y grows southward
|
||||
@@ -131,42 +132,7 @@ export function tilesForBounds(bounds, zoom = 12, { maxTiles = 64 } = {}) {
|
||||
// ─── Daily budget accounting ───────────────────────────────
|
||||
|
||||
/**
|
||||
* UTC calendar-day key for budget bucketing.
|
||||
*
|
||||
* @param {number} [epochMs=Date.now()] - Timestamp in ms.
|
||||
* @returns {string} 'YYYY-MM-DD' in UTC.
|
||||
* Re-exported from `./tileBudget.js`, which keyed tile proxies share. The
|
||||
* traffic provider and its tests keep one import for tile math and budget.
|
||||
*/
|
||||
export function utcDayKey(epochMs = Date.now()) {
|
||||
return new Date(epochMs).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a persisted budget state against today's UTC day key.
|
||||
* Rolls the counter to zero on day change; replaces missing/corrupt state.
|
||||
* Returns the SAME object when it is already valid for `dayKey` (cheap to
|
||||
* call on every request).
|
||||
*
|
||||
* @param {{date:string, count:number}|null|undefined} state - Persisted state.
|
||||
* @param {string} dayKey - Today's UTC day key (from `utcDayKey`).
|
||||
* @returns {{date:string, count:number}} Valid state for `dayKey`.
|
||||
*/
|
||||
export function normalizeBudget(state, dayKey) {
|
||||
const valid =
|
||||
Boolean(state) &&
|
||||
state.date === dayKey &&
|
||||
Number.isFinite(state.count) &&
|
||||
state.count >= 0;
|
||||
return valid ? state : { date: dayKey, count: 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the daily soft cap has been reached.
|
||||
*
|
||||
* @param {{count:number}} state - Normalized budget state.
|
||||
* @param {number} limit - Daily tile budget; non-positive/invalid never blocks.
|
||||
* @returns {boolean} True when `count >= limit`.
|
||||
*/
|
||||
export function isOverBudget(state, limit) {
|
||||
if (!Number.isFinite(limit) || limit <= 0) return false;
|
||||
return state.count >= limit;
|
||||
}
|
||||
export { utcDayKey, normalizeBudget, isOverBudget } from './tileBudget.js';
|
||||
@@ -389,6 +389,179 @@ function rioRouteLabel(routeId) {
|
||||
: routeId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Carris Metropolitana realtime route ids wrap the GTFS route id in an
|
||||
* operator-area prefix and keep its pattern suffix: `[LA77N]1708_0` is GTFS
|
||||
* route `1708_0`, whose `short_name` (the number on the bus) is `1708`, per
|
||||
* the operator's own `/v2/routes` API. Anything else passes through.
|
||||
* @param {string|null} routeId
|
||||
* @returns {string|null}
|
||||
*/
|
||||
function carrisMetropolitanaRouteLabel(routeId) {
|
||||
const match = /^\[[A-Z0-9]+\](\d{4})_\d+$/.exec(routeId ?? '');
|
||||
return match ? match[1] : routeId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Route ids that wrap the rider-facing line in a fixed prefix, suffix or
|
||||
* zero-padding. The returned function yields the captured line, or the id
|
||||
* unchanged when it does not match the operator's pattern.
|
||||
* @param {RegExp} pattern One capture group: the line a rider would say.
|
||||
* @returns {(routeId: string|null) => string|null}
|
||||
*/
|
||||
function routeIdPattern(pattern) {
|
||||
return (routeId) => {
|
||||
const match = pattern.exec(routeId ?? '');
|
||||
return match ? match[1] : routeId;
|
||||
};
|
||||
}
|
||||
|
||||
/** Zero-padded numbers (Edmonton `005`, Toulon `0002`, Bordeaux `01`). */
|
||||
const unpaddedRouteLabel = routeIdPattern(/^0+(\d+\w*)$/);
|
||||
/** Turin GTT appends `U` (urban) to the line: `10NU` is night line 10N. */
|
||||
const gttRouteLabel = routeIdPattern(/^(\w+?)U$/);
|
||||
/** Venice ACTV appends the network: `5.1_UN` is waterbus line 5.1, `53E_ES` bus 53E. */
|
||||
const actvRouteLabel = routeIdPattern(/^(.+)_[A-Z]{2}$/);
|
||||
/** BC Transit appends the system: `95-VIC` is Victoria route 95. */
|
||||
const bcTransitRouteLabel = routeIdPattern(/^(.+)-VIC$/);
|
||||
/** Carris (Lisbon city) appends a variant: `172_1` is route 172. */
|
||||
const carrisRouteLabel = routeIdPattern(/^(\w+)_\d+$/);
|
||||
/** Astuce prefixes the network: `TCAR:93` is Rouen line 93. */
|
||||
const astuceNetworkLabel = routeIdPattern(/^[A-Z]+:(.+)$/);
|
||||
/** BAS.MY Johor appends a fixed operator code: `J33CWLMYJB` is route J33. */
|
||||
const mybasJohorRouteLabel = routeIdPattern(/^(.+?)CWLMYJB$/);
|
||||
|
||||
/** LiA prefixes its network: `8-C3` is Le Havre line C3, `8-09` line 9. */
|
||||
const liaRouteLabel = routeIdPattern(/^8-0*(\w.*)$/);
|
||||
|
||||
/**
|
||||
* Le Havre LiA runs its two tram lines as A and B and the funicular as
|
||||
* `FUNI`; every other line is a bus.
|
||||
* @param {string|null} routeId
|
||||
* @returns {string}
|
||||
*/
|
||||
function liaRouteMode(routeId) {
|
||||
if (!routeId) return 'unknown';
|
||||
const line = liaRouteLabel(routeId);
|
||||
if (line === 'FUNI') return 'rail';
|
||||
return /^[AB]$/.test(line) ? 'tram' : 'bus';
|
||||
}
|
||||
|
||||
/**
|
||||
* Rouen Astuce lines that are not buses, by route id, from the TCAR
|
||||
* routes.txt (checked 2026-11-09): line 90 is the Métro (GTFS type 1, a light
|
||||
* rail that runs mostly on the street) and 99 the Calypso river boat.
|
||||
*/
|
||||
const ASTUCE_ROUTES = Object.freeze({
|
||||
// i18n-ignore: line name, as on the vehicle
|
||||
'TCAR:90': Object.freeze({ mode: 'tram', label: 'Métro' }),
|
||||
// i18n-ignore: line name, as on the vehicle
|
||||
'TCAR:99': Object.freeze({ mode: 'ferry', label: 'Calypso' }),
|
||||
});
|
||||
|
||||
/** @param {string|null} routeId @returns {string} */
|
||||
function astuceRouteMode(routeId) {
|
||||
if (!routeId) return 'unknown';
|
||||
return ASTUCE_ROUTES[routeId]?.mode ?? 'bus';
|
||||
}
|
||||
|
||||
/** `TCAR:93` is Rouen line 93; the Métro and Calypso keep their names. */
|
||||
function astuceRouteLabel(routeId) {
|
||||
return ASTUCE_ROUTES[routeId ?? '']?.label ?? astuceNetworkLabel(routeId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Prague PID route ids are `L` plus a number whose range says what runs it,
|
||||
* per PID's GTFS routes.txt (data.pid.cz, checked 2026-11-09): 1–99 trams
|
||||
* (50–59 trolleybuses, 49 the Petřín funicular `LD`), 100–990 buses (the
|
||||
* 800s are the X replacement lines, `L803` is X3), 991–993 metro A–C,
|
||||
* 1000s and 1100s the S and R trains (`L1004` is S4, `L1109` R9), the rest
|
||||
* of 1200–1399 other trains, 1700s town buses, 1800s the P ferries, and from
|
||||
* 2000 more buses (`L2841` is 841). A label the range cannot give stays the
|
||||
* route id.
|
||||
* @param {string|null} routeId
|
||||
* @returns {{mode: string, label: string|null}}
|
||||
*/
|
||||
function pidRoute(routeId) {
|
||||
const match = /^L(\d+)$/.exec(routeId ?? '');
|
||||
if (!match) return { mode: 'unknown', label: routeId };
|
||||
const n = Number(match[1]);
|
||||
// i18n-ignore: line code, as signed
|
||||
if (n === 49) return { mode: 'rail', label: 'LD' };
|
||||
if (n >= 50 && n <= 59) return { mode: 'bus', label: String(n) };
|
||||
if (n < 100) return { mode: 'tram', label: String(n) };
|
||||
if (n >= 800 && n < 900) return { mode: 'bus', label: `X${n - 800}` };
|
||||
if (n < 991) return { mode: 'bus', label: String(n) };
|
||||
// i18n-ignore: metro line letters, as signed
|
||||
if (n <= 993) return { mode: 'subway', label: 'ABC'[n - 991] };
|
||||
if (n >= 1000 && n < 1100) return { mode: 'rail', label: `S${n - 1000}` };
|
||||
if (n >= 1100 && n < 1200) return { mode: 'rail', label: `R${n - 1100}` };
|
||||
if (n >= 1200 && n < 1400) return { mode: 'rail', label: routeId };
|
||||
if (n >= 1800 && n < 1900) return { mode: 'ferry', label: `P${n - 1800}` };
|
||||
if (n >= 2800 && n < 2900) return { mode: 'bus', label: String(n - 2000) };
|
||||
return { mode: n >= 1700 ? 'bus' : 'unknown', label: routeId };
|
||||
}
|
||||
|
||||
/** @param {string|null} routeId @returns {string} */
|
||||
function pidRouteMode(routeId) {
|
||||
return pidRoute(routeId).mode;
|
||||
}
|
||||
|
||||
/** @param {string|null} routeId @returns {string|null} */
|
||||
function pidRouteLabel(routeId) {
|
||||
return pidRoute(routeId).label;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bordeaux TBM tram and boat lines by route id, from TBM's static GTFS
|
||||
* routes.txt (checked 2026-11-09): the trams are numbered ids, not letters.
|
||||
*/
|
||||
const TBM_TRAM_LINES = Object.freeze({
|
||||
59: 'A',
|
||||
60: 'B',
|
||||
61: 'C',
|
||||
62: 'D',
|
||||
163: 'E',
|
||||
164: 'F',
|
||||
});
|
||||
const TBM_BOAT_LINES = new Set(['951', '952', '953']);
|
||||
|
||||
/**
|
||||
* Bordeaux TBM: trams A–F and the BAT³ river shuttles; every other line is a
|
||||
* bus.
|
||||
* @param {string|null} routeId
|
||||
* @returns {string}
|
||||
*/
|
||||
function tbmRouteMode(routeId) {
|
||||
if (!routeId) return 'unknown';
|
||||
if (Object.hasOwn(TBM_TRAM_LINES, routeId)) return 'tram';
|
||||
return TBM_BOAT_LINES.has(routeId) ? 'ferry' : 'bus';
|
||||
}
|
||||
|
||||
/** `59` is tram A; zero-padded bus ids lose their padding (`01` is 1). */
|
||||
function tbmRouteLabel(routeId) {
|
||||
return Object.hasOwn(TBM_TRAM_LINES, routeId ?? '')
|
||||
? TBM_TRAM_LINES[routeId]
|
||||
: unpaddedRouteLabel(routeId);
|
||||
}
|
||||
|
||||
/**
|
||||
* A registry entry, frozen with its centre and terms, and switched on.
|
||||
* @param {object} fields
|
||||
* @returns {Readonly<object>}
|
||||
*/
|
||||
function transitFeed(fields) {
|
||||
return Object.freeze({
|
||||
defaultEnabled: true,
|
||||
...fields,
|
||||
center: Object.freeze({ ...fields.center }),
|
||||
terms: Object.freeze({ ...fields.terms }),
|
||||
...(fields.headers
|
||||
? { headers: Object.freeze({ ...fields.headers }) }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Registry of feeds. Order is presentation order in the stats/credit text.
|
||||
* `loadRadiusKm` is the distance from `center` inside which the feed is polled.
|
||||
@@ -398,7 +571,14 @@ function rioRouteLabel(routeId) {
|
||||
* url: string, headers?: Record<string, string>,
|
||||
* license: string, licenseUrl: string, attribution: string,
|
||||
* defaultMode: string, routeMode?: (routeId: string|null) => string,
|
||||
* speedUnit?: 'km/h', refreshMs?: number, sourceUrl?: string,
|
||||
* creditDated?: boolean,
|
||||
* }>>}
|
||||
* `speedUnit` marks a feed that publishes km/h where the spec requires m/s;
|
||||
* the proxy converts it before the snapshot leaves the server. `refreshMs`
|
||||
* stretches the proxy's cache for an upstream that rate-limits below one
|
||||
* request per 15 s TTL. `sourceUrl` and `creditDated` add a link and a date
|
||||
* to the credit for licences that ask for them (`transitFeedCredit`).
|
||||
*/
|
||||
export const TRANSIT_FEED_REGISTRY = Object.freeze([
|
||||
Object.freeze({
|
||||
@@ -788,6 +968,624 @@ export const TRANSIT_FEED_REGISTRY = Object.freeze([
|
||||
defaultMode: 'bus',
|
||||
routeLabel: rioRouteLabel,
|
||||
}),
|
||||
transitFeed({
|
||||
id: 'zditm-szczecin',
|
||||
name: 'Szczecin buses and trams',
|
||||
operator:
|
||||
'Zarząd Dróg i Transportu Miejskiego w Szczecinie (ZDiTM), Szczecin',
|
||||
region: 'Szczecin, Poland',
|
||||
// Every vehicle in a 2026-11-05 snapshot sat between 53.34–53.55 N and
|
||||
// 14.44–14.77 E, inside 20 km of this point.
|
||||
center: { lat: 53.44, lon: 14.56 },
|
||||
loadRadiusKm: 25,
|
||||
url: 'https://www.zditm.szczecin.pl/storage/gtfs/gtfs-rt-vehicles.pb',
|
||||
license: 'CC0 1.0',
|
||||
licenseUrl: 'https://www.zditm.szczecin.pl/en/zditm/for-developers',
|
||||
attribution:
|
||||
'Zarząd Dróg i Transportu Miejskiego w Szczecinie (ZDiTM Szczecin)',
|
||||
terms: {
|
||||
quote:
|
||||
'The data are provided free of charge under the CC0 1.0 licence, for use in any context, with no prior notification required.',
|
||||
note: 'CC0 needs no credit; it is given anyway as a courtesy. ZDiTM gives no guarantee of accuracy or completeness. The developer page says the feed updates every minute; measured on 2026-11-05 the file changed about every 20 seconds, with vehicle timestamps up to 50 seconds old. Speeds are m/s: checked against vehicle movement between two snapshots. The feed carries no route id at all, so every vehicle takes the feed default mode even though the city also runs trams.',
|
||||
},
|
||||
defaultMode: 'bus',
|
||||
}),
|
||||
transitFeed({
|
||||
id: 'carris-metropolitana-lisboa',
|
||||
name: 'Carris Metropolitana',
|
||||
operator: 'Carris Metropolitana (TML), Lisbon Metropolitan Area',
|
||||
region: 'Lisbon Metropolitan Area, Portugal',
|
||||
// Every vehicle in a 2026-11-07 snapshot, from Mafra to Setúbal, sat
|
||||
// between 38.47–39.00 N and 9.47–8.79 W, inside 40 km of this point.
|
||||
center: { lat: 38.73, lon: -9.13 },
|
||||
loadRadiusKm: 45,
|
||||
url: 'https://api.carrismetropolitana.pt/v2/vehicles.pb',
|
||||
license: 'No separate licence for the realtime feed',
|
||||
licenseUrl: 'https://github.com/carrismetropolitana/api',
|
||||
attribution: 'Carris Metropolitana',
|
||||
terms: {
|
||||
quote:
|
||||
'GET /vehicles.pb — Returns vehicle locations in Protobuf format, following the GTFS-RT Vehicle Locations standard.',
|
||||
note: 'The operator publishes this feed keyless and documented for developers in its public API repository, but states no licence for the realtime endpoint; its static GTFS (github.com/carrismetropolitana/gtfs) is CC BY 4.0. Credited as a courtesy, on the same basis as the Metro Transit and OVapi feeds; switch it off if Carris objects. The quote above is the API README, not a licence. `/vehicles.pb` answers with a 307 to `/v2/vehicles.pb`, so the registry points at the target. Speeds are km/h, not the m/s the spec requires: 2026-11-07 readings of 31–33 matched 6–9 m/s of measured movement, so the proxy converts them. Vehicle reports arrive every 30–50 seconds. Route ids are the GTFS route id inside an area prefix and are read as the line number.',
|
||||
},
|
||||
defaultMode: 'bus',
|
||||
routeLabel: carrisMetropolitanaRouteLabel,
|
||||
speedUnit: 'km/h',
|
||||
}),
|
||||
transitFeed({
|
||||
id: 'pid-praha',
|
||||
name: 'Prague PID',
|
||||
operator: 'Pražská integrovaná doprava (PID), via Golemio',
|
||||
region: 'Prague and Central Bohemia, Czechia',
|
||||
// PID reaches across Central Bohemia: on 2026-11-08, 95% of 923 vehicles
|
||||
// sat within 58 km of Prague, the farthest at 98 km.
|
||||
center: { lat: 50.08, lon: 14.45 },
|
||||
loadRadiusKm: 60,
|
||||
url: 'https://api.golemio.cz/v2/vehiclepositions/gtfsrt/vehicle_positions.pb',
|
||||
license: 'CC BY',
|
||||
licenseUrl: 'https://pid.cz/o-systemu/opendata/',
|
||||
attribution: 'Pražská integrovaná doprava (PID) / Golemio',
|
||||
terms: {
|
||||
quote:
|
||||
'Data, která lze stáhnout přímo zde z webu, jsou stejně jako celý obsah webu opatřena licencí CC-BY',
|
||||
note: 'The PID open-data page lists this realtime feed and licenses the data it publishes under CC BY, crediting the author and any changes. The same page says data served from other sources follows that source’s licence; the feed is served by Golemio, the city’s data platform, which states none of its own. Keyless since the catalogue moved it off the X-Access-Token endpoint. Speeds are m/s, checked against movement on 2026-11-07. Route ids are `L` plus a number whose range gives the mode and the line (metro `L991` is A, train `L1004` is S4, ferry `L1801` is P1); `pidRoute` holds the table, read from PID’s GTFS routes.txt on 2026-11-09.',
|
||||
},
|
||||
defaultMode: 'bus',
|
||||
routeLabel: pidRouteLabel,
|
||||
routeMode: pidRouteMode,
|
||||
}),
|
||||
transitFeed({
|
||||
id: 'ztm-warszawa',
|
||||
name: 'Warsaw buses and trams',
|
||||
operator: 'Miasto Stołeczne Warszawa, relayed by mkuran.pl',
|
||||
region: 'Warsaw, Poland',
|
||||
center: { lat: 52.25, lon: 21.02 },
|
||||
loadRadiusKm: 30,
|
||||
url: 'https://mkuran.pl/gtfs/warsaw/vehicles.pb',
|
||||
license: 'Warsaw Open Data Project Usage Terms',
|
||||
licenseUrl: 'https://mkuran.pl/gtfs/',
|
||||
attribution: 'Miasto Stołeczne Warszawa (api.um.warszawa.pl)',
|
||||
sourceUrl: 'http://api.um.warszawa.pl',
|
||||
creditDated: true,
|
||||
terms: {
|
||||
quote:
|
||||
'The City Hall requires a credit by providing the name Miasto Stołeczne Warszawa, data download date (use the timestamp from the FeedHeader) and link to http://api.um.warszawa.pl.',
|
||||
note: 'Positions come from the Warsaw City Hall API, repackaged as GTFS-RT by Mikołaj Kuranowski’s unofficial feed. The credit names the city, links its API (`sourceUrl`) and carries the date of the feed timestamp (`creditDated`), the three things the terms ask for. Vehicle timestamps ran about two minutes old on 2026-11-07, which the delayed playback absorbs.',
|
||||
},
|
||||
defaultMode: 'bus',
|
||||
}),
|
||||
transitFeed({
|
||||
id: 'tbm-bordeaux',
|
||||
name: 'Bordeaux TBM',
|
||||
operator: 'Transports Bordeaux Métropole (TBM), Bordeaux Métropole',
|
||||
region: 'Bordeaux, France',
|
||||
center: { lat: 44.85, lon: -0.65 },
|
||||
loadRadiusKm: 20,
|
||||
url: 'https://bdx.mecatran.com/utw/ws/gtfsfeed/vehicles/bordeaux?apiKey=opendata-bordeaux-metropole-flux-gtfs-rt',
|
||||
license: 'Licence Ouverte / Etalab 2.0',
|
||||
licenseUrl:
|
||||
'https://transport.data.gouv.fr/datasets/offres-de-services-bus-tram-et-scolaire-au-format-gtfs-netex-gtfs-rt-siri-lite',
|
||||
attribution: 'Bordeaux Métropole (TBM)',
|
||||
terms: {
|
||||
quote: 'licence: fr-lo',
|
||||
note: 'The quote is the licence field of the national access point’s dataset record (fr-lo is the Licence Ouverte), which asks only for credit. The `apiKey` in the URL is not a credential: it is the same fixed string printed in the open-data listing for everyone. Speeds are m/s. The trams (A–F) and BAT³ boats ride numbered route ids (tram A is `59`), so `tbmRouteMode` and `tbmRouteLabel` map them from TBM’s static GTFS; other numbered lines are buses.',
|
||||
},
|
||||
defaultMode: 'bus',
|
||||
routeLabel: tbmRouteLabel,
|
||||
routeMode: tbmRouteMode,
|
||||
}),
|
||||
transitFeed({
|
||||
id: 'sendai-city-bus',
|
||||
name: 'Sendai City Bus',
|
||||
operator: 'Sendai City Transportation Bureau (仙台市交通局), via ODPT',
|
||||
region: 'Sendai, Japan',
|
||||
center: { lat: 38.27, lon: 140.82 },
|
||||
loadRadiusKm: 22,
|
||||
url: 'https://api-public.odpt.org/api/v4/gtfs/realtime/odpt_SendaiMunicipal_bus_realtime_information_vehicle',
|
||||
license: 'CC BY 4.0',
|
||||
licenseUrl: 'https://creativecommons.org/licenses/by/4.0/',
|
||||
attribution: '仙台市交通局 (Sendai City Transportation Bureau) / ODPT',
|
||||
terms: {
|
||||
quote: 'https://creativecommons.org/licenses/by/4.0/',
|
||||
note: 'Served by the Open Data Challenge for Public Transportation (ODPT) public API, which needs no key for this feed; the catalogue records CC BY 4.0 as its licence. Vehicle timestamps ran about two minutes old on 2026-11-07. Speeds are m/s.',
|
||||
},
|
||||
defaultMode: 'bus',
|
||||
}),
|
||||
transitFeed({
|
||||
id: 'ztm-poznan',
|
||||
name: 'Poznań buses and trams',
|
||||
operator: 'Zarząd Transportu Miejskiego w Poznaniu (ZTM Poznań)',
|
||||
region: 'Poznań, Poland',
|
||||
center: { lat: 52.39, lon: 16.92 },
|
||||
loadRadiusKm: 20,
|
||||
url: 'https://www.ztm.poznan.pl/pl/dla-deweloperow/getGtfsRtFile/?file=vehicle_positions.pb',
|
||||
license: 'ZTM Poznań open-data terms',
|
||||
licenseUrl: 'https://www.ztm.poznan.pl/otwarte-dane/dla-deweloperow/',
|
||||
attribution: 'Zarząd Transportu Miejskiego w Poznaniu (processed)',
|
||||
creditDated: true,
|
||||
terms: {
|
||||
quote:
|
||||
'Treści udostępniane są bezwnioskowo i bezpłatnie, na zasadach powszechnego dostępu i stanowią źródło informacji.',
|
||||
note: 'Reuse is free on three conditions: name the source, give the date of the last update, and say the data were processed. The credit names ZTM, says processed, and carries the date of the feed timestamp (`creditDated`) as the update date. The feed carries no bearing, so heading comes from movement between fixes.',
|
||||
},
|
||||
defaultMode: 'bus',
|
||||
}),
|
||||
transitFeed({
|
||||
id: 'ztm-gdansk',
|
||||
name: 'Gdańsk buses and trams',
|
||||
operator: 'Zarząd Transportu Miejskiego w Gdańsku (ZTM Gdańsk)',
|
||||
region: 'Gdańsk, Poland',
|
||||
center: { lat: 54.35, lon: 18.6 },
|
||||
loadRadiusKm: 20,
|
||||
url: 'https://ckan2.multimediagdansk.pl/gtfs-rt?feed=vehiclePositions',
|
||||
license: 'CC BY 4.0',
|
||||
licenseUrl:
|
||||
'https://dane.gov.pl/pl/dataset/38599,rozklady-jazdy-i-lokalizacja-przystankow-gtfs-rt',
|
||||
attribution: 'Zarząd Transportu Miejskiego w Gdańsku',
|
||||
terms: {
|
||||
note: 'The national catalogue (dane.gov.pl) lists this dataset under CC BY 4.0. No bearing; heading comes from movement. Speeds are m/s.',
|
||||
},
|
||||
defaultMode: 'bus',
|
||||
}),
|
||||
transitFeed({
|
||||
id: 'ets-edmonton',
|
||||
name: 'Edmonton Transit Service',
|
||||
operator: 'City of Edmonton (Edmonton Transit Service)',
|
||||
region: 'Edmonton, AB',
|
||||
center: { lat: 53.53, lon: -113.52 },
|
||||
loadRadiusKm: 25,
|
||||
url: 'https://gtfs.edmonton.ca/TMGTFSRealTimeWebService/Vehicle/VehiclePositions.pb',
|
||||
license: 'City of Edmonton Open Data Agreement',
|
||||
licenseUrl:
|
||||
'https://www.edmonton.ca/transportation/Web-version2.1-OpenDataAgreement.pdf',
|
||||
attribution:
|
||||
'Contains information licensed under the Open Data Agreement – City of Edmonton',
|
||||
terms: {
|
||||
note: 'The agreement is the same municipal template as Mississauga and Burlington: a licence to use, modify and distribute for any lawful purpose, in any medium. It was read from search excerpts; the PDF itself would not extract here. Route ids are zero-padded. Speeds are m/s.',
|
||||
},
|
||||
defaultMode: 'bus',
|
||||
routeLabel: unpaddedRouteLabel,
|
||||
}),
|
||||
transitFeed({
|
||||
id: 'halifax-transit',
|
||||
name: 'Halifax Transit',
|
||||
operator: 'Halifax Regional Municipality (Halifax Transit)',
|
||||
region: 'Halifax, NS',
|
||||
center: { lat: 44.7, lon: -63.6 },
|
||||
loadRadiusKm: 22,
|
||||
url: 'https://gtfs.halifax.ca/realtime/Vehicle/VehiclePositions.pb',
|
||||
license: 'Open Government Licence – Halifax',
|
||||
licenseUrl: 'https://www.halifax.ca/home/open-data/open-data-license',
|
||||
attribution:
|
||||
'Contains information licenced under the Open Government Licence – Halifax',
|
||||
terms: {
|
||||
quote:
|
||||
'The information provider grants you a worldwide, royalty-free, perpetual, non-exclusive licence to use the Information, including for commercial purposes, subject to the terms below.',
|
||||
note: 'Version 1.0, modelled on the Open Government Licence – Canada 2.0. The attribution is the licence’s own standard statement, with a link to the licence where possible. Logos and crests are excluded and no endorsement may be implied, so the credit stays plain text. The page refuses automated requests (403); its text was read in a browser on 2026-11-08. Speeds are m/s.',
|
||||
},
|
||||
defaultMode: 'bus',
|
||||
}),
|
||||
transitFeed({
|
||||
id: 'bc-transit-victoria',
|
||||
name: 'Victoria Regional Transit',
|
||||
operator: 'BC Transit (Victoria Regional Transit System)',
|
||||
region: 'Victoria, BC',
|
||||
center: { lat: 48.53, lon: -123.43 },
|
||||
loadRadiusKm: 28,
|
||||
url: 'https://bct.tmix.se/gtfs-realtime/vehicleupdates.pb?operatorIds=48',
|
||||
license: 'BC Transit Open Data Terms of Use',
|
||||
licenseUrl: 'https://www.bctransit.com/open-data/terms-of-use',
|
||||
attribution: 'BC Transit',
|
||||
terms: {
|
||||
quote:
|
||||
'BC Transit grants you a limited, revocable and non-exclusive license to use, reproduce, and redistribute the Data in accordance with these Terms.',
|
||||
note: 'The grant is revocable, like the Ontario municipal templates. Route ids carry a `-VIC` system suffix. Speeds are m/s.',
|
||||
},
|
||||
defaultMode: 'bus',
|
||||
routeLabel: bcTransitRouteLabel,
|
||||
}),
|
||||
transitFeed({
|
||||
id: 'tam-montpellier',
|
||||
name: 'Montpellier TaM',
|
||||
operator: 'Montpellier Méditerranée Métropole (TaM)',
|
||||
region: 'Montpellier, France',
|
||||
center: { lat: 43.61, lon: 3.87 },
|
||||
loadRadiusKm: 20,
|
||||
url: 'https://gtfsproxy.e-tam.fr/COMMON/VehiclePosition.pb',
|
||||
license: 'ODbL',
|
||||
licenseUrl:
|
||||
'https://transport.data.gouv.fr/datasets/offre-de-transport-tam-en-temps-reel-gtfs-rt-urbain-et-suburbain',
|
||||
attribution: 'Montpellier Méditerranée Métropole (TaM)',
|
||||
terms: {
|
||||
quote: 'licence: odc-odbl',
|
||||
note: 'The quote is the dataset record’s licence field. ODbL asks for credit and share-alike on a derived database; the layer redistributes no database, it draws the positions. Speeds are km/h: readings ran 3.3 times the measured movement on 2026-11-07, so the proxy converts them.',
|
||||
},
|
||||
defaultMode: 'bus',
|
||||
speedUnit: 'km/h',
|
||||
}),
|
||||
transitFeed({
|
||||
id: 'ztp-krakow-bus',
|
||||
name: 'Kraków buses',
|
||||
operator: 'Zarząd Transportu Publicznego w Krakowie (ZTP Kraków)',
|
||||
region: 'Kraków, Poland',
|
||||
center: { lat: 50.06, lon: 19.94 },
|
||||
loadRadiusKm: 22,
|
||||
url: 'https://gtfs.ztp.krakow.pl/VehiclePositions_A.pb',
|
||||
license: 'No licence stated',
|
||||
licenseUrl: 'https://gtfs.ztp.krakow.pl/',
|
||||
attribution: 'Zarząd Transportu Publicznego w Krakowie',
|
||||
terms: {
|
||||
note: 'ZTP publishes this feed keyless on its own GTFS server and lists it in the Mobility Database, but states no licence there or on dane.gov.pl. Shipped on the same basis as Metro Transit and OVapi; switch it off if ZTP objects.',
|
||||
},
|
||||
defaultMode: 'bus',
|
||||
}),
|
||||
transitFeed({
|
||||
id: 'ztp-krakow-tram',
|
||||
name: 'Kraków trams',
|
||||
operator: 'Zarząd Transportu Publicznego w Krakowie (ZTP Kraków)',
|
||||
region: 'Kraków, Poland',
|
||||
center: { lat: 50.06, lon: 19.94 },
|
||||
loadRadiusKm: 22,
|
||||
url: 'https://gtfs.ztp.krakow.pl/VehiclePositions_T.pb',
|
||||
license: 'No licence stated',
|
||||
licenseUrl: 'https://gtfs.ztp.krakow.pl/',
|
||||
attribution: 'Zarząd Transportu Publicznego w Krakowie',
|
||||
terms: { note: 'Same server and basis as the bus feed.' },
|
||||
defaultMode: 'tram',
|
||||
}),
|
||||
transitFeed({
|
||||
id: 'vilnius-transport',
|
||||
name: 'Vilnius public transport',
|
||||
operator: 'SĮ Susisiekimo paslaugos, Vilnius',
|
||||
region: 'Vilnius, Lithuania',
|
||||
center: { lat: 54.69, lon: 25.28 },
|
||||
loadRadiusKm: 20,
|
||||
url: 'https://www.stops.lt/vilnius/vehicle_positions.pb',
|
||||
license: 'No licence stated',
|
||||
licenseUrl: 'https://mobilitydatabase.org/feeds/gtfs_rt/mdb-2383',
|
||||
attribution: 'SĮ Susisiekimo paslaugos (stops.lt)',
|
||||
terms: {
|
||||
note: 'Published keyless by the city transport agency on stops.lt; no licence found. Shipped on the same basis as Metro Transit and OVapi. Answered 522 once on 2026-11-07 and normally since.',
|
||||
},
|
||||
defaultMode: 'bus',
|
||||
}),
|
||||
transitFeed({
|
||||
id: 'tpbi-bucuresti',
|
||||
name: 'Bucharest public transport',
|
||||
operator: 'Asociația de Dezvoltare Intercomunitară TPBI, Bucharest–Ilfov',
|
||||
region: 'Bucharest, Romania',
|
||||
center: { lat: 44.43, lon: 26.1 },
|
||||
loadRadiusKm: 25,
|
||||
url: 'https://gtfs.tpbi.ro/api/gtfs-rt/vehiclePositions',
|
||||
license: 'No licence stated',
|
||||
licenseUrl: 'https://mobilitydatabase.org/feeds/gtfs_rt/mdb-2104',
|
||||
attribution: 'TPBI (Transport Public București–Ilfov)',
|
||||
terms: {
|
||||
note: 'Keyless public endpoint from the metropolitan transport authority; no licence found. Same basis as Metro Transit and OVapi.',
|
||||
},
|
||||
defaultMode: 'bus',
|
||||
}),
|
||||
transitFeed({
|
||||
id: 'roma-mobilita',
|
||||
name: 'Rome public transport',
|
||||
operator: 'Roma Servizi per la Mobilità',
|
||||
region: 'Rome, Italy',
|
||||
center: { lat: 41.89, lon: 12.5 },
|
||||
loadRadiusKm: 35,
|
||||
url: 'https://romamobilita.it/wp-content/uploads/shared/rome_rtgtfs_vehicle_positions_feed.pb',
|
||||
license: 'No licence stated',
|
||||
licenseUrl: 'https://mobilitydatabase.org/feeds/gtfs_rt/mdb-1776',
|
||||
attribution: 'Roma Servizi per la Mobilità',
|
||||
terms: {
|
||||
note: 'Keyless file on the agency’s own site; the catalogue’s older `/sites/default/files/` path now 404s. No licence found; same basis as Metro Transit and OVapi. Fewer than one vehicle in five carries a bearing, so most headings come from movement.',
|
||||
},
|
||||
defaultMode: 'bus',
|
||||
}),
|
||||
transitFeed({
|
||||
id: 'gtt-torino',
|
||||
name: 'Turin GTT',
|
||||
operator: 'Gruppo Torinese Trasporti (GTT)',
|
||||
region: 'Turin, Italy',
|
||||
center: { lat: 45.07, lon: 7.68 },
|
||||
loadRadiusKm: 20,
|
||||
url: 'https://percorsieorari.gtt.to.it/das_gtfsrt/vehicle_position.aspx',
|
||||
license: 'No licence stated',
|
||||
licenseUrl: 'https://mobilitydatabase.org/feeds/gtfs_rt/mdb-2696',
|
||||
attribution: 'Gruppo Torinese Trasporti (GTT)',
|
||||
terms: {
|
||||
note: 'Official keyless endpoint; no licence found. Same basis as Metro Transit and OVapi. Route ids append `U` for urban lines.',
|
||||
},
|
||||
defaultMode: 'bus',
|
||||
routeLabel: gttRouteLabel,
|
||||
}),
|
||||
transitFeed({
|
||||
id: 'actv-venezia-bus',
|
||||
name: 'Venice ACTV buses',
|
||||
operator: 'Azienda del Consorzio Trasporti Veneziano (ACTV)',
|
||||
region: 'Venice, Italy',
|
||||
center: { lat: 45.4, lon: 12.2 },
|
||||
loadRadiusKm: 38,
|
||||
url: 'https://tpl.actv.it/aut/GTFSRT/vehiclePositions',
|
||||
license: 'ACTV open-data catalogue',
|
||||
licenseUrl:
|
||||
'https://actv.avmspa.it/it/content/catalogo-dei-dati-metadati-e-banche-dati-actv',
|
||||
attribution: 'ACTV S.p.A.',
|
||||
terms: {
|
||||
note: 'ACTV lists this feed in its own open-data catalogue; that page would not render for a script, so its licence is not quoted. Same basis as Metro Transit and OVapi. Route ids append a two-letter network code (`_UM`, `_ES`, `_UN`).',
|
||||
},
|
||||
defaultMode: 'bus',
|
||||
routeLabel: actvRouteLabel,
|
||||
}),
|
||||
transitFeed({
|
||||
id: 'actv-venezia-waterbus',
|
||||
name: 'Venice ACTV waterbuses',
|
||||
operator: 'Azienda del Consorzio Trasporti Veneziano (ACTV) Navigazione',
|
||||
region: 'Venice, Italy',
|
||||
center: { lat: 45.43, lon: 12.33 },
|
||||
loadRadiusKm: 15,
|
||||
url: 'https://tpl.actv.it/nav/GTFSRT/vehiclePositions',
|
||||
license: 'ACTV open-data catalogue',
|
||||
licenseUrl:
|
||||
'https://actv.avmspa.it/it/content/catalogo-dei-dati-metadati-e-banche-dati-actv',
|
||||
attribution: 'ACTV S.p.A.',
|
||||
terms: { note: 'Same catalogue and basis as the ACTV bus feed.' },
|
||||
defaultMode: 'ferry',
|
||||
routeLabel: actvRouteLabel,
|
||||
}),
|
||||
transitFeed({
|
||||
id: 'adelaide-metro',
|
||||
name: 'Adelaide Metro',
|
||||
operator: 'Adelaide Metro (Government of South Australia)',
|
||||
region: 'Adelaide, SA',
|
||||
center: { lat: -34.93, lon: 138.6 },
|
||||
loadRadiusKm: 35,
|
||||
url: 'https://gtfs.adelaidemetro.com.au/v1/realtime/vehicle_positions',
|
||||
// Any Accept naming a protobuf type gets base64 text labelled JSON; only
|
||||
// */* returns the binary feed (checked 2026-11-08).
|
||||
headers: { Accept: '*/*' },
|
||||
license: 'No licence stated for the realtime feed',
|
||||
licenseUrl: 'https://mobilitydatabase.org/feeds/gtfs_rt/mdb-1772',
|
||||
attribution: 'Adelaide Metro',
|
||||
terms: {
|
||||
note: 'Official keyless endpoint; the state catalogue page refused automated reads, so no licence is quoted. Same basis as Metro Transit and OVapi. Speeds are m/s. The server picks its encoding from Accept, hence the override.',
|
||||
},
|
||||
defaultMode: 'bus',
|
||||
}),
|
||||
transitFeed({
|
||||
id: 'marta-atlanta',
|
||||
name: 'MARTA buses',
|
||||
operator: 'Metropolitan Atlanta Rapid Transit Authority (MARTA)',
|
||||
region: 'Atlanta, GA',
|
||||
center: { lat: 33.78, lon: -84.39 },
|
||||
loadRadiusKm: 35,
|
||||
url: 'https://gtfs-rt.itsmarta.com/TMGTFSRealTimeWebService/vehicle/vehiclepositions.pb',
|
||||
license: 'No licence stated',
|
||||
licenseUrl: 'https://mobilitydatabase.org/feeds/gtfs_rt/mdb-1601',
|
||||
attribution: 'MARTA',
|
||||
terms: {
|
||||
note: 'Official keyless endpoint; no licence found. Same basis as Metro Transit and OVapi. Speeds are m/s.',
|
||||
},
|
||||
defaultMode: 'bus',
|
||||
}),
|
||||
transitFeed({
|
||||
id: 'rtd-denver',
|
||||
name: 'Denver RTD',
|
||||
operator: 'Regional Transportation District (RTD), Denver',
|
||||
region: 'Denver, CO',
|
||||
center: { lat: 39.75, lon: -104.99 },
|
||||
loadRadiusKm: 55,
|
||||
url: 'https://open-data.rtd-denver.com/files/gtfs-rt/rtd/VehiclePosition.pb',
|
||||
license: 'RTD GTFS-realtime licence agreement',
|
||||
licenseUrl:
|
||||
'https://www.rtd-denver.com/business-center/open-spatial-data/gtfs-realtime-license-agreement',
|
||||
attribution: 'Regional Transportation District (RTD)',
|
||||
terms: {
|
||||
quote:
|
||||
'RTD hereby grants you non-exclusive, limited and revocable rights to use, reproduce, and redistribute the Data',
|
||||
note: 'Read 2026-11-09. The agreement is a click-through (“By clicking to download the Data files, you confirm that you have read and agree”) that RTD may change without notice; it permits redistribution, asks for no credit wording, and bars RTD trademarks from being used with the data, so the credit stays plain text. The feed itself is keyless. Speeds are m/s.',
|
||||
},
|
||||
defaultMode: 'bus',
|
||||
}),
|
||||
transitFeed({
|
||||
id: 'ctp-cluj',
|
||||
name: 'Cluj-Napoca CTP',
|
||||
operator: 'Compania de Transport Public Cluj-Napoca',
|
||||
region: 'Cluj-Napoca, Romania',
|
||||
center: { lat: 46.77, lon: 23.59 },
|
||||
loadRadiusKm: 15,
|
||||
url: 'https://cluj-rt-feed.gtfs.ro/vehiclePositions',
|
||||
license: 'No licence stated',
|
||||
licenseUrl: 'https://mobilitydatabase.org/feeds/gtfs_rt/mdb-2614',
|
||||
attribution: 'Compania de Transport Public Cluj-Napoca',
|
||||
terms: {
|
||||
note: 'Keyless; no licence found. Same basis as Metro Transit and OVapi.',
|
||||
},
|
||||
defaultMode: 'bus',
|
||||
}),
|
||||
transitFeed({
|
||||
id: 'ctp-iasi',
|
||||
name: 'Iași CTP',
|
||||
operator: 'Compania de Transport Public Iași',
|
||||
region: 'Iași, Romania',
|
||||
center: { lat: 47.16, lon: 27.59 },
|
||||
loadRadiusKm: 15,
|
||||
url: 'https://iasi-rt-feed.gtfs.ro/vehiclePositions',
|
||||
license: 'No licence stated',
|
||||
licenseUrl: 'https://mobilitydatabase.org/feeds/gtfs_rt/mdb-2118',
|
||||
attribution: 'Compania de Transport Public Iași',
|
||||
terms: {
|
||||
note: 'Keyless; no licence found. Same basis as Metro Transit and OVapi.',
|
||||
},
|
||||
defaultMode: 'bus',
|
||||
}),
|
||||
transitFeed({
|
||||
id: 'mpk-rzeszow',
|
||||
name: 'Rzeszów city transport',
|
||||
operator: 'Rzeszowski Transport Miejski (MPK Rzeszów)',
|
||||
region: 'Rzeszów, Poland',
|
||||
center: { lat: 50.04, lon: 22.0 },
|
||||
loadRadiusKm: 15,
|
||||
url: 'https://www.mpkrzeszow.pl/gtfs/rt/gtfsrt.pb',
|
||||
license: 'MPK Rzeszów open data',
|
||||
licenseUrl: 'https://www.mpkrzeszow.pl/otwartedane/',
|
||||
attribution: 'MPK Rzeszów',
|
||||
terms: {
|
||||
note: 'Listed on the operator’s open-data page, which names no licence. No bearing; heading comes from movement.',
|
||||
},
|
||||
defaultMode: 'bus',
|
||||
}),
|
||||
transitFeed({
|
||||
id: 'emt-malaga',
|
||||
name: 'Málaga EMT',
|
||||
operator: 'Empresa Malagueña de Transportes (EMT)',
|
||||
region: 'Málaga, Spain',
|
||||
center: { lat: 36.72, lon: -4.42 },
|
||||
loadRadiusKm: 15,
|
||||
url: 'https://gtfs.emtmalaga.es/GTFSRTapi/api/vehicleposition',
|
||||
license: 'No licence stated',
|
||||
licenseUrl: 'https://mobilitydatabase.org/feeds/gtfs_rt/mdb-2213',
|
||||
attribution: 'EMT Málaga',
|
||||
terms: {
|
||||
note: 'Keyless; no licence found. Same basis as Metro Transit and OVapi. No bearing; heading comes from movement.',
|
||||
},
|
||||
defaultMode: 'bus',
|
||||
}),
|
||||
transitFeed({
|
||||
id: 'carris-lisboa',
|
||||
name: 'Carris (Lisbon city)',
|
||||
operator: 'Carris – Companhia Carris de Ferro de Lisboa',
|
||||
region: 'Lisbon, Portugal',
|
||||
center: { lat: 38.74, lon: -9.15 },
|
||||
loadRadiusKm: 15,
|
||||
url: 'https://gateway.carris.pt/gateway/gtfs/api/v2.11/GTFS/realtime/vehiclepositions',
|
||||
license: 'No licence stated',
|
||||
licenseUrl: 'https://mobilitydatabase.org/feeds/gtfs_rt/mdb-2930',
|
||||
attribution: 'Carris',
|
||||
terms: {
|
||||
note: 'Keyless official gateway; no licence found. Same basis as Metro Transit and OVapi. Carries neither bearing nor speed, and vehicle reports run about a minute old. Route ids append a variant.',
|
||||
},
|
||||
defaultMode: 'bus',
|
||||
routeLabel: carrisRouteLabel,
|
||||
}),
|
||||
transitFeed({
|
||||
id: 'mybas-johor',
|
||||
name: 'BAS.MY Johor Bahru',
|
||||
operator: 'BAS.MY, via data.gov.my',
|
||||
region: 'Johor Bahru, Malaysia',
|
||||
center: { lat: 1.51, lon: 103.75 },
|
||||
loadRadiusKm: 35,
|
||||
url: 'https://api.data.gov.my/gtfs-realtime/vehicle-position/mybas-johor/',
|
||||
license: 'CC BY 4.0',
|
||||
licenseUrl: 'https://developer.data.gov.my/realtime-api/gtfs-realtime',
|
||||
attribution: 'BAS.MY / data.gov.my',
|
||||
// data.gov.my allows 4 GTFS-Realtime requests a minute and refreshes
|
||||
// vehicle positions every 30 seconds, so the proxy asks half as often as
|
||||
// the default 15 s TTL would.
|
||||
refreshMs: 30_000,
|
||||
terms: {
|
||||
quote: 'All GTFS Realtime feeds are licensed under CC BY 4.0.',
|
||||
note: 'Served keyless by Malaysia’s national open-data API (developer.data.gov.my, read 2026-11-09). Its rate-limit page caps GTFS Realtime at 4 requests a minute and answers 429 above that; the feed updates every 30 seconds, hence `refreshMs`. Without the trailing slash the API redirects. Route ids append an operator code.',
|
||||
},
|
||||
defaultMode: 'bus',
|
||||
routeLabel: mybasJohorRouteLabel,
|
||||
}),
|
||||
transitFeed({
|
||||
id: 'mybas-kota-bharu',
|
||||
name: 'BAS.MY Kota Bharu',
|
||||
operator: 'BAS.MY, via data.gov.my',
|
||||
region: 'Kota Bharu, Malaysia',
|
||||
// Routes run south into Kelantan: 95% of vehicles within 58 km on 2026-11-08.
|
||||
center: { lat: 6.06, lon: 102.22 },
|
||||
loadRadiusKm: 60,
|
||||
url: 'https://api.data.gov.my/gtfs-realtime/vehicle-position/mybas-kota-bharu/',
|
||||
license: 'CC BY 4.0',
|
||||
licenseUrl: 'https://developer.data.gov.my/realtime-api/gtfs-realtime',
|
||||
attribution: 'BAS.MY / data.gov.my',
|
||||
refreshMs: 30_000,
|
||||
terms: {
|
||||
quote: 'All GTFS Realtime feeds are licensed under CC BY 4.0.',
|
||||
note: 'Same API, licence and rate limit as Johor Bahru.',
|
||||
},
|
||||
defaultMode: 'bus',
|
||||
}),
|
||||
transitFeed({
|
||||
id: 'astuce-rouen',
|
||||
name: 'Rouen Astuce',
|
||||
operator: 'Métropole Rouen Normandie (Astuce), réseau TCAR',
|
||||
region: 'Rouen, France',
|
||||
center: { lat: 49.44, lon: 1.1 },
|
||||
loadRadiusKm: 20,
|
||||
url: 'https://api.mrn.cityway.fr/dataflow/vehicle-tc-tr/download?provider=TCAR&dataFormat=gtfs-rt',
|
||||
license: 'Licence Ouverte 2.0',
|
||||
licenseUrl:
|
||||
'https://transport.data.gouv.fr/datasets/donnees-statiques-et-temps-reel-du-reseau-astuce-metropole-rouen-normandie',
|
||||
attribution: 'Métropole Rouen Normandie (Astuce)',
|
||||
terms: {
|
||||
quote: 'licence: lov2',
|
||||
note: 'The metropolis publishes its realtime data under the Licence Ouverte (dataset record quoted), and this is its own TCAR vehicle feed from that record (178 vehicles on 2026-11-09). The suburban TAE and TNI networks are separate feeds in the same record and are not read here. Route ids carry the network prefix (`TCAR:`); `TCAR:90` is the Métro (a street-running light rail, drawn as a tram) and `TCAR:99` the Calypso boat.',
|
||||
},
|
||||
defaultMode: 'bus',
|
||||
routeLabel: astuceRouteLabel,
|
||||
routeMode: astuceRouteMode,
|
||||
}),
|
||||
transitFeed({
|
||||
id: 'mpm-toulon',
|
||||
name: 'Toulon Mistral',
|
||||
operator: 'Métropole Toulon Provence Méditerranée (réseau Mistral)',
|
||||
region: 'Toulon, France',
|
||||
center: { lat: 43.12, lon: 5.94 },
|
||||
loadRadiusKm: 18,
|
||||
url: 'https://feed-rdtpm-toulon.ratpdev.com/VehiclePosition/GTFS-RT',
|
||||
license: 'Licence Ouverte 2.0',
|
||||
licenseUrl:
|
||||
'https://transport.data.gouv.fr/datasets/reseau-de-transport-urbain-de-la-metropole-toulon-provence-mediterranee',
|
||||
attribution: 'Métropole Toulon Provence Méditerranée (Mistral)',
|
||||
terms: {
|
||||
quote: 'licence: lov2',
|
||||
note: 'Dataset record’s licence field quoted. Speeds are km/h: readings ran 3.5 times the measured movement on 2026-11-07, so the proxy converts them. Route ids are zero-padded.',
|
||||
},
|
||||
defaultMode: 'bus',
|
||||
routeLabel: unpaddedRouteLabel,
|
||||
speedUnit: 'km/h',
|
||||
}),
|
||||
transitFeed({
|
||||
id: 'lia-le-havre',
|
||||
name: 'Le Havre LiA',
|
||||
operator: 'Le Havre Seine Métropole (LiA)',
|
||||
region: 'Le Havre, France',
|
||||
center: { lat: 49.49, lon: 0.11 },
|
||||
loadRadiusKm: 18,
|
||||
url: 'https://opendata.transports-lia.fr/gtfs-rt/VehiclePosition.pb',
|
||||
license: 'ODbL',
|
||||
licenseUrl:
|
||||
'https://transport.data.gouv.fr/datasets/reseau-de-transport-en-commun-lia-gtfs-siri',
|
||||
attribution: 'Le Havre Seine Métropole (LiA)',
|
||||
terms: {
|
||||
quote: 'licence: odc-odbl',
|
||||
note: 'The network publishes under ODbL (dataset record quoted), and this is its own GTFS-RT vehicle feed from that record (105 vehicles on 2026-11-09). The layer draws positions and redistributes no database. Route ids carry the network prefix `8-`; lines A and B are the tramway.',
|
||||
},
|
||||
defaultMode: 'bus',
|
||||
routeLabel: liaRouteLabel,
|
||||
routeMode: liaRouteMode,
|
||||
}),
|
||||
transitFeed({
|
||||
id: 'filbleu-tours',
|
||||
name: 'Tours Fil Bleu',
|
||||
operator: 'Syndicat des Mobilités de Touraine (Fil Bleu)',
|
||||
region: 'Tours, France',
|
||||
center: { lat: 47.39, lon: 0.69 },
|
||||
loadRadiusKm: 18,
|
||||
url: 'https://data.filbleu.fr/ws-tr/gtfs-rt/opendata/vehicle-positions',
|
||||
license: 'Licence Ouverte 2.0',
|
||||
licenseUrl:
|
||||
'https://transport.data.gouv.fr/datasets/fil-bleu-syndicat-des-mobilites-gtfs-gtfs-rt',
|
||||
attribution: 'Fil Bleu – Syndicat des Mobilités de Touraine',
|
||||
terms: {
|
||||
quote: 'licence: lov2',
|
||||
note: 'Dataset record’s licence field quoted.',
|
||||
},
|
||||
defaultMode: 'bus',
|
||||
}),
|
||||
]);
|
||||
|
||||
/**
|
||||
|
||||
@@ -552,3 +552,229 @@ test('Rio feeds are CC BY 4.0 and live', () => {
|
||||
'Rio has a DATA_SOURCES row',
|
||||
);
|
||||
});
|
||||
|
||||
test('Szczecin is CC0, live, covers the city and not Berlin', () => {
|
||||
const feed = getTransitFeed('zditm-szczecin');
|
||||
assert.equal(feed.defaultEnabled, true);
|
||||
assert.equal(feed.license, 'CC0 1.0');
|
||||
assert.equal(
|
||||
feed.licenseUrl,
|
||||
'https://www.zditm.szczecin.pl/en/zditm/for-developers',
|
||||
);
|
||||
assert.match(feed.terms.quote, /CC0 1\.0 licence/);
|
||||
assert.match(feed.attribution, /ZDiTM/);
|
||||
assert.equal(feed.routeLabel, undefined, 'the feed has no route ids');
|
||||
assert.equal(transitModeFor(feed, null), 'bus');
|
||||
const ids = (lat, lon) => transitFeedsInRange(lat, lon).map((f) => f.id);
|
||||
assert.ok(ids(53.4285, 14.5528).includes('zditm-szczecin'), 'city centre');
|
||||
assert.ok(ids(53.5, 14.7).includes('zditm-szczecin'), 'north-east edge');
|
||||
assert.ok(!ids(52.52, 13.405).includes('zditm-szczecin'), 'Berlin');
|
||||
const sources = readFileSync(
|
||||
new URL('../../DATA_SOURCES.md', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
assert.ok(
|
||||
sources.split('\n').some((l) => l.startsWith('| **ZDiTM Szczecin')),
|
||||
'Szczecin has a DATA_SOURCES row',
|
||||
);
|
||||
});
|
||||
|
||||
test('Carris Metropolitana covers Lisbon from Mafra to Setúbal, not Porto', () => {
|
||||
const feed = getTransitFeed('carris-metropolitana-lisboa');
|
||||
assert.equal(feed.defaultEnabled, true);
|
||||
assert.equal(feed.url, 'https://api.carrismetropolitana.pt/v2/vehicles.pb');
|
||||
assert.equal(feed.speedUnit, 'km/h');
|
||||
assert.match(feed.terms.note, /no licence for the realtime endpoint/);
|
||||
const ids = (lat, lon) => transitFeedsInRange(lat, lon).map((f) => f.id);
|
||||
for (const [place, lat, lon] of [
|
||||
['Lisbon', 38.7223, -9.1393],
|
||||
['Mafra', 38.9369, -9.3277],
|
||||
['Setúbal', 38.5244, -8.8882],
|
||||
]) {
|
||||
assert.ok(ids(lat, lon).includes(feed.id), place);
|
||||
}
|
||||
assert.ok(!ids(41.1579, -8.6291).includes(feed.id), 'Porto');
|
||||
const sources = readFileSync(
|
||||
new URL('../../DATA_SOURCES.md', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
assert.ok(
|
||||
sources.split('\n').some((l) => l.startsWith('| **Carris Metropolitana')),
|
||||
'Lisbon has a DATA_SOURCES row',
|
||||
);
|
||||
});
|
||||
|
||||
test('Carris Metropolitana route ids read as the line number on the bus', () => {
|
||||
const feed = getTransitFeed('carris-metropolitana-lisboa');
|
||||
assert.equal(transitRouteLabel(feed, '[LA77N]1708_0'), '1708');
|
||||
assert.equal(transitRouteLabel(feed, '[BNA17]2772_1'), '2772');
|
||||
assert.equal(transitRouteLabel(feed, 'unexpected'), 'unexpected');
|
||||
assert.equal(transitRouteLabel(feed, null), null);
|
||||
});
|
||||
|
||||
// The 2026-11 batch: one landmark per feed, inside its own radius.
|
||||
const NEW_CITY_LANDMARKS = {
|
||||
'pid-praha': [50.0875, 14.4213],
|
||||
'ztm-warszawa': [52.2297, 21.0122],
|
||||
'tbm-bordeaux': [44.8378, -0.5792],
|
||||
'sendai-city-bus': [38.2602, 140.8824],
|
||||
'ztm-poznan': [52.4064, 16.9252],
|
||||
'ztm-gdansk': [54.352, 18.6466],
|
||||
'ets-edmonton': [53.5461, -113.4938],
|
||||
'halifax-transit': [44.6488, -63.5752],
|
||||
'bc-transit-victoria': [48.4284, -123.3656],
|
||||
'tam-montpellier': [43.6108, 3.8767],
|
||||
'ztp-krakow-bus': [50.0614, 19.9366],
|
||||
'ztp-krakow-tram': [50.0614, 19.9366],
|
||||
'vilnius-transport': [54.6872, 25.2797],
|
||||
'tpbi-bucuresti': [44.4268, 26.1025],
|
||||
'roma-mobilita': [41.9028, 12.4964],
|
||||
'gtt-torino': [45.0703, 7.6869],
|
||||
'actv-venezia-bus': [45.4408, 12.3155],
|
||||
'actv-venezia-waterbus': [45.4408, 12.3155],
|
||||
'adelaide-metro': [-34.9285, 138.6007],
|
||||
'marta-atlanta': [33.749, -84.388],
|
||||
'rtd-denver': [39.7392, -104.9903],
|
||||
'ctp-cluj': [46.7712, 23.6236],
|
||||
'ctp-iasi': [47.1585, 27.6014],
|
||||
'mpk-rzeszow': [50.0412, 21.9991],
|
||||
'emt-malaga': [36.7213, -4.4214],
|
||||
'carris-lisboa': [38.7223, -9.1393],
|
||||
'mybas-johor': [1.4927, 103.7414],
|
||||
'mybas-kota-bharu': [6.1254, 102.2381],
|
||||
'astuce-rouen': [49.4432, 1.0999],
|
||||
'mpm-toulon': [43.1242, 5.928],
|
||||
'lia-le-havre': [49.4944, 0.1079],
|
||||
'filbleu-tours': [47.3941, 0.6848],
|
||||
};
|
||||
|
||||
test('every city in the 2026-11 batch is live, licensed and covers its own centre', () => {
|
||||
for (const [id, [lat, lon]] of Object.entries(NEW_CITY_LANDMARKS)) {
|
||||
const feed = getTransitFeed(id);
|
||||
assert.ok(feed, `${id} is routable`);
|
||||
assert.ok(
|
||||
publicTransitCatalog().some((f) => f.id === id),
|
||||
`${id} is offered to the browser`,
|
||||
);
|
||||
assert.ok(feed.terms?.note, `${id} records the basis it ships on`);
|
||||
assert.ok(
|
||||
transitFeedsInRange(lat, lon).some((f) => f.id === id),
|
||||
`${id} covers its city`,
|
||||
);
|
||||
}
|
||||
// A feed's radius stays local: Warsaw is not polled for Kraków.
|
||||
assert.ok(
|
||||
!transitFeedsInRange(50.0614, 19.9366).some((f) => f.id === 'ztm-warszawa'),
|
||||
);
|
||||
});
|
||||
|
||||
test('feeds with no stated licence say so and name the basis they ship on', () => {
|
||||
for (const feed of TRANSIT_FEED_REGISTRY.filter((f) =>
|
||||
/^No licence stated/.test(f.license),
|
||||
)) {
|
||||
assert.match(
|
||||
feed.terms.note,
|
||||
/Metro Transit and OVapi|Same .*basis/,
|
||||
`${feed.id} names its basis`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('route ids in the 2026-11 batch read as the line on the vehicle', () => {
|
||||
const label = (id, routeId) => transitRouteLabel(getTransitFeed(id), routeId);
|
||||
assert.equal(label('pid-praha', 'L135'), '135');
|
||||
assert.equal(label('ets-edmonton', '009'), '9');
|
||||
assert.equal(label('mpm-toulon', '0002'), '2');
|
||||
assert.equal(label('gtt-torino', '10NU'), '10N');
|
||||
assert.equal(label('actv-venezia-waterbus', '5.1_UN'), '5.1');
|
||||
assert.equal(label('actv-venezia-bus', '53E_ES'), '53E');
|
||||
assert.equal(label('bc-transit-victoria', '95-VIC'), '95');
|
||||
assert.equal(label('carris-lisboa', '172_1'), '172');
|
||||
assert.equal(label('astuce-rouen', 'TCAR:93'), '93');
|
||||
assert.equal(label('mybas-johor', 'J33CWLMYJB'), 'J33');
|
||||
// Unmatched and absent ids pass through untouched.
|
||||
assert.equal(label('pid-praha', 'S4'), 'S4');
|
||||
assert.equal(label('gtt-torino', null), null);
|
||||
});
|
||||
|
||||
test('trams, waterbuses and buses are told apart where the ids allow it', () => {
|
||||
const mode = (id, routeId) => transitModeFor(getTransitFeed(id), routeId);
|
||||
assert.equal(mode('pid-praha', 'L22'), 'tram');
|
||||
assert.equal(mode('pid-praha', 'L135'), 'bus');
|
||||
assert.equal(mode('tbm-bordeaux', '16'), 'bus');
|
||||
assert.equal(mode('lia-le-havre', '8-B'), 'tram');
|
||||
assert.equal(mode('lia-le-havre', '8-C3'), 'bus');
|
||||
assert.equal(mode('ztp-krakow-tram', null), 'tram');
|
||||
assert.equal(mode('actv-venezia-waterbus', '1_UN'), 'ferry');
|
||||
});
|
||||
|
||||
test('feeds that publish km/h are marked for conversion, and only those', () => {
|
||||
const kmh = TRANSIT_FEED_REGISTRY.filter((f) => f.speedUnit === 'km/h')
|
||||
.map((f) => f.id)
|
||||
.sort();
|
||||
assert.deepEqual(kmh, [
|
||||
'carris-metropolitana-lisboa',
|
||||
'mpm-toulon',
|
||||
'tam-montpellier',
|
||||
]);
|
||||
});
|
||||
|
||||
// Route ids as the live feeds and the operators' own GTFS routes.txt carried
|
||||
// them on 2026-11-09: the ranges and numbered tram ids are not guessable from
|
||||
// the line names riders see.
|
||||
test('Prague route-id ranges give metro, trains, ferries and trolleybuses their mode', () => {
|
||||
const feed = getTransitFeed('pid-praha');
|
||||
const read = (routeId) => [
|
||||
transitModeFor(feed, routeId),
|
||||
transitRouteLabel(feed, routeId),
|
||||
];
|
||||
assert.deepEqual(read('L22'), ['tram', '22']);
|
||||
assert.deepEqual(read('L58'), ['bus', '58'], 'trolleybus');
|
||||
assert.deepEqual(read('L49'), ['rail', 'LD'], 'Petřín funicular');
|
||||
assert.deepEqual(read('L135'), ['bus', '135']);
|
||||
assert.deepEqual(read('L803'), ['bus', 'X3']);
|
||||
assert.deepEqual(read('L991'), ['subway', 'A']);
|
||||
assert.deepEqual(read('L993'), ['subway', 'C']);
|
||||
assert.deepEqual(read('L1004'), ['rail', 'S4']);
|
||||
assert.deepEqual(read('L1109'), ['rail', 'R9']);
|
||||
assert.deepEqual(read('L1204'), ['rail', 'L1204'], 'no label to derive');
|
||||
assert.deepEqual(read('L1801'), ['ferry', 'P1']);
|
||||
assert.deepEqual(read('L1703'), ['bus', 'L1703']);
|
||||
assert.deepEqual(read('L2841'), ['bus', '841']);
|
||||
assert.deepEqual(read('S4'), ['bus', 'S4'], 'unknown ids keep the default');
|
||||
});
|
||||
|
||||
test('Bordeaux trams ride numbered route ids and keep their letters', () => {
|
||||
const feed = getTransitFeed('tbm-bordeaux');
|
||||
for (const [routeId, line] of [
|
||||
['59', 'A'],
|
||||
['62', 'D'],
|
||||
['163', 'E'],
|
||||
]) {
|
||||
assert.equal(transitModeFor(feed, routeId), 'tram', routeId);
|
||||
assert.equal(transitRouteLabel(feed, routeId), line);
|
||||
}
|
||||
assert.equal(transitModeFor(feed, '952'), 'ferry');
|
||||
assert.equal(transitModeFor(feed, '01'), 'bus');
|
||||
assert.equal(transitRouteLabel(feed, '01'), '1');
|
||||
});
|
||||
|
||||
test('Rouen Métro and Calypso are not buses; Le Havre reads its own ids', () => {
|
||||
const feed = getTransitFeed('astuce-rouen');
|
||||
assert.equal(transitModeFor(feed, 'TCAR:90'), 'tram');
|
||||
assert.equal(transitRouteLabel(feed, 'TCAR:90'), 'Métro');
|
||||
assert.equal(transitModeFor(feed, 'TCAR:99'), 'ferry');
|
||||
assert.equal(transitModeFor(feed, 'TCAR:93'), 'bus');
|
||||
assert.equal(transitRouteLabel(feed, 'TAE:F'), 'F');
|
||||
const lia = getTransitFeed('lia-le-havre');
|
||||
assert.equal(transitModeFor(lia, '8-FUNI'), 'rail');
|
||||
assert.equal(transitRouteLabel(lia, '8-C3'), 'C3');
|
||||
assert.equal(transitRouteLabel(lia, '8-09'), '9');
|
||||
assert.equal(transitRouteLabel(lia, '8-A'), 'A');
|
||||
});
|
||||
|
||||
test('data.gov.my feeds ask the proxy for no more than its 4 a minute', () => {
|
||||
for (const id of ['mybas-johor', 'mybas-kota-bharu']) {
|
||||
assert.ok(getTransitFeed(id).refreshMs >= 30_000, id);
|
||||
}
|
||||
});
|
||||
@@ -257,24 +257,43 @@ export function buildTransitSnapshot(feed, bytes, now = Date.now()) {
|
||||
truncated: decoded.truncated,
|
||||
count: decoded.vehicles.length,
|
||||
vehicles: repairVehicleTimestamps(
|
||||
decoded.vehicles,
|
||||
feed.speedUnit === 'km/h'
|
||||
? decoded.vehicles.map((v) =>
|
||||
v.speedMps === null ? v : { ...v, speedMps: v.speedMps / 3.6 },
|
||||
)
|
||||
: decoded.vehicles,
|
||||
decoded.timestamp,
|
||||
Math.floor(now / 1000),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fresh window for one feed's cached snapshot: the shared TTL, stretched by a
|
||||
* registry `refreshMs` for upstreams that rate-limit below one request per
|
||||
* TTL (data.gov.my allows 4 a minute). Never shorter than the shared TTL.
|
||||
* @param {{ refreshMs?: number }|null|undefined} feed Registry entry.
|
||||
* @returns {number}
|
||||
*/
|
||||
export function transitFeedTtlMs(feed) {
|
||||
const refresh = feed?.refreshMs;
|
||||
return Number.isFinite(refresh) && refresh > TRANSIT_PROXY_TTL_MS
|
||||
? refresh
|
||||
: TRANSIT_PROXY_TTL_MS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify a cache entry for the request policy.
|
||||
* @param {{ at: number }|null|undefined} entry Cached snapshot (`at` = fetch ms).
|
||||
* @param {number} now
|
||||
* @param {number} [ttlMs] Fresh window; `transitFeedTtlMs(feed)` for a feed.
|
||||
* @returns {'none'|'fresh'|'stale'|'expired'}
|
||||
*/
|
||||
export function transitCacheState(entry, now) {
|
||||
export function transitCacheState(entry, now, ttlMs = TRANSIT_PROXY_TTL_MS) {
|
||||
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 < ttlMs) return 'fresh';
|
||||
if (age < TRANSIT_PROXY_STALE_MAX_MS) return 'stale';
|
||||
return 'expired';
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
repairVehicleTimestamps,
|
||||
resolveTransitRoute,
|
||||
transitCacheState,
|
||||
transitFeedTtlMs,
|
||||
transitRedirectDecision,
|
||||
transitResponseHeaders,
|
||||
transitUpstreamHeaders,
|
||||
@@ -294,6 +295,27 @@ test('Rio speeds pass through as the m/s they are published in', () => {
|
||||
assert.equal(rio.vehicles[0].speedMps, 10);
|
||||
});
|
||||
|
||||
test('Carris Metropolitana km/h speeds leave the proxy as m/s', () => {
|
||||
// 2026-11-07: readings of 31-33 matched 6-9 m/s of measured movement.
|
||||
const bytes = encodeFeed({
|
||||
entities: [
|
||||
{ id: 'moving', lat: 38.72, lon: -9.14, speed: 36 },
|
||||
{ id: 'parked', lat: 38.73, lon: -9.15 },
|
||||
],
|
||||
});
|
||||
const feed = getTransitFeed('carris-metropolitana-lisboa');
|
||||
const lisbon = buildTransitSnapshot(feed, bytes, 1);
|
||||
const byId = Object.fromEntries(lisbon.vehicles.map((v) => [v.id, v]));
|
||||
assert.ok(Math.abs(byId.moving.speedMps - 10) < 1e-6);
|
||||
assert.equal(byId.parked.speedMps, null, 'no speed stays no speed');
|
||||
const szczecin = buildTransitSnapshot(
|
||||
getTransitFeed('zditm-szczecin'),
|
||||
encodeFeed({ entities: [{ id: 'm', lat: 53.43, lon: 14.55, speed: 10 }] }),
|
||||
1,
|
||||
);
|
||||
assert.equal(szczecin.vehicles[0].speedMps, 10, 'm/s feeds are untouched');
|
||||
});
|
||||
|
||||
test('conditional-request validators and gzip ride along with feed identification', () => {
|
||||
const entur = getTransitFeed('entur-norway');
|
||||
const plain = transitUpstreamHeaders(entur);
|
||||
@@ -375,3 +397,24 @@ test('client history validates response bounds, epochs and cancellation through
|
||||
{ name: 'AbortError' },
|
||||
);
|
||||
});
|
||||
|
||||
test('Adelaide asks for */* because a protobuf Accept gets base64 text back', () => {
|
||||
const headers = transitUpstreamHeaders(getTransitFeed('adelaide-metro'));
|
||||
assert.equal(headers.Accept, '*/*');
|
||||
// Every other feed keeps the protobuf-first Accept.
|
||||
assert.match(
|
||||
transitUpstreamHeaders(getTransitFeed('mbta')).Accept,
|
||||
/protobuf/,
|
||||
);
|
||||
});
|
||||
|
||||
test('a feed refreshMs stretches its fresh window, never shortens it', () => {
|
||||
const johor = getTransitFeed('mybas-johor');
|
||||
assert.equal(transitFeedTtlMs(johor), 30_000);
|
||||
assert.equal(transitFeedTtlMs(getTransitFeed('mbta')), TRANSIT_PROXY_TTL_MS);
|
||||
assert.equal(transitFeedTtlMs({ refreshMs: 1_000 }), TRANSIT_PROXY_TTL_MS);
|
||||
const now = 1_000_000;
|
||||
const entry = { at: now - 20_000 };
|
||||
assert.equal(transitCacheState(entry, now), 'stale');
|
||||
assert.equal(transitCacheState(entry, now, transitFeedTtlMs(johor)), 'fresh');
|
||||
});
|
||||
@@ -3005,6 +3005,15 @@
|
||||
"many": "لا مصدر بيانات هنا بعد · {n} منطقة متاحة",
|
||||
"other": "لا مصدر بيانات هنا بعد · {n} منطقة متاحة"
|
||||
},
|
||||
"Fly below {km} km to a covered city": "انخفض إلى ما دون {km} كم فوق مدينة مغطاة",
|
||||
"No bike share here yet · {n} systems available": {
|
||||
"zero": "لا دراجات مشتركة هنا بعد · {n} نظام متاح",
|
||||
"one": "لا دراجات مشتركة هنا بعد · نظام واحد متاح ({n})",
|
||||
"two": "لا دراجات مشتركة هنا بعد · نظامان متاحان ({n})",
|
||||
"few": "لا دراجات مشتركة هنا بعد · {n} أنظمة متاحة",
|
||||
"many": "لا دراجات مشتركة هنا بعد · {n} نظامًا متاحًا",
|
||||
"other": "لا دراجات مشتركة هنا بعد · {n} نظام متاح"
|
||||
},
|
||||
"degraded": "متدهور",
|
||||
"stale": "متقادم",
|
||||
"partial": "جزئي",
|
||||
|
||||
@@ -2751,6 +2751,11 @@
|
||||
"one": "Hier noch kein Feed · {n} Region verfügbar",
|
||||
"other": "Hier noch kein Feed · {n} Regionen verfügbar"
|
||||
},
|
||||
"Fly below {km} km to a covered city": "Unter {km} km Höhe zu einer abgedeckten Stadt fliegen",
|
||||
"No bike share here yet · {n} systems available": {
|
||||
"one": "Hier noch kein Bikesharing · {n} System verfügbar",
|
||||
"other": "Hier noch kein Bikesharing · {n} Systeme verfügbar"
|
||||
},
|
||||
"degraded": "eingeschränkt",
|
||||
"stale": "veraltet",
|
||||
"partial": "teilweise",
|
||||
|
||||
@@ -2915,6 +2915,12 @@
|
||||
"many": "Aún no hay fuente aquí · {n} de regiones disponibles",
|
||||
"other": "Aún no hay fuente aquí · {n} regiones disponibles"
|
||||
},
|
||||
"Fly below {km} km to a covered city": "Desciende por debajo de {km} km sobre una ciudad con cobertura",
|
||||
"No bike share here yet · {n} systems available": {
|
||||
"one": "Aún no hay bicis compartidas aquí · {n} sistema disponible",
|
||||
"many": "Aún no hay bicis compartidas aquí · {n} de sistemas disponibles",
|
||||
"other": "Aún no hay bicis compartidas aquí · {n} sistemas disponibles"
|
||||
},
|
||||
"Wind speed field unavailable": "Campo de velocidad del viento no disponible",
|
||||
"Decoder feeds": "Fuentes del decodificador",
|
||||
"WebUSB + decoder feeds": "WebUSB + fuentes del decodificador",
|
||||
|
||||
+29
-23
@@ -820,7 +820,7 @@
|
||||
"FLIGHT {count}": "VOL {count}",
|
||||
"REUSED": "RÉUTILISÉ",
|
||||
"NO STAGE RE-ENTRY / RECOVERY DATA": "AUCUNE DONNÉE DE RENTRÉE / RÉCUPÉRATION D'ÉTAGE",
|
||||
"{count} / 30D": "{count} / 30J",
|
||||
"{count} / 30D": "{count} / 30 J",
|
||||
"NO MISSIONS AVAILABLE IN THE CURRENT 30-DAY WINDOW": "AUCUNE MISSION DISPONIBLE DANS LA FENÊTRE ACTUELLE DE 30 JOURS",
|
||||
"DATE UNAVAILABLE": "DATE INDISPONIBLE",
|
||||
"UNSPECIFIED OPERATOR": "OPÉRATEUR NON PRÉCISÉ",
|
||||
@@ -1052,11 +1052,11 @@
|
||||
"Longitudes must be within ±180°": "Les longitudes doivent être comprises entre −180° et +180°",
|
||||
"Select one side of the dateline": "Ne traversez pas la ligne de changement de date",
|
||||
"Box must enclose an area": "La zone doit délimiter une surface",
|
||||
"Add the full Starlink broadband shell (thousands of extra points)": "Ajouter toute la constellation Starlink (des milliers de points en plus)",
|
||||
"Loading the Starlink shell…": "Chargement de la constellation Starlink…",
|
||||
"Add the full Starlink broadband shell (thousands of extra points)": "Ajouter la couche complète de la constellation Starlink (des milliers de points en plus)",
|
||||
"Loading the Starlink shell…": "Chargement de la couche de la constellation Starlink…",
|
||||
"Starlink {detail} — click to retry": "Starlink {detail} — cliquer pour réessayer",
|
||||
"load failed": "échec du chargement",
|
||||
"Showing the full Starlink shell — click for the core catalog only": "Constellation Starlink complète affichée — cliquer pour le catalogue de base seulement",
|
||||
"Showing the full Starlink shell — click for the core catalog only": "Couche complète de la constellation Starlink affichée — cliquer pour le catalogue de base seulement",
|
||||
"DENSE": "DENSE",
|
||||
"CelesTrak unreachable": "CelesTrak injoignable",
|
||||
"DOCKED · {name} · +{extra}": "AMARRÉ · {name} · +{extra}",
|
||||
@@ -1137,7 +1137,7 @@
|
||||
"Hybrid": "Hybride",
|
||||
"Lines": "Lignes",
|
||||
"Dots": "Points",
|
||||
"Road lines colored by live flow: green to red": "Routes colorées selon le trafic en direct : du vert au rouge",
|
||||
"Road lines colored by live flow: green to red": "Routes colorées selon le trafic en direct : du vert au rouge",
|
||||
"Moving dots colored by live flow": "Points mobiles colorés selon le trafic en direct",
|
||||
"Moving dots": "Points mobiles",
|
||||
"TomTom key unavailable": "Clé TomTom indisponible",
|
||||
@@ -1153,7 +1153,7 @@
|
||||
"LIVE · Roads: {source} · No flow roads in view": "DIRECT · Routes : {source} · Aucune route avec flux dans la vue",
|
||||
"LIVE · Roads: {source} · Flow {percent}%": "DIRECT · Routes : {source} · Flux {percent} %",
|
||||
"SIMULATED · Roads: {source} · Flow {percent}%": "SIMULÉ · Routes : {source} · Flux {percent} %",
|
||||
"STALE · Roads: {source} · Flow: cached TomTom tiles": "OBSOLÈTE · Routes : {source} · Flux : tuiles TomTom en cache",
|
||||
"STALE · Roads: {source} · Flow: cached TomTom tiles": "OBSOLÈTE · Routes : {source} · Flux : tuiles TomTom en cache",
|
||||
"Syncing flow · Roads: OpenStreetMap · Flow: TomTom · Unmatched: simulated": "Synchro du flux · Routes : OpenStreetMap · Flux : TomTom · Non appariées : simulées",
|
||||
"LIVE · Roads: OpenStreetMap · Flow: TomTom · {percent}% cov · Unmatched: simulated": "DIRECT · Routes : OpenStreetMap · Flux : TomTom · couv. {percent} % · Non appariées : simulées",
|
||||
"LIVE · Roads: OpenStreetMap · Flow: TomTom · {percent}% cov": "DIRECT · Routes : OpenStreetMap · Flux : TomTom · couv. {percent} %",
|
||||
@@ -1250,7 +1250,7 @@
|
||||
"Contiguous US · gaps ≠ no rain": "États-Unis contigus · lacunes ≠ absence de pluie",
|
||||
"Americas + Pacific · not individual strikes": "Amériques + Pacifique · pas des impacts individuels",
|
||||
"Color: strikes/km²/min ×10³": "Couleur : impacts/km²/min ×10³",
|
||||
"Color: {units}": "Couleur : {units}",
|
||||
"Color: {units}": "Couleur : {units}",
|
||||
"RADAR · {units}": "RADAR · {units}",
|
||||
"60°S–60°N · typically 2–3 h delayed": "60°S–60°N · retard habituel de 2 à 3 h",
|
||||
"North America · infrared imagery": "Amérique du Nord · imagerie infrarouge",
|
||||
@@ -1382,7 +1382,7 @@
|
||||
"{label} is unavailable": "{label} est indisponible",
|
||||
"This map stack": "Ce fond de carte",
|
||||
"{label} unavailable: {hint}": "{label} indisponible : {hint}",
|
||||
"Google 3D tiles unavailable — check the key's API restrictions, quota, or network": "Tuiles Google 3D indisponibles — vérifiez les restrictions d'API de la clé, le quota ou le réseau",
|
||||
"Google 3D tiles unavailable — check the key's API restrictions, quota, or network": "Google 3D Tiles indisponible — vérifiez les restrictions d'API de la clé, le quota ou le réseau",
|
||||
"Needs {envVars} — add it in Provider Settings — or a Cesium ion token for the ion-hosted route": "Nécessite {envVars} — à ajouter dans les paramètres des fournisseurs — ou un jeton Cesium ion pour la variante hébergée par ion",
|
||||
"Exit cockpit to fly to a camera": "Quittez le cockpit pour rejoindre une caméra",
|
||||
"Exit cockpit to fly to a location": "Quittez le cockpit pour rejoindre un lieu",
|
||||
@@ -1521,7 +1521,7 @@
|
||||
"Apply details": "Appliquer les détails",
|
||||
"Select a scene first": "Sélectionnez d'abord une scène",
|
||||
"Share selected scene": "Partager la scène sélectionnée",
|
||||
"Scene JSON preserves authored settings and attribution. It does not include asset files. Bundles include only pack files you choose here or previously imported bundle files.": "Le JSON de scène conserve les réglages de l'auteur et l'attribution. Il n'inclut pas les fichiers de ressources. Les archives n'incluent que les fichiers de paquets choisis ici ou ceux d'archives déjà importées.",
|
||||
"Scene JSON preserves authored settings and attribution. It does not include asset files. Bundles include only pack files you choose here or previously imported bundle files.": "Le JSON de scène conserve les paramètres de l'auteur et l'attribution. Il n'inclut pas les fichiers de ressources. Les archives n'incluent que les fichiers de paquets choisis ici ou ceux d'archives déjà importées.",
|
||||
"Download scene JSON": "Télécharger le JSON de scène",
|
||||
"Choose data-pack files": "Choisir des fichiers de paquet de données",
|
||||
"Or choose a data-pack folder": "Ou choisir un dossier de paquet de données",
|
||||
@@ -2584,6 +2584,12 @@
|
||||
"many": "Pas encore de flux ici · {n} de régions disponibles",
|
||||
"other": "Pas encore de flux ici · {n} régions disponibles"
|
||||
},
|
||||
"Fly below {km} km to a covered city": "Descendez sous {km} km au-dessus d'une ville couverte",
|
||||
"No bike share here yet · {n} systems available": {
|
||||
"one": "Pas encore de vélos partagés ici · {n} système disponible",
|
||||
"many": "Pas encore de vélos partagés ici · {n} de systèmes disponibles",
|
||||
"other": "Pas encore de vélos partagés ici · {n} systèmes disponibles"
|
||||
},
|
||||
"degraded": "dégradé",
|
||||
"stale": "obsolète",
|
||||
"partial": "partiel",
|
||||
@@ -2891,7 +2897,7 @@
|
||||
"AVAILABLE MISSIONS": "MISSIONS DISPONIBLES",
|
||||
"SELECT A MISSION TO INSPECT": "CHOISIR UNE MISSION À EXAMINER",
|
||||
"LOADING 30-DAY MISSION INDEX": "CHARGEMENT DE L'INDEX DES MISSIONS À 30 JOURS",
|
||||
"Space Missions unavailable: the mission index could not load. Try again later.": "Missions spatiales indisponibles : impossible de charger l’index des missions. Réessayez plus tard.",
|
||||
"Space Missions unavailable: the mission index could not load. Try again later.": "Missions spatiales indisponibles : impossible de charger l'index des missions. Réessayez plus tard.",
|
||||
"30-DAY MISSION INDEX UNAVAILABLE": "INDEX DES MISSIONS À 30 JOURS INDISPONIBLE",
|
||||
"TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB APERÇU · ENTER / SPACE SÉLECTIONNE",
|
||||
"Internet radio and local SDR": "Radio Internet et SDR local",
|
||||
@@ -2912,7 +2918,7 @@
|
||||
"Radio volume": "Volume de la radio",
|
||||
"STATION SITE": "SITE DE LA STATION",
|
||||
"DIRECTORY: RADIO BROWSER": "ANNUAIRE : RADIO BROWSER",
|
||||
"Audio connects directly to the broadcaster when you press play, choose a station on the globe or ask voice to play. Your IP is visible to that broadcaster.": "Le flux audio se connecte directement au diffuseur dès que vous lancez la lecture, choisissez une station sur le globe ou la demandez par la voix. Votre IP est visible par ce diffuseur.",
|
||||
"Audio connects directly to the broadcaster when you press play, choose a station on the globe or ask voice to play. Your IP is visible to that broadcaster.": "Le flux audio se connecte directement au diffuseur lorsque vous lancez la lecture, que vous choisissez une station sur le globe ou que vous la demandez par la voix. Votre adresse IP est visible par ce diffuseur.",
|
||||
"Local RTL-SDR receiver": "Récepteur RTL-SDR local",
|
||||
"LOCAL RTL-SDR": "RTL-SDR LOCAL",
|
||||
"Chrome/Edge · WebUSB · one local tuner shared by FM and the Local ADS-B layer.": "Chrome/Edge · WebUSB · un seul tuner local partagé entre la FM et la couche ADS-B local.",
|
||||
@@ -3275,7 +3281,7 @@
|
||||
"The provider refused access for this key. Check the key's project, region or permissions.": "Le fournisseur a refusé l'accès pour cette clé. Vérifiez le projet, la région ou les autorisations de la clé.",
|
||||
"The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "Le compte du fournisseur n'a plus de crédit. Vérifiez la facturation ou choisissez un autre fournisseur d'IA dans les paramètres.",
|
||||
"SIGHTING": "OBSERVATION",
|
||||
"Show {name} sighting": "Afficher l’observation de {name}",
|
||||
"Show {name} sighting": "Afficher l'observation : {name}",
|
||||
"seen {age} ago": "vu il y a {age}",
|
||||
"observed {age} ago": "observé il y a {age}",
|
||||
"Be Whale Wise": "Respectez les cétacés",
|
||||
@@ -3313,7 +3319,7 @@
|
||||
"Cetacean": "Cétacé",
|
||||
"Wildlife Sightings": "Observations naturalistes",
|
||||
"Exit cockpit to fly to a sighting": "Quittez le cockpit pour rejoindre une observation",
|
||||
"Exit cockpit to fly to a sighting area": "Quittez le cockpit pour rejoindre une zone d’observation",
|
||||
"Exit cockpit to fly to a sighting area": "Quittez le cockpit pour rejoindre une zone d'observation",
|
||||
"Wildlife": "Faune et flore",
|
||||
"Zoom in for local sightings": "Zoomez pour les observations locales",
|
||||
"Loading photo...": "Chargement de la photo…",
|
||||
@@ -3323,26 +3329,26 @@
|
||||
"Acartia": "Acartia",
|
||||
"CC BY 4.0": "CC BY 4.0",
|
||||
"{count} sightings in view": "{count} observations dans la vue",
|
||||
"Close sighting": "Fermer l’observation",
|
||||
"Wildlife observed within 48 h (whales 7 d). Color and badge glyph show the taxon group. Sensitive species are shown only as 0.2° cells; Salish Sea marine mammal reports via Acartia are rounded to 0.01°. Not a census.": "Faune observée au cours des dernières 48 h (cétacés 7 jours). La couleur et le pictogramme du badge indiquent le groupe taxonomique. Les espèces sensibles sont limitées à des cellules de 0,2° ; les signalements de mammifères marins Acartia en mer des Salish sont arrondis à 0,01°. Pas un recensement.",
|
||||
"Close sighting": "Fermer l'observation",
|
||||
"Wildlife observed within 48 h (whales 7 d). Color and badge glyph show the taxon group. Sensitive species are shown only as 0.2° cells; Salish Sea marine mammal reports via Acartia are rounded to 0.01°. Not a census.": "Faune observée au cours des dernières 48 h (cétacés 7 jours). La couleur et le pictogramme du badge indiquent le groupe taxonomique. Les espèces sensibles sont limitées à des cellules de 0,2° ; les signalements de mammifères marins en mer des Salish provenant d'Acartia sont arrondis à 0,01°. Pas un recensement.",
|
||||
"Open {name} on iNaturalist": "Ouvrir {name} sur iNaturalist",
|
||||
"Open {name} on Acartia": "Ouvrir {name} sur Acartia",
|
||||
"iNaturalist": "iNaturalist",
|
||||
"iNaturalist contributors · licences per record": "Contributeurs iNaturalist · licences par observation",
|
||||
"iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "Contributeurs iNaturalist · Acartia {count} (mer des Salish) · licences par observation",
|
||||
"{days}d": "{days}j",
|
||||
"{days}d": "{days} j",
|
||||
"All markers": "Tous les marqueurs",
|
||||
"Show markers of every age": "Afficher les marqueurs de tout âge",
|
||||
"Maximum event age: {days}d; undated markers are kept": "Ancienneté maximale : {days} j ; marqueurs sans date conservés",
|
||||
"Maximum event age: {days}d; undated markers are kept": "Ancienneté maximale : {days} j ; marqueurs sans date conservés",
|
||||
"event age filter|All": "Tous",
|
||||
"Event age": "Ancienneté",
|
||||
"Event age: {days}d · undated kept": "Ancienneté : {days} j · marqueurs sans date conservés",
|
||||
"Close camera preview": "Fermer l’aperçu de la caméra",
|
||||
"Refresh map still": "Actualiser l’image fixe sur la carte",
|
||||
"The map still uses a separate request and may show a different moment.": "L’image fixe de la carte utilise une requête distincte et peut montrer un autre moment.",
|
||||
"Event age: {days}d · undated kept": "Ancienneté : {days} j · marqueurs sans date conservés",
|
||||
"Close camera preview": "Fermer l'aperçu de la caméra",
|
||||
"Refresh map still": "Actualiser l'image fixe sur la carte",
|
||||
"The map still uses a separate request and may show a different moment.": "L'image fixe de la carte utilise une requête distincte et peut montrer un autre moment.",
|
||||
"Video plays from Québec 511, which sees your IP address.": "La vidéo est lue depuis Québec 511, qui voit votre adresse IP.",
|
||||
"STATIONS": "STATIONS",
|
||||
"Crewed stations and the craft that visit them": "Stations habitées et les vaisseaux qui s’y rendent",
|
||||
"Crewed stations and the craft that visit them": "Stations habitées et les vaisseaux qui s'y rendent",
|
||||
"IMAGING": "IMAGERIE",
|
||||
"Earth observation: optical, radar, weather and science": "Observation de la Terre : optique, radar, météo et science",
|
||||
"TELECOM": "TÉLÉCOM",
|
||||
@@ -3355,7 +3361,7 @@
|
||||
"Rocket bodies, fragments and dead satellites": "Étages de fusée, fragments et satellites hors service",
|
||||
"Classes may lag: SATCAT is a cached copy": "Classes peut-être en retard : SATCAT est une copie en cache",
|
||||
"Classes may lag: SATCAT unavailable": "Classes peut-être en retard : SATCAT indisponible",
|
||||
"CelesTrak SATCAT gives each object's type and status. Without a current copy the classes rest on the UCS database, names and CelesTrak groups, so a satellite that has died since may still show as working.": "SATCAT de CelesTrak donne le type et l’état de chaque objet. Sans copie à jour, les classes reposent sur la base UCS, les noms et les groupes CelesTrak : un satellite hors service depuis peut encore apparaître comme actif.",
|
||||
"CelesTrak SATCAT gives each object's type and status. Without a current copy the classes rest on the UCS database, names and CelesTrak groups, so a satellite that has died since may still show as working.": "SATCAT de CelesTrak donne le type et l'état de chaque objet. Sans copie à jour, les classes reposent sur la base UCS, les noms et les groupes CelesTrak : un satellite hors service depuis peut encore apparaître comme actif.",
|
||||
"STARLINK ···": "STARLINK ···",
|
||||
"STARLINK ✕": "STARLINK ✕",
|
||||
"SENSOR SWATH {value} KM": "FAUCHÉE CAPTEUR {value} KM",
|
||||
|
||||
@@ -2584,6 +2584,12 @@
|
||||
"many": "Ancora nessun feed qui · {n} di regioni disponibili",
|
||||
"other": "Ancora nessun feed qui · {n} regioni disponibili"
|
||||
},
|
||||
"Fly below {km} km to a covered city": "Scendi sotto {km} km su una città coperta",
|
||||
"No bike share here yet · {n} systems available": {
|
||||
"one": "Ancora nessun bike sharing qui · {n} sistema disponibile",
|
||||
"many": "Ancora nessun bike sharing qui · {n} di sistemi disponibili",
|
||||
"other": "Ancora nessun bike sharing qui · {n} sistemi disponibili"
|
||||
},
|
||||
"degraded": "degradato",
|
||||
"stale": "obsoleto",
|
||||
"partial": "parziale",
|
||||
|
||||
@@ -2454,6 +2454,10 @@
|
||||
"No feed here yet · {n} regions available": {
|
||||
"other": "ここにはまだフィードがありません · 利用可能な地域 {n}か所"
|
||||
},
|
||||
"Fly below {km} km to a covered city": "{km} km未満まで高度を下げ、カバー範囲内の都市へ移動してください",
|
||||
"No bike share here yet · {n} systems available": {
|
||||
"other": "ここにはまだシェアサイクルがありません · 利用可能なシステム {n}件"
|
||||
},
|
||||
"degraded": "性能低下",
|
||||
"stale": "更新遅延",
|
||||
"partial": "部分的",
|
||||
|
||||
@@ -2586,6 +2586,12 @@
|
||||
"many": "Ainda não há feed aqui · {n} de regiões disponíveis",
|
||||
"other": "Ainda não há feed aqui · {n} regiões disponíveis"
|
||||
},
|
||||
"Fly below {km} km to a covered city": "Desça abaixo de {km} km sobre uma cidade com cobertura",
|
||||
"No bike share here yet · {n} systems available": {
|
||||
"one": "Ainda não há bicicletas compartilhadas aqui · {n} sistema disponível",
|
||||
"many": "Ainda não há bicicletas compartilhadas aqui · {n} de sistemas disponíveis",
|
||||
"other": "Ainda não há bicicletas compartilhadas aqui · {n} sistemas disponíveis"
|
||||
},
|
||||
"degraded": "degradado",
|
||||
"stale": "desatualizado",
|
||||
"partial": "parcial",
|
||||
|
||||
@@ -2979,6 +2979,13 @@
|
||||
"many": "Здесь пока нет потока данных · доступно {n} регионов",
|
||||
"other": "Здесь пока нет потока данных · доступно {n} региона"
|
||||
},
|
||||
"Fly below {km} km to a covered city": "Опуститесь ниже {km} км над городом с покрытием",
|
||||
"No bike share here yet · {n} systems available": {
|
||||
"one": "Здесь пока нет проката велосипедов · доступна {n} система",
|
||||
"few": "Здесь пока нет проката велосипедов · доступны {n} системы",
|
||||
"many": "Здесь пока нет проката велосипедов · доступно {n} систем",
|
||||
"other": "Здесь пока нет проката велосипедов · доступно {n} системы"
|
||||
},
|
||||
"Wind speed field unavailable": "Поле скорости ветра недоступно",
|
||||
"Decoder feeds": "Потоки декодера",
|
||||
"WebUSB + decoder feeds": "WebUSB + потоки декодера",
|
||||
|
||||
@@ -2648,6 +2648,13 @@
|
||||
"many": "Тут поки немає потоку даних · доступно {n} регіонів",
|
||||
"other": "Тут поки немає потоку даних · доступно {n} регіону"
|
||||
},
|
||||
"Fly below {km} km to a covered city": "Опустіться нижче {km} км над містом із покриттям",
|
||||
"No bike share here yet · {n} systems available": {
|
||||
"one": "Тут поки немає прокату велосипедів · доступна {n} система",
|
||||
"few": "Тут поки немає прокату велосипедів · доступні {n} системи",
|
||||
"many": "Тут поки немає прокату велосипедів · доступно {n} систем",
|
||||
"other": "Тут поки немає прокату велосипедів · доступно {n} системи"
|
||||
},
|
||||
"degraded": "з перебоями",
|
||||
"stale": "застарів",
|
||||
"partial": "частково",
|
||||
|
||||
@@ -2355,6 +2355,10 @@
|
||||
"No feed here yet · {n} regions available": {
|
||||
"other": "此处暂无数据源 · 有 {n} 个覆盖区域"
|
||||
},
|
||||
"Fly below {km} km to a covered city": "请降至 {km} km 以下并飞往有覆盖的城市",
|
||||
"No bike share here yet · {n} systems available": {
|
||||
"other": "此处暂无共享单车 · 有 {n} 个可用系统"
|
||||
},
|
||||
"degraded": "降级",
|
||||
"stale": "过期",
|
||||
"partial": "部分可用",
|
||||
|
||||
@@ -2343,6 +2343,10 @@
|
||||
"No feed here yet · {n} regions available": {
|
||||
"other": "此處暫無數據源 · 有 {n} 個覆蓋區域"
|
||||
},
|
||||
"Fly below {km} km to a covered city": "請降至 {km} km 以下並飛往有覆蓋的城市",
|
||||
"No bike share here yet · {n} systems available": {
|
||||
"other": "此處暫無共享單車 · 有 {n} 個可用系統"
|
||||
},
|
||||
"degraded": "降級",
|
||||
"stale": "過期",
|
||||
"partial": "部分可用",
|
||||
|
||||
Vendored
+37
-4
@@ -1,5 +1,6 @@
|
||||
import { msg, t } from '../../i18n/index.js';
|
||||
import { STATUS_POLL_MS } from './policy.js';
|
||||
import { ACTIVATION_ENTER_ALTITUDE_M, STATUS_POLL_MS } from './policy.js';
|
||||
import { GBFS_CITY_REGISTRY } from './registry.js';
|
||||
|
||||
export function createControls({ state: layerState, services, parts, source }) {
|
||||
const methods = {
|
||||
@@ -13,6 +14,14 @@ export function createControls({ state: layerState, services, parts, source }) {
|
||||
|
||||
updateInterval: STATUS_POLL_MS,
|
||||
|
||||
/**
|
||||
* Kept so camera moves can repaint the row before the next status poll.
|
||||
* @param {object} dataManager DataLayerManager instance.
|
||||
*/
|
||||
attachDataManager(dataManager) {
|
||||
layerState._dataManager = dataManager;
|
||||
},
|
||||
|
||||
/**
|
||||
* Return a sampled array of detectable station objects for HUD overlay rendering.
|
||||
* @param {Object} [options] - Sampling options (maxCount, seed).
|
||||
@@ -57,15 +66,39 @@ export function createControls({ state: layerState, services, parts, source }) {
|
||||
|
||||
/**
|
||||
* Return current layer statistics for the UI status display.
|
||||
* @returns {{ count: number, lastUpdate: number|null, loading: boolean, loadingLabel?: string, error?: string, degraded?: boolean, stale?: boolean }}
|
||||
* @returns {{ count: number, lastUpdate: number|null, loading: boolean, loadingLabel?: string, error?: string, degraded?: boolean, stale?: boolean, status?: string, statusMessage?: string }}
|
||||
*/
|
||||
getStats() {
|
||||
// A city in range without points is loading even before its fetch
|
||||
// starts: the row repaints on entry, ahead of beginLoading().
|
||||
const loading =
|
||||
layerState._loading ||
|
||||
[...layerState._activeCityIds].some(
|
||||
(cityId) =>
|
||||
!layerState._cityRuntime.has(cityId) &&
|
||||
!layerState._failedCityIds.has(cityId),
|
||||
);
|
||||
const stats = {
|
||||
count: layerState._count,
|
||||
lastUpdate: layerState._lastUpdate,
|
||||
loading: layerState._loading,
|
||||
loading,
|
||||
};
|
||||
if (layerState._loading) {
|
||||
// No system in range: the camera is too high or no system is near. A
|
||||
// failed city also leaves range empty and reports its failure below.
|
||||
if (
|
||||
layerState._enabled &&
|
||||
!loading &&
|
||||
layerState._activeCityIds.size === 0 &&
|
||||
layerState._failedCityIds.size === 0
|
||||
) {
|
||||
// English: the analyst's empty note and the row's prompt, both shown
|
||||
// through translateStatusMessage (ui/statusText.js).
|
||||
const note = layerState._altitudeGateEnabled
|
||||
? `No bike share here yet · ${GBFS_CITY_REGISTRY.length} systems available`
|
||||
: `Fly below ${Math.round(ACTIVATION_ENTER_ALTITUDE_M / 1000).toLocaleString('en-US')} km to a covered city`;
|
||||
return { ...stats, status: 'zoom-in', statusMessage: note };
|
||||
}
|
||||
if (loading) {
|
||||
stats.loadingLabel =
|
||||
layerState._activeCityIds.size > 0
|
||||
? t('syncing {count} city feeds...', {
|
||||
|
||||
@@ -169,6 +169,7 @@ export function createLifecycle({
|
||||
viewer.scene.primitives.remove(layerState._pointCollection);
|
||||
layerState._pointCollection = null;
|
||||
}
|
||||
layerState._dataManager = null;
|
||||
layerState._viewer = null;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,366 @@
|
||||
// Bike Share's Layers row. With no system in range it says why instead of
|
||||
// "never" and keeps no date for data it no longer draws. After the camera
|
||||
// settles, the last row the panel painted must match what the layer reports
|
||||
// now. Each case drives the real viewport,
|
||||
// ingestion, rendering and controls; the manager is a spy that records the
|
||||
// row text (LayerPanel._buildMetaText) at every refreshLayerStats() call.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import { CITY_BY_ID, GBFS_CITY_REGISTRY } from './registry.js';
|
||||
import { createState } from './state.js';
|
||||
import { createControls } from './controls.js';
|
||||
import { createIngestion } from './ingestion.js';
|
||||
import { createModel } from './model.js';
|
||||
import { createViewport } from './viewport.js';
|
||||
import { createRendering } from './rendering.js';
|
||||
import { createLifecycle } from './lifecycle.js';
|
||||
import { LayerPanel, layerFeedState } from '../../ui/layerPanel.js';
|
||||
|
||||
const BOSTON_ID = 'boston-bluebikes';
|
||||
const TORONTO_ID = 'toronto-bike-share';
|
||||
const BUFFALO_ID = 'buffalo-reddy';
|
||||
const LONDON = { lat: 51.5072, lon: -0.1276 };
|
||||
const BOSTON = { lat: 42.3601, lon: -71.0589 };
|
||||
const BOSTON_NUDGED = { lat: 42.37, lon: -71.05 };
|
||||
const BETWEEN = { lat: 43.25, lon: -79.1 };
|
||||
const TORONTO_ONLY = { lat: 43.7, lon: -79.6 };
|
||||
const LOW_M = 10_000;
|
||||
|
||||
globalThis.document ??= {
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
};
|
||||
|
||||
function deferred() {
|
||||
let resolve;
|
||||
const promise = new Promise((res) => {
|
||||
resolve = res;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
function makeSource() {
|
||||
const cityOf = (url) => {
|
||||
for (const [id, city] of CITY_BY_ID) {
|
||||
if (url === city.stationInformationUrl) return { id, kind: 'info' };
|
||||
if (url === city.stationStatusUrl) return { id, kind: 'status' };
|
||||
}
|
||||
throw new Error(`unexpected GBFS url ${url}`);
|
||||
};
|
||||
const source = {
|
||||
dead: new Set(),
|
||||
/** `${cityId}:${kind}` → deferred that gates the answer. */
|
||||
gates: new Map(),
|
||||
async getStations(url, { signal } = {}) {
|
||||
const { id, kind } = cityOf(url);
|
||||
const gate = source.gates.get(`${id}:${kind}`);
|
||||
if (gate) {
|
||||
await new Promise((resolve, reject) => {
|
||||
gate.promise.then(resolve);
|
||||
signal?.addEventListener('abort', () =>
|
||||
reject(Object.assign(new Error('aborted'), { name: 'AbortError' })),
|
||||
);
|
||||
});
|
||||
} else {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1));
|
||||
}
|
||||
signal?.throwIfAborted();
|
||||
if (source.dead.has(id)) throw new Error('GBFS HTTP 503');
|
||||
const city = CITY_BY_ID.get(id);
|
||||
if (kind === 'info') {
|
||||
return {
|
||||
data: {
|
||||
stations: [
|
||||
{
|
||||
station_id: `${id}-1`,
|
||||
name: `${id} dock`,
|
||||
lat: city.centerLat,
|
||||
lon: city.centerLon,
|
||||
capacity: 10,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
data: {
|
||||
stations: [
|
||||
{
|
||||
station_id: `${id}-1`,
|
||||
num_bikes_available: 3,
|
||||
num_docks_available: 7,
|
||||
is_installed: true,
|
||||
is_renting: true,
|
||||
is_returning: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
return source;
|
||||
}
|
||||
|
||||
/** The row as the panel draws it: state chip, count, meta text. */
|
||||
const rowText = (raw) => {
|
||||
const stats = {
|
||||
count: 0,
|
||||
lastUpdate: null,
|
||||
...raw,
|
||||
loading: raw.loading === true,
|
||||
};
|
||||
const meta = LayerPanel.prototype._buildMetaText({
|
||||
id: 'bikeshare',
|
||||
source: 'GBFS',
|
||||
enabled: true,
|
||||
lifecycleState: 'enabled',
|
||||
stats,
|
||||
});
|
||||
return `${layerFeedState(stats)} | ${stats.count || '—'} | ${meta}`;
|
||||
};
|
||||
|
||||
function makeLiveLayer() {
|
||||
const source = makeSource();
|
||||
const services = {
|
||||
render: { governorRequestRender() {} },
|
||||
overlays: {
|
||||
setOverlayEntries() {},
|
||||
setOverlaySourceVisible() {},
|
||||
clearOverlaySource() {},
|
||||
},
|
||||
sprites: { registerSpriteCollection() {}, restoreSpriteOrder() {} },
|
||||
picking: { registerPickOwner() {}, unregisterPickOwner() {} },
|
||||
};
|
||||
const state = createState({ services });
|
||||
const parts = {};
|
||||
const context = { state, services, parts, source };
|
||||
parts.ingestion = createIngestion(context);
|
||||
parts.model = createModel(context);
|
||||
parts.viewport = createViewport(context);
|
||||
parts.rendering = createRendering(context);
|
||||
parts.selection = {
|
||||
_installClickHandler() {},
|
||||
_clearSelection() {},
|
||||
_onKeyDown() {},
|
||||
};
|
||||
parts.queries = { collectDetectableStations: () => [] };
|
||||
parts.lifecycle = createLifecycle(context);
|
||||
const { methods: controls } = createControls(context);
|
||||
|
||||
const camera = { ...LONDON, height: LOW_M };
|
||||
const viewer = {
|
||||
camera: {
|
||||
get positionCartographic() {
|
||||
return Cesium.Cartographic.fromDegrees(
|
||||
camera.lon,
|
||||
camera.lat,
|
||||
camera.height,
|
||||
);
|
||||
},
|
||||
computeViewRectangle: () => undefined,
|
||||
changed: { addEventListener() {}, removeEventListener() {} },
|
||||
},
|
||||
scene: { globe: {}, primitives: { add() {}, remove() {} } },
|
||||
};
|
||||
parts.lifecycle.methods.init(viewer);
|
||||
|
||||
const painted = [];
|
||||
controls.attachDataManager({
|
||||
refreshLayerStats() {
|
||||
painted.push(rowText(controls.getStats()));
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
state,
|
||||
source,
|
||||
painted,
|
||||
lastPainted: () => painted.at(-1),
|
||||
stats: () => controls.getStats(),
|
||||
live: () => rowText(controls.getStats()),
|
||||
moveTo(place, height = LOW_M) {
|
||||
Object.assign(camera, place, { height });
|
||||
return parts.viewport.runProximityCheck();
|
||||
},
|
||||
async enable() {
|
||||
parts.lifecycle.methods.enable(viewer);
|
||||
await settle();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function settle() {
|
||||
for (let i = 0; i < 5; i += 1)
|
||||
await new Promise((resolve) => setTimeout(resolve, 2));
|
||||
}
|
||||
|
||||
test('entering a city paints the loading row, not "never"', async () => {
|
||||
const layer = makeLiveLayer();
|
||||
const info = deferred();
|
||||
layer.source.gates.set(`${BOSTON_ID}:info`, info);
|
||||
await layer.enable();
|
||||
const before = layer.painted.length;
|
||||
const entering = layer.moveTo(BOSTON);
|
||||
assert.ok(layer.painted.length > before, 'entering repainted');
|
||||
const atEntry = layer.lastPainted();
|
||||
const liveAfter = layer.live();
|
||||
info.resolve();
|
||||
await entering;
|
||||
await settle();
|
||||
assert.match(liveAfter, /syncing 1 city feeds/);
|
||||
assert.match(atEntry, /^loading \|/, 'the entry repaint reads loading');
|
||||
assert.doesNotMatch(atEntry, /never/);
|
||||
});
|
||||
test('re-entering a city with cached station info paints loading', async () => {
|
||||
const layer = makeLiveLayer();
|
||||
await layer.enable();
|
||||
await layer.moveTo(BOSTON);
|
||||
await settle();
|
||||
await layer.moveTo(LONDON);
|
||||
await settle();
|
||||
const status = deferred();
|
||||
layer.source.gates.set(`${BOSTON_ID}:status`, status);
|
||||
const entering = layer.moveTo(BOSTON);
|
||||
const atEntry = layer.lastPainted();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
const liveAfter = layer.live();
|
||||
status.resolve();
|
||||
await entering;
|
||||
await settle();
|
||||
assert.match(liveAfter, /syncing 1 city feeds/);
|
||||
assert.match(atEntry, /^loading \|/, 'the entry repaint reads loading');
|
||||
assert.doesNotMatch(atEntry, /never/);
|
||||
});
|
||||
|
||||
test('leaving a city mid-load does not leave the row on LOADING', async () => {
|
||||
const layer = makeLiveLayer();
|
||||
layer.source.gates.set(`${BOSTON_ID}:info`, deferred());
|
||||
await layer.enable();
|
||||
const entering = layer.moveTo(BOSTON);
|
||||
await settle();
|
||||
assert.equal(layer.state._loading, true);
|
||||
|
||||
await layer.moveTo(LONDON);
|
||||
await entering;
|
||||
await settle();
|
||||
assert.equal(layer.state._loading, false);
|
||||
assert.match(layer.live(), /No bike share here yet/);
|
||||
assert.equal(layer.lastPainted(), layer.live());
|
||||
});
|
||||
|
||||
test('a camera nudge during the first load still repaints the drawn stations', async () => {
|
||||
const layer = makeLiveLayer();
|
||||
const status = deferred();
|
||||
layer.source.gates.set(`${BOSTON_ID}:status`, status);
|
||||
await layer.enable();
|
||||
const entering = layer.moveTo(BOSTON);
|
||||
await settle();
|
||||
assert.ok(layer.state._cityRuntime.has(BOSTON_ID), 'points are drawn');
|
||||
|
||||
await layer.moveTo(BOSTON_NUDGED);
|
||||
status.resolve();
|
||||
await entering;
|
||||
await settle();
|
||||
assert.equal(layer.state._count, 1, 'one station on the map');
|
||||
assert.equal(layer.lastPainted(), layer.live());
|
||||
});
|
||||
|
||||
test('leaving a city whose load failed repaints the error away', async () => {
|
||||
const layer = makeLiveLayer();
|
||||
layer.source.dead.add(BOSTON_ID);
|
||||
await layer.enable();
|
||||
await layer.moveTo(BOSTON);
|
||||
await settle();
|
||||
assert.match(layer.lastPainted(), /GBFS fetch error/i);
|
||||
|
||||
await layer.moveTo(LONDON);
|
||||
await settle();
|
||||
assert.match(layer.live(), /No bike share here yet/);
|
||||
assert.equal(layer.lastPainted(), layer.live());
|
||||
});
|
||||
|
||||
test('leaving the failed city of two repaints DEGRADED away', async () => {
|
||||
const layer = makeLiveLayer();
|
||||
layer.source.dead.add(BUFFALO_ID);
|
||||
await layer.enable();
|
||||
await layer.moveTo(BETWEEN);
|
||||
await settle();
|
||||
assert.match(layer.lastPainted(), /^degraded/);
|
||||
|
||||
await layer.moveTo(TORONTO_ONLY);
|
||||
await settle();
|
||||
assert.doesNotMatch(layer.live(), /^degraded/);
|
||||
assert.equal(layer.lastPainted(), layer.live());
|
||||
});
|
||||
|
||||
test('above the altitude gate the row says how low to fly', async () => {
|
||||
const layer = makeLiveLayer();
|
||||
await layer.enable();
|
||||
await layer.moveTo(LONDON, 2_000_000);
|
||||
const stats = layer.stats();
|
||||
assert.equal(stats.status, 'zoom-in');
|
||||
assert.equal(stats.statusMessage, 'Fly below 48 km to a covered city');
|
||||
assert.equal(stats.error, undefined);
|
||||
});
|
||||
|
||||
test('a city with no system says so and counts the ones there are', async () => {
|
||||
const layer = makeLiveLayer();
|
||||
await layer.enable();
|
||||
const stats = layer.stats();
|
||||
assert.equal(stats.status, 'zoom-in');
|
||||
assert.equal(
|
||||
stats.statusMessage,
|
||||
`No bike share here yet · ${GBFS_CITY_REGISTRY.length} systems available`,
|
||||
);
|
||||
assert.equal(stats.count, 0);
|
||||
});
|
||||
|
||||
test('a city in range, loading, failed or a disabled layer gets no prompt', async () => {
|
||||
const layer = makeLiveLayer();
|
||||
assert.equal(layer.stats().status, undefined, 'disabled');
|
||||
|
||||
const info = deferred();
|
||||
layer.source.gates.set(`${BOSTON_ID}:info`, info);
|
||||
await layer.enable();
|
||||
const entering = layer.moveTo(BOSTON);
|
||||
assert.equal(layer.stats().status, undefined, 'loading');
|
||||
info.resolve();
|
||||
await entering;
|
||||
await settle();
|
||||
assert.equal(layer.stats().status, undefined, 'loaded');
|
||||
|
||||
// Left mid-load: range is empty, but the aborted load still owns the row.
|
||||
const leaving = makeLiveLayer();
|
||||
leaving.source.gates.set(`${BOSTON_ID}:info`, deferred());
|
||||
await leaving.enable();
|
||||
const loading = leaving.moveTo(BOSTON);
|
||||
await settle();
|
||||
const left = leaving.moveTo(LONDON);
|
||||
assert.equal(leaving.state._activeCityIds.size, 0);
|
||||
assert.equal(leaving.stats().loading, true);
|
||||
assert.equal(leaving.stats().status, undefined, 'leaving mid-load');
|
||||
await Promise.all([loading, left]);
|
||||
|
||||
const failed = makeLiveLayer();
|
||||
failed.source.dead.add(BOSTON_ID);
|
||||
await failed.enable();
|
||||
await failed.moveTo(BOSTON);
|
||||
await settle();
|
||||
assert.equal(failed.stats().status, undefined, 'failed');
|
||||
assert.equal(failed.stats().error, 'GBFS fetch error');
|
||||
});
|
||||
|
||||
test('leaving every system behind drops the date of the data it drew', async () => {
|
||||
for (const height of [LOW_M, 2_000_000]) {
|
||||
const layer = makeLiveLayer();
|
||||
await layer.enable();
|
||||
await layer.moveTo(BOSTON);
|
||||
await settle();
|
||||
assert.ok(layer.stats().lastUpdate, 'Boston is dated');
|
||||
|
||||
await layer.moveTo(LONDON, height);
|
||||
assert.equal(layer.stats().lastUpdate, null, `at ${height} m`);
|
||||
}
|
||||
});
|
||||
@@ -19,6 +19,10 @@ export function createState({ services }) {
|
||||
|
||||
layerState._viewer = null;
|
||||
|
||||
/** @type {{ refreshLayerStats?: (layerId?: string) => void }|null} Manager that repaints the layer's row. */
|
||||
|
||||
layerState._dataManager = null;
|
||||
|
||||
/** @type {Cesium.PointPrimitiveCollection|null} Primitive collection for station dots. */
|
||||
|
||||
layerState._pointCollection = null;
|
||||
|
||||
@@ -116,6 +116,11 @@ export function createViewport({ state: layerState, services, parts, source }) {
|
||||
parts.rendering.removeCityPoints(cityId);
|
||||
}
|
||||
|
||||
/** Repaint the row now: it follows the camera, the manager tick is 60 s. */
|
||||
function refreshRow() {
|
||||
layerState._dataManager?.refreshLayerStats?.('bikeshare');
|
||||
}
|
||||
|
||||
/** Deactivate all currently active cities and clear the active set. */
|
||||
|
||||
function deactivateAllCities() {
|
||||
@@ -125,6 +130,8 @@ export function createViewport({ state: layerState, services, parts, source }) {
|
||||
// A failure belongs to a city in range; none are once everything is off.
|
||||
layerState._failedCityIds.clear();
|
||||
layerState._count = 0;
|
||||
// Nothing is left on the map to date.
|
||||
layerState._lastUpdate = null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -203,9 +210,11 @@ export function createViewport({ state: layerState, services, parts, source }) {
|
||||
|
||||
const generation = ++layerState._proximityGeneration;
|
||||
const altitude = getCameraAltitude(layerState._viewer);
|
||||
const wasGateEnabled = layerState._altitudeGateEnabled;
|
||||
// Altitude gate: disable all cities when camera is too high
|
||||
if (!shouldActivateAtAltitude(altitude)) {
|
||||
deactivateAllCities();
|
||||
if (wasGateEnabled) refreshRow();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -220,15 +229,25 @@ export function createViewport({ state: layerState, services, parts, source }) {
|
||||
}
|
||||
}
|
||||
// A failure never outlives its city's range.
|
||||
let failureCleared = false;
|
||||
for (const cityId of layerState._failedCityIds) {
|
||||
if (!nextActive.has(cityId)) layerState._failedCityIds.delete(cityId);
|
||||
if (!nextActive.has(cityId)) {
|
||||
layerState._failedCityIds.delete(cityId);
|
||||
failureCleared = true;
|
||||
}
|
||||
}
|
||||
|
||||
const changed =
|
||||
!wasGateEnabled ||
|
||||
failureCleared ||
|
||||
nextActive.size !== layerState._activeCityIds.size ||
|
||||
[...nextActive].some((cityId) => !layerState._activeCityIds.has(cityId));
|
||||
layerState._activeCityIds = nextActive;
|
||||
if (layerState._activeCityIds.size === 0) {
|
||||
if (nextActive.size === 0) {
|
||||
layerState._count = 0;
|
||||
return;
|
||||
layerState._lastUpdate = null;
|
||||
}
|
||||
if (changed) refreshRow();
|
||||
|
||||
// Only activate cities that don't already have rendered points
|
||||
const toActivate = [];
|
||||
@@ -240,6 +259,9 @@ export function createViewport({ state: layerState, services, parts, source }) {
|
||||
await Promise.all(
|
||||
toActivate.map((cityId) => activateCity(cityId, generation)),
|
||||
);
|
||||
// Superseded loads repaint too: a newer check may have painted the row
|
||||
// while this one was still loading, or found nothing to load itself.
|
||||
if (layerState._enabled) refreshRow();
|
||||
}
|
||||
|
||||
/** Schedule a debounced proximity check after camera movement. */
|
||||
|
||||
@@ -448,7 +448,7 @@ export function createGeometry({ state: layerState, services, parts, source }) {
|
||||
}
|
||||
|
||||
// Photoreal regime. Sampling is delegated to the shared coarse-cell
|
||||
// sampler. It remains event-driven, one-shot per cell, and keeps its
|
||||
// sampler. It remains event-driven, a few probes per cell, and keeps its
|
||||
// existing acceptance window; CCTV adds no rooftop rejection policy.
|
||||
if (sampleGround && parts.model.projectionTilesReady()) {
|
||||
record.groundMeshSampleRequestCount =
|
||||
|
||||
@@ -37,6 +37,22 @@ import {
|
||||
DISPLAY_CORRIDOR_FAIR_SHARE,
|
||||
} from './policy.js';
|
||||
|
||||
/** Half a coarse floor cell (0.001°), less a margin so rounding keeps a
|
||||
* clamped coordinate inside the cell. */
|
||||
const HALF_CELL_DEG = 0.00049;
|
||||
|
||||
/**
|
||||
* A coordinate held within half a cell of the cell's centre (the short way
|
||||
* round for longitude), so a read lands in the given cell.
|
||||
* @param {number} deg @param {number} centreDeg
|
||||
* @returns {number}
|
||||
*/
|
||||
function clampToCell(deg, centreDeg) {
|
||||
let d = deg - centreDeg;
|
||||
d -= 360 * Math.round(d / 360);
|
||||
return centreDeg + Math.max(-HALF_CELL_DEG, Math.min(HALF_CELL_DEG, d));
|
||||
}
|
||||
|
||||
export function createMotion({
|
||||
flightState,
|
||||
services,
|
||||
@@ -865,12 +881,15 @@ export function createMotion({
|
||||
);
|
||||
// Boundary hysteresis: a position jittering across a cell edge would flip
|
||||
// floors at fleet-tick rate (see stickyFloorCell).
|
||||
const cell = stickyFloorCell(
|
||||
Cesium.Math.toDegrees(carto.latitude),
|
||||
Cesium.Math.toDegrees(carto.longitude),
|
||||
state?.cell,
|
||||
const latDeg = Cesium.Math.toDegrees(carto.latitude);
|
||||
const lonDeg = Cesium.Math.toDegrees(carto.longitude);
|
||||
const cell = stickyFloorCell(latDeg, lonDeg, state?.cell);
|
||||
// A cell holds rendered-surface readings at the points they were taken,
|
||||
// so the floor is read where the contact is, held inside its sticky cell.
|
||||
const floor = cachedGroundFloor(
|
||||
clampToCell(latDeg, cell.lat),
|
||||
clampToCell(lonDeg, cell.lon),
|
||||
);
|
||||
const floor = cachedGroundFloor(cell.lat, cell.lon);
|
||||
const next = state || {
|
||||
cell,
|
||||
in: new Cesium.Cartesian3(),
|
||||
|
||||
@@ -300,8 +300,8 @@ export function createFlightSnapshotRenderer({
|
||||
// floor cells collected in the loop (fire-and-forget, single-flight;
|
||||
// read synchronously by NEXT poll's clamp — the military-layer pattern).
|
||||
warmGroundFloor(floorWarmPoints);
|
||||
// Round 4: sample the RENDERED mesh for those same cells (one-shot per
|
||||
// cell, budget-capped, viewer-proximate, google-3d regime only). Own
|
||||
// Round 4: sample the RENDERED mesh for those same cells (a few probes
|
||||
// per cell, budget-capped, viewer-proximate, google-3d regime only). Own
|
||||
// billboards/models are excluded so a vertical probe can't land on an
|
||||
// aircraft instead of the pavement.
|
||||
sampleMeshFloorCells(flightState._viewer?.scene, floorWarmPoints, {
|
||||
|
||||
@@ -228,8 +228,8 @@ export function createMilitarySnapshotRenderer({
|
||||
const bbStale = flightState._billboards.get(icao24);
|
||||
if (bbStale) bbStale.position = liftedPos;
|
||||
}
|
||||
// Round 4: sample the RENDERED mesh for those cells (one-shot per cell,
|
||||
// budget-capped, viewer-proximate, google-3d regime only), excluding
|
||||
// Round 4: sample the RENDERED mesh for those cells (a few probes per
|
||||
// cell, budget-capped, viewer-proximate, google-3d regime only), excluding
|
||||
// this layer's own billboards/models from the probe.
|
||||
{
|
||||
const viewerCarto =
|
||||
|
||||
@@ -159,7 +159,8 @@ export function buildIncidentCard(row, nowMs, { link = null } = {}) {
|
||||
// the known cause, behavior and complexity values and the wording around them.
|
||||
const facts = [];
|
||||
if (Number.isFinite(row.acres)) {
|
||||
const acres = Math.round(row.acres);
|
||||
const acres =
|
||||
row.acres < 1 ? Math.round(row.acres * 100) / 100 : Math.round(row.acres);
|
||||
facts.push(
|
||||
tn(acres, '{acres} ac', '{acres} ac', { acres: formatNumber(acres) }),
|
||||
);
|
||||
|
||||
@@ -302,3 +302,34 @@ test('partial incident details render only the facts that exist', () => {
|
||||
'Catron County · $85K to date',
|
||||
]);
|
||||
});
|
||||
|
||||
test('a fire under one acre keeps its WFIGS size instead of reading 0 ac', () => {
|
||||
const row = {
|
||||
stableId: 'x',
|
||||
name: 'Grey',
|
||||
acres: 0.25,
|
||||
containedPct: null,
|
||||
state: 'US-WA',
|
||||
category: 'WF',
|
||||
discoveredTime: null,
|
||||
updatedTime: null,
|
||||
cause: null,
|
||||
behavior: null,
|
||||
personnel: null,
|
||||
county: null,
|
||||
costToDate: null,
|
||||
complexity: null,
|
||||
};
|
||||
assert.equal(
|
||||
buildIncidentCard(row, 1758000000000).details[0],
|
||||
'0.25 ac · containment unknown · US-WA',
|
||||
);
|
||||
assert.equal(
|
||||
buildIncidentCard({ ...row, acres: 0.01 }, 1758000000000).details[0],
|
||||
'0.01 ac · containment unknown · US-WA',
|
||||
);
|
||||
assert.equal(
|
||||
buildIncidentCard({ ...row, acres: 1.4 }, 1758000000000).details[0],
|
||||
'1 ac · containment unknown · US-WA',
|
||||
);
|
||||
});
|
||||
@@ -124,7 +124,14 @@ export function createHeight({ state, services, parts }) {
|
||||
const cells = new Map();
|
||||
const collect = (point, answer) => {
|
||||
if (!onGround) return;
|
||||
if (!cells.has(answer.cell))
|
||||
// A cell keeps the last point offered in it: the path is walked
|
||||
// oldest first, so that is where the vehicle is now or about to be,
|
||||
// and the mesh sampler reads the surface there.
|
||||
const held = cells.get(answer.cell);
|
||||
if (held) {
|
||||
held.lat = point.lat;
|
||||
held.lon = point.lon;
|
||||
} else
|
||||
cells.set(answer.cell, {
|
||||
lat: point.lat,
|
||||
lon: point.lon,
|
||||
@@ -136,6 +143,13 @@ export function createHeight({ state, services, parts }) {
|
||||
const newest = entry.fixes.at(-1);
|
||||
const answer = requestHeight(newest.lat, newest.lon, entry);
|
||||
entry.heightCell = answer.cell;
|
||||
// The selected vehicle's path also offers its history, newest segment
|
||||
// first; the vehicle's own cell is read where its newest fix is.
|
||||
const own = cells.get(answer.cell);
|
||||
if (own) {
|
||||
own.lat = newest.lat;
|
||||
own.lon = newest.lon;
|
||||
}
|
||||
if (answer.height !== null) {
|
||||
entry.heightM = answer.height;
|
||||
entry.heightResolved = true;
|
||||
|
||||
@@ -401,7 +401,13 @@ export function createIngestion({ state, services, parts, source }) {
|
||||
);
|
||||
}
|
||||
if (state._viewer)
|
||||
registerDynamicCredit(state._viewer, transitFeedCredit(feed));
|
||||
registerDynamicCredit(
|
||||
state._viewer,
|
||||
transitFeedCredit(feed, {
|
||||
feedTimestamp: snapshot?.feedTimestamp,
|
||||
fetchedAt,
|
||||
}),
|
||||
);
|
||||
if (
|
||||
state._selectedKey &&
|
||||
state._vehicles.get(state._selectedKey)?.feedId === feed.id
|
||||
|
||||
@@ -2938,6 +2938,114 @@ test('mesh refinement reaches all 100 retained DEM cells within five poll budget
|
||||
assert.equal(probed.size, 100);
|
||||
});
|
||||
|
||||
test('mesh refinement probes the latest point the path offers in a cell', async (t) => {
|
||||
const { createHeight } = await import('./height.js');
|
||||
// Three points along one cell's stretch of path, oldest first.
|
||||
const path = [
|
||||
{ lat: 42.3611, lon: -71.0751 },
|
||||
{ lat: 42.3612, lon: -71.0753 },
|
||||
{ lat: 42.3614, lon: -71.0754 },
|
||||
];
|
||||
const entry = { key: 'train', to: path[0], fixes: [path.at(-1)] };
|
||||
const probed = [];
|
||||
const state = {
|
||||
_enabled: true,
|
||||
_selectedKey: null,
|
||||
_visible: new Set([entry]),
|
||||
_vehicles: new Map([[entry.key, entry]]),
|
||||
_floorCursor: 0,
|
||||
_heightDirty: new Set(),
|
||||
_viewer: { camera: { positionCartographic: { height: 100 } } },
|
||||
};
|
||||
const height = createHeight({
|
||||
state,
|
||||
services: {
|
||||
render: { governorRequestRender() {} },
|
||||
ground: {
|
||||
coarseFloorCoord: (lat, lon) => ({
|
||||
lat: Number(lat.toFixed(3)),
|
||||
lon: Number(lon.toFixed(3)),
|
||||
}),
|
||||
cachedGroundFloor: () => 10,
|
||||
GROUND_FLOOR_LIFT_M: 1.5,
|
||||
},
|
||||
mesh: {
|
||||
sampleMeshFloorCells(scene, points) {
|
||||
probed.push(...points.map((p) => [p.lat, p.lon]));
|
||||
},
|
||||
},
|
||||
},
|
||||
parts: {
|
||||
trails: {
|
||||
prepareEntry(e, collect) {
|
||||
if (collect)
|
||||
for (const point of path)
|
||||
collect(point, height.requestHeight(point.lat, point.lon));
|
||||
},
|
||||
},
|
||||
rendering: { requestVisibility() {} },
|
||||
},
|
||||
});
|
||||
t.after(() => height.clear());
|
||||
height.anchorFloors();
|
||||
assert.deepEqual(probed, [[42.3614, -71.0754]]);
|
||||
});
|
||||
|
||||
test('the selected vehicle is probed at its newest fix in its own cell', async (t) => {
|
||||
const { createHeight } = await import('./height.js');
|
||||
// The selected path offers its active corridor first, then its history
|
||||
// newest segment first, so the last offer in the cell is an older point.
|
||||
const newest = { lat: 42.3614, lon: -71.0754 };
|
||||
const offers = [
|
||||
newest,
|
||||
{ lat: 42.3612, lon: -71.0753 },
|
||||
{ lat: 42.3611, lon: -71.0751 },
|
||||
];
|
||||
const entry = { key: 'train', to: offers[0], fixes: [newest] };
|
||||
const probed = [];
|
||||
const state = {
|
||||
_enabled: true,
|
||||
_selectedKey: entry.key,
|
||||
_visible: new Set([entry]),
|
||||
_vehicles: new Map([[entry.key, entry]]),
|
||||
_floorCursor: 0,
|
||||
_heightDirty: new Set(),
|
||||
_viewer: { camera: { positionCartographic: { height: 100 } } },
|
||||
};
|
||||
const height = createHeight({
|
||||
state,
|
||||
services: {
|
||||
render: { governorRequestRender() {} },
|
||||
ground: {
|
||||
coarseFloorCoord: (lat, lon) => ({
|
||||
lat: Number(lat.toFixed(3)),
|
||||
lon: Number(lon.toFixed(3)),
|
||||
}),
|
||||
cachedGroundFloor: () => 10,
|
||||
GROUND_FLOOR_LIFT_M: 1.5,
|
||||
},
|
||||
mesh: {
|
||||
sampleMeshFloorCells(scene, points) {
|
||||
probed.push(...points.map((p) => [p.lat, p.lon]));
|
||||
},
|
||||
},
|
||||
},
|
||||
parts: {
|
||||
trails: {
|
||||
prepareEntry(e, collect) {
|
||||
if (collect)
|
||||
for (const point of offers)
|
||||
collect(point, height.requestHeight(point.lat, point.lon));
|
||||
},
|
||||
},
|
||||
rendering: { requestVisibility() {} },
|
||||
},
|
||||
});
|
||||
t.after(() => height.clear());
|
||||
height.anchorFloors();
|
||||
assert.deepEqual(probed, [[42.3614, -71.0754]]);
|
||||
});
|
||||
|
||||
test('selection churn releases past geometry on every retained vehicle', async (t) => {
|
||||
const app = harness(t, { floorAt: () => 10 });
|
||||
const { mergeHistory, seek } = await import('../../data/contactPlayback.js');
|
||||
@@ -3983,3 +4091,54 @@ test('parked transit reprojects held and reported courses at the bounded camera
|
||||
'selected unknown course also stays screen-up',
|
||||
);
|
||||
});
|
||||
|
||||
test('a view with no feed reads its coverage prompt, not an undated feed', async (t) => {
|
||||
const app = harness(t);
|
||||
app.serve('mbta', () => ({
|
||||
status: 200,
|
||||
body: snapshot(
|
||||
'mbta',
|
||||
'MBTA',
|
||||
[vehicle('bus-1', 42.36, -71.06, Date.now() / 1000)],
|
||||
{ fetchedAt: Date.now() },
|
||||
),
|
||||
}));
|
||||
app.layer.enable(app.viewer);
|
||||
await app.layer.update();
|
||||
assert.ok(app.layer.getStats().lastUpdate, 'Boston has a dated snapshot');
|
||||
|
||||
// London has no registered feed: the row prompts instead of dating Boston's
|
||||
// last snapshot over an empty map.
|
||||
app.lookAt({ west: -0.2, south: 51.45, east: -0.05, north: 51.55 });
|
||||
app.advance(1_000);
|
||||
const away = app.layer.getStats();
|
||||
assert.equal(away.status, 'zoom-in');
|
||||
assert.equal(away.count, 0);
|
||||
assert.equal(away.lastUpdate, null);
|
||||
assert.match(
|
||||
away.statusMessage,
|
||||
/^No feed here yet · \d+ regions available$/,
|
||||
);
|
||||
assert.equal(away.coverage, away.statusMessage);
|
||||
|
||||
// Above the altitude gate the prompt says how low to fly.
|
||||
app.viewer.camera.positionCartographic.height = 10_000_000;
|
||||
app.lookAt({ west: -0.2, south: 51.45, east: -0.05, north: 51.55 });
|
||||
app.advance(1_000);
|
||||
assert.equal(
|
||||
app.layer.getStats().statusMessage,
|
||||
'Fly below 2,850 km to a covered region',
|
||||
);
|
||||
});
|
||||
|
||||
test('the prompt names the altitude the gate opens at, not 3,000 km', async (t) => {
|
||||
// 2,900 km is under ACTIVATION_ALTITUDE_M but above the enter threshold.
|
||||
const app = harness(t, { altitude: 2_900_000 });
|
||||
app.layer.enable(app.viewer);
|
||||
await app.layer.update();
|
||||
assert.deepEqual(app.requested, [], 'nothing loads over Boston');
|
||||
assert.equal(
|
||||
app.layer.getStats().statusMessage,
|
||||
'Fly below 2,850 km to a covered region',
|
||||
);
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import {
|
||||
ACTIVATION_ALTITUDE_M,
|
||||
ACTIVATION_ENTER_ALTITUDE_M,
|
||||
FEED_STALE_AFTER_MS,
|
||||
aggregateTransitFeedHealth,
|
||||
transitDetectionClass,
|
||||
@@ -245,17 +245,19 @@ export function createQueries({ state, parts }) {
|
||||
const health = aggregateTransitFeedHealth(statuses, now);
|
||||
const count = state._vehicles.size;
|
||||
if (state._enabled && active.length === 0) {
|
||||
// English: the analyst's empty note and the row's prompt, both shown
|
||||
// through translateStatusMessage (ui/statusText.js).
|
||||
const note = state._altitudeGateOpen
|
||||
? `No feed here yet · ${TRANSIT_REGION_COUNT} regions available`
|
||||
: `Fly below ${Math.round(ACTIVATION_ENTER_ALTITUDE_M / 1000).toLocaleString('en-US')} km to a covered region`;
|
||||
return {
|
||||
count: 0,
|
||||
lastUpdate: state._lastUpdate,
|
||||
error: null,
|
||||
source: 'GTFS-RT',
|
||||
status: 'zoom-in',
|
||||
// English data (the analyst's empty note to the model); the voice
|
||||
// card shows it through NOTE_PATTERNS (voice/resultDisplay.js).
|
||||
coverage: state._altitudeGateOpen
|
||||
? `No feed here yet · ${TRANSIT_REGION_COUNT} regions available`
|
||||
: `Fly below ${Math.round(ACTIVATION_ALTITUDE_M / 1000).toLocaleString('en-US')} km to a covered region`,
|
||||
statusMessage: note,
|
||||
coverage: note,
|
||||
};
|
||||
}
|
||||
// Age is shown by the same rule the row state uses, so the text and the
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
// Transit: a manager tick that failed over Boston must not keep its error on
|
||||
// the row the coverage repaint draws after the camera leaves for London.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createState } from './state.js';
|
||||
import { createViewport } from './viewport.js';
|
||||
import { createQueries } from './queries.js';
|
||||
import { LayerLifecycle } from '../../data/lifecycle.js';
|
||||
import { LayerPanel, layerFeedState } from '../../ui/layerPanel.js';
|
||||
|
||||
const rad = Math.PI / 180;
|
||||
const LONDON = { lat: 51.5072, lon: -0.1276 };
|
||||
const BOSTON = { lat: 42.3601, lon: -71.0589 };
|
||||
|
||||
function viewer(at, height) {
|
||||
return {
|
||||
camera: {
|
||||
positionCartographic: {
|
||||
height,
|
||||
latitude: at.lat * rad,
|
||||
longitude: at.lon * rad,
|
||||
},
|
||||
computeViewRectangle: () => undefined,
|
||||
},
|
||||
scene: { globe: {} },
|
||||
};
|
||||
}
|
||||
|
||||
test('leaving a feed whose tick failed repaints its error away', async () => {
|
||||
const services = {
|
||||
overlays: {
|
||||
setOverlayEntries() {},
|
||||
setOverlaySourceVisible() {},
|
||||
clearOverlaySource() {},
|
||||
},
|
||||
render: { governorRequestRender() {} },
|
||||
};
|
||||
const state = createState({ services });
|
||||
const feedStatus = (feedId) => {
|
||||
if (!state._feedStatus.has(feedId))
|
||||
state._feedStatus.set(feedId, { count: 0, error: null, loading: false });
|
||||
return state._feedStatus.get(feedId);
|
||||
};
|
||||
const parts = {
|
||||
ingestion: {
|
||||
feedStatus,
|
||||
// As pollFeed's catch does when the proxy answers 503.
|
||||
pollFeed(feed) {
|
||||
Object.assign(feedStatus(feed.id), {
|
||||
error: `${feed.name} feed unavailable`,
|
||||
loading: false,
|
||||
});
|
||||
return Promise.resolve();
|
||||
},
|
||||
removeFeedVehicles() {},
|
||||
sweepAgedVehicles() {},
|
||||
},
|
||||
rendering: { requestVisibility() {}, syncRenderHold() {} },
|
||||
};
|
||||
parts.viewport = createViewport({ state, services, parts });
|
||||
const queries = createQueries({ state, parts }).methods;
|
||||
state._enabled = true;
|
||||
|
||||
const lifecycle = new LayerLifecycle(null);
|
||||
lifecycle.register({
|
||||
id: 'transit',
|
||||
name: 'Transit',
|
||||
source: 'GTFS-RT',
|
||||
getStats: () => queries.getStats(),
|
||||
async update() {},
|
||||
});
|
||||
const entry = lifecycle.layers.get('transit');
|
||||
entry.enabled = true;
|
||||
entry.lifecycleState = 'enabled';
|
||||
entry.initialized = true;
|
||||
|
||||
const row = () => {
|
||||
const layer = lifecycle.getAll()[0];
|
||||
return `${layerFeedState(layer.stats)} | ${LayerPanel.prototype._buildMetaText(layer)}`;
|
||||
};
|
||||
const painted = [];
|
||||
state._dataManager = {
|
||||
refreshLayerStats(layerId) {
|
||||
lifecycle.refreshLayerStats(layerId);
|
||||
painted.push(row());
|
||||
},
|
||||
};
|
||||
|
||||
state._viewer = viewer(BOSTON, 4_000);
|
||||
parts.viewport.runProximityCheck();
|
||||
await lifecycle.refreshLayer('transit');
|
||||
assert.match(row(), /feed unavailable/, 'the tick recorded the failure');
|
||||
|
||||
const before = painted.length;
|
||||
state._viewer = viewer(LONDON, 4_000);
|
||||
parts.viewport.runProximityCheck();
|
||||
assert.equal(queries.getStats().status, 'zoom-in');
|
||||
assert.ok(painted.length > before, 'leaving Boston repainted the row');
|
||||
assert.doesNotMatch(painted.at(-1), /feed unavailable/);
|
||||
});
|
||||
@@ -115,7 +115,7 @@ export function createState({ services }) {
|
||||
state._cockpitVision = false;
|
||||
/** @type {Array<[string, EventListener]>} window listeners to remove on destroy. */
|
||||
state._styleListeners = [];
|
||||
/** @type {{ refreshLayerStats?: () => void }|null} */
|
||||
/** @type {{ refreshLayerStats?: (layerId?: string) => void }|null} */
|
||||
state._dataManager = null;
|
||||
/** @type {number|null} */
|
||||
state._lastUpdate = null;
|
||||
|
||||
@@ -83,7 +83,9 @@ export function createViewport({ state, services, parts }) {
|
||||
if (!state._enabled || !state._viewer) return;
|
||||
refreshViewBounds();
|
||||
const altitude = getCameraAltitude();
|
||||
const wasGateOpen = state._altitudeGateOpen;
|
||||
state._altitudeGateOpen = altitudeGateOpen(altitude);
|
||||
let changed = state._altitudeGateOpen !== wasGateOpen;
|
||||
const center = state._altitudeGateOpen ? getCameraCenterLatLon() : null;
|
||||
const desired = new Map();
|
||||
if (center) {
|
||||
@@ -106,17 +108,23 @@ export function createViewport({ state, services, parts }) {
|
||||
state._activeFeeds.delete(feedId);
|
||||
state._feedStatus.delete(feedId);
|
||||
parts.ingestion.removeFeedVehicles(feedId);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
// Nothing is left on the map to date.
|
||||
if (desired.size === 0) state._lastUpdate = null;
|
||||
for (const [feedId, feed] of desired) {
|
||||
if (!state._activeFeeds.has(feedId)) {
|
||||
state._activeFeeds.set(feedId, feed);
|
||||
void parts.ingestion.pollFeed(feed, state._generation);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
parts.ingestion.sweepAgedVehicles(Date.now());
|
||||
parts.rendering.syncRenderHold();
|
||||
governorRequestRender('transit-proximity');
|
||||
// Repaint now: the row follows the camera, the manager tick is 15 s.
|
||||
if (changed) state._dataManager?.refreshLayerStats?.('transit');
|
||||
}
|
||||
|
||||
function onCameraChanged() {
|
||||
|
||||
@@ -333,7 +333,7 @@ function runAllocationProbe(entryCount, profile = 'generic') {
|
||||
for (const workload of WORKLOADS) {
|
||||
test(`steady moving-source frames stay in budget ${workload.name}`, (t) => {
|
||||
if (!CALIBRATED_ALLOCATION_RUNTIME) {
|
||||
return t.skip(`allocation budgets are calibrated for Node 24; running ${process.versions.node}`);
|
||||
return t.skip(`allocation budgets are calibrated for Node 24 or 26; running ${process.versions.node}`);
|
||||
}
|
||||
const payload = runAllocationProbe(workload.entries, workload.profile);
|
||||
|
||||
|
||||
@@ -127,9 +127,10 @@ test('an unevaluable engines range reports unknown rather than guessing', () =>
|
||||
// A full pass run under `mise exec node@24 --` still re-execed system Node 25
|
||||
// through npm; the forced gate refused ("received 25.6.1") and A3 reported it
|
||||
// as a product failure. A3 now invokes the binary directly and checks it.
|
||||
test('only a parseable Node 24 counts as the calibrated allocation runtime', () => {
|
||||
test('every supported Node major counts as a calibrated allocation runtime', () => {
|
||||
assert.equal(isCalibratedAllocationRuntime('v24.19.0'), true);
|
||||
assert.equal(isCalibratedAllocationRuntime('24.19.0'), true, 'the v prefix is optional');
|
||||
assert.equal(isCalibratedAllocationRuntime('v26.3.0'), true);
|
||||
assert.equal(isCalibratedAllocationRuntime('v25.6.1'), false, 'the npm re-resolution case');
|
||||
assert.equal(isCalibratedAllocationRuntime('v22.21.1'), false);
|
||||
for (const junk of ['', ' ', 'node', undefined, null]) {
|
||||
|
||||
@@ -209,7 +209,11 @@ test('routing middleware uses configured OSRM servers while retaining request bo
|
||||
const request = async (query) => {
|
||||
let payload;
|
||||
await handler(
|
||||
{ url: `/?${query}`, socket: { remoteAddress: '127.0.0.1' } },
|
||||
{
|
||||
url: `/?${query}`,
|
||||
method: 'GET',
|
||||
socket: { remoteAddress: '127.0.0.1' },
|
||||
},
|
||||
{
|
||||
writeHead() {},
|
||||
end(body) {
|
||||
|
||||
+69
-12
@@ -418,13 +418,26 @@ export function createGroundFloor({ terrain, signal }) {
|
||||
// is the photogrammetric MESH, which sits above it (measured ~17 m at the
|
||||
// Austin airport apron). DEM-flooring therefore still buried sprites/trails
|
||||
// inside the mesh. These cells hold the RENDERED surface height, sampled
|
||||
// one-shot per coarse cell near the viewer (scene.sampleHeight — the same
|
||||
// at the callers' points, a few per coarse cell near the viewer, each kept
|
||||
// where it was taken and read back from the nearest (scene.sampleHeight — the same
|
||||
// source groundSnap uses, which is why 3D models always looked right) and
|
||||
// reported here by meshFloorSampler.js. Design principle (owner): NEVER
|
||||
// below the visible surface; slightly above is always fine. The DEM stays
|
||||
// the instant global prior/fallback and the sanity gate for samples.
|
||||
/** @type {Map<string, number>} coarse cell key -> rendered-surface height (m, ellipsoidal). */
|
||||
/** @type {Map<string, Array<{lat: number, lon: number, h: number}>>}
|
||||
* coarse cell key -> rendered-surface readings (m, ellipsoidal), each kept
|
||||
* at the point it was probed. A read answers from the reading nearest the
|
||||
* asked point, so a viaduct and the street beside it in one cell keep their
|
||||
* own heights. */
|
||||
const _meshCells = new Map();
|
||||
/** @constant {number} Readings closer than this (degrees, about 5 m) are
|
||||
* one spot: the lower one is kept there. */
|
||||
const MESH_READING_SAME_SPOT_DEG = 0.00005;
|
||||
/** @constant {number} Readings kept per cell. The sampler probes a cell at
|
||||
* most eight times, but ground snaps report at each aircraft's position;
|
||||
* past this many, a reading away from the others is not kept, so a busy
|
||||
* apron cell stays a short list to scan. */
|
||||
const MESH_READINGS_PER_CELL = 16;
|
||||
/** @type {boolean} Whether mesh cells apply — true only in the google-3d
|
||||
* regime (photoreal stack). On globe stacks the rendered surface IS the
|
||||
* terrain provider (Re:Earth on keyless), so the DEM is already correct
|
||||
@@ -444,7 +457,8 @@ export function createGroundFloor({ terrain, signal }) {
|
||||
|
||||
/**
|
||||
* Records a sampled rendered-surface height for the cell containing the
|
||||
* coordinate (one-shot latch — first accepted sample wins).
|
||||
* coordinate, unchecked, when the cell has no reading yet. The sampler goes
|
||||
* through `reportValidatedMeshFloorCell`, which keeps a reading per point.
|
||||
* @param {number} lat @param {number} lon @param {number} heightM
|
||||
*/
|
||||
function reportMeshFloorCell(lat, lon, heightM) {
|
||||
@@ -456,7 +470,7 @@ export function createGroundFloor({ terrain, signal }) {
|
||||
return;
|
||||
const c = coarseFloorCoord(lat, lon);
|
||||
const key = `${c.lat},${c.lon}`;
|
||||
if (!_meshCells.has(key)) _meshCells.set(key, heightM);
|
||||
if (!_meshCells.has(key)) _meshCells.set(key, [{ lat, lon, h: heightM }]);
|
||||
}
|
||||
|
||||
/** Return whether a rendered-mesh sample is plausible against a real DEM prior. */
|
||||
@@ -471,9 +485,11 @@ export function createGroundFloor({ terrain, signal }) {
|
||||
|
||||
/**
|
||||
* Validate and record a rendered-mesh floor through the single shared gate.
|
||||
* Callers must not bypass this with raw `reportMeshFloorCell`: a coarse-LOD,
|
||||
* rooftop, or aircraft hit would otherwise win the session-long first-write
|
||||
* latch and poison every floor consumer in the cell.
|
||||
* Callers must not bypass this with raw `reportMeshFloorCell`: a coarse-LOD
|
||||
* or aircraft hit would otherwise poison every floor consumer in the cell.
|
||||
* The reading is kept at the point it was taken; a second reading at the
|
||||
* same spot (within about 5 m) keeps the lower of the two. A cell keeps at
|
||||
* most MESH_READINGS_PER_CELL readings.
|
||||
* @returns {boolean} Whether the sample passed and was reported.
|
||||
*/
|
||||
function reportValidatedMeshFloorCell(lat, lon, heightM) {
|
||||
@@ -481,13 +497,41 @@ export function createGroundFloor({ terrain, signal }) {
|
||||
const cell = coarseFloorCoord(lat, lon);
|
||||
const prior = cachedRealEllipsoidalGround(cell.lat, cell.lon);
|
||||
if (!meshFloorSampleWithinPrior(heightM, prior)) return false;
|
||||
reportMeshFloorCell(cell.lat, cell.lon, heightM);
|
||||
const key = `${cell.lat},${cell.lon}`;
|
||||
const readings = _meshCells.get(key);
|
||||
if (!readings) {
|
||||
_meshCells.set(key, [{ lat, lon, h: heightM }]);
|
||||
return true;
|
||||
}
|
||||
const same = readings.find(
|
||||
(r) =>
|
||||
Math.abs(r.lat - lat) < MESH_READING_SAME_SPOT_DEG &&
|
||||
Math.abs(r.lon - lon) < MESH_READING_SAME_SPOT_DEG,
|
||||
);
|
||||
if (same) {
|
||||
if (heightM < same.h) same.h = heightM;
|
||||
} else if (readings.length < MESH_READINGS_PER_CELL) {
|
||||
readings.push({ lat, lon, h: heightM });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronous mesh-cell read — null when the cell is unsampled OR the
|
||||
* google-3d regime is inactive (globe stacks must floor on the DEM).
|
||||
* Whether the cell holding the point has the real DEM prior that
|
||||
* `reportValidatedMeshFloorCell` judges a reading against.
|
||||
* @param {number} lat @param {number} lon
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function meshFloorPriorReady(lat, lon) {
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return false;
|
||||
const c = coarseFloorCoord(lat, lon);
|
||||
return cachedRealEllipsoidalGround(c.lat, c.lon) != null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronous mesh-cell read — the cell's reading nearest the point, or
|
||||
* null when the cell is unsampled OR the google-3d regime is inactive (globe
|
||||
* stacks must floor on the DEM).
|
||||
* @param {number} lat @param {number} lon
|
||||
* @returns {number|null}
|
||||
*/
|
||||
@@ -495,8 +539,20 @@ export function createGroundFloor({ terrain, signal }) {
|
||||
if (!_meshPreferred) return null;
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null;
|
||||
const c = coarseFloorCoord(lat, lon);
|
||||
const h = _meshCells.get(`${c.lat},${c.lon}`);
|
||||
return h != null ? h : null;
|
||||
const readings = _meshCells.get(`${c.lat},${c.lon}`);
|
||||
if (!readings) return null;
|
||||
if (readings.length === 1) return readings[0].h;
|
||||
const k = Math.cos((lat * Math.PI) / 180) ** 2;
|
||||
let best = readings[0],
|
||||
bestD = Infinity;
|
||||
for (const r of readings) {
|
||||
const d = (r.lat - lat) ** 2 + k * (r.lon - lon) ** 2;
|
||||
if (d < bestD) {
|
||||
bestD = d;
|
||||
best = r;
|
||||
}
|
||||
}
|
||||
return best.h;
|
||||
}
|
||||
|
||||
/** Test hook: clears mesh cells (never called at runtime — cells are valid all session). */
|
||||
@@ -662,6 +718,7 @@ export function createGroundFloor({ terrain, signal }) {
|
||||
reportMeshFloorCell,
|
||||
meshFloorSampleWithinPrior,
|
||||
reportValidatedMeshFloorCell,
|
||||
meshFloorPriorReady,
|
||||
cachedMeshFloor,
|
||||
_clearMeshFloorCellsForTest,
|
||||
cachedGroundFloor,
|
||||
|
||||
@@ -101,7 +101,7 @@ const HELD_SNAP_MAX_DRIFT_M = 250;
|
||||
* alone, and the asymmetry is not tidiness:
|
||||
*
|
||||
* - A cell reading LOW against a real sample is the EXPECTED error here, not a
|
||||
* contradiction. Cells are a one-shot first-write latch over ~111 m, the
|
||||
* contradiction. Cells hold a few point readings over ~111 m, the
|
||||
* borrowed-neighbour path leans to the LOWEST on purpose (`neighborFloorM`),
|
||||
* and `displayFloorHeightM` only ever RAISES — the whole floor chain is
|
||||
* built to under-read rather than to invent. Releasing on that sign would
|
||||
|
||||
@@ -11,6 +11,7 @@ export function createMeshFloorSampler({
|
||||
coarseFloorCoord,
|
||||
cachedMeshFloor,
|
||||
reportValidatedMeshFloorCell,
|
||||
meshFloorPriorReady,
|
||||
setMeshFloorPreferred,
|
||||
meshFloorPreferred,
|
||||
cachedGroundFloor,
|
||||
@@ -23,13 +24,16 @@ export function createMeshFloorSampler({
|
||||
// which sits above the Re:Earth bare-earth DEM (measured ~17 m at the Austin
|
||||
// airport apron) — so DEM-floored sprites/trails still buried themselves in
|
||||
// the mesh while 3D models (which groundSnap against the mesh) looked right.
|
||||
// This module samples the mesh height ONE-SHOT per coarse cell and reports it
|
||||
// to groundFloor.js, whose cachedGroundFloor() then answers mesh-first.
|
||||
// This module samples the mesh height at the callers' points, a few probes
|
||||
// per coarse cell, and reports them to groundFloor.js, which keeps each
|
||||
// reading that passes its gate at its own point; cachedGroundFloor() then
|
||||
// answers mesh-first, from the reading nearest the asked point.
|
||||
//
|
||||
// Rationing (the CCTV loading-jitter lesson): scene.sampleHeight forces
|
||||
// tile-load prioritization at the probe point, so sampling is (a) gated to
|
||||
// cells NEAR the viewer — where tiles are streamed and where an error is
|
||||
// visible at all, (b) capped per call, (c) one-shot per cell for the session,
|
||||
// visible at all, (b) capped per call, (c) at most MAX_PROBES_PER_CELL
|
||||
// readings per cell for the session, each at a point apart from the others,
|
||||
// (d) skipped entirely outside the google-3d regime. A returned-undefined
|
||||
// probe (tiles not streamed yet) is NOT latched — it retries on a later call.
|
||||
//
|
||||
@@ -49,6 +53,16 @@ export function createMeshFloorSampler({
|
||||
* streamed LOD under a high camera is coarse everywhere, so every probe
|
||||
* would latch junk. */
|
||||
const MAX_CAMERA_HEIGHT_M = 25_000;
|
||||
/** @constant {number} Readings per cell: a cell is probed at most this
|
||||
* many times, each at a point apart from the others, and groundFloor keeps
|
||||
* each reading at its point (a read answers from the nearest one). */
|
||||
const MAX_PROBES_PER_CELL = 8;
|
||||
/** @constant {number} How far apart (degrees, about 17 m) a new probe must
|
||||
* be from the cell's earlier ones to be worth taking. */
|
||||
const PROBE_APART_DEG = 0.00015;
|
||||
/** @type {Map<string, Array<{lat: number, lon: number}>>} Points probed
|
||||
* per cell that returned a reading. */
|
||||
const _probedPoints = new Map();
|
||||
/** @type {Cesium.Cartographic} Scratch for probe coordinates. */
|
||||
const _scratchProbe = new Cesium.Cartographic();
|
||||
|
||||
@@ -82,7 +96,7 @@ export function createMeshFloorSampler({
|
||||
|
||||
/**
|
||||
* Samples the rendered mesh height for the coarse cells containing `points`,
|
||||
* one-shot per cell, budget-capped, viewer-proximity-gated. Call ONCE per
|
||||
* a few probes per cell, budget-capped, viewer-proximity-gated. Call ONCE per
|
||||
* layer poll (never per contact, never per frame). Synchronous — Cesium's
|
||||
* sampleHeight is a CPU-side query against loaded tiles.
|
||||
*
|
||||
@@ -119,8 +133,21 @@ export function createMeshFloorSampler({
|
||||
const cell = coarseFloorCoord(p.lat, p.lon);
|
||||
const key = `${cell.lat},${cell.lon}`;
|
||||
if (attempted.has(key)) continue;
|
||||
// A cell with a reading is probed again only at a point well apart
|
||||
// from the ones already probed, a few times at most, so the cell's
|
||||
// readings follow where its vehicles actually are.
|
||||
const probes = _probedPoints.get(key);
|
||||
if (
|
||||
cachedMeshFloor(cell.lat, cell.lon) != null &&
|
||||
(probes?.length >= MAX_PROBES_PER_CELL ||
|
||||
probes?.some(
|
||||
(q) =>
|
||||
Math.abs(q.lat - p.lat) < PROBE_APART_DEG &&
|
||||
Math.abs(q.lon - p.lon) < PROBE_APART_DEG,
|
||||
))
|
||||
)
|
||||
continue;
|
||||
attempted.add(key);
|
||||
if (cachedMeshFloor(cell.lat, cell.lon) != null) continue; // one-shot latch
|
||||
if (
|
||||
Number.isFinite(viewerLat) &&
|
||||
Number.isFinite(viewerLon) &&
|
||||
@@ -131,9 +158,11 @@ export function createMeshFloorSampler({
|
||||
}
|
||||
let height;
|
||||
try {
|
||||
// Probe where the caller's point is, not at the cell's centre: a
|
||||
// centre that falls on a roof would lift everything in the cell.
|
||||
const carto = Cesium.Cartographic.fromDegrees(
|
||||
cell.lon,
|
||||
cell.lat,
|
||||
p.lon,
|
||||
p.lat,
|
||||
0,
|
||||
_scratchProbe,
|
||||
);
|
||||
@@ -171,7 +200,23 @@ export function createMeshFloorSampler({
|
||||
// feasible and deliberately deferred — see the P2 in
|
||||
// the project roadmap, which bundles it with the fly_route probe so
|
||||
// one post-launch pass settles rendered-mesh sampling everywhere.
|
||||
reportValidatedMeshFloorCell(cell.lat, cell.lon, height);
|
||||
const accepted = reportValidatedMeshFloorCell(p.lat, p.lon, height);
|
||||
// A spot counts as probed once the gate has judged it against a real
|
||||
// prior: accepted, or refused with the cell's DEM warm. A spot refused
|
||||
// only because the DEM was still cold is tried again later. Each spot is
|
||||
// recorded once, so a cell retried while it has no reading does not fill
|
||||
// its probe allowance with the same spot.
|
||||
if (!accepted && !meshFloorPriorReady(cell.lat, cell.lon)) continue;
|
||||
if (!probes) _probedPoints.set(key, [{ lat: p.lat, lon: p.lon }]);
|
||||
else if (
|
||||
probes.length < MAX_PROBES_PER_CELL &&
|
||||
!probes.some(
|
||||
(q) =>
|
||||
Math.abs(q.lat - p.lat) < PROBE_APART_DEG &&
|
||||
Math.abs(q.lon - p.lon) < PROBE_APART_DEG,
|
||||
)
|
||||
)
|
||||
probes.push({ lat: p.lat, lon: p.lon });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
nextTransitBackoffMs,
|
||||
resolveTransitRoute,
|
||||
transitCacheState,
|
||||
transitFeedTtlMs,
|
||||
transitRedirectDecision,
|
||||
transitResponseHeaders,
|
||||
transitUpstreamHeaders,
|
||||
@@ -266,7 +267,7 @@ export function createTransitService({ fetchImpl = fetch } = {}) {
|
||||
);
|
||||
}
|
||||
const cached = cache.get(feed.id);
|
||||
const state = transitCacheState(cached, now);
|
||||
const state = transitCacheState(cached, now, transitFeedTtlMs(feed));
|
||||
if (state === 'fresh') {
|
||||
return reply(
|
||||
200,
|
||||
|
||||
@@ -313,9 +313,9 @@ test('FIRMS retains a large successful source during partial failure and filters
|
||||
const first = json(await request());
|
||||
assert.equal(first.count, 130001);
|
||||
assert.equal(first.sources.filter((s) => s.ok).length, 1);
|
||||
assert.equal(calls, 4);
|
||||
assert.equal(calls, 3);
|
||||
assert.equal(json(await request()).count, 130001);
|
||||
assert.equal(calls, 4);
|
||||
assert.equal(calls, 3);
|
||||
assert.deepEqual(json(await request('/status')).transactions, {
|
||||
used: 3,
|
||||
limit: 5000,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { once } from 'node:events';
|
||||
import { gunzipSync } from 'node:zlib';
|
||||
import { WebSocketServer } from 'ws';
|
||||
import { setTimeout as delay } from 'node:timers/promises';
|
||||
import * as providers from '../../server/providers/live.js';
|
||||
@@ -15,7 +16,7 @@ function install(plugin, preview = false) {
|
||||
},
|
||||
},
|
||||
});
|
||||
return async (route, url = '/', method = 'GET') => {
|
||||
return async (route, url = '/', method = 'GET', headers = {}) => {
|
||||
assert.ok(routes.has(route), `registered route: ${route}`);
|
||||
const response = {
|
||||
statusCode: 200,
|
||||
@@ -32,7 +33,7 @@ function install(plugin, preview = false) {
|
||||
this.body = body;
|
||||
},
|
||||
};
|
||||
await routes.get(route)({ url, method }, response);
|
||||
await routes.get(route)({ url, method, headers }, response);
|
||||
return response;
|
||||
};
|
||||
}
|
||||
@@ -584,6 +585,168 @@ test('track backfill proxy returns 502 on an oversized upstream body and caches
|
||||
assert.equal(callCount, 1);
|
||||
});
|
||||
|
||||
test('the aircraft snapshot routes decline a non-GET before any upstream work', async (t) => {
|
||||
// A fetch that throws proves the refusal comes before any upstream call.
|
||||
t.mock.method(globalThis, 'fetch', async (url) => {
|
||||
throw new Error(`upstream must not be reached: ${url}`);
|
||||
});
|
||||
const cases = [
|
||||
[install(providers.openSkyProxy()), '/api/flights'],
|
||||
[install(providers.adsbLolProxy()), '/api/military'],
|
||||
];
|
||||
for (const [request, route] of cases) {
|
||||
for (const method of ['POST', 'PUT', 'DELETE', 'PATCH']) {
|
||||
const response = await request(route, '/', method);
|
||||
assert.equal(response.statusCode, 405, `${method} ${route}`);
|
||||
// RFC 9110 section 15.5.6: a 405 names the methods it accepts.
|
||||
assert.equal(response.headers.allow, 'GET', `${method} ${route}`);
|
||||
assert.deepEqual(JSON.parse(response.body), {
|
||||
error: 'Method Not Allowed',
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('the snapshot method check hands each /track route on to its own handler', async () => {
|
||||
// The snapshot mount also matches /track, so it must hand /track on
|
||||
// before it checks the method.
|
||||
for (const [plugin, route] of [
|
||||
[providers.openSkyProxy(), '/api/flights'],
|
||||
[providers.adsbLolProxy(), '/api/military'],
|
||||
]) {
|
||||
const routes = new Map();
|
||||
plugin.configureServer({
|
||||
middlewares: { use: (path, handler) => routes.set(path, handler) },
|
||||
});
|
||||
const res = {
|
||||
statusCode: 200,
|
||||
writeHead(s) {
|
||||
this.statusCode = s;
|
||||
},
|
||||
end() {},
|
||||
};
|
||||
let handedOn = false;
|
||||
await routes.get(route)(
|
||||
{ url: '/track?icao24=abc123', method: 'POST' },
|
||||
res,
|
||||
() => {
|
||||
handedOn = true;
|
||||
},
|
||||
);
|
||||
assert.ok(handedOn, `${route}/track reaches next()`);
|
||||
assert.equal(
|
||||
res.statusCode,
|
||||
200,
|
||||
`${route}/track is not answered by the snapshot handler`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('the OpenSky snapshot is compressed for clients that ask, and an error body never inherits the cached frame', async (t) => {
|
||||
environment(t, {
|
||||
OPENSKY_AUTH_MODE: 'anon',
|
||||
OPENSKY_CLIENT_ID: undefined,
|
||||
OPENSKY_CLIENT_SECRET: undefined,
|
||||
OPENSKY_USERNAME: undefined,
|
||||
OPENSKY_PASSWORD: undefined,
|
||||
});
|
||||
let now = Date.now();
|
||||
t.mock.method(Date, 'now', () => now);
|
||||
t.mock.method(console, 'log', () => {});
|
||||
t.mock.method(console, 'error', () => {});
|
||||
|
||||
let upstreamStatus = 200;
|
||||
let snapshotAgeMs = 0;
|
||||
t.mock.method(globalThis, 'fetch', async (url) => {
|
||||
if (url.includes('/lat/'))
|
||||
return Response.json({
|
||||
now: now / 1000,
|
||||
ac: Array.from({ length: 200 }, (_, index) => ({
|
||||
hex: `abc${index}`,
|
||||
lat: 30,
|
||||
lon: -97,
|
||||
alt_baro: 10000,
|
||||
})),
|
||||
});
|
||||
assert.ok(url.includes('/states/'), `unexpected upstream call: ${url}`);
|
||||
if (upstreamStatus !== 200)
|
||||
return new Response('{"error":"upstream"}', { status: upstreamStatus });
|
||||
const sourceSeconds = Math.floor((now - snapshotAgeMs) / 1000);
|
||||
// Healthy credits keep the 9 s base TTL, so the clock jumps below clear it.
|
||||
return Response.json(
|
||||
{
|
||||
time: sourceSeconds,
|
||||
states: Array.from({ length: 200 }, (_, index) => [
|
||||
`abc${index}`,
|
||||
'CALLSIGN',
|
||||
'United States',
|
||||
sourceSeconds,
|
||||
-97.7,
|
||||
30.1,
|
||||
10000,
|
||||
]),
|
||||
},
|
||||
{ headers: { 'x-rate-limit-remaining': '4000' } },
|
||||
);
|
||||
});
|
||||
|
||||
// A fresh module: the snapshot cache, its frame and the TTL are module state.
|
||||
const isolated = await import(
|
||||
`../../server/providers/aircraft/opensky.js?compression=${now}`
|
||||
);
|
||||
const states = install(isolated.openSkyProxy());
|
||||
const compressed = await states('/api/flights', '/', 'GET', {
|
||||
'accept-encoding': 'gzip, deflate, br',
|
||||
});
|
||||
assert.equal(compressed.statusCode, 200);
|
||||
assert.equal(compressed.headers['content-encoding'], 'gzip');
|
||||
assert.equal(compressed.headers.vary, 'Accept-Encoding');
|
||||
assert.ok(Buffer.isBuffer(compressed.body));
|
||||
|
||||
// Same cached snapshot, no Accept-Encoding: identity bytes, which the frame
|
||||
// above must decode to.
|
||||
const identity = await states('/api/flights');
|
||||
assert.equal(identity.headers['x-opensky-cache'], 'HIT');
|
||||
assert.equal(identity.headers['content-encoding'], undefined);
|
||||
assert.equal(identity.headers.vary, 'Accept-Encoding');
|
||||
assert.equal(gunzipSync(compressed.body).toString(), identity.body);
|
||||
|
||||
// `gzip;q=0` refuses gzip.
|
||||
const refused = await states('/api/flights', '/', 'GET', {
|
||||
'accept-encoding': 'gzip;q=0',
|
||||
});
|
||||
assert.equal(refused.headers['content-encoding'], undefined);
|
||||
assert.equal(refused.body, identity.body);
|
||||
|
||||
// An upstream rejection is not cached, so it must not carry the previous
|
||||
// snapshot's Content-Encoding.
|
||||
upstreamStatus = 403;
|
||||
now += 60000; // past the 9 s base TTL
|
||||
const rejected = await states('/api/flights', '/', 'GET', {
|
||||
'accept-encoding': 'gzip',
|
||||
});
|
||||
assert.equal(rejected.statusCode, 403);
|
||||
assert.equal(rejected.headers['content-encoding'], undefined);
|
||||
assert.ok(JSON.parse(rejected.body).error, 'a readable error body');
|
||||
|
||||
// A snapshot too old to serve falls back to adsb.lol, which negotiates the
|
||||
// same way.
|
||||
upstreamStatus = 200;
|
||||
snapshotAgeMs = 3600000;
|
||||
now += 60000;
|
||||
const regional = await states('/api/flights', '?lat=30&lon=-97', 'GET', {
|
||||
'accept-encoding': 'gzip',
|
||||
});
|
||||
assert.equal(regional.statusCode, 200);
|
||||
assert.equal(regional.headers['x-flight-source'], 'adsb.lol');
|
||||
assert.equal(regional.headers['content-encoding'], 'gzip');
|
||||
assert.equal(regional.headers.vary, 'Accept-Encoding');
|
||||
assert.equal(
|
||||
JSON.parse(gunzipSync(regional.body).toString()).states.length,
|
||||
200,
|
||||
);
|
||||
});
|
||||
|
||||
test('OpenSky Basic mode no longer sends username and password: it asks anonymously and warns once', async (t) => {
|
||||
environment(t, {
|
||||
OPENSKY_CLIENT_ID: undefined,
|
||||
|
||||
@@ -20,12 +20,9 @@ import {
|
||||
import { openAiRealtimeProxy } from 'gods-eye-view/server/providers/openai';
|
||||
import { keySetupEndpoint } from 'gods-eye-view/server/standalone/key-setup';
|
||||
import { realtimeInstructions } from '../../server/providers/openai/instructions.js';
|
||||
import {
|
||||
GEV_REALTIME_TOOLS,
|
||||
offeredTools,
|
||||
realtimeTools,
|
||||
} from '../../server/providers/openai/tools.js';
|
||||
import { GEV_REALTIME_TOOLS } from '../../server/providers/openai/tools.js';
|
||||
import { createDebugLogHandler } from '../../server/providers/openai/debug-log.js';
|
||||
import { realtimeSessionTools } from '../../server/providers/openai/realtime.js';
|
||||
import { attachVoiceResult } from '../voice/speech.js';
|
||||
import { sanitizeDebugValue } from '../voice/realtimeDiagnostics.js';
|
||||
import { standaloneVoiceTools } from '../../server/standalone/voiceTools.js';
|
||||
@@ -199,7 +196,10 @@ test('Realtime handler preserves tools and default instructions, isolates suppli
|
||||
sent.at(-1).session.instructions,
|
||||
realtimeInstructions(guidance),
|
||||
);
|
||||
assert.deepEqual(sent.at(-1).session.tools, realtimeTools());
|
||||
assert.deepEqual(
|
||||
sent.at(-1).session.tools,
|
||||
realtimeSessionTools(GEV_REALTIME_TOOLS),
|
||||
);
|
||||
}
|
||||
assert.notEqual(sent[0].session.instructions, sent[1].session.instructions);
|
||||
assert.equal(sent[0].session.instructions, sent[2].session.instructions);
|
||||
@@ -607,7 +607,10 @@ test('osm_query is offered only when an operator configured Overpass', async (t)
|
||||
assert.doesNotMatch(plain.instructions, /osm_query/);
|
||||
assert.ok(names(plain).includes('resolve_area'));
|
||||
assert.ok(names(plain).includes('find_imagery'));
|
||||
assert.deepEqual(configured.tools, GEV_REALTIME_TOOLS);
|
||||
assert.deepEqual(
|
||||
configured.tools,
|
||||
realtimeSessionTools(GEV_REALTIME_TOOLS, { overpass: true }),
|
||||
);
|
||||
assert.match(configured.instructions, /osm_query/);
|
||||
assert.equal(
|
||||
configured.instructions,
|
||||
@@ -656,7 +659,7 @@ test('Realtime sessions carry supplied tools, and the standalone voice adds the
|
||||
);
|
||||
assert.equal(response.status, 200);
|
||||
// Without an operator Overpass, osm_query is left out of the session.
|
||||
assert.deepEqual(sent.at(-1).session.tools, offeredTools(tools));
|
||||
assert.deepEqual(sent.at(-1).session.tools, realtimeSessionTools(tools));
|
||||
assert.ok(
|
||||
!sent.at(-1).session.tools.some(({ name }) => name === 'osm_query'),
|
||||
);
|
||||
@@ -740,6 +743,26 @@ test('key setup writes only the supplied application root, retains request guard
|
||||
assert.equal(existsSync(path.join(untouched, '.env')), false);
|
||||
});
|
||||
|
||||
test('Realtime sessions leave out the Unicode property patterns OpenAI refuses (#1618)', async (t) => {
|
||||
// OpenAI answers 400 invalid_function_parameters for a whole Realtime session
|
||||
// whose tool schema uses `\p{…}`, as resolve_area's area ids do, so voice
|
||||
// never connects. The session drops only those patterns, as typed chat does.
|
||||
env(t, 'OPENAI_API_KEY', 'fixture-upstream-secret');
|
||||
env(t, 'GEV_RATELIMIT_OPENAI_PER_MIN', undefined);
|
||||
const sent = [];
|
||||
t.mock.method(globalThis, 'fetch', async (_url, options) => {
|
||||
sent.push(JSON.parse(options.body).session);
|
||||
return Response.json({ value: 'fixture-ephemeral' });
|
||||
});
|
||||
const response = await request(
|
||||
install(openAiRealtimeProxy({})).get('/api/realtime/token'),
|
||||
);
|
||||
assert.equal(response.status, 200);
|
||||
const { tools } = sent.at(-1);
|
||||
assert.ok(tools.some(({ name }) => name === 'resolve_area'));
|
||||
assert.doesNotMatch(JSON.stringify(tools), /\\\\[pP]\{/);
|
||||
});
|
||||
|
||||
test('Realtime service configuration selects compatible endpoint/model without forwarding request model IDs or keys', async () => {
|
||||
const handler = install(
|
||||
openAiRealtimeProxy({
|
||||
@@ -757,7 +780,7 @@ test('Realtime service configuration selects compatible endpoint/model without f
|
||||
assert.equal(payload.session.model, 'configured-model');
|
||||
assert.deepEqual(
|
||||
payload.session.tools,
|
||||
realtimeTools({ overpass: false }),
|
||||
realtimeSessionTools(GEV_REALTIME_TOOLS),
|
||||
);
|
||||
return Response.json({ value: 'short-lived-fixture' });
|
||||
},
|
||||
|
||||
@@ -684,25 +684,34 @@ test('the disk cache survives a restart and stays within its bounds', async () =
|
||||
await createNominatimCache({ dir, now: time.now }).get('k0'),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
fs.statSync(dir).mode & 0o777,
|
||||
0o700,
|
||||
'the query cache directory is private',
|
||||
);
|
||||
// The cache is private only through POSIX mode bits. Windows has none
|
||||
// (directories stat as 0o666) and the cache keeps the checkout's ACLs,
|
||||
// so these checks run, and privacy holds, only on POSIX systems.
|
||||
const posixModes = process.platform !== 'win32';
|
||||
if (posixModes) {
|
||||
assert.equal(
|
||||
fs.statSync(dir).mode & 0o777,
|
||||
0o700,
|
||||
'the query cache directory is private (POSIX modes)',
|
||||
);
|
||||
}
|
||||
const text = fs
|
||||
.readdirSync(dir)
|
||||
.filter((name) => name.endsWith('.json'))
|
||||
.map((name) => fs.readFileSync(path.join(dir, name), 'utf8'))
|
||||
.join('\n');
|
||||
assert.equal(text.includes('k4'), false, 'disk entries never store raw keys');
|
||||
for (const name of fs
|
||||
.readdirSync(dir)
|
||||
.filter((entry) => entry.endsWith('.json')))
|
||||
assert.equal(
|
||||
fs.statSync(path.join(dir, name)).mode & 0o777,
|
||||
0o600,
|
||||
`${name} is private`,
|
||||
);
|
||||
if (posixModes) {
|
||||
for (const name of fs
|
||||
.readdirSync(dir)
|
||||
.filter((entry) => entry.endsWith('.json'))) {
|
||||
assert.equal(
|
||||
fs.statSync(path.join(dir, name)).mode & 0o777,
|
||||
0o600,
|
||||
`${name} is private (POSIX modes)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
|
||||
@@ -501,6 +501,25 @@ test('the upstream host is pinned: a redirect is refused, not followed', async (
|
||||
assert.deepEqual(seen, ['error'], 'a redirect would escape the pinned host');
|
||||
});
|
||||
|
||||
test('the routing proxy declines a non-GET before rate limiting or upstream', async (t) => {
|
||||
// A fetch that throws proves the refusal comes before any upstream call.
|
||||
const request = install(installRouteMiddleware);
|
||||
t.mock.method(globalThis, 'fetch', async () => {
|
||||
throw new Error('upstream must not be reached');
|
||||
});
|
||||
for (const method of ['POST', 'PUT', 'DELETE', 'PATCH']) {
|
||||
const response = await request(
|
||||
'/api/route',
|
||||
'?profile=car&coords=-97,30;-97.01,30.01',
|
||||
method,
|
||||
);
|
||||
assert.equal(response.statusCode, 405, method);
|
||||
// RFC 9110 section 15.5.6: a 405 names the methods it accepts.
|
||||
assert.equal(response.headers.allow, 'GET', method);
|
||||
assert.deepEqual(response.body, { error: 'Method Not Allowed' });
|
||||
}
|
||||
});
|
||||
|
||||
test('both paid Google routes refuse cross-site requests before calling Google', async (t) => {
|
||||
const plugin = googlePlacesContextProxy({ resolveApiKey: () => 'key' });
|
||||
const routes = new Map();
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { gunzipSync } from 'node:zlib';
|
||||
import {
|
||||
acceptsGzip,
|
||||
encodedBody,
|
||||
gzipBody,
|
||||
} from '../../server/providers/common/compress.js';
|
||||
|
||||
/** A request shaped like the one Node hands a middleware. */
|
||||
function request(acceptEncoding) {
|
||||
return {
|
||||
url: '/api/flights',
|
||||
method: 'GET',
|
||||
headers:
|
||||
acceptEncoding === undefined ? {} : { 'accept-encoding': acceptEncoding },
|
||||
};
|
||||
}
|
||||
|
||||
/** Repetitive JSON shaped like a snapshot body. */
|
||||
function snapshotBody(states = 200) {
|
||||
return JSON.stringify({
|
||||
time: 1700000000,
|
||||
states: Array.from({ length: states }, (_, index) => [
|
||||
`abc${index}`,
|
||||
'CALLSIGN',
|
||||
'United States',
|
||||
1700000000,
|
||||
30.1,
|
||||
-97.7,
|
||||
10000,
|
||||
]),
|
||||
});
|
||||
}
|
||||
|
||||
/** Seeded high-entropy bytes, so the "gzip made it bigger" check is stable. */
|
||||
function incompressibleBytes(length) {
|
||||
const bytes = Buffer.alloc(length);
|
||||
let state = 123456789;
|
||||
for (let index = 0; index < length; index += 1) {
|
||||
state = (state * 1103515245 + 12345) % 2147483648;
|
||||
bytes[index] = (state >>> 16) & 0xff;
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
test('accept-encoding is read the way clients actually write it', () => {
|
||||
assert.equal(acceptsGzip(request('gzip')), true);
|
||||
assert.equal(acceptsGzip(request('gzip, deflate, br')), true);
|
||||
assert.equal(acceptsGzip(request('br;q=1.0, gzip;q=0.8')), true);
|
||||
assert.equal(acceptsGzip(request('GZIP')), true);
|
||||
assert.equal(acceptsGzip(request(' gzip ')), true);
|
||||
|
||||
assert.equal(acceptsGzip(request('br, deflate')), false);
|
||||
assert.equal(acceptsGzip(request('identity')), false);
|
||||
assert.equal(acceptsGzip(request('')), false);
|
||||
assert.equal(acceptsGzip(request(undefined)), false);
|
||||
assert.equal(acceptsGzip({}), false);
|
||||
assert.equal(acceptsGzip(undefined), false);
|
||||
|
||||
// `q=0` names gzip and refuses it (RFC 9110 section 12.4.2).
|
||||
assert.equal(acceptsGzip(request('gzip;q=0')), false);
|
||||
assert.equal(acceptsGzip(request('gzip;q=0.000')), false);
|
||||
assert.equal(acceptsGzip(request('gzip;q=0.001')), true);
|
||||
assert.equal(acceptsGzip(request('gzip;q=1')), true);
|
||||
assert.equal(acceptsGzip(request('gzip;q=1.000')), true);
|
||||
|
||||
// A q that is not a whole `qvalue` is unreadable and must not round into
|
||||
// acceptance.
|
||||
assert.equal(acceptsGzip(request('gzip;q=bogus')), false);
|
||||
assert.equal(acceptsGzip(request('gzip;q=1oops')), false);
|
||||
assert.equal(acceptsGzip(request('gzip;q=0.5abc')), false);
|
||||
assert.equal(acceptsGzip(request('gzip;q=2')), false);
|
||||
assert.equal(acceptsGzip(request('gzip;q=1.5')), false);
|
||||
assert.equal(acceptsGzip(request('gzip;q=0.0001')), false);
|
||||
assert.equal(acceptsGzip(request('gzip;q=')), false);
|
||||
|
||||
// `gzip` is not matched as a substring of a neighbouring token.
|
||||
assert.equal(acceptsGzip(request('x-gzip')), false);
|
||||
assert.equal(acceptsGzip(request('gzipped')), false);
|
||||
});
|
||||
|
||||
test('a body is only compressed when compression pays for itself', async () => {
|
||||
assert.equal(await gzipBody(''), null);
|
||||
assert.equal(
|
||||
await gzipBody('{"states":[]}'),
|
||||
null,
|
||||
'below the frame threshold',
|
||||
);
|
||||
assert.equal(await gzipBody(Buffer.alloc(1023, 'a')), null, 'one byte short');
|
||||
|
||||
const identity = snapshotBody();
|
||||
assert.ok(identity.length > 1024, 'fixture is worth compressing');
|
||||
const compressed = await gzipBody(identity);
|
||||
assert.ok(Buffer.isBuffer(compressed));
|
||||
assert.ok(compressed.byteLength < Buffer.byteLength(identity));
|
||||
assert.equal(gunzipSync(compressed).toString(), identity);
|
||||
|
||||
// Buffers are accepted as-is; data gzip cannot shrink stays uncompressed.
|
||||
assert.ok((await gzipBody(Buffer.from(identity))) !== null);
|
||||
assert.equal(await gzipBody(incompressibleBytes(4096)), null);
|
||||
});
|
||||
|
||||
test('the encoded body and the header that names it are chosen together', async () => {
|
||||
const identity = snapshotBody();
|
||||
const compressed = await gzipBody(identity);
|
||||
|
||||
const negotiated = encodedBody(request('gzip'), identity, compressed);
|
||||
assert.equal(negotiated.body, compressed);
|
||||
assert.equal(negotiated.headers['Content-Encoding'], 'gzip');
|
||||
assert.equal(negotiated.headers.Vary, 'Accept-Encoding');
|
||||
assert.equal(gunzipSync(negotiated.body).toString(), identity);
|
||||
|
||||
// Offered but refused: identity bytes and no Content-Encoding.
|
||||
const refused = encodedBody(request('br'), identity, compressed);
|
||||
assert.equal(refused.body, identity);
|
||||
assert.equal(refused.headers['Content-Encoding'], undefined);
|
||||
assert.equal(refused.headers.Vary, 'Accept-Encoding');
|
||||
|
||||
// Wanted but not available: same answer.
|
||||
const unavailable = encodedBody(request('gzip'), identity, null);
|
||||
assert.equal(unavailable.body, identity);
|
||||
assert.equal(unavailable.headers['Content-Encoding'], undefined);
|
||||
|
||||
// Vary is on both branches, so a shared cache cannot serve the wrong encoding.
|
||||
assert.equal(unavailable.headers.Vary, 'Accept-Encoding');
|
||||
assert.equal(
|
||||
encodedBody(request(undefined), identity, compressed).headers.Vary,
|
||||
'Accept-Encoding',
|
||||
);
|
||||
});
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
numericSummary,
|
||||
thinEvenly,
|
||||
} from '../results.js';
|
||||
import { roundHeadingDegrees } from '../../data/headingDegrees.js';
|
||||
|
||||
const ICAO24_SCHEMA = Object.freeze({
|
||||
type: 'string',
|
||||
@@ -75,7 +76,7 @@ function aircraftRow(record, from) {
|
||||
altitude_m: round(record.baroAltitudeM ?? record.ellipsoidAltitudeM, 0),
|
||||
on_ground: record.onGround === true,
|
||||
speed_mps: round(record.speedMps, 1),
|
||||
course_deg: round(record.courseDeg, 0),
|
||||
course_deg: roundHeadingDegrees(record.courseDeg),
|
||||
vertical_rate_mps: round(record.verticalRateMps, 1),
|
||||
type_code: record.typeCode || null,
|
||||
registration: record.registration || null,
|
||||
|
||||
@@ -146,6 +146,19 @@ test('the military feed is used on request and reported when missing', async ()
|
||||
);
|
||||
});
|
||||
|
||||
test('an aircraft course that rounds to 360 reads as 0', async () => {
|
||||
const catalog = composeCatalog({
|
||||
tools: coreTools,
|
||||
services: {
|
||||
aircraft: feed([
|
||||
record('ddd444', 37.6, -122.3, { callsign: 'N360', courseDeg: 359.6 }),
|
||||
]),
|
||||
},
|
||||
});
|
||||
const result = await catalog.call('find_aircraft', { callsign: 'N360' });
|
||||
assert.equal(result.data.rows[0].course_deg, 0);
|
||||
});
|
||||
|
||||
test('aircraft are found by exactly one identifier across feeds', async () => {
|
||||
const catalog = composeCatalog({
|
||||
tools: coreTools,
|
||||
|
||||
@@ -71,6 +71,7 @@ function weatherRow(weather) {
|
||||
precipitation_mm: weather.precipitationMm ?? null,
|
||||
cloud_cover_pct: weather.cloudCoverPct ?? null,
|
||||
wind_kph: weather.windKph ?? null,
|
||||
// Raw Open-Meteo value, so north reads 360.
|
||||
wind_direction_deg: weather.windDirectionDeg ?? null,
|
||||
visibility_m: weather.visibilityM ?? null,
|
||||
};
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
numericSummary,
|
||||
thinEvenly,
|
||||
} from '../results.js';
|
||||
import { roundHeadingDegrees } from '../../data/headingDegrees.js';
|
||||
import { normalizeVesselType } from '../../data/vesselTypes.js';
|
||||
|
||||
const MMSI_SCHEMA = Object.freeze({
|
||||
@@ -57,8 +58,8 @@ function vesselRow(record, from) {
|
||||
lat: round(record.latitude, 5),
|
||||
lon: round(record.longitude, 5),
|
||||
speed_mps: round(record.speedMps, 1),
|
||||
course_deg: round(record.courseDeg, 0),
|
||||
heading_deg: round(record.headingDeg, 0),
|
||||
course_deg: roundHeadingDegrees(record.courseDeg),
|
||||
heading_deg: roundHeadingDegrees(record.headingDeg),
|
||||
observed_at: isoTime(record.observedAtMs),
|
||||
...(from ? { distance_km: round(distanceKm(from, point), 1) } : {}),
|
||||
};
|
||||
|
||||
@@ -351,6 +351,25 @@ test('find_vessel answers carry normalized types and destinations', async () =>
|
||||
);
|
||||
});
|
||||
|
||||
test('a vessel course or heading that rounds to 360 reads as 0', async () => {
|
||||
const catalog = composeCatalog({
|
||||
tools: coreTools,
|
||||
services: {
|
||||
vessels: vessels({
|
||||
records: [
|
||||
vessel('366000003', 37.78, -122.4, {
|
||||
courseDeg: 359.7,
|
||||
headingDeg: 359.6,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
},
|
||||
});
|
||||
const [row] = (await catalog.call('find_vessel', { mmsi: '366000003' })).data
|
||||
.rows;
|
||||
assert.deepEqual([row.course_deg, row.heading_deg], [0, 0]);
|
||||
});
|
||||
|
||||
test('a destination with no letters counts as unknown', async () => {
|
||||
const catalog = composeCatalog({
|
||||
tools: coreTools,
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
resolveArea,
|
||||
} from '../area.js';
|
||||
import { LIMIT_SCHEMA, capRows, countNoun, toBase64 } from '../results.js';
|
||||
import { roundHeadingDegrees } from '../../data/headingDegrees.js';
|
||||
|
||||
const FRAME_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp']);
|
||||
const MAX_FRAME_BYTES = 3 * 1024 * 1024;
|
||||
@@ -85,7 +86,7 @@ export const findCctvCameras = defineTool({
|
||||
provider: text(camera.provider),
|
||||
lat: round(camera.lat, 6),
|
||||
lon: round(camera.lon, 6),
|
||||
heading_deg: round(camera.headingDeg, 0),
|
||||
heading_deg: roundHeadingDegrees(camera.headingDeg),
|
||||
feed_type: text(camera.feedType),
|
||||
credit: text(camera.credit),
|
||||
distance_km: round(distanceKm(center, camera), 2),
|
||||
|
||||
@@ -77,6 +77,20 @@ test('cameras in an area are listed nearest first', async () => {
|
||||
assert.equal(result.data.rows[1].credit, null);
|
||||
});
|
||||
|
||||
test('a camera heading that rounds to 360 reads as 0', async () => {
|
||||
const source = {
|
||||
async getCatalog() {
|
||||
return { sources: [{ ...cameras[0], headingDeg: 359.6 }] };
|
||||
},
|
||||
};
|
||||
const catalog = composeCatalog({
|
||||
tools: coreTools,
|
||||
services: { cctv: source },
|
||||
});
|
||||
const result = await catalog.call('find_cctv_cameras', { area: downtown });
|
||||
assert.equal(result.data.rows[0].heading_deg, 0);
|
||||
});
|
||||
|
||||
test('a snapshot returns the frame as an image with its credit', async () => {
|
||||
const source = cctv();
|
||||
const catalog = composeCatalog({
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
resolveArea,
|
||||
} from '../area.js';
|
||||
import { LIMIT_SCHEMA, capRows, countNoun, isoTime } from '../results.js';
|
||||
import { roundHeadingDegrees } from '../../data/headingDegrees.js';
|
||||
|
||||
const MAX_SYSTEMS = 3;
|
||||
|
||||
@@ -244,7 +245,7 @@ export const getTransitVehicles = defineTool({
|
||||
trip_id: vehicle.tripId ?? null,
|
||||
lat: vehicle.lat,
|
||||
lon: vehicle.lon,
|
||||
bearing_deg: round(vehicle.bearing, 0),
|
||||
bearing_deg: roundHeadingDegrees(vehicle.bearing),
|
||||
speed_mps: round(vehicle.speedMps, 1),
|
||||
status: vehicle.status ?? null,
|
||||
occupancy: vehicle.occupancy ?? null,
|
||||
|
||||
@@ -311,6 +311,23 @@ test('transit vehicles come from nearby feeds and can be limited to a route', as
|
||||
assert.equal(rapid.data.rows[0].id, 'v2');
|
||||
});
|
||||
|
||||
test('a transit bearing that rounds to 360 reads as 0', async () => {
|
||||
const north = [{ ...vehicles[0], bearing: 359.6 }];
|
||||
const catalog = composeCatalog({
|
||||
tools: coreTools,
|
||||
services: {
|
||||
transit: transit({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ vehicles: north }),
|
||||
}),
|
||||
clock,
|
||||
},
|
||||
});
|
||||
const result = await catalog.call('get_transit_vehicles', { area: downtown });
|
||||
assert.equal(result.data.rows[0].bearing_deg, 0);
|
||||
});
|
||||
|
||||
test('transit reports uncovered areas and unavailable feeds', async () => {
|
||||
const catalog = composeCatalog({
|
||||
tools: coreTools,
|
||||
|
||||
@@ -10,6 +10,7 @@ import { parseTleText, tleCatalogNumber } from '../../sources/tle.js';
|
||||
import { POINT_SCHEMA, resolvePoint } from '../area.js';
|
||||
import { defineTool, ToolError } from '../catalog.js';
|
||||
import { LIMIT_SCHEMA, capRows, countNoun, isoTime } from '../results.js';
|
||||
import { roundHeadingDegrees } from '../../data/headingDegrees.js';
|
||||
|
||||
const text = (value) => (typeof value === 'string' && value ? value : null);
|
||||
|
||||
@@ -241,7 +242,7 @@ export const nextSatellitePass = defineTool({
|
||||
peak: isoTime(pass.maxElevMs),
|
||||
set: isoTime(pass.setMs),
|
||||
max_elevation_deg: round(pass.maxElevDeg, 1),
|
||||
rise_azimuth_deg: round(pass.riseAzDeg, 0),
|
||||
rise_azimuth_deg: roundHeadingDegrees(pass.riseAzDeg),
|
||||
rise_direction: compass(pass.riseAzDeg),
|
||||
visible: pass.visible,
|
||||
},
|
||||
@@ -283,7 +284,7 @@ export const satellitesOverhead = defineTool({
|
||||
name: entry.name,
|
||||
norad: entry.norad,
|
||||
elevation_deg: round(look.elevDeg, 1),
|
||||
azimuth_deg: round(look.azDeg, 0),
|
||||
azimuth_deg: roundHeadingDegrees(look.azDeg),
|
||||
direction: compass(look.azDeg),
|
||||
},
|
||||
]
|
||||
|
||||
@@ -2,6 +2,10 @@ import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { twoline2satrec } from 'satellite.js';
|
||||
import { composeCatalog, coreTools } from '../index.js';
|
||||
import {
|
||||
findNextSatellitePass,
|
||||
lookAnglesAt,
|
||||
} from '../../data/satellitePass.js';
|
||||
import {
|
||||
noradIdFromSatnum,
|
||||
parseTleText,
|
||||
@@ -296,6 +300,37 @@ test('satellites overhead are those above the elevation, highest first', async (
|
||||
);
|
||||
});
|
||||
|
||||
test('satellite azimuths that round to 360 read as 0', async () => {
|
||||
const catalog = composeCatalog({
|
||||
tools: coreTools,
|
||||
services: { satellites: satellites(), clock: CLOCK },
|
||||
});
|
||||
// At these points the ISS's raw azimuths fall just below 360°; check that
|
||||
// first so the test cannot pass on a raw value near 0.
|
||||
const [, line1, line2] = TLE.split('\n');
|
||||
const iss = twoline2satrec(line1, line2);
|
||||
const nearNorth = (deg) => deg > 359.5 && deg < 360;
|
||||
const rawPass = findNextSatellitePass({
|
||||
satrec: iss,
|
||||
latDeg: 10,
|
||||
lonDeg: 30,
|
||||
fromMs: CLOCK.now(),
|
||||
});
|
||||
assert.ok(nearNorth(rawPass.riseAzDeg), `rise ${rawPass.riseAzDeg}°`);
|
||||
const rawLook = lookAnglesAt(iss, CLOCK.now(), 39.25, 65.75);
|
||||
assert.ok(nearNorth(rawLook.azDeg), `azimuth ${rawLook.azDeg}°`);
|
||||
const pass = await catalog.call('next_satellite_pass', {
|
||||
location: { lat: 10, lon: 30 },
|
||||
});
|
||||
assert.equal(pass.data.pass.rise_azimuth_deg, 0);
|
||||
assert.equal(pass.data.pass.rise_direction, 'N');
|
||||
const overhead = await catalog.call('satellites_overhead', {
|
||||
location: { lat: 39.25, lon: 65.75 },
|
||||
});
|
||||
assert.equal(overhead.data.rows[0].name, 'ISS (ZARYA)');
|
||||
assert.equal(overhead.data.rows[0].azimuth_deg, 0);
|
||||
});
|
||||
|
||||
test('stale orbit and launch data are reported', async () => {
|
||||
const stale = {
|
||||
readGroup: async () => ({ ok: true, status: 200, text: TLE, stale: true }),
|
||||
|
||||
@@ -11,14 +11,17 @@ import {
|
||||
} from './layerNames.js';
|
||||
export { localizeLayerName } from './layerNames.js';
|
||||
import { createWeatherPanel } from './weatherPanel.js';
|
||||
import { translateFeedError, translateStatusLine } from './statusText.js';
|
||||
import {
|
||||
translateFeedError,
|
||||
translateStatusLine,
|
||||
translateStatusMessage,
|
||||
} from './statusText.js';
|
||||
import {
|
||||
formatNumber,
|
||||
getLocale,
|
||||
msg,
|
||||
onLocaleChange,
|
||||
t,
|
||||
translateKnown,
|
||||
} from '../i18n/index.js';
|
||||
|
||||
// Feed-state values are machine ids (QA hooks, CSS feed-* classes); only the
|
||||
@@ -695,7 +698,7 @@ export class LayerPanel {
|
||||
typeof stats.statusMessage === 'string' &&
|
||||
stats.statusMessage.trim()
|
||||
) {
|
||||
return `${source} · ${translateKnown(stats.statusMessage.trim())}`;
|
||||
return `${source} · ${translateStatusMessage(stats.statusMessage.trim())}`;
|
||||
}
|
||||
const ago = stats.lastUpdate ? this._timeAgo(stats.lastUpdate) : t('never');
|
||||
if (stats.loading) {
|
||||
|
||||
@@ -452,3 +452,57 @@ test('without the Utilities list, Directions stays in Data Layers under Utilitie
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('a layer with no feed in view says why instead of "never"', async () => {
|
||||
const { LayerPanel, presentLayerCount, presentLayerToggle } =
|
||||
await import('./layerPanel.js');
|
||||
const { setLocale } = await import('../i18n/index.js');
|
||||
const panel = LayerPanel.prototype;
|
||||
const rows = [
|
||||
{
|
||||
id: 'transit',
|
||||
name: 'Transit',
|
||||
source: 'GTFS-RT',
|
||||
enabled: true,
|
||||
stats: {
|
||||
count: 0,
|
||||
lastUpdate: null,
|
||||
source: 'GTFS-RT',
|
||||
status: 'zoom-in',
|
||||
statusMessage: 'No feed here yet · 16 regions available',
|
||||
},
|
||||
meta: 'GTFS-RT · No feed here yet · 16 regions available',
|
||||
},
|
||||
{
|
||||
id: 'bikeshare',
|
||||
name: 'Bikeshare',
|
||||
source: 'GBFS',
|
||||
enabled: true,
|
||||
stats: {
|
||||
count: 0,
|
||||
lastUpdate: null,
|
||||
loading: false,
|
||||
status: 'zoom-in',
|
||||
statusMessage: 'Fly below 48 km to a covered city',
|
||||
},
|
||||
meta: 'GBFS · Fly below 48 km to a covered city',
|
||||
},
|
||||
];
|
||||
for (const layer of rows) {
|
||||
// Guidance is normal operation: the layer is on and nothing has failed.
|
||||
assert.equal(presentLayerToggle(layer).text, 'ON');
|
||||
assert.equal(presentLayerCount(layer.stats).empty, true);
|
||||
assert.equal(panel._buildMetaText(layer), layer.meta);
|
||||
}
|
||||
|
||||
setLocale('ru');
|
||||
try {
|
||||
for (const layer of rows) {
|
||||
const meta = panel._buildMetaText(layer);
|
||||
assert.doesNotMatch(meta, /never|Fly below|No feed|covered/, meta);
|
||||
assert.match(meta, /^(GTFS-RT|GBFS) · /, meta);
|
||||
}
|
||||
} finally {
|
||||
setLocale('en');
|
||||
}
|
||||
});
|
||||
@@ -265,3 +265,64 @@ export function translateStatusLine(text) {
|
||||
.map(translateStatusPiece)
|
||||
.join(STATUS_SEPARATOR);
|
||||
}
|
||||
|
||||
/** Thousands separators out of an English number ("3,000" → 3000). */
|
||||
const englishNumber = (text) => Number(text.replace(/,/g, ''));
|
||||
|
||||
// Coverage notes (transit/queries.js, bikeshare/controls.js). Their " · " is
|
||||
// inside one sentence, so they match whole before the line is split.
|
||||
const COVERAGE_NOTE_PATTERNS = [
|
||||
[
|
||||
/^Fly below ([\d,]+) km to a covered region$/,
|
||||
([, km]) =>
|
||||
t('Fly below {km} km to a covered region', {
|
||||
km: formatNumber(englishNumber(km)),
|
||||
}),
|
||||
],
|
||||
[
|
||||
/^No feed here yet · ([\d,]+) regions available$/,
|
||||
([, regions]) => {
|
||||
const count = englishNumber(regions);
|
||||
return tn(
|
||||
count,
|
||||
'No feed here yet · {n} region available',
|
||||
'No feed here yet · {n} regions available',
|
||||
{ n: formatNumber(count) },
|
||||
);
|
||||
},
|
||||
],
|
||||
[
|
||||
/^Fly below ([\d,]+) km to a covered city$/,
|
||||
([, km]) =>
|
||||
t('Fly below {km} km to a covered city', {
|
||||
km: formatNumber(englishNumber(km)),
|
||||
}),
|
||||
],
|
||||
[
|
||||
/^No bike share here yet · ([\d,]+) systems available$/,
|
||||
([, systems]) => {
|
||||
const count = englishNumber(systems);
|
||||
return tn(
|
||||
count,
|
||||
'No bike share here yet · {n} system available',
|
||||
'No bike share here yet · {n} systems available',
|
||||
{ n: formatNumber(count) },
|
||||
);
|
||||
},
|
||||
],
|
||||
];
|
||||
|
||||
/**
|
||||
* A layer's status message in the UI language: its catalog entry, a coverage
|
||||
* note by its shape, or a status line translated piece by piece.
|
||||
*/
|
||||
export function translateStatusMessage(text) {
|
||||
if (typeof text !== 'string') return text;
|
||||
const known = translateKnown(text);
|
||||
if (known !== text) return known;
|
||||
for (const [pattern, format] of COVERAGE_NOTE_PATTERNS) {
|
||||
const match = pattern.exec(text);
|
||||
if (match) return format(match);
|
||||
}
|
||||
return translateStatusLine(text);
|
||||
}
|
||||
@@ -3,7 +3,11 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { setLocale, t } from '../i18n/index.js';
|
||||
import { translateFeedError, translateStatusLine } from './statusText.js';
|
||||
import {
|
||||
translateFeedError,
|
||||
translateStatusLine,
|
||||
translateStatusMessage,
|
||||
} from './statusText.js';
|
||||
|
||||
function inLocale(locale, run) {
|
||||
setLocale(locale);
|
||||
@@ -75,3 +79,55 @@ for (const locale of ['es', 'ru']) {
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// A layer's coverage note when no feed covers the view: one sentence with a
|
||||
// " · " in it (transit/queries.js, bikeshare/controls.js).
|
||||
const COVERAGE_NOTES = [
|
||||
'Fly below 2,850 km to a covered region',
|
||||
'No feed here yet · 16 regions available',
|
||||
'Fly below 48 km to a covered city',
|
||||
'No bike share here yet · 61 systems available',
|
||||
];
|
||||
|
||||
test('en: coverage notes show as they came', () => {
|
||||
setLocale('en');
|
||||
for (const text of COVERAGE_NOTES)
|
||||
assert.equal(translateStatusMessage(text), text);
|
||||
assert.equal(
|
||||
translateStatusMessage('No bike share here yet · 1 systems available'),
|
||||
'No bike share here yet · 1 system available',
|
||||
);
|
||||
});
|
||||
|
||||
for (const locale of ['es', 'ru', 'ja']) {
|
||||
test(`${locale}: a coverage note translates whole, numbers kept`, () => {
|
||||
inLocale(locale, () => {
|
||||
for (const text of COVERAGE_NOTES) {
|
||||
const shown = translateStatusMessage(text);
|
||||
assert.doesNotMatch(
|
||||
shown,
|
||||
/\b(Fly|below|covered|feed|bike share|available)\b/,
|
||||
shown,
|
||||
);
|
||||
const number = /[\d,]+(?= km| regions| systems)/.exec(text)[0];
|
||||
assert.ok(
|
||||
shown.replace(/[^\d]/g, '').includes(number.replace(/,/g, '')),
|
||||
shown,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test(`${locale}: a status message without a coverage note keeps its other forms`, () => {
|
||||
inLocale(locale, () => {
|
||||
assert.equal(
|
||||
translateStatusMessage('Upstream response too large'),
|
||||
t('Upstream response too large'),
|
||||
);
|
||||
assert.equal(
|
||||
translateStatusMessage('2 feeds live · 14 heard'),
|
||||
translateStatusLine('2 feeds live · 14 heard'),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
+10
-13
@@ -7,7 +7,7 @@ import {
|
||||
ALLOCATION_TEST_FILES,
|
||||
UNIT_TEST_ROOTS,
|
||||
allocationTestArgs,
|
||||
assertNode24AllocationRuntime,
|
||||
assertCalibratedAllocationRuntime,
|
||||
buildUnitTestPlan,
|
||||
discoverUnitTestFiles,
|
||||
isCalibratedAllocationRuntime,
|
||||
@@ -87,30 +87,27 @@ test('the runner discovers every test file in the repository', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('allocation runtime calibration is explicit and pinned to Node 24', () => {
|
||||
assert.equal(assertNode24AllocationRuntime('24.19.0'), '24.19.0');
|
||||
test('allocation runtime calibration covers both supported Node majors', () => {
|
||||
assert.equal(assertCalibratedAllocationRuntime('24.19.0'), '24.19.0');
|
||||
assert.throws(
|
||||
() => assertNode24AllocationRuntime('22.23.1'),
|
||||
/calibrated Node 24 runtime/,
|
||||
);
|
||||
assert.throws(
|
||||
() => assertNode24AllocationRuntime('26.0.0'),
|
||||
/calibrated Node 24 runtime/,
|
||||
() => assertCalibratedAllocationRuntime('22.23.1'),
|
||||
/calibrated Node 24 or 26/,
|
||||
);
|
||||
assert.equal(assertCalibratedAllocationRuntime('26.0.0'), '26.0.0');
|
||||
assert.equal(isCalibratedAllocationRuntime('24.19.0'), true);
|
||||
assert.equal(isCalibratedAllocationRuntime('22.23.1'), false);
|
||||
assert.equal(isCalibratedAllocationRuntime('26.3.0'), false);
|
||||
assert.equal(isCalibratedAllocationRuntime('26.3.0'), true);
|
||||
});
|
||||
|
||||
test('npm test stays green on every supported engine, not only the calibrated one', () => {
|
||||
test('npm test calibrates both supported engines while retaining off-range warnings', () => {
|
||||
// package.json wiring: `npm test` must invoke this runner, and the engines
|
||||
// range it advertises must not be narrower than what the runner tolerates.
|
||||
const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
|
||||
assert.equal(pkg.scripts.test, 'node scripts/run-unit-tests.mjs');
|
||||
const enginesNode = String(pkg.engines?.node || '');
|
||||
assert.ok(enginesNode, 'engines.node must be declared');
|
||||
// The runner throws for uncalibrated runtimes ONLY behind the explicit
|
||||
// opt-in env; by default it skips, so a supported non-24 engine cannot fail.
|
||||
// Unsupported runtimes only skip probes without the explicit opt-in hard gate.
|
||||
// Node 26 is calibrated and runs those probes with the same budgets.
|
||||
const runner = readFileSync(new URL('../scripts/run-unit-tests.mjs', import.meta.url), 'utf8');
|
||||
assert.match(runner, /GEV_REQUIRE_ALLOCATION_GATE/);
|
||||
assert.match(runner, /SKIPPED .*allocation microbenchmarks/);
|
||||
|
||||
@@ -70,7 +70,7 @@ const cases = [
|
||||
|
||||
// Transit's coverage text when nothing is in view (transit/queries.js).
|
||||
const transitCoverage = [
|
||||
'transit: Fly below 3,000 km to a covered region',
|
||||
'transit: Fly below 2,850 km to a covered region',
|
||||
'transit: No feed here yet · 12 regions available',
|
||||
];
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ import {
|
||||
} from '../i18n/index.js';
|
||||
import { layerNameForId, sourceLabel } from '../ui/layerNames.js';
|
||||
import { localizePlaceName } from '../ui/placeNames.js';
|
||||
import { translateStatusLine } from '../ui/statusText.js';
|
||||
import { translateStatusMessage } from '../ui/statusText.js';
|
||||
import { formatQuantity } from '../ui/unitLabels.js';
|
||||
|
||||
/** Split "a; b" caveat text into separate notes. */
|
||||
@@ -397,27 +397,6 @@ const NOTE_PATTERNS = [
|
||||
'Contacts panel snapshot — the same retained cohort supplies the card, speech, and follow-up memory',
|
||||
)) + period,
|
||||
],
|
||||
// Transit's empty note when no feed covers the view (transit/queries.js);
|
||||
// the model gets the English.
|
||||
[
|
||||
/^Fly below ([\d,]+) km to a covered region$/,
|
||||
([, km]) =>
|
||||
t('Fly below {km} km to a covered region', {
|
||||
km: formatNumber(Number(km.replace(/,/g, ''))),
|
||||
}),
|
||||
],
|
||||
[
|
||||
/^No feed here yet · ([\d,]+) regions available$/,
|
||||
([, regions]) => {
|
||||
const count = Number(regions.replace(/,/g, ''));
|
||||
return tn(
|
||||
count,
|
||||
'No feed here yet · {n} region available',
|
||||
'No feed here yet · {n} regions available',
|
||||
{ n: formatNumber(count) },
|
||||
);
|
||||
},
|
||||
],
|
||||
[
|
||||
/^([a-z0-9-]+) (off|loading|error)$/,
|
||||
([, key, status]) =>
|
||||
@@ -444,10 +423,10 @@ function shownNote(text) {
|
||||
const shown = match ? show(match) : null;
|
||||
if (shown) return shown;
|
||||
}
|
||||
// A layer's empty note can be its status line, English pieces joined by
|
||||
// " · " ("feeds 1090, 978 stale · 0 heard", "TTC 0 (95s old)").
|
||||
const known = translateKnown(text);
|
||||
return known !== text ? known : translateStatusLine(text);
|
||||
// A layer's empty note can be its coverage note ("Fly below 2,850 km to a
|
||||
// covered region") or its status line, English pieces joined by " · "
|
||||
// ("feeds 1090, 978 stale · 0 heard", "TTC 0 (95s old)").
|
||||
return translateStatusMessage(text);
|
||||
}
|
||||
|
||||
/** Feed states as the card's chips show them; the values stay English. */
|
||||
|
||||
Reference in new issue
Block a user