mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-13 06:42:23 +00:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c29ee98e23 | ||
|
|
5110f4cce3 | ||
|
|
8792e4701e | ||
|
|
87674240bd | ||
|
|
d5f7926c7e | ||
|
|
1c49a89faa |
No files matched your search
@@ -0,0 +1,7 @@
|
|||||||
|
- 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.
|
||||||
|
Switching the terrain provider or globe starts the kept heights afresh
|
||||||
|
(mazy1998, review by kvnloo, #1688).
|
||||||
@@ -5806,6 +5806,23 @@ Noir samples also exclude its deliberately vignetted outer field, using shader
|
|||||||
settings and screen position rather than observed brightness. Selected-trail
|
settings and screen position rather than observed brightness. Selected-trail
|
||||||
diagnostics report every condition even when selection fails.
|
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
|
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
|
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
|
behavior. The selected trail uses a 3 px core over 5 px backing, mode-coloured
|
||||||
|
|||||||
+200
-17
@@ -5,6 +5,13 @@ import {
|
|||||||
FLOOR_WARM_PER_POLL,
|
FLOOR_WARM_PER_POLL,
|
||||||
TRANSIT_POLL_MS,
|
TRANSIT_POLL_MS,
|
||||||
HEIGHT_SAMPLE_MAX_ALTITUDE_M,
|
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,
|
withinViewBounds,
|
||||||
} from './policy.js';
|
} from './policy.js';
|
||||||
|
|
||||||
@@ -47,35 +54,190 @@ export function createHeight({ state, services, parts }) {
|
|||||||
return cameraAltitude() <= HEIGHT_SAMPLE_MAX_ALTITUDE_M;
|
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 };
|
||||||
|
/** The globe and terrain provider the kept heights were read from. */
|
||||||
|
let terrainGlobeRead = null,
|
||||||
|
terrainProviderRead = null;
|
||||||
|
|
||||||
|
/** 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.
|
* The floor under a vehicle, and the best fallback if its own cell is cold.
|
||||||
* @param {number} lat
|
* @param {number} lat
|
||||||
* @param {number} lon
|
* @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}}
|
* @returns {{cell: string, height: number|null, prior: number|null}}
|
||||||
*/
|
*/
|
||||||
const terrainPoint = new Cesium.Cartographic();
|
function requestHeight(lat, lon, entry = null, need = TERRAIN_NEED.PATH) {
|
||||||
function requestHeight(lat, lon, entry = null) {
|
|
||||||
const coarse = coarseFloorCoord(lat, lon);
|
const coarse = coarseFloorCoord(lat, lon);
|
||||||
const cell = `${coarse.lat},${coarse.lon}`;
|
const cell = `${coarse.lat},${coarse.lon}`;
|
||||||
const fixtureFloor =
|
const fixtureFloor =
|
||||||
entry?.qaFloorM ?? state._qaFixtureFloors?.get(entry?.key);
|
entry?.qaFloorM ?? state._qaFixtureFloors?.get(entry?.key);
|
||||||
if (Number.isFinite(fixtureFloor))
|
if (Number.isFinite(fixtureFloor))
|
||||||
return { cell, height: fixtureFloor, prior: null };
|
return { cell, height: fixtureFloor, prior: null };
|
||||||
// On a terrain globe, use the loaded surface under the marker. This is
|
// On a terrain globe, use the loaded surface under the marker. This reads
|
||||||
// synchronous cache work and does not launch a terrain request.
|
// tiles already loaded and does not launch a terrain request.
|
||||||
const globe = state._viewer?.scene?.globe;
|
const globe = state._viewer?.scene?.globe;
|
||||||
|
const provider = state._viewer?.terrainProvider;
|
||||||
|
// A height belongs to the terrain it was read from. Checked before the
|
||||||
|
// flat-globe answer, so terrain → flat → terrain starts afresh too.
|
||||||
|
if (globe !== terrainGlobeRead || provider !== terrainProviderRead) {
|
||||||
|
clearTerrainHeights();
|
||||||
|
terrainGlobeRead = globe;
|
||||||
|
terrainProviderRead = provider;
|
||||||
|
}
|
||||||
if (globe?.show === true) {
|
if (globe?.show === true) {
|
||||||
Cesium.Cartographic.fromDegrees(lon, lat, 0, terrainPoint);
|
if (provider instanceof Cesium.EllipsoidTerrainProvider)
|
||||||
const rendered = globe.getHeight?.(terrainPoint);
|
return { cell, height: GROUND_FLOOR_LIFT_M, prior: null };
|
||||||
const flat =
|
const rendered = globe.getHeight
|
||||||
state._viewer?.terrainProvider instanceof
|
? terrainHeight(globe, lat, lon, need)
|
||||||
Cesium.EllipsoidTerrainProvider;
|
: undefined;
|
||||||
if (Number.isFinite(rendered) || flat)
|
if (Number.isFinite(rendered))
|
||||||
return {
|
return { cell, height: rendered + GROUND_FLOOR_LIFT_M, prior: null };
|
||||||
cell,
|
|
||||||
height: (flat ? 0 : rendered) + GROUND_FLOOR_LIFT_M,
|
|
||||||
prior: null,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
const floor = cachedGroundFloor(lat, lon);
|
const floor = cachedGroundFloor(lat, lon);
|
||||||
if (Number.isFinite(floor)) {
|
if (Number.isFinite(floor)) {
|
||||||
@@ -107,6 +269,7 @@ export function createHeight({ state, services, parts }) {
|
|||||||
function rereadFloors() {
|
function rereadFloors() {
|
||||||
state._floorTimer = null;
|
state._floorTimer = null;
|
||||||
if (!state._enabled) return;
|
if (!state._enabled) return;
|
||||||
|
terrainWaiting = 0;
|
||||||
const onGround = nearGround(),
|
const onGround = nearGround(),
|
||||||
candidates = [];
|
candidates = [];
|
||||||
const entries = [...state._vehicles.values()].filter(
|
const entries = [...state._vehicles.values()].filter(
|
||||||
@@ -120,6 +283,13 @@ export function createHeight({ state, services, parts }) {
|
|||||||
Number(b.key === state._selectedKey) -
|
Number(b.key === state._selectedKey) -
|
||||||
Number(a.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) {
|
for (const entry of entries) {
|
||||||
const cells = new Map();
|
const cells = new Map();
|
||||||
const collect = (point, answer) => {
|
const collect = (point, answer) => {
|
||||||
@@ -141,7 +311,12 @@ export function createHeight({ state, services, parts }) {
|
|||||||
};
|
};
|
||||||
parts.trails.prepareEntry(entry, collect);
|
parts.trails.prepareEntry(entry, collect);
|
||||||
const newest = entry.fixes.at(-1);
|
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;
|
entry.heightCell = answer.cell;
|
||||||
// The selected vehicle's path also offers its history, newest segment
|
// The selected vehicle's path also offers its history, newest segment
|
||||||
// first; the vehicle's own cell is read where its newest fix is.
|
// first; the vehicle's own cell is read where its newest fix is.
|
||||||
@@ -227,7 +402,12 @@ export function createHeight({ state, services, parts }) {
|
|||||||
for (const entry of entries) parts.trails.prepareEntry(entry);
|
for (const entry of entries) parts.trails.prepareEntry(entry);
|
||||||
parts.rendering.requestVisibility();
|
parts.rendering.requestVisibility();
|
||||||
governorRequestRender('transit-floor');
|
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);
|
state._floorTimer = setTimeout(rereadFloors, FLOOR_REREAD_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -258,6 +438,7 @@ export function createHeight({ state, services, parts }) {
|
|||||||
}
|
}
|
||||||
state._floorAttempts = 0;
|
state._floorAttempts = 0;
|
||||||
state._heightDirty.clear();
|
state._heightDirty.clear();
|
||||||
|
clearTerrainHeights();
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -267,5 +448,7 @@ export function createHeight({ state, services, parts }) {
|
|||||||
anchorFloors,
|
anchorFloors,
|
||||||
rereadFloors,
|
rereadFloors,
|
||||||
clear,
|
clear,
|
||||||
|
/** Terrain read counters since the layer was created, for checks. */
|
||||||
|
terrainStats: () => ({ ...terrainStats, held: terrainHeights.size }),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,390 @@
|
|||||||
|
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('another terrain provider or globe is read afresh, however settled the old heights were', (t) => {
|
||||||
|
const { height, state, globe, world, clock, reads } = terrain(t);
|
||||||
|
const viewer = state._viewer;
|
||||||
|
const at = () => height.requestHeight(42.36, -71.06).height;
|
||||||
|
// Read once the camera and tiles have long settled: kept indefinitely.
|
||||||
|
height.requestHeight(10, 10);
|
||||||
|
clock.now += TERRAIN_SETTLE_MS * 2;
|
||||||
|
reads.length = 0;
|
||||||
|
assert.equal(at(), 100 + LIFT);
|
||||||
|
clock.now += TERRAIN_SETTLE_MS * 10;
|
||||||
|
world.surface = () => 250;
|
||||||
|
assert.equal(at(), 100 + LIFT, 'same terrain: the kept height stands');
|
||||||
|
assert.equal(reads.length, 1);
|
||||||
|
|
||||||
|
viewer.terrainProvider = {};
|
||||||
|
assert.equal(at(), 250 + LIFT, 'a new provider is a new surface');
|
||||||
|
assert.equal(reads.length, 2);
|
||||||
|
|
||||||
|
world.surface = () => 400;
|
||||||
|
viewer.scene.globe = { show: true, getHeight: globe.getHeight };
|
||||||
|
assert.equal(at(), 400 + LIFT, 'and so is a new globe');
|
||||||
|
assert.equal(reads.length, 3);
|
||||||
|
|
||||||
|
// Terrain → flat → the same terrain provider: nothing asks the terrain
|
||||||
|
// while the globe is flat, yet the old heights must not come back.
|
||||||
|
const real = viewer.terrainProvider;
|
||||||
|
viewer.terrainProvider = new Cesium.EllipsoidTerrainProvider();
|
||||||
|
assert.equal(at(), LIFT);
|
||||||
|
world.surface = () => 30;
|
||||||
|
viewer.terrainProvider = real;
|
||||||
|
assert.equal(at(), 30 + LIFT);
|
||||||
|
assert.equal(reads.length, 4);
|
||||||
|
});
|
||||||
|
|
||||||
|
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,
|
isStaleVehicleFix,
|
||||||
transitVehicleKey,
|
transitVehicleKey,
|
||||||
vehicleReportTimeMs,
|
vehicleReportTimeMs,
|
||||||
|
TERRAIN_NEED,
|
||||||
} from './policy.js';
|
} from './policy.js';
|
||||||
import {
|
import {
|
||||||
transitModeFor,
|
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
|
// 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.
|
// cell still cold takes the lowest neighbour as a prior, as before.
|
||||||
const priorHeightM = entry.heightM;
|
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) {
|
if (height.height !== null) {
|
||||||
entry.heightCell = height.cell;
|
entry.heightCell = height.cell;
|
||||||
entry.heightM = height.height;
|
entry.heightM = height.height;
|
||||||
@@ -309,7 +315,12 @@ export function createIngestion({ state, services, parts, source }) {
|
|||||||
}
|
}
|
||||||
continue;
|
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.
|
// Keep the local prior separate from resolved historical floors.
|
||||||
// A neighbouring cell is not a guaranteed lower bound.
|
// A neighbouring cell is not a guaranteed lower bound.
|
||||||
const prior = height.height === null ? height.prior : height.height;
|
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;
|
export const FLOOR_REREAD_MS = 900;
|
||||||
/** Re-reads before a poll's cycle gives up and waits for the next poll. */
|
/** Re-reads before a poll's cycle gives up and waits for the next poll. */
|
||||||
export const FLOOR_REREAD_ATTEMPTS = 6;
|
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 ---
|
// --- Rendering ---
|
||||||
/** On-screen glyph size in CSS px at the near end of the distance ramp. */
|
/** On-screen glyph size in CSS px at the near end of the distance ramp. */
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
} from '../../data/contactPlayback.js';
|
} from '../../data/contactPlayback.js';
|
||||||
import { createContactTrailRenderer } from '../../data/contactTrailRenderer.js';
|
import { createContactTrailRenderer } from '../../data/contactTrailRenderer.js';
|
||||||
import { fixDistanceM } from './movement.js';
|
import { fixDistanceM } from './movement.js';
|
||||||
|
import { TERRAIN_NEED } from './policy.js';
|
||||||
import { transitStyleProfile } from '../../data/transitPresetStyle.js';
|
import { transitStyleProfile } from '../../data/transitPresetStyle.js';
|
||||||
|
|
||||||
const MODE_COLOR = {
|
const MODE_COLOR = {
|
||||||
@@ -72,8 +73,13 @@ export function createTrails({ state, services, parts, source }) {
|
|||||||
b = {};
|
b = {};
|
||||||
let vertices = 0,
|
let vertices = 0,
|
||||||
signature = '';
|
signature = '';
|
||||||
const floor = (point) => {
|
const floor = (point, need = TERRAIN_NEED.PATH) => {
|
||||||
const answer = parts.height.requestHeight(point.lat, point.lon, entry);
|
const answer = parts.height.requestHeight(
|
||||||
|
point.lat,
|
||||||
|
point.lon,
|
||||||
|
entry,
|
||||||
|
need,
|
||||||
|
);
|
||||||
if (collect) collect(point, answer);
|
if (collect) collect(point, answer);
|
||||||
return answer.height;
|
return answer.height;
|
||||||
};
|
};
|
||||||
@@ -99,12 +105,6 @@ export function createTrails({ state, services, parts, source }) {
|
|||||||
lon: a.lon + ((b.lon - a.lon) * i) / n,
|
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(
|
const activeIndex = Math.max(
|
||||||
0,
|
0,
|
||||||
Math.min(
|
Math.min(
|
||||||
@@ -112,6 +112,18 @@ export function createTrails({ state, services, parts, source }) {
|
|||||||
entry.sample.fromSeq - entry.track.baseSeq,
|
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);
|
for (let i = activeIndex; i + 1 < entry.track.count; i++) order.push(i);
|
||||||
if (entry === selected)
|
if (entry === selected)
|
||||||
for (let i = activeIndex - 1; i >= 0; i--) order.push(i);
|
for (let i = activeIndex - 1; i >= 0; i--) order.push(i);
|
||||||
|
|||||||
Reference in new issue
Block a user