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 }; }