From 24190a3e35394a04166a499bff5548d62dff89c2 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Tue, 3 Nov 2026 04:21:48 +0400 Subject: [PATCH] Keep city stations, vehicle tracks and trail history across globe switches --- scripts/qa-urban-engine-switch.mjs | 127 ++++++++++++++++++ src/app/constructCatalog.js | 4 +- src/layers/bikeshare/index.js | 39 +++++- src/layers/bikeshare/ingestion.js | 2 +- .../bikeshare/render/cesium/lifecycle.js | 45 +++---- src/layers/bikeshare/render/navara.js | 33 ++--- src/layers/bikeshare/viewport.js | 12 +- src/layers/transit/lifecycle.js | 86 +++++++++--- src/layers/transit/rendering.js | 2 + src/layers/transit/trails.js | 14 ++ src/layers/transit/viewport.js | 3 +- 11 files changed, 288 insertions(+), 79 deletions(-) create mode 100644 scripts/qa-urban-engine-switch.mjs diff --git a/scripts/qa-urban-engine-switch.mjs b/scripts/qa-urban-engine-switch.mjs new file mode 100644 index 000000000..5f94d2ef0 --- /dev/null +++ b/scripts/qa-urban-engine-switch.mjs @@ -0,0 +1,127 @@ +import puppeteer from 'puppeteer'; +import assert from 'node:assert/strict'; +import { blockPaidQaMaps } from './qa-map-policy.mjs'; +import { switchGlobe } from './qa-switch-globe.mjs'; +import { installTransitFixture } from './fixtures/w2/transit.mjs'; +import { installCombinedFixture } from './fixtures/w2/combined.mjs'; +const url = new URL(process.argv[2]), + bus = process.argv.includes('--transit'), + id = bus ? 'transit' : 'bikeshare'; +if ( + !['localhost', '127.0.0.1'].includes(url.hostname) || + ['', '4173', '4185', '4186', '4187', '4188', '4189', '4194'].includes( + url.port, + ) +) + throw Error('Supply local keyless preview URL'); +url.search = '?engine=navara&welcome=0'; +url.hash = + 'v=2&lat=42.3554&lon=-71.0605&alt=2700&pitch=-90&heading=0&map=sentinel2&l='; +const b = await puppeteer.launch({ + headless: true, + args: ['--use-angle=metal', '--enable-gpu'], +}); +try { + const p = await b.newPage(); + await blockPaidQaMaps(p); + await p.setViewport({ width: 1280, height: 900 }); + const errors = []; + p.on('pageerror', (e) => { + errors.push(e.message); + console.log('error', e.message); + }); + const fx = bus + ? await installTransitFixture(p) + : await installCombinedFixture(p); + fx.setStep?.(9); + await p.goto(url.href); + await p.waitForFunction( + () => + window.__godsEyeView?.engine?.kind === 'navara' && + document.querySelector('#loading-screen.hidden'), + ); + await p.evaluate(async (id) => { + const { dataManager, styleManager, tileset } = window.__godsEyeView; + if (tileset || window.__GOOGLE_MAPS_API_KEY__) + throw Error('Expected keyless preview'); + await styleManager.initialRestorePromise; + window.qaUrban = dataManager.layers.get(id).module; + await dataManager.setEnabled(id, true, { origin: 'user' }); + }, id); + await p.waitForFunction(() => window.qaUrban.getStats().count > 0, { + timeout: 30000, + }); + const handle = await p.waitForFunction( + () => { + const a = window.__godsEyeView; + for (const r of window.qaUrban.getDetectableObjects({ + maxCount: 500, + seed: 0, + })) { + const s = a.engine.projector.worldToWindow(r.position); + if (!s || s.x < 420 || s.x > 860 || s.y < 205 || s.y > 670) continue; + const pk = a.engine.picker.pick(s); + if (pk?.id === r.sourceId || pk?.id?.id === r.sourceId) + return { x: s.x, y: s.y, id: r.sourceId }; + } + return false; + }, + { timeout: 30000 }, + ); + const target = await handle.jsonValue(); + await p.mouse.click(target.x, target.y); + await p.waitForFunction( + (key) => + window.qaUrban + .getDetectableObjects({ maxCount: 500, seed: 0 }) + .some((r) => r.sourceId === key && r.skipLabel), + {}, + target.id, + ); + const reads = () => + fx.requests.filter((path) => + bus + ? path.includes('/transit/trail/') + : path.includes('station_information'), + ).length; + if (bus) + await p.waitForFunction( + () => window.qaUrban.getDetectableObjects({ maxCount: 500 }).length > 0, + ); + const original = reads(); + const first = await p.evaluate(() => + window.qaUrban.getAnalystRecords(500).map((v) => v.id), + ); + for (const kind of ['cesium', 'navara']) { + await switchGlobe(p, kind); + await p.waitForFunction( + (key) => + window.qaUrban + .getDetectableObjects({ maxCount: 500, seed: 0 }) + .some((r) => r.sourceId === key && r.skipLabel), + { timeout: 20000 }, + target.id, + ); + const v = await p.evaluate( + (id) => ({ + same: + window.qaUrban === + window.__godsEyeView.dataManager.layers.get(id).module, + stats: window.qaUrban.getStats(), + ids: window.qaUrban.getAnalystRecords(500).map((v) => v.id), + }), + id, + ); + console.log(kind, id, v.stats.count, target.id, reads()); + assert.ok(v.same); + assert.deepEqual(v.ids, first); + assert.equal(reads(), original); + } + assert.deepEqual(errors, []); + await fx.dispose?.(); + console.log( + 'PASS viewport activation, real pick and original city data across two switches', + ); +} finally { + await b.close(); +} diff --git a/src/app/constructCatalog.js b/src/app/constructCatalog.js index c87962e3f..4bf1ebd06 100644 --- a/src/app/constructCatalog.js +++ b/src/app/constructCatalog.js @@ -253,11 +253,11 @@ export function createApplicationCatalog({ ], [ 'transit', - () => createApplicationTransit({ surface, source: sources.transit }), + () => sessionModel('transit', () => createApplicationTransit({ surface, source: sources.transit })), ], [ 'bikeshare', - () => createApplicationBikeshare({ source: sources.bikeshare }), + () => sessionModel('bikeshare', () => createApplicationBikeshare({ source: sources.bikeshare })), ], [ 'directions', diff --git a/src/layers/bikeshare/index.js b/src/layers/bikeshare/index.js index aa94c95ec..94bee7237 100644 --- a/src/layers/bikeshare/index.js +++ b/src/layers/bikeshare/index.js @@ -23,7 +23,10 @@ export function createBikeshareLayer({ services, source, rendererFactory }) { let renderer = null; let initialized = false; const lifecycle = { - init(viewer, options = {}) { + init(viewer, options) { + return this.attachRenderer(viewer, options); + }, + attachRenderer(viewer, options = {}) { if (initialized) throw new Error('Bikeshare layer is already initialized'); renderer ??= rendererFactory({ @@ -36,12 +39,44 @@ export function createBikeshareLayer({ services, source, rendererFactory }) { Object.assign(parts, renderer); initialized = true; try { - return renderer.lifecycle.methods.init(viewer, options); + state._restoringViewport = Boolean(options.waitForCameraRestore); + renderer.lifecycle.methods.init(viewer, options); + if (state._enabled) { + const selected = state._selectedKey; + for (const city of state._activeCityIds) { + const stations = state._stationInfoCache.get(city); + if (!stations) continue; + parts.rendering.ensureCityPoints(city, stations); + const status = state._statusCache.get(city)?.statusMap; + if (status) parts.rendering.applyStatusToPoints(city, status); + } + if (selected) parts.selection._selectStation(selected); + renderer.lifecycle.methods.show(viewer); + } } catch (error) { initialized = false; throw error; } }, + resumeViewport() { + if (!state._restoringViewport) return; + state._restoringViewport = false; + if (state._enabled) parts.viewport?.onCameraChanged(); + }, + detachRenderer() { + if (!renderer) return; + state._restoringViewport = true; + clearTimeout(state._cameraDebounceTimer); + const selected = state._selectedKey; + renderer.lifecycle.methods.detach(); + state._selectedKey = selected; + state._cityRuntime.clear(); + state._stationRenderMap.clear(); + state._viewer = state._engine = null; + renderer = null; + initialized = false; + for (const key of ['rendering', 'selection', 'viewport', 'lifecycle']) delete parts[key]; + }, enable(...args) { return renderer?.lifecycle.methods.enable(...args); }, diff --git a/src/layers/bikeshare/ingestion.js b/src/layers/bikeshare/ingestion.js index 19548c8ee..16e0a5d7b 100644 --- a/src/layers/bikeshare/ingestion.js +++ b/src/layers/bikeshare/ingestion.js @@ -187,7 +187,7 @@ export function createIngestion({ try { const statusMap = await loadCityStationStatus(cityId, generation); if (!isCurrent(cityId)) return; - parts.rendering.applyStatusToPoints(cityId, statusMap); + parts.rendering?.applyStatusToPoints(cityId, statusMap); layerState._failedCityIds.delete(cityId); refreshed = true; } catch (error) { diff --git a/src/layers/bikeshare/render/cesium/lifecycle.js b/src/layers/bikeshare/render/cesium/lifecycle.js index abc2c947d..2571dbe8c 100644 --- a/src/layers/bikeshare/render/cesium/lifecycle.js +++ b/src/layers/bikeshare/render/cesium/lifecycle.js @@ -35,29 +35,6 @@ export function createLifecycle({ registerSpriteCollection('bikeshare', layerState._pointCollection); layerState._pointCollection.show = false; - layerState._enabled = false; - layerState._cameraDebounceTimer = null; - layerState._cameraChangedAttached = false; - layerState._altitudeGateEnabled = false; - layerState._proximityGeneration = 0; - - layerState._activeCityIds = new Set(); - layerState._cityRuntime = new Map(); - layerState._stationInfoCache = new Map(); - layerState._statusCache = new Map(); - layerState._inFlightInfo = new Map(); - layerState._inFlightStatus = new Map(); - layerState._stationRenderMap = new Map(); - layerState._clickHandler = null; - layerState._selectedKey = null; - layerState._selectedEntity = null; - layerState._count = 0; - layerState._lastUpdate = null; - layerState._loading = false; - layerState._loadingOps = 0; - layerState._failedCityIds.clear(); - layerState._limitWarned = false; - layerState._overlayHost.setVisible( BIKESHARE_SELECTED_OVERLAY_SOURCE_ID, false, @@ -79,9 +56,12 @@ export function createLifecycle({ */ enable(viewer) { if (layerState._enabled) return; - viewer = layerState._viewer; layerState._enabled = true; layerState._failedCityIds.clear(); + this.show(viewer); + }, + show(viewer) { + viewer = layerState._viewer; layerState._pointCollection.show = true; layerState._overlayHost.setVisible( BIKESHARE_SELECTED_OVERLAY_SOURCE_ID, @@ -149,6 +129,23 @@ export function createLifecycle({ layerState._loadingOps = 0; }, + detach() { + const viewer = layerState._viewer; + parts.selection._clearSelection(); + layerState._clickHandler?.destroy(); + layerState._clickHandler = null; + document.removeEventListener('keydown', parts.selection._onKeyDown); + unregisterPickOwner('bikeshare'); + if (layerState._cameraChangedAttached) { + viewer.camera.changed.removeEventListener(parts.viewport.onCameraChanged); + releaseCameraSensitivity(viewer.camera, 'bikeshare'); + layerState._cameraChangedAttached = false; + } + unregisterSpriteCollection?.('bikeshare', layerState._pointCollection); + viewer.scene.primitives.remove(layerState._pointCollection); + layerState._pointCollection = null; + }, + /** Permanently release primitives, handlers, and the selected host source. */ destroy(viewer) { viewer = layerState._viewer; diff --git a/src/layers/bikeshare/render/navara.js b/src/layers/bikeshare/render/navara.js index 33ccdc79d..504f48cc4 100644 --- a/src/layers/bikeshare/render/navara.js +++ b/src/layers/bikeshare/render/navara.js @@ -311,27 +311,6 @@ export function createBikeshareNavaraRenderer(engine, context) { if (destroyed) throw new Error('Bikeshare renderer is destroyed'); state._engine = engine; state._viewer = null; - state._enabled = false; - state._cameraDebounceTimer = null; - state._cameraChangedAttached = false; - state._altitudeGateEnabled = false; - state._proximityGeneration = 0; - state._activeCityIds = new Set(); - state._cityRuntime = new Map(); - state._stationInfoCache = new Map(); - state._statusCache = new Map(); - state._inFlightInfo = new Map(); - state._inFlightStatus = new Map(); - state._stationRenderMap = new Map(); - state._clickHandler = null; - state._selectedKey = null; - state._selectedEntity = null; - state._count = 0; - state._lastUpdate = null; - state._loading = false; - state._loadingOps = 0; - state._error = null; - state._limitWarned = false; normalSet.setCount(0); selectedSet.setCount(0); normalSet.commit(); @@ -345,6 +324,9 @@ export function createBikeshareNavaraRenderer(engine, context) { if (destroyed || state._enabled) return; state._enabled = true; state._error = null; + this.show(); + }, + show() { state._overlayHost.setVisible( BIKESHARE_SELECTED_OVERLAY_SOURCE_ID, true, @@ -389,6 +371,15 @@ export function createBikeshareNavaraRenderer(engine, context) { state._loading = false; state._loadingOps = 0; }, + detach() { + selection._clearSelection(); + selectionRemove?.(); + cameraRemove?.(); + state._cameraChangedAttached = false; + normalSet.destroy(); + selectedSet.destroy(); + destroyed = true; + }, destroy() { if (destroyed) return; this.disable(); diff --git a/src/layers/bikeshare/viewport.js b/src/layers/bikeshare/viewport.js index 8bd53f832..41b7cd0e5 100644 --- a/src/layers/bikeshare/viewport.js +++ b/src/layers/bikeshare/viewport.js @@ -69,7 +69,7 @@ export function createViewport({ state: layerState, services, parts, camera }) { governorRequestRender('bikeshare-deactivate'); parts.ingestion.abortInFlight(layerState._inFlightInfo, cityId); parts.ingestion.abortInFlight(layerState._inFlightStatus, cityId); - parts.rendering.removeCityPoints(cityId); + parts.rendering?.removeCityPoints(cityId); } /** Deactivate all currently active cities and clear the active set. */ @@ -107,12 +107,12 @@ export function createViewport({ state: layerState, services, parts, camera }) { ) return; - parts.rendering.ensureCityPoints(cityId, stationMap); + parts.rendering?.ensureCityPoints(cityId, stationMap); // Apply any cached status immediately for snappier initial rendering const cachedStatus = layerState._statusCache.get(cityId)?.statusMap; if (cachedStatus) { - parts.rendering.applyStatusToPoints(cityId, cachedStatus); + parts.rendering?.applyStatusToPoints(cityId, cachedStatus); } const statusMap = await parts.ingestion.loadCityStationStatus( @@ -125,7 +125,7 @@ export function createViewport({ state: layerState, services, parts, camera }) { generation !== layerState._proximityGeneration ) return; - parts.rendering.applyStatusToPoints(cityId, statusMap); + parts.rendering?.applyStatusToPoints(cityId, statusMap); layerState._lastUpdate = Date.now(); layerState._failedCityIds.delete(cityId); } catch (error) { @@ -152,7 +152,7 @@ export function createViewport({ state: layerState, services, parts, camera }) { */ async function runProximityCheck() { - if (!layerState._enabled) return; + if (!layerState._enabled || layerState._restoringViewport) return; const generation = ++layerState._proximityGeneration; const altitude = getCameraAltitude(layerState._viewer); @@ -208,7 +208,7 @@ export function createViewport({ state: layerState, services, parts, camera }) { /** Camera change event handler — triggers a debounced proximity check. */ function onCameraChanged() { - if (!layerState._enabled) return; + if (!layerState._enabled || layerState._restoringViewport) return; scheduleProximityCheck(); } return { diff --git a/src/layers/transit/lifecycle.js b/src/layers/transit/lifecycle.js index 180e9e1f6..f92193777 100644 --- a/src/layers/transit/lifecycle.js +++ b/src/layers/transit/lifecycle.js @@ -114,7 +114,11 @@ export function createLifecycle({ * Create the point collection. Called once at bootstrap. * @param {Cesium.Viewer} viewer */ - init(viewer, options = {}) { + init(viewer, options) { + this.attachRenderer(viewer, options); + }, + attachRenderer(viewer, options = {}) { + state._restoringViewport = Boolean(options.waitForCameraRestore); if (parts.renderer) throw new Error('Transit layer is already initialized'); if (typeof rendererFactory !== 'function') @@ -137,27 +141,16 @@ export function createLifecycle({ state._viewer = null; throw error; } - state._enabled = false; - state._activeFeeds.clear(); - state._feedStatus.clear(); - state._inFlight.clear(); - for (const entry of state._vehicles.values()) { - entry.detectContact = null; - parts.rendering.cancelWake(entry); - destroyTrack(entry.track); + state._overlayHost.setVisible(TRANSIT_SELECTED_OVERLAY_SOURCE_ID, state._enabled); + if (state._enabled) { + for (const [key, entry] of state._vehicles) { + parts.rendering.addMarker(key, entry); + entry.detectContact = null; + entry.pathRevision = -1; + } + this.bindScene(viewer); + if (!state._restoringViewport) this.repaint(); } - state._vehicles.clear(); - parts.height.clear(); - state._selectedKey = null; - state._lastUpdate = null; - state._error = null; - state._limitWarned = false; - state._altitudeGateOpen = false; - state._moving.clear(); - state._heightDirty.clear(); - state._rotationPose = null; - state._rotationDirty = false; - state._overlayHost.setVisible(TRANSIT_SELECTED_OVERLAY_SOURCE_ID, false); parts.renderer.restoreOrder(); bindStyleEvents(); console.log( @@ -171,9 +164,13 @@ export function createLifecycle({ */ enable(viewer) { state._enabled = true; - bindVisibility(); state._generation += 1; state._error = null; + this.bindScene(viewer); + }, + + bindScene(viewer) { + bindVisibility(); state._markers.show = true; state._animatedMarkers.show = true; state._maintenanceTimer = setInterval( @@ -199,6 +196,51 @@ export function createLifecycle({ parts.renderer.restoreOrder(); }, + repaint() { + state._visibilityAt = 0; + state._rotationDirty = true; + parts.viewport.runProximityCheck(); + parts.rendering.refreshVisibility(); + parts.trails.rebind(); + parts.selection.refreshSelectedCard(true); + for (const feed of state._activeFeeds.values()) parts.renderer.addCredit(feed); + }, + resumeViewport() { + if (!state._restoringViewport) return; + state._restoringViewport = false; + if (state._enabled && parts.renderer) this.repaint(); + }, + detachRenderer() { + if (!parts.renderer) return; + state._restoringViewport = true; + unbindVisibility(); + clearInterval(state._maintenanceTimer); + clearTimeout(state._cameraDebounceTimer); + clearTimeout(state._visibilityTimer); + state._maintenanceTimer = state._cameraDebounceTimer = state._visibilityTimer = null; + parts.selection.removeClickHandler(); + unregisterPickOwner('transit'); + state._cameraChangedRemove?.(); + state._cameraChangedRemove = null; + if (state._cameraChangedAttached) returnCameraSensitivity(); + state._cameraChangedAttached = false; + state._preRenderRemove?.(); + state._preRenderRemove = null; + parts.trails.detachRenderer(); + for (const entry of state._vehicles.values()) { + parts.rendering.cancelWake(entry); + entry.marker = entry.markerCollection = entry.detectContact = null; + } + state._detectCache = null; + state._detectBuiltAt = -Infinity; + state._visible.clear(); + state._moving.clear(); + if (state._renderHeld) releaseContinuousRender('transit'); + state._renderHeld = false; + parts.renderer.destroy(); + parts.renderer = state._markers = state._animatedMarkers = state._viewer = null; + }, + /** * Hide everything, stop polling, drop all vehicles. * @param {Cesium.Viewer} viewer diff --git a/src/layers/transit/rendering.js b/src/layers/transit/rendering.js index a69508a69..4c130e49d 100644 --- a/src/layers/transit/rendering.js +++ b/src/layers/transit/rendering.js @@ -91,6 +91,7 @@ export function createRendering({ state, services, parts }) { ); entry.surfaceReady = !!known; entry.heightPending = !known; + if (!entry.marker) return; entry.marker.show = !!known && vehicleInView(entry); if (known) { entry.marker.position = state._scratchCartesian; @@ -239,6 +240,7 @@ export function createRendering({ state, services, parts }) { } function addMarker(key, entry) { + if (!state._markers) return null; const marker = state._markers.add({ id: key, position: entry.toCart, diff --git a/src/layers/transit/trails.js b/src/layers/transit/trails.js index 43fa53d0d..bd009e4be 100644 --- a/src/layers/transit/trails.js +++ b/src/layers/transit/trails.js @@ -316,6 +316,18 @@ export function createTrails({ state, services, parts, source }) { services.render.governorRequestRender('transit-trail-age'); }, 1000); } + function detachRenderer() { + renderer?.destroy(); + renderer = null; + } + function rebind() { + if (!selected) return; + ensureRenderer(); + selected.pathRevision = -1; + style(); + prepareEntry(selected); + update(); + } function clear() { if (historyRequest) { historyRequest.abort(); @@ -338,6 +350,8 @@ export function createTrails({ state, services, parts, source }) { update, style, destroy: clear, + detachRenderer, + rebind, requestDiagnostics: () => ({ requestCount, abortCount, diff --git a/src/layers/transit/viewport.js b/src/layers/transit/viewport.js index 1cc36aa7b..d710dd187 100644 --- a/src/layers/transit/viewport.js +++ b/src/layers/transit/viewport.js @@ -79,7 +79,7 @@ export function createViewport({ state, services, parts }) { globalThis.document?.visibilityState === 'hidden' ) return; - if (!state._enabled || !parts.renderer?.ready || !state._viewer) return; + if (!state._enabled || !parts.renderer?.ready || state._restoringViewport) return; refreshViewBounds(); const altitude = getCameraAltitude(); state._altitudeGateOpen = altitudeGateOpen(altitude); @@ -119,6 +119,7 @@ export function createViewport({ state, services, parts }) { } function onCameraChanged() { + if (state._restoringViewport) return; // The bounds update immediately; the feed proximity check stays debounced. // A person dragging the globe should not wait a third of a second for the // vehicles under the cursor to start moving again.