Compare commits

...
Author SHA1 Message Date
German Khokhlov ea76fc4bc0 Fold duplicate coverage tests into their neighbours 2026-11-06 22:01:39 +04:00
German Khokhlov 5c4918affb Tighten coverage prompt comments and docs 2026-11-06 20:20:11 +04:00
German Khokhlov 3a78f40024 Merge main into german/transit-bikeshare-empty-state
Conflict in src/layers/transit/queries.js: keep the branch's shared note with the gate's enter altitude and main's distinct region count.
2026-11-06 20:00:58 +04:00
Said Turksever 3d6423c6f8 Merge pull request #870 from waar19/perf/fleet-tick-skip-hidden
perf(flights): skip dead-reckoning contacts that stay behind the horizon
2026-11-06 15:03:33 +00:00
Said Turksever 15fdf65a73 Merge branch 'main' into perf/fleet-tick-skip-hidden 2026-11-06 14:52:56 +00:00
Said Turksever 141181f83a docs(flights): changelog names the one-in-13-ticks rotation, about once a second 2026-11-06 12:02:30 +00:00
Said Turksever 0dbbd9047e Merge remote-tracking branch 'upstream/main' into HEAD 2026-11-06 11:54:29 +00:00
German Khokhlov 697b6b59e6 Repaint coverage rows after loads, nudges and cleared failures 2026-11-06 14:47:47 +04:00
German Khokhlov 90db4eb2c9 Name the real gate altitude and repaint coverage rows at once
The Transit prompt said "Fly below 3,000 km", but the closed gate opens at
2,850 km; it now names the enter threshold, as Bike Share does. When the
camera changes coverage, Transit and Bike Share ask the Layers panel to
repaint instead of leaving the old prompt up until the next 15 s / 60 s tick.
2026-11-05 21:21:03 +04:00
German Khokhlov 25c33893f6 Add the changelog entry for #1576 2026-11-05 12:53:09 +04:00
German Khokhlov b6ad32009f Say why Transit and Bike Share rows are empty outside coverage
Over a city with no feed or system (London, Tokyo) both rows read
"ON · — · never", the same as a feed that never answered. Transit already
computed a coverage note but put it only in `coverage`, which the row does
not show; Bike Share reported nothing at all.

Both layers now report a `zoom-in` guidance status with a `statusMessage`:
"Fly below 3,000 km to a covered region" / "No feed here yet · N regions
available" for Transit, and "Fly below 48 km to a covered city" / "No bike
share here yet · N systems available" for Bike Share. A loading or failed
Bike Share city keeps its own text. Leaving coverage clears the layer's
last-update time, so the row no longer dates data it no longer draws.

The coverage-note translations move from the voice card into
`translateStatusMessage` (ui/statusText.js), which the Layers row and the
voice card now share.
2026-11-05 12:52:28 +04:00
Said Turksever 2c936ae131 docs: correct hidden-contact rotation interval 2026-11-03 16:26:45 +00:00
Said Turksever b68cd25e9e Merge commit '2ebde97fb3af19402eab83fca2bfbec9ddfd166a' into easy-wins-20261103-pr870 2026-11-03 16:17:34 +00:00
Said Turksever 66a5c483c2 fix: resolve review findings and refresh PR #870 against main 2026-11-03 16:17:31 +00:00
Wilmer AlzateandClaude Opus 5.5 81f49406b8 perf(flights): skip dead-reckoning contacts that stay behind the horizon
The fleet tick dead-reckoned, floored and distance-tested every contact on
every ~80 ms tick, then hid the ones behind the horizon. The worldwide feed
carries ~13k contacts; zoomed in, well under 1% are on screen, so nearly all
of that work produced a hidden billboard.

A contact that is already hidden and still behind the horizon at its last
position now re-reckons only on its turn in a staggered one-in-12-ticks
rotation (about once a second, HIDDEN_CONTACT_DR_TICKS). Anything that
position puts in view, and any contact with a fleet model, takes the full
pass immediately, so on-screen behaviour is unchanged. Hidden-inclusive
proximity queries (getNearby includeHidden, getAllPositions) read positions
at most about a second old, well inside the 30 s poll's uncertainty.

Measured in Chrome on a Radeon 780M, ~12.9k contacts, 56 on screen over
Colombia at 4 km, same view before and after:
  fleet tick   7.66 -> 2.03 ms/frame, longest tick 50.9 -> ~16 ms
  frame        20.6 -> 14.0 ms, 42.9 -> 57.8 FPS
Teleporting the camera to Madrid showed 975 contacts within 100 ms.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-30 12:31:59 -05:00
38 changed files with 1134 additions and 57 deletions

No files matched your search

@@ -0,0 +1,4 @@
- Say why Transit and Bike Share are empty outside coverage. Over a city with
no feed or system (London, Tokyo) their Layers rows say so and count the
covered regions or systems, or say how low to fly, instead of `never`;
leaving coverage clears the last-update time (German, @gekh, #1576).
+4
View File
@@ -0,0 +1,4 @@
- Reduce dead-reckoning work for flights already hidden behind the horizon.
Hidden contacts update on a staggered one-in-13-ticks rotation (about once a
second) while visible contacts and contacts with fleet models retain the full
update path (Wilmer Alzate, #870).
+12 -1
View File
@@ -1975,6 +1975,17 @@ Mapped-installation zoom guidance appears in the layer row without counting as
a global loading failure. Guidance statuses do not suppress independent refresh
errors.
Transit and Bike Share say the same when no feed or system covers the view,
instead of an undated `never`. Above their altitude gates the rows read
`GTFS-RT · Fly below 2,850 km to a covered region` and
`GBFS · Fly below 48 km to a covered city`; lower, over a city neither covers
(London, Tokyo), `GTFS-RT · No feed here yet · N regions available` and
`GBFS · No bike share here yet · N systems available` (covered regions,
catalogued systems). The chip stays ON. A loading or failed Bike Share city
shows its own text. Leaving coverage clears the last-update time. The voice
analyst gets the same English notes; both translate them through
`translateStatusMessage` (`src/ui/statusText.js`).
## Places and CCTV request bounds
With a Google key configured, nearby and text search reject missing, blank,
@@ -5350,7 +5361,7 @@ silently demoting every later lookup for the session.
### Motion & Symbology Correctness (June 10, 2026)
- **Flights (commercial + military)** render one poll interval behind real time (30s/15s) and interpolate between two known feed-stamped fixes (OpenSky `time_position`; adsb.lol `receipt − seen_pos`). The display latency is an intentional product decision — do not "fix" it away. The whole fleet dead-reckons at ~12Hz (1m² write gating); aircraft get a 3-poll grace period (faded icon) before removal. When a position epoch pauses, both layers coast for at least 60 seconds of contact grace with an absolute five-minute ceiling. Source backoff marks each contact and the cockpit `STALE`; the cockpit then holds the exact layer position instead of continuing inertial flight. Repeated-position kinematic changes create a forward-only synthetic fix rather than mutating history, and grounded history is lifted only when no owned 3D model already controls its datum. A nominally successful worldwide OpenSky response whose own snapshot epoch is more than two minutes old prefers the existing 250-nm viewport-scoped adsb.lol fallback and labels the source/coverage accordingly when that upstream is available. If the fallback is also unavailable, the layer's freshness/error fields use the source epoch—never the cache receipt time—so the UI reports an old snapshot rather than “just now.”
- **Flights (commercial + military)** render one poll interval behind real time (30s/15s) and interpolate between two known feed-stamped fixes (OpenSky `time_position`; adsb.lol `receipt − seen_pos`). The display latency is an intentional product decision — do not "fix" it away. The fleet dead-reckons at ~12Hz (1m² write gating); a contact already hidden behind the horizon whose last position still is re-reckons on a staggered one-in-13-ticks rotation (about once a second) instead, so the worldwide feed's ~13k contacts no longer cost a full pass when well under 1% are on screen. Anything that position puts in view, and any contact with a fleet model, takes the full pass immediately, and hidden-inclusive proximity queries read positions at most about a second old; aircraft get a 3-poll grace period (faded icon) before removal. When a position epoch pauses, both layers coast for at least 60 seconds of contact grace with an absolute five-minute ceiling. Source backoff marks each contact and the cockpit `STALE`; the cockpit then holds the exact layer position instead of continuing inertial flight. Repeated-position kinematic changes create a forward-only synthetic fix rather than mutating history, and grounded history is lifted only when no owned 3D model already controls its datum. A nominally successful worldwide OpenSky response whose own snapshot epoch is more than two minutes old prefers the existing 250-nm viewport-scoped adsb.lol fallback and labels the source/coverage accordingly when that upstream is available. If the fallback is also unavailable, the layer's freshness/error fields use the source epoch—never the cache receipt time—so the UI reports an old snapshot rather than “just now.”
- **World-space headings at every angle** (`src/data/iconOrientation.js`): aircraft/vessel icon rotation uses the camera right/up basis per tick (alignedAxis always ZERO), which is exact at screen center and for orthographic/nadir views and remains stable through >180° tracked orbits. Perspective rays vary across the viewport, so off-center contacts at oblique pitch can diverge from an exact finite-difference window projection; a regression test pins that known regime, and field evidence decides whether to adopt exact projection with the basis method as fallback. Fleet rotations refresh on camera-pose change; tracked entities per frame. Billboards are horizon-culled via a shared EllipsoidalOccluder.
- **Military/OpenSky reconciliation** (`src/data/militaryRegistry.js`): known-military ICAOs render amber in the flights layer (60s self-poll of the cached mil endpoint when the military layer is off) and are suppressed there while the military layer renders them.
- **Satellites**: 838-sat core catalog (stations/visual/GPS/GLONASS/Galileo/GEO), tracking lands ~726km out via `viewFrom` (tracked entity owns a point graphic so the tracking camera engages), rings realign via primitive modelMatrix (no per-second rebuild flicker), optional `setParams({catalog:'dense'})` Starlink mode.
+1
View File
@@ -347,6 +347,7 @@
"src/layers/flights/tracking.js",
"src/layers/flights/rendering.js",
"src/layers/flights/testing.js",
"src/layers/flights/hiddenContactRotation.test.mjs",
"src/layers/flights/evidence.js",
"src/layers/flights/ingestion.js",
"src/layers/flights/queries.js",
+17 -1
View File
@@ -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 {
-10
View File
@@ -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);
}
+25
View File
@@ -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 = {
+9
View File
@@ -2918,6 +2918,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": "جزئي",
+5
View File
@@ -2668,6 +2668,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",
+6
View File
@@ -2827,6 +2827,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",
+6
View File
@@ -2537,6 +2537,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",
+6
View File
@@ -2537,6 +2537,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",
+4
View File
@@ -2407,6 +2407,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": "部分的",
+6
View File
@@ -2539,6 +2539,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",
+7
View File
@@ -2890,6 +2890,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 + потоки декодера",
+7
View File
@@ -2601,6 +2601,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": "частково",
+4
View File
@@ -2308,6 +2308,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": "部分可用",
+4
View File
@@ -2296,6 +2296,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": "部分可用",
+37 -4
View File
@@ -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...', {
+1
View File
@@ -169,6 +169,7 @@ export function createLifecycle({
viewer.scene.primitives.remove(layerState._pointCollection);
layerState._pointCollection = null;
}
layerState._dataManager = null;
layerState._viewer = null;
},
};
+366
View File
@@ -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`);
}
});
+4
View File
@@ -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;
+25 -3
View File
@@ -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. */
@@ -0,0 +1,163 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { createFlightState } from './state.js';
import { createRendering } from './rendering.js';
import {
FLEET_DR_INTERVAL_MS,
HIDDEN_CONTACT_DR_INTERVAL_MS,
HIDDEN_CONTACT_DR_TICKS,
hiddenContactDrPhase,
} from './policy.js';
// Contacts hidden behind the horizon re-reckon on one fleet tick per rotation
// instead of every tick. Their positions still feed hidden-inclusive proximity
// queries, so the rotation must reach every contact about once a second, and
// it must spread them across ticks so no single tick carries the whole fleet.
const icao = (n) => n.toString(16).padStart(6, '0');
test('the rotation revisits each hidden contact about once a second', () => {
const periodMs = HIDDEN_CONTACT_DR_TICKS * FLEET_DR_INTERVAL_MS;
assert.ok(HIDDEN_CONTACT_DR_TICKS > 1);
assert.ok(
Math.abs(periodMs - HIDDEN_CONTACT_DR_INTERVAL_MS) <= FLEET_DR_INTERVAL_MS,
);
});
test('a contact keeps the same slot, inside the rotation', () => {
for (let n = 0; n < 500; n++) {
const phase = hiddenContactDrPhase(icao(n * 7919));
assert.ok(Number.isInteger(phase));
assert.ok(phase >= 0 && phase < HIDDEN_CONTACT_DR_TICKS);
assert.equal(hiddenContactDrPhase(icao(n * 7919)), phase);
}
});
test('a worldwide fleet is spread evenly enough that no tick carries a burst', () => {
const fleet = 13000;
const perSlot = new Array(HIDDEN_CONTACT_DR_TICKS).fill(0);
// Real ICAO24 addresses cluster by national block; sample several blocks.
for (let n = 0; n < fleet; n++)
perSlot[hiddenContactDrPhase(icao(0x0a0000 + n * 53))]++;
const fair = fleet / HIDDEN_CONTACT_DR_TICKS;
for (const count of perSlot) assert.ok(count < fair * 1.5, `${count} > 1.5×`);
});
/** Real fleet tick and horizon culling, with motion and presentation injected. */
function fleetFixture() {
let now = 1000;
let reckonings = 0;
const services = {
groundSnap: { createGroundSnap: () => ({ clear() {} }) },
militaryRegistry: { isMilitaryIcao: () => false },
aircraftPresentation: { isTr3b: () => false },
camera: {},
focus: {
focusNowMs: () => now,
getFocusTarget: () => null,
nearFarScalarValueAtDistance: () => 1,
advanceProjectedSpriteFocus: () => ({ factor: 1 }),
},
recession: {
applyAircraftBillboardTreatment: () => ({
factors: { scale: 1 },
alpha: 1,
}),
},
};
const flightState = createFlightState({ services });
const camera = {
positionWC: Cesium.Cartesian3.fromDegrees(0, 0, 100),
positionCartographic: { height: 100 },
heading: 0,
pitch: 0,
roll: 0,
frustum: {
computeCullingVolume: () => ({
computeVisibility: () => Cesium.Intersect.INSIDE,
}),
},
};
const id = 'abc123';
// 111 km away at ground level is beyond a 100 m camera's horizon, but
// inside the model keep radius. No WebGL scene is needed for the culler.
const billboard = {
show: false,
position: Cesium.Cartesian3.fromDegrees(1, 0, 0),
};
flightState._viewer = { camera, scene: {} };
flightState._billboardCollection = { show: true };
flightState._billboards.set(id, billboard);
flightState.records.data.set(id, {});
flightState._models3dEnabled = false;
// Far cockpit contacts avoid glyph rasterization, keeping the fixture DOM-free.
flightState._cockpitContactMode = true;
const parts = {
tracking: { _refreshCockpitNearContacts() {} },
motion: {
_deadReckon() {
reckonings++;
return billboard.position;
},
_floorGroundedDisplayPosition(id, info, position) {
return position;
},
},
};
const rendering = createRendering({ flightState, services, parts });
return {
flightState,
camera,
billboard,
id,
reckonings: () => reckonings,
tick() {
now += FLEET_DR_INTERVAL_MS;
rendering._fleetTick();
},
offTurn() {
flightState._fleetTickSeq =
(hiddenContactDrPhase(id) + 1) % HIDDEN_CONTACT_DR_TICKS;
},
};
}
test('the fleet tick dead-reckons a hidden contact once per complete rotation', () => {
const f = fleetFixture();
for (let i = 0; i < HIDDEN_CONTACT_DR_TICKS; i++) f.tick();
assert.equal(f.reckonings(), 1);
assert.equal(f.billboard.show, false);
});
test('a camera reveal bypasses the hidden rotation on the same fleet tick', () => {
const f = fleetFixture();
f.offTurn();
f.tick();
assert.equal(f.reckonings(), 0);
f.camera.positionWC = Cesium.Cartesian3.fromDegrees(1, 0, 100);
f.offTurn();
f.tick();
assert.equal(f.reckonings(), 1);
assert.equal(f.billboard.show, true);
});
test('a hidden contact with a retained fleet model is never rotation-skipped', () => {
const f = fleetFixture();
f.flightState._models3dEnabled = true;
const model = { ready: true, show: true };
f.flightState._models.set(f.id, model);
f.offTurn();
f.tick();
assert.equal(f.reckonings(), 1);
assert.equal(
f.flightState._models.get(f.id),
model,
'model remains in keep range',
);
assert.equal(
model.show,
false,
'the full pass hides its model beyond the horizon',
);
});
+31
View File
@@ -198,6 +198,37 @@ export const FLEET_DR_INTERVAL_MS = 80;
export const ROTATION_REFRESH_MS = 1000;
/**
* @constant {number} Dead-reckoning cadence (ms) for contacts hidden behind the
* horizon. The worldwide feed carries ~13k contacts and a zoomed-in view shows
* well under 1% of them; the rest still need positions for hidden-inclusive
* proximity queries, but ~1 s old (≈250 m for an airliner) is well inside the
* 30 s poll's own uncertainty. Each hidden contact is re-reckoned on one tick in
* every HIDDEN_CONTACT_DR_TICKS, staggered by id so no tick carries them all.
*/
export const HIDDEN_CONTACT_DR_INTERVAL_MS = 1000;
export const HIDDEN_CONTACT_DR_TICKS = Math.max(
1,
Math.round(HIDDEN_CONTACT_DR_INTERVAL_MS / FLEET_DR_INTERVAL_MS),
);
/**
* Stable slot in [0, HIDDEN_CONTACT_DR_TICKS) for a contact id, so each hidden
* contact re-reckons on the same tick of every rotation.
* @param {string} id Contact id (ICAO24).
* @returns {number}
*/
export function hiddenContactDrPhase(id) {
const text = String(id);
let hash = 0;
for (let i = 0; i < text.length; i++)
hash = (hash * 31 + text.charCodeAt(i)) >>> 0;
return hash % HIDDEN_CONTACT_DR_TICKS;
}
/** Max course slew (deg/s) — well above real turns (≤4°/s), hides fix-boundary
* steps. Scaled down toward COURSE_MIN_DPS at low speed (courseSlewCapDps). */
+24
View File
@@ -36,6 +36,8 @@ import {
COURSE_SLEW_DT_MAX_SEC,
ROTATION_REFRESH_MS,
COURSE_MAX_DPS,
HIDDEN_CONTACT_DR_TICKS,
hiddenContactDrPhase,
} from './policy.js';
import { AIRCRAFT_ENVIRONMENT_MAP_OPTIONS } from '../../data/aircraftModelLighting.js';
@@ -839,6 +841,12 @@ export function createRendering({
}
}
/** The contact's hidden-rotation slot, cached on its billboard. */
function _hiddenDrPhase(bb, icao24) {
bb._gevHiddenDrPhase ??= hiddenContactDrPhase(icao24);
return bb._gevHiddenDrPhase;
}
function _fleetTick() {
if (
!flightState._viewer ||
@@ -965,9 +973,25 @@ export function createRendering({
for (const icao of toRelease) _releaseModel(icao);
}
const hiddenDrPhase = flightState._fleetTickSeq++ % HIDDEN_CONTACT_DR_TICKS;
for (const [icao24, bb] of flightState._billboards) {
if (icao24 === flightState._trackedIcao) continue; // tracked entity owns its own motion
// A contact still behind the horizon at its last position would only be
// re-reckoned to be hidden again, so off its turn it keeps that position
// (see HIDDEN_CONTACT_DR_TICKS). Anything the stale position puts in view,
// and any contact with a fleet model, takes the full pass below at once.
if (
!bb.show &&
_hiddenDrPhase(bb, icao24) !== hiddenDrPhase &&
!flightState._models.has(icao24) &&
!occluder.isPointVisible(
flightState._cullPositions.get(icao24) || bb.position,
)
)
continue;
const info = flightState.records.data.get(icao24);
const dr = parts.motion._deadReckon(
+4
View File
@@ -204,6 +204,10 @@ export function createFlightState({ source, services }) {
flightState._lastFleetTickMs = 0;
/** @type {number} Fleet ticks run; selects which hidden contacts re-reckon */
flightState._fleetTickSeq = 0;
/** @type {string} Camera pose signature at the last rotation pass */
flightState._lastCamPoseSig = '';
+51
View File
@@ -3982,3 +3982,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',
);
});
+8 -6
View File
@@ -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/);
});
+1 -1
View File
@@ -112,7 +112,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;
+8
View File
@@ -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() {
+6 -3
View File
@@ -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) {
+54
View File
@@ -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');
}
});
+61
View File
@@ -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);
}
+57 -1
View File
@@ -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'),
);
});
});
}
+1 -1
View File
@@ -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',
];
+5 -26
View File
@@ -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. */