mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-12 22:32:19 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d5f7926c7e | ||
|
|
1c49a89faa |
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).
|
||||
@@ -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
@@ -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 }),
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user