Compare commits

..
Author SHA1 Message Date
Mazeyar Moeini FeizabadiandClaude Fable 5.2 ec354565c8 docs(changelog): add the #1719 fragment
Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-13 17:28:10 +01:00
Mazeyar Moeini FeizabadiandClaude Fable 5.2 1d81955f1d perf(detection): reuse the last sweep when the view and contacts are unchanged
Every detection paint projected, faded and bracketed every contact, even
when nothing it reads had changed since the paint before. Live Flights
moves the fleet every 80 ms, so at 60 fps about seven paints in ten
rebuilt the same paths.

Each paint now compares the view and every contact (same objects, same
coordinates, same value in each field the sweep reads) with what the
last sweep was built from, and on a match strokes the kept paths and
places callouts from the kept candidates. Nothing has to announce a
change. Solve paints, the Cyber sonar sweep, Global Context echoes and
depth-tested contacts are always swept.

Also in the sweep: the camera distance without Cesium's argument
checks, the identity hash worked out once per identity, and each
layer's identity sets looked up once per run of objects.

Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-13 17:27:04 +01:00
11 changed files with 810 additions and 67 deletions

No files matched your search

@@ -1,3 +0,0 @@
- Draw a vessel whose feed sends no length at its ship type's typical size, so
a cruise ship or container ship no longer shows as its type's smallest model
(samehkhamis, #1715).
@@ -0,0 +1,4 @@
- The detection overlay no longer rebuilds every bracket on a paint where the
view and every contact are unchanged, which cuts frame work by a fifth to a
third at a whole-globe view of Live Flights with a still camera (mazy1998,
#1719).
+22
View File
@@ -7459,6 +7459,28 @@ apply to `public/` assets or built-preview serving.
holds the way it stripped detection's would take bracket promptness with it, silently.
- Known, pre-existing, and deliberately out of scope here: the overlay's backing store does not
re-derive on a DPR change mid-session (`worldOverlay` sizing — untouched by this work).
- **A detection paint reuses the sweep before it when nothing it reads has changed
(`src/data/detectionPaintCache.js`).** The sweep in `_drawOverlay` (project every contact, fade
it, append its bracket, build callout candidates) depends only on the view and on the contacts.
Each paint still asks every layer for its contacts, then compares them with what the last sweep
was built from: the same objects in the same order, the same position coordinates, and the same
value in every field the sweep reads (`PAINTED_OBJECT_FIELDS`), under the same view (profile,
style, canvas size, keyhole, camera position, view-projection matrix, UI rectangles; see
`_paintViewOf`). On a match the kept bracket paths, counts and callout candidates are used again
and only the strokes and callouts are repainted. No layer has to announce a change, so none can
leave a stale picture behind. A sweep that READS a new object field or a new view value must add
it to those two lists; `detectionHost.test.mjs` compares 41 frames against a full sweep, canvas
call for canvas call.
- Never reused or kept: a solve paint (the sweep gathers the cohort), any paint while the Cyber
sonar sweep or a Global Context echo is live (brackets change with the clock), and any sweep
that reached a depth-tested contact (its answer comes from the scene).
- Where it pays: a held camera with contacts that move in steps. Live Flights re-positions the
fleet every 80 ms, so at 60 fps about seven paints in ten reuse a sweep. Once a frame takes
longer than 80 ms every paint sees moved aircraft and none is reused; after eight such sweeps
the contacts are only recorded on every eighth, so the comparison costs nothing there.
Satellites move on every frame and are never reused.
- `getDetectionDiagnostics().paintReuseCount` (and `data-paint-reuse-count`) counts reused
paints since `initDetection`; `placementBuildCount` is 0 on a reused paint.
- The detection MODE BANNER (`DENSE VIS:… SRC:… DENS:…% ELASTIC …ms`) is
developer telemetry and is HIDDEN by default. It paints only under
`?detectDebug=1` (the `trafficDebug` convention), resolved once per
-8
View File
@@ -1149,7 +1149,6 @@
70,
79
],
"typical_len": 300,
"rules": [
{
"min_len": 70,
@@ -1189,7 +1188,6 @@
80,
89
],
"typical_len": 250,
"rules": [
{
"name_re": "\\bLNG\\b|\\bGAS\\b",
@@ -1216,7 +1214,6 @@
60,
69
],
"typical_len": 140,
"rules": [
{
"min_len": 70,
@@ -1246,7 +1243,6 @@
37,
37
],
"typical_len": 19,
"rules": [
{
"min_len": 35,
@@ -1277,7 +1273,6 @@
30,
30
],
"typical_len": 24,
"rules": [
{
"min_len": 15,
@@ -1348,7 +1343,6 @@
55,
55
],
"typical_len": 40,
"rules": [
{
"min_len": 25,
@@ -1364,7 +1358,6 @@
40,
49
],
"typical_len": 40,
"rules": [
{
"min_len": 20,
@@ -1380,7 +1373,6 @@
33,
33
],
"typical_len": 100,
"rules": [
{
"min_len": 50,
+2
View File
@@ -2690,6 +2690,7 @@
"src/data/detectionDraw.js",
"src/data/detectionPolicy.js",
"src/data/detectionOcclusion.js",
"src/data/detectionPaintCache.js",
"src/data/detectionPresentation.js",
"src/data/detectionRenderDemand.js",
"src/data/sceneOcclusionProbe.js",
@@ -4372,6 +4373,7 @@
"src/data/detectionRenderDemand.js",
"src/data/detectionPresentation.js",
"src/data/detectionOcclusion.js",
"src/data/detectionPaintCache.js",
"src/data/sceneOcclusionProbe.js",
"src/data/modelEligibility.js",
"src/app/layers/recentImagery.js",
+139 -11
View File
@@ -51,6 +51,7 @@ import {
} from './detectionPolicy.js';
import { detectionBracketOpacity } from './detectionPresentation.js';
import { SurfaceOcclusion } from './detectionOcclusion.js';
import { createDetectionPaintCache } from './detectionPaintCache.js';
import { SceneOcclusionProbe } from './sceneOcclusionProbe.js';
import {
createCyberSonarSampler,
@@ -243,6 +244,15 @@ let _hostLane = null;
let _calloutLane = null;
let _transitBracketPaths = new Map();
let _transitBracketAlpha = 1;
/**
* What the last full sweep of the objects built, kept for the paints that
* follow it with the same view and the same objects. See `_drawOverlay`.
*/
const _paintCache = createDetectionPaintCache();
/** Scratch for `_paintViewOf`; the cache copies it when it keeps a paint. */
const _paintView = [];
/** Paints since `initDetection` that reused a kept sweep (diagnostics). */
let _paintReuseCount = 0;
/**
* Callouts solved by the sensor lane this frame, replayed by the callout lane.
* The array and its rows are POOLED — `_calloutCount` is the live length — so a
@@ -379,6 +389,8 @@ export function destroyDetection() {
_calloutLane?.unregister?.();
_calloutLane = null;
_transitBracketPaths.clear();
_paintCache.clear();
_paintReuseCount = 0;
_calloutCount = 0;
_viewer = null;
_layers = [];
@@ -643,6 +655,7 @@ function _publishDiagnostics() {
dataset.solveMs = String(_lastDiagnostics.solveMs ?? 0);
dataset.frameTotalMs = String(_lastDiagnostics.frameTotalMs ?? 0);
dataset.throttleSkipCount = String(_lastDiagnostics.throttleSkipCount ?? 0);
dataset.paintReuseCount = String(_lastDiagnostics.paintReuseCount ?? 0);
}
/**
@@ -718,6 +731,7 @@ function _applyModeState() {
_syncSurfaceVisibility();
_setLanesActive(false);
_labelArbiter.clear();
_paintCache.clear();
_surfaceOcclusion.clear();
_surfaceSuppressedKeys.clear();
_occlusionProbe.reset();
@@ -1259,15 +1273,53 @@ function _materializeCandidate(
};
}
/**
* Every value of the view that the object sweep in `_drawOverlay` and the
* callout candidates it builds read, in a fixed order, for
* `detectionPaintCache`. A value the sweep starts to read belongs here.
*/
function _paintViewOf(frame, keyholeOutsideOpacity, cyberMapActive) {
const view = _paintView;
const { keyhole, cameraPosition, viewProjectionMatrix, uiRects } = frame;
let n = 0;
view[n++] = _mode;
view[n++] = _theme;
view[n++] = _charWidth;
view[n++] = frame.width;
view[n++] = frame.height;
view[n++] = keyholeOutsideOpacity;
view[n++] = cyberMapActive;
view[n++] = keyhole?.centerX;
view[n++] = keyhole?.centerY;
view[n++] = keyhole?.radius;
view[n++] = keyhole?.featherPx;
view[n++] = cameraPosition?.x;
view[n++] = cameraPosition?.y;
view[n++] = cameraPosition?.z;
for (let i = 0; i < 16; i++) view[n++] = viewProjectionMatrix[i];
const rectCount = Array.isArray(uiRects) ? uiRects.length : -1;
view[n++] = rectCount;
for (let i = 0; i < rectCount; i++) {
const rect = uiRects[i];
view[n++] = rect?.x;
view[n++] = rect?.y;
view[n++] = rect?.w;
view[n++] = rect?.h;
}
view.length = n;
return view;
}
/**
* Paint detection against the shared host frame. Brackets remain broad and
* per-frame; rich placements rebuild only for selected/fading identities and
* for a bounded cohort on solve ticks.
*/
function _drawOverlay(frame) {
_transitBracketPaths.clear();
const { width, height } = frame;
if (!_ctx || width <= 0 || height <= 0) {
_transitBracketPaths.clear();
_paintCache.forget();
return {
didSolve: false,
solveMs: 0,
@@ -1309,6 +1361,8 @@ function _drawOverlay(frame) {
// callout lane repaints the last solve on frames the sensor lane skips,
// leaving rows here would strand the final callsigns on screen after the
// last data layer is switched off.
_transitBracketPaths.clear();
_paintCache.clear();
_calloutCount = 0;
_ctx.font = FONT;
_drawScanlines(width, height, now);
@@ -1361,6 +1415,9 @@ function _drawOverlay(frame) {
// every host lane. Detection retains the manual scalar projection below.
const occluder = frame.occluder;
const camPos = frame.cameraPosition;
const camX = camPos.x;
const camY = camPos.y;
const camZ = camPos.z;
// Answers to the occlusion tests started on earlier frames land here.
_surfaceOcclusion.beginFrame(now);
_occlusionProbe.beginFrame(_viewer?.scene, _surfaceOcclusion.now);
@@ -1390,7 +1447,7 @@ function _drawOverlay(frame) {
if (!_charWidth) _charWidth = _ctx.measureText('0000000000').width / 10 || 6;
const tiers = _theme.tiers || null;
const colorFor = (key) => (tiers && tiers[key]) || _theme.line;
const bracketPaths = new Map();
let bracketPaths = new Map();
// Global Context sonar echoes: a second bracket, banded by strength, around
// each flight or vessel a pulse front has just crossed.
// Resting pulses light nothing, so the per-contact echo work is skipped.
@@ -1415,14 +1472,41 @@ function _drawOverlay(frame) {
};
let visibleCount = 0;
const bracketOpacityCounts = { full: 0, partial: 0, hidden: 0 };
const aircraftBracketSectors = { left: 0, front: 0, right: 0 };
let bracketOpacityCounts = { full: 0, partial: 0, hidden: 0 };
let aircraftBracketSectors = { left: 0, front: 0, right: 0 };
let protectedVisibleCount = 0;
const candidateMap = new Map();
let candidateMap = new Map();
const cohortBuilders = shouldSolve ? new Map() : null;
const demandByLayer = shouldSolve ? new Map() : null;
let placementBuildCount = 0;
for (let i = 0; i < objects.length; i++) {
// The sweep below is the cost of this paint: every object projected, faded
// and given its bracket. What it builds depends only on the view and on the
// objects, so when both are exactly what the last sweep had, that sweep's
// paths, counts and callout candidates are used again and only the strokes
// and the callouts are repainted. Three things keep a paint from being
// reused or kept:
// - the Cyber sonar sweep and Global Context echoes change brackets with the
// clock alone;
// - a solve needs the sweep to gather its cohort;
// - a depth-tested contact gets its answer from the scene, not from here.
// A kept solve paint holds the solve's own candidates too, so the callouts a
// solve has just chosen are placed from the same map either way.
const paintKeepable = !sonarActive && !echoField;
const paintView = paintKeepable
? _paintViewOf(frame, keyholeOutsideOpacity, cyberMapActive)
: null;
const kept =
paintKeepable && !shouldSolve
? _paintCache.reuse(paintView, objects)
: null;
let depthTestedSwept = false;
let identityLayerId;
let renderSources;
let selectedSources;
if (!kept) _transitBracketPaths.clear();
const sweepCount = kept ? 0 : objects.length;
for (let i = 0; i < sweepCount; i++) {
const obj = objects[i];
if (!obj.position) continue;
// Skip objects occluded by the ellipsoid (behind the horizon)
@@ -1450,8 +1534,12 @@ function _drawOverlay(frame) {
let halfW;
let halfH;
if (obj.type === 'AIR') {
// Cartesian3.distance, without its two argument checks per object.
const dx = camX - px;
const dy = camY - py;
const dz = camZ - pz;
const bscale = nearFarScale(
Cesium.Cartesian3.distance(camPos, obj.position),
Math.sqrt(dx * dx + dy * dy + dz * dz),
BILL_NEAR,
BILL_NEAR_SCALE,
BILL_FAR,
@@ -1477,6 +1565,7 @@ function _drawOverlay(frame) {
// Something the scene drew in front of a depth-tested contact hides its
// bracket and callout too, on every frame, not only on solves.
if (obj.depthTested === true) {
depthTestedSwept = true;
const testedLayerId = obj._layerId || 'unknown';
const testedSourceId = obj.sourceId ?? obj.id ?? i;
const hidden = _surfaceOcclusion.isHidden(
@@ -1557,14 +1646,29 @@ function _drawOverlay(frame) {
if (!primary && !micro) continue;
const layerId = obj._layerId || 'unknown';
const sourceId = obj.sourceId ?? obj.id ?? i;
const liveForRender = renderIdentities.get(layerId)?.has(sourceId) || false;
// Objects arrive a layer at a time, so the layer's sets are looked up once
// per run of objects, not once per object.
if (layerId !== identityLayerId) {
identityLayerId = layerId;
renderSources = renderIdentities.get(layerId);
selectedSources = selectedIdentities?.get(layerId);
}
const liveForRender = renderSources?.has(sourceId) || false;
if (!shouldSolve && !liveForRender) continue;
obj._cohortSourceId = sourceId;
obj._cohortPriority = _semanticPriority(obj);
obj._cohortBand =
admissionAlpha >= 0.999 ? 8 : Math.floor(admissionAlpha * 8);
obj._cohortHash = stableIdentityHash(layerId, sourceId);
// The hash is of the identity alone, so it is worked out once per identity.
if (
obj._cohortHashLayerId !== layerId ||
obj._cohortHashSourceId !== sourceId
) {
obj._cohortHash = stableIdentityHash(layerId, sourceId);
obj._cohortHashLayerId = layerId;
obj._cohortHashSourceId = sourceId;
}
obj._candidateScreenX = sx;
obj._candidateScreenY = sy;
obj._candidateHalfW = halfW;
@@ -1578,8 +1682,7 @@ function _drawOverlay(frame) {
demandByLayer.set(layerId, (demandByLayer.get(layerId) || 0) + 1);
if (!cohortBuilders.has(layerId))
cohortBuilders.set(layerId, new BoundedCohort(256));
const incumbent =
selectedIdentities?.get(layerId)?.has(sourceId) || false;
const incumbent = selectedSources?.has(sourceId) || false;
cohortBuilders.get(layerId).consider(obj, incumbent);
}
@@ -1599,6 +1702,29 @@ function _drawOverlay(frame) {
}
// Start this frame's occlusion tests; nothing here waits for them.
_occlusionProbe.endFrame();
if (kept) {
_paintReuseCount++;
({
bracketPaths,
visibleCount,
bracketOpacityCounts,
aircraftBracketSectors,
protectedVisibleCount,
candidateMap,
} = kept);
} else if (paintKeepable && !depthTestedSwept) {
// `candidateMap` is kept by reference: a solve below adds to it.
_paintCache.keep(paintView, objects, {
bracketPaths,
visibleCount,
bracketOpacityCounts,
aircraftBracketSectors,
protectedVisibleCount,
candidateMap,
});
} else {
_paintCache.forget();
}
const altitude = _viewer?.camera?.positionCartographic?.height ?? 1e9;
const collectiveBudget = labelBudgetFor(altitude, _densityPct);
@@ -1782,6 +1908,7 @@ function _drawOverlay(frame) {
// drawing them cost it.
surfaceProbeTests: _occlusionProbe.lastFrameTests,
surfaceProbeMs: _occlusionProbe.lastFrameMs,
paintReuseCount: _paintReuseCount,
};
} else {
const selectedCount = _labelArbiter.selectedKeys.size;
@@ -1795,6 +1922,7 @@ function _drawOverlay(frame) {
_lastDiagnostics.aircraftBracketSectors = aircraftBracketSectors;
_lastDiagnostics.bracketPresentationOpacity = bracketPresentationOpacity;
_lastDiagnostics.protectedVisibleCount = protectedVisibleCount;
_lastDiagnostics.paintReuseCount = _paintReuseCount;
}
_drawScanlines(width, height, now);
+231
View File
@@ -1661,3 +1661,234 @@ test('a WebGL 1 scene reads depth synchronously and reports it once', () => {
env.cleanup();
}
});
// --- Reusing a paint -------------------------------------------------------
/**
* Contacts as the fleet layers return them: the same objects on every paint,
* changed in place. `fresh` returns equal copies instead, which no paint can
* reuse, so the two can be compared.
*/
function steadyContacts({ fresh = false } = {}) {
const objects = [
{ position: new Cesium.Cartesian3(-0.4, 0.2, 6_356_752), sourceId: 'air-1', id: 'AIRONE', metric: 'FL350', type: 'AIR' },
{ position: new Cesium.Cartesian3(0.4, -0.2, 6_356_752), sourceId: 'sat-1', id: 'SATONE', metric: '412KM', type: 'SAT' },
{ position: new Cesium.Cartesian3(0.1, 0.5, 6_356_752), sourceId: 'sea-1', id: 'SEAONE', metric: '12 kn', type: 'SEA' },
{ position: new Cesium.Cartesian3(-0.2, -0.5, 6_356_752), sourceId: 'mil-1', id: 'MILONE', metric: 'FL410', type: 'AIR', tier: 'military' },
];
let calls = 0;
return {
objects,
get calls() { return calls; },
layer: {
id: 'flights',
getDetectableObjects() {
calls += 1;
return fresh
? objects.map((object) => ({ ...object, position: Cesium.Cartesian3.clone(object.position) }))
: objects;
},
},
};
}
/** Everything painted since the trace was last emptied, with each path as its points. */
function takePainted(env) {
const painted = env.paintTrace.map((call) =>
call.map((value) => (value instanceof MockPath2D ? value.points.map((point) => [...point]) : value)));
env.paintTrace.length = 0;
return painted;
}
const bracketPointCount = (env) => env.detectionCtx.calls
.filter(([name]) => name === 'stroke')
.reduce((sum, [, path]) => sum + (path?.points?.length ?? 0), 0);
test('a paint with the same view and the same contacts reuses the last sweep, and paints exactly what a full sweep paints', () => {
// The same frames twice: once with contacts a paint can reuse, once with
// equal copies it cannot. Every canvas call must match, frame for frame.
const frames = (fresh) => {
const env = installEnvironment();
const contacts = steadyContacts({ fresh });
const painted = [];
try {
initWorldOverlay(env.viewer);
initDetection(env.viewer, [contacts.layer], () => {});
setMode('DENSE');
settleFrame(env);
painted.push(takePainted(env));
for (let frame = 1; frame <= 40; frame++) {
// Through the run: a contact moves, one is relabelled, one changes
// tier, and the camera turns.
if (frame === 9) contacts.objects[0].position.x += 0.05;
if (frame === 17) contacts.objects[1].metric = '398KM';
if (frame === 23) contacts.objects[2].tier = 'military';
if (frame === 31) env.viewer.camera.viewMatrix[12] = 0.03;
nextFrame(env);
painted.push(takePainted(env));
}
return { painted, reused: getDetectionDiagnostics().paintReuseCount };
} finally {
env.cleanup();
}
};
const reusing = frames(false);
const sweeping = frames(true);
assert.equal(sweeping.reused, 0, 'copies are swept on every paint');
// 40 frames 16 ms apart: a solve about every eighth, four changes, and the
// rest reuse the sweep before them.
assert.ok(reusing.reused >= 28, `${reusing.reused} of 40 paints reused a sweep`);
assert.equal(reusing.painted.length, sweeping.painted.length);
reusing.painted.forEach((frame, index) => {
assert.ok(frame.some(([target, name]) => target === 'detection' && name === 'stroke'), `frame ${index} strokes brackets`);
assert.deepEqual(frame, sweeping.painted[index], `frame ${index}`);
});
assert.ok(
reusing.painted.some((frame) => frame.some(([, name, text]) => name === 'fillText' && text === 'AIRONE')),
'callouts were painted during the run',
);
});
test('a reused paint builds no bracket paths and still strokes the kept ones', () => {
const env = installEnvironment();
const contacts = steadyContacts();
const builtPaths = [];
class CountingPath2D extends MockPath2D {
constructor() { super(); builtPaths.push(this); }
}
try {
globalThis.Path2D = CountingPath2D;
initWorldOverlay(env.viewer);
initDetection(env.viewer, [contacts.layer], () => {});
setMode('DENSE');
settleFrame(env);
assert.equal(getDetectionDiagnostics().paintReuseCount, 0);
const built = builtPaths.length;
const points = builtPaths.reduce((sum, path) => sum + path.points.length, 0);
assert.ok(points >= 4 * 12, 'four brackets of twelve points were built');
env.detectionCtx.calls.length = 0;
nextFrame(env);
assert.equal(getDetectionDiagnostics().paintReuseCount, 1);
assert.equal(builtPaths.length, built, 'no path was built');
assert.equal(builtPaths.reduce((sum, path) => sum + path.points.length, 0), points, 'no point was added to a kept path');
assert.equal(bracketPointCount(env), 4 * 12, 'the kept brackets are stroked');
assert.equal(getDetectionDiagnostics().visibleCount, 4);
assert.equal(getDetectionDiagnostics().placementBuildCount, 0, 'no placement was built on the reused paint');
} finally {
env.cleanup();
}
});
test('any change to a contact or to the view is swept on the very next paint', () => {
const changes = {
'a contact moves': (contacts) => { contacts.objects[0].position.y -= 0.01; },
'a contact gets a new position object': (contacts) => {
contacts.objects[0].position = new Cesium.Cartesian3(0.3, 0.3, 6_356_752);
},
'a contact is renamed': (contacts) => { contacts.objects[1].id = 'SATTWO'; },
'a contact reading changes': (contacts) => { contacts.objects[0].metric = 'FL360'; },
'a contact becomes the tracked one': (contacts) => { contacts.objects[0].skipLabel = true; },
'a contact changes tier': (contacts) => { contacts.objects[3].tier = undefined; },
'a contact changes type': (contacts) => { contacts.objects[2].type = 'VEH'; },
'a contact joins': (contacts) => {
contacts.objects.push({ position: new Cesium.Cartesian3(0.6, 0.6, 6_356_752), sourceId: 'air-2', id: 'AIRTWO', type: 'AIR' });
},
'a contact leaves': (contacts) => { contacts.objects.pop(); },
'a contact is replaced by an equal one': (contacts) => { contacts.objects[1] = { ...contacts.objects[1] }; },
'two contacts swap places in the list': (contacts) => { contacts.objects.reverse(); },
'the camera turns': (contacts, env) => { env.viewer.camera.viewMatrix[13] = 0.02; },
'the camera moves': (contacts, env) => {
env.viewer.camera.positionWC = new Cesium.Cartesian3(0, 100, 10_000_000);
},
'the style changes': () => { setDetectionStyle('thermal'); },
'the profile changes': () => { setMode('SPARSE'); },
};
for (const [name, change] of Object.entries(changes)) {
const env = installEnvironment();
const contacts = steadyContacts();
try {
initWorldOverlay(env.viewer);
initDetection(env.viewer, [contacts.layer], () => {});
setDetectionStyle('normal');
setMode('DENSE');
settleFrame(env);
nextFrame(env);
assert.equal(getDetectionDiagnostics().paintReuseCount, 1, `${name}: steady paints reuse`);
change(contacts, env);
nextFrame(env);
assert.equal(getDetectionDiagnostics().paintReuseCount, 1, `${name}: swept again`);
assert.equal(getDetectionDiagnostics().observationCount, contacts.objects.length, name);
} finally {
env.cleanup();
}
}
});
test('a depth-tested contact, the sonar sweep and echoes are swept on every paint', () => {
const run = ({ depthTested = false, sonar = false, echo = false } = {}) => {
const env = installEnvironment();
const contacts = steadyContacts();
if (depthTested) {
contacts.objects[0].type = 'VEH';
contacts.objects[0].depthTested = true;
}
if (sonar) {
env.document.documentElement = {
dataset: { uiTheme: 'cyber', cyberSonarOpacity: '35' },
style: { setProperty() {}, removeProperty() {} },
};
const hud = env.document.createElement('div');
hud.id = 'intel-hud';
hud.classList = { contains: (className) => className === 'active' };
env.document.body.appendChild(hud);
}
try {
if (echo)
setAwarenessEchoField({
center: Cesium.Cartesian3.fromDegrees(0, 89, 0),
radiusM: 250_000,
pulse: { count: 1000, periodMs: 1000, startFraction: 0 },
echo: { layerIds: ['flights'], fadeMs: 60_000, color: 'rgb(1, 2, 3)', spreadPx: 8, lineWidth: 3.25 },
});
initWorldOverlay(env.viewer);
initDetection(env.viewer, [contacts.layer], () => {});
setMode('DENSE');
settleFrame(env);
const sweeps = contacts.calls;
for (let i = 0; i < 30; i++) nextFrame(env);
assert.ok(contacts.calls > sweeps, 'frames painted');
return getDetectionDiagnostics().paintReuseCount;
} finally {
clearAwarenessEchoField();
env.cleanup();
}
};
assert.ok(run() > 15, 'the same contacts with none of these are reused');
assert.equal(run({ depthTested: true }), 0, 'depth-tested');
assert.equal(run({ sonar: true }), 0, 'sonar');
assert.equal(run({ echo: true }), 0, 'echo');
});
test('turning detection off lets go of the kept sweep', () => {
const env = installEnvironment();
const contacts = steadyContacts();
try {
initWorldOverlay(env.viewer);
initDetection(env.viewer, [contacts.layer], () => {});
setMode('DENSE');
settleFrame(env);
nextFrame(env);
assert.equal(getDetectionDiagnostics().paintReuseCount, 1);
setMode('OFF');
setMode('DENSE');
// The first paint back is a solve, and the one after it reuses that.
nextFrame(env);
assert.equal(getDetectionDiagnostics().paintReuseCount, 1);
nextFrame(env);
assert.equal(getDetectionDiagnostics().paintReuseCount, 2);
} finally {
env.cleanup();
}
});
+206
View File
@@ -0,0 +1,206 @@
/**
* @module detectionPaintCache
* @description Lets the detection overlay keep what one paint built (bracket
* paths, counts, callout candidates) and use it again on the next paint when
* that paint would build exactly the same thing.
*
* Nothing announces a change to this cache. Each paint hands over the values
* the sweep reads from the view and the objects the layers returned, and the
* cache compares them, value by value, with what the kept paint was built
* from. A layer therefore cannot leave a stale picture behind by forgetting to
* say it changed: an object that moved, appeared, left or was relabelled is
* seen here, and the sweep runs again.
*
* Pure: no Cesium, no DOM.
*/
/**
* The object fields the detection sweep and its callout candidates read.
* Position is compared separately, by coordinate. `sameFields` and
* `recordFields` below name each of these outright, in this order, because a
* field read by a name held in a variable costs several times as much, and
* this runs for every object on every paint.
*/
export const PAINTED_OBJECT_FIELDS = Object.freeze([
'_layerId',
'type',
'tier',
'skipLabel',
'id',
'metric',
'sourceId',
'bracketHalfWidth',
'bracketHalfHeight',
'depthTested',
'cardSourceId',
'cardEntryId',
]);
/**
* After this many sweeps in a row with no reuse between them, the objects are
* only recorded on every RECORD_STRIDE-th sweep. A scene whose objects move on
* every paint can never reuse one, so recording each of them would be work
* with no return; a scene that settles is picked up again within RECORD_STRIDE
* paints.
*/
export const SWEEPS_BEFORE_BACKOFF = 8;
export const RECORD_STRIDE = 8;
const FIELD_COUNT = PAINTED_OBJECT_FIELDS.length;
function sameFields(object, fields, f) {
return (
object._layerId === fields[f] &&
object.type === fields[f + 1] &&
object.tier === fields[f + 2] &&
object.skipLabel === fields[f + 3] &&
object.id === fields[f + 4] &&
object.metric === fields[f + 5] &&
object.sourceId === fields[f + 6] &&
object.bracketHalfWidth === fields[f + 7] &&
object.bracketHalfHeight === fields[f + 8] &&
object.depthTested === fields[f + 9] &&
object.cardSourceId === fields[f + 10] &&
object.cardEntryId === fields[f + 11]
);
}
/** Most fields are what they were at the last sweep; only the others are written. */
function recordFields(object, fields, f) {
let value;
if ((value = object._layerId) !== fields[f]) fields[f] = value;
if ((value = object.type) !== fields[f + 1]) fields[f + 1] = value;
if ((value = object.tier) !== fields[f + 2]) fields[f + 2] = value;
if ((value = object.skipLabel) !== fields[f + 3]) fields[f + 3] = value;
if ((value = object.id) !== fields[f + 4]) fields[f + 4] = value;
if ((value = object.metric) !== fields[f + 5]) fields[f + 5] = value;
if ((value = object.sourceId) !== fields[f + 6]) fields[f + 6] = value;
if ((value = object.bracketHalfWidth) !== fields[f + 7])
fields[f + 7] = value;
if ((value = object.bracketHalfHeight) !== fields[f + 8])
fields[f + 8] = value;
if ((value = object.depthTested) !== fields[f + 9]) fields[f + 9] = value;
if ((value = object.cardSourceId) !== fields[f + 10]) fields[f + 10] = value;
if ((value = object.cardEntryId) !== fields[f + 11]) fields[f + 11] = value;
}
/**
* @returns {{
* reuse: (view: Array, objects: Array<object>) => (object|null),
* keep: (view: Array, objects: Array<object>, built: object) => boolean,
* forget: () => void,
* clear: () => void,
* }}
*/
export function createDetectionPaintCache() {
let built = null;
let viewKept = [];
let count = 0;
let references = [];
let fields = [];
let coordinates = new Float64Array(0);
let positioned = new Uint8Array(0);
let sweepsSinceReuse = 0;
function sameView(view) {
if (view.length !== viewKept.length) return false;
for (let i = 0; i < view.length; i++)
if (view[i] !== viewKept[i]) return false;
return true;
}
function sameObjects(objects) {
if (objects.length !== count) return false;
for (let i = 0, c = 0, f = 0; i < count; i++, c += 3, f += FIELD_COUNT) {
const object = objects[i];
if (object !== references[i]) return false;
const position = object.position;
if (position) {
// A coordinate that is not a number never equals the kept one, so such
// an object is swept again on every paint.
if (
positioned[i] !== 1 ||
position.x !== coordinates[c] ||
position.y !== coordinates[c + 1] ||
position.z !== coordinates[c + 2]
)
return false;
} else if (positioned[i] !== 0) {
return false;
}
if (!sameFields(object, fields, f)) return false;
}
return true;
}
return {
/**
* What the kept paint built, when `view` and `objects` are what it was
* built from; otherwise null.
* @param {Array} view - Every value of the view the sweep reads, in a fixed order.
* @param {Array<object>} objects - The objects the layers returned this paint.
*/
reuse(view, objects) {
if (!built || !sameView(view) || !sameObjects(objects)) return null;
sweepsSinceReuse = 0;
return built;
},
/**
* Keep `result` as the result of sweeping `objects` under `view`.
* @returns {boolean} False when recording was skipped (see RECORD_STRIDE).
*/
keep(view, objects, result) {
sweepsSinceReuse++;
if (
sweepsSinceReuse > SWEEPS_BEFORE_BACKOFF &&
(sweepsSinceReuse - SWEEPS_BEFORE_BACKOFF) % RECORD_STRIDE !== 0
) {
built = null;
return false;
}
viewKept = view.slice();
count = objects.length;
if (positioned.length < count) {
// Room to spare, so a feed that grows by a few contacts a poll does
// not allocate on each of them.
const room = Math.ceil(count * 1.25);
coordinates = new Float64Array(room * 3);
positioned = new Uint8Array(room);
references = new Array(room).fill(undefined);
fields = new Array(room * FIELD_COUNT).fill(undefined);
}
for (let i = 0, c = 0, f = 0; i < count; i++, c += 3, f += FIELD_COUNT) {
const object = objects[i];
if (references[i] !== object) references[i] = object;
const position = object.position;
positioned[i] = position ? 1 : 0;
if (position) {
coordinates[c] = position.x;
coordinates[c + 1] = position.y;
coordinates[c + 2] = position.z;
}
recordFields(object, fields, f);
}
built = result;
return true;
},
/** Reuse nothing until the next `keep`. */
forget() {
built = null;
},
/** Forget the kept paint and let go of its objects. */
clear() {
built = null;
viewKept = [];
count = 0;
references = [];
fields = [];
coordinates = new Float64Array(0);
positioned = new Uint8Array(0);
sweepsSinceReuse = 0;
},
};
}
+197
View File
@@ -0,0 +1,197 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
createDetectionPaintCache,
PAINTED_OBJECT_FIELDS,
RECORD_STRIDE,
SWEEPS_BEFORE_BACKOFF,
} from './detectionPaintCache.js';
const contacts = () => [
{
position: { x: 1, y: 2, z: 3 },
_layerId: 'flights',
sourceId: 'a',
id: 'A',
metric: 'FL300',
type: 'AIR',
},
{
position: { x: 4, y: 5, z: 6 },
_layerId: 'vessels',
sourceId: 'b',
id: 'B',
type: 'SEA',
},
{ _layerId: 'cctv', sourceId: 'c', id: 'C', type: 'CAM' },
];
const view = () => ['DENSE', 800, 600, 0.25, 1, 0, 0, 1];
function kept() {
const cache = createDetectionPaintCache();
const objects = contacts();
const built = { paths: 'kept' };
assert.equal(cache.keep(view(), objects, built), true);
return { cache, objects, built };
}
test('the kept sweep is handed back for the same view and the same objects', () => {
const { cache, objects, built } = kept();
assert.equal(cache.reuse(view(), objects), built);
assert.equal(cache.reuse(view(), objects), built, 'and again');
// The same objects in a new list are still the same objects.
assert.equal(cache.reuse(view(), [...objects]), built);
});
test('nothing is handed back before a sweep is kept, or after it is forgotten', () => {
const cache = createDetectionPaintCache();
const objects = contacts();
assert.equal(cache.reuse(view(), objects), null);
cache.keep(view(), objects, {});
cache.forget();
assert.equal(cache.reuse(view(), objects), null);
cache.keep(view(), objects, {});
cache.clear();
assert.equal(cache.reuse(view(), objects), null);
});
test('any value of the view that differs is a miss', () => {
for (let i = 0; i < view().length; i++) {
const { cache, objects } = kept();
const changed = view();
changed[i] = typeof changed[i] === 'number' ? changed[i] + 1e-9 : 'SPARSE';
assert.equal(cache.reuse(changed, objects), null, `view value ${i}`);
}
const { cache, objects } = kept();
assert.equal(cache.reuse([...view(), 0], objects), null, 'one value more');
assert.equal(
cache.reuse(view().slice(0, -1), objects),
null,
'one value fewer',
);
});
test('a view value that is not a number never matches itself', () => {
const cache = createDetectionPaintCache();
const objects = contacts();
cache.keep([NaN], objects, {});
assert.equal(cache.reuse([NaN], objects), null);
});
test('the view is copied when kept, so the caller may write the next one over it', () => {
const cache = createDetectionPaintCache();
const objects = contacts();
const scratch = view();
const built = {};
cache.keep(scratch, objects, built);
scratch[1] = 1024;
assert.equal(cache.reuse(scratch, objects), null);
scratch[1] = 800;
assert.equal(cache.reuse(scratch, objects), built);
});
test('a coordinate changed in place is a miss, however small', () => {
for (const axis of ['x', 'y', 'z']) {
const { cache, objects } = kept();
objects[1].position[axis] += 1e-9;
assert.equal(cache.reuse(view(), objects), null, axis);
}
});
test('a position object swapped for an equal one is still the same paint', () => {
const { cache, objects, built } = kept();
objects[0].position = { x: 1, y: 2, z: 3 };
assert.equal(cache.reuse(view(), objects), built);
});
test('a position that appears, goes away or is not a number is a miss', () => {
let { cache, objects } = kept();
objects[2].position = { x: 0, y: 0, z: 0 };
assert.equal(cache.reuse(view(), objects), null, 'appears');
({ cache, objects } = kept());
objects[0].position = null;
assert.equal(cache.reuse(view(), objects), null, 'goes away');
cache = createDetectionPaintCache();
objects = contacts();
objects[0].position.x = NaN;
cache.keep(view(), objects, {});
assert.equal(cache.reuse(view(), objects), null, 'not a number');
});
test('every field the sweep reads is compared', () => {
for (const field of PAINTED_OBJECT_FIELDS) {
const { cache, objects, built } = kept();
const before = objects[0][field];
objects[0][field] = before === 'changed' ? 'again' : 'changed';
assert.equal(cache.reuse(view(), objects), null, field);
objects[0][field] = before;
assert.equal(cache.reuse(view(), objects), built, `${field} put back`);
}
});
test('a field the sweep does not read may change freely', () => {
const { cache, objects, built } = kept();
objects[0]._candidateScreenX = 412;
objects[0]._cohortHash = 7;
assert.equal(cache.reuse(view(), objects), built);
});
test('a different object, count or order is a miss', () => {
let { cache, objects } = kept();
assert.equal(cache.reuse(view(), contacts()), null, 'equal copies');
({ cache, objects } = kept());
assert.equal(cache.reuse(view(), objects.slice(0, 2)), null, 'one fewer');
assert.equal(cache.reuse(view(), [...objects, objects[0]]), null, 'one more');
({ cache, objects } = kept());
assert.equal(
cache.reuse(view(), [objects[1], objects[0], objects[2]]),
null,
'order',
);
});
test('keeping again replaces what was kept, for more or fewer objects', () => {
const { cache, objects, built } = kept();
const more = [...objects, ...contacts(), ...contacts()];
const builtMore = {};
cache.keep(view(), more, builtMore);
assert.equal(cache.reuse(view(), objects), null);
assert.equal(cache.reuse(view(), more), builtMore);
cache.keep(view(), objects, built);
assert.equal(cache.reuse(view(), more), null);
assert.equal(cache.reuse(view(), objects), built);
});
test('objects that change on every paint are recorded only now and then', () => {
const cache = createDetectionPaintCache();
const objects = contacts();
const recorded = [];
const sweep = () => {
objects[0].position.x += 1;
assert.equal(cache.reuse(view(), objects), null);
recorded.push(cache.keep(view(), objects, {}));
};
for (let i = 0; i < SWEEPS_BEFORE_BACKOFF; i++) sweep();
assert.ok(recorded.every(Boolean), 'the first sweeps are all recorded');
recorded.length = 0;
for (let i = 0; i < RECORD_STRIDE * 5; i++) sweep();
assert.equal(recorded.filter(Boolean).length, 5, 'then one in every stride');
// A skipped sweep leaves nothing behind to reuse by mistake.
const built = {};
let wasRecorded = cache.keep(view(), objects, built);
assert.equal(wasRecorded, false);
assert.equal(cache.reuse(view(), objects), null);
// Once the objects hold still, the next recorded sweep is reused, and
// every sweep after a reuse is recorded again.
while (!wasRecorded) wasRecorded = cache.keep(view(), objects, built);
assert.equal(cache.reuse(view(), objects), built);
for (let i = 0; i < SWEEPS_BEFORE_BACKOFF; i++)
assert.equal(cache.keep(view(), objects, built), true);
});
+9 -15
View File
@@ -133,9 +133,8 @@ export function hullBeam(beam) {
/**
* The model for a vessel of AIS ship type `shipType` and the reported length
* and beam (metres) and name: the first rule that matches in the first type
* range that has one, else `default_vessel`. Drawn at the reported length;
* when it is unknown (most live feeds send none), chosen and drawn at the
* range's `typical_len`, else as built.
* range that has one, else `default_vessel`. Drawn at the reported length, as
* built when it is unknown.
* @param {object} types Parsed `types.json`.
* @param {number} [shipType]
* @param {number} [length]
@@ -146,14 +145,13 @@ export function hullBeam(beam) {
export function resolveVessel(types, shipType, length, beam, name) {
const L = hullLength(length) ?? 0;
const B = hullBeam(beam) ?? 0;
const drawn = L > 0 ? L : null;
if (shipType !== undefined && shipType !== null) {
for (const group of types.vessels) {
if (!(shipType >= group.types[0] && shipType <= group.types[1])) continue;
const len = L > 0 ? L : (group.typical_len ?? 0);
const drawn = len > 0 ? len : null;
for (const rule of group.rules) {
if (rule.min_len && len < rule.min_len) continue;
if (rule.max_len && len > rule.max_len) continue;
if (rule.min_len && L < rule.min_len) continue;
if (rule.max_len && L > rule.max_len) continue;
if (rule.name_re && !new RegExp(rule.name_re).test(name ?? ''))
continue;
if (rule.max_bl && !(B > 0 && L > 0 && B / L <= rule.max_bl)) continue;
@@ -161,7 +159,7 @@ export function resolveVessel(types, shipType, length, beam, name) {
}
}
}
return { model: types.default_vessel, length: L > 0 ? L : null };
return { model: types.default_vessel, length: drawn };
}
/**
@@ -343,22 +341,18 @@ export function aircraftModelSpec(set, { typeCode, type, category } = {}) {
* @returns {object} {@link fleetModelSpec}
*/
export function vesselModelSpec(set, { type, length, beam, name } = {}) {
// A beam counts only beside a reported length: a vessel drawn at its type's
// typical length keeps the model's proportions.
const reported = hullLength(length ?? undefined);
const hullWidth = reported === undefined ? undefined : (beam ?? undefined);
const resolved = resolveVessel(
set.types,
aisShipTypeCode(type),
reported,
hullWidth,
length ?? undefined,
beam ?? undefined,
name ?? undefined,
);
return specOf(
set,
resolved,
(r) => ({ model: set.types.default_vessel, length: r.length }),
{ beam: hullWidth },
{ beam: beam ?? undefined },
);
}
-30
View File
@@ -57,7 +57,6 @@ const sliceTypes = {
vessels: [
{
types: [70, 79],
typical_len: 300,
rules: [
{ min_len: 70, max_len: 140, max_bl: 0.12, model: 'inland_cargo' },
{
@@ -134,29 +133,6 @@ test('vessels: the first matching rule of the first type range with one, else th
});
});
test("vessels of unknown length are chosen and drawn at their range's typical length", () => {
assert.deepEqual(resolveVessel(sliceTypes, 70), {
model: 'container_ship',
length: 300,
});
assert.deepEqual(resolveVessel(sliceTypes, 70, 511, 126), {
model: 'container_ship',
length: 300,
});
assert.deepEqual(resolveVessel(sliceTypes, 70, 90, 15), {
model: 'general_cargo',
length: 90,
});
assert.deepEqual(resolveVessel(sliceTypes, 31), {
model: 'tug',
length: null,
});
// Every passenger, cargo and tanker range of the set has one.
for (const code of [60, 70, 80]) {
assert.ok(resolveVessel(types, code).length > 100, String(code));
}
});
test("vessels: AIS's out-of-range sizes (511 m long, 126 m abeam) are unknown, drawn as built", () => {
assert.deepEqual(resolveVessel(sliceTypes, 52, 511), {
model: 'tug',
@@ -291,12 +267,6 @@ test('vessel specs read AIS codes and text families, stretched to the reported b
const [sx, sy, sz] = box.scaleXYZ;
assert.ok(Math.abs(sy - 51 / catalog.container_ship.beam) < 1e-12);
assert.ok(Math.abs(sz - (sx + sy) / 2) < 1e-12);
// A beam with no reported length: the type's typical length, in the model's
// own proportions, not a typical-length hull squeezed to that beam.
const unsized = vesselModelSpec(set, { type: '70', length: 0, beam: 14 });
assert.equal(unsized.model, 'container_ship');
const [ux, uy, uz] = unsized.scaleXYZ;
assert.ok(Math.abs(uy - ux) < 1e-12 && Math.abs(uz - ux) < 1e-12);
assert.equal(vesselModelSpec(set, { type: 'Tug', length: 30 }).model, 'tug');
assert.equal(vesselModelSpec(set, { type: '36' }).model, 'sailboat');
// No type, no size: the generic vessel as built.