diff --git a/changelog.d/0000-transit-terrain-height-cache.md b/changelog.d/0000-transit-terrain-height-cache.md new file mode 100644 index 000000000..f846f015d --- /dev/null +++ b/changelog.d/0000-transit-terrain-height-cache.md @@ -0,0 +1,6 @@ +- Fix the page freezing for seconds at a time with Transit on over a terrain + globe, most visibly while zooming over a large fleet. Every reread asked the + terrain for the height of every trail fix of every vehicle in view; heights + are now kept per point, read again only while the camera moves or tiles + load, and read within a time budget that puts vehicle positions first + (mazy1998). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index bdfefe043..67c567edd 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5578,6 +5578,23 @@ Noir samples also exclude its deliberately vignetted outer field, using shader settings and screen position rather than observed brightness. Selected-trail diagnostics report every condition even when selection fails. +On a terrain globe Transit reads each vehicle's and trail point's height from +the loaded surface (`globe.getHeight`, a ray cast against the drawn tile's +mesh) and keeps the answer per point, about a metre apart +(`src/layers/transit/height.js`, constants in `policy.js`). While the camera +moves or tiles load a kept height is read again after 2 s; a height read once +both have been still for 5 s is kept until either changes. Reads are budgeted +at 16 ms per 200 ms window, 12 ms of it for re-reads, longest-kept first. A +vehicle's own position is always read; the path its marker is drawn along +comes next; the undrawn trail history of unselected vehicles may use a quarter +of each budget. Past the budget a kept point keeps its last height, a new +point takes the last height read in its ~111 m floor cell or waits, and the +reread cycle continues until none waits. Before this, every reread asked the +terrain for every trail fix of every vehicle in view, which blocked the page +for up to 19 s at a time over a 650-vehicle fleet while zooming. Verified +against Boston's MBTA feed on a keyless terrain globe only; no steeper city +has a keyless feed to check against. + Transit brackets use the normal-composite overlay surface: a 1.25 px mode-colour stroke over 3.25 px dark backing. Other contact themes retain their existing behavior. The selected trail uses a 3 px core over 5 px backing, mode-coloured diff --git a/src/layers/transit/height.js b/src/layers/transit/height.js index 027dc7d41..6a7c7d8f4 100644 --- a/src/layers/transit/height.js +++ b/src/layers/transit/height.js @@ -5,6 +5,13 @@ import { FLOOR_WARM_PER_POLL, TRANSIT_POLL_MS, HEIGHT_SAMPLE_MAX_ALTITUDE_M, + TERRAIN_HEIGHT_CACHE_MAX, + TERRAIN_HEIGHT_FRESH_MS, + TERRAIN_NEED, + TERRAIN_READ_BUDGET_MS, + TERRAIN_READ_WINDOW_MS, + TERRAIN_REFRESH_BUDGET_MS, + TERRAIN_SETTLE_MS, withinViewBounds, } from './policy.js'; @@ -47,35 +54,182 @@ export function createHeight({ state, services, parts }) { return cameraAltitude() <= HEIGHT_SAMPLE_MAX_ALTITUDE_M; } + // --- Loaded-terrain heights --- + // `globe.getHeight` casts a ray against the mesh of the tile drawn under the + // point. The fleet asks for every fix of every trail on each reread, so the + // answers are kept per point and re-read on a time budget; see policy.js. + const terrainPoint = new Cesium.Cartographic(); + /** Point key → `{ h, at }`: the height read there and when. */ + const terrainHeights = new Map(); + /** Floor-cell key → the last height read anywhere in that cell. */ + const terrainCells = new Map(); + let terrainWindowAt = -Infinity; + let terrainReadMs = 0; + let terrainRefreshMs = 0; + // Per need (history, path): the oldest read among the points this window + // left for later, and the bound the next window re-reads up to. + const terrainDeferredAt = [Infinity, Infinity]; + const terrainRefreshUpTo = [Infinity, Infinity]; + /** Points a pass left unread or due a re-read; see rereadFloors. */ + let terrainWaiting = 0; + /** When the camera last moved or the globe was last loading tiles. */ + let terrainChangedAt = -Infinity; + let cameraLon = NaN, + cameraLat = NaN, + cameraHeight = NaN; + const terrainStats = { reads: 0, reused: 0, deferred: 0, borrowed: 0 }; + + /** A number unique to a point on a `scale`-per-degree grid. */ + function gridKey(lat, lon, scale) { + return ( + (Math.round(lat * scale) + 90 * scale) * (360 * scale + 1) + + Math.round(lon * scale) + + 180 * scale + ); + } + + function clearTerrainHeights() { + terrainHeights.clear(); + terrainCells.clear(); + terrainWindowAt = -Infinity; + terrainReadMs = terrainRefreshMs = 0; + terrainDeferredAt.fill(Infinity); + terrainRefreshUpTo.fill(Infinity); + terrainWaiting = 0; + terrainChangedAt = -Infinity; + cameraLon = cameraLat = cameraHeight = NaN; + } + + /** + * The loaded terrain's height at a point, or undefined when it is not + * known yet. A point read within the reuse time answers from memory, and + * so does one read after the camera and the globe's tiles had settled: the + * surface under it cannot have changed since. Past + * the window's budget a held point keeps its last height and a new point + * borrows its floor cell's. `need` (TERRAIN_NEED) ranks the request: a + * vehicle's own position is read regardless, since a marker without a + * height is not drawn; the path a marker is drawn along comes next; a trail + * nobody is looking at takes what is left. Requests come in the same order + * every pass, so re-reads go to the longest-held points first: otherwise + * the first few would be re-read forever and the rest never. + */ + function terrainHeight(globe, lat, lon, need) { + const now = performance.now(); + if (now - terrainWindowAt >= TERRAIN_READ_WINDOW_MS) { + for (let i = 0; i < terrainDeferredAt.length; i++) { + terrainRefreshUpTo[i] = terrainDeferredAt[i]; + terrainDeferredAt[i] = Infinity; + } + terrainWindowAt = now; + terrainReadMs = terrainRefreshMs = 0; + // The globe redraws a point from another tile only after the camera + // moves or a tile arrives. + const camera = state._viewer?.camera?.positionCartographic; + if ( + globe.tilesLoaded === false || + camera?.longitude !== cameraLon || + camera?.latitude !== cameraLat || + camera?.height !== cameraHeight + ) { + terrainChangedAt = now; + cameraLon = camera?.longitude; + cameraLat = camera?.latitude; + cameraHeight = camera?.height; + } + } + const key = gridKey(lat, lon, 1e5); + const held = terrainHeights.get(key); + if ( + held && + (now - held.at < TERRAIN_HEIGHT_FRESH_MS || + held.at >= terrainChangedAt + TERRAIN_SETTLE_MS) + ) { + terrainStats.reused++; + return held.h; + } + const cellKey = gridKey(lat, lon, 1e3); + const marker = need === TERRAIN_NEED.MARKER; + // History may use a quarter of each budget; the path the rest. + const share = need === TERRAIN_NEED.HISTORY ? 0.25 : 1; + const affordable = + marker && !held + ? true + : terrainReadMs < TERRAIN_READ_BUDGET_MS * (marker ? 1 : share) && + (!held || + marker || + (terrainRefreshMs < TERRAIN_REFRESH_BUDGET_MS * share && + held.at <= terrainRefreshUpTo[need])); + if (!affordable) { + terrainStats.deferred++; + if (held) { + if (!marker && held.at < terrainDeferredAt[need]) + terrainDeferredAt[need] = held.at; + terrainWaiting++; + return held.h; + } + const borrowed = terrainCells.get(cellKey); + if (borrowed !== undefined) { + terrainStats.borrowed++; + return borrowed; + } + terrainWaiting++; + return undefined; + } + Cesium.Cartographic.fromDegrees(lon, lat, 0, terrainPoint); + const rendered = globe.getHeight(terrainPoint); + const spent = performance.now() - now; + terrainReadMs += spent; + if (held) terrainRefreshMs += spent; + terrainStats.reads++; + // A tile that left the globe keeps the height it gave. + if (!Number.isFinite(rendered)) return held?.h; + if (held) { + held.h = rendered; + held.at = now; + } else { + if (terrainHeights.size >= TERRAIN_HEIGHT_CACHE_MAX) { + let drop = TERRAIN_HEIGHT_CACHE_MAX >> 2; + for (const old of terrainHeights.keys()) { + terrainHeights.delete(old); + if (--drop <= 0) break; + } + terrainCells.clear(); + } + terrainHeights.set(key, { h: rendered, at: now }); + } + terrainCells.set(cellKey, rendered); + return rendered; + } + /** * The floor under a vehicle, and the best fallback if its own cell is cold. * @param {number} lat * @param {number} lon + * @param {?object} [entry] The vehicle, for a fixture floor. + * @param {number} [need] How much the answer matters; see TERRAIN_NEED. * @returns {{cell: string, height: number|null, prior: number|null}} */ - const terrainPoint = new Cesium.Cartographic(); - function requestHeight(lat, lon, entry = null) { + function requestHeight(lat, lon, entry = null, need = TERRAIN_NEED.PATH) { const coarse = coarseFloorCoord(lat, lon); const cell = `${coarse.lat},${coarse.lon}`; const fixtureFloor = entry?.qaFloorM ?? state._qaFixtureFloors?.get(entry?.key); if (Number.isFinite(fixtureFloor)) return { cell, height: fixtureFloor, prior: null }; - // On a terrain globe, use the loaded surface under the marker. This is - // synchronous cache work and does not launch a terrain request. + // On a terrain globe, use the loaded surface under the marker. This reads + // tiles already loaded and does not launch a terrain request. const globe = state._viewer?.scene?.globe; if (globe?.show === true) { - Cesium.Cartographic.fromDegrees(lon, lat, 0, terrainPoint); - const rendered = globe.getHeight?.(terrainPoint); - const flat = + if ( state._viewer?.terrainProvider instanceof - Cesium.EllipsoidTerrainProvider; - if (Number.isFinite(rendered) || flat) - return { - cell, - height: (flat ? 0 : rendered) + GROUND_FLOOR_LIFT_M, - prior: null, - }; + Cesium.EllipsoidTerrainProvider + ) + return { cell, height: GROUND_FLOOR_LIFT_M, prior: null }; + const rendered = globe.getHeight + ? terrainHeight(globe, lat, lon, need) + : undefined; + if (Number.isFinite(rendered)) + return { cell, height: rendered + GROUND_FLOOR_LIFT_M, prior: null }; } const floor = cachedGroundFloor(lat, lon); if (Number.isFinite(floor)) { @@ -107,6 +261,7 @@ export function createHeight({ state, services, parts }) { function rereadFloors() { state._floorTimer = null; if (!state._enabled) return; + terrainWaiting = 0; const onGround = nearGround(), candidates = []; const entries = [...state._vehicles.values()].filter( @@ -120,6 +275,13 @@ export function createHeight({ state, services, parts }) { Number(b.key === state._selectedKey) - Number(a.key === state._selectedKey), ); + // Every vehicle's own position before any trail: the first trails of a + // pass must not spend the budget the last markers are waiting on. + for (const entry of entries) { + const newest = entry.fixes.at(-1); + if (newest) + requestHeight(newest.lat, newest.lon, entry, TERRAIN_NEED.MARKER); + } for (const entry of entries) { const cells = new Map(); const collect = (point, answer) => { @@ -141,7 +303,12 @@ export function createHeight({ state, services, parts }) { }; parts.trails.prepareEntry(entry, collect); const newest = entry.fixes.at(-1); - const answer = requestHeight(newest.lat, newest.lon, entry); + const answer = requestHeight( + newest.lat, + newest.lon, + entry, + TERRAIN_NEED.MARKER, + ); entry.heightCell = answer.cell; // The selected vehicle's path also offers its history, newest segment // first; the vehicle's own cell is read where its newest fix is. @@ -227,7 +394,12 @@ export function createHeight({ state, services, parts }) { for (const entry of entries) parts.trails.prepareEntry(entry); parts.rendering.requestVisibility(); governorRequestRender('transit-floor'); - if (state._floorAttempts++ < FLOOR_REREAD_ATTEMPTS) + // A pass that ran out of terrain budget before reaching every point + // comes back for the rest. Each pass reads its budget, and points stop + // falling due once the terrain settles, so this ends. + const waiting = terrainWaiting; + terrainWaiting = 0; + if (state._floorAttempts++ < FLOOR_REREAD_ATTEMPTS || waiting > 0) state._floorTimer = setTimeout(rereadFloors, FLOOR_REREAD_MS); } @@ -258,6 +430,7 @@ export function createHeight({ state, services, parts }) { } state._floorAttempts = 0; state._heightDirty.clear(); + clearTerrainHeights(); } return { @@ -267,5 +440,7 @@ export function createHeight({ state, services, parts }) { anchorFloors, rereadFloors, clear, + /** Terrain read counters since the layer was created, for checks. */ + terrainStats: () => ({ ...terrainStats, held: terrainHeights.size }), }; } diff --git a/src/layers/transit/height.test.mjs b/src/layers/transit/height.test.mjs new file mode 100644 index 000000000..c9bff2ef0 --- /dev/null +++ b/src/layers/transit/height.test.mjs @@ -0,0 +1,356 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as Cesium from 'cesium'; +import { createHeight } from './height.js'; +import { + FLOOR_REREAD_ATTEMPTS, + FLOOR_REREAD_MS, + TERRAIN_HEIGHT_CACHE_MAX, + TERRAIN_HEIGHT_FRESH_MS, + TERRAIN_NEED, + TERRAIN_READ_BUDGET_MS, + TERRAIN_READ_WINDOW_MS, + TERRAIN_REFRESH_BUDGET_MS, + TERRAIN_SETTLE_MS, +} from './policy.js'; + +const LIFT = 1.5; + +/** + * A height part over a terrain globe whose every read costs `costMs` of a + * clock the test owns. `surface(lat, lon)` is the terrain; `reads` lists the + * points actually read. + */ +function terrain(t, { costMs = 0, surface = () => 100, provider = {} } = {}) { + const clock = { now: 1_000_000 }; + t.mock.method(performance, 'now', () => clock.now); + const reads = []; + const world = { surface, costMs }; + const globe = { + show: true, + getHeight(point) { + const lat = Cesium.Math.toDegrees(point.latitude); + const lon = Cesium.Math.toDegrees(point.longitude); + reads.push(`${lat.toFixed(5)},${lon.toFixed(5)}`); + clock.now += world.costMs; + return world.surface(lat, lon); + }, + }; + const state = { + _enabled: true, + _selectedKey: null, + _vehicles: new Map(), + _visible: new Set(), + _floorCursor: 0, + _floorAttempts: 0, + _floorTimer: null, + _heightDirty: new Set(), + _viewer: { + camera: { positionCartographic: { height: 500 } }, + scene: { globe }, + terrainProvider: provider, + }, + }; + const parts = { + trails: { prepareEntry() {} }, + rendering: { requestVisibility() {} }, + }; + const height = createHeight({ + state, + services: { + render: { governorRequestRender() {} }, + ground: { + coarseFloorCoord: (lat, lon) => ({ + lat: Number(lat.toFixed(3)), + lon: Number(lon.toFixed(3)), + }), + cachedGroundFloor: () => null, + neighborFloorM: () => null, + warmGroundFloor() {}, + GROUND_FLOOR_LIFT_M: LIFT, + }, + }, + parts, + }); + t.after(() => height.clear()); + const nextWindow = () => { + clock.now += TERRAIN_READ_WINDOW_MS; + }; + return { height, state, parts, globe, world, clock, reads, nextWindow }; +} + +test('a point is read from the terrain once and reused until its reuse time passes', (t) => { + const { height, reads, clock, world } = terrain(t); + const first = height.requestHeight(42.36, -71.06); + assert.deepEqual(first, { cell: '42.36,-71.06', height: 100 + LIFT, prior: null }); + for (let i = 0; i < 500; i++) height.requestHeight(42.36, -71.06); + assert.equal(reads.length, 1, '501 requests, one terrain read'); + + // A point a metre away is its own point; the same cell does not stand in + // for it while the budget allows a read. + world.surface = (lat) => (lat > 42.36 ? 140 : 100); + assert.equal(height.requestHeight(42.36002, -71.06).height, 140 + LIFT); + assert.equal(reads.length, 2); + + // The surface under the first point changed (the globe drew a finer tile). + world.surface = () => 120; + clock.now += TERRAIN_HEIGHT_FRESH_MS - 1; + assert.equal(height.requestHeight(42.36, -71.06).height, 100 + LIFT); + clock.now += 1; + assert.equal(height.requestHeight(42.36, -71.06).height, 120 + LIFT); + assert.equal(reads.length, 3); + assert.deepEqual(height.terrainStats(), { + reads: 3, + reused: 501, + deferred: 0, + borrowed: 0, + held: 2, + }); +}); + +test('terrain that is not loaded yet is not remembered as an answer', (t) => { + const { height, reads, world } = terrain(t, { surface: () => undefined }); + assert.deepEqual(height.requestHeight(42.36, -71.06), { + cell: '42.36,-71.06', + height: null, + prior: null, + }); + assert.equal(height.requestHeight(42.36, -71.06).height, null); + assert.equal(reads.length, 2, 'each request asks again'); + world.surface = () => 12; + assert.equal(height.requestHeight(42.36, -71.06).height, 12 + LIFT); + assert.equal(height.terrainStats().held, 1); +}); + +test('a tile that leaves the globe keeps the height it gave', (t) => { + const { height, clock, world } = terrain(t, { surface: () => 30 }); + assert.equal(height.requestHeight(42.36, -71.06).height, 30 + LIFT); + world.surface = () => undefined; + clock.now += TERRAIN_HEIGHT_FRESH_MS * 20; + assert.equal(height.requestHeight(42.36, -71.06).height, 30 + LIFT); +}); + +test('new points stop being read once a window has spent its budget', (t) => { + const costMs = 5; + const { height, reads, nextWindow } = terrain(t, { + costMs, + surface: (lat, lon) => Math.round(lon * -1000), + }); + const affordable = Math.ceil(TERRAIN_READ_BUDGET_MS / costMs); + // One point per floor cell (0.001° apart), more than the window affords. + const answers = []; + for (let i = 0; i < affordable + 3; i++) + answers.push(height.requestHeight(42.36, -71.06 - i * 0.001).height); + assert.equal(reads.length, affordable); + assert.deepEqual( + answers.slice(affordable), + [null, null, null], + 'a point in a cell never read waits', + ); + assert.ok(reads.length * costMs < TERRAIN_READ_BUDGET_MS + costMs, 'the overrun is one read at most'); + + // A new point in a cell that was read borrows that cell's height. + const borrowed = height.requestHeight(42.36004, -71.06); + assert.equal(borrowed.height, 71060 + LIFT); + assert.equal(reads.length, affordable, 'borrowing reads nothing'); + assert.equal(height.terrainStats().borrowed, 1); + + // The next window reads the points that waited, and the borrower its own. + nextWindow(); + for (let i = affordable; i < affordable + 3; i++) + assert.equal( + height.requestHeight(42.36, -71.06 - i * 0.001).height, + Math.round((-71.06 - i * 0.001) * -1000) + LIFT, + ); + assert.equal(reads.length, affordable + 3); +}); + +test("a vehicle's own position is read even when the window is spent", (t) => { + const { height, reads } = terrain(t, { costMs: TERRAIN_READ_BUDGET_MS }); + height.requestHeight(42.36, -71.06); + assert.equal(height.requestHeight(42.37, -71.07).height, null, 'a trail point waits'); + assert.equal(height.requestHeight(42.38, -71.08, null, TERRAIN_NEED.MARKER).height, 100 + LIFT); + assert.equal(reads.length, 2); +}); + +test('undrawn trail history leaves most of the budget to the paths markers are drawn along', (t) => { + const costMs = 1; + const { height, reads } = terrain(t, { costMs }); + // New points, one per floor cell: history first, as a pass offers them. + let lon = -71; + const ask = (need) => height.requestHeight(42, (lon -= 0.001), null, need).height; + const history = Array.from({ length: 30 }, () => ask(TERRAIN_NEED.HISTORY)); + const read = history.filter((h) => h !== null).length; + assert.equal(read, TERRAIN_READ_BUDGET_MS / 4, 'history stops at a quarter of the budget'); + const path = Array.from({ length: 30 }, () => ask(TERRAIN_NEED.PATH)); + assert.equal(path.filter((h) => h !== null).length, TERRAIN_READ_BUDGET_MS - read, 'the path takes the rest'); + assert.equal(ask(TERRAIN_NEED.HISTORY), null); + assert.equal(ask(TERRAIN_NEED.MARKER), 100 + LIFT, 'a marker is still read'); + assert.equal(reads.length, TERRAIN_READ_BUDGET_MS + 1); +}); + +test('held points keep their last height while re-reads are over budget, and the longest-held are re-read first so none starves', (t) => { + const costMs = TERRAIN_REFRESH_BUDGET_MS / 2; + const { height, reads, clock, world, nextWindow } = terrain(t, { surface: () => 50 }); + const points = Array.from({ length: 40 }, (_, i) => [42.36, -71.06 - i * 0.001]); + for (const [lat, lon] of points) height.requestHeight(lat, lon); + assert.equal(reads.length, 40); + + world.costMs = costMs; + world.surface = () => 80; + clock.now += TERRAIN_HEIGHT_FRESH_MS; + reads.length = 0; + const pass = () => points.map(([lat, lon]) => height.requestHeight(lat, lon).height); + const first = pass(); + const perWindow = Math.ceil(TERRAIN_REFRESH_BUDGET_MS / costMs); + assert.equal(reads.length, perWindow, 'one window re-reads its budget and no more'); + assert.deepEqual(first.slice(0, perWindow), Array(perWindow).fill(80 + LIFT)); + assert.deepEqual(first.slice(perWindow), Array(40 - perWindow).fill(50 + LIFT), 'the rest keep their last height'); + + // Pass after pass, a window apart: the budget moves down the list instead + // of returning to the first points, whose reuse time runs out again long + // before the last ones have had a turn (40 points, ${perWindow} a window). + const turns = new Map(); + for (const point of reads) turns.set(point, 1); + const cycle = Math.ceil(40 / perWindow); + for (let passes = 1; passes < cycle; passes++) { + nextWindow(); + reads.length = 0; + pass(); + for (const point of reads) turns.set(point, (turns.get(point) || 0) + 1); + } + assert.equal(turns.size, 40, 'every point was re-read in one cycle'); + assert.ok([...turns.values()].every((count) => count === 1), 'and none twice before the others once'); + assert.ok(cycle * TERRAIN_READ_WINDOW_MS > TERRAIN_HEIGHT_FRESH_MS, 'the cycle is longer than the reuse time, so the first points were stale again'); + assert.deepEqual(pass().slice(-1), [80 + LIFT]); +}); + +test('once the camera and the tiles have settled a height is kept until either changes', (t) => { + const { height, state, globe, reads, clock, world } = terrain(t, { surface: () => 10 }); + const read = () => height.requestHeight(42.36, -71.06).height; + assert.equal(read(), 10 + LIFT); + // Still settling: the point falls due and is read again. + world.surface = () => 20; + clock.now += TERRAIN_SETTLE_MS; + assert.equal(read(), 20 + LIFT); + assert.equal(reads.length, 2); + // Read after the settle time: kept, however long the view stays put. + world.surface = () => 99; + for (let i = 0; i < 50; i++) { + clock.now += 60_000; + assert.equal(read(), 20 + LIFT); + } + assert.equal(reads.length, 2); + + // The camera moves: the globe may draw the point from a finer tile. + state._viewer.camera.positionCartographic = { longitude: 0, latitude: 0, height: 250 }; + clock.now += TERRAIN_READ_WINDOW_MS; + assert.equal(read(), 99 + LIFT); + assert.equal(reads.length, 3); + + // Settled again, then a tile starts loading with the camera still. + world.surface = () => 101; + clock.now += TERRAIN_SETTLE_MS; + assert.equal(read(), 101 + LIFT); + clock.now += 60_000; + assert.equal(read(), 101 + LIFT); + assert.equal(reads.length, 4); + world.surface = () => 105; + globe.tilesLoaded = false; + clock.now += TERRAIN_READ_WINDOW_MS; + assert.equal(read(), 105 + LIFT); + assert.equal(reads.length, 5); +}); + +test('a flat globe answers without reading terrain', (t) => { + const { height, reads } = terrain(t, { + provider: new Cesium.EllipsoidTerrainProvider(), + }); + assert.deepEqual(height.requestHeight(42.36, -71.06), { + cell: '42.36,-71.06', + height: LIFT, + prior: null, + }); + assert.equal(reads.length, 0); +}); + +test('a hidden globe reads no terrain and clear() forgets what was read', (t) => { + const { height, reads, globe } = terrain(t); + height.requestHeight(42.36, -71.06); + height.clear(); + assert.equal(height.terrainStats().held, 0); + height.requestHeight(42.36, -71.06); + assert.equal(reads.length, 2, 'a cleared layer reads again'); + globe.show = false; + assert.equal(height.requestHeight(42.5, -71.5).height, null); + assert.equal(reads.length, 2); +}); + +test('the points kept stay bounded', (t) => { + const { height } = terrain(t); + const total = TERRAIN_HEIGHT_CACHE_MAX + 5_000; + for (let i = 0; i < total; i++) + height.requestHeight(10 + Math.floor(i / 1000) * 0.001, 20 + (i % 1000) * 0.0001); + const { held, reads } = height.terrainStats(); + assert.equal(reads, total); + assert.ok(held <= TERRAIN_HEIGHT_CACHE_MAX, `${held} held`); + assert.ok(held >= TERRAIN_HEIGHT_CACHE_MAX * 0.7, `${held} held`); +}); + +test('a fleet rereading every trail fix reads each point once, not once per request', (t) => { + const { height, state, parts, reads } = terrain(t); + t.mock.timers.enable({ apis: ['setTimeout'] }); + // 600 vehicles with 40 fixes each, 25 m apart along a street. + const fleet = Array.from({ length: 600 }, (_, v) => ({ + key: `bus-${v}`, + fixes: Array.from({ length: 40 }, (_, i) => ({ + lat: 42 + v * 0.002, + lon: -71 + i * 0.0003, + })), + })); + for (const entry of fleet) { + state._vehicles.set(entry.key, entry); + state._visible.add(entry); + } + let requests = 0; + parts.trails.prepareEntry = (entry, collect) => { + for (const fix of entry.fixes) { + requests++; + const answer = height.requestHeight(fix.lat, fix.lon, entry); + if (collect) collect(fix, answer); + } + }; + height.anchorFloors(); + for (let i = 0; i < FLOOR_REREAD_ATTEMPTS; i++) t.mock.timers.tick(FLOOR_REREAD_MS); + assert.equal(requests, 600 * 40 * 2 * (FLOOR_REREAD_ATTEMPTS + 1)); + assert.equal(new Set(reads).size, 600 * 40); + assert.equal(reads.length, 600 * 40, `${requests} requests, ${reads.length} terrain reads`); + assert.ok(fleet.every((entry) => entry.heightM === 100 + LIFT && entry.heightResolved)); +}); + +test('a reread cycle outlasts its attempts while trail points still wait for a read, then stops', (t) => { + const costMs = 4; + const { height, state, parts, reads, clock } = terrain(t, { costMs }); + t.mock.timers.enable({ apis: ['setTimeout'] }); + const entry = { + key: 'bus', + // One fix per floor cell, so nothing can be borrowed. + fixes: Array.from({ length: 60 }, (_, i) => ({ lat: 42, lon: -71 + i * 0.001 })), + }; + state._vehicles.set(entry.key, entry); + state._visible.add(entry); + parts.trails.prepareEntry = (e) => { + for (const fix of e.fixes) height.requestHeight(fix.lat, fix.lon, e); + }; + height.anchorFloors(); + let passes = 1; + while (state._floorTimer && passes < 200) { + clock.now += FLOOR_REREAD_MS; + t.mock.timers.tick(FLOOR_REREAD_MS); + passes++; + } + assert.equal(new Set(reads).size, 60, 'every fix was read in the end'); + assert.ok(passes > FLOOR_REREAD_ATTEMPTS + 1, `${passes} passes: the cycle kept going for the unread fixes`); + assert.ok(passes < 120, `${passes} passes: and stopped once none waited`); + assert.equal(state._floorTimer, null); +}); diff --git a/src/layers/transit/ingestion.js b/src/layers/transit/ingestion.js index afce6a4aa..036b75974 100644 --- a/src/layers/transit/ingestion.js +++ b/src/layers/transit/ingestion.js @@ -12,6 +12,7 @@ import { isStaleVehicleFix, transitVehicleKey, vehicleReportTimeMs, + TERRAIN_NEED, } from './policy.js'; import { transitModeFor, @@ -272,7 +273,12 @@ export function createIngestion({ state, services, parts, source }) { // DEM one, or a change of map regime, moves a parked vehicle too. A // cell still cold takes the lowest neighbour as a prior, as before. const priorHeightM = entry.heightM; - const height = parts.height.requestHeight(record.lat, record.lon); + const height = parts.height.requestHeight( + record.lat, + record.lon, + null, + TERRAIN_NEED.MARKER, + ); if (height.height !== null) { entry.heightCell = height.cell; entry.heightM = height.height; @@ -309,7 +315,12 @@ export function createIngestion({ state, services, parts, source }) { } continue; } - const height = parts.height.requestHeight(record.lat, record.lon); + const height = parts.height.requestHeight( + record.lat, + record.lon, + null, + TERRAIN_NEED.MARKER, + ); // Keep the local prior separate from resolved historical floors. // A neighbouring cell is not a guaranteed lower bound. const prior = height.height === null ? height.prior : height.height; diff --git a/src/layers/transit/policy.js b/src/layers/transit/policy.js index eabe5df4a..de9e831ab 100644 --- a/src/layers/transit/policy.js +++ b/src/layers/transit/policy.js @@ -77,6 +77,43 @@ export const FLOOR_WARM_PER_POLL = 300; export const FLOOR_REREAD_MS = 900; /** Re-reads before a poll's cycle gives up and waits for the next poll. */ export const FLOOR_REREAD_ATTEMPTS = 6; +/** + * Terrain heights read from the globe's loaded surface are kept per point + * (1e-5°, about a metre): one read is a ray cast against the mesh of the tile + * drawn there, not a table lookup, and a fleet asks for every trail fix on + * every reread. While the camera moves or tiles load, a kept height is reused + * this long before the point is read again. + */ +export const TERRAIN_HEIGHT_FRESH_MS = 2_000; +/** + * A height read once the camera has been still and the globe's tiles loaded + * for this long is kept until either changes: the globe draws a point from + * another tile only after one of them does. + */ +export const TERRAIN_SETTLE_MS = 5_000; +/** Terrain read budgets are counted over windows of this length. */ +export const TERRAIN_READ_WINDOW_MS = 200; +/** + * Milliseconds of terrain reads in one window. Past it a point never read + * takes the last height read in its ~111 m floor cell, or waits for a later + * pass; a vehicle's own position is read regardless. + */ +export const TERRAIN_READ_BUDGET_MS = 16; +/** + * The share of that budget re-reads of kept points may use, the longest-kept + * first, leaving the rest for points never read. + */ +export const TERRAIN_REFRESH_BUDGET_MS = 12; +/** + * How much a terrain height matters to what is drawn, lowest first. HISTORY is + * a past fix of a vehicle that is not selected (its trail is not drawn) and + * may use a quarter of each budget; PATH is the stretch a marker is drawn + * along, or any fix of the selected vehicle; MARKER is a vehicle's own + * position, read even when the window's budget is spent. + */ +export const TERRAIN_NEED = Object.freeze({ HISTORY: 0, PATH: 1, MARKER: 2 }); +/** Points kept; the oldest quarter is dropped past it. */ +export const TERRAIN_HEIGHT_CACHE_MAX = 60_000; // --- Rendering --- /** On-screen glyph size in CSS px at the near end of the distance ramp. */ diff --git a/src/layers/transit/trails.js b/src/layers/transit/trails.js index 47973c1a2..8b315d9fb 100644 --- a/src/layers/transit/trails.js +++ b/src/layers/transit/trails.js @@ -8,6 +8,7 @@ import { } from '../../data/contactPlayback.js'; import { createContactTrailRenderer } from '../../data/contactTrailRenderer.js'; import { fixDistanceM } from './movement.js'; +import { TERRAIN_NEED } from './policy.js'; import { transitStyleProfile } from '../../data/transitPresetStyle.js'; const MODE_COLOR = { @@ -72,8 +73,13 @@ export function createTrails({ state, services, parts, source }) { b = {}; let vertices = 0, signature = ''; - const floor = (point) => { - const answer = parts.height.requestHeight(point.lat, point.lon, entry); + const floor = (point, need = TERRAIN_NEED.PATH) => { + const answer = parts.height.requestHeight( + point.lat, + point.lon, + entry, + need, + ); if (collect) collect(point, answer); return answer.height; }; @@ -99,12 +105,6 @@ export function createTrails({ state, services, parts, source }) { lon: a.lon + ((b.lon - a.lon) * i) / n, }); } - for (let i = 0; i < entry.track.count; i++) { - readFix(entry.track, i, a); - const h = floor(a); - if (h !== null) correctHeight(entry.track, a.seq, h); - } - const order = []; const activeIndex = Math.max( 0, Math.min( @@ -112,6 +112,18 @@ export function createTrails({ state, services, parts, source }) { entry.sample.fromSeq - entry.track.baseSeq, ), ); + for (let i = 0; i < entry.track.count; i++) { + readFix(entry.track, i, a); + // Fixes behind an unselected vehicle are kept but not drawn. + const h = floor( + a, + entry === selected || i >= activeIndex + ? TERRAIN_NEED.PATH + : TERRAIN_NEED.HISTORY, + ); + if (h !== null) correctHeight(entry.track, a.seq, h); + } + const order = []; for (let i = activeIndex; i + 1 < entry.track.count; i++) order.push(i); if (entry === selected) for (let i = activeIndex - 1; i >= 0; i--) order.push(i);