diff --git a/src/layers/traffic/flow.js b/src/layers/traffic/flow.js index caf8aede2..ff5c7c189 100644 --- a/src/layers/traffic/flow.js +++ b/src/layers/traffic/flow.js @@ -1,6 +1,7 @@ import { roadIdentity } from './retention.js'; import * as Cesium from 'cesium'; import { + floorCellReader, prepareRoadSurfaces, roadHeightStations, trafficSurfaceReady, @@ -301,6 +302,7 @@ export function createFlow({ state: layerState, services, parts, source }) { } } if (depthHeight !== undefined) layerState._viewHeightEstimate = depthHeight; + const floorAt = floorCellReader(services.ground); for (const road of roads) { road.key ||= roadIdentity(road); const prepared = !refining && preparedRoads.get(road.key); @@ -319,7 +321,7 @@ export function createFlow({ state: layerState, services, parts, source }) { ); for (let i = 0; i < road.coords.length; i++) { const [lon, lat] = road.coords[i]; - const floor = services.ground?.cachedGroundFloor?.(lat, lon); + const floor = floorAt(lat, lon); const height = Number.isFinite(floor) && Math.abs(floor) <= 9000 ? Math.max(floor, layerState._viewHeightEstimate) diff --git a/src/layers/traffic/index.js b/src/layers/traffic/index.js index 4a5839f7d..eb0f8f544 100644 --- a/src/layers/traffic/index.js +++ b/src/layers/traffic/index.js @@ -39,10 +39,10 @@ export function createTrafficLayer({ services, source }) { parts.controls = createControls(context); parts.lifecycle = createLifecycle(context); state._parseRoads = TRAFFIC_TIMING_ENABLED - ? (data, trace) => + ? (data, trace, parsedBySource) => trace - ? parts.timing.parseRoadsTimed(data, trace) - : parts.model.parseRoads(data) + ? parts.timing.parseRoadsTimed(data, trace, parsedBySource) + : parts.model.parseRoads(data, trace, parsedBySource) : parts.model.parseRoads; state._loadRoadsForBounds = TRAFFIC_TIMING_ENABLED diff --git a/src/layers/traffic/ingestion.js b/src/layers/traffic/ingestion.js index a5e09f543..0065f5638 100644 --- a/src/layers/traffic/ingestion.js +++ b/src/layers/traffic/ingestion.js @@ -348,11 +348,16 @@ export function createIngestion({ layerState._retainedPreview = false; layerState._detailRoadsReady = false; let paintRevision = 0; + // The snapshot that ends a pass replaces the streamed roads with the + // composed set, which is mostly the tile records already parsed and + // drawn. Remembering those per pass means only the records new to that + // snapshot (the clipped coarse roads) are parsed, not the whole set again. + let parsedBySource = new WeakMap(); const onTile = (data, passSignal, preview) => { if (generation !== layerState._loadGeneration || passSignal.aborted) return; const start = performance.now(); - const parsed = layerState._parseRoads(data, trace); + const parsed = layerState._parseRoads(data, trace, parsedBySource); phaseTiming('parse', start, { roads: parsed.length, incremental: true }); if (data.replace) streamed = parsed; else streamed.push(...parsed); @@ -531,6 +536,7 @@ export function createIngestion({ if (altitude > FAST_FETCH_ALTITUDE) return; streamed = []; + parsedBySource = new WeakMap(); majorPass = false; // Detailed pass: fetch the full road graph (tertiary, residential, etc.) console.log(`[Data:Traffic] Full fetch local roads [${cacheKey}]`); diff --git a/src/layers/traffic/model.js b/src/layers/traffic/model.js index ff1c89ddb..10b2a4bbb 100644 --- a/src/layers/traffic/model.js +++ b/src/layers/traffic/model.js @@ -1,5 +1,5 @@ import * as Cesium from 'cesium'; -import { roadSurfaceChunks } from './surface.js'; +import { floorCellReader, roadSurfaceChunks } from './surface.js'; import { t, translateKnown } from '../../i18n/index.js'; import { flowDensityMult, @@ -13,22 +13,35 @@ import { } from './policy.js'; export function createModel({ state: layerState, services, parts, source }) { - /** Build scene waypoints from source records; thinning and terrain remain rendering policy. */ - function parseRoads(roadData) { + /** + * Build scene waypoints from source records; thinning and terrain remain rendering policy. + * + * `parsedBySource` (source record → its parsed roads) is filled as records + * are parsed, and a record already in it is answered from it. + */ + function parseRoads(roadData, _trace, parsedBySource) { if (!roadData || !roadData.roads) { return []; } const roads = []; + const floorAt = floorCellReader(services.ground); for (const road of roadData.roads) { if (!road.coordinates || road.coordinates.length < 2) continue; + // A replacing snapshot repeats records this load already parsed. + const known = parsedBySource?.get(road); + if (known) { + for (const parsed of known) roads.push(parsed); + continue; + } + const first = roads.length; for (const coords of roadSurfaceChunks(road.coordinates)) { const type = road.type; const oneway = road.oneway; // Pre-compute Cartesian3 waypoints (lon, lat, height) for fast lerp animation const waypoints = coords.map(([lng, lat]) => { - const floor = services.ground?.cachedGroundFloor?.(lat, lng); + const floor = floorAt(lat, lng); const h = (Number.isFinite(floor) && Math.abs(floor) <= 9000 ? floor @@ -60,6 +73,7 @@ export function createModel({ state: layerState, services, parts, source }) { flow: road.flow || null, }); } + parsedBySource?.set(road, roads.slice(first)); } return roads; diff --git a/src/layers/traffic/roadLoadCost.test.mjs b/src/layers/traffic/roadLoadCost.test.mjs new file mode 100644 index 000000000..c248624c4 --- /dev/null +++ b/src/layers/traffic/roadLoadCost.test.mjs @@ -0,0 +1,444 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as Cesium from 'cesium'; +import { createTrafficSource, outsideDetailTiles } from './source.js'; +import { createIngestion } from './ingestion.js'; +import { createModel } from './model.js'; +import { floorCellReader, roadSurfaceChunks } from './surface.js'; +import { DOT_HEIGHT_OFFSET } from './policy.js'; +import { clipTileLine } from '../../sources/openFreeMap.js'; +import { tileToBBox } from '../../data/tomtomTiles.js'; + +// The clip as it was before the extent shortcut: every road against the four +// regions around every tile. The shortcut must return exactly this. +function clipEveryRoadAgainstEveryTile(roads, tiles) { + return roads.flatMap((road) => { + let lines = [road.coordinates]; + for (const tile of tiles) { + const box = tileToBBox(tile.z, tile.x, tile.y); + const regions = [ + { west: -180, east: box.west, south: -85, north: 85 }, + { west: box.east, east: 180, south: -85, north: 85 }, + { west: box.west, east: box.east, south: -85, north: box.south }, + { west: box.west, east: box.east, south: box.north, north: 85 }, + ]; + lines = lines.flatMap((line) => + regions.flatMap((region) => clipTileLine(line, region)), + ); + } + return lines.map((coordinates) => ({ ...road, coordinates })); + }); +} + +function seeded(seed) { + let state = seed >>> 0; + return () => { + state = (Math.imul(state, 1664525) + 1013904223) >>> 0; + return state / 2 ** 32; + }; +} + +function tileBlock(x, y, size = 4) { + const tiles = []; + for (let dx = 0; dx < size; dx++) + for (let dy = 0; dy < size; dy++) + tiles.push({ z: 14, x: x + dx, y: y + dy }); + return tiles; +} + +/** Roads in and around a tile block: wandering, straight, tiny, and on tile edges. */ +function roadsAround(tiles, random, count = 400) { + const boxes = tiles.map((tile) => tileToBBox(tile.z, tile.x, tile.y)); + const west = Math.min(...boxes.map((box) => box.west)), + east = Math.max(...boxes.map((box) => box.east)), + south = Math.min(...boxes.map((box) => box.south)), + north = Math.max(...boxes.map((box) => box.north)); + const width = east - west, + height = north - south; + const roads = []; + for (let i = 0; i < count; i++) { + const step = i % 7 === 0 ? 0.00002 : i % 3 === 0 ? 0.01 : 0.002; + let lon = west - width / 2 + random() * width * 2, + lat = south - height / 2 + random() * height * 2; + const coordinates = [[lon, lat]]; + const points = 1 + Math.floor(random() * 12); + for (let j = 0; j < points; j++) { + lon += (random() - 0.5) * step; + lat += (random() - 0.5) * step; + coordinates.push([lon, lat]); + } + roads.push({ type: 'primary', oneway: i % 2, id: i, coordinates }); + } + const box = boxes[5]; + const midLat = (box.south + box.north) / 2, + midLon = (box.west + box.east) / 2; + roads.push( + // Along a tile edge, ending on a corner, and touching an edge from outside. + { + id: 'edge', + coordinates: [ + [box.west, box.south - 0.01], + [box.west, box.north + 0.01], + ], + }, + { + id: 'corner', + coordinates: [ + [box.west - 0.01, box.south - 0.01], + [box.west, box.south], + [box.east, box.north], + ], + }, + { + id: 'touch', + coordinates: [ + [box.west - 0.01, midLat], + [box.west, midLat], + ], + }, + // Across the whole block, and a repeated vertex. + { + id: 'across', + coordinates: [ + [west - 0.01, midLat], + [east + 0.01, midLat + 0.0003], + ], + }, + { + id: 'repeat', + coordinates: [ + [midLon, midLat], + [midLon, midLat], + [midLon + 0.001, midLat], + ], + }, + // Records the clip drops or leaves alone. + { id: 'single', coordinates: [[west - 0.02, midLat]] }, + { id: 'empty', coordinates: [] }, + { + id: 'negative-zero', + coordinates: [ + [-0, south - 0.02], + [0.001, south - 0.02], + ], + }, + { + id: 'polar', + coordinates: [ + [west - 0.02, 86], + [west - 0.02, 86.01], + ], + }, + ); + return roads; +} + +for (const [place, x, y] of [ + ['Manhattan', 4823, 6158], + ['London, across longitude 0', 8190, 5446], + ['longitude 0 and latitude 0', 8190, 8190], + ['the southern and eastern hemispheres', 15070, 9826], +]) + test(`coarse roads clipped around detail tiles match the every-tile clip: ${place}`, () => { + const tiles = tileBlock(x, y); + const roads = roadsAround(tiles, seeded(x * 31 + y)); + for (const loaded of [tiles, tiles.slice(0, 5), tiles.slice(3, 4), []]) { + const expected = clipEveryRoadAgainstEveryTile(roads, loaded); + assert.ok(loaded.length === 0 || expected.length !== roads.length); + // deepStrictEqual separates -0 from 0 and compares every vertex exactly. + assert.deepStrictEqual(outsideDetailTiles(roads, loaded), expected); + } + }); + +test('only a road that reaches a detail tile is clipped against it', () => { + const tiles = tileBlock(4823, 6158); + const block = tiles.map((tile) => tileToBBox(tile.z, tile.x, tile.y)); + const west = Math.min(...block.map((box) => box.west)), + north = Math.max(...block.map((box) => box.north)); + // 300 roads beside the block and between its tile edges, none touching one. + const clear = []; + for (let i = 0; i < 300; i++) { + const box = block[i % block.length]; + const lon = i % 2 ? west - 0.02 - i * 1e-5 : box.west + 0.004; + const lat = i % 2 ? box.south + 0.004 : north + 0.01 + i * 1e-5; + clear.push({ + coordinates: [ + [lon, lat], + [lon + 0.002, lat + 0.0005], + [lon + 0.003, lat + 0.001], + ], + }); + } + const first = block[0]; + const crossing = { + coordinates: [ + [first.west + 0.004, north + 0.01], + [first.west + 0.004, (first.south + first.north) / 2], + ], + }; + let clips = 0; + const counted = (line, region) => { + clips++; + return clipTileLine(line, region); + }; + const roads = outsideDetailTiles([...clear, crossing], tiles, counted); + assert.deepStrictEqual( + roads, + clipEveryRoadAgainstEveryTile([...clear, crossing], tiles), + ); + assert.equal(roads.length, 301); + // Four regions for each tile the crossing road reaches, never the 19,264 + // clips of every road against every tile. + assert.ok(clips >= 4, `expected the crossing road to be clipped: ${clips}`); + assert.ok(clips <= 4 * tiles.length, `clipped ${clips} times`); +}); + +/** A DEM-regime ground service: one floor per toFixed(3) cell, counting reads. */ +function cellGround({ mesh = false, warm = () => true } = {}) { + const ground = { + reads: 0, + meshFloorPreferred: () => mesh, + cachedGroundFloor(lat, lon) { + ground.reads++; + if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null; + // Number() as the shared cache does it: "-0.000" and "0.000" are one cell. + const cell = `${Number(lat.toFixed(3))},${Number(lon.toFixed(3))}`; + if (!warm(cell)) return null; + let hash = 0; + for (const character of cell) + hash = (Math.imul(hash, 31) + character.charCodeAt(0)) | 0; + // The mesh regime answers per point, not per cell. + return (hash % 4000) / 10 + (mesh ? lat - Math.floor(lat * 1000) / 1000 : 0); + }, + }; + return ground; +} + +test('floor reads are shared per cell and equal the per-coordinate reads', () => { + const random = seeded(7); + const points = []; + for (let i = 0; i < 4000; i++) + points.push([40.75 + random() * 0.01, -73.99 + random() * 0.01]); + // Cell edges, where toFixed(3) and Math.round can disagree, in each sign. + for (const lat of [40.7505, -40.7505, 0.0005, -0.0005, 1.0005, 8.5745]) + for (const lon of [-73.9905, 73.9905, 0.0005, -0.0005, 1.0005, 2.3515]) + points.push([lat, lon], [lat + 1e-9, lon - 1e-9], [lat - 1e-9, lon]); + points.push([Number.NaN, 0], [0, Infinity], [0, 0], [-0, -0]); + + const direct = cellGround(); + const expected = points.map(([lat, lon]) => + direct.cachedGroundFloor(lat, lon), + ); + const ground = cellGround(); + const floorAt = floorCellReader(ground); + assert.deepStrictEqual( + points.map(([lat, lon]) => floorAt(lat, lon)), + expected, + ); + // 4000 points in a 0.01 degree square share about 121 cells. + assert.ok(ground.reads < 600, `read the shared cache ${ground.reads} times`); + + // A cold cell is remembered as cold for the pass, not asked again. + const cold = cellGround({ warm: () => false }); + const coldAt = floorCellReader(cold); + assert.equal(coldAt(40.7512, -73.9812), null); + assert.equal(coldAt(40.7513, -73.9813), null); + assert.equal(cold.reads, 1); +}); + +test('mesh floors and unknown ground services are read per coordinate', () => { + const mesh = cellGround({ mesh: true }); + const meshAt = floorCellReader(mesh); + const a = meshAt(40.7512, -73.9812), + b = meshAt(40.75121, -73.9812); + assert.equal(mesh.reads, 2); + assert.notEqual(a, b); + + // No regime to ask: nothing says a floor is the same across a cell. + let reads = 0; + const bareAt = floorCellReader({ cachedGroundFloor: () => ++reads }); + bareAt(40.7512, -73.9812); + bareAt(40.7512, -73.9812); + assert.equal(reads, 2); + assert.equal(floorCellReader(undefined)(1, 2), undefined); + assert.equal(floorCellReader({})(1, 2), undefined); +}); + +function road(lon, lat, points = 40) { + const coordinates = []; + for (let i = 0; i < points; i++) + coordinates.push([lon + i * 0.00004, lat + i * 0.00003]); + return { type: 'residential', oneway: 1, coordinates }; +} + +test('parsing reads each floor cell once and builds the same waypoints', () => { + const ground = cellGround({ warm: (cell) => !cell.endsWith('3') }); + const state = { _viewHeightEstimate: 12 }; + const model = createModel({ + state, + services: { ground }, + parts: {}, + source: {}, + }); + const records = [road(-73.99, 40.75), road(-73.9899, 40.7501)]; + const parsed = model.parseRoads({ roads: records }); + const coordinates = records.reduce( + (count, record) => count + record.coordinates.length, + 0, + ); + assert.ok( + ground.reads < coordinates / 3, + `${ground.reads} floor reads for ${coordinates} coordinates`, + ); + + const direct = cellGround({ warm: (cell) => !cell.endsWith('3') }); + const expected = records.flatMap((record) => + roadSurfaceChunks(record.coordinates).map((coords) => + coords.map(([lon, lat]) => { + const floor = direct.cachedGroundFloor(lat, lon); + return Cesium.Cartesian3.fromDegrees( + lon, + lat, + (floor ?? state._viewHeightEstimate) + DOT_HEIGHT_OFFSET, + ); + }), + ), + ); + assert.ok(direct.reads >= coordinates); + assert.deepStrictEqual( + parsed.map((entry) => entry.waypoints), + expected, + ); +}); + +test('a record parsed earlier in the pass is not parsed again', () => { + const ground = cellGround(); + const model = createModel({ + state: {}, + services: { ground }, + parts: {}, + source: {}, + }); + const first = { ...road(-73.99, 40.75), oneway: 0 }, + second = road(-73.98, 40.76), + short = { type: 'primary', coordinates: [[-73.97, 40.77]] }; + const parsedBySource = new WeakMap(); + const tile = model.parseRoads({ roads: [first] }, null, parsedBySource); + assert.equal(tile.length, 2, 'a two-way record parses to both directions'); + const reads = ground.reads; + const replaced = model.parseRoads( + { roads: [second, short, first], replace: true }, + null, + parsedBySource, + ); + assert.equal(replaced.length, 3); + // The same parsed roads, in the snapshot's order: dots stay attached. + assert.equal(replaced[1], tile[0]); + assert.equal(replaced[2], tile[1]); + const fresh = cellGround(); + createModel({ + state: {}, + services: { ground: fresh }, + parts: {}, + source: {}, + }).parseRoads({ roads: [second] }); + assert.equal(ground.reads - reads, fresh.reads, 'only the new record read'); + + // Without the map every call parses, as before. + const again = model.parseRoads({ roads: [first] }); + assert.notEqual(again[0], tile[0]); + assert.deepStrictEqual(again, tile); +}); + +test('the snapshot that ends a pass parses only the records it adds', async () => { + const detail = { z: 14, x: 3743, y: 6745 }; + const core = tileToBBox(detail.z, detail.x, detail.y); + const local = { + type: 'residential', + oneway: 1, + coordinates: [ + [core.west + 0.002, core.south + 0.002], + [core.west + 0.004, core.south + 0.004], + ], + }; + const distant = { + type: 'primary', + oneway: 1, + coordinates: [ + [core.east + 0.01, core.south], + [core.east + 0.02, core.south], + ], + }; + const bounds = { + ...core, + east: core.east + 0.03, + coverage: { + detail: [detail], + coarse: [{ z: 12, x: 935, y: 1686 }], + key: 'test', + }, + }; + const source = createTrafficSource({ + mapTiles: { + async fetchBounds(box, { zoom, onTile }) { + const tile = { roads: zoom === 14 ? [local] : [distant] }; + onTile?.(tile); + return { + tiles: [tile], + loadedTiles: zoom === 14 ? [detail] : bounds.coverage.coarse, + partial: false, + }; + }, + }, + }); + const parsedRecords = []; + const painted = []; + const state = { + _enabled: true, + _loadGeneration: 0, + _tileCache: new Map(), + _roadMode: null, + _liveMode: false, + // The model's contract: fill the map, answer known records from it. + _parseRoads: (data, trace, parsedBySource) => + data.roads.flatMap((record) => { + const known = parsedBySource?.get(record); + if (known) return known; + parsedRecords.push(record); + const parsed = [{ record }]; + parsedBySource?.set(record, parsed); + return parsed; + }), + }; + const ingestion = createIngestion({ + state, + source, + services: {}, + parts: { + viewport: { + clampBounds: (b) => b, + getBoundsCenter: () => ({ lat: 30.26, lon: -97.74 }), + }, + flow: { + warmFlow: () => Promise.resolve([]), + applyFlowThenRender: async (roads, box, generation, altitude, label) => { + if (label === 'Loaded full') painted.push(roads); + return true; + }, + }, + }, + }); + await ingestion.loadRoadsForBounds(bounds, 350); + assert.equal( + parsedRecords.filter((record) => record === local).length, + 1, + 'the detail tile record is parsed when its tile streams, not again', + ); + // The full graph is still the detail record plus the coarse road outside it. + assert.equal(painted.at(-1).length, 2); + assert.equal(painted.at(-1)[0].record, local); + assert.deepStrictEqual( + painted.at(-1)[1].record.coordinates, + distant.coordinates, + ); +}); diff --git a/src/layers/traffic/source.js b/src/layers/traffic/source.js index 7327a3f98..135db0f38 100644 --- a/src/layers/traffic/source.js +++ b/src/layers/traffic/source.js @@ -33,23 +33,108 @@ export function trafficDetailBounds(box) { return detail; } -/** Keep coarse roads only outside the detailed tile cores. */ -export function outsideDetailTiles(roads, tiles) { +/** Longitude/latitude extent of one line; NaN coordinates leave it unusable. */ +function lineExtent(coordinates) { + let west = Infinity, + east = -Infinity, + south = Infinity, + north = -Infinity; + for (const [lon, lat] of coordinates) { + if (!(lon >= west)) west = lon; + if (!(lon <= east)) east = lon; + if (!(lat >= south)) south = lat; + if (!(lat <= north)) north = lat; + } + return { coordinates, west, east, south, north, settled: false }; +} + +/** + * What `clipTileLine` returns for a line lying wholly inside the clip region: + * the same line with each vertex recomputed from the one before it. Returns + * null for a line under the 12 m floor `clipTileLine` drops. + */ +function unclippedLine(coordinates) { + if (coordinates.length < 2) return null; + const out = []; + let metres = 0; + for (let i = 1; i < coordinates.length; i++) { + const a = coordinates[i - 1], + b = coordinates[i]; + const dx = b[0] - a[0], + dy = b[1] - a[1]; + if (i === 1) out.push([a[0] + 0 * dx, a[1] + 0 * dy]); + out.push([a[0] + dx, a[1] + dy]); + metres += + Math.hypot( + (out[i][0] - out[i - 1][0]) * Math.cos((out[i][1] * Math.PI) / 180), + out[i][1] - out[i - 1][1], + ) * 111320; + } + return metres >= 12 ? out : null; +} + +const sameLine = (a, b) => + a.every( + (point, i) => Object.is(point[0], b[i][0]) && Object.is(point[1], b[i][1]), + ); + +/** + * Keep coarse roads only outside the detailed tile cores. + * + * Each tile splits a line into the four regions around its core. Nearly every + * coarse road lies wholly inside one of those regions for nearly every tile, + * where the clip hands the line back unchanged, so that case is answered from + * the line's extent and only a line that reaches a tile's core or edge is + * clipped. Clipping every road against every tile froze the page for hundreds + * of milliseconds per road load at street level. + * + * `clip` is injectable for tests. + */ +export function outsideDetailTiles(roads, tiles, clip = clipTileLine) { + const boxes = tiles.map((tile) => tileToBBox(tile.z, tile.x, tile.y)); return roads.flatMap((road) => { - let lines = [road.coordinates]; - for (const tile of tiles) { - const box = tileToBBox(tile.z, tile.x, tile.y); - const regions = [ - { west: -180, east: box.west, south: -85, north: 85 }, - { west: box.east, east: 180, south: -85, north: 85 }, - { west: box.west, east: box.east, south: -85, north: box.south }, - { west: box.west, east: box.east, south: box.north, north: 85 }, - ]; - lines = lines.flatMap((line) => - regions.flatMap((region) => clipTileLine(line, region)), - ); + let lines = [lineExtent(road.coordinates)]; + for (const box of boxes) { + const next = []; + for (const line of lines) { + // Strictly inside one region, and inside the bounds every region + // shares: the other three regions return nothing. + const clear = + line.west > -180 && + line.east < 180 && + line.south > -85 && + line.north < 85 && + (line.east < box.west || + line.west > box.east || + (line.west > box.west && + line.east < box.east && + (line.north < box.south || line.south > box.north))); + if (!clear) { + const regions = [ + { west: -180, east: box.west, south: -85, north: 85 }, + { west: box.east, east: 180, south: -85, north: 85 }, + { west: box.west, east: box.east, south: -85, north: box.south }, + { west: box.west, east: box.east, south: box.north, north: 85 }, + ]; + for (const region of regions) + for (const coordinates of clip(line.coordinates, region)) + next.push(lineExtent(coordinates)); + } else if (line.settled) { + next.push(line); + } else { + // The first unclipped pass can still move a vertex by a rounding + // step (a road crossing longitude or latitude 0); once a pass + // changes nothing, no later one will. + const coordinates = unclippedLine(line.coordinates); + if (!coordinates) continue; + const kept = lineExtent(coordinates); + kept.settled = sameLine(coordinates, line.coordinates); + next.push(kept); + } + } + lines = next; } - return lines.map((coordinates) => ({ ...road, coordinates })); + return lines.map(({ coordinates }) => ({ ...road, coordinates })); }); } diff --git a/src/layers/traffic/surface.js b/src/layers/traffic/surface.js index 3344faa64..37a528901 100644 --- a/src/layers/traffic/surface.js +++ b/src/layers/traffic/surface.js @@ -69,6 +69,45 @@ export function trafficSurfaceReady(scene) { return currentRenderedFrame(scene)?.ready === true; } +/** + * Ground-floor reads for one synchronous pass over road coordinates, asking + * the shared cache once per floor cell instead of once per coordinate. + * + * A road tile puts thousands of coordinates into a few hundred ~111 m cells, + * and each shared read formats and looks up two string keys, which added up + * to seconds per street-level load. Mesh floors answer per point (the nearest + * reading in the cell), so they are read through unchanged; only the DEM + * regime is one value per cell. Build a reader per pass: cells warm up + * between passes. + */ +export function floorCellReader(ground) { + const read = ground?.cachedGroundFloor; + if (!read) return () => undefined; + if (ground.meshFloorPreferred?.() !== false) + return (lat, lon) => read(lat, lon); + const cells = new Map(); + return (lat, lon) => { + const y = lat * 1000, + x = lon * 1000; + const row = Math.round(y), + column = Math.round(x); + // The shared cache rounds with toFixed(3). At a cell edge that and + // Math.round can pick different cells, so an edge point asks directly. + if (!( + Math.abs(Math.abs(y - row) - 0.5) > 1e-6 && + Math.abs(Math.abs(x - column) - 0.5) > 1e-6 + )) + return read(lat, lon); + const key = row * 1e6 + column; + let floor = cells.get(key); + if (floor === undefined) { + floor = read(lat, lon) ?? null; + cells.set(key, floor); + } + return floor; + }; +} + /** Preserve bends and insert height samples at most 150 m apart, splitting long roads. */ export function roadSurfaceChunks(coordinates) { const chunks = []; diff --git a/src/layers/traffic/timing.js b/src/layers/traffic/timing.js index 0be95a330..bd1a3abb2 100644 --- a/src/layers/traffic/timing.js +++ b/src/layers/traffic/timing.js @@ -1,5 +1,5 @@ import * as Cesium from 'cesium'; -import { roadSurfaceChunks } from './surface.js'; +import { floorCellReader, roadSurfaceChunks } from './surface.js'; import { TRAFFIC_TIMING_ENABLED, DOT_HEIGHT_OFFSET } from './policy.js'; export function createTiming({ state: layerState, services, parts, source }) { @@ -190,7 +190,7 @@ export function createTiming({ state: layerState, services, parts, source }) { * waypoint-materialization time independently. */ - function parseRoadsTimed(roadData, trace) { + function parseRoadsTimed(roadData, trace, parsedBySource) { /* TRACE_ONLY_BEGIN */ const _trafficTimingState = trafficTimingPass( trace, @@ -231,11 +231,19 @@ export function createTiming({ state: layerState, services, parts, source }) { } const roads = []; + const floorAt = floorCellReader(services.ground); /* TRACE_ONLY_BEGIN */ let _trafficTimingWaypointMaterializationMs = 0; /* TRACE_ONLY_END */ for (const road of roadData.roads) { if (!road.coordinates || road.coordinates.length < 2) continue; + // A replacing snapshot repeats records this load already parsed. + const known = parsedBySource?.get(road); + if (known) { + for (const parsed of known) roads.push(parsed); + continue; + } + const first = roads.length; for (const coords of roadSurfaceChunks(road.coordinates)) { const type = road.type; @@ -245,7 +253,7 @@ export function createTiming({ state: layerState, services, parts, source }) { const _trafficTimingMaterializeStart = performance.now(); /* TRACE_ONLY_END */ const waypoints = coords.map(([lng, lat]) => { - const floor = services.ground?.cachedGroundFloor?.(lat, lng); + const floor = floorAt(lat, lng); const h = (Number.isFinite(floor) && Math.abs(floor) <= 9000 ? floor @@ -279,6 +287,7 @@ export function createTiming({ state: layerState, services, parts, source }) { flow: road.flow || null, }); } + parsedBySource?.set(road, roads.slice(first)); } /* TRACE_ONLY_BEGIN */ const _trafficTimingMetrics = {