Compare commits

...
Author SHA1 Message Date
German Khokhlov 4c62435910 Drop the duplicate callout test and the card id cache 2026-11-06 22:01:39 +04:00
German Khokhlov c9f40a837c Tighten callout fade comments and docs 2026-11-06 20:20:11 +04:00
German Khokhlov 910e63a56a Merge main into german/vessel-callout-under-card
Resolve addPaintItem by keeping both the collision reset from main and the
queued-card frame stamp from this branch.
2026-11-06 20:00:45 +04:00
Said Turksever 3d6423c6f8 Merge pull request #870 from waar19/perf/fleet-tick-skip-hidden
perf(flights): skip dead-reckoning contacts that stay behind the horizon
2026-11-06 15:03:33 +00:00
Said Turksever 15fdf65a73 Merge branch 'main' into perf/fleet-tick-skip-hidden 2026-11-06 14:52:56 +00:00
Said Turksever 141181f83a docs(flights): changelog names the one-in-13-ticks rotation, about once a second 2026-11-06 12:02:30 +00:00
Said Turksever 0dbbd9047e Merge remote-tracking branch 'upstream/main' into HEAD 2026-11-06 11:54:29 +00:00
German Khokhlov 1015b16664 Prune queued-card rows whose pooled item paints another entry
A pooled paint item reused by another card keeps a fresh frame stamp, so
rows for cards that stopped painting survived every prune and the index
grew for the whole session. The prune now also drops a row whose item
paints a different entry id or source, and a test pins the bound.
2026-11-06 14:45:37 +04:00
German Khokhlov da00b096d2 Fade a vessel's callout by its card's painted opacity
The callout now asks for the opacity its own card paints with this frame,
keyhole fade included, and fades out as the card fades in. A card faded out
at the keyhole rim leaves the callout readable, and a card fading out after
leaving its source still hides it. The vessel card id is cached on the
record, and the allocation gate covers the lookup.
2026-11-05 21:21:02 +04:00
German Khokhlov ee3466dff9 Keep the queued-card check off the per-frame allocation path
Writing a fractional alpha onto the overlay record for every paint item
boxed a heap number per item per frame. The allocation gate caught it:
the generic 60-entry workload went from 2,838 to 12,614 B/frame. The
record now keeps only the frame stamp and a pointer to its paint item,
and isOverlayEntryQueued multiplies the alphas when it is asked. The
gate is back to main's numbers on Node 24.
2026-11-05 13:14:00 +04:00
German Khokhlov 092e7e71a8 Add the changelog entry for #1577 2026-11-05 12:54:11 +04:00
German Khokhlov 652f3c8ea1 Hide a vessel's detection callout while its own card paints
With Vessels and Detection on, a vessel with an ambient card on the map
also got its detection callout next to the bracket. The callout lane
paints beneath the cards, so its text showed through the card's plate
and covered the speed and course.

The callout now gives way only to a card the shared overlay actually
paints in the same frame. The overlay paints part of the published
cards (shared budget, collisions, UI chrome), so a vessel whose card
was dropped keeps its callout.

The detection lane paints before the cards publish their paint rects,
so getOverlayPaintRect has nothing for the current frame there. The new
isOverlayEntryQueued answers from the paint queue that the overlay
solves before painting. Vessel detection objects name their card in
cardSourceId/cardEntryId.
2026-11-05 12:53:17 +04:00
Said Turksever 2c936ae131 docs: correct hidden-contact rotation interval 2026-11-03 16:26:45 +00:00
Said Turksever b68cd25e9e Merge commit '2ebde97fb3af19402eab83fca2bfbec9ddfd166a' into easy-wins-20261103-pr870 2026-11-03 16:17:34 +00:00
Said Turksever 66a5c483c2 fix: resolve review findings and refresh PR #870 against main 2026-11-03 16:17:31 +00:00
Wilmer AlzateandClaude Opus 5.5 81f49406b8 perf(flights): skip dead-reckoning contacts that stay behind the horizon
The fleet tick dead-reckoned, floored and distance-tested every contact on
every ~80 ms tick, then hid the ones behind the horizon. The worldwide feed
carries ~13k contacts; zoomed in, well under 1% are on screen, so nearly all
of that work produced a hidden billboard.

A contact that is already hidden and still behind the horizon at its last
position now re-reckons only on its turn in a staggered one-in-12-ticks
rotation (about once a second, HIDDEN_CONTACT_DR_TICKS). Anything that
position puts in view, and any contact with a fleet model, takes the full
pass immediately, so on-screen behaviour is unchanged. Hidden-inclusive
proximity queries (getNearby includeHidden, getAllPositions) read positions
at most about a second old, well inside the 30 s poll's uncertainty.

Measured in Chrome on a Radeon 780M, ~12.9k contacts, 56 on screen over
Colombia at 4 km, same view before and after:
  fleet tick   7.66 -> 2.03 ms/frame, longest tick 50.9 -> ~16 ms
  frame        20.6 -> 14.0 ms, 42.9 -> 57.8 FPS
Teleporting the camera to Madrid showed 975 contacts within 100 ms.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-30 12:31:59 -05:00
15 changed files with 638 additions and 20 deletions

No files matched your search

@@ -0,0 +1,3 @@
- A vessel whose ambient card is on the map no longer gets a Detection callout
showing through the card and covering its speed and course. A vessel whose
card the overlay did not paint keeps its callout (German, @gekh, #1577).
+4
View File
@@ -0,0 +1,4 @@
- Reduce dead-reckoning work for flights already hidden behind the horizon.
Hidden contacts update on a staggered one-in-13-ticks rotation (about once a
second) while visible contacts and contacts with fleet models retain the full
update path (Wilmer Alzate, #870).
+2 -2
View File
@@ -5350,7 +5350,7 @@ silently demoting every later lookup for the session.
### Motion & Symbology Correctness (June 10, 2026)
- **Flights (commercial + military)** render one poll interval behind real time (30s/15s) and interpolate between two known feed-stamped fixes (OpenSky `time_position`; adsb.lol `receipt − seen_pos`). The display latency is an intentional product decision — do not "fix" it away. The whole fleet dead-reckons at ~12Hz (1m² write gating); aircraft get a 3-poll grace period (faded icon) before removal. When a position epoch pauses, both layers coast for at least 60 seconds of contact grace with an absolute five-minute ceiling. Source backoff marks each contact and the cockpit `STALE`; the cockpit then holds the exact layer position instead of continuing inertial flight. Repeated-position kinematic changes create a forward-only synthetic fix rather than mutating history, and grounded history is lifted only when no owned 3D model already controls its datum. A nominally successful worldwide OpenSky response whose own snapshot epoch is more than two minutes old prefers the existing 250-nm viewport-scoped adsb.lol fallback and labels the source/coverage accordingly when that upstream is available. If the fallback is also unavailable, the layer's freshness/error fields use the source epoch—never the cache receipt time—so the UI reports an old snapshot rather than “just now.”
- **Flights (commercial + military)** render one poll interval behind real time (30s/15s) and interpolate between two known feed-stamped fixes (OpenSky `time_position`; adsb.lol `receipt − seen_pos`). The display latency is an intentional product decision — do not "fix" it away. The fleet dead-reckons at ~12Hz (1m² write gating); a contact already hidden behind the horizon whose last position still is re-reckons on a staggered one-in-13-ticks rotation (about once a second) instead, so the worldwide feed's ~13k contacts no longer cost a full pass when well under 1% are on screen. Anything that position puts in view, and any contact with a fleet model, takes the full pass immediately, and hidden-inclusive proximity queries read positions at most about a second old; aircraft get a 3-poll grace period (faded icon) before removal. When a position epoch pauses, both layers coast for at least 60 seconds of contact grace with an absolute five-minute ceiling. Source backoff marks each contact and the cockpit `STALE`; the cockpit then holds the exact layer position instead of continuing inertial flight. Repeated-position kinematic changes create a forward-only synthetic fix rather than mutating history, and grounded history is lifted only when no owned 3D model already controls its datum. A nominally successful worldwide OpenSky response whose own snapshot epoch is more than two minutes old prefers the existing 250-nm viewport-scoped adsb.lol fallback and labels the source/coverage accordingly when that upstream is available. If the fallback is also unavailable, the layer's freshness/error fields use the source epoch—never the cache receipt time—so the UI reports an old snapshot rather than “just now.”
- **World-space headings at every angle** (`src/data/iconOrientation.js`): aircraft/vessel icon rotation uses the camera right/up basis per tick (alignedAxis always ZERO), which is exact at screen center and for orthographic/nadir views and remains stable through >180° tracked orbits. Perspective rays vary across the viewport, so off-center contacts at oblique pitch can diverge from an exact finite-difference window projection; a regression test pins that known regime, and field evidence decides whether to adopt exact projection with the basis method as fallback. Fleet rotations refresh on camera-pose change; tracked entities per frame. Billboards are horizon-culled via a shared EllipsoidalOccluder.
- **Military/OpenSky reconciliation** (`src/data/militaryRegistry.js`): known-military ICAOs render amber in the flights layer (60s self-poll of the cached mil endpoint when the military layer is off) and are suppressed there while the military layer renders them.
- **Satellites**: 838-sat core catalog (stations/visual/GPS/GLONASS/Galileo/GEO), tracking lands ~726km out via `viewFrom` (tracked entity owns a point graphic so the tracking camera engages), rings realign via primitive modelMatrix (no per-second rebuild flicker), optional `setParams({catalog:'dense'})` Starlink mode.
@@ -5483,7 +5483,7 @@ silently demoting every later lookup for the session.
Mission world text has no native `LabelGraphics`: overview launch markers publish at most 48 ambient candidates for a 24-winner budget, while selecting a mission clears that overview source and publishes its launch-site callout, stage re-entry annotations, live/estimated payload-position readout, and orbit annotation as protected selected-lane entries. The source retains the exact former strings and colors. Static anchors reuse the Cartesian values used to build their mission geometry; the moving payload entry reads the layer's per-frame live-position cache; catalog-backed orbit annotation positions are cached in the same one-second matrix update that realigns the orbit primitive. Keyhole edge fade, horizon culling, final collision placement, and UI exclusion are owned by the shared host. Deselect restores the bounded overview, and refresh, disable, and destroy replace or clear both mission sources.
A selected mission renders its orbit as four repeating tactical sectors, each containing one prominent cyan dot followed by one hundred thin translucent dashes. The bright dots act as orbit anchors while the subdued dash field remains depth-tested against the globe and is shown only for the selected mission.
Close selected-pad views add one static 500 m-radius cyan launch-zone ring with a low-opacity translucent fill over the sampled photoreal launch-site surface. The single scene primitive is created only for the visible selected site and is otherwise dormant. It appears during Focus, sufficiently close manual zoom, and the replay countdown, but is suppressed above 120 km camera altitude, beyond 180 km direct camera-to-pad range, for unselected missions, and whenever Space Missions is inactive. Focus establishes a launch-site-centered camera transform once; subsequent manual heading and pitch changes remain centered on that site without an automated per-frame correction. Surface mission markers and labels use an additional conservative globe-limb margin before the exact ellipsoid occluder boundary, preventing near-horizon visibility from alternating between frames.
- **AIS vessels**: chevron symbology (naval cyan base, type tints), world-space headings, MMSI-keyed reconciliation (selection survives refreshes; pinned 3 refreshes with STALE marker when absent), detection-overlay integration (`type: 'SEA'`), contextStore registration for voice Q&A. Empty-space clicks, id-less photorealistic-tile picks, and Escape dismiss the vessel card/HUD/context and clear its trail; picks owned by another layer (including `gev-trail:*`) and raw vessel-record picks without a live MMSI key are no-ops for vessel selection. Click and key handlers detach while the layer is disabled and reinstall on enable. Selecting another vessel replaces the selection and trail, and reconciliation clears a trail if its owning vessel is evicted.
- **AIS vessels**: chevron symbology (naval cyan base, type tints), world-space headings, MMSI-keyed reconciliation (selection survives refreshes; pinned 3 refreshes with STALE marker when absent), detection-overlay integration (`type: 'SEA'`; a vessel's callout fades by the painted opacity of its own ambient card, so a card dropped by budget, collisions or UI chrome, or faded at the keyhole rim, leaves the callout readable), contextStore registration for voice Q&A. Empty-space clicks, id-less photorealistic-tile picks, and Escape dismiss the vessel card/HUD/context and clear its trail; picks owned by another layer (including `gev-trail:*`) and raw vessel-record picks without a live MMSI key are no-ops for vessel selection. Click and key handlers detach while the layer is disabled and reinstall on enable. Selecting another vessel replaces the selection and trail, and reconciliation clears a trail if its owning vessel is evicted.
- **Track trails**: server accumulates per-MMSI ring buffers (`/api/vessels/track?mmsi=`, Float32+Uint32, 64 samples, 30s/25m thinning); aircraft backfill proxies `/api/flights/track` (OAuth, own credit bucket). Military aircraft keep only their locally accumulated trails: `/api/military/track` validates the hex, then answers `503 history_unavailable` without an upstream request.
- Shared `src/data/pickRegistry.js` stops the two flight layers' click handlers from fighting over the camera.
+1
View File
@@ -347,6 +347,7 @@
"src/layers/flights/tracking.js",
"src/layers/flights/rendering.js",
"src/layers/flights/testing.js",
"src/layers/flights/hiddenContactRotation.test.mjs",
"src/layers/flights/evidence.js",
"src/layers/flights/ingestion.js",
"src/layers/flights/queries.js",
+39
View File
@@ -1786,3 +1786,42 @@ test('vessel focus wire skips hidden sprites at rest while a target is tracked',
assert.equal(result.activeCount, 1);
for (const billboard of hidden) assert.equal(billboard.color.alpha, 1);
});
test('detection objects name the vessel card their callout gives way to', () => {
const vessel = (mmsi, name) => ({
name,
mmsi,
type: 'Cargo',
destination: '',
speed: 9.5,
course: 80,
heading: 80,
lastPositionUtc: '',
missedRefreshes: 0,
position: POS,
billboard: null,
});
const keyed = vessel('200', 'GOLDEN BEAR');
const unkeyed = { ...vessel('', 'NO MMSI'), lat: 37.8, lon: -122.4 };
_setVesselStateForTest({
viewer: {},
records: [keyed, unkeyed],
billboardCollection: { show: true, remove() {} },
});
try {
const objects = Object.fromEntries(
aisLiveVesselsLayer
.getDetectableObjects()
.map((object) => [object.id, object]),
);
assert.equal(objects['GOLDEN BEAR'].cardSourceId, 'ais-live-vessels');
assert.equal(objects['GOLDEN BEAR'].cardEntryId, 'vessel:200');
assert.equal(
objects['NO MMSI'].cardEntryId,
'vessel:unkeyed:NO MMSI:37.80000:-122.40000',
'the same identity the vessel card is published under',
);
} finally {
_setVesselStateForTest({ enabled: false });
}
});
+24 -3
View File
@@ -21,7 +21,10 @@ import {
getKeyholeFadeTuning,
keyholeLabelAlphaFromGeometry,
} from '../celestialRing.js';
import { registerWorldOverlayPaintLane } from '../overlays/worldOverlay.js';
import {
getQueuedOverlayEntryAlpha,
registerWorldOverlayPaintLane,
} from '../overlays/worldOverlay.js';
import {
DETECTION_STYLE,
DETECTION_THEME_MAP,
@@ -107,6 +110,8 @@ const THEME_MAP = DETECTION_THEME_MAP;
const SPARSE_ZONE_FRACTION = 0.5;
/** Solve cadence for membership/collision; accepted identities still reproject every frame. */
const LABEL_SOLVE_INTERVAL_MS = 125;
/** Own-card opacity that fully hides a callout; below it the callout fades in. */
const CARD_ALPHA_HIDING_CALLOUT = 0.5;
/** Alpha bands retain bracket batching while approximating a continuous radial fade. */
const BRACKET_ALPHA_STEPS = 4;
/** Finer bands for Global Context echo brackets, whose fade is animated. */
@@ -576,7 +581,8 @@ export function getDetectionDiagnostics() {
// sprites/strokes. Nothing is copied on the production paint path.
result.calloutRects = _calloutPool
.slice(0, _calloutCount)
.map(({ x, y, w, h, alpha }) => ({ x, y, w, h, alpha }));
.filter((row) => row.paintAlpha > 0.001)
.map(({ x, y, w, h, paintAlpha }) => ({ x, y, w, h, alpha: paintAlpha }));
return result;
}
@@ -1077,6 +1083,9 @@ function _stashCallout(entry, acquireFade, keyhole) {
font: FONT,
microFont: MICRO_FONT,
alpha: 1,
cardSourceId: '',
cardEntryId: '',
paintAlpha: 1,
};
_calloutPool[_calloutCount] = row;
}
@@ -1103,6 +1112,8 @@ function _stashCallout(entry, acquireFade, keyhole) {
row.font = FONT;
row.microFont = MICRO_FONT;
row.alpha = alpha;
row.cardSourceId = candidate.cardSourceId;
row.cardEntryId = candidate.cardEntryId;
_calloutCount++;
}
@@ -1134,7 +1145,15 @@ function _paintCalloutLane(frame) {
ctx.textBaseline = 'alphabetic';
for (let i = 0; i < _calloutCount; i++) {
const row = _calloutPool[i];
paintDetectionCallout(ctx, row, row.alpha);
// The callout would show through its contact's own card: fade it by the
// opacity that card paints with this frame.
const cardAlpha =
row.cardEntryId === ''
? 0
: getQueuedOverlayEntryAlpha(row.cardSourceId, row.cardEntryId);
row.paintAlpha =
row.alpha * Math.max(0, 1 - cardAlpha / CARD_ALPHA_HIDING_CALLOUT);
if (row.paintAlpha > 0.001) paintDetectionCallout(ctx, row, row.paintAlpha);
}
ctx.globalAlpha = 1;
}
@@ -1211,6 +1230,8 @@ function _materializeCandidate(
primary,
micro,
hasMicro: card.hasMicro,
cardSourceId: obj.cardSourceId ? String(obj.cardSourceId) : '',
cardEntryId: obj.cardEntryId ? String(obj.cardEntryId) : '',
};
}
+179
View File
@@ -20,9 +20,16 @@ import {
} from './detection.js';
import {
destroyWorldOverlay,
getOverlayPaintRect,
initWorldOverlay,
setOverlayEntries,
} from '../overlays/worldOverlay.js';
import { applyVesselOverlayPolicy } from './vesselLabels.js';
import {
getKeyholeFadeTuning,
getKeyholeGeometry,
keyholeLabelAlphaFromGeometry,
} from '../celestialRing.js';
import { DETECTION_THEME_MAP } from '../overlays/worldOverlayTokens.js';
import {
clearAwarenessEchoField,
@@ -579,6 +586,178 @@ test('every style paints its OWN plate token through the production callout path
}
});
/** A vessel detection contact that names its ambient card, like the AIS layer does. */
function shipWithCard(mmsi, position) {
return {
position,
sourceId: mmsi,
id: `SHIP-${mmsi}`,
metric: '6 kn',
type: 'SEA',
cardSourceId: 'ais-live-vessels',
cardEntryId: `vessel:${mmsi}`,
};
}
/** The production ambient vessel card (not protected: it goes through the arbiter). */
function ambientVesselCard(mmsi, position) {
return applyVesselOverlayPolicy({
id: `vessel:${mmsi}`,
position,
title: `CARD-${mmsi}`,
details: ['CARGO · 6KT · 80°'],
gapPx: 10,
});
}
test('a callout stays readable when its own card is faded out at the keyhole rim', () => {
// Default keyhole tuning: 7% feather, 1% outside opacity.
assert.deepEqual(getKeyholeFadeTuning(), { fadeRatio: 0.07, outsideOpacity: 0.01 });
const width = 1600;
const height = 900;
const keyhole = getKeyholeGeometry(width, height);
const env = installEnvironment({ width, height, dpr: 1 });
// Just outside the keyhole's left rim: the card sits above or below the
// anchor (outside, 1% alpha), the callout to its right (inside the feather).
const position = new Cesium.Cartesian3(270 / (width / 2) - 1, 1 - 460 / (height / 2), 6_356_752);
const ship = shipWithCard('200', position);
try {
initWorldOverlay(env.viewer);
initDetection(env.viewer, [{ id: 'ais-live-vessels', getDetectableObjects: () => [ship] }], () => {});
setMode('DENSE');
for (let i = 0; i < 4; i++) settleFrame(env);
const [alone] = getDetectionDiagnostics().calloutRects;
assert.ok(alone && alone.alpha >= 0.5, 'without a card the callout is clearly visible');
setOverlayEntries('ais-live-vessels', [ambientVesselCard('200', position)]);
for (let i = 0; i < 4; i++) settleFrame(env);
const rect = getOverlayPaintRect('ais-live-vessels', 'vessel:200');
assert.ok(rect, 'the host paints the card (at the outside opacity)');
const cardAlpha = keyholeLabelAlphaFromGeometry(rect.x + rect.w / 2, rect.y + rect.h / 2, keyhole);
assert.ok(cardAlpha <= 0.05, `the card is invisible at the rim (keyhole alpha ${cardAlpha})`);
const callouts = getDetectionDiagnostics().calloutRects;
assert.equal(callouts.length, 1, 'the callout keeps painting while its card is faded out');
assert.ok(callouts[0].alpha >= 0.4, 'and stays readable');
} finally {
destroyDetection();
destroyWorldOverlay();
env.cleanup();
}
});
test('a callout stays hidden while its card fades out after leaving the published cohort', () => {
const env = installEnvironment({ width: 1600, height: 900, dpr: 1 });
const at = (x, y) => new Cesium.Cartesian3(x, y, 6_356_752);
const ship = shipWithCard('200', at(0.1, 0.1));
const painted = (text) => env.ctx.calls.some(([name, value]) => name === 'fillText' && value === text);
const frame = (ms) => {
env.ctx.calls.length = 0;
env.advance(ms);
env.postRender.raise();
};
try {
initWorldOverlay(env.viewer);
initDetection(env.viewer, [{ id: 'ais-live-vessels', getDetectableObjects: () => [ship] }], () => {});
setMode('DENSE');
setOverlayEntries('ais-live-vessels', [ambientVesselCard('200', ship.position)]);
for (let i = 0; i < 6; i++) frame(250);
frame(16);
assert.ok(painted('CARD-200') && !painted('SHIP-200'), 'steady state: card only');
// The vessel layer republishes its screen-grid cohort after a camera move:
// this vessel's card is replaced by another one, the vessel stays detectable.
setOverlayEntries('ais-live-vessels', [ambientVesselCard('300', at(-0.5, -0.4))]);
frame(16);
assert.ok(getOverlayPaintRect('ais-live-vessels', 'vessel:200'), 'the dropped card is still fading out');
assert.ok(painted('CARD-200'), 'and its text still paints');
assert.ok(!painted('SHIP-200'), 'no full callout under the fading card');
for (let i = 0; i < 4; i++) frame(250);
assert.ok(!painted('CARD-200'), 'the card has faded out');
assert.ok(painted('SHIP-200'), 'and the callout is back');
} finally {
destroyDetection();
destroyWorldOverlay();
env.cleanup();
}
});
test('a vessel whose ambient card loses the collision keeps its callout', () => {
const env = installEnvironment({ width: 900, height: 600, dpr: 1 });
// Four vessels a few pixels apart: their cards collide (a card goes only
// above or below its anchor), so the arbiter paints only some of them.
const ships = [0, 1, 2, 3].map((i) =>
shipWithCard(String(100 + i), new Cesium.Cartesian3(-0.2 + i * 0.005, i * 0.005, 6_356_752)),
);
const painted = (text) => env.ctx.calls.some(([name, value]) => name === 'fillText' && value === text);
try {
initWorldOverlay(env.viewer);
initDetection(env.viewer, [{ id: 'ais-live-vessels', getDetectableObjects: () => ships }], () => {});
setOverlayEntries(
'ais-live-vessels',
ships.map((ship) => ambientVesselCard(ship.sourceId, ship.position)),
);
setMode('DENSE');
for (let i = 0; i < 4; i++) settleFrame(env);
env.ctx.calls.length = 0;
settleFrame(env);
const winners = ships.filter((ship) => getOverlayPaintRect('ais-live-vessels', ship.cardEntryId));
assert.ok(
winners.length >= 1 && winners.length < ships.length,
`the colliding cards leave some unpainted (${winners.length} of ${ships.length} painted)`,
);
for (const ship of ships) {
const carded = winners.includes(ship);
assert.equal(
painted(ship.id),
!carded,
carded ? `${ship.id} draws no callout under its card` : `${ship.id} keeps its callout`,
);
}
assert.equal(
getDetectionDiagnostics().calloutRects.length,
ships.length - winners.length,
'only the callouts that painted are reported',
);
} finally {
destroyDetection();
destroyWorldOverlay();
env.cleanup();
}
});
test('a callout fades out while its card fades in instead of vanishing at once', () => {
const env = installEnvironment({ width: 1600, height: 900, dpr: 1 });
const ship = shipWithCard('200', new Cesium.Cartesian3(0.1, 0.1, 6_356_752));
try {
initWorldOverlay(env.viewer);
initDetection(env.viewer, [{ id: 'ais-live-vessels', getDetectableObjects: () => [ship] }], () => {});
setMode('DENSE');
for (let i = 0; i < 4; i++) settleFrame(env);
const [alone] = getDetectionDiagnostics().calloutRects;
assert.ok(alone.alpha >= 0.9, 'the callout starts fully visible');
setOverlayEntries('ais-live-vessels', [ambientVesselCard('200', ship.position)]);
const alphas = [];
for (let i = 0; i < 30; i++) {
env.advance(16);
env.postRender.raise();
alphas.push(getDetectionDiagnostics().calloutRects[0]?.alpha ?? 0);
}
assert.ok(
alphas.some((alpha) => alpha > 0.1 && alpha < alone.alpha - 0.1),
`the callout passes through partial opacity: ${alphas.map((a) => a.toFixed(2)).join(' ')}`,
);
assert.equal(alphas.at(-1), 0, 'and is gone once the card is fully in');
} finally {
destroyDetection();
destroyWorldOverlay();
env.cleanup();
}
});
test('space-tier callouts take the heavier plate while air contacts keep the light one', () => {
// Satellites sit over the high-albedo lit Earth disc far more often than
// aircraft do, so the space tier carries the heavier backing. Losing the
@@ -0,0 +1,163 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { createFlightState } from './state.js';
import { createRendering } from './rendering.js';
import {
FLEET_DR_INTERVAL_MS,
HIDDEN_CONTACT_DR_INTERVAL_MS,
HIDDEN_CONTACT_DR_TICKS,
hiddenContactDrPhase,
} from './policy.js';
// Contacts hidden behind the horizon re-reckon on one fleet tick per rotation
// instead of every tick. Their positions still feed hidden-inclusive proximity
// queries, so the rotation must reach every contact about once a second, and
// it must spread them across ticks so no single tick carries the whole fleet.
const icao = (n) => n.toString(16).padStart(6, '0');
test('the rotation revisits each hidden contact about once a second', () => {
const periodMs = HIDDEN_CONTACT_DR_TICKS * FLEET_DR_INTERVAL_MS;
assert.ok(HIDDEN_CONTACT_DR_TICKS > 1);
assert.ok(
Math.abs(periodMs - HIDDEN_CONTACT_DR_INTERVAL_MS) <= FLEET_DR_INTERVAL_MS,
);
});
test('a contact keeps the same slot, inside the rotation', () => {
for (let n = 0; n < 500; n++) {
const phase = hiddenContactDrPhase(icao(n * 7919));
assert.ok(Number.isInteger(phase));
assert.ok(phase >= 0 && phase < HIDDEN_CONTACT_DR_TICKS);
assert.equal(hiddenContactDrPhase(icao(n * 7919)), phase);
}
});
test('a worldwide fleet is spread evenly enough that no tick carries a burst', () => {
const fleet = 13000;
const perSlot = new Array(HIDDEN_CONTACT_DR_TICKS).fill(0);
// Real ICAO24 addresses cluster by national block; sample several blocks.
for (let n = 0; n < fleet; n++)
perSlot[hiddenContactDrPhase(icao(0x0a0000 + n * 53))]++;
const fair = fleet / HIDDEN_CONTACT_DR_TICKS;
for (const count of perSlot) assert.ok(count < fair * 1.5, `${count} > 1.5×`);
});
/** Real fleet tick and horizon culling, with motion and presentation injected. */
function fleetFixture() {
let now = 1000;
let reckonings = 0;
const services = {
groundSnap: { createGroundSnap: () => ({ clear() {} }) },
militaryRegistry: { isMilitaryIcao: () => false },
aircraftPresentation: { isTr3b: () => false },
camera: {},
focus: {
focusNowMs: () => now,
getFocusTarget: () => null,
nearFarScalarValueAtDistance: () => 1,
advanceProjectedSpriteFocus: () => ({ factor: 1 }),
},
recession: {
applyAircraftBillboardTreatment: () => ({
factors: { scale: 1 },
alpha: 1,
}),
},
};
const flightState = createFlightState({ services });
const camera = {
positionWC: Cesium.Cartesian3.fromDegrees(0, 0, 100),
positionCartographic: { height: 100 },
heading: 0,
pitch: 0,
roll: 0,
frustum: {
computeCullingVolume: () => ({
computeVisibility: () => Cesium.Intersect.INSIDE,
}),
},
};
const id = 'abc123';
// 111 km away at ground level is beyond a 100 m camera's horizon, but
// inside the model keep radius. No WebGL scene is needed for the culler.
const billboard = {
show: false,
position: Cesium.Cartesian3.fromDegrees(1, 0, 0),
};
flightState._viewer = { camera, scene: {} };
flightState._billboardCollection = { show: true };
flightState._billboards.set(id, billboard);
flightState.records.data.set(id, {});
flightState._models3dEnabled = false;
// Far cockpit contacts avoid glyph rasterization, keeping the fixture DOM-free.
flightState._cockpitContactMode = true;
const parts = {
tracking: { _refreshCockpitNearContacts() {} },
motion: {
_deadReckon() {
reckonings++;
return billboard.position;
},
_floorGroundedDisplayPosition(id, info, position) {
return position;
},
},
};
const rendering = createRendering({ flightState, services, parts });
return {
flightState,
camera,
billboard,
id,
reckonings: () => reckonings,
tick() {
now += FLEET_DR_INTERVAL_MS;
rendering._fleetTick();
},
offTurn() {
flightState._fleetTickSeq =
(hiddenContactDrPhase(id) + 1) % HIDDEN_CONTACT_DR_TICKS;
},
};
}
test('the fleet tick dead-reckons a hidden contact once per complete rotation', () => {
const f = fleetFixture();
for (let i = 0; i < HIDDEN_CONTACT_DR_TICKS; i++) f.tick();
assert.equal(f.reckonings(), 1);
assert.equal(f.billboard.show, false);
});
test('a camera reveal bypasses the hidden rotation on the same fleet tick', () => {
const f = fleetFixture();
f.offTurn();
f.tick();
assert.equal(f.reckonings(), 0);
f.camera.positionWC = Cesium.Cartesian3.fromDegrees(1, 0, 100);
f.offTurn();
f.tick();
assert.equal(f.reckonings(), 1);
assert.equal(f.billboard.show, true);
});
test('a hidden contact with a retained fleet model is never rotation-skipped', () => {
const f = fleetFixture();
f.flightState._models3dEnabled = true;
const model = { ready: true, show: true };
f.flightState._models.set(f.id, model);
f.offTurn();
f.tick();
assert.equal(f.reckonings(), 1);
assert.equal(
f.flightState._models.get(f.id),
model,
'model remains in keep range',
);
assert.equal(
model.show,
false,
'the full pass hides its model beyond the horizon',
);
});
+31
View File
@@ -198,6 +198,37 @@ export const FLEET_DR_INTERVAL_MS = 80;
export const ROTATION_REFRESH_MS = 1000;
/**
* @constant {number} Dead-reckoning cadence (ms) for contacts hidden behind the
* horizon. The worldwide feed carries ~13k contacts and a zoomed-in view shows
* well under 1% of them; the rest still need positions for hidden-inclusive
* proximity queries, but ~1 s old (≈250 m for an airliner) is well inside the
* 30 s poll's own uncertainty. Each hidden contact is re-reckoned on one tick in
* every HIDDEN_CONTACT_DR_TICKS, staggered by id so no tick carries them all.
*/
export const HIDDEN_CONTACT_DR_INTERVAL_MS = 1000;
export const HIDDEN_CONTACT_DR_TICKS = Math.max(
1,
Math.round(HIDDEN_CONTACT_DR_INTERVAL_MS / FLEET_DR_INTERVAL_MS),
);
/**
* Stable slot in [0, HIDDEN_CONTACT_DR_TICKS) for a contact id, so each hidden
* contact re-reckons on the same tick of every rotation.
* @param {string} id Contact id (ICAO24).
* @returns {number}
*/
export function hiddenContactDrPhase(id) {
const text = String(id);
let hash = 0;
for (let i = 0; i < text.length; i++)
hash = (hash * 31 + text.charCodeAt(i)) >>> 0;
return hash % HIDDEN_CONTACT_DR_TICKS;
}
/** Max course slew (deg/s) — well above real turns (≤4°/s), hides fix-boundary
* steps. Scaled down toward COURSE_MIN_DPS at low speed (courseSlewCapDps). */
+24
View File
@@ -36,6 +36,8 @@ import {
COURSE_SLEW_DT_MAX_SEC,
ROTATION_REFRESH_MS,
COURSE_MAX_DPS,
HIDDEN_CONTACT_DR_TICKS,
hiddenContactDrPhase,
} from './policy.js';
import { AIRCRAFT_ENVIRONMENT_MAP_OPTIONS } from '../../data/aircraftModelLighting.js';
@@ -839,6 +841,12 @@ export function createRendering({
}
}
/** The contact's hidden-rotation slot, cached on its billboard. */
function _hiddenDrPhase(bb, icao24) {
bb._gevHiddenDrPhase ??= hiddenContactDrPhase(icao24);
return bb._gevHiddenDrPhase;
}
function _fleetTick() {
if (
!flightState._viewer ||
@@ -965,9 +973,25 @@ export function createRendering({
for (const icao of toRelease) _releaseModel(icao);
}
const hiddenDrPhase = flightState._fleetTickSeq++ % HIDDEN_CONTACT_DR_TICKS;
for (const [icao24, bb] of flightState._billboards) {
if (icao24 === flightState._trackedIcao) continue; // tracked entity owns its own motion
// A contact still behind the horizon at its last position would only be
// re-reckoned to be hidden again, so off its turn it keeps that position
// (see HIDDEN_CONTACT_DR_TICKS). Anything the stale position puts in view,
// and any contact with a fleet model, takes the full pass below at once.
if (
!bb.show &&
_hiddenDrPhase(bb, icao24) !== hiddenDrPhase &&
!flightState._models.has(icao24) &&
!occluder.isPointVisible(
flightState._cullPositions.get(icao24) || bb.position,
)
)
continue;
const info = flightState.records.data.get(icao24);
const dr = parts.motion._deadReckon(
+4
View File
@@ -204,6 +204,10 @@ export function createFlightState({ source, services }) {
flightState._lastFleetTickMs = 0;
/** @type {number} Fleet ticks run; selects which hidden contacts re-reckon */
flightState._fleetTickSeq = 0;
/** @type {string} Camera pose signature at the last rotation pass */
flightState._lastCamPoseSig = '';
+5 -1
View File
@@ -21,6 +21,7 @@ import {
REFRESH_MS,
FOCUS_EVIDENCE_DEV,
} from './policy.js';
import { VESSEL_OVERLAY_SOURCE_ID } from '../../data/vesselLabels.js';
export function createQueries({
vesselState,
@@ -469,7 +470,7 @@ export function createQueries({
* @param {Object} [options={}] - Options from the detection system.
* @param {number} [options.maxCount] - Maximum objects to return (defaults to all).
* @param {number} [options.seed] - Seed offset for stride sampling.
* @returns {Array<{position: Cesium.Cartesian3, id: string, type: string, skipLabel: boolean}>}
* @returns {Array<{position: Cesium.Cartesian3, id: string, type: string, skipLabel: boolean, cardSourceId: string, cardEntryId: string}>}
*/
getDetectableObjects(options = {}) {
if (
@@ -504,6 +505,9 @@ export function createQueries({
id: record.name || record.mmsi || 'VESSEL',
type: 'SEA',
skipLabel: record === selected,
// Detection hides the callout while this card paints.
cardSourceId: VESSEL_OVERLAY_SOURCE_ID,
cardEntryId: components.cards.vesselOverlayEntryId(record),
klass: record.type
? String(record.type).toUpperCase().slice(0, 14)
: undefined,
+73 -14
View File
@@ -212,6 +212,10 @@ const _paintQueue = [];
const _paintItemPool = [];
const _paintRectPool = [];
const _paintRectByKey = new Map();
/** @type {Map<string, Map<string, object>>} This frame's paint items by source and entry id. */
const _queuedItemBySource = new Map();
/** Index rows that stop painting are dropped this many frames apart. */
const QUEUED_ITEM_PRUNE_FRAMES = 600;
const _hitRects = [];
const _paintedBySource = Object.create(null);
const _paintedSourceKeys = [];
@@ -1042,6 +1046,30 @@ export function getOverlayPaintRect(sourceId, entryId) {
return rect && rect.key === key && rect.stamp === _frameStamp ? rect : null;
}
/**
* Opacity an entry paints with this frame, or 0 when it is not queued. Custom
* lanes may call it before later lanes publish paint rects. Leaves out the
* Cyber sonar fade and yield-collision hiding.
* @param {string} sourceId
* @param {string} entryId
* @returns {number}
*/
export function getQueuedOverlayEntryAlpha(sourceId, entryId) {
if (_destroyed) return 0;
const item = _queuedItemBySource.get(sourceId)?.get(entryId);
// Pooled items are reassigned every frame: check the frame and the entry.
if (!item || item.stamp !== _frameStamp) return 0;
const record = item.record;
if (record.entry.id !== entryId || record.entry.source !== sourceId) return 0;
return (
record.sourceAlpha *
item.temporalAlpha *
record.distanceAlpha *
record.altitudeAlpha *
(_keyhole ? entryKeyholeAlpha(record.entry, item.placement, _keyhole) : 1)
);
}
/**
* Resolve the topmost interactive world-overlay entry at CSS-pixel coords.
* @param {number} x
@@ -2223,7 +2251,34 @@ function addPaintItem(record, placement, temporalAlpha, selected) {
item.zIndex = record.entry.zIndex;
item.key = record.key;
item.collisionHidden = false;
item.stamp = _frameStamp;
_paintQueue[_paintCount++] = item;
// Keyed by source and id, not `record.key`, so a lookup builds no string.
// Also holds cards fading out after they left `_records`.
let bySource = _queuedItemBySource.get(record.entry.source);
if (!bySource) {
bySource = new Map();
_queuedItemBySource.set(record.entry.source, bySource);
}
bySource.set(record.entry.id, item);
}
/** Drop index rows for entries that stopped painting (bounded, not per frame). */
function pruneQueuedItemIndex() {
for (const [sourceId, bySource] of _queuedItemBySource) {
for (const [entryId, item] of bySource) {
// A pooled item that now paints another entry keeps a fresh stamp.
const entry = item.record.entry;
if (
item.stamp !== _frameStamp ||
entry.id !== entryId ||
entry.source !== sourceId
) {
bySource.delete(entryId);
}
}
if (bySource.size === 0) _queuedItemBySource.delete(sourceId);
}
}
function sourceCanPaint(sourceId) {
@@ -2295,6 +2350,7 @@ function solveDomains(timestamp) {
solveRevision += domain.arbiter.solveRevision;
}
_paintQueue.length = _paintCount;
if (_frameStamp % QUEUED_ITEM_PRUNE_FRAMES === 0) pruneQueuedItemIndex();
_solveDirty = false;
_diagnostics.solveMs = solveMs;
_diagnostics.selectedCount = selectedCount;
@@ -2423,24 +2479,26 @@ function paintCustomLane(lane) {
}
}
/** Keyhole edge fade of one placed entry (1 for entries that do not fade). */
function entryKeyholeAlpha(entry, placement, keyhole) {
if (entry.edgeFade !== 'keyhole') return 1;
const keyholeX = placement.centerX - keyhole.centerX;
const keyholeY = placement.centerY - keyhole.centerY;
return keyholeX * keyholeX + keyholeY * keyholeY <=
keyhole.radius * keyhole.radius
? 1
: keyholeLabelAlphaFromGeometry(
placement.centerX,
placement.centerY,
keyhole,
);
}
function paintEntryItem(item, keyhole, sonar) {
if (item.collisionHidden) return;
const { record, placement } = item;
const entry = record.entry;
let keyholeAlpha = 1;
if (entry.edgeFade === 'keyhole') {
const keyholeX = placement.centerX - keyhole.centerX;
const keyholeY = placement.centerY - keyhole.centerY;
keyholeAlpha =
keyholeX * keyholeX + keyholeY * keyholeY <=
keyhole.radius * keyhole.radius
? 1
: keyholeLabelAlphaFromGeometry(
placement.centerX,
placement.centerY,
keyhole,
);
}
const keyholeAlpha = entryKeyholeAlpha(entry, placement, keyhole);
// All five channels are normalized at their source. Keep the multiply on
// the hot paint path so its intermediate doubles remain unboxed; the pure
// `combinedOverlayAlpha` export still specifies/tests the same binding.
@@ -2829,6 +2887,7 @@ export function destroyWorldOverlay() {
_accessibleActivatorByKey.clear();
_accessibilitySignature = '';
_paintRectByKey.clear();
_queuedItemBySource.clear();
_paintCount = 0;
_paintRectCount = 0;
_hitRectCount = 0;
+83
View File
@@ -33,6 +33,7 @@ import {
activateWorldOverlayAction,
initWorldOverlay,
subscribeWorldOverlayActions,
getQueuedOverlayEntryAlpha,
isOverlayPointVisible,
normalizeOverlayEntry,
paintLaneForOverlayEntry,
@@ -1222,6 +1223,42 @@ test('custom detection lane receives the shared host frame and paints below ordi
env.cleanup();
});
test('a detection lane sees which cards this frame will paint before they publish paint rects', () => {
// The detection lane paints before the cards publish this frame's paint
// rects, so it reads the queued alpha instead.
const env = installMockEnvironment({ width: 400, height: 300, dpr: 1 });
initWorldOverlay(env.viewer);
const seen = [];
registerWorldOverlayPaintLane('detection', () => {
seen.push({
shown: getQueuedOverlayEntryAlpha('cards', 'SHOWN'),
hidden: getQueuedOverlayEntryAlpha('hidden-cards', 'HIDDEN'),
unknown: getQueuedOverlayEntryAlpha('cards', 'NOT-PUBLISHED'),
rect: getOverlayPaintRect('cards', 'SHOWN'),
});
}, { id: 'detection', active: true, target: 'detection' });
setOverlayEntries('cards', [selectedEntry('SHOWN', {
position: positionAtScreen(200, 150),
})]);
setOverlayEntries('hidden-cards', [selectedEntry('HIDDEN', {
position: positionAtScreen(100, 100),
})]);
setOverlaySourceVisible('hidden-cards', false);
env.postRender.raise();
assert.deepEqual(seen[0], { shown: 1, hidden: 0, unknown: 0, rect: null },
'the card is queued while its paint rect is still unpublished');
assert.ok(getOverlayPaintRect('cards', 'SHOWN'), 'the card did paint after the lane');
assert.equal(getOverlayPaintRect('hidden-cards', 'HIDDEN'), null);
clearOverlaySource('cards');
env.postRender.raise();
assert.equal(seen[1].shown, 0, 'a card cleared since the last frame is no longer queued');
destroyWorldOverlay();
assert.equal(getQueuedOverlayEntryAlpha('cards', 'SHOWN'), 0);
env.cleanup();
});
test('custom-lane painter state is bracketed by host save/restore', () => {
// A painter that leaks a clip or alpha must not corrupt later frames: the
// host wraps every custom-lane painter in save/try/finally-restore. Pin the
@@ -2489,3 +2526,49 @@ test('promoting an ambient entry to selected paints it once with no fading dupli
assert.equal(paint(), 1, 'deselect restores the ambient card without re-entry cooldown');
} finally { env.cleanup(); }
});
test('the queued-card index stays bounded while card identities churn', () => {
// The index is internal: capture it through Map.prototype.set, like the
// projection-record test.
const originalSet = Map.prototype.set;
let queuedIndex = null;
Map.prototype.set = function captureQueuedIndex(key, value) {
if (typeof key === 'string' && key.startsWith('churn-card-')
&& value && typeof value === 'object' && 'temporalAlpha' in value
&& 'placement' in value && 'stamp' in value) {
queuedIndex = this;
}
return originalSet.call(this, key, value);
};
const env = installMockEnvironment({ width: 400, height: 300, dpr: 1 });
try {
initWorldOverlay(env.viewer);
const FRAMES = 1300;
for (let frame = 0; frame < FRAMES; frame++) {
// One card whose id changes every frame reuses one pooled paint item,
// like a vessel cohort republished as the camera moves.
setOverlayEntries('cards', [selectedEntry(`churn-card-${frame}`, {
position: positionAtScreen(200, 150),
})]);
env.advanceTime(16);
env.postRender.raise();
assert.equal(getQueuedOverlayEntryAlpha('cards', `churn-card-${frame}`), 1,
'the live card is queued in the frame just painted');
if (frame > 0) {
assert.equal(getQueuedOverlayEntryAlpha('cards', `churn-card-${frame - 1}`), 0,
'a stale row never reports the previous card as queued');
}
}
assert.ok(queuedIndex, 'the queued-card index was never populated');
// Rows are pruned every 600 painted frames, so with one new id per frame
// the index holds at most one prune window of ids plus the live card.
const bound = 600 + 1;
assert.ok(
queuedIndex.size <= bound,
`queued-card index holds ${queuedIndex.size} rows after ${FRAMES} identities (bound ${bound})`,
);
} finally {
Map.prototype.set = originalSet;
env.cleanup();
}
});
@@ -1217,6 +1217,9 @@ function buildPhase6DetectionWorkload(count) {
: `FL${String(180 + (index % 220)).padStart(3, '0')}`,
type: ['AIR', 'SAT', 'SEA', 'VEH'][index % 4],
tier: index % 17 === 0 ? 'military' : undefined,
// Vessels name their ambient card, so each callout asks the host for it.
cardSourceId: index % 4 === 2 ? 'ais-live-vessels' : undefined,
cardEntryId: index % 4 === 2 ? `vessel:${index}` : undefined,
});
}
return {