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.
This commit is contained in:
German Khokhlov committed 2026-11-05 12:53:17 +04:00
1 parent 7d8b80db0a
commit 652f3c8ea1
7 files changed
+194 -4

No files matched your search

+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,
+26
View File
@@ -1065,6 +1065,22 @@ 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)));
return (
!!record && record.queuedStamp === _frameStamp && record.queuedAlpha > 0.001
);
}
/**
* Resolve the topmost interactive world-overlay entry at CSS-pixel coords.
* @param {number} x
@@ -1737,6 +1753,8 @@ function getProjectionRecord(entry) {
contentAlpha: 1,
altitudeAlpha: 1,
sourceAlpha: 1,
queuedStamp: 0,
queuedAlpha: 0,
protectedPlacement: null,
distanceOptions: {},
altitudeOptions: {},
@@ -2254,6 +2272,14 @@ 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. Keyhole and sonar fades are left out.
record.queuedStamp = _frameStamp;
record.queuedAlpha =
record.sourceAlpha *
temporalAlpha *
record.distanceAlpha *
record.altitudeAlpha;
}
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