From eed8acc3f711697430b4ca3fd564ff75778101a5 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Tue, 3 Nov 2026 16:24:08 +0400 Subject: [PATCH] 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. --- scripts/format-scope.json | 1 + src/layers/traffic/flow.js | 51 +++++-- src/layers/traffic/handoff.test.mjs | 206 ++++++++++++++++++++++++++++ src/layers/traffic/lifecycle.js | 6 +- src/layers/traffic/motion.test.mjs | 25 ++++ src/layers/traffic/retention.js | 25 +++- 6 files changed, 299 insertions(+), 15 deletions(-) create mode 100644 src/layers/traffic/handoff.test.mjs diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 21cc4986d..49789895b 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -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", diff --git a/src/layers/traffic/flow.js b/src/layers/traffic/flow.js index 837529f0e..76504bb51 100644 --- a/src/layers/traffic/flow.js +++ b/src/layers/traffic/flow.js @@ -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 = { diff --git a/src/layers/traffic/handoff.test.mjs b/src/layers/traffic/handoff.test.mjs new file mode 100644 index 000000000..a5a5a4960 --- /dev/null +++ b/src/layers/traffic/handoff.test.mjs @@ -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/); +}); diff --git a/src/layers/traffic/lifecycle.js b/src/layers/traffic/lifecycle.js index 9970ea41f..186151a36 100644 --- a/src/layers/traffic/lifecycle.js +++ b/src/layers/traffic/lifecycle.js @@ -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); }); } diff --git a/src/layers/traffic/motion.test.mjs b/src/layers/traffic/motion.test.mjs index ef60e478f..23bf84165 100644 --- a/src/layers/traffic/motion.test.mjs +++ b/src/layers/traffic/motion.test.mjs @@ -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); +}); diff --git a/src/layers/traffic/retention.js b/src/layers/traffic/retention.js index 5e9552343..926abaff4 100644 --- a/src/layers/traffic/retention.js +++ b/src/layers/traffic/retention.js @@ -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 }; }