diff --git a/changelog.d/1645-traffic-lines-one-build.md b/changelog.d/1645-traffic-lines-one-build.md new file mode 100644 index 000000000..da1d00be2 --- /dev/null +++ b/changelog.d/1645-traffic-lines-one-build.md @@ -0,0 +1,3 @@ +- Street Traffic Lines build one set at a time while a city loads, instead of + one per paint, so panning with Lines on no longer piles up line builds + (Sameh Khamis, @samehkhamis, #1645). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 91b7a56a4..03d4efc94 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -134,7 +134,7 @@ existing batch size and one bounded upstream chunk. Larger requests return HTTP 413 before disk-cache loading or upstream work; malformed coordinates remain 400. The browser continues to split larger workloads into sequential 64-point requests. -Traffic holds continuous rendering only while enabled with shown animated dots, a pulsing jam heat-line, or road lines whose geometry is still building (replaced lines stay drawn until their successors are ready, so a rebuild never blinks); hidden dots (`dots` off) and settled flow lines hold nothing. Empty and high-altitude views release the hold. A failed load leaves no hold only when no animated content is retained: if an earlier load succeeded, ingestion keeps its animated points and the hold stays. Population admission reacquires the hold and resets animation time. Clear, disable, and destroy also release it. Retained fading dots keep animation alive until they are removed. When the last point leaves mid height correction, the correction is settled at once, before the hold is released, so it cannot keep the layer reporting loading. +Traffic holds continuous rendering only while enabled with shown animated dots, a pulsing jam heat-line, or road lines whose geometry is still building (replaced lines stay drawn until their successors are ready, so a rebuild never blinks; one set builds at a time, and of the rebuilds asked for meanwhile only the newest runs, when that build finishes; turning lines off removes them at once); hidden dots (`dots` off) and settled flow lines hold nothing. Empty and high-altitude views release the hold. A failed load leaves no hold only when no animated content is retained: if an earlier load succeeded, ingestion keeps its animated points and the hold stays. Population admission reacquires the hold and resets animation time. Clear, disable, and destroy also release it. Retained fading dots keep animation alive until they are removed. When the last point leaves mid height correction, the correction is settled at once, before the hold is released, so it cannot keep the layer reporting loading. Shared distance checks use `src/geoDistance.js` with `{ lat, lon }` points and the IUGG mean spherical Earth radius, 6371.0088 km. `greatCircleKm` is diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 7dbd9d2b7..c82c5ebb3 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -1077,6 +1077,7 @@ "src/layers/traffic/roadModes.test.mjs", "scripts/qa-traffic-motion.mjs", "src/layers/traffic/motion.test.mjs", + "src/layers/traffic/lineRebuild.test.mjs", "scripts/qa-journey-recorder.mjs", "scripts/qa-alpr-journey.mjs", "scripts/qa-context-arrivals.mjs", diff --git a/src/layers/traffic/lineRebuild.test.mjs b/src/layers/traffic/lineRebuild.test.mjs new file mode 100644 index 000000000..b4533b63d --- /dev/null +++ b/src/layers/traffic/lineRebuild.test.mjs @@ -0,0 +1,219 @@ +// Street Traffic road lines build one set at a time: rebuilds asked for while +// a set is building wait, and only the newest of them runs. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as Cesium from 'cesium'; +import { createRendering } from './rendering.js'; + +// Cesium tests a material's uniforms against these browser classes. +for (const name of [ + 'HTMLCanvasElement', + 'HTMLImageElement', + 'ImageBitmap', + 'OffscreenCanvas', +]) + globalThis[name] ??= class {}; +// A line primitive's render state is checked against the context's limits, +// which only a WebGL context sets. +Cesium.ContextLimits._minimumAliasedLineWidth = 1; +Cesium.ContextLimits._maximumAliasedLineWidth = 1; + +/** A road with flow; `level` picks its bucket (0.1 jam, 0.6 slow, 1 free). */ +function road(level = 1) { + return { + flow: { level, closure: false }, + segmentDist: [100], + waypoints: Cesium.Cartesian3.fromDegreesArray([ + -97.744, 30.267, -97.742, 30.267, + ]), + }; +} + +/** + * Rendering over a scene that records its ground primitives. Each added + * primitive is replaced by a stand-in whose `ready` the test sets, as Cesium + * sets it once the asynchronous geometry is built. + */ +function harness() { + const scene = []; + const added = []; + let linesOn = true; + const state = { + _viewer: { + scene: { + globe: { show: true }, + groundPrimitives: { + add(primitive) { + const line = { + ready: false, + roads: primitive.geometryInstances.length, + }; + scene.push(line); + added.push(line); + return line; + }, + remove(line) { + const index = scene.indexOf(line); + if (index >= 0) scene.splice(index, 1); + }, + }, + }, + }, + _liveMode: true, + _showLines: true, + _heatSupported: true, + _heatJamPrim: null, + _heatSlowPrim: null, + _heatFreePrim: null, + _heatRetired: [], + _heatPendingRoads: null, + _heatLineCount: 0, + }; + const rendering = createRendering({ + state, + services: {}, + source: {}, + parts: { + style: { heatlineOn: () => linesOn }, + animation: { syncRenderDemand() {} }, + }, + }); + return { + state, + rendering, + scene, + added, + setLines(on) { + linesOn = on; + state._showLines = on; + }, + /** Finish every build in the scene, then run a frame. */ + frame({ built = true } = {}) { + if (built) for (const line of scene) line.ready = true; + rendering.retireHeatLines(); + }, + }; +} + +const roads = (count) => Array.from({ length: count }, () => road()); + +test('rebuilds asked for while lines build do not start more builds', () => { + const { rendering, scene, added, frame } = harness(); + rendering.rebuildHeatLines(roads(1)); + assert.equal(added.length, 1); + // Progressive paints while the first set builds. + for (const count of [2, 3, 4]) rendering.rebuildHeatLines(roads(count)); + frame({ built: false }); + assert.equal(added.length, 1, 'no build started during a build'); + assert.equal(scene.length, 1); + assert.deepEqual(scene[0].roads, 1); +}); + +test('the newest rebuild asked for during a build is the one that runs', () => { + const { state, rendering, scene, added, frame } = harness(); + rendering.rebuildHeatLines(roads(1)); + for (const count of [2, 3, 4]) rendering.rebuildHeatLines(roads(count)); + frame(); + // One build for the three requests, of the last one's roads. + assert.deepEqual( + added.map((line) => line.roads), + [1, 4], + ); + assert.equal(state._heatLineCount, 4); + assert.equal(state._heatPendingRoads, null); + // The set it replaces stays drawn while it builds, then goes. + assert.deepEqual( + scene.map((line) => line.roads), + [1, 4], + ); + frame(); + assert.deepEqual( + scene.map((line) => line.roads), + [4], + ); + // Nothing is waiting, so later frames start nothing. + frame(); + assert.equal(added.length, 2); +}); + +test('a rebuild with nothing building starts at once', () => { + const { rendering, added, frame } = harness(); + rendering.rebuildHeatLines(roads(1)); + frame(); + rendering.rebuildHeatLines(roads(2)); + assert.deepEqual( + added.map((line) => line.roads), + [1, 2], + ); +}); + +test('turning lines off during a build removes them at once and drops the waiting rebuild', () => { + const { state, rendering, scene, added, setLines, frame } = harness(); + rendering.rebuildHeatLines(roads(1)); + frame(); + rendering.rebuildHeatLines(roads(2)); + rendering.rebuildHeatLines(roads(3)); + assert.equal(scene.length, 2, 'the drawn set and the building one'); + setLines(false); + rendering.rebuildHeatLines(roads(3)); + assert.deepEqual(scene, [], 'not deferred behind the build'); + assert.equal(state._heatPendingRoads, null); + assert.equal(state._heatLineCount, 0); + frame(); + assert.equal(added.length, 2, 'the waiting rebuild never runs'); + assert.deepEqual(scene, []); +}); + +test('a rebuild right after a build finishes does not keep the lines that build replaced', () => { + const { rendering, scene } = harness(); + rendering.rebuildHeatLines(roads(1)); + for (const line of scene) line.ready = true; + rendering.rebuildHeatLines(roads(2)); + // The second build finishes, and a third is asked for before a frame ran. + for (const line of scene) line.ready = true; + rendering.rebuildHeatLines(roads(3)); + assert.deepEqual( + scene.map((line) => line.roads), + [2, 3], + ); +}); + +test('removing the lines drops the waiting rebuild', () => { + const { state, rendering, scene, added, frame } = harness(); + rendering.rebuildHeatLines(roads(1)); + rendering.rebuildHeatLines(roads(2)); + rendering.removeHeatLines(); + assert.deepEqual(scene, []); + assert.equal(state._heatPendingRoads, null); + frame(); + assert.equal(added.length, 1); +}); + +test('a stream of rebuilds never leaves more than two sets in the scene, and ends with one', () => { + const { rendering, scene, added, frame } = harness(); + let most = 0; + const lines = [road(0.1), road(0.6), road(1)]; + // Three primitives per set: one per flow bucket. + for (let paint = 0; paint < 40; paint += 1) { + rendering.rebuildHeatLines(lines); + most = Math.max(most, scene.length); + // A build takes several paints. + if (paint % 5 === 4) frame(); + else frame({ built: false }); + most = Math.max(most, scene.length); + } + frame(); + frame(); + assert.equal(most <= 6, true, `${most} primitives at once`); + assert.equal(scene.length, 3, 'only the current set remains'); + assert.equal( + scene.every((line) => line.ready), + true, + ); + // Without coalescing every paint builds: 40 sets, 120 primitives. + assert.equal( + added.length <= 3 * 10, + true, + `${added.length} primitives built`, + ); +}); diff --git a/src/layers/traffic/rendering.js b/src/layers/traffic/rendering.js index 402cd08b6..49f5af8e9 100644 --- a/src/layers/traffic/rendering.js +++ b/src/layers/traffic/rendering.js @@ -59,24 +59,37 @@ export function createRendering({ /** Remove the heat-line / flow-line ground primitives from the scene. */ function removeHeatLines() { + layerState._heatPendingRoads = null; removePrimitives(takeHeatLines()); removePrimitives(layerState._heatRetired || []); layerState._heatRetired = []; parts.animation.syncRenderDemand?.(); } + /** @returns {boolean} Whether a line primitive's geometry is still building. */ + + function linesBuilding() { + return LINE_KEYS.some((key) => layerState[key] && !layerState[key].ready); + } + /** * Replaced lines stay drawn until their successors finish building * (GroundPolylinePrimitive geometry is asynchronous), so a rebuild never - * blinks. Called every held frame. + * blinks. Once they have, the replaced lines go and the newest rebuild + * asked for in the meantime starts. Called every held frame. */ function retireHeatLines() { - if (!layerState._heatRetired?.length) return; - if (LINE_KEYS.some((key) => layerState[key] && !layerState[key].ready)) - return; - removePrimitives(layerState._heatRetired); - layerState._heatRetired = []; + if (linesBuilding()) return; + if (layerState._heatRetired?.length) { + removePrimitives(layerState._heatRetired); + layerState._heatRetired = []; + } + const pending = layerState._heatPendingRoads; + if (pending) { + layerState._heatPendingRoads = null; + rebuildHeatLines(pending); + } } /** @@ -95,24 +108,37 @@ export function createRendering({ * the globe is hidden (photoreal) and on terrain or tiles otherwise. No-op in * sim mode, when both looks are off, or without ground-primitive support. * + * One build runs at a time. A city loads in progressive paints, each asking + * for a rebuild, and every build drapes up to the cap of polylines + * asynchronously. A rebuild asked for while lines are building only replaces + * the one waiting, which retireHeatLines starts when the build finishes, so + * the scene never holds more than the lines drawn and the lines building. + * Turning the lines off is never deferred. + * * @param {Array} roads - Road objects visible in the current render. */ function rebuildHeatLines(roads) { + const wanted = + layerState._viewer && layerState._liveMode && parts.style.heatlineOn(); + if (wanted && linesBuilding()) { + layerState._heatPendingRoads = roads; + return; + } + layerState._heatPendingRoads = null; + // No build is left for replaced lines to wait for (it finished, or lines + // are going off), so they go first and never pile up behind this rebuild. + removePrimitives(layerState._heatRetired || []); + layerState._heatRetired = []; const previous = takeHeatLines(); const finish = () => { - // Nothing new to wait for: the old lines go now. if (LINE_KEYS.some((key) => layerState[key])) (layerState._heatRetired ||= []).push(...previous); + // Nothing new to wait for: the old lines go now. else removePrimitives(previous); parts.animation.syncRenderDemand?.(); }; - if ( - !layerState._viewer || - !layerState._liveMode || - !parts.style.heatlineOn() - ) - return finish(); + if (!wanted) return finish(); if (layerState._heatSupported === null) { layerState._heatSupported = Cesium.GroundPolylinePrimitive.isSupported( layerState._viewer.scene, diff --git a/src/layers/traffic/state.js b/src/layers/traffic/state.js index 8c2fd9f94..e82cd6191 100644 --- a/src/layers/traffic/state.js +++ b/src/layers/traffic/state.js @@ -200,6 +200,10 @@ export function createState({ services, source }) { layerState._heatRetired = []; + /** @type {Array|null} Roads of the newest rebuild asked for while the lines were building. */ + + layerState._heatPendingRoads = null; + /** @type {number} Heat-lines in the current render (stats). */ layerState._heatLineCount = 0;