Keep the traffic population and vehicle heights after a globe round trip

The republish after a globe switch ran as a refinement ('Handoff refined'):
it sampled the incoming globe while it still showed coarse tiles and,
being a refinement, never refined again. Keyless Cesium's first terrain
tiles put Austin's streets near -2 200 m, so vehicles stayed there, and
the next globe weighted each road by the camera distance to those
waypoints (1.9 km became 4.1 km), cutting the population from about 620
to 260 until the camera moved.

The handoff is now a completed pass that refines once the incoming
surface has loaded, road density and the coverage radius are measured to
the road's coordinates on the local ground estimate instead of to the
last sampled waypoints, and flow that lands after a publication
re-allocates the retained roads' budgets instead of only recolouring
them.
This commit is contained in:
German Khokhlov committed 2026-11-03 16:24:08 +04:00
1 parent f4879fc24b
commit eed8acc3f7
6 files changed
+299 -15

No files matched your search

+1
View File
@@ -1040,6 +1040,7 @@
"src/layers/traffic/roadModes.test.mjs",
"scripts/qa-traffic-motion.mjs",
"src/layers/traffic/motion.test.mjs",
"src/layers/traffic/handoff.test.mjs",
"scripts/qa-journey-recorder.mjs",
"scripts/qa-alpr-journey.mjs",
"scripts/qa-context-arrivals.mjs",
+38 -13
View File
@@ -14,6 +14,22 @@ import {
import { msg } from '../../i18n/index.js';
import { nextTask } from '../../sources/vectorTiles.js';
/**
* Camera distance, in metres, to a [lon, lat] road coordinate standing on the
* local ground estimate (a local tangent-plane approximation, metres apart at
* the traffic layer's kilometre range). Null without a camera.
*/
export function groundDistanceFrom(camera, groundHeight = 0) {
const view = camera && Cesium.Cartographic.fromCartesian(camera);
if (!view) return null;
const clearance = view.height - (groundHeight || 0) - DOT_HEIGHT_OFFSET;
const lat = Cesium.Math.toDegrees(view.latitude);
const lon = Cesium.Math.toDegrees(view.longitude);
const east = 111320 * Math.cos(view.latitude);
return ([roadLon, roadLat]) =>
Math.hypot((roadLon - lon) * east, (roadLat - lat) * 111320, clearance);
}
export function createFlow({ state: layerState, services, parts, source }) {
const { registerDynamicCredit, TOMTOM_CREDIT } = services.credits;
const { fetchFlowForBounds } = source;
@@ -333,10 +349,22 @@ export function createFlow({ state: layerState, services, parts, source }) {
}
}
}
// Camera distances are measured to the road's coordinates standing on the
// local ground estimate, never to its waypoints. Until this pass samples
// them again, waypoints keep the heights of whichever surface sampled them
// last: after a globe switch the outgoing globe's, often taken from its
// coarse first tiles (about -2 200 m at Austin on keyless Cesium). Weighted
// by those, a returning globe's population fell from 620 to 260.
const viewCamera =
layerState._engine?.camera.livePosition ??
layerState._viewer?.camera?.positionWC;
const groundDistance = groundDistanceFrom(
viewCamera,
layerState._viewHeightEstimate,
);
const selected = parts.rendering
.visibleRoadsForAltitude(roads, altitude)
.filter((road) => {
const camera = (layerState._engine?.camera.livePosition ?? layerState._viewer?.camera?.positionWC);
const localHeight = Math.max(
0,
altitude - (layerState._viewHeightEstimate || 0),
@@ -345,12 +373,8 @@ export function createFlow({ state: layerState, services, parts, source }) {
? clamped.coverage.rangeKm * 1000 + 1500
: Math.max(1200, localHeight * 2);
if (
camera &&
road.waypoints.every(
(point) =>
Cesium.Cartesian3.distanceSquared(camera, point) >
radius * radius,
)
groundDistance &&
road.coords.every((coordinate) => groundDistance(coordinate) > radius)
)
return false;
// Spend surface work on roads crossing the canvas, with a small pan margin.
@@ -368,14 +392,11 @@ export function createFlow({ state: layerState, services, parts, source }) {
maxY = Math.max(maxY, screen.y);
}
road.coverageBand = maxY < scene.canvas.clientHeight / 2 ? 1 : 0;
if (clamped.coverage && camera) {
if (clamped.coverage && groundDistance) {
road.baseDensityWeight ??= road.densityWeight || 1;
road.densityWeight =
road.baseDensityWeight /
Math.max(
1,
Cesium.Cartesian3.distance(camera, road.waypoints[0]) / 1500,
);
Math.max(1, groundDistance(road.coords[0]) / 1500);
}
return (
maxX >= -100 &&
@@ -469,7 +490,11 @@ export function createFlow({ state: layerState, services, parts, source }) {
const flowJob = applyFlowToRoads(admitted, clamped, generation, signal);
flowJob
.then(() => {
if (current()) parts.model.recolorDotsInPlace(label);
if (!current()) return;
parts.model.recolorDotsInPlace(label);
// Flow cleared for this refresh may land after the roads were
// published; their budgets then follow it too, not only the colours.
if (layerState._liveMode) parts.retention?.retarget?.();
})
.catch(() => {});
const options = {
+206
View File
@@ -0,0 +1,206 @@
// Traffic republished on the incoming globe after a switch.
import test from 'node:test';
import assert from 'node:assert/strict';
import * as C from 'cesium';
import { createState } from './state.js';
import { createFlow } from './flow.js';
import { createLifecycle } from './lifecycle.js';
import { DOT_HEIGHT_OFFSET } from './policy.js';
const LON = -97.744;
const LAT = 30.267;
const GROUND = 150;
// Keyless Cesium's first coarse terrain tiles put Austin's streets about
// 2 200 m below sea level (Gulf of Mexico bathymetry in the level-0 mesh).
const COARSE = -2200;
const bounds = { west: -97.76, east: -97.73, south: 30.26, north: 30.28 };
const coverage = { rangeKm: 10 };
function road(offset, height) {
const coords = [
[LON + offset, LAT + offset],
[LON + offset + 0.002, LAT + offset],
];
return {
type: 'primary',
oneway: 1,
coords,
key: `road:${offset}`,
surfaceReady: true,
waypoints: coords.map(([lon, lat]) =>
C.Cartesian3.fromDegrees(lon, lat, height + DOT_HEIGHT_OFFSET),
),
segmentDist: [190],
};
}
/** An incoming Navara-like surface whose finer tiles arrive later. */
function navaraSurface() {
const listeners = new Set();
const surface = {
ready: false,
height: COARSE,
samples: 0,
surfaceKey: 'terrain',
canvas: { clientWidth: 1000, clientHeight: 800 },
project: () => ({ x: 500, y: 500 }),
sampleHeightSupported: true,
sampleHeight: () => {
surface.samples++;
return surface.height;
},
postRender: {
addEventListener(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
},
},
frame: () => [...listeners].forEach((listener) => listener()),
engine: {
camera: {
pose: () => ({
cartographic: C.Cartographic.fromDegrees(LON, LAT, 1800),
}),
livePosition: C.Cartesian3.fromDegrees(LON, LAT, 1800),
},
},
};
return surface;
}
function fixture(surface) {
const state = createState({ services: {} });
const weights = [];
const paints = [];
Object.assign(state, {
_enabled: true,
_loadGeneration: 1,
_flowPending: 0,
_roadSource: 'OpenStreetMap',
_viewHeightEstimate: GROUND,
_engine: surface.engine,
_trafficScene: () => surface,
_activeFetchAbort: new AbortController(),
});
const flow = createFlow({
state,
services: { credits: { registerDynamicCredit() {} } },
source: {},
parts: {
model: {
allocateRoadDotBudgets: (roads) => {
weights.push(roads.map((r) => r.densityWeight));
return roads.map(() => 4);
},
recolorDotsInPlace() {},
},
rendering: {
visibleRoadsForAltitude: (roads) => roads,
renderRoadsForAltitude: (roads, _altitude, label) =>
paints.push({ label, roads: roads.length }),
},
},
});
return { state, flow, weights, paints };
}
const height = (point) => C.Cartographic.fromCartesian(point).height;
const settle = () => new Promise((resolve) => setTimeout(resolve, 5));
test('a republish weights roads by distance to the local ground, not by heights another globe sampled', async () => {
const offsets = [0.004, 0.012, 0.02];
const weightsFor = async (sampledHeight) => {
const surface = navaraSurface();
surface.ready = true;
surface.height = GROUND;
const { flow, weights } = fixture(surface);
const roads = offsets.map((offset) => road(offset, sampledHeight));
await flow.applyFlowThenRender(
roads,
{ ...bounds, coverage },
1,
1800,
'Handoff',
);
return weights[0];
};
const fromGround = await weightsFor(GROUND);
const fromCoarse = await weightsFor(COARSE);
assert.equal(fromGround.length, offsets.length);
for (let i = 0; i < offsets.length; i++)
assert.ok(
Math.abs(fromCoarse[i] - fromGround[i]) < 1e-9,
`road ${i}: ${fromCoarse[i]} against ${fromGround[i]}`,
);
// A nearer road keeps more of its weight than a farther one.
assert.ok(fromGround[0] > fromGround[2]);
});
test('the handoff pass on a loading globe refines once its surface is ready', async () => {
const surface = navaraSurface();
const { flow, paints } = fixture(surface);
const roads = [road(0.004, GROUND), road(0.008, GROUND)];
await flow.applyFlowThenRender(
roads,
{ ...bounds, coverage },
1,
1800,
'Handoff',
);
assert.ok(
roads.every((r) => Math.abs(height(r.waypoints[0]) - COARSE) < 4),
'the coarse tiles are sampled provisionally',
);
surface.height = GROUND;
surface.frame();
assert.equal(paints.at(-1).label, 'Handoff', 'not ready: no refinement yet');
surface.ready = true;
surface.frame();
await settle();
assert.equal(paints.at(-1).label, 'Handoff refined');
for (const r of roads)
assert.ok(
Math.abs(height(r.waypoints[0]) - GROUND - DOT_HEIGHT_OFFSET) < 0.01,
'vehicles follow the loaded surface',
);
});
test('resuming the viewport republishes the retained roads as a pass that can still refine', () => {
const labels = [];
const state = createState({ services: {} });
const surfaceInput = { roads: [road(0.004, GROUND)], bounds };
Object.assign(state, {
_enabled: true,
_restoringViewport: true,
_fetching: false,
_surfaceInput: surfaceInput,
_lastBounds: bounds,
_loadGeneration: 3,
});
const lifecycle = createLifecycle({
state,
services: {},
source: { resetFlowTileCache() {} },
parts: {
retention: { resume() {} },
viewport: { getCameraAltitude: () => 1800, onCameraChanged() {} },
flow: {
applyFlowThenRender: async (
roads,
clamped,
generation,
altitude,
label,
) => {
labels.push({ roads, clamped, generation, altitude, label });
return true;
},
},
},
});
lifecycle.methods.resumeViewport();
assert.equal(labels.length, 1);
assert.equal(labels[0].roads, surfaceInput.roads);
assert.equal(labels[0].generation, 3);
assert.doesNotMatch(labels[0].label, /refined/);
});
+5 -1
View File
@@ -137,8 +137,12 @@ export function createLifecycle({
if (layerState._populationPending) parts.retention.resume();
const previous = layerState._surfaceInput;
if (previous && !layerState._fetching && layerState._lastBounds === previous.bounds) {
// A completed pass, not a refinement: the incoming globe often still
// shows coarse tiles, so it samples provisionally and refines once its
// surface has loaded ('Handoff refined'). A refinement here never
// refined again and left vehicles on those coarse heights.
void parts.flow.applyFlowThenRender(previous.roads, previous.bounds, layerState._loadGeneration,
parts.viewport.getCameraAltitude(), 'Handoff refined').catch(error => {
parts.viewport.getCameraAltitude(), 'Handoff').catch(error => {
if (error?.name !== 'AbortError') console.warn('[Data:Traffic] Surface refresh failed', error);
});
}
+25
View File
@@ -671,3 +671,28 @@ test('retained road density follows the settled view without requiring a toggle'
'fresh toggle and retained view have the same budget',
);
});
test('flow that lands after a publication re-allocates the retained population', (t) => {
const h = setup(t);
const [road] = h.roads();
// A refresh clears the road's flow before it is published again.
road.flow = null;
h.parts.retention.reconcile([road], 450, true);
h.settle();
const uncongested = h.state._count;
const ids = new Set(h.state._dots.map((dot) => dot.id));
assert.ok(uncongested > 1);
road.flow = { level: 0.4, closure: false };
h.parts.model.recolorDotsInPlace('late flow');
h.parts.retention.retarget();
h.settle();
assert.ok(
h.state._count > 2 * uncongested,
`slow flow carries more vehicles: ${uncongested} -> ${h.state._count}`,
);
assert.ok(
[...ids].every((id) => h.state._dots.some((dot) => dot.id === id)),
'the vehicles already on the road keep moving',
);
assert.equal(h.state._motion.rebuilds, 0);
});
+24 -1
View File
@@ -190,6 +190,29 @@ export function createRetention({ state, parts }) {
if (!state._dots.length && timer === null) pump();
else schedule();
}
/**
* Re-allocate the published roads' dot budgets from their current flow.
* Flow that lands after a publication changes each road's density (slow and
* jammed roads carry more vehicles); recoloring alone kept the budgets the
* roads had while their flow was cleared for the refresh.
*/
function retarget() {
const wanted = [];
for (const record of records.values()) {
if (!record.wanted) continue;
record.road.flow = record.road.source.flow;
wanted.push(record.road);
}
if (!wanted.length) return;
const budgets = parts.model.allocateRoadDotBudgets(
wanted,
altitude,
roadDotBudget(altitude),
);
for (let i = 0; i < wanted.length; i++)
records.get(wanted[i].key).target = budgets[i];
schedule();
}
function easeHeights(dt) {
const blend = Math.min(1, dt * 12);
const scene = state._trafficScene?.() ?? state._viewer?.scene;
@@ -277,5 +300,5 @@ export function createRetention({ state, parts }) {
state._populationPending = false;
state._heightPending = false;
}
return { reconcile, easeHeights, finishHeights, clear, refreshCounts, resume: schedule };
return { reconcile, retarget, easeHeights, finishHeights, clear, refreshCounts, resume: schedule };
}