Compare commits

..
Author SHA1 Message Date
Mazeyar Moeini FeizabadiandClaude Fable 5.2 cc5718bb82 docs(changelog): add the #1712 fragment
Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-13 16:06:05 +01:00
Mazeyar Moeini FeizabadiandClaude Fable 5.2 f47313ff3b perf(vessels): prune the AIS store on a timer and evict in insertion order
Every AIS position message walked both the vessel map and the pending-track
map to expire stale entries, and once the store was past its 50,000 cap it
also sorted every row to find the one to evict. The static-data map was never
pruned at all.

The expiry walk now runs on the existing 15 s AIS watchdog tick. Readers
already filtered rows by age; the track reader and the pending first fix now
check age too, so nothing expired is served between ticks. The vessel map is
re-inserted on update, which keeps it least recently updated first, so the
cap is enforced per message by dropping from the front. A vessel's static
entry goes when the vessel does, and static entries that never got a position
are held to the same cap.

Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-13 14:43:43 +01:00
9 changed files with 326 additions and 312 deletions

No files matched your search

-4
View File
@@ -1,4 +0,0 @@
- ALPR camera badges no longer take depth samples while the overlay paints, or
again each time the layer is switched back on. On a terrain globe their
height comes from the loaded terrain and is remembered per camera; some
badges move a few metres onto the drawn surface (mazy1998, #1706).
+3
View File
@@ -0,0 +1,3 @@
- The AIS vessel store no longer walks every vessel for each position message.
Expiry runs on the existing 15 s tick, the cap evicts in update order, and a
vessel's static data is dropped with it (mazy1998, #1712).
+4 -8
View File
@@ -1513,14 +1513,10 @@ tile streamed in; floors resolve before new badges appear (bounded wait), and a
badge whose cell has not resolved clamps until it does, then moves in place.
Overlay anchors start on the same floor and write marker properties only on
change. Once the camera is still and tiles have settled, each overlay anchor is
replaced once by the rendered surface under it (accepted only within -30/+40 m
of the floor cell), so badges do not float or sink on slopes; wedge corners
follow their own floor cells instead of staying level with the apex. The
surface is read outside the overlay paint, one camera per frame: a terrain
globe answers from its loaded mesh (`globe.getHeight`) and only photoreal
tiles are depth-sampled (`scene.sampleHeight`). Answers are kept per camera
until the map source changes, so a refresh or turning the layer off and on
samples nothing again. Unknown bearings never acquire a wedge. The selected
replaced once by the rendered surface under it (two depth samples per paint,
accepted only within -30/+40 m of the floor cell), so badges do not float or
sink on slopes; wedge corners follow their own floor cells instead of staying
level with the apex. Unknown bearings never acquire a wedge. The selected
card's `DIRECTION` wraps after rounding to whole degrees, so a mapped bearing
of 359.5° or more reads `0°` rather than `360°`. The row reads
`N nearby · M on screen`; when every loaded camera is outside the view it adds
+4
View File
@@ -10,6 +10,7 @@ import {
readAisTrack,
aisStreamRows,
newestAisPositionAt,
pruneAisStreamCache,
} from './ais-store.js';
// ---------------------------------------------------------------------------
// AISStream live vessel cache state
@@ -338,6 +339,9 @@ function startAisStreamWatchdogTick() {
} catch (error) {
console.warn('[AISStream] watchdog tick failed', error?.message || '');
}
// The store no longer prunes per message; this tick is its expiry clock,
// so a silent feed still releases vessels it stopped hearing from.
pruneAisStreamCache();
}, AISSTREAM_TICK_MS);
_aisStreamTickTimer.unref?.();
}
+56 -19
View File
@@ -16,6 +16,8 @@ const _aisStreamStatic = new Map();
const _aisStreamTracks = new Map();
/** @type {Map<string,{lat:number,lon:number,epochSec:number}>} mmsi -> first fix awaiting second (lazy buffer allocation) */
const _aisStreamTrackPending = new Map();
/** Full prune walks so far; diagnostics only (see aisStreamCacheStats). */
let _aisStreamPruneWalks = 0;
/**
* Store one parsed AIS envelope.
@@ -105,6 +107,10 @@ export function ingestAisStreamEnvelope(envelope) {
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return true;
const staticData = _aisStreamStatic.get(mmsi) || {};
// Re-insert rather than overwrite: a Map iterates in insertion order, so
// this keeps the least recently updated vessel first and the cap can evict
// from the front instead of sorting every row.
_aisStreamVessels.delete(mmsi);
_aisStreamVessels.set(mmsi, {
lat,
lon,
@@ -132,10 +138,22 @@ export function ingestAisStreamEnvelope(envelope) {
aisEpochSeconds(metadata.time_utc ?? metadata.TimeUtc),
);
pruneAisStreamCache();
// Only the cap is enforced per message, and that is O(1). Expiry needs a
// walk of every row, so it runs on the caller's timer (pruneAisStreamCache);
// readers check age themselves in between.
while (_aisStreamVessels.size > AISSTREAM_CACHE_MAX)
dropAisVessel(_aisStreamVessels.keys().next().value);
return true;
}
/** Forget one vessel entirely, so no side map can outgrow the vessel map. */
function dropAisVessel(mmsi) {
_aisStreamVessels.delete(mmsi);
_aisStreamStatic.delete(mmsi);
_aisStreamTracks.delete(mmsi);
_aisStreamTrackPending.delete(mmsi);
}
/**
* Parses an AISStream UTC timestamp into epoch seconds (fallback: now).
*/
@@ -157,7 +175,12 @@ function appendAisTrackSample(mmsi, lat, lon, epochSec) {
let track = _aisStreamTracks.get(mmsi);
if (!track) {
const pending = _aisStreamTrackPending.get(mmsi);
if (!pending) {
// A first fix older than the retention window is treated as absent: the
// timed prune may not have removed it yet, and it must not seed a track.
const pendingCutoffSec = Math.floor(
(Date.now() - AISSTREAM_STALE_MS) / 1000,
);
if (!pending || pending.epochSec < pendingCutoffSec) {
_aisStreamTrackPending.set(mmsi, { lat, lon, epochSec });
return;
}
@@ -207,6 +230,10 @@ function writeAisTrackSample(track, lat, lon, epochSec) {
export function readAisTrack(mmsi) {
const track = _aisStreamTracks.get(mmsi);
if (!track || !track.len) return [];
// An expired vessel stays in the maps until the next timed prune; its track
// must read as gone straight away, as its row does in aisStreamRows.
const row = _aisStreamVessels.get(mmsi);
if (!row || row._updatedAt < Date.now() - AISSTREAM_STALE_MS) return [];
const samples = [];
const start =
(track.head - track.len + AIS_TRACK_SAMPLES) % AIS_TRACK_SAMPLES;
@@ -267,14 +294,18 @@ export function aisStreamRows(maxRows) {
return rows.slice(0, maxRows).map(({ _updatedAt, ...row }) => row);
}
function pruneAisStreamCache() {
/**
* Drop expired vessels and everything kept for them.
*
* This walks every row, so it runs on a timer (the AIS watchdog tick in
* ais-live.js) and not per message. Nothing depends on it for correctness:
* readers filter by age and ingest enforces the cap. It only frees memory.
*/
export function pruneAisStreamCache() {
_aisStreamPruneWalks++;
const cutoff = Date.now() - AISSTREAM_STALE_MS;
for (const [mmsi, row] of _aisStreamVessels) {
if (row._updatedAt < cutoff) {
_aisStreamVessels.delete(mmsi);
_aisStreamTracks.delete(mmsi);
_aisStreamTrackPending.delete(mmsi);
}
if (row._updatedAt < cutoff) dropAisVessel(mmsi);
}
// Pending single-fix entries for vessels never seen again must not leak
const pendingCutoffSec = Math.floor(cutoff / 1000);
@@ -282,20 +313,26 @@ function pruneAisStreamCache() {
if (pending.epochSec < pendingCutoffSec)
_aisStreamTrackPending.delete(mmsi);
}
if (_aisStreamVessels.size <= AISSTREAM_CACHE_MAX) return;
const ordered = [..._aisStreamVessels.entries()].sort(
(a, b) => a[1]._updatedAt - b[1]._updatedAt,
);
for (const [mmsi] of ordered.slice(
0,
_aisStreamVessels.size - AISSTREAM_CACHE_MAX,
)) {
_aisStreamVessels.delete(mmsi);
_aisStreamTracks.delete(mmsi);
_aisStreamTrackPending.delete(mmsi);
// Static data that arrived without a position has no vessel row to be
// dropped with. It is kept for the position that may follow, but only up to
// the vessel cap, oldest first, so the static map is bounded too.
for (const mmsi of _aisStreamStatic.keys()) {
if (_aisStreamStatic.size <= AISSTREAM_CACHE_MAX) break;
if (!_aisStreamVessels.has(mmsi)) _aisStreamStatic.delete(mmsi);
}
}
/** Map sizes and the number of prune walks, for tests and diagnostics. */
export function aisStreamCacheStats() {
return {
vessels: _aisStreamVessels.size,
statics: _aisStreamStatic.size,
tracks: _aisStreamTracks.size,
pendingTracks: _aisStreamTrackPending.size,
pruneWalks: _aisStreamPruneWalks,
};
}
export function newestAisPositionAt(rows) {
return rows[0]?.last_position_UTC || null;
}
+177
View File
@@ -0,0 +1,177 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as store from './ais-store.js';
const { AISSTREAM_CACHE_MAX, AISSTREAM_STALE_MS } = store;
// One clock for the whole file, only ever moved forward. The store is module
// state shared by every test here, so each test starts by stepping past the
// retention window: whatever an earlier test left behind is already expired.
let now = Date.parse('2030-01-01T00:00:00Z');
const useClock = (t) => {
now += AISSTREAM_STALE_MS + 60_000;
t.mock.method(Date, 'now', () => now);
};
const position = (mmsi, lat = 53.4, extraMetaData = {}) =>
store.ingestAisStreamEnvelope({
MessageType: 'PositionReport',
Message: { PositionReport: { UserID: Number(mmsi), Sog: 10, Cog: 90 } },
MetaData: {
MMSI: mmsi,
latitude: lat,
longitude: 6.2,
time_utc: new Date(now).toISOString(),
...extraMetaData,
},
});
// No position in the header, so this never creates a vessel row by itself.
const staticData = (mmsi, body) =>
store.ingestAisStreamEnvelope({
MessageType: 'ShipStaticData',
Message: { ShipStaticData: { UserID: Number(mmsi), ...body } },
MetaData: { MMSI: mmsi },
});
const rows = () => store.aisStreamRows(AISSTREAM_CACHE_MAX);
const rowFor = (mmsi) => rows().find((row) => row.mmsi === mmsi);
const mmsiAt = (index) => String(300000000 + index);
/** Fill the store to exactly the cap with vessels 0..cap-1, oldest first. */
const fillToCap = () => {
for (let index = 0; index < AISSTREAM_CACHE_MAX; index++) {
now += 1;
position(mmsiAt(index));
}
};
test('ingesting messages does not walk the whole cache per message', (t) => {
useClock(t);
const before = store.aisStreamCacheStats().pruneWalks;
for (let index = 0; index < 2000; index++) {
now += 1;
position(String(210000000 + (index % 200)));
}
assert.equal(store.aisStreamCacheStats().pruneWalks, before);
store.pruneAisStreamCache();
assert.equal(store.aisStreamCacheStats().pruneWalks, before + 1);
});
test('readers never see an expired vessel between timed prunes', (t) => {
useClock(t);
const mmsi = '211000001';
position(mmsi, 53.4);
now += 60_000;
position(mmsi, 53.5);
assert.equal(rowFor(mmsi).mmsi, mmsi);
assert.equal(store.readAisTrack(mmsi).length, 2);
// No prune and no other message: the entry is still in the maps.
now += AISSTREAM_STALE_MS + 1;
assert.equal(rowFor(mmsi), undefined);
assert.deepEqual(store.readAisTrack(mmsi), []);
});
test('a first fix older than the retention window never seeds a track', (t) => {
useClock(t);
const mmsi = '211000002';
const old = new Date(now - AISSTREAM_STALE_MS - 60_000).toISOString();
position(mmsi, 53.4, { time_utc: old });
now += 60_000;
position(mmsi, 53.5);
assert.deepEqual(store.readAisTrack(mmsi), []);
});
test('past the cap the least recently updated vessels are evicted', (t) => {
useClock(t);
fillToCap();
// Vessel 1 reports again, so it is no longer among the oldest.
now += 1;
position(mmsiAt(1));
for (let index = 0; index < 3; index++) {
now += 1;
position(mmsiAt(AISSTREAM_CACHE_MAX + index));
}
const kept = new Set(rows().map((row) => row.mmsi));
assert.equal(kept.size, AISSTREAM_CACHE_MAX);
assert.deepEqual(
[0, 1, 2, 3, 4].map((index) => kept.has(mmsiAt(index))),
[false, true, false, false, true],
);
for (let index = 0; index < 3; index++)
assert.ok(kept.has(mmsiAt(AISSTREAM_CACHE_MAX + index)));
});
test('an evicted vessel takes its static entry with it', (t) => {
useClock(t);
const evicted = mmsiAt(0);
staticData(evicted, { Type: 70, Destination: 'HAMBURG' });
fillToCap();
assert.equal(rowFor(evicted).type, '70');
now += 1;
position(mmsiAt(AISSTREAM_CACHE_MAX));
assert.equal(rowFor(evicted), undefined);
// Were the static entry still cached, the next position would pick it up.
now += 1;
position(evicted);
assert.equal(rowFor(evicted).type, '');
assert.equal(rowFor(evicted).destination, '');
});
test('the timed prune drops expired vessels and their static entries', (t) => {
useClock(t);
store.pruneAisStreamCache();
assert.deepEqual(
{ ...store.aisStreamCacheStats(), pruneWalks: 0 },
{ vessels: 0, statics: 0, tracks: 0, pendingTracks: 0, pruneWalks: 0 },
);
const mmsi = '211000003';
// Static data ahead of the first position must still reach the row, and
// must survive a prune while it waits.
staticData(mmsi, { Type: 70, Destination: 'HAMBURG' });
store.pruneAisStreamCache();
position(mmsi, 53.4);
now += 60_000;
position(mmsi, 53.5);
assert.equal(rowFor(mmsi).type, '70');
assert.equal(rowFor(mmsi).destination, 'HAMBURG');
const live = store.aisStreamCacheStats();
assert.deepEqual(
[live.vessels, live.statics, live.tracks],
[1, 1, 1],
);
now += AISSTREAM_STALE_MS + 1;
store.pruneAisStreamCache();
const pruned = store.aisStreamCacheStats();
assert.deepEqual(
[pruned.vessels, pruned.statics, pruned.tracks, pruned.pendingTracks],
[0, 0, 0, 0],
);
position(mmsi, 53.6);
assert.equal(rowFor(mmsi).type, '');
});
test('static entries that never get a position are capped, oldest first', (t) => {
useClock(t);
store.pruneAisStreamCache();
for (let index = 0; index < AISSTREAM_CACHE_MAX + 5; index++)
staticData(mmsiAt(index), { Type: 70 });
// A vessel with a row keeps its static entry however old that entry is.
position(mmsiAt(0));
assert.equal(store.aisStreamCacheStats().statics, AISSTREAM_CACHE_MAX + 5);
store.pruneAisStreamCache();
assert.equal(store.aisStreamCacheStats().statics, AISSTREAM_CACHE_MAX);
now += 1;
position(mmsiAt(0));
position(mmsiAt(3));
position(mmsiAt(AISSTREAM_CACHE_MAX));
assert.equal(rowFor(mmsiAt(0)).type, '70');
assert.equal(rowFor(mmsiAt(3)).type, '');
assert.equal(rowFor(mmsiAt(AISSTREAM_CACHE_MAX)).type, '70');
});
+57 -139
View File
@@ -4,10 +4,10 @@ import {
MARKER_ICON_SIZE,
SELECTED_MARKER_ICON_SIZE,
MARKER_FLOOR_LIFT_M,
REMEMBERED_ANCHORS,
ANCHOR_SAMPLES_PER_PAINT,
SURFACE_BELOW_FLOOR_M,
SURFACE_ABOVE_FLOOR_M,
SURFACE_SAMPLES_PER_FRAME,
SURFACE_SAMPLES_PER_PAINT,
} from './policy.js';
import {
MARKER_IMAGE,
@@ -19,17 +19,11 @@ import {
validAlprGroundHeight,
} from './visuals.js';
const nextAnimationFrame = (step) =>
typeof requestAnimationFrame === 'function'
? requestAnimationFrame(step)
: setTimeout(step, 16);
/** Per-layer presentation on a caller-owned overlay; Cesium remains the fallback. */
export function createAlprOverlay({
state,
services,
onSurfaceChange = () => {},
requestFrame = nextAnimationFrame,
}) {
let lane,
removeMapListener,
@@ -39,12 +33,7 @@ export function createAlprOverlay({
hits = [],
images = [],
destroyed = false,
lastCamera = null,
sampling = false;
// Rendered-surface answers by camera id. Entities are rebuilt by a refresh
// and by turning the layer off and on; keeping the answers on them meant
// every rebuild sampled the same cameras again.
const surfaceAnswers = new Map();
lastCamera = null;
const requestPaint = () => {
if (!destroyed && state.enabled) lane?.requestPaint();
};
@@ -103,72 +92,38 @@ export function createAlprOverlay({
entity.gevAlprDisplayPosition = null;
nativeVisible(entity, true);
}
surfaceAnswers.clear();
state.lastAnchorSampleAt = 0;
retryUntil = Date.now() + 15000;
requestPaint();
}
function rememberSurface(record, height, checked) {
surfaceAnswers.delete(record.id);
surfaceAnswers.set(record.id, {
latitude: record.latitude,
longitude: record.longitude,
height,
checked,
});
if (surfaceAnswers.size > REMEMBERED_ANCHORS)
surfaceAnswers.delete(surfaceAnswers.keys().next().value);
}
/**
* Rendered surface height under a camera. A terrain globe answers from its
* loaded mesh; only photoreal tiles need `sampleHeight`, which renders depth
* and compiles its shaders on first use (a freeze of up to 1.9 s at load).
*/
function surfaceHeight(record, entity) {
function anchorFor(record, entity, budget) {
if (entity.gevAlprCanvasPosition) return entity.gevAlprCanvasPosition;
const scene = state.viewer.scene;
const location = Cesium.Cartographic.fromDegrees(
record.longitude,
record.latitude,
);
if (scene.globe?.show) return scene.globe.getHeight?.(location);
if (!scene.sampleHeightSupported) return undefined;
try {
return scene.sampleHeight(location, [entity]);
} catch {
/* streaming tiles */
return undefined;
}
}
/** The anchor a paint can have without sampling, or null while it waits for one. */
function anchorFor(record, entity) {
if (entity.gevAlprCanvasPosition) return entity.gevAlprCanvasPosition;
const scene = state.viewer.scene;
const answer = surfaceAnswers.get(record.id);
if (
answer?.latitude === record.latitude &&
answer.longitude === record.longitude
) {
setAnchor(record, entity, answer.height);
if (answer.checked)
entity.gevAlprSurfaceCheckedFor = entity.gevAlprCanvasPosition;
return entity.gevAlprCanvasPosition;
}
// A floor-placed marker anchors where its native badge sits. A marker
// still waiting for its floor reads the terrain globe, or on photoreal
// tiles waits for sampleAnchors().
// A floor-placed marker anchors where its native badge sits; only a
// marker still waiting for its floor samples the rendered surface, and
// each paint samples a few at most (each sample is a depth render).
let height;
const floor = services.groundFloor.cachedGroundFloor(
record.latitude,
record.longitude,
);
if (validAlprGroundHeight(floor)) height = floor + MARKER_FLOOR_LIFT_M;
else if (scene.globe.show)
height = scene.globe.getHeight?.(
Cesium.Cartographic.fromDegrees(record.longitude, record.latitude),
);
else if (scene.sampleHeightSupported) {
if (budget.samples <= 0) return null;
budget.samples -= 1;
try {
height = scene.sampleHeight(location, [entity]);
} catch {
/* streaming tiles */
}
}
if (!validAlprGroundHeight(height) && scene.globe.show)
height = scene.globe.getHeight?.(location);
if (!validAlprGroundHeight(height)) return null;
setAnchor(record, entity, height);
return entity.gevAlprCanvasPosition;
@@ -203,19 +158,14 @@ export function createAlprOverlay({
}
/**
* Sample the rendered surface for the overlay cameras, outside the paint.
*
* A camera with no floor yet gets its anchor from a sample. An anchored one
* is checked against the surface once: floors come from ~111 m cells, so on
* a slope a badge could float or sink by tens of metres. Checks run only
* while the camera is still and tiles have settled, and samples far outside
* the floor prior (a roof or a streaming tile) are rejected. Each frame
* takes SURFACE_SAMPLES_PER_FRAME samples, so no frame carries the whole
* cohort; sampling inside paint() put up to six depth renders in one frame.
* Replace floor anchors with the rendered surface under each overlay camera.
* Floors come from ~111 m cells, so on a slope a badge could float or sink
* by tens of metres. Runs only while the camera is still and tiles have
* settled, a few depth samples per paint, and each anchor at most once;
* samples far outside the floor prior (a roof or a streaming tile) are
* rejected. Returns whether anchors remain to check.
*/
function sampleAnchors() {
sampling = false;
if (destroyed || !state.enabled) return;
function refineAnchors(budget) {
const camera = state.viewer.camera;
const scene = state.viewer.scene;
const still =
@@ -238,41 +188,27 @@ export function createAlprOverlay({
position: Cesium.Cartesian3.clone(camera.positionWC),
direction: Cesium.Cartesian3.clone(camera.directionWC),
};
const canSample = scene.globe?.show || scene.sampleHeightSupported;
let samples = SURFACE_SAMPLES_PER_FRAME,
pending = false,
progressed = false,
moved = false;
// Cameras with no anchor come first: they are not on the overlay yet.
for (const record of records) {
const entity = state.dataSource.entities.getById(record.id);
if (!entity || entity.gevAlprCanvasPosition) continue;
// The floor may have resolved since the paint that found none.
if (anchorFor(record, entity)) moved = true;
else if (!canSample || samples <= 0) pending = true;
else {
samples -= 1;
const sample = surfaceHeight(record, entity);
if (validAlprGroundHeight(sample)) {
setAnchor(record, entity, sample);
rememberSurface(record, sample, false);
moved = progressed = true;
} else pending = true;
}
}
if (!scene.sampleHeightSupported) return false;
let pending = false;
for (const record of records) {
const entity = state.dataSource.entities.getById(record.id);
const anchor = entity?.gevAlprCanvasPosition;
if (!anchor || !canSample || entity.gevAlprSurfaceCheckedFor === anchor)
continue;
if (!still || !surfaceSettled(scene) || samples <= 0) {
if (!anchor || entity.gevAlprSurfaceCheckedFor === anchor) continue;
if (!still || !surfaceSettled(scene) || budget.samples <= 0) {
pending = true;
continue;
}
samples -= 1;
progressed = true;
const sample = surfaceHeight(record, entity);
let height = Cesium.Cartographic.fromCartesian(anchor).height;
budget.samples -= 1;
let sample;
try {
sample = scene.sampleHeight(
Cesium.Cartographic.fromDegrees(record.longitude, record.latitude),
[entity],
);
} catch {
/* streaming tiles */
}
const current = Cesium.Cartographic.fromCartesian(anchor).height;
const floor = services.groundFloor.cachedGroundFloor(
record.latitude,
record.longitude,
@@ -282,43 +218,21 @@ export function createAlprOverlay({
(!validAlprGroundHeight(floor) ||
(sample >= floor - SURFACE_BELOW_FLOOR_M &&
sample <= floor + SURFACE_ABOVE_FLOOR_M));
if (accepted && Math.abs(sample + MARKER_FLOOR_LIFT_M - height) > 1) {
height = sample + MARKER_FLOOR_LIFT_M;
setAnchor(record, entity, height);
moved = true;
}
if (accepted && Math.abs(sample + MARKER_FLOOR_LIFT_M - current) > 1)
setAnchor(record, entity, sample + MARKER_FLOOR_LIFT_M);
entity.gevAlprSurfaceCheckedFor = entity.gevAlprCanvasPosition;
// A surface that did not answer is asked again after a rebuild.
if (validAlprGroundHeight(sample)) rememberSurface(record, height, true);
if (entity.id === state.selectedId)
entity.gevAlprDisplayPosition = entity.gevAlprCanvasPosition;
}
if (moved) requestPaint();
if (!pending || Date.now() >= retryUntil) return;
// Take the next ration on the next frame; when nothing could be sampled
// (camera moving, tiles streaming, no floor yet) look again in 250 ms.
if (progressed) scheduleSampling();
else if (!retryTimer)
retryTimer = setTimeout(() => {
retryTimer = null;
scheduleSampling();
}, 250);
}
function scheduleSampling() {
if (sampling || destroyed) return;
sampling = true;
requestFrame(sampleAnchors);
return pending;
}
function paint({ ctx, width, height, keyhole, occluder }) {
hits = [];
if (!state.enabled || destroyed) return;
let waiting = false;
let unresolved = false;
const painted = [];
const canSample =
state.viewer.scene.globe?.show ||
state.viewer.scene.sampleHeightSupported;
const budget = { samples: ANCHOR_SAMPLES_PER_PAINT };
for (const record of records) {
const entity = state.dataSource.entities.getById(record.id);
if (!entity) continue;
@@ -332,14 +246,12 @@ export function createAlprOverlay({
nativeVisible(entity, true);
continue;
}
const anchor = anchorFor(record, entity);
const anchor = anchorFor(record, entity, budget);
if (!anchor) {
nativeVisible(entity, true);
waiting = true;
unresolved = true;
continue;
}
if (canSample && entity.gevAlprSurfaceCheckedFor !== anchor)
waiting = true;
canvasOwned(entity, true);
if (occluder && !occluder.isPointVisible(anchor)) {
setShown(entity, false);
@@ -423,9 +335,15 @@ export function createAlprOverlay({
ctx.restore();
hits.push({ id: record.id, x: origin.x, y: origin.y, radius: size / 2 });
}
// Anchors still to find or to check are sampled after this paint, a few
// per frame, never inside it.
if (waiting && !retryTimer && Date.now() < retryUntil) scheduleSampling();
const refining = refineAnchors({
samples: Math.min(budget.samples, SURFACE_SAMPLES_PER_PAINT),
});
if ((unresolved || refining) && !retryTimer && Date.now() < retryUntil) {
retryTimer = setTimeout(() => {
retryTimer = null;
requestPaint();
}, 250);
}
}
return {
+21 -137
View File
@@ -45,10 +45,8 @@ function harness(t, count = 2, { floor = null } = {}) {
active,
removed = 0,
paints = 0;
const frames = [];
const overlay = createAlprOverlay({
state,
requestFrame: (step) => frames.push(step),
services: {
groundFloor: { cachedGroundFloor: () => floor },
overlays: {
@@ -108,12 +106,6 @@ function harness(t, count = 2, { floor = null } = {}) {
entities,
draw,
paint: (occluder) => paint({ ctx, width: 800, height: 600, occluder }),
/** Run the sampling step queued for the next animation frame, if any. */
frame() {
const step = frames.shift();
step?.();
return Boolean(step);
},
mapChanged() {
mapListener({ detail: { status: 'ready' } });
},
@@ -251,149 +243,41 @@ test('overlay glyphs and native badges shrink alike with distance', () => {
assert.ok(Math.abs(markerScale(20_250) - 0.8) < 1e-9);
});
/** A photoreal scene: no globe, the rendered surface read by depth samples. */
function photoreal(h, heightAt = () => 172) {
const scene = h.state.viewer.scene;
h.state.viewer.camera.directionWC = Cesium.Cartesian3.UNIT_X;
scene.globe.show = false;
scene.sampleHeightSupported = true;
scene.primitives = { length: 0, get: () => null };
const sampled = [];
scene.sampleHeight = (carto) => {
sampled.push(carto);
return heightAt(Cesium.Math.toDegrees(carto.latitude));
};
return sampled;
}
const anchorHeight = (h, id) =>
Cesium.Cartographic.fromCartesian(
h.entities.getById(id).gevAlprCanvasPosition,
).height;
/** Run sampling frames until none is queued, waiting out the 250 ms look-again. */
function settle(t, h) {
for (let i = 0; i < 400; i++) if (!h.frame()) t.mock.timers.tick(250);
}
test('a still view replaces floor anchors with the rendered surface, within the floor window', (t) => {
// Floors come from ~111 m cells; on a slope a badge floated above the mesh.
t.mock.timers.enable({ apis: ['setTimeout', 'Date'] });
const h = harness(t, 2, { floor: 150 });
const sampled = photoreal(h, (latitude) => (latitude > 30.00005 ? 400 : 172));
const scene = h.state.viewer.scene;
const camera = h.state.viewer.camera;
camera.directionWC = Cesium.Cartesian3.UNIT_X;
scene.sampleHeightSupported = true;
scene.primitives = { length: 0, get: () => null };
let samples = 0;
scene.sampleHeight = (carto) => {
samples++;
return Cesium.Math.toDegrees(carto.latitude) > 30.00005 ? 400 : 172;
};
h.overlay.sync(h.records);
h.paint();
assert.ok(
Math.abs(anchorHeight(h, 'camera:0') - 151.5) < 0.01,
'floor anchor first',
);
assert.equal(h.frame(), true, 'the paint queued a sampling frame');
const height = (id) =>
Cesium.Cartographic.fromCartesian(
h.entities.getById(id).gevAlprCanvasPosition,
).height;
assert.equal(
sampled.length,
samples,
0,
'no depth samples while the camera may still be moving',
);
settle(t, h);
assert.ok(Math.abs(height('camera:0') - 151.5) < 0.01, 'floor anchor first');
h.paint();
assert.ok(
Math.abs(anchorHeight(h, 'camera:0') - 173.5) < 0.01,
Math.abs(height('camera:0') - 173.5) < 0.01,
'rendered surface once still',
);
assert.ok(
Math.abs(anchorHeight(h, 'camera:1') - 151.5) < 0.01,
Math.abs(height('camera:1') - 151.5) < 0.01,
'a rooftop-height sample is rejected',
);
assert.equal(sampled.length, 2);
const taken = samples;
h.paint();
settle(t, h);
assert.equal(sampled.length, 2, 'each anchor is checked once');
});
test('a paint never samples the surface; frames take one sample each', (t) => {
// Sampling inside paint() put up to six depth renders into one frame, and
// the first of them compiles shaders: a freeze of 0.8 to 1.9 s at load.
t.mock.timers.enable({ apis: ['setTimeout', 'Date'] });
const h = harness(t, 8);
const sampled = photoreal(h);
h.overlay.sync(h.records);
h.paint();
h.paint();
assert.equal(sampled.length, 0, 'no sample inside a paint');
assert.equal(h.draw.length, 0, 'unanchored cameras keep their native badge');
for (let taken = 1; taken <= 8; taken++) {
assert.equal(h.frame(), true);
assert.equal(sampled.length, taken, 'one sample per frame');
}
for (const record of h.records)
assert.ok(Math.abs(anchorHeight(h, record.id) - 172) < 0.01);
h.paint();
assert.ok(h.draw.length > 0, 'anchored cameras paint on the overlay');
});
test('a terrain globe is read from its mesh, never depth-sampled', (t) => {
t.mock.timers.enable({ apis: ['setTimeout', 'Date'] });
const h = harness(t, 3, { floor: 150 });
const sampled = photoreal(h);
const scene = h.state.viewer.scene;
scene.globe.show = true;
scene.globe.tilesLoaded = true;
let reads = 0;
scene.globe.getHeight = () => {
reads++;
return 172;
};
h.overlay.sync(h.records);
h.paint();
settle(t, h);
assert.equal(sampled.length, 0);
assert.equal(reads, 3);
// The same anchor the depth sample gave: the surface plus the marker lift.
for (const record of h.records)
assert.ok(Math.abs(anchorHeight(h, record.id) - 173.5) < 0.01);
});
test('surface answers outlive rebuilt markers and a re-enable, until the map changes', (t) => {
t.mock.timers.enable({ apis: ['setTimeout', 'Date'] });
const h = harness(t, 4, { floor: 150 });
const sampled = photoreal(h);
h.overlay.sync(h.records);
h.paint();
settle(t, h);
assert.equal(sampled.length, 4);
const before = h.records.map((record) => anchorHeight(h, record.id));
// A refresh or a toggle removes the entities and builds new ones.
const rebuild = () => {
h.overlay.clear();
h.entities.removeAll();
for (const record of h.records)
h.entities.add({
id: record.id,
billboard: { image: 'fallback', show: true },
polyline: { show: true },
polygon: { show: true },
});
h.overlay.sync(h.records);
h.paint();
settle(t, h);
};
rebuild();
h.state.enabled = false;
h.overlay.clear();
h.state.enabled = true;
rebuild();
assert.equal(sampled.length, 4, 'no camera is sampled again');
assert.deepEqual(
h.records.map((record) => anchorHeight(h, record.id)),
before,
);
// A camera that moved is a different question.
h.records[0] = { ...h.records[0], latitude: h.records[0].latitude + 0.001 };
rebuild();
assert.equal(sampled.length, 5);
h.mapChanged();
h.paint();
settle(t, h);
assert.equal(sampled.length, 9, 'a new map surface is sampled afresh');
assert.equal(samples, taken, 'each anchor is checked once');
});
+4 -5
View File
@@ -66,15 +66,14 @@ export const MAX_CANVAS_FRUSTUMS = 64;
/** Metres a floor-placed marker sits above its resolved ground cell. */
export const MARKER_FLOOR_LIFT_M = 1.5;
/** Rendered-surface samples one frame may take (on photoreal tiles each is a
* depth render of several milliseconds, so they are spread over frames). */
export const SURFACE_SAMPLES_PER_FRAME = 1;
/** Surface answers kept by camera id, so a refresh or re-enable asks nothing. */
export const REMEMBERED_ANCHORS = 2048;
/** Rendered-surface samples one overlay paint may take (each is a depth render). */
export const ANCHOR_SAMPLES_PER_PAINT = 4;
/** Accepted rendered-surface window around a floor cell (a cut or an embankment
* sits within it; a rooftop or an unstreamed tile usually does not). */
export const SURFACE_BELOW_FLOOR_M = 30;
/** Rendered-surface refinements per paint while the camera is still. */
export const SURFACE_SAMPLES_PER_PAINT = 2;
export const SURFACE_ABOVE_FLOOR_M = 40;
/** OSM attribution may collapse after five seconds; full credit stays in Data attribution. */