Keep city stations, vehicle tracks and trail history across globe switches

This commit is contained in:
German Khokhlov committed 2026-11-03 04:21:48 +04:00
1 parent c4a97a653c
commit 24190a3e35
11 files changed
+288 -79

No files matched your search

+127
View File
@@ -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();
}
+2 -2
View File
@@ -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',
+37 -2
View File
@@ -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);
},
+1 -1
View File
@@ -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) {
+21 -24
View File
@@ -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;
+12 -21
View File
@@ -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();
+6 -6
View File
@@ -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 {
+64 -22
View File
@@ -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
+2
View File
@@ -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,
+14
View File
@@ -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,
+2 -1
View File
@@ -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.