From b6ad32009fec418d1a5bbff4aa18adb1fbf5cd4c Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Thu, 5 Nov 2026 12:52:28 +0400 Subject: [PATCH 1/6] Say why Transit and Bike Share rows are empty outside coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Over a city with no feed or system (London, Tokyo) both rows read "ON · — · never", the same as a feed that never answered. Transit already computed a coverage note but put it only in `coverage`, which the row does not show; Bike Share reported nothing at all. Both layers now report a `zoom-in` guidance status with a `statusMessage`: "Fly below 3,000 km to a covered region" / "No feed here yet · N regions available" for Transit, and "Fly below 48 km to a covered city" / "No bike share here yet · N systems available" for Bike Share. A loading or failed Bike Share city keeps its own text. Leaving coverage clears the layer's last-update time, so the row no longer dates data it no longer draws. The coverage-note translations move from the voice card into `translateStatusMessage` (ui/statusText.js), which the Layers row and the voice card now share. --- docs/CURRENT-STATE.md | 13 ++ src/i18n/locales/ar.json | 9 ++ src/i18n/locales/de.json | 5 + src/i18n/locales/es.json | 6 + src/i18n/locales/fr.json | 6 + src/i18n/locales/it.json | 6 + src/i18n/locales/ja.json | 4 + src/i18n/locales/pt.json | 6 + src/i18n/locales/ru.json | 7 ++ src/i18n/locales/uk.json | 7 ++ src/i18n/locales/zh-CN.json | 4 + src/i18n/locales/zh-Hant.json | 4 + src/layers/bikeshare/controls.js | 22 +++- src/layers/bikeshare/coverageStatus.test.mjs | 124 +++++++++++++++++++ src/layers/bikeshare/viewport.js | 3 + src/layers/transit/lifecycle.test.mjs | 39 ++++++ src/layers/transit/queries.js | 13 +- src/layers/transit/viewport.js | 3 + src/ui/layerPanel.js | 9 +- src/ui/layerPanel.test.mjs | 54 ++++++++ src/ui/statusText.js | 63 ++++++++++ src/ui/statusText.test.mjs | 58 ++++++++- src/voice/resultDisplay.js | 31 +---- 23 files changed, 459 insertions(+), 37 deletions(-) create mode 100644 src/layers/bikeshare/coverageStatus.test.mjs diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index f1244947d..8368eeacf 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1817,6 +1817,19 @@ 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 report the same kind of guidance when no feed or system +covers the view, instead of an undated `never`. Above their altitude gates the +rows read `GTFS-RT · Fly below 3,000 km to a covered region` and +`GBFS · Fly below 48 km to a covered city`; lower down over a city neither +covers (London, Tokyo) they read `GTFS-RT · No feed here yet · N regions +available` and `GBFS · No bike share here yet · N systems available`, N being +the enabled feeds and the catalogued systems. The +chip stays ON. A Bike Share city that is loading or failed keeps its own +loading or error text. Leaving every covered feed or system clears the layer's +last-update time, so the row never dates data it no longer draws. The same +English notes are the voice analyst's empty-result notes; both surfaces show +them translated through `translateStatusMessage` (`src/ui/statusText.js`). + ## Places and CCTV request bounds With a Google key configured, nearby and text search reject missing, blank, diff --git a/src/i18n/locales/ar.json b/src/i18n/locales/ar.json index c9fca14ce..b7193b9e8 100644 --- a/src/i18n/locales/ar.json +++ b/src/i18n/locales/ar.json @@ -2912,6 +2912,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": "جزئي", diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index bb683ed9f..57be9f388 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -2662,6 +2662,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", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 10dcbef8f..2a3bb478d 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -2821,6 +2821,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", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 108e56f36..57186643b 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -2531,6 +2531,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", diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index 6768f93c7..cecbc7a03 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -2531,6 +2531,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", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index b4566b4cb..47a84c9d4 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -2401,6 +2401,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": "部分的", diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index 3f37cde2e..2ac851bc0 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -2533,6 +2533,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", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index e279508ef..cba8c8796 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -2884,6 +2884,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 + потоки декодера", diff --git a/src/i18n/locales/uk.json b/src/i18n/locales/uk.json index 8c3b634b9..71054ea18 100644 --- a/src/i18n/locales/uk.json +++ b/src/i18n/locales/uk.json @@ -2595,6 +2595,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": "частково", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 6125315f2..0d4033ced 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -2302,6 +2302,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": "部分可用", diff --git a/src/i18n/locales/zh-Hant.json b/src/i18n/locales/zh-Hant.json index 66eb37b96..457a32aeb 100644 --- a/src/i18n/locales/zh-Hant.json +++ b/src/i18n/locales/zh-Hant.json @@ -2290,6 +2290,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": "部分可用", diff --git a/src/layers/bikeshare/controls.js b/src/layers/bikeshare/controls.js index 5f73a2dc9..993022506 100644 --- a/src/layers/bikeshare/controls.js +++ b/src/layers/bikeshare/controls.js @@ -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 = { @@ -57,7 +58,7 @@ 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() { const stats = { @@ -65,6 +66,23 @@ export function createControls({ state: layerState, services, parts, source }) { lastUpdate: layerState._lastUpdate, loading: layerState._loading, }; + // No system in range is guidance, not an empty feed: the camera is too + // high, or no catalogued system is near the view. A city that failed + // leaves range empty too, and reports its failure below instead. + if ( + layerState._enabled && + !layerState._loading && + layerState._activeCityIds.size === 0 && + layerState._failedCityIds.size === 0 + ) { + // English data (the analyst's empty note to the model, and the Layers + // row's prompt); both show it 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 (layerState._loading) { stats.loadingLabel = layerState._activeCityIds.size > 0 diff --git a/src/layers/bikeshare/coverageStatus.test.mjs b/src/layers/bikeshare/coverageStatus.test.mjs new file mode 100644 index 000000000..a9f17e73e --- /dev/null +++ b/src/layers/bikeshare/coverageStatus.test.mjs @@ -0,0 +1,124 @@ +// BIKESHARE COVERAGE STATUS — a view with no system says so. +// +// Over a city with no catalogued system (London, Tokyo) the layer drew nothing +// and reported nothing, so its row read "ON · — · GBFS · never": the same as a +// feed that had never answered. These cases pin the rule that replaced it: +// with no system in range the layer reports a guidance status that says why, +// and a view it left keeps no date for data it no longer draws. +// +// Run with: npm test +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { createState } from './state.js'; +import { createControls } from './controls.js'; +import { createModel } from './model.js'; +import { createViewport } from './viewport.js'; +import { GBFS_CITY_REGISTRY } from './registry.js'; + +const LONDON = { lat: 51.5072, lon: -0.1276 }; + +function makeLayer() { + const services = { + overlays: { + setOverlayEntries() {}, + setOverlaySourceVisible() {}, + clearOverlaySource() {}, + }, + render: { governorRequestRender() {} }, + }; + const state = createState({ services }); + const parts = { + queries: { collectDetectableStations: () => [] }, + model: createModel({ state, services, parts: {}, source: null }), + ingestion: { abortInFlight() {} }, + rendering: { removeCityPoints() {} }, + }; + parts.viewport = createViewport({ state, services, parts, source: null }); + const { methods } = createControls({ state, services, parts, source: {} }); + state._enabled = true; + return { state, methods, viewport: parts.viewport }; +} + +/** A viewer looking straight down at `at` from `height` metres. */ +function viewerAt(at, height) { + const rad = Math.PI / 180; + return { + camera: { + positionCartographic: { + height, + latitude: at.lat * rad, + longitude: at.lon * rad, + }, + computeViewRectangle: () => undefined, + }, + scene: { globe: {} }, + }; +} + +test('above the altitude gate the row says how low to fly', async () => { + const { state, methods, viewport } = makeLayer(); + state._viewer = viewerAt(LONDON, 2_000_000); + await viewport.runProximityCheck(); + + const stats = methods.getStats(); + 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 { state, methods, viewport } = makeLayer(); + state._viewer = viewerAt(LONDON, 8_000); + await viewport.runProximityCheck(); + + const stats = methods.getStats(); + 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 with a system in range is not given the prompt', () => { + const { state, methods } = makeLayer(); + state._altitudeGateEnabled = true; + state._activeCityIds.add('boston-bluebikes'); + + assert.equal(methods.getStats().status, undefined); +}); + +test('a loading or failed city reports itself, not the prompt', () => { + const { state, methods } = makeLayer(); + state._altitudeGateEnabled = true; + state._loading = true; + assert.equal(methods.getStats().status, undefined); + + // A city that failed is dropped from range, and still owns the row. + state._loading = false; + state._failedCityIds.add('boston-bluebikes'); + const stats = methods.getStats(); + assert.equal(stats.status, undefined); + assert.equal(stats.error, 'GBFS fetch error'); +}); + +test('a disabled layer reports no prompt', () => { + const { state, methods } = makeLayer(); + state._enabled = false; + assert.equal(methods.getStats().status, undefined); +}); + +test('leaving every system behind drops the date of the data it drew', async () => { + for (const height of [8_000, 2_000_000]) { + const { state, methods, viewport } = makeLayer(); + state._lastUpdate = Date.now(); + // Boston's stations were drawn and dated; the camera moves to London. + state._activeCityIds.add('boston-bluebikes'); + state._cityRuntime.set('boston-bluebikes', {}); + state._viewer = viewerAt(LONDON, height); + await viewport.runProximityCheck(); + + assert.equal(state._activeCityIds.size, 0); + assert.equal(methods.getStats().lastUpdate, null, `at ${height} m`); + } +}); diff --git a/src/layers/bikeshare/viewport.js b/src/layers/bikeshare/viewport.js index 25a4df7d0..0739d341c 100644 --- a/src/layers/bikeshare/viewport.js +++ b/src/layers/bikeshare/viewport.js @@ -125,6 +125,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; } /** @@ -227,6 +229,7 @@ export function createViewport({ state: layerState, services, parts, source }) { layerState._activeCityIds = nextActive; if (layerState._activeCityIds.size === 0) { layerState._count = 0; + layerState._lastUpdate = null; return; } diff --git a/src/layers/transit/lifecycle.test.mjs b/src/layers/transit/lifecycle.test.mjs index ea547f85d..a269dc9e9 100644 --- a/src/layers/transit/lifecycle.test.mjs +++ b/src/layers/transit/lifecycle.test.mjs @@ -3982,3 +3982,42 @@ 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 3,000 km to a covered region', + ); +}); diff --git a/src/layers/transit/queries.js b/src/layers/transit/queries.js index b86613160..8023cdb39 100644 --- a/src/layers/transit/queries.js +++ b/src/layers/transit/queries.js @@ -240,17 +240,20 @@ export function createQueries({ state, parts }) { const health = aggregateTransitFeedHealth(statuses, now); const count = state._vehicles.size; if (state._enabled && active.length === 0) { + // English data (the analyst's empty note to the model, and the Layers + // row's prompt); both show it through translateStatusMessage + // (ui/statusText.js). + const note = state._altitudeGateOpen + ? `No feed here yet · ${TRANSIT_ENABLED_FEEDS.length} regions available` + : `Fly below ${Math.round(ACTIVATION_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_ENABLED_FEEDS.length} 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 diff --git a/src/layers/transit/viewport.js b/src/layers/transit/viewport.js index 730126f10..27cc0569c 100644 --- a/src/layers/transit/viewport.js +++ b/src/layers/transit/viewport.js @@ -108,6 +108,9 @@ export function createViewport({ state, services, parts }) { parts.ingestion.removeFeedVehicles(feedId); } } + // Leaving coverage leaves nothing on the map to date: a row that read + // "26s ago" over an empty view would claim a feed that is no longer here. + if (desired.size === 0) state._lastUpdate = null; for (const [feedId, feed] of desired) { if (!state._activeFeeds.has(feedId)) { state._activeFeeds.set(feedId, feed); diff --git a/src/ui/layerPanel.js b/src/ui/layerPanel.js index b24e8be7b..29a4bc3c0 100644 --- a/src/ui/layerPanel.js +++ b/src/ui/layerPanel.js @@ -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 @@ -691,7 +694,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) { diff --git a/src/ui/layerPanel.test.mjs b/src/ui/layerPanel.test.mjs index 73710e3fc..1be2df5d7 100644 --- a/src/ui/layerPanel.test.mjs +++ b/src/ui/layerPanel.test.mjs @@ -431,3 +431,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'); + } +}); diff --git a/src/ui/statusText.js b/src/ui/statusText.js index 1af6b8b2a..f4cbb59a0 100644 --- a/src/ui/statusText.js +++ b/src/ui/statusText.js @@ -265,3 +265,66 @@ 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 of a layer whose view has no feed, whole lines with a number +// in them: transit (transit/queries.js) and bikeshare (bikeshare/controls.js). +// Their " · " is part of one sentence, so they are matched before the line is +// split into pieces. Keep these in step with the producers. +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); +} diff --git a/src/ui/statusText.test.mjs b/src/ui/statusText.test.mjs index a64975c48..1b6479b45 100644 --- a/src/ui/statusText.test.mjs +++ b/src/ui/statusText.test.mjs @@ -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 3,000 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'), + ); + }); + }); +} diff --git a/src/voice/resultDisplay.js b/src/voice/resultDisplay.js index 112200f7f..cd909d2f7 100644 --- a/src/voice/resultDisplay.js +++ b/src/voice/resultDisplay.js @@ -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 3,000 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. */ From 25c33893f666bf77b8cb416151657ab84a4a1834 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Thu, 5 Nov 2026 12:53:09 +0400 Subject: [PATCH 2/6] Add the changelog entry for #1576 --- changelog.d/1576-transit-bikeshare-coverage-prompt.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 changelog.d/1576-transit-bikeshare-coverage-prompt.md diff --git a/changelog.d/1576-transit-bikeshare-coverage-prompt.md b/changelog.d/1576-transit-bikeshare-coverage-prompt.md new file mode 100644 index 000000000..c3dcbe82a --- /dev/null +++ b/changelog.d/1576-transit-bikeshare-coverage-prompt.md @@ -0,0 +1,6 @@ +- Say why Transit and Bike Share are empty outside coverage. Over a city with + no feed or system (London, Tokyo) their Layers rows read + `No feed here yet · 16 regions available` and + `No bike share here yet · 61 systems available`, or how low to fly, instead + of `never`; leaving coverage clears the last-update time (German, @gekh, + #1576). From 90db4eb2c9ed0c61adbfe6ffaf7a17b1ffe1d829 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Thu, 5 Nov 2026 21:21:03 +0400 Subject: [PATCH 3/6] Name the real gate altitude and repaint coverage rows at once The Transit prompt said "Fly below 3,000 km", but the closed gate opens at 2,850 km; it now names the enter threshold, as Bike Share does. When the camera changes coverage, Transit and Bike Share ask the Layers panel to repaint instead of leaving the old prompt up until the next 15 s / 60 s tick. --- docs/CURRENT-STATE.md | 2 +- src/layers/bikeshare/controls.js | 9 ++ src/layers/bikeshare/lifecycle.js | 1 + src/layers/bikeshare/state.js | 4 + src/layers/bikeshare/viewport.js | 17 ++++ src/layers/promptRepaint.test.mjs | 122 +++++++++++++++++++++++++ src/layers/transit/gatePrompt.test.mjs | 66 +++++++++++++ src/layers/transit/lifecycle.test.mjs | 2 +- src/layers/transit/queries.js | 4 +- src/layers/transit/viewport.js | 7 ++ src/ui/statusText.test.mjs | 2 +- src/voice/analystEmptyNotes.test.mjs | 2 +- src/voice/resultDisplay.js | 2 +- 13 files changed, 233 insertions(+), 7 deletions(-) create mode 100644 src/layers/promptRepaint.test.mjs create mode 100644 src/layers/transit/gatePrompt.test.mjs diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 8368eeacf..946254fbf 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1819,7 +1819,7 @@ errors. Transit and Bike Share report the same kind of guidance when no feed or system covers the view, instead of an undated `never`. Above their altitude gates the -rows read `GTFS-RT · Fly below 3,000 km to a covered region` and +rows read `GTFS-RT · Fly below 2,850 km to a covered region` and `GBFS · Fly below 48 km to a covered city`; lower down over a city neither covers (London, Tokyo) they read `GTFS-RT · No feed here yet · N regions available` and `GBFS · No bike share here yet · N systems available`, N being diff --git a/src/layers/bikeshare/controls.js b/src/layers/bikeshare/controls.js index 993022506..78edd907b 100644 --- a/src/layers/bikeshare/controls.js +++ b/src/layers/bikeshare/controls.js @@ -14,6 +14,15 @@ export function createControls({ state: layerState, services, parts, source }) { updateInterval: STATUS_POLL_MS, + /** + * Keep the manager so camera-driven coverage changes 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). diff --git a/src/layers/bikeshare/lifecycle.js b/src/layers/bikeshare/lifecycle.js index 3e46b5972..050269bfd 100644 --- a/src/layers/bikeshare/lifecycle.js +++ b/src/layers/bikeshare/lifecycle.js @@ -169,6 +169,7 @@ export function createLifecycle({ viewer.scene.primitives.remove(layerState._pointCollection); layerState._pointCollection = null; } + layerState._dataManager = null; layerState._viewer = null; }, }; diff --git a/src/layers/bikeshare/state.js b/src/layers/bikeshare/state.js index 3617914bf..518c2397a 100644 --- a/src/layers/bikeshare/state.js +++ b/src/layers/bikeshare/state.js @@ -19,6 +19,10 @@ export function createState({ services }) { layerState._viewer = null; + /** @type {{ refreshLayerStats?: () => void }|null} Manager that repaints the layer's row. */ + + layerState._dataManager = null; + /** @type {Cesium.PointPrimitiveCollection|null} Primitive collection for station dots. */ layerState._pointCollection = null; diff --git a/src/layers/bikeshare/viewport.js b/src/layers/bikeshare/viewport.js index 0739d341c..dbc328f61 100644 --- a/src/layers/bikeshare/viewport.js +++ b/src/layers/bikeshare/viewport.js @@ -116,6 +116,14 @@ export function createViewport({ state: layerState, services, parts, source }) { parts.rendering.removeCityPoints(cityId); } + /** + * Repaint the layer's row now. Its text follows the camera (coverage + * prompt, station count), and the manager tick is 60 s. + */ + function refreshRow() { + layerState._dataManager?.refreshLayerStats?.(); + } + /** Deactivate all currently active cities and clear the active set. */ function deactivateAllCities() { @@ -205,9 +213,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; } @@ -226,12 +236,18 @@ export function createViewport({ state: layerState, services, parts, source }) { if (!nextActive.has(cityId)) layerState._failedCityIds.delete(cityId); } + const changed = + !wasGateEnabled || + nextActive.size !== layerState._activeCityIds.size || + [...nextActive].some((cityId) => !layerState._activeCityIds.has(cityId)); layerState._activeCityIds = nextActive; if (layerState._activeCityIds.size === 0) { layerState._count = 0; layerState._lastUpdate = null; + if (changed) refreshRow(); return; } + if (changed) refreshRow(); // Only activate cities that don't already have rendered points const toActivate = []; @@ -243,6 +259,7 @@ export function createViewport({ state: layerState, services, parts, source }) { await Promise.all( toActivate.map((cityId) => activateCity(cityId, generation)), ); + if (generation === layerState._proximityGeneration) refreshRow(); } /** Schedule a debounced proximity check after camera movement. */ diff --git a/src/layers/promptRepaint.test.mjs b/src/layers/promptRepaint.test.mjs new file mode 100644 index 000000000..563aaeac7 --- /dev/null +++ b/src/layers/promptRepaint.test.mjs @@ -0,0 +1,122 @@ +// When the camera changes which coverage prompt a Transit or +// Bike Share row shows, the layer asks the panel to repaint at once, instead +// of leaving the old prompt up until the manager's next tick (Transit 15 s, +// Bike Share 60 s). +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { createState as createBikeState } from './bikeshare/state.js'; +import { createControls as createBikeControls } from './bikeshare/controls.js'; +import { createModel as createBikeModel } from './bikeshare/model.js'; +import { createViewport as createBikeViewport } from './bikeshare/viewport.js'; +import { createState as createTransitState } from './transit/state.js'; +import { createViewport as createTransitViewport } from './transit/viewport.js'; +import { createQueries as createTransitQueries } from './transit/queries.js'; + +const rad = Math.PI / 180; +const LONDON = { lat: 51.5072, lon: -0.1276 }; +const BOSTON = { lat: 42.3601, lon: -71.0589 }; + +const services = () => ({ + overlays: { + setOverlayEntries() {}, + setOverlaySourceVisible() {}, + clearOverlaySource() {}, + }, + render: { governorRequestRender() {} }, +}); + +function viewer(at, height) { + return { + camera: { + positionCartographic: { + height, + latitude: at.lat * rad, + longitude: at.lon * rad, + }, + computeViewRectangle: () => undefined, + }, + scene: { globe: {} }, + }; +} + +function spyManager() { + const dm = { repaints: 0 }; + dm.refreshLayerStats = () => { + dm.repaints++; + }; + return dm; +} + +test('Bike Share: flying from London into Boston repaints the prompt row', async () => { + const svc = services(); + const state = createBikeState({ services: svc }); + const parts = { + queries: { collectDetectableStations: () => [] }, + model: createBikeModel({ state, services: svc, parts: {}, source: null }), + ingestion: { + abortInFlight() {}, + loadCityStationInfo: async () => new Map(), + loadCityStationStatus: async () => new Map(), + }, + rendering: { + removeCityPoints() {}, + ensureCityPoints() {}, + applyStatusToPoints() {}, + }, + }; + parts.viewport = createBikeViewport({ + state, + services: svc, + parts, + source: null, + }); + const { methods } = createBikeControls({ + state, + services: svc, + parts, + source: {}, + }); + state._enabled = true; + const dm = spyManager(); + methods.attachDataManager(dm); + + state._viewer = viewer(LONDON, 8_000); + await parts.viewport.runProximityCheck(); + assert.equal(methods.getStats().status, 'zoom-in'); + + const before = dm.repaints; + state._viewer = viewer(BOSTON, 3_000); + await parts.viewport.runProximityCheck(); + assert.equal(methods.getStats().status, undefined, 'Boston is covered'); + assert.ok(dm.repaints > before, 'the row was asked to repaint'); +}); + +test('Transit: leaving Boston for London repaints the row', () => { + const svc = services(); + const state = createTransitState({ services: svc }); + const parts = { + ingestion: { + feedStatus: () => ({ count: 0 }), + pollFeed() {}, + removeFeedVehicles() {}, + sweepAgedVehicles() {}, + }, + rendering: { requestVisibility() {}, syncRenderHold() {} }, + }; + parts.viewport = createTransitViewport({ state, services: svc, parts }); + const queries = createTransitQueries({ state, parts }).methods; + state._enabled = true; + const dm = spyManager(); + state._dataManager = dm; + + state._viewer = viewer(BOSTON, 4_000); + parts.viewport.runProximityCheck(); + assert.ok(state._activeFeeds.size > 0, 'Boston has a feed'); + state._lastUpdate = Date.now(); + + const before = dm.repaints; + state._viewer = viewer(LONDON, 4_000); + parts.viewport.runProximityCheck(); + assert.equal(queries.getStats().status, 'zoom-in'); + assert.ok(dm.repaints > before, 'the row was asked to repaint'); +}); diff --git a/src/layers/transit/gatePrompt.test.mjs b/src/layers/transit/gatePrompt.test.mjs new file mode 100644 index 000000000..7f439fcdc --- /dev/null +++ b/src/layers/transit/gatePrompt.test.mjs @@ -0,0 +1,66 @@ +// The Transit coverage prompt names the altitude the closed gate +// actually opens at. Descending over Boston to 2,900 km (below 3,000 km, above +// the 2,850 km enter threshold) loads nothing, so a prompt "Fly below 3,000 km" +// asks for something the camera has already done. +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 { ACTIVATION_ENTER_ALTITUDE_M } from './policy.js'; + +const BOSTON = { lat: 42.3601, lon: -71.0589 }; + +function makeLayer() { + const services = { + overlays: { + setOverlayEntries() {}, + setOverlaySourceVisible() {}, + clearOverlaySource() {}, + }, + render: { governorRequestRender() {} }, + }; + const state = createState({ services }); + const polled = []; + const parts = { + ingestion: { + feedStatus: () => ({ count: 0 }), + pollFeed: (feed) => polled.push(feed.id), + removeFeedVehicles() {}, + sweepAgedVehicles() {}, + }, + rendering: { requestVisibility() {}, syncRenderHold() {} }, + }; + parts.viewport = createViewport({ state, services, parts }); + const queries = createQueries({ state, parts }).methods; + state._enabled = true; + const rad = Math.PI / 180; + state._viewer = { + camera: { + positionCartographic: { + height: 0, + latitude: BOSTON.lat * rad, + longitude: BOSTON.lon * rad, + }, + computeViewRectangle: () => undefined, + }, + scene: { globe: {} }, + }; + return { state, queries, viewport: parts.viewport, polled }; +} + +test('below the prompted altitude but above the gate, the prompt names the gate', () => { + const { state, queries, viewport, polled } = makeLayer(); + state._viewer.camera.positionCartographic.height = 2_900_000; + viewport.runProximityCheck(); + + // Nothing loads at 2,900 km: the gate opens at the enter threshold. + assert.equal(state._altitudeGateOpen, false); + assert.deepEqual(polled, []); + + const km = Math.round(ACTIVATION_ENTER_ALTITUDE_M / 1000).toLocaleString( + 'en-US', + ); + const { statusMessage } = queries.getStats(); + assert.equal(statusMessage, `Fly below ${km} km to a covered region`); +}); diff --git a/src/layers/transit/lifecycle.test.mjs b/src/layers/transit/lifecycle.test.mjs index a269dc9e9..dc18fd6f0 100644 --- a/src/layers/transit/lifecycle.test.mjs +++ b/src/layers/transit/lifecycle.test.mjs @@ -4018,6 +4018,6 @@ test('a view with no feed reads its coverage prompt, not an undated feed', async app.advance(1_000); assert.equal( app.layer.getStats().statusMessage, - 'Fly below 3,000 km to a covered region', + 'Fly below 2,850 km to a covered region', ); }); diff --git a/src/layers/transit/queries.js b/src/layers/transit/queries.js index 8023cdb39..748929213 100644 --- a/src/layers/transit/queries.js +++ b/src/layers/transit/queries.js @@ -1,5 +1,5 @@ import { - ACTIVATION_ALTITUDE_M, + ACTIVATION_ENTER_ALTITUDE_M, FEED_STALE_AFTER_MS, aggregateTransitFeedHealth, transitDetectionClass, @@ -245,7 +245,7 @@ export function createQueries({ state, parts }) { // (ui/statusText.js). const note = state._altitudeGateOpen ? `No feed here yet · ${TRANSIT_ENABLED_FEEDS.length} regions available` - : `Fly below ${Math.round(ACTIVATION_ALTITUDE_M / 1000).toLocaleString('en-US')} km to a covered region`; + : `Fly below ${Math.round(ACTIVATION_ENTER_ALTITUDE_M / 1000).toLocaleString('en-US')} km to a covered region`; return { count: 0, lastUpdate: state._lastUpdate, diff --git a/src/layers/transit/viewport.js b/src/layers/transit/viewport.js index 27cc0569c..0e3b317d4 100644 --- a/src/layers/transit/viewport.js +++ b/src/layers/transit/viewport.js @@ -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,6 +108,7 @@ export function createViewport({ state, services, parts }) { state._activeFeeds.delete(feedId); state._feedStatus.delete(feedId); parts.ingestion.removeFeedVehicles(feedId); + changed = true; } } // Leaving coverage leaves nothing on the map to date: a row that read @@ -115,11 +118,15 @@ export function createViewport({ state, services, parts }) { 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'); + // The row's text follows the camera (coverage prompt, feed list), so it + // repaints now instead of at the next 15 s manager tick. + if (changed) state._dataManager?.refreshLayerStats?.(); } function onCameraChanged() { diff --git a/src/ui/statusText.test.mjs b/src/ui/statusText.test.mjs index 1b6479b45..6d1648b6d 100644 --- a/src/ui/statusText.test.mjs +++ b/src/ui/statusText.test.mjs @@ -83,7 +83,7 @@ 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 3,000 km to a covered region', + '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', diff --git a/src/voice/analystEmptyNotes.test.mjs b/src/voice/analystEmptyNotes.test.mjs index b0de16ebd..cfacee4bf 100644 --- a/src/voice/analystEmptyNotes.test.mjs +++ b/src/voice/analystEmptyNotes.test.mjs @@ -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', ]; diff --git a/src/voice/resultDisplay.js b/src/voice/resultDisplay.js index cd909d2f7..72a929b94 100644 --- a/src/voice/resultDisplay.js +++ b/src/voice/resultDisplay.js @@ -423,7 +423,7 @@ function shownNote(text) { const shown = match ? show(match) : null; if (shown) return shown; } - // A layer's empty note can be its coverage note ("Fly below 3,000 km to a + // 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); From 697b6b59e66291c6c690b6a77fd2030396233e03 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Fri, 6 Nov 2026 14:45:39 +0400 Subject: [PATCH 4/6] Repaint coverage rows after loads, nudges and cleared failures --- src/data/lifecycle.js | 19 +- src/data/manager.js | 4 +- src/data/manager.test.mjs | 25 ++ src/layers/bikeshare/controls.js | 15 +- src/layers/bikeshare/rowRepaint.test.mjs | 295 ++++++++++++++++++ src/layers/bikeshare/state.js | 2 +- src/layers/bikeshare/viewport.js | 13 +- .../transit/refreshErrorRepaint.test.mjs | 98 ++++++ src/layers/transit/state.js | 2 +- src/layers/transit/viewport.js | 2 +- 10 files changed, 464 insertions(+), 11 deletions(-) create mode 100644 src/layers/bikeshare/rowRepaint.test.mjs create mode 100644 src/layers/transit/refreshErrorRepaint.test.mjs diff --git a/src/data/lifecycle.js b/src/data/lifecycle.js index 6c2290c72..c01926451 100644 --- a/src/data/lifecycle.js +++ b/src/data/lifecycle.js @@ -2385,11 +2385,28 @@ 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' }); } + /** + * A tick's failure belongs to the feed it polled. Once the layer's own stats + * stop reporting one (the camera left that feed), the row stops showing it + * instead of waiting for 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 { diff --git a/src/data/manager.js b/src/data/manager.js index cf7b31fae..688fb94d2 100644 --- a/src/data/manager.js +++ b/src/data/manager.js @@ -20,8 +20,10 @@ export class DataLayerManager extends LayerLifecycle { * 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. + * @param {string} [layerId] - The layer whose row changed. */ - refreshLayerStats() { + refreshLayerStats(layerId) { + this._dropRecoveredRefreshError(layerId); this._refreshTogglePanel(); } diff --git a/src/data/manager.test.mjs b/src/data/manager.test.mjs index 70f705ec6..de3502979 100644 --- a/src/data/manager.test.mjs +++ b/src/data/manager.test.mjs @@ -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 = { diff --git a/src/layers/bikeshare/controls.js b/src/layers/bikeshare/controls.js index 78edd907b..03bb544ab 100644 --- a/src/layers/bikeshare/controls.js +++ b/src/layers/bikeshare/controls.js @@ -70,17 +70,26 @@ export function createControls({ state: layerState, services, parts, source }) { * @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, }; // No system in range is guidance, not an empty feed: the camera is too // high, or no catalogued system is near the view. A city that failed // leaves range empty too, and reports its failure below instead. if ( layerState._enabled && - !layerState._loading && + !loading && layerState._activeCityIds.size === 0 && layerState._failedCityIds.size === 0 ) { @@ -92,7 +101,7 @@ export function createControls({ state: layerState, services, parts, source }) { : `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 (layerState._loading) { + if (loading) { stats.loadingLabel = layerState._activeCityIds.size > 0 ? t('syncing {count} city feeds...', { diff --git a/src/layers/bikeshare/rowRepaint.test.mjs b/src/layers/bikeshare/rowRepaint.test.mjs new file mode 100644 index 000000000..144ff5502 --- /dev/null +++ b/src/layers/bikeshare/rowRepaint.test.mjs @@ -0,0 +1,295 @@ +// Bike Share row repaints: entering a city, leaving it or nudging the camera +// mid-load, and leaving a failed city. +// Bike Share: 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 } 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), + 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()); +}); diff --git a/src/layers/bikeshare/state.js b/src/layers/bikeshare/state.js index 518c2397a..199c9b5ca 100644 --- a/src/layers/bikeshare/state.js +++ b/src/layers/bikeshare/state.js @@ -19,7 +19,7 @@ export function createState({ services }) { layerState._viewer = null; - /** @type {{ refreshLayerStats?: () => void }|null} Manager that repaints the layer's row. */ + /** @type {{ refreshLayerStats?: (layerId?: string) => void }|null} Manager that repaints the layer's row. */ layerState._dataManager = null; diff --git a/src/layers/bikeshare/viewport.js b/src/layers/bikeshare/viewport.js index dbc328f61..008c01333 100644 --- a/src/layers/bikeshare/viewport.js +++ b/src/layers/bikeshare/viewport.js @@ -121,7 +121,7 @@ export function createViewport({ state: layerState, services, parts, source }) { * prompt, station count), and the manager tick is 60 s. */ function refreshRow() { - layerState._dataManager?.refreshLayerStats?.(); + layerState._dataManager?.refreshLayerStats?.('bikeshare'); } /** Deactivate all currently active cities and clear the active set. */ @@ -232,12 +232,17 @@ 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; @@ -259,7 +264,9 @@ export function createViewport({ state: layerState, services, parts, source }) { await Promise.all( toActivate.map((cityId) => activateCity(cityId, generation)), ); - if (generation === layerState._proximityGeneration) refreshRow(); + // 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. */ diff --git a/src/layers/transit/refreshErrorRepaint.test.mjs b/src/layers/transit/refreshErrorRepaint.test.mjs new file mode 100644 index 000000000..d1a5c16b8 --- /dev/null +++ b/src/layers/transit/refreshErrorRepaint.test.mjs @@ -0,0 +1,98 @@ +// 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'); + + state._viewer = viewer(LONDON, 4_000); + parts.viewport.runProximityCheck(); + assert.equal(queries.getStats().status, 'zoom-in'); + assert.doesNotMatch(painted.at(-1), /feed unavailable/); +}); diff --git a/src/layers/transit/state.js b/src/layers/transit/state.js index 75b13d84b..569298e6e 100644 --- a/src/layers/transit/state.js +++ b/src/layers/transit/state.js @@ -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; diff --git a/src/layers/transit/viewport.js b/src/layers/transit/viewport.js index 0e3b317d4..a62d49009 100644 --- a/src/layers/transit/viewport.js +++ b/src/layers/transit/viewport.js @@ -126,7 +126,7 @@ export function createViewport({ state, services, parts }) { governorRequestRender('transit-proximity'); // The row's text follows the camera (coverage prompt, feed list), so it // repaints now instead of at the next 15 s manager tick. - if (changed) state._dataManager?.refreshLayerStats?.(); + if (changed) state._dataManager?.refreshLayerStats?.('transit'); } function onCameraChanged() { From 5c4918affb792bbfe4ac863031b3dcd55734a30c Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Fri, 6 Nov 2026 20:20:11 +0400 Subject: [PATCH 5/6] Tighten coverage prompt comments and docs --- .../1576-transit-bikeshare-coverage-prompt.md | 2 +- docs/CURRENT-STATE.md | 22 +++++++++---------- src/data/lifecycle.js | 5 ++--- src/layers/bikeshare/controls.js | 13 +++++------ src/layers/bikeshare/coverageStatus.test.mjs | 7 ++---- src/layers/bikeshare/rowRepaint.test.mjs | 2 -- src/layers/bikeshare/viewport.js | 5 +---- src/layers/promptRepaint.test.mjs | 6 ++--- src/layers/transit/gatePrompt.test.mjs | 6 ++--- src/layers/transit/queries.js | 5 ++--- src/layers/transit/viewport.js | 6 ++--- src/ui/statusText.js | 6 ++--- 12 files changed, 31 insertions(+), 54 deletions(-) diff --git a/changelog.d/1576-transit-bikeshare-coverage-prompt.md b/changelog.d/1576-transit-bikeshare-coverage-prompt.md index c3dcbe82a..660c1f30b 100644 --- a/changelog.d/1576-transit-bikeshare-coverage-prompt.md +++ b/changelog.d/1576-transit-bikeshare-coverage-prompt.md @@ -1,6 +1,6 @@ - Say why Transit and Bike Share are empty outside coverage. Over a city with no feed or system (London, Tokyo) their Layers rows read - `No feed here yet · 16 regions available` and + `No feed here yet · 17 regions available` and `No bike share here yet · 61 systems available`, or how low to fly, instead of `never`; leaving coverage clears the last-update time (German, @gekh, #1576). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 69d83d759..13cce0050 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1975,18 +1975,16 @@ 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 report the same kind of guidance 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 down over a city neither -covers (London, Tokyo) they read `GTFS-RT · No feed here yet · N regions -available` and `GBFS · No bike share here yet · N systems available`, N being -the enabled feeds and the catalogued systems. The -chip stays ON. A Bike Share city that is loading or failed keeps its own -loading or error text. Leaving every covered feed or system clears the layer's -last-update time, so the row never dates data it no longer draws. The same -English notes are the voice analyst's empty-result notes; both surfaces show -them translated through `translateStatusMessage` (`src/ui/statusText.js`). +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 diff --git a/src/data/lifecycle.js b/src/data/lifecycle.js index c01926451..3ea02eca9 100644 --- a/src/data/lifecycle.js +++ b/src/data/lifecycle.js @@ -2393,9 +2393,8 @@ export class LayerLifecycle { } /** - * A tick's failure belongs to the feed it polled. Once the layer's own stats - * stop reporting one (the camera left that feed), the row stops showing it - * instead of waiting for the next tick. + * 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; diff --git a/src/layers/bikeshare/controls.js b/src/layers/bikeshare/controls.js index 03bb544ab..d90a62b5b 100644 --- a/src/layers/bikeshare/controls.js +++ b/src/layers/bikeshare/controls.js @@ -15,8 +15,7 @@ export function createControls({ state: layerState, services, parts, source }) { updateInterval: STATUS_POLL_MS, /** - * Keep the manager so camera-driven coverage changes can repaint the row - * before the next status poll. + * Kept so camera moves can repaint the row before the next status poll. * @param {object} dataManager DataLayerManager instance. */ attachDataManager(dataManager) { @@ -84,18 +83,16 @@ export function createControls({ state: layerState, services, parts, source }) { lastUpdate: layerState._lastUpdate, loading, }; - // No system in range is guidance, not an empty feed: the camera is too - // high, or no catalogued system is near the view. A city that failed - // leaves range empty too, and reports its failure below instead. + // 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 data (the analyst's empty note to the model, and the Layers - // row's prompt); both show it through translateStatusMessage - // (ui/statusText.js). + // 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`; diff --git a/src/layers/bikeshare/coverageStatus.test.mjs b/src/layers/bikeshare/coverageStatus.test.mjs index a9f17e73e..004c75c94 100644 --- a/src/layers/bikeshare/coverageStatus.test.mjs +++ b/src/layers/bikeshare/coverageStatus.test.mjs @@ -1,10 +1,7 @@ // BIKESHARE COVERAGE STATUS — a view with no system says so. // -// Over a city with no catalogued system (London, Tokyo) the layer drew nothing -// and reported nothing, so its row read "ON · — · GBFS · never": the same as a -// feed that had never answered. These cases pin the rule that replaced it: -// with no system in range the layer reports a guidance status that says why, -// and a view it left keeps no date for data it no longer draws. +// With no system in range (London, Tokyo) the layer says why instead of +// "never", and keeps no date for data it no longer draws. // // Run with: npm test import { test } from 'node:test'; diff --git a/src/layers/bikeshare/rowRepaint.test.mjs b/src/layers/bikeshare/rowRepaint.test.mjs index 144ff5502..5f5538168 100644 --- a/src/layers/bikeshare/rowRepaint.test.mjs +++ b/src/layers/bikeshare/rowRepaint.test.mjs @@ -1,5 +1,3 @@ -// Bike Share row repaints: entering a city, leaving it or nudging the camera -// mid-load, and leaving a failed city. // Bike Share: 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 diff --git a/src/layers/bikeshare/viewport.js b/src/layers/bikeshare/viewport.js index 008c01333..d4cc44cb6 100644 --- a/src/layers/bikeshare/viewport.js +++ b/src/layers/bikeshare/viewport.js @@ -116,10 +116,7 @@ export function createViewport({ state: layerState, services, parts, source }) { parts.rendering.removeCityPoints(cityId); } - /** - * Repaint the layer's row now. Its text follows the camera (coverage - * prompt, station count), and the manager tick is 60 s. - */ + /** Repaint the row now: it follows the camera, the manager tick is 60 s. */ function refreshRow() { layerState._dataManager?.refreshLayerStats?.('bikeshare'); } diff --git a/src/layers/promptRepaint.test.mjs b/src/layers/promptRepaint.test.mjs index 563aaeac7..38193ca72 100644 --- a/src/layers/promptRepaint.test.mjs +++ b/src/layers/promptRepaint.test.mjs @@ -1,7 +1,5 @@ -// When the camera changes which coverage prompt a Transit or -// Bike Share row shows, the layer asks the panel to repaint at once, instead -// of leaving the old prompt up until the manager's next tick (Transit 15 s, -// Bike Share 60 s). +// A camera move that changes a Transit or Bike Share coverage prompt repaints +// the row at once, not at the next manager tick (15 s, 60 s). import { test } from 'node:test'; import assert from 'node:assert/strict'; import { createState as createBikeState } from './bikeshare/state.js'; diff --git a/src/layers/transit/gatePrompt.test.mjs b/src/layers/transit/gatePrompt.test.mjs index 7f439fcdc..dc4c3e863 100644 --- a/src/layers/transit/gatePrompt.test.mjs +++ b/src/layers/transit/gatePrompt.test.mjs @@ -1,7 +1,5 @@ -// The Transit coverage prompt names the altitude the closed gate -// actually opens at. Descending over Boston to 2,900 km (below 3,000 km, above -// the 2,850 km enter threshold) loads nothing, so a prompt "Fly below 3,000 km" -// asks for something the camera has already done. +// The Transit prompt names the altitude the gate opens at: at 2,900 km over +// Boston (under 3,000 km, above the 2,850 km enter threshold) nothing loads. import { test } from 'node:test'; import assert from 'node:assert/strict'; import { createState } from './state.js'; diff --git a/src/layers/transit/queries.js b/src/layers/transit/queries.js index 2efe928d5..7a9f2e074 100644 --- a/src/layers/transit/queries.js +++ b/src/layers/transit/queries.js @@ -245,9 +245,8 @@ export function createQueries({ state, parts }) { const health = aggregateTransitFeedHealth(statuses, now); const count = state._vehicles.size; if (state._enabled && active.length === 0) { - // English data (the analyst's empty note to the model, and the Layers - // row's prompt); both show it through translateStatusMessage - // (ui/statusText.js). + // 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`; diff --git a/src/layers/transit/viewport.js b/src/layers/transit/viewport.js index a62d49009..38315e4ae 100644 --- a/src/layers/transit/viewport.js +++ b/src/layers/transit/viewport.js @@ -111,8 +111,7 @@ export function createViewport({ state, services, parts }) { changed = true; } } - // Leaving coverage leaves nothing on the map to date: a row that read - // "26s ago" over an empty view would claim a feed that is no longer here. + // 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)) { @@ -124,8 +123,7 @@ export function createViewport({ state, services, parts }) { parts.ingestion.sweepAgedVehicles(Date.now()); parts.rendering.syncRenderHold(); governorRequestRender('transit-proximity'); - // The row's text follows the camera (coverage prompt, feed list), so it - // repaints now instead of at the next 15 s manager tick. + // Repaint now: the row follows the camera, the manager tick is 15 s. if (changed) state._dataManager?.refreshLayerStats?.('transit'); } diff --git a/src/ui/statusText.js b/src/ui/statusText.js index f4cbb59a0..d13faff7e 100644 --- a/src/ui/statusText.js +++ b/src/ui/statusText.js @@ -269,10 +269,8 @@ export function translateStatusLine(text) { /** Thousands separators out of an English number ("3,000" → 3000). */ const englishNumber = (text) => Number(text.replace(/,/g, '')); -// Coverage notes of a layer whose view has no feed, whole lines with a number -// in them: transit (transit/queries.js) and bikeshare (bikeshare/controls.js). -// Their " · " is part of one sentence, so they are matched before the line is -// split into pieces. Keep these in step with the producers. +// 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$/, From ea76fc4bc07327b9d8297aa6b2663660ca0d5947 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Fri, 6 Nov 2026 22:01:39 +0400 Subject: [PATCH 6/6] Fold duplicate coverage tests into their neighbours --- .../1576-transit-bikeshare-coverage-prompt.md | 8 +- src/data/manager.js | 12 -- src/layers/bikeshare/coverageStatus.test.mjs | 121 ------------------ src/layers/bikeshare/rowRepaint.test.mjs | 79 +++++++++++- src/layers/bikeshare/viewport.js | 4 +- src/layers/promptRepaint.test.mjs | 120 ----------------- src/layers/transit/gatePrompt.test.mjs | 64 --------- src/layers/transit/lifecycle.test.mjs | 12 ++ .../transit/refreshErrorRepaint.test.mjs | 2 + 9 files changed, 94 insertions(+), 328 deletions(-) delete mode 100644 src/layers/bikeshare/coverageStatus.test.mjs delete mode 100644 src/layers/promptRepaint.test.mjs delete mode 100644 src/layers/transit/gatePrompt.test.mjs diff --git a/changelog.d/1576-transit-bikeshare-coverage-prompt.md b/changelog.d/1576-transit-bikeshare-coverage-prompt.md index 660c1f30b..4d13d8d3e 100644 --- a/changelog.d/1576-transit-bikeshare-coverage-prompt.md +++ b/changelog.d/1576-transit-bikeshare-coverage-prompt.md @@ -1,6 +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 read - `No feed here yet · 17 regions available` and - `No bike share here yet · 61 systems available`, or how low to fly, instead - of `never`; leaving coverage clears the last-update time (German, @gekh, - #1576). + 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). diff --git a/src/data/manager.js b/src/data/manager.js index 688fb94d2..5de10f665 100644 --- a/src/data/manager.js +++ b/src/data/manager.js @@ -15,18 +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. - * @param {string} [layerId] - The layer whose row changed. - */ - refreshLayerStats(layerId) { - this._dropRecoveredRefreshError(layerId); - this._refreshTogglePanel(); - } - _buildMetaText(layer) { return this._presentation.panel._buildMetaText(layer); } diff --git a/src/layers/bikeshare/coverageStatus.test.mjs b/src/layers/bikeshare/coverageStatus.test.mjs deleted file mode 100644 index 004c75c94..000000000 --- a/src/layers/bikeshare/coverageStatus.test.mjs +++ /dev/null @@ -1,121 +0,0 @@ -// BIKESHARE COVERAGE STATUS — a view with no system says so. -// -// With no system in range (London, Tokyo) the layer says why instead of -// "never", and keeps no date for data it no longer draws. -// -// Run with: npm test -import { test } from 'node:test'; -import assert from 'node:assert/strict'; -import { createState } from './state.js'; -import { createControls } from './controls.js'; -import { createModel } from './model.js'; -import { createViewport } from './viewport.js'; -import { GBFS_CITY_REGISTRY } from './registry.js'; - -const LONDON = { lat: 51.5072, lon: -0.1276 }; - -function makeLayer() { - const services = { - overlays: { - setOverlayEntries() {}, - setOverlaySourceVisible() {}, - clearOverlaySource() {}, - }, - render: { governorRequestRender() {} }, - }; - const state = createState({ services }); - const parts = { - queries: { collectDetectableStations: () => [] }, - model: createModel({ state, services, parts: {}, source: null }), - ingestion: { abortInFlight() {} }, - rendering: { removeCityPoints() {} }, - }; - parts.viewport = createViewport({ state, services, parts, source: null }); - const { methods } = createControls({ state, services, parts, source: {} }); - state._enabled = true; - return { state, methods, viewport: parts.viewport }; -} - -/** A viewer looking straight down at `at` from `height` metres. */ -function viewerAt(at, height) { - const rad = Math.PI / 180; - return { - camera: { - positionCartographic: { - height, - latitude: at.lat * rad, - longitude: at.lon * rad, - }, - computeViewRectangle: () => undefined, - }, - scene: { globe: {} }, - }; -} - -test('above the altitude gate the row says how low to fly', async () => { - const { state, methods, viewport } = makeLayer(); - state._viewer = viewerAt(LONDON, 2_000_000); - await viewport.runProximityCheck(); - - const stats = methods.getStats(); - 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 { state, methods, viewport } = makeLayer(); - state._viewer = viewerAt(LONDON, 8_000); - await viewport.runProximityCheck(); - - const stats = methods.getStats(); - 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 with a system in range is not given the prompt', () => { - const { state, methods } = makeLayer(); - state._altitudeGateEnabled = true; - state._activeCityIds.add('boston-bluebikes'); - - assert.equal(methods.getStats().status, undefined); -}); - -test('a loading or failed city reports itself, not the prompt', () => { - const { state, methods } = makeLayer(); - state._altitudeGateEnabled = true; - state._loading = true; - assert.equal(methods.getStats().status, undefined); - - // A city that failed is dropped from range, and still owns the row. - state._loading = false; - state._failedCityIds.add('boston-bluebikes'); - const stats = methods.getStats(); - assert.equal(stats.status, undefined); - assert.equal(stats.error, 'GBFS fetch error'); -}); - -test('a disabled layer reports no prompt', () => { - const { state, methods } = makeLayer(); - state._enabled = false; - assert.equal(methods.getStats().status, undefined); -}); - -test('leaving every system behind drops the date of the data it drew', async () => { - for (const height of [8_000, 2_000_000]) { - const { state, methods, viewport } = makeLayer(); - state._lastUpdate = Date.now(); - // Boston's stations were drawn and dated; the camera moves to London. - state._activeCityIds.add('boston-bluebikes'); - state._cityRuntime.set('boston-bluebikes', {}); - state._viewer = viewerAt(LONDON, height); - await viewport.runProximityCheck(); - - assert.equal(state._activeCityIds.size, 0); - assert.equal(methods.getStats().lastUpdate, null, `at ${height} m`); - } -}); diff --git a/src/layers/bikeshare/rowRepaint.test.mjs b/src/layers/bikeshare/rowRepaint.test.mjs index 5f5538168..4bfea999c 100644 --- a/src/layers/bikeshare/rowRepaint.test.mjs +++ b/src/layers/bikeshare/rowRepaint.test.mjs @@ -1,11 +1,13 @@ -// Bike Share: after the camera settles, the last row the panel painted must -// match what the layer reports now. Each case drives the real viewport, +// 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 } from './registry.js'; +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'; @@ -175,6 +177,7 @@ function makeLiveLayer() { source, painted, lastPainted: () => painted.at(-1), + stats: () => controls.getStats(), live: () => rowText(controls.getStats()), moveTo(place, height = LOW_M) { Object.assign(camera, place, { height }); @@ -291,3 +294,73 @@ test('leaving the failed city of two repaints DEGRADED away', async () => { 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`); + } +}); diff --git a/src/layers/bikeshare/viewport.js b/src/layers/bikeshare/viewport.js index d4cc44cb6..46b6b7d12 100644 --- a/src/layers/bikeshare/viewport.js +++ b/src/layers/bikeshare/viewport.js @@ -243,11 +243,9 @@ export function createViewport({ state: layerState, services, parts, source }) { 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; layerState._lastUpdate = null; - if (changed) refreshRow(); - return; } if (changed) refreshRow(); diff --git a/src/layers/promptRepaint.test.mjs b/src/layers/promptRepaint.test.mjs deleted file mode 100644 index 38193ca72..000000000 --- a/src/layers/promptRepaint.test.mjs +++ /dev/null @@ -1,120 +0,0 @@ -// A camera move that changes a Transit or Bike Share coverage prompt repaints -// the row at once, not at the next manager tick (15 s, 60 s). -import { test } from 'node:test'; -import assert from 'node:assert/strict'; -import { createState as createBikeState } from './bikeshare/state.js'; -import { createControls as createBikeControls } from './bikeshare/controls.js'; -import { createModel as createBikeModel } from './bikeshare/model.js'; -import { createViewport as createBikeViewport } from './bikeshare/viewport.js'; -import { createState as createTransitState } from './transit/state.js'; -import { createViewport as createTransitViewport } from './transit/viewport.js'; -import { createQueries as createTransitQueries } from './transit/queries.js'; - -const rad = Math.PI / 180; -const LONDON = { lat: 51.5072, lon: -0.1276 }; -const BOSTON = { lat: 42.3601, lon: -71.0589 }; - -const services = () => ({ - overlays: { - setOverlayEntries() {}, - setOverlaySourceVisible() {}, - clearOverlaySource() {}, - }, - render: { governorRequestRender() {} }, -}); - -function viewer(at, height) { - return { - camera: { - positionCartographic: { - height, - latitude: at.lat * rad, - longitude: at.lon * rad, - }, - computeViewRectangle: () => undefined, - }, - scene: { globe: {} }, - }; -} - -function spyManager() { - const dm = { repaints: 0 }; - dm.refreshLayerStats = () => { - dm.repaints++; - }; - return dm; -} - -test('Bike Share: flying from London into Boston repaints the prompt row', async () => { - const svc = services(); - const state = createBikeState({ services: svc }); - const parts = { - queries: { collectDetectableStations: () => [] }, - model: createBikeModel({ state, services: svc, parts: {}, source: null }), - ingestion: { - abortInFlight() {}, - loadCityStationInfo: async () => new Map(), - loadCityStationStatus: async () => new Map(), - }, - rendering: { - removeCityPoints() {}, - ensureCityPoints() {}, - applyStatusToPoints() {}, - }, - }; - parts.viewport = createBikeViewport({ - state, - services: svc, - parts, - source: null, - }); - const { methods } = createBikeControls({ - state, - services: svc, - parts, - source: {}, - }); - state._enabled = true; - const dm = spyManager(); - methods.attachDataManager(dm); - - state._viewer = viewer(LONDON, 8_000); - await parts.viewport.runProximityCheck(); - assert.equal(methods.getStats().status, 'zoom-in'); - - const before = dm.repaints; - state._viewer = viewer(BOSTON, 3_000); - await parts.viewport.runProximityCheck(); - assert.equal(methods.getStats().status, undefined, 'Boston is covered'); - assert.ok(dm.repaints > before, 'the row was asked to repaint'); -}); - -test('Transit: leaving Boston for London repaints the row', () => { - const svc = services(); - const state = createTransitState({ services: svc }); - const parts = { - ingestion: { - feedStatus: () => ({ count: 0 }), - pollFeed() {}, - removeFeedVehicles() {}, - sweepAgedVehicles() {}, - }, - rendering: { requestVisibility() {}, syncRenderHold() {} }, - }; - parts.viewport = createTransitViewport({ state, services: svc, parts }); - const queries = createTransitQueries({ state, parts }).methods; - state._enabled = true; - const dm = spyManager(); - state._dataManager = dm; - - state._viewer = viewer(BOSTON, 4_000); - parts.viewport.runProximityCheck(); - assert.ok(state._activeFeeds.size > 0, 'Boston has a feed'); - state._lastUpdate = Date.now(); - - const before = dm.repaints; - state._viewer = viewer(LONDON, 4_000); - parts.viewport.runProximityCheck(); - assert.equal(queries.getStats().status, 'zoom-in'); - assert.ok(dm.repaints > before, 'the row was asked to repaint'); -}); diff --git a/src/layers/transit/gatePrompt.test.mjs b/src/layers/transit/gatePrompt.test.mjs deleted file mode 100644 index dc4c3e863..000000000 --- a/src/layers/transit/gatePrompt.test.mjs +++ /dev/null @@ -1,64 +0,0 @@ -// The Transit prompt names the altitude the gate opens at: at 2,900 km over -// Boston (under 3,000 km, above the 2,850 km enter threshold) nothing loads. -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 { ACTIVATION_ENTER_ALTITUDE_M } from './policy.js'; - -const BOSTON = { lat: 42.3601, lon: -71.0589 }; - -function makeLayer() { - const services = { - overlays: { - setOverlayEntries() {}, - setOverlaySourceVisible() {}, - clearOverlaySource() {}, - }, - render: { governorRequestRender() {} }, - }; - const state = createState({ services }); - const polled = []; - const parts = { - ingestion: { - feedStatus: () => ({ count: 0 }), - pollFeed: (feed) => polled.push(feed.id), - removeFeedVehicles() {}, - sweepAgedVehicles() {}, - }, - rendering: { requestVisibility() {}, syncRenderHold() {} }, - }; - parts.viewport = createViewport({ state, services, parts }); - const queries = createQueries({ state, parts }).methods; - state._enabled = true; - const rad = Math.PI / 180; - state._viewer = { - camera: { - positionCartographic: { - height: 0, - latitude: BOSTON.lat * rad, - longitude: BOSTON.lon * rad, - }, - computeViewRectangle: () => undefined, - }, - scene: { globe: {} }, - }; - return { state, queries, viewport: parts.viewport, polled }; -} - -test('below the prompted altitude but above the gate, the prompt names the gate', () => { - const { state, queries, viewport, polled } = makeLayer(); - state._viewer.camera.positionCartographic.height = 2_900_000; - viewport.runProximityCheck(); - - // Nothing loads at 2,900 km: the gate opens at the enter threshold. - assert.equal(state._altitudeGateOpen, false); - assert.deepEqual(polled, []); - - const km = Math.round(ACTIVATION_ENTER_ALTITUDE_M / 1000).toLocaleString( - 'en-US', - ); - const { statusMessage } = queries.getStats(); - assert.equal(statusMessage, `Fly below ${km} km to a covered region`); -}); diff --git a/src/layers/transit/lifecycle.test.mjs b/src/layers/transit/lifecycle.test.mjs index dc18fd6f0..4711eebdf 100644 --- a/src/layers/transit/lifecycle.test.mjs +++ b/src/layers/transit/lifecycle.test.mjs @@ -4021,3 +4021,15 @@ test('a view with no feed reads its coverage prompt, not an undated feed', async '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', + ); +}); diff --git a/src/layers/transit/refreshErrorRepaint.test.mjs b/src/layers/transit/refreshErrorRepaint.test.mjs index d1a5c16b8..d85a4a375 100644 --- a/src/layers/transit/refreshErrorRepaint.test.mjs +++ b/src/layers/transit/refreshErrorRepaint.test.mjs @@ -91,8 +91,10 @@ test('leaving a feed whose tick failed repaints its error away', async () => { 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/); });