mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-07 03:44:00 +00:00
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c06eb89190 | ||
|
|
ea76fc4bc0 | ||
|
|
5c4918affb | ||
|
|
3a78f40024 | ||
|
|
697b6b59e6 | ||
|
|
90db4eb2c9 | ||
|
|
25c33893f6 | ||
|
|
b6ad32009f |
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).
|
||||
@@ -1980,6 +1980,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,
|
||||
|
||||
+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 = {
|
||||
|
||||
@@ -2920,6 +2920,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": "جزئي",
|
||||
|
||||
@@ -2670,6 +2670,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",
|
||||
|
||||
@@ -2829,6 +2829,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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "部分的",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -2892,6 +2892,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 + потоки декодера",
|
||||
|
||||
@@ -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": "частково",
|
||||
|
||||
@@ -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": "部分可用",
|
||||
|
||||
@@ -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": "部分可用",
|
||||
|
||||
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. */
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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'),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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