Merge pull request #1576 from bilawalsidhu/german/transit-bikeshare-empty-state

Say why Transit and Bike Share rows are empty outside coverage
This commit is contained in:
Said Turksever authored and GitHub committed 2026-11-09 15:48:34 +00:00
commit 0dbc657bf9
32 files changed
+906 -56

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).
+11
View File
@@ -2284,6 +2284,17 @@ Mapped-installation zoom guidance appears in the layer row without counting as
a global loading failure. Guidance statuses do not suppress independent refresh a global loading failure. Guidance statuses do not suppress independent refresh
errors. 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 ## Places and CCTV request bounds
With a Google key configured, nearby and text search reject missing, blank, With a Google key configured, nearby and text search reject missing, blank,
+17 -1
View File
@@ -2385,11 +2385,27 @@ export class LayerLifecycle {
/** /**
* Ask presentation to repaint layer rows now, for data that lands outside * Ask presentation to repaint layer rows now, for data that lands outside
* the manager tick (Transit's proximity polls, Directions' route steps). * 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' }); 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) { _publishActivity(change) {
for (const callback of this._activityListeners) { for (const callback of this._activityListeners) {
try { try {
-10
View File
@@ -15,16 +15,6 @@ export class DataLayerManager extends LayerLifecycle {
this._presentation.refresh(); 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) { _buildMetaText(layer) {
return this._presentation.panel._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']); 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) => { test('periodic polls pause hidden, catch up once when due, and dispose visibility listeners', async (t) => {
const listeners = new Set(); const listeners = new Set();
const doc = { const doc = {
+9
View File
@@ -3005,6 +3005,15 @@
"many": "لا مصدر بيانات هنا بعد · {n} منطقة متاحة", "many": "لا مصدر بيانات هنا بعد · {n} منطقة متاحة",
"other": "لا مصدر بيانات هنا بعد · {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": "متدهور", "degraded": "متدهور",
"stale": "متقادم", "stale": "متقادم",
"partial": "جزئي", "partial": "جزئي",
+5
View File
@@ -2751,6 +2751,11 @@
"one": "Hier noch kein Feed · {n} Region verfügbar", "one": "Hier noch kein Feed · {n} Region verfügbar",
"other": "Hier noch kein Feed · {n} Regionen 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", "degraded": "eingeschränkt",
"stale": "veraltet", "stale": "veraltet",
"partial": "teilweise", "partial": "teilweise",
+6
View File
@@ -2915,6 +2915,12 @@
"many": "Aún no hay fuente aquí · {n} de regiones disponibles", "many": "Aún no hay fuente aquí · {n} de regiones disponibles",
"other": "Aún no hay fuente aquí · {n} 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", "Wind speed field unavailable": "Campo de velocidad del viento no disponible",
"Decoder feeds": "Fuentes del decodificador", "Decoder feeds": "Fuentes del decodificador",
"WebUSB + decoder feeds": "WebUSB + fuentes del decodificador", "WebUSB + decoder feeds": "WebUSB + fuentes del decodificador",
+6
View File
@@ -2584,6 +2584,12 @@
"many": "Pas encore de flux ici · {n} de régions disponibles", "many": "Pas encore de flux ici · {n} de régions disponibles",
"other": "Pas encore de flux ici · {n} 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é", "degraded": "dégradé",
"stale": "obsolète", "stale": "obsolète",
"partial": "partiel", "partial": "partiel",
+6
View File
@@ -2584,6 +2584,12 @@
"many": "Ancora nessun feed qui · {n} di regioni disponibili", "many": "Ancora nessun feed qui · {n} di regioni disponibili",
"other": "Ancora nessun feed qui · {n} 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", "degraded": "degradato",
"stale": "obsoleto", "stale": "obsoleto",
"partial": "parziale", "partial": "parziale",
+4
View File
@@ -2454,6 +2454,10 @@
"No feed here yet · {n} regions available": { "No feed here yet · {n} regions available": {
"other": "ここにはまだフィードがありません · 利用可能な地域 {n}か所" "other": "ここにはまだフィードがありません · 利用可能な地域 {n}か所"
}, },
"Fly below {km} km to a covered city": "{km} km未満まで高度を下げ、カバー範囲内の都市へ移動してください",
"No bike share here yet · {n} systems available": {
"other": "ここにはまだシェアサイクルがありません · 利用可能なシステム {n}件"
},
"degraded": "性能低下", "degraded": "性能低下",
"stale": "更新遅延", "stale": "更新遅延",
"partial": "部分的", "partial": "部分的",
+6
View File
@@ -2586,6 +2586,12 @@
"many": "Ainda não há feed aqui · {n} de regiões disponíveis", "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" "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", "degraded": "degradado",
"stale": "desatualizado", "stale": "desatualizado",
"partial": "parcial", "partial": "parcial",
+7
View File
@@ -2979,6 +2979,13 @@
"many": "Здесь пока нет потока данных · доступно {n} регионов", "many": "Здесь пока нет потока данных · доступно {n} регионов",
"other": "Здесь пока нет потока данных · доступно {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": "Поле скорости ветра недоступно", "Wind speed field unavailable": "Поле скорости ветра недоступно",
"Decoder feeds": "Потоки декодера", "Decoder feeds": "Потоки декодера",
"WebUSB + decoder feeds": "WebUSB + потоки декодера", "WebUSB + decoder feeds": "WebUSB + потоки декодера",
+7
View File
@@ -2648,6 +2648,13 @@
"many": "Тут поки немає потоку даних · доступно {n} регіонів", "many": "Тут поки немає потоку даних · доступно {n} регіонів",
"other": "Тут поки немає потоку даних · доступно {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": "з перебоями", "degraded": "з перебоями",
"stale": "застарів", "stale": "застарів",
"partial": "частково", "partial": "частково",
+4
View File
@@ -2355,6 +2355,10 @@
"No feed here yet · {n} regions available": { "No feed here yet · {n} regions available": {
"other": "此处暂无数据源 · 有 {n} 个覆盖区域" "other": "此处暂无数据源 · 有 {n} 个覆盖区域"
}, },
"Fly below {km} km to a covered city": "请降至 {km} km 以下并飞往有覆盖的城市",
"No bike share here yet · {n} systems available": {
"other": "此处暂无共享单车 · 有 {n} 个可用系统"
},
"degraded": "降级", "degraded": "降级",
"stale": "过期", "stale": "过期",
"partial": "部分可用", "partial": "部分可用",
+4
View File
@@ -2343,6 +2343,10 @@
"No feed here yet · {n} regions available": { "No feed here yet · {n} regions available": {
"other": "此處暫無數據源 · 有 {n} 個覆蓋區域" "other": "此處暫無數據源 · 有 {n} 個覆蓋區域"
}, },
"Fly below {km} km to a covered city": "請降至 {km} km 以下並飛往有覆蓋的城市",
"No bike share here yet · {n} systems available": {
"other": "此處暫無共享單車 · 有 {n} 個可用系統"
},
"degraded": "降級", "degraded": "降級",
"stale": "過期", "stale": "過期",
"partial": "部分可用", "partial": "部分可用",
+37 -4
View File
@@ -1,5 +1,6 @@
import { msg, t } from '../../i18n/index.js'; 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 }) { export function createControls({ state: layerState, services, parts, source }) {
const methods = { const methods = {
@@ -13,6 +14,14 @@ export function createControls({ state: layerState, services, parts, source }) {
updateInterval: STATUS_POLL_MS, 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. * Return a sampled array of detectable station objects for HUD overlay rendering.
* @param {Object} [options] - Sampling options (maxCount, seed). * @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. * 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() { 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 = { const stats = {
count: layerState._count, count: layerState._count,
lastUpdate: layerState._lastUpdate, 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 = stats.loadingLabel =
layerState._activeCityIds.size > 0 layerState._activeCityIds.size > 0
? t('syncing {count} city feeds...', { ? t('syncing {count} city feeds...', {
+1
View File
@@ -169,6 +169,7 @@ export function createLifecycle({
viewer.scene.primitives.remove(layerState._pointCollection); viewer.scene.primitives.remove(layerState._pointCollection);
layerState._pointCollection = null; layerState._pointCollection = null;
} }
layerState._dataManager = null;
layerState._viewer = 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; 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. */ /** @type {Cesium.PointPrimitiveCollection|null} Primitive collection for station dots. */
layerState._pointCollection = null; layerState._pointCollection = null;
+25 -3
View File
@@ -116,6 +116,11 @@ export function createViewport({ state: layerState, services, parts, source }) {
parts.rendering.removeCityPoints(cityId); 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. */ /** Deactivate all currently active cities and clear the active set. */
function deactivateAllCities() { 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. // A failure belongs to a city in range; none are once everything is off.
layerState._failedCityIds.clear(); layerState._failedCityIds.clear();
layerState._count = 0; 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 generation = ++layerState._proximityGeneration;
const altitude = getCameraAltitude(layerState._viewer); const altitude = getCameraAltitude(layerState._viewer);
const wasGateEnabled = layerState._altitudeGateEnabled;
// Altitude gate: disable all cities when camera is too high // Altitude gate: disable all cities when camera is too high
if (!shouldActivateAtAltitude(altitude)) { if (!shouldActivateAtAltitude(altitude)) {
deactivateAllCities(); deactivateAllCities();
if (wasGateEnabled) refreshRow();
return; return;
} }
@@ -220,15 +229,25 @@ export function createViewport({ state: layerState, services, parts, source }) {
} }
} }
// A failure never outlives its city's range. // A failure never outlives its city's range.
let failureCleared = false;
for (const cityId of layerState._failedCityIds) { 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; layerState._activeCityIds = nextActive;
if (layerState._activeCityIds.size === 0) { if (nextActive.size === 0) {
layerState._count = 0; layerState._count = 0;
return; layerState._lastUpdate = null;
} }
if (changed) refreshRow();
// Only activate cities that don't already have rendered points // Only activate cities that don't already have rendered points
const toActivate = []; const toActivate = [];
@@ -240,6 +259,9 @@ export function createViewport({ state: layerState, services, parts, source }) {
await Promise.all( await Promise.all(
toActivate.map((cityId) => activateCity(cityId, generation)), 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. */ /** Schedule a debounced proximity check after camera movement. */
+51
View File
@@ -3983,3 +3983,54 @@ test('parked transit reprojects held and reported courses at the bounded camera
'selected unknown course also stays screen-up', '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 { import {
ACTIVATION_ALTITUDE_M, ACTIVATION_ENTER_ALTITUDE_M,
FEED_STALE_AFTER_MS, FEED_STALE_AFTER_MS,
aggregateTransitFeedHealth, aggregateTransitFeedHealth,
transitDetectionClass, transitDetectionClass,
@@ -245,17 +245,19 @@ export function createQueries({ state, parts }) {
const health = aggregateTransitFeedHealth(statuses, now); const health = aggregateTransitFeedHealth(statuses, now);
const count = state._vehicles.size; const count = state._vehicles.size;
if (state._enabled && active.length === 0) { 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 { return {
count: 0, count: 0,
lastUpdate: state._lastUpdate, lastUpdate: state._lastUpdate,
error: null, error: null,
source: 'GTFS-RT', source: 'GTFS-RT',
status: 'zoom-in', status: 'zoom-in',
// English data (the analyst's empty note to the model); the voice statusMessage: note,
// card shows it through NOTE_PATTERNS (voice/resultDisplay.js). coverage: note,
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`,
}; };
} }
// Age is shown by the same rule the row state uses, so the text and the // 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
@@ -115,7 +115,7 @@ export function createState({ services }) {
state._cockpitVision = false; state._cockpitVision = false;
/** @type {Array<[string, EventListener]>} window listeners to remove on destroy. */ /** @type {Array<[string, EventListener]>} window listeners to remove on destroy. */
state._styleListeners = []; state._styleListeners = [];
/** @type {{ refreshLayerStats?: () => void }|null} */ /** @type {{ refreshLayerStats?: (layerId?: string) => void }|null} */
state._dataManager = null; state._dataManager = null;
/** @type {number|null} */ /** @type {number|null} */
state._lastUpdate = null; state._lastUpdate = null;
+8
View File
@@ -83,7 +83,9 @@ export function createViewport({ state, services, parts }) {
if (!state._enabled || !state._viewer) return; if (!state._enabled || !state._viewer) return;
refreshViewBounds(); refreshViewBounds();
const altitude = getCameraAltitude(); const altitude = getCameraAltitude();
const wasGateOpen = state._altitudeGateOpen;
state._altitudeGateOpen = altitudeGateOpen(altitude); state._altitudeGateOpen = altitudeGateOpen(altitude);
let changed = state._altitudeGateOpen !== wasGateOpen;
const center = state._altitudeGateOpen ? getCameraCenterLatLon() : null; const center = state._altitudeGateOpen ? getCameraCenterLatLon() : null;
const desired = new Map(); const desired = new Map();
if (center) { if (center) {
@@ -106,17 +108,23 @@ export function createViewport({ state, services, parts }) {
state._activeFeeds.delete(feedId); state._activeFeeds.delete(feedId);
state._feedStatus.delete(feedId); state._feedStatus.delete(feedId);
parts.ingestion.removeFeedVehicles(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) { for (const [feedId, feed] of desired) {
if (!state._activeFeeds.has(feedId)) { if (!state._activeFeeds.has(feedId)) {
state._activeFeeds.set(feedId, feed); state._activeFeeds.set(feedId, feed);
void parts.ingestion.pollFeed(feed, state._generation); void parts.ingestion.pollFeed(feed, state._generation);
changed = true;
} }
} }
parts.ingestion.sweepAgedVehicles(Date.now()); parts.ingestion.sweepAgedVehicles(Date.now());
parts.rendering.syncRenderHold(); parts.rendering.syncRenderHold();
governorRequestRender('transit-proximity'); governorRequestRender('transit-proximity');
// Repaint now: the row follows the camera, the manager tick is 15 s.
if (changed) state._dataManager?.refreshLayerStats?.('transit');
} }
function onCameraChanged() { function onCameraChanged() {
+6 -3
View File
@@ -11,14 +11,17 @@ import {
} from './layerNames.js'; } from './layerNames.js';
export { localizeLayerName } from './layerNames.js'; export { localizeLayerName } from './layerNames.js';
import { createWeatherPanel } from './weatherPanel.js'; import { createWeatherPanel } from './weatherPanel.js';
import { translateFeedError, translateStatusLine } from './statusText.js'; import {
translateFeedError,
translateStatusLine,
translateStatusMessage,
} from './statusText.js';
import { import {
formatNumber, formatNumber,
getLocale, getLocale,
msg, msg,
onLocaleChange, onLocaleChange,
t, t,
translateKnown,
} from '../i18n/index.js'; } from '../i18n/index.js';
// Feed-state values are machine ids (QA hooks, CSS feed-* classes); only the // Feed-state values are machine ids (QA hooks, CSS feed-* classes); only the
@@ -695,7 +698,7 @@ export class LayerPanel {
typeof stats.statusMessage === 'string' && typeof stats.statusMessage === 'string' &&
stats.statusMessage.trim() 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'); const ago = stats.lastUpdate ? this._timeAgo(stats.lastUpdate) : t('never');
if (stats.loading) { if (stats.loading) {
+54
View File
@@ -452,3 +452,57 @@ test('without the Utilities list, Directions stays in Data Layers under Utilitie
restore(); 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) .map(translateStatusPiece)
.join(STATUS_SEPARATOR); .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 assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import { setLocale, t } from '../i18n/index.js'; import { setLocale, t } from '../i18n/index.js';
import { translateFeedError, translateStatusLine } from './statusText.js'; import {
translateFeedError,
translateStatusLine,
translateStatusMessage,
} from './statusText.js';
function inLocale(locale, run) { function inLocale(locale, run) {
setLocale(locale); 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). // Transit's coverage text when nothing is in view (transit/queries.js).
const transitCoverage = [ 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', 'transit: No feed here yet · 12 regions available',
]; ];
+5 -26
View File
@@ -29,7 +29,7 @@ import {
} from '../i18n/index.js'; } from '../i18n/index.js';
import { layerNameForId, sourceLabel } from '../ui/layerNames.js'; import { layerNameForId, sourceLabel } from '../ui/layerNames.js';
import { localizePlaceName } from '../ui/placeNames.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'; import { formatQuantity } from '../ui/unitLabels.js';
/** Split "a; b" caveat text into separate notes. */ /** 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', 'Contacts panel snapshot — the same retained cohort supplies the card, speech, and follow-up memory',
)) + period, )) + 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)$/, /^([a-z0-9-]+) (off|loading|error)$/,
([, key, status]) => ([, key, status]) =>
@@ -444,10 +423,10 @@ function shownNote(text) {
const shown = match ? show(match) : null; const shown = match ? show(match) : null;
if (shown) return shown; if (shown) return shown;
} }
// A layer's empty note can be its status line, English pieces joined by // A layer's empty note can be its coverage note ("Fly below 2,850 km to a
// " · " ("feeds 1090, 978 stale · 0 heard", "TTC 0 (95s old)"). // covered region") or its status line, English pieces joined by " · "
const known = translateKnown(text); // ("feeds 1090, 978 stale · 0 heard", "TTC 0 (95s old)").
return known !== text ? known : translateStatusLine(text); return translateStatusMessage(text);
} }
/** Feed states as the card's chips show them; the values stay English. */ /** Feed states as the card's chips show them; the values stay English. */