Compare commits

...
Author SHA1 Message Date
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
8 changed files with 200 additions and 4 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).
+1 -1
View File
@@ -5322,7 +5322,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 whose ambient card the shared host paints in the current frame draws no detection callout, because the callout would show through the card's plate, while a vessel whose card was published but not painted (shared budget, collisions, UI chrome) keeps its callout — its detectable object names the card in `cardSourceId`/`cardEntryId`, and the callout lane asks `isOverlayEntryQueued` in `src/overlays/worldOverlay.js`, which answers from the solved paint queue because the detection lane paints before the cards publish their paint rects), 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.
+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 });
}
});
+22 -2
View File
@@ -21,7 +21,10 @@ import {
getKeyholeFadeTuning,
keyholeLabelAlphaFromGeometry,
} from '../celestialRing.js';
import { registerWorldOverlayPaintLane } from '../overlays/worldOverlay.js';
import {
isOverlayEntryQueued,
registerWorldOverlayPaintLane,
} from '../overlays/worldOverlay.js';
import {
DETECTION_STYLE,
DETECTION_THEME_MAP,
@@ -576,6 +579,7 @@ export function getDetectionDiagnostics() {
// sprites/strokes. Nothing is copied on the production paint path.
result.calloutRects = _calloutPool
.slice(0, _calloutCount)
.filter((row) => !row.yielded)
.map(({ x, y, w, h, alpha }) => ({ x, y, w, h, alpha }));
return result;
}
@@ -1077,6 +1081,9 @@ function _stashCallout(entry, acquireFade, keyhole) {
font: FONT,
microFont: MICRO_FONT,
alpha: 1,
cardSourceId: '',
cardEntryId: '',
yielded: false,
};
_calloutPool[_calloutCount] = row;
}
@@ -1103,6 +1110,9 @@ function _stashCallout(entry, acquireFade, keyhole) {
row.font = FONT;
row.microFont = MICRO_FONT;
row.alpha = alpha;
row.cardSourceId = candidate.cardSourceId;
row.cardEntryId = candidate.cardEntryId;
row.yielded = false;
_calloutCount++;
}
@@ -1134,7 +1144,15 @@ function _paintCalloutLane(frame) {
ctx.textBaseline = 'alphabetic';
for (let i = 0; i < _calloutCount; i++) {
const row = _calloutPool[i];
paintDetectionCallout(ctx, row, row.alpha);
// The contact's own card (an ambient vessel card) already names it, and
// this callout would show through the card's plate. Checked every frame
// against the cards queued for THIS frame: the host paints only part of
// the published cards, and a contact whose card did not paint keeps its
// callout.
row.yielded =
row.cardEntryId !== '' &&
isOverlayEntryQueued(row.cardSourceId, row.cardEntryId);
if (!row.yielded) paintDetectionCallout(ctx, row, row.alpha);
}
ctx.globalAlpha = 1;
}
@@ -1211,6 +1229,8 @@ function _materializeCandidate(
primary,
micro,
hasMicro: card.hasMicro,
cardSourceId: obj.cardSourceId ? String(obj.cardSourceId) : '',
cardEntryId: obj.cardEntryId ? String(obj.cardEntryId) : '',
};
}
+62
View File
@@ -19,9 +19,11 @@ import {
suspendDetection,
} from './detection.js';
import {
clearOverlaySource,
destroyWorldOverlay,
initWorldOverlay,
setOverlayEntries,
setOverlaySourceVisible,
} from '../overlays/worldOverlay.js';
import { DETECTION_THEME_MAP } from '../overlays/worldOverlayTokens.js';
import {
@@ -579,6 +581,66 @@ test('every style paints its OWN plate token through the production callout path
}
});
test('a callout gives way to its contact\'s own card only while that card paints', () => {
// A vessel's ambient card already shows its name, speed and course, and the
// callout drawn beneath it shows through the card's plate. The host paints
// only part of the published cards (shared budget, collisions, UI chrome),
// so a contact whose card was published but not painted keeps its callout.
const env = installEnvironment({ width: 900, height: 600, dpr: 1 });
try {
const ships = [
{ x: -0.3, sourceId: 'carded', id: 'SHIP-CARDED', card: 'vessel:carded' },
{ x: 0.3, sourceId: 'dropped', id: 'SHIP-DROPPED', card: 'vessel:dropped' },
].map(({ x, sourceId, id, card }) => ({
position: new Cesium.Cartesian3(x, 0, 6_356_752),
sourceId,
id,
metric: '6 kn',
type: 'SEA',
cardSourceId: card === 'vessel:carded' ? 'vessel-cards' : 'dropped-cards',
cardEntryId: card,
}));
initWorldOverlay(env.viewer);
initDetection(env.viewer, [{ id: 'ais-live-vessels', getDetectableObjects: () => ships }], () => {});
const card = (id, title, x) => ({
id,
position: new Cesium.Cartesian3(x, 0, 6_356_752),
variant: 'label',
title,
selected: true,
protected: true,
horizonCull: false,
edgeFade: 'none',
});
setOverlayEntries('vessel-cards', [card('vessel:carded', 'CARD-CARDED', -0.3)]);
// Published, but the host does not paint it this frame.
setOverlayEntries('dropped-cards', [card('vessel:dropped', 'CARD-DROPPED', 0.3)]);
setOverlaySourceVisible('dropped-cards', false);
setMode('DENSE');
settleFrame(env);
env.ctx.calls.length = 0;
settleFrame(env);
const painted = (text) => env.ctx.calls.some(([name, value]) => name === 'fillText' && value === text);
assert.ok(painted('CARD-CARDED'), 'the fixture card reached the frame');
assert.ok(!painted('CARD-DROPPED'), 'the hidden source paints no card');
assert.ok(!painted('SHIP-CARDED'), 'no callout under the painted card');
assert.ok(painted('SHIP-DROPPED'), 'the contact whose card did not paint keeps its callout');
assert.equal(getDetectionDiagnostics().calloutRects.length, 1,
'only the callout that painted is reported');
// The card leaves the host: the callout comes back on the next frame.
clearOverlaySource('vessel-cards');
env.ctx.calls.length = 0;
settleFrame(env);
assert.ok(painted('SHIP-CARDED'), 'the callout returns once its card stops painting');
} 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
+6 -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,10 @@ export function createQueries({
id: record.name || record.mmsi || 'VESSEL',
type: 'SEA',
skipLabel: record === selected,
// The vessel's ambient card: while the overlay paints it, detection
// draws no callout under it.
cardSourceId: VESSEL_OVERLAY_SOURCE_ID,
cardEntryId: components.cards.vesselOverlayEntryId(record),
klass: record.type
? String(record.type).toUpperCase().slice(0, 14)
: undefined,
+29
View File
@@ -1065,6 +1065,28 @@ export function getOverlayPaintRect(sourceId, entryId) {
return rect && rect.key === key && rect.stamp === _frameStamp ? rect : null;
}
/**
* Whether an entry is queued to paint in the frame being painted. Custom lanes
* run before the host's own entries, so `getOverlayPaintRect` has nothing for
* this frame yet while they paint; this answers from the solved paint queue.
* @param {string} sourceId
* @param {string} entryId
* @returns {boolean}
*/
export function isOverlayEntryQueued(sourceId, entryId) {
if (_destroyed) return false;
const record = _records.get(entryKey(String(sourceId), String(entryId)));
if (!record || record.queuedStamp !== _frameStamp) return false;
// Same channels as the paint, minus the per-frame keyhole and sonar fades.
return (
record.sourceAlpha *
record.queuedItem.temporalAlpha *
record.distanceAlpha *
record.altitudeAlpha >
0.001
);
}
/**
* Resolve the topmost interactive world-overlay entry at CSS-pixel coords.
* @param {number} x
@@ -1737,6 +1759,8 @@ function getProjectionRecord(entry) {
contentAlpha: 1,
altitudeAlpha: 1,
sourceAlpha: 1,
queuedStamp: 0,
queuedItem: null,
protectedPlacement: null,
distanceOptions: {},
altitudeOptions: {},
@@ -2254,6 +2278,11 @@ function addPaintItem(record, placement, temporalAlpha, selected) {
item.zIndex = record.entry.zIndex;
item.key = record.key;
_paintQueue[_paintCount++] = item;
// Read by `isOverlayEntryQueued` while the custom lanes paint, before this
// item publishes its paint rect. Only a stamp and a pointer: storing the
// item's alpha here would box a double per paint item per frame.
record.queuedStamp = _frameStamp;
record.queuedItem = item;
}
function sourceCanPaint(sourceId) {
+38
View File
@@ -24,6 +24,7 @@ import {
activateWorldOverlayAction,
initWorldOverlay,
subscribeWorldOverlayActions,
isOverlayEntryQueued,
isOverlayPointVisible,
normalizeOverlayEntry,
paintLaneForOverlayEntry,
@@ -1213,6 +1214,43 @@ 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 first, so a card's paint rectangle for this
// frame does not exist yet when it runs. The queued check is what lets a
// contact's callout give way to that contact's own card in the same frame.
const env = installMockEnvironment({ width: 400, height: 300, dpr: 1 });
initWorldOverlay(env.viewer);
const seen = [];
registerWorldOverlayPaintLane('detection', () => {
seen.push({
shown: isOverlayEntryQueued('cards', 'SHOWN'),
hidden: isOverlayEntryQueued('hidden-cards', 'HIDDEN'),
unknown: isOverlayEntryQueued('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: true, hidden: false, unknown: false, 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, false, 'a card cleared since the last frame is no longer queued');
destroyWorldOverlay();
assert.equal(isOverlayEntryQueued('cards', 'SHOWN'), false);
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