Compare commits

...
Author SHA1 Message Date
Mazeyar Moeini FeizabadiandClaude Fable 5.2 d5f7926c7e docs(changelog): name the transit height fragment after its pull request
Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-12 21:50:22 +01:00
Mazeyar Moeini FeizabadiandClaude Fable 5.2 1c49a89faa fix(transit): keep terrain heights per point so rereads stop freezing the page
On a terrain globe every floor reread asked globe.getHeight, a ray cast against
the drawn tile's mesh, for every trail fix of every vehicle in view, twice per
pass and seven passes per poll. Over Boston's 650 vehicles that was about
150,000 reads per 10 s for about 8,000 distinct points, and blocked the page
for up to 19 s at a time while zooming.

Heights are now kept per point (1e-5 degrees), read again only while the camera
moves or tiles load, and read within a time budget: vehicle positions first,
the path a marker is drawn along next, undrawn trail history last.

Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-12 21:10:49 +01:00
7 changed files with 640 additions and 26 deletions

No files matched your search

@@ -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, #1688).
+17
View File
@@ -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
+191 -16
View File
@@ -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 }),
};
}
+356
View File
@@ -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);
});
+13 -2
View File
@@ -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;
+37
View File
@@ -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. */
+20 -8
View File
@@ -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);