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);