Keep traffic vehicles and road flow across globe switches

This commit is contained in:
German Khokhlov committed 2026-11-03 04:09:21 +04:00
1 parent 4e09559337
commit 75df3389f3
20 files changed
+586 -109

No files matched your search

+1
View File
@@ -3679,6 +3679,7 @@
"src/layers/shared/render/navaraStems.js",
"src/layers/submarineCables/render/navara.js",
"src/layers/directions/render/navara.js",
"src/layers/traffic/render/navara.js",
"src/layers/shared/render/navaraStory.js",
"src/layers/bhoteKoshi/render/navara.js",
"src/layers/weather/render/navara.js",
+182
View File
@@ -0,0 +1,182 @@
import { trafficFixtureResponse } from './traffic-fixtures.mjs';
import puppeteer from 'puppeteer';
import sharp from 'sharp';
import assert from 'node:assert/strict';
import { blockPaidQaMaps } from './qa-map-policy.mjs';
const base = new URL(process.argv[2]);
if (
!['localhost', '127.0.0.1'].includes(base.hostname) ||
['', '4173', '4185', '4186', '4187', '4188', '4189', '4194'].includes(
base.port,
)
)
throw new Error('Supply an unreserved local keyless preview URL');
base.search = '?engine=navara&welcome=0&look=cinematic';
base.hash =
'v=2&lat=30.2670&lon=-97.7440&alt=1800&heading=0&pitch=-90&roll=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: 1024, height: 768 });
const errors = [];
p.on('pageerror', (e) => {
errors.push(e.message);
console.log('error', e.message.split('?')[0]);
});
const keyless = process.argv.includes('--simulation');
const reads = new Map();
await p.setRequestInterception(true);
p.on('request', async (req) => {
const u = new URL(req.url());
if (
u.hostname === 'tiles.openfreemap.org' ||
u.pathname.startsWith('/api/tomtom/')
) {
reads.set(u.href, (reads.get(u.href) || 0) + 1);
if (keyless && u.pathname === '/api/tomtom/status')
return req.respond({
status: 200,
contentType: 'application/json',
body: '{"hasKey":false}',
});
const response = trafficFixtureResponse(req);
if (response) return req.respond(response);
throw Error('unrecognized traffic URL: ' + u.pathname);
}
return req.continue();
});
p.on('console', (msg) => {
if (msg.type() === 'error' && /Shader|WebGLProgram/i.test(msg.text()))
errors.push(msg.text());
});
await p.goto(base.href);
const ready = async (kind) => {
await p.waitForFunction(
(k) =>
window.__godsEyeView?.engine.kind === k &&
!document.querySelector('#globe-engine')?.disabled &&
!document.querySelector('.engine-switch-frame') &&
document.querySelector('#loading-screen.hidden'),
{ timeout: 90000 },
kind,
);
await p.evaluate(
() => window.__godsEyeView.styleManager.initialRestorePromise,
);
};
await ready('navara');
await p.evaluate(async () => {
const { tileset, dataManager } = window.__godsEyeView;
if (tileset || window.__GOOGLE_MAPS_API_KEY__)
throw Error('Keyless preview required');
window.qaTraffic = dataManager.layers.get('traffic').module;
window.qaTraffic.setParams({ jamViz: 'both' });
await dataManager.setEnabled('traffic', true, { origin: 'user' });
});
const waitTraffic = async () => {
try {
await p.waitForFunction(() => window.qaTraffic.getStats().count >= 10, {
timeout: 40000,
});
} catch (e) {
console.log(
'no traffic',
await p.evaluate(() => window.qaTraffic.getStats()),
);
throw e;
}
};
await waitTraffic();
await new Promise((resolve) => setTimeout(resolve, 2000));
const ids = await p.evaluate(() =>
window.qaTraffic.getDetectableObjects().map((p) => p.id),
);
const first = await p.evaluate(
() => window.qaTraffic.getStats().motion.rebuilds,
);
for (const kind of ['navara', 'cesium', 'navara']) {
if ((await p.evaluate(() => window.__godsEyeView.engine.kind)) !== kind) {
await p.select('#globe-engine', kind);
await ready(kind);
}
await waitTraffic();
const check = await p.evaluate(() => {
const { dataManager } = window.__godsEyeView;
const mod = dataManager.layers.get('traffic').module;
return {
same: mod === window.qaTraffic,
...mod.getStats(),
params: mod.getParams(),
ids: mod.getDetectableObjects().map((p) => p.id),
};
});
console.log(
kind,
check.mode,
check.count,
check.flowBuckets,
check.heatLines,
check.motion,
);
assert.equal(check.same, true);
assert.equal(check.params.jamViz, 'both');
assert.equal(check.error, null);
assert.equal(
check.motion.rebuilds,
first,
'traffic population was cleared',
);
assert.ok(
check.ids.some((id) => ids.includes(id)),
'original moving vehicles survived',
);
if (!keyless)
assert.ok(check.heatLines > 0, 'congestion corridors are published');
if (keyless) assert.equal(check.flowBuckets.sim, check.count);
else assert.ok(check.flowBuckets.slow + check.flowBuckets.jam > 0);
}
if (keyless)
assert.ok(![...reads.keys()].some((u) => u.includes('/tomtom/flow/')));
const pixels = async () =>
sharp(await p.screenshot())
.extract({ left: 360, top: 255, width: 330, height: 260 })
.removeAlpha()
.raw()
.toBuffer();
// Source counts can be ready before the incoming renderer has drawn terrain.
// Let the camera and the newly attached road heights settle before comparison.
await new Promise((resolve) => setTimeout(resolve, 2200));
const shown = await pixels();
await p.evaluate(() =>
window.__godsEyeView.dataManager.setEnabled('traffic', false, {
origin: 'user',
}),
);
await new Promise((resolve) => setTimeout(resolve, 600));
const hidden = await pixels();
let visible = 0;
for (let i = 0; i < shown.length; i += 3) {
if (
shown[i] +
shown[i + 1] +
shown[i + 2] -
(hidden[i] + hidden[i + 1] + hidden[i + 2]) >
110
)
visible++;
}
console.log('changed traffic pixels', visible);
assert.ok(visible > 15, 'changing the layer changes the rendered streets');
assert.deepEqual(errors, []);
console.log(
'PASS retained traffic population and flow, real geography and stable identities across both globes',
);
} finally {
await b.close();
}
+3 -1
View File
@@ -241,7 +241,9 @@ export function createApplicationCatalog({
],
[
'traffic',
() => createApplicationTraffic({ source: sources.traffic, surface }),
() => sessionModel('traffic', () => createApplicationTraffic({
source: sources.traffic, surface, nativeRenderer: engine => rendererFor('traffic',engine),
})),
],
['cctv', () => createApplicationCctv({ surface, source: sources.cctv })],
[
+1 -1
View File
@@ -47,7 +47,7 @@ export function createApplicationData({
throw new TypeError('An application layer catalog is required');
for (const layer of catalog.layers)
layer.attachMapStackController?.(mapStackController);
const attached = dataManager.bindCatalog(viewer, engine, catalog);
const attached = dataManager.bindCatalog(viewer, engine, catalog, { waitForCameraRestore: layerSession?.restoringView === true });
for (const layer of catalog.layers) layer.attachDataManager?.(dataManager);
if (allowQaRegistration) {
+2 -2
View File
@@ -4,9 +4,9 @@ import * as ground from '../../data/groundFloor.js';
import * as render from '../../renderGovernor.js';
/** Construct one layer using the application scene owners and a supplied source. */
export function createApplicationTraffic({ source, surface }) {
export function createApplicationTraffic({ source, surface, nativeRenderer }) {
return createTrafficLayer({
source,
services: { credits, render, ground: surface?.groundFloor || ground },
services: { credits, render, nativeRenderer, ground: surface?.groundFloor || ground },
});
}
+1
View File
@@ -56,6 +56,7 @@ test('the Navara mode registers migrated renderers once', async () => {
'alpr-cameras',
'satellites',
'rocket-launches',
'traffic',
'cctv',
'radio',
'transit',
+4
View File
@@ -126,6 +126,10 @@ export async function registerNavaraLayerRenderers() {
const { drawStorySource } = await import('../layers/shared/render/navaraStory.js');
return (engine, { data }) => drawStorySource(engine, navaraViews.get(engine), data);
},
traffic: async () => {
const { createTrafficNavaraRenderer } = await import('../layers/traffic/render/navara.js');
return engine => createTrafficNavaraRenderer(engine, navaraViews.get(engine));
},
directions: async () => {
const { createDirectionsNavaraRenderer } = await import('../layers/directions/render/navara.js');
return (engine) => createDirectionsNavaraRenderer(engine, navaraViews.get(engine));
+2 -2
View File
@@ -142,7 +142,7 @@ export class LayerLifecycle {
}
/** Bind the next catalog only after the preceding scene released its entries. */
bindCatalog(viewer, engine, catalog) {
bindCatalog(viewer, engine, catalog, options = {}) {
if (this._registrationsFinalized) throw new Error('Scene already attached');
this.viewer = viewer;
this.engine = engine ?? engineFromViewer(viewer) ?? null;
@@ -154,7 +154,7 @@ export class LayerLifecycle {
else {
if (entry.module !== layer) throw new Error(`Layer owner changed: ${layer.id}`);
if (entry.initialized) {
const ready = layer.attachRenderer(viewer, this._moduleContext());
const ready = layer.attachRenderer(viewer, { ...this._moduleContext(), ...options });
if (ready?.then) attachments.push(ready);
}
}
+6 -2
View File
@@ -33,7 +33,8 @@ export function createAnimation({
if (needed) {
layerState._lastAnimTime = 0;
services.render.holdContinuousRender('traffic');
layerState._viewer?.scene?.requestRender?.();
if (layerState._engine) layerState._engine.render.request('traffic');
else layerState._viewer?.scene?.requestRender?.();
} else services.render.releaseContinuousRender('traffic');
}
@@ -318,6 +319,9 @@ export function createAnimation({
// Jam heat-lines throb (~1.6 s period) — one shared material uniform for
// the whole jam batch, no geometry rebuild. Null outside live heatline mode.
if (layerState._native && layerState._heatJamPrim) {
layerState._heatJamPrim.setAlpha(HEAT_JAM_BASE_ALPHA + HEAT_JAM_PULSE_ALPHA * Math.sin(now / 260));
}
if (layerState._heatJamPrim?.appearance) {
layerState._heatJamPrim.appearance.material.uniforms.color.alpha =
HEAT_JAM_BASE_ALPHA + HEAT_JAM_PULSE_ALPHA * Math.sin(now / 260);
@@ -349,7 +353,7 @@ export function createAnimation({
/** Remove all point primitives and reset dot/road arrays and counters. */
function clearDots() {
services.credits?.hideOsmCredit?.(layerState._viewer, 'traffic');
services.credits?.hideOsmCredit?.(layerState._engine ?? layerState._viewer, 'traffic');
parts.retention.clear();
if (layerState._dots.length) layerState._motion.rebuilds++;
if (layerState._pointCollection) layerState._pointCollection.removeAll();
+14 -10
View File
@@ -70,7 +70,7 @@ export function createFlow({ state: layerState, services, parts, source }) {
layerState._flowStatusKnown = true;
if (layerState._liveMode) {
console.log('[Data:Traffic] TomTom key present — live flow mode');
registerDynamicCredit(layerState._viewer, TOMTOM_CREDIT);
registerDynamicCredit(layerState._engine ?? layerState._viewer, TOMTOM_CREDIT);
}
})
.catch((e) => {
@@ -217,6 +217,10 @@ export function createFlow({ state: layerState, services, parts, source }) {
) {
if (generation !== layerState._loadGeneration || signal?.aborted)
return false;
if (layerState._waitForScene) {
await layerState._waitForScene;
if (generation !== layerState._loadGeneration || signal?.aborted) return false;
}
const revision = (layerState._surfaceRevision || 0) + 1;
layerState._surfaceRevision = revision;
const current = () =>
@@ -237,6 +241,8 @@ export function createFlow({ state: layerState, services, parts, source }) {
layerState._retainedPreview = true;
return true;
}
if (roads.length && !majorPreview && !label.includes('refined'))
layerState._surfaceInput = { roads, bounds: clamped };
const preparedRoads =
layerState._preparedRoads || (layerState._preparedRoads = new Map());
const refining = label.includes('refined');
@@ -253,7 +259,8 @@ export function createFlow({ state: layerState, services, parts, source }) {
parts.rendering.renderRoadsForAltitude([], altitude, label, trace);
return true;
}
const scene = layerState._viewer.scene;
const scene = layerState._trafficScene?.() ?? layerState._viewer?.scene;
if (!scene) return false;
// The enable-time estimate can predate the first local mesh (especially
// after an intercontinental jump). Once previews have measured roads, use
// that local evidence to select the detail view at its real elevation.
@@ -325,7 +332,7 @@ export function createFlow({ state: layerState, services, parts, source }) {
const selected = parts.rendering
.visibleRoadsForAltitude(roads, altitude)
.filter((road) => {
const camera = layerState._viewer.camera?.positionWC;
const camera = (layerState._engine?.camera.livePosition ?? layerState._viewer?.camera?.positionWC);
const localHeight = Math.max(
0,
altitude - (layerState._viewHeightEstimate || 0),
@@ -349,10 +356,7 @@ export function createFlow({ state: layerState, services, parts, source }) {
maxX = -Infinity,
maxY = -Infinity;
for (const point of road.waypoints) {
const screen = Cesium.SceneTransforms.worldToWindowCoordinates(
scene,
point,
);
const screen = scene.project ? scene.project(point, {}) : Cesium.SceneTransforms.worldToWindowCoordinates(scene, point);
if (!screen) continue;
minX = Math.min(minX, screen.x);
maxX = Math.max(maxX, screen.x);
@@ -382,7 +386,7 @@ export function createFlow({ state: layerState, services, parts, source }) {
roadDotBudget(altitude),
);
const admitted = selected.filter((r, i) => budgets[i] > 0);
const camera = layerState._viewer.camera?.positionWC;
const camera = (layerState._engine?.camera.livePosition ?? layerState._viewer?.camera?.positionWC);
if (camera)
admitted.sort(
(a, b) =>
@@ -478,7 +482,7 @@ export function createFlow({ state: layerState, services, parts, source }) {
};
let prepared = await prepareRoadSurfaces(
unresolved,
layerState._viewer.scene,
scene,
services.ground,
[layerState._pointCollection],
signal,
@@ -500,7 +504,7 @@ export function createFlow({ state: layerState, services, parts, source }) {
if (!current()) return false;
prepared = await prepareRoadSurfaces(
prepared.pending,
layerState._viewer.scene,
scene,
services.ground,
[layerState._pointCollection],
signal,
+13 -10
View File
@@ -69,16 +69,18 @@ export function createReticleFootprint() {
const hits = Array.from({ length: 25 }, () => geo.cartesian3.create());
const carto = geo.cartographic.create();
const ray = new Cesium.Ray();
return (viewer, altitude) => {
const { camera, scene } = viewer;
if (!camera.getPickRay) return null;
const nadir = camera.positionCartographic;
return (viewer, altitude, engine) => {
const camera = engine?.camera ?? viewer?.camera;
if (!camera?.getPickRay && !camera?.pickRay) return null;
const pose = engine?.camera.pose();
const nadir = pose?.cartographic ?? camera.positionCartographic;
const canvas = engine?.host.canvas ?? viewer.scene.canvas;
const near = {
lat: geo.math.toDegrees(nadir.latitude),
lon: geo.math.toDegrees(nadir.longitude),
};
const width = scene.canvas.clientWidth || scene.canvas.width,
height = scene.canvas.clientHeight || scene.canvas.height;
const width = canvas.clientWidth || canvas.width,
height = canvas.clientHeight || canvas.height;
const side = Math.min(width, height),
left = (width - side) / 2,
top = (height - side) / 2;
@@ -98,8 +100,9 @@ export function createReticleFootprint() {
dlon -= 360 * Math.round(dlon / 360);
dx = dlon * 111.195 * Math.cos(nadir.latitude);
} else {
camera.getPickRay(p, ray);
const d = ray.direction;
const picked = engine ? camera.pickRay(p) : camera.getPickRay(p, ray) || ray;
if (!picked) continue;
const d = picked.direction;
dx =
-Math.sin(nadir.longitude) * d.x + Math.cos(nadir.longitude) * d.y;
dy =
@@ -155,8 +158,8 @@ export function createReticleFootprint() {
nadir.latitude,
nadir.longitude,
nadir.height / 10000,
camera.heading,
camera.pitch,
pose?.heading ?? camera.heading,
pose?.pitch ?? camera.pitch,
]
.map((v) => Number(v || 0).toFixed(7))
.join(','),
+2 -2
View File
@@ -270,12 +270,12 @@ export function createIngestion({
layerState._viewHeightEstimate = 0;
const centerLat = (clamped.south + clamped.north) / 2;
const centerLon = (clamped.west + clamped.east) / 2;
const scene = layerState._viewer?.scene;
const scene = layerState._trafficScene?.() ?? layerState._viewer?.scene;
const location = geo.cartographic.fromDegrees(centerLon, centerLat);
let estimated = services.ground?.cachedGroundFloor?.(centerLat, centerLon);
if (!Number.isFinite(estimated)) {
try {
estimated = scene?.globe?.show
estimated = scene?.engine ? scene.sampleHeight(location) : scene?.globe?.show
? scene.globe.getHeight?.(location)
: scene?.sampleHeightSupported
? scene.sampleHeight(location, [layerState._pointCollection])
+123 -59
View File
@@ -14,6 +14,51 @@ export function createLifecycle({
}) {
const { resetFlowTileCache } = source;
function readyForSurfaceWork() {
const resolve = layerState._sceneReady;
layerState._sceneReady = layerState._waitForScene = null;
resolve?.();
}
function bindScene() {
const viewer = layerState._viewer, engine = layerState._engine;
const camera = engine?.camera ?? viewer.camera;
const frames = engine?.frames ?? viewer.scene;
if (viewer) layerState._surfaceFrameRemover = observeTrafficSurface(viewer.scene);
layerState._pointCollection.show = true;
layerState._lastAnimTime = 0;
parts.animation.syncRenderDemand();
layerState._preRenderRemover = frames.preRender.addEventListener(() => {
parts.animation.animate();
layerState._native?.points.flush();
});
if (TRAFFIC_TIMING_ENABLED) {
parts.timing.clearTrafficTimingEntries();
layerState._trafficTimingCurrentAnchor = null;
layerState._trafficTimingPostRenderRemovers = new Set();
layerState._trafficTimingMoveEndRemover = camera.moveEnd.addEventListener(parts.timing.markTrafficTimingMoveEnd);
}
layerState._cameraRemover = camera.changed.addEventListener(parts.viewport.onCameraChanged);
layerState._arrivalRemover = camera.moveEnd.addEventListener(() => parts.viewport.onCameraChanged({immediate:true}));
claimCameraSensitivity(engine ?? camera,'traffic',0.05);
}
function unbindScene() {
const camera = layerState._engine?.camera ?? layerState._viewer?.camera;
layerState._surfaceFrameRemover?.();
layerState._preRenderRemover?.();
layerState._trafficTimingMoveEndRemover?.();
for (const remove of layerState._trafficTimingPostRenderRemovers || []) remove();
layerState._trafficTimingPostRenderRemovers = null;
layerState._trafficTimingCurrentAnchor = null;
layerState._cameraRemover?.();
// Some legacy camera events do not return a remover.
if (!layerState._cameraRemover) camera?.changed?.removeEventListener?.(parts.viewport.onCameraChanged);
layerState._arrivalRemover?.();
layerState._surfaceFrameRemover = layerState._preRenderRemover = layerState._cameraRemover = layerState._arrivalRemover = layerState._trafficTimingMoveEndRemover = null;
releaseCameraSensitivity(layerState._engine ?? camera,'traffic');
}
const methods = {
/**
* One-time initialisation. Creates the PointPrimitiveCollection and adds it
@@ -22,14 +67,8 @@ export function createLifecycle({
*
* @param {Cesium.Viewer} viewer - The Cesium viewer instance.
*/
init(viewer) {
layerState._viewer = viewer;
layerState._pointCollection = new Cesium.PointPrimitiveCollection({
blendOption: Cesium.BlendOption.TRANSLUCENT,
});
// Add permanently — toggle with .show to avoid destroy-on-remove errors
viewer.scene.primitives.add(layerState._pointCollection);
layerState._pointCollection.show = false;
init(viewer, options) {
this.attachRenderer(viewer, options);
layerState._dots = [];
layerState._roads = [];
layerState._count = 0;
@@ -60,9 +99,8 @@ export function createLifecycle({
layerState._stylePreset =
document?.documentElement?.dataset?.gevStyle || 'normal';
if (!layerState._styleListenerBound) {
window.addEventListener('gev:style-change', (e) =>
parts.style.setStylePreset(e?.detail?.style),
);
layerState._styleListener = (e) => parts.style.setStylePreset(e?.detail?.style);
window.addEventListener('gev:style-change', layerState._styleListener);
layerState._styleListenerBound = true;
}
}
@@ -70,6 +108,66 @@ export function createLifecycle({
console.log('[Data:Traffic] Initialized');
},
attachRenderer(viewer, {engine=null,waitForCameraRestore=false}={}) {
layerState._restoringViewport = waitForCameraRestore;
layerState._viewer = viewer;
layerState._engine = engine;
layerState._native = !viewer && engine ? services.nativeRenderer(engine) : null;
layerState._heatSupported = null;
const points = layerState._native?.points ?? new Cesium.PointPrimitiveCollection({blendOption: Cesium.BlendOption.TRANSLUCENT});
if (viewer) viewer.scene.primitives.add(points);
layerState._pointCollection = points;
for(const dot of layerState._dots) dot.point = points.add(dot.point);
points.show = layerState._enabled;
if (!waitForCameraRestore) readyForSurfaceWork();
if (layerState._enabled) {
bindScene();
parts.rendering.rebuildHeatLines(layerState._roads);
if (layerState._dots.length && layerState._roads.some(r=>!r.directFlow))
services.credits?.showOsmCredit?.(engine ?? viewer,'traffic',{openMapTiles:true});
if (layerState._liveMode) services.credits?.registerDynamicCredit?.(engine ?? viewer, services.credits.TOMTOM_CREDIT);
parts.viewport.onCameraChanged({immediate:true});
}
},
resumeViewport() {
if (!layerState._restoringViewport) return;
layerState._restoringViewport = false;
readyForSurfaceWork();
if (!layerState._enabled) return;
if (layerState._populationPending) parts.retention.resume();
const previous = layerState._surfaceInput;
if (previous && !layerState._fetching && layerState._lastBounds === previous.bounds) {
void parts.flow.applyFlowThenRender(previous.roads, previous.bounds, layerState._loadGeneration,
parts.viewport.getCameraAltitude(), 'Handoff refined').catch(error => {
if (error?.name !== 'AbortError') console.warn('[Data:Traffic] Surface refresh failed', error);
});
}
parts.viewport.onCameraChanged({immediate:true});
},
detachRenderer() {
if(!layerState._pointCollection)return;
unbindScene();
clearTimeout(layerState._fetchTimeout);
layerState._surfaceRefineRemove?.();
layerState._surfaceRefineRemove = null;
layerState._surfaceRevision = (layerState._surfaceRevision || 0)+1;
layerState._preparedRoads?.clear();
parts.rendering.removeHeatLines();
services.credits?.hideOsmCredit?.(layerState._engine ?? layerState._viewer,'traffic');
for(const dot of layerState._dots) {
const p=dot.point;
dot.point={position:Cesium.Cartesian3.clone(p.position),pixelSize:p.pixelSize,color:p.color,show:p.show,
outlineColor:p.outlineColor,outlineWidth:p.outlineWidth,scaleByDistance:p.scaleByDistance,
translucencyByDistance:p.translucencyByDistance,disableDepthTestDistance:0};
}
layerState._viewer?.scene.primitives.remove(layerState._pointCollection);
layerState._native?.destroy();
if(layerState._renderHeld)services.render.releaseContinuousRender('traffic');
layerState._renderHeld=false;
layerState._pointCollection=layerState._viewer=layerState._engine=layerState._native=null;
layerState._waitForScene ??= new Promise(resolve=>{layerState._sceneReady=resolve;});
},
/**
* Enable the traffic layer. Shows the point collection, subscribes to the
* preRender animation loop and camera-change events, and kicks off an
@@ -79,35 +177,10 @@ export function createLifecycle({
*/
enable(viewer) {
if (layerState._enabled) return;
if (viewer) layerState._viewer = viewer;
layerState._enabled = true;
layerState._surfaceFrameRemover = observeTrafficSurface(viewer.scene);
if (layerState._roadMode !== 'tomtom') source.prefetch?.();
parts.animation.syncRenderDemand();
layerState._lastAnimTime = 0;
layerState._pointCollection.show = true;
layerState._preRenderRemover = viewer.scene.preRender.addEventListener(
parts.animation.animate,
);
if (TRAFFIC_TIMING_ENABLED) {
parts.timing.clearTrafficTimingEntries();
layerState._trafficTimingCurrentAnchor = null;
layerState._trafficTimingPostRenderRemovers = new Set();
layerState._trafficTimingMoveEndRemover =
viewer.camera.moveEnd.addEventListener(
parts.timing.markTrafficTimingMoveEnd,
);
}
// Share the 5% movement threshold with other camera-driven layers.
viewer.camera.changed.addEventListener(parts.viewport.onCameraChanged);
// Always inspect the final view, even when the last flight step is below
// camera.changed's movement threshold.
layerState._arrivalRemover = viewer.camera.moveEnd.addEventListener(() =>
parts.viewport.onCameraChanged({ immediate: true }),
);
claimCameraSensitivity(viewer.camera, 'traffic', 0.05);
bindScene();
// Enabling is explicit intent, not a camera gesture that needs settling.
parts.viewport.onCameraChanged({ immediate: true });
@@ -142,7 +215,8 @@ export function createLifecycle({
*/
disable(viewer) {
layerState._enabled = false;
services.credits?.hideOsmCredit?.(layerState._viewer, 'traffic');
layerState._surfaceInput = null;
services.credits?.hideOsmCredit?.(layerState._engine ?? layerState._viewer, 'traffic');
parts.animation.syncRenderDemand();
clearTimeout(layerState._fetchTimeout);
clearInterval(layerState._enableKickTimer);
@@ -164,25 +238,7 @@ export function createLifecycle({
layerState._flowStale = false;
layerState._flowOldestTileAt = null;
layerState._surfaceFrameRemover?.();
layerState._surfaceFrameRemover = null;
if (layerState._preRenderRemover) {
layerState._preRenderRemover();
layerState._preRenderRemover = null;
}
if (TRAFFIC_TIMING_ENABLED) {
layerState._trafficTimingMoveEndRemover?.();
layerState._trafficTimingMoveEndRemover = null;
for (const remove of layerState._trafficTimingPostRenderRemovers || [])
remove();
layerState._trafficTimingPostRenderRemovers = null;
layerState._trafficTimingCurrentAnchor = null;
}
viewer.camera.changed.removeEventListener(parts.viewport.onCameraChanged);
layerState._arrivalRemover?.();
layerState._arrivalRemover = null;
releaseCameraSensitivity(viewer.camera, 'traffic');
unbindScene();
if (layerState._pointCollection) layerState._pointCollection.show = false;
},
@@ -195,11 +251,19 @@ export function createLifecycle({
destroy(viewer) {
this.disable(viewer);
if (layerState._pointCollection) {
viewer.scene.primitives.remove(layerState._pointCollection);
layerState._viewer?.scene.primitives.remove(layerState._pointCollection);
layerState._native?.destroy();
layerState._native = null;
layerState._pointCollection = null;
}
parts.rendering.removeHeatLines();
layerState._tileCache.clear();
if (layerState._styleListenerBound && typeof window !== 'undefined') {
window.removeEventListener('gev:style-change', layerState._styleListener);
layerState._styleListenerBound = false;
}
layerState._sceneReady?.();
layerState._waitForScene = layerState._sceneReady = null;
resetFlowTileCache();
layerState._count = 0;
layerState._lastUpdate = null;
+187
View File
@@ -0,0 +1,187 @@
import * as geo from '../../../geo/index.js';
import { MAX_DOTS } from '../policy.js';
// Point handles keep the same mutable fields the shared traffic motion loop uses.
// The GPU gets two packed draws, including the preset's optional colored outlines.
function createTrafficPoints(engine) {
const points = new Set(),
renderSets = [];
for (const [role, order] of [
['outline', 0],
['fill', 1],
]) {
renderSets.push(
engine.movers.createSet({
id: 'traffic-' + role,
kind: 'point',
order,
capacity: MAX_DOTS,
depthTest: 'normal',
pickable: false,
point: { sizePx: 1, outlinePx: 0 },
}),
);
}
const color = [0, 0, 0, 0];
let empty = true;
const range = (p, d) => {
if (!p) return 1;
// The engine's distance scalar interpolates in squared range.
const x = Math.min(
1,
Math.max(
0,
(d * d - p.near * p.near) / (p.far * p.far - p.near * p.near),
),
);
return p.nearValue + (p.farValue - p.nearValue) * Math.pow(x, 0.2);
};
return {
show: true,
add(spec) {
const pos = geo.cartesian3.clone(spec.position);
const p = {
...spec,
show: spec.show ?? true,
get position() {
return pos;
},
set position(v) {
geo.cartesian3.clone(v, pos);
},
};
points.add(p);
return p;
},
remove(point) {
return points.delete(point);
},
removeAll() {
points.clear();
this.flush();
},
flush() {
if (!points.size && empty) return;
empty = points.size === 0;
const center = engine.camera.livePosition;
const sizes = [0, 0];
if (this.show && center)
for (const p of points) {
if (
!p.show ||
p.pixelSize <= 0 ||
!engine.projector.horizonVisible(p.position)
)
continue;
const distance = geo.cartesian3.distance(p.position, center),
scale = range(p.scaleByDistance, distance),
alpha = range(p.translucencyByDistance, distance);
if (alpha <= 0) continue;
for (
let i = p.outlineWidth > 0 && p.outlineColor ? 0 : 1;
i < 2;
i++
) {
const c = i ? p.color : p.outlineColor;
color[0] = c.red;
color[1] = c.green;
color[2] = c.blue;
color[3] = c.alpha * alpha;
renderSets[i].set(sizes[i]++, {
position: p.position,
scale: Math.max(
0,
(p.pixelSize + (i ? 0 : 2 * p.outlineWidth)) * scale,
),
color,
show: true,
});
}
}
for (let i = 0; i < 2; i++) {
renderSets[i].setCount(sizes[i]);
renderSets[i].commit();
}
},
destroy() {
for (const set of renderSets) set.destroy();
points.clear();
},
};
}
export function createTrafficNavaraRenderer(engine, native) {
const { view } = native;
const points = createTrafficPoints(engine);
const surface = {
engine,
canvas: engine.host.canvas,
postRender: engine.frames.postRender,
get sampleHeightSupported() {
return engine.surface.supported;
},
sampleHeight: (p) =>
engine.maps.photorealActive()
? engine.surface.sampleHeight(p)
: engine.surface.terrainHeight(p),
project: (world, out) => engine.projector.worldToWindow(world, out),
get ready() {
return engine.surface.tilesLoaded();
},
get surfaceKey() {
return engine.maps.photorealActive() ? 'tiles' : 'terrain';
},
};
const makeHeat = (roads, tint, width, alpha) => {
if (!roads.length) return null;
const source = view.addSource({
type: 'geojson',
data: {
type: 'FeatureCollection',
features: roads.map((r) => ({
type: 'Feature',
properties: {},
geometry: { type: 'LineString', coordinates: r.coords },
})),
},
});
const css =
(Math.round(tint.red * 255) << 16) |
(Math.round(tint.green * 255) << 8) |
Math.round(tint.blue * 255);
let layer;
try {
layer = view.addLayer({
type: 'vector',
source,
polyline: {
color: css,
width,
opacity: alpha,
clampToGround: true,
transparent: true,
lit: false,
},
});
} catch (e) {
source.delete();
throw e;
}
let current = alpha;
layer.on('featureUpdated', ({ evaluator }) =>
evaluator.evaluate(() => ({ opacity: current })),
);
return {
setAlpha(a) {
if (Math.abs(a - current) > 0.015) {
current = a;
layer.forceUpdate();
}
},
destroy() {
layer.delete();
source.delete();
},
};
};
return { points, surface, makeHeat, destroy: () => points.destroy() };
}
+19 -5
View File
@@ -37,6 +37,11 @@ export function createRendering({
/** Remove both heat-line ground primitives from the scene. */
function removeHeatLines() {
if (layerState._native) {
layerState._heatJamPrim?.destroy();
layerState._heatSlowPrim?.destroy();
layerState._heatJamPrim = layerState._heatSlowPrim = null;
}
if (layerState._heatJamPrim) {
layerState._viewer?.scene.groundPrimitives.remove(
layerState._heatJamPrim,
@@ -68,11 +73,12 @@ export function createRendering({
function rebuildHeatLines(roads) {
removeHeatLines();
if (
!layerState._viewer ||
(!layerState._viewer && !layerState._native) ||
!layerState._liveMode ||
!parts.style.heatlineOn()
)
return;
if (layerState._native) layerState._heatSupported = true;
if (layerState._heatSupported === null) {
layerState._heatSupported = Cesium.GroundPolylinePrimitive.isSupported(
layerState._viewer.scene,
@@ -122,6 +128,14 @@ export function createRendering({
? new Cesium.Color(0.7, 0.7, 0.7, HEAT_SLOW_COLOR.alpha)
: HEAT_SLOW_COLOR;
if (layerState._native) {
const roadsFor = bucket => kept.filter(item=>item.bucket===bucket).map(item=>item.road);
layerState._heatJamPrim = layerState._native.makeHeat(roadsFor('jam'), jamLineColor, HEAT_LINE_JAM_WIDTH, HEAT_JAM_BASE_ALPHA);
layerState._heatSlowPrim = layerState._native.makeHeat(roadsFor('slow'), slowLineColor, HEAT_LINE_SLOW_WIDTH, slowLineColor.alpha);
layerState._heatLineCount = kept.length;
parts.animation.syncRenderDemand();
return;
}
const jamInstances = instancesFor('jam', HEAT_LINE_JAM_WIDTH);
if (jamInstances.length) {
layerState._heatJamPrim = layerState._viewer.scene.groundPrimitives.add(
@@ -202,7 +216,7 @@ export function createRendering({
// nadir view: oblique pitches put the loaded roads many km away even at
// low altitude. Probe three roads and stretch the curves accordingly.
let areaDist = altitude;
if (layerState._viewer && filteredRoads.length) {
if ((layerState._viewer || layerState._engine) && filteredRoads.length) {
const probes = [
filteredRoads[0],
filteredRoads[Math.floor(filteredRoads.length / 2)],
@@ -214,7 +228,7 @@ export function createRendering({
areaDist = Math.max(
areaDist,
Cesium.Cartesian3.distance(
layerState._viewer.camera.positionWC,
layerState._engine?.camera.livePosition ?? layerState._viewer.camera.positionWC,
wp,
),
);
@@ -233,10 +247,10 @@ export function createRendering({
: null;
parts.retention.reconcile(filteredRoads, altitude, replace);
if (layerState._dots.length && filteredRoads.some((r) => !r.directFlow))
services.credits?.showOsmCredit?.(layerState._viewer, 'traffic', {
services.credits?.showOsmCredit?.(layerState._engine ?? layerState._viewer, 'traffic', {
openMapTiles: true,
});
else services.credits?.hideOsmCredit?.(layerState._viewer, 'traffic');
else services.credits?.hideOsmCredit?.(layerState._engine ?? layerState._viewer, 'traffic');
parts.model.recolorDotsInPlace(label);
const renderMetrics = state
+5 -4
View File
@@ -17,6 +17,7 @@ export function createRetention({ state, parts }) {
const screenTo = new Cesium.Cartesian2();
const eye = new Cesium.Cartesian3();
function projectSafely(scene, point, screen) {
if (scene.project) return Boolean(scene.project(point, screen));
if (scene.camera.viewMatrix) {
Cesium.Matrix4.multiplyByPoint(scene.camera.viewMatrix, point, eye);
if (eye.z >= -(scene.camera.frustum?.near || 0)) return false;
@@ -50,7 +51,7 @@ export function createRetention({ state, parts }) {
};
function pump() {
timer = null;
if (!state._enabled) return;
if (!state._enabled || !state._pointCollection) return;
let budget = DOT_CHANGE_BUDGET;
let pending = false;
const now = Date.now();
@@ -191,9 +192,9 @@ export function createRetention({ state, parts }) {
}
function easeHeights(dt) {
const blend = Math.min(1, dt * 12);
const scene = state._viewer?.scene;
const scene = state._trafficScene?.() ?? state._viewer?.scene;
const canProject =
scene?.canvas?.clientHeight && scene.camera && scene.frameState;
scene?.canvas?.clientHeight && (scene.project || (scene.camera && scene.frameState));
state._heightPending = false;
for (let r = 0; r < state._roads.length; r++) {
const road = state._roads[r];
@@ -276,5 +277,5 @@ export function createRetention({ state, parts }) {
state._populationPending = false;
state._heightPending = false;
}
return { reconcile, easeHeights, finishHeights, clear, refreshCounts };
return { reconcile, easeHeights, finishHeights, clear, refreshCounts, resume: schedule };
}
+4
View File
@@ -17,6 +17,10 @@ export function createState({ services }) {
/** @type {Cesium.Viewer|null} */
layerState._viewer = null;
layerState._engine = null;
layerState._native = null;
layerState._trafficScene = () => layerState._native?.surface ?? layerState._viewer?.scene;
/** @type {Cesium.PointPrimitiveCollection|null} */
+6 -3
View File
@@ -50,6 +50,7 @@ export function observeTrafficSurface(scene) {
}
function currentRenderedFrame(scene) {
if (scene.engine) return {ready:scene.ready};
if (!scene.postRender?.addEventListener || !scene.camera?.viewMatrix)
return { ready: surfaceTilesReady(scene) };
const frame = renderedFrames.get(scene);
@@ -204,6 +205,7 @@ const surfaceId = (object) => {
*/
const surfaceKeys = new WeakMap();
export function trafficSurfaceKey(scene) {
if (scene.engine) return scene.surfaceKey;
let state = surfaceKeys.get(scene);
if (!state) {
state = { globe: null, terrain: null, tiles: [], key: '' };
@@ -278,7 +280,7 @@ export async function prepareRoadSurfaces(
cache = { surface, heights: new Map() };
sceneCaches.set(scene, cache);
}
const cameraCarto = scene.camera?.positionCartographic;
const cameraCarto = scene.engine?.camera.pose().cartographic ?? scene.camera?.positionCartographic;
const cameraLon = cameraCarto
? Cesium.Math.toDegrees(cameraCarto.longitude)
: 0;
@@ -333,7 +335,8 @@ export async function prepareRoadSurfaces(
let sampled;
// sampleHeight reads the locally rendered mesh, not unresolved/offscreen
// tiles. Global tilesLoaded can stay false while this street is usable.
if (scene.globe?.show) sampled = scene.globe.getHeight?.(carto);
if (scene.engine) sampled = scene.sampleHeight(carto);
else if (scene.globe?.show) sampled = scene.globe.getHeight?.(carto);
else {
const estimate = Cesium.Cartographic.fromCartesian(
road.waypoints[i],
@@ -342,7 +345,7 @@ export async function prepareRoadSurfaces(
if (validHeight(sampled)) metrics.depthHits++;
}
if (
!scene.globe?.show &&
!scene.engine && !scene.globe?.show &&
!validHeight(sampled) &&
scene.sampleHeightSupported
) {
+8 -8
View File
@@ -24,7 +24,7 @@ export function createViewport({ state: layerState, services, parts, source }) {
*/
function getCameraAltitude() {
const carto = layerState._viewer.camera.positionCartographic;
const carto = layerState._engine?.camera.pose().cartographic ?? layerState._viewer?.camera.positionCartographic;
return carto ? carto.height : Infinity;
}
@@ -35,7 +35,7 @@ export function createViewport({ state: layerState, services, parts, source }) {
*/
function getViewBounds() {
const rect = layerState._viewer.camera.computeViewRectangle();
const rect = layerState._engine?.camera.viewRectangle() ?? layerState._viewer?.camera.computeViewRectangle();
if (!rect) return null;
return {
south: geo.math.toDegrees(rect.south),
@@ -60,18 +60,18 @@ export function createViewport({ state: layerState, services, parts, source }) {
*/
function getFetchCenter() {
const carto = layerState._viewer.camera.positionCartographic;
const carto = layerState._engine?.camera.pose().cartographic ?? layerState._viewer?.camera.positionCartographic;
if (!carto) return null;
const nadirLat = geo.math.toDegrees(carto.latitude);
const nadirLon = geo.math.toDegrees(carto.longitude);
let hitLat;
let hitLon;
const canvas = layerState._viewer.scene.canvas;
const canvas = layerState._engine?.host.canvas ?? layerState._viewer.scene.canvas;
const width = canvas.clientWidth || canvas.width;
const height = canvas.clientHeight || canvas.height;
if (width > 0 && height > 0) {
const hit = layerState._viewer.camera.pickEllipsoid(
const hit = (layerState._engine?.camera ?? layerState._viewer.camera).pickEllipsoid(
geo.cartesian2.create(width / 2, height / 2),
Cesium.Ellipsoid.WGS84,
);
@@ -174,7 +174,7 @@ export function createViewport({ state: layerState, services, parts, source }) {
/** Debounce camera events and sample the actual settled view, including final flight steps. */
function onCameraChanged(options) {
if (!layerState._enabled) return;
if (!layerState._enabled || layerState._waitForScene || layerState._restoringViewport) return;
clearTimeout(layerState._fetchTimeout);
if (getCameraAltitude() > ACTIVATION_ALTITUDE) {
clearTimeout(layerState._retryTimer);
@@ -197,10 +197,10 @@ export function createViewport({ state: layerState, services, parts, source }) {
: null;
layerState._fetchTimeout = setTimeout(
() => {
if (!layerState._enabled) return;
if (!layerState._enabled || layerState._waitForScene || layerState._restoringViewport) return;
const alt = getCameraAltitude();
if (alt > ACTIVATION_ALTITUDE) return onCameraChanged();
const footprint = reticleFootprint(layerState._viewer, alt);
const footprint = reticleFootprint(layerState._viewer, alt, layerState._engine);
const bounds = footprint || getViewBounds();
if (!bounds) return;
const center = footprint ? null : getFetchCenter();
+3
View File
@@ -24,6 +24,7 @@ window.addEventListener('pagehide', (event) => {
async function start(next, engineSwitch = false, cockpit = null) {
kind = next;
layerSession.restoringView = engineSwitch;
application = createStandaloneApplication({
googleApiKey: import.meta.env.GOOGLE_MAPS_API_KEY,
cesiumToken: import.meta.env.CESIUM_ION_TOKEN,
@@ -37,6 +38,8 @@ async function start(next, engineSwitch = false, cockpit = null) {
if (engineSwitch) {
const style = controls.styleManager;
await style.initialRestorePromise;
layerSession.restoringView = false;
for (const {module} of layerSession.manager.layers.values()) module.resumeViewport?.();
if (layerSession.handoffLayers) layerSession.manager.layers.get('directions')?.module.resumeCamera?.();
// Only resume the aircraft that actually survived the handoff. A history
// entry may select a different subject, and a stale track may disappear.