From 652f3c8ea110f3b583e9adfd542617b06808f39b Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Thu, 5 Nov 2026 12:53:17 +0400 Subject: [PATCH 1/8] 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. --- docs/CURRENT-STATE.md | 2 +- src/data/aisLiveVessels.test.mjs | 39 +++++++++++++++++++ src/data/detection.js | 24 +++++++++++- src/data/detectionHost.test.mjs | 62 ++++++++++++++++++++++++++++++ src/layers/vessels/queries.js | 7 +++- src/overlays/worldOverlay.js | 26 +++++++++++++ src/overlays/worldOverlay.test.mjs | 38 ++++++++++++++++++ 7 files changed, 194 insertions(+), 4 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index f1244947d..370edcd7b 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -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. diff --git a/src/data/aisLiveVessels.test.mjs b/src/data/aisLiveVessels.test.mjs index 8e12bff94..edb3505f2 100644 --- a/src/data/aisLiveVessels.test.mjs +++ b/src/data/aisLiveVessels.test.mjs @@ -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 }); + } +}); diff --git a/src/data/detection.js b/src/data/detection.js index 36ed6372e..8ea124649 100644 --- a/src/data/detection.js +++ b/src/data/detection.js @@ -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) : '', }; } diff --git a/src/data/detectionHost.test.mjs b/src/data/detectionHost.test.mjs index f8858d602..7bcb9300e 100644 --- a/src/data/detectionHost.test.mjs +++ b/src/data/detectionHost.test.mjs @@ -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 diff --git a/src/layers/vessels/queries.js b/src/layers/vessels/queries.js index c848acc2d..7b2b66825 100644 --- a/src/layers/vessels/queries.js +++ b/src/layers/vessels/queries.js @@ -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, diff --git a/src/overlays/worldOverlay.js b/src/overlays/worldOverlay.js index 7b0b9d5fd..16699c548 100644 --- a/src/overlays/worldOverlay.js +++ b/src/overlays/worldOverlay.js @@ -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) { diff --git a/src/overlays/worldOverlay.test.mjs b/src/overlays/worldOverlay.test.mjs index f2e608b78..a0b5160b6 100644 --- a/src/overlays/worldOverlay.test.mjs +++ b/src/overlays/worldOverlay.test.mjs @@ -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 From 092e7e71a8cabcaa2ed1274f5f217a0e971e1bec Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Thu, 5 Nov 2026 12:54:11 +0400 Subject: [PATCH 2/8] Add the changelog entry for #1577 --- changelog.d/1577-vessel-callout-under-card.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 changelog.d/1577-vessel-callout-under-card.md diff --git a/changelog.d/1577-vessel-callout-under-card.md b/changelog.d/1577-vessel-callout-under-card.md new file mode 100644 index 000000000..579d805b9 --- /dev/null +++ b/changelog.d/1577-vessel-callout-under-card.md @@ -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). From ee3466dff93152204238bd7f83bd456d850bef00 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Thu, 5 Nov 2026 13:14:00 +0400 Subject: [PATCH 3/8] 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. --- src/overlays/worldOverlay.js | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/src/overlays/worldOverlay.js b/src/overlays/worldOverlay.js index 16699c548..726e766ee 100644 --- a/src/overlays/worldOverlay.js +++ b/src/overlays/worldOverlay.js @@ -1076,8 +1076,14 @@ export function getOverlayPaintRect(sourceId, entryId) { 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 && record.queuedStamp === _frameStamp && record.queuedAlpha > 0.001 + record.sourceAlpha * + record.queuedItem.temporalAlpha * + record.distanceAlpha * + record.altitudeAlpha > + 0.001 ); } @@ -1754,7 +1760,7 @@ function getProjectionRecord(entry) { altitudeAlpha: 1, sourceAlpha: 1, queuedStamp: 0, - queuedAlpha: 0, + queuedItem: null, protectedPlacement: null, distanceOptions: {}, altitudeOptions: {}, @@ -2273,13 +2279,10 @@ function addPaintItem(record, placement, temporalAlpha, selected) { 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. + // 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.queuedAlpha = - record.sourceAlpha * - temporalAlpha * - record.distanceAlpha * - record.altitudeAlpha; + record.queuedItem = item; } function sourceCanPaint(sourceId) { From da00b096d2bae42b28a5e34a6c7aed5543d0291b Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Thu, 5 Nov 2026 21:21:02 +0400 Subject: [PATCH 4/8] 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. --- docs/CURRENT-STATE.md | 2 +- src/data/aisLiveVessels.test.mjs | 1 + src/data/detection.js | 33 ++-- src/data/detectionHost.test.mjs | 174 ++++++++++++++++++ src/layers/vessels/cards.js | 8 + src/layers/vessels/records.js | 3 + src/overlays/worldOverlay.js | 91 +++++---- src/overlays/worldOverlay.test.mjs | 16 +- .../worldOverlayAllocation.worker.mjs | 3 + 9 files changed, 274 insertions(+), 57 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 370edcd7b..26cf5af18 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -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'`; 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. +- **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 detection callout fades out while its own ambient card paints in the same frame, judged by the card's painted opacity including the keyhole fade, so a card dropped by budget, collisions or UI chrome, or faded out 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. diff --git a/src/data/aisLiveVessels.test.mjs b/src/data/aisLiveVessels.test.mjs index edb3505f2..e1c2fcc5a 100644 --- a/src/data/aisLiveVessels.test.mjs +++ b/src/data/aisLiveVessels.test.mjs @@ -1821,6 +1821,7 @@ test('detection objects name the vessel card their callout gives way to', () => 'vessel:unkeyed:NO MMSI:37.80000:-122.40000', 'the same identity the vessel card is published under', ); + assert.equal(keyed.overlayEntryId, 'vessel:200', 'cached on the record for later frames'); } finally { _setVesselStateForTest({ enabled: false }); } diff --git a/src/data/detection.js b/src/data/detection.js index 8ea124649..f4d1ddf3b 100644 --- a/src/data/detection.js +++ b/src/data/detection.js @@ -22,7 +22,7 @@ import { keyholeLabelAlphaFromGeometry, } from '../celestialRing.js'; import { - isOverlayEntryQueued, + getQueuedOverlayEntryAlpha, registerWorldOverlayPaintLane, } from '../overlays/worldOverlay.js'; import { @@ -110,6 +110,11 @@ 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; +/** + * A contact's own card at this opacity or more hides its callout; below it the + * callout fades in, so a card fading in or out never snaps the callout. + */ +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. */ @@ -579,8 +584,8 @@ 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 })); + .filter((row) => row.paintAlpha > 0.001) + .map(({ x, y, w, h, paintAlpha }) => ({ x, y, w, h, alpha: paintAlpha })); return result; } @@ -1083,7 +1088,7 @@ function _stashCallout(entry, acquireFade, keyhole) { alpha: 1, cardSourceId: '', cardEntryId: '', - yielded: false, + paintAlpha: 1, }; _calloutPool[_calloutCount] = row; } @@ -1112,7 +1117,7 @@ function _stashCallout(entry, acquireFade, keyhole) { row.alpha = alpha; row.cardSourceId = candidate.cardSourceId; row.cardEntryId = candidate.cardEntryId; - row.yielded = false; + row.paintAlpha = alpha; _calloutCount++; } @@ -1144,15 +1149,15 @@ function _paintCalloutLane(frame) { ctx.textBaseline = 'alphabetic'; for (let i = 0; i < _calloutCount; i++) { const row = _calloutPool[i]; - // 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); + // Under the contact's own card (an ambient vessel card) the callout would + // show through the plate. Only a card painting this frame counts. + 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; } diff --git a/src/data/detectionHost.test.mjs b/src/data/detectionHost.test.mjs index 7bcb9300e..4c1c6dddc 100644 --- a/src/data/detectionHost.test.mjs +++ b/src/data/detectionHost.test.mjs @@ -21,10 +21,17 @@ import { import { clearOverlaySource, destroyWorldOverlay, + getOverlayPaintRect, initWorldOverlay, setOverlayEntries, setOverlaySourceVisible, } 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, @@ -641,6 +648,173 @@ test('a callout gives way to its contact\'s own card only while that card paints } }); +/** 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`, + ); + } + } 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 diff --git a/src/layers/vessels/cards.js b/src/layers/vessels/cards.js index bcf3459f1..2563bf420 100644 --- a/src/layers/vessels/cards.js +++ b/src/layers/vessels/cards.js @@ -117,6 +117,14 @@ export function createCards({ /** Stable overlay identity for MMSI-keyed and source-retained unkeyed rows. */ function vesselOverlayEntryId(record) { + // Cached on the record: detection asks for it every frame for every vessel. + if (record?.overlayEntryId) return record.overlayEntryId; + const id = composeVesselOverlayEntryId(record); + if (record) record.overlayEntryId = id; + return id; + } + + function composeVesselOverlayEntryId(record) { const mmsi = String(record?.mmsi || '').trim(); if (mmsi) return `vessel:${mmsi}`; const name = String(record?.name || 'VESSEL').trim() || 'VESSEL'; diff --git a/src/layers/vessels/records.js b/src/layers/vessels/records.js index ad6d85cbd..aeb2f39bf 100644 --- a/src/layers/vessels/records.js +++ b/src/layers/vessels/records.js @@ -23,6 +23,9 @@ export function normalizeVessel(row) { lastPositionUtc: String(row.last_position_UTC || ''), lastPositionEpoch: finiteNumber(row.last_position_epoch), missedRefreshes: 0, + // Filled on first use by the cards' `vesselOverlayEntryId`. The id reads + // only the MMSI, or for an unkeyed row fields it never updates. + overlayEntryId: null, }; } diff --git a/src/overlays/worldOverlay.js b/src/overlays/worldOverlay.js index 726e766ee..aabcc7be0 100644 --- a/src/overlays/worldOverlay.js +++ b/src/overlays/worldOverlay.js @@ -258,6 +258,10 @@ const _paintQueue = []; const _paintItemPool = []; const _paintRectPool = []; const _paintRectByKey = new Map(); +/** @type {Map>} 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 = []; @@ -1066,24 +1070,26 @@ export function getOverlayPaintRect(sourceId, entryId) { } /** - * 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. + * Opacity an entry will paint with in the frame being painted, or 0 when it is + * not queued. Valid from the custom lanes, which paint before the entries in + * later lanes publish their paint rects. Leaves out only the Cyber sonar fade. * @param {string} sourceId * @param {string} entryId - * @returns {boolean} + * @returns {number} */ -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. +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 * - record.queuedItem.temporalAlpha * - record.distanceAlpha * - record.altitudeAlpha > - 0.001 + item.temporalAlpha * + record.distanceAlpha * + record.altitudeAlpha * + (_keyhole ? entryKeyholeAlpha(record.entry, item.placement, _keyhole) : 1) ); } @@ -1759,8 +1765,6 @@ function getProjectionRecord(entry) { contentAlpha: 1, altitudeAlpha: 1, sourceAlpha: 1, - queuedStamp: 0, - queuedItem: null, protectedPlacement: null, distanceOptions: {}, altitudeOptions: {}, @@ -2277,12 +2281,27 @@ function addPaintItem(record, placement, temporalAlpha, selected) { item.lane = paintLaneForOverlayEntry(record.entry); item.zIndex = record.entry.zIndex; item.key = record.key; + item.stamp = _frameStamp; _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; + // Indexed by source and id rather than by `record.key`, so a lookup builds + // no string. A card fading out after it left its source's entries is still + // queued here, though `_records` no longer has it. + 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) { + if (item.stamp !== _frameStamp) bySource.delete(entryId); + } + if (bySource.size === 0) _queuedItemBySource.delete(sourceId); + } } function sourceCanPaint(sourceId) { @@ -2354,6 +2373,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; @@ -2482,23 +2502,25 @@ 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) { 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. @@ -2799,6 +2821,7 @@ export function destroyWorldOverlay() { _accessibleActivatorByKey.clear(); _accessibilitySignature = ''; _paintRectByKey.clear(); + _queuedItemBySource.clear(); _paintCount = 0; _paintRectCount = 0; _hitRectCount = 0; diff --git a/src/overlays/worldOverlay.test.mjs b/src/overlays/worldOverlay.test.mjs index a0b5160b6..6e713a5da 100644 --- a/src/overlays/worldOverlay.test.mjs +++ b/src/overlays/worldOverlay.test.mjs @@ -24,7 +24,7 @@ import { activateWorldOverlayAction, initWorldOverlay, subscribeWorldOverlayActions, - isOverlayEntryQueued, + getQueuedOverlayEntryAlpha, isOverlayPointVisible, normalizeOverlayEntry, paintLaneForOverlayEntry, @@ -1216,16 +1216,16 @@ test('custom detection lane receives the shared host frame and paints below ordi 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 + // frame does not exist yet when it runs. The queued alpha 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'), + shown: getQueuedOverlayEntryAlpha('cards', 'SHOWN'), + hidden: getQueuedOverlayEntryAlpha('hidden-cards', 'HIDDEN'), + unknown: getQueuedOverlayEntryAlpha('cards', 'NOT-PUBLISHED'), rect: getOverlayPaintRect('cards', 'SHOWN'), }); }, { id: 'detection', active: true, target: 'detection' }); @@ -1238,16 +1238,16 @@ test('a detection lane sees which cards this frame will paint before they publis setOverlaySourceVisible('hidden-cards', false); env.postRender.raise(); - assert.deepEqual(seen[0], { shown: true, hidden: false, unknown: false, rect: null }, + 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, false, 'a card cleared since the last frame is no longer queued'); + assert.equal(seen[1].shown, 0, 'a card cleared since the last frame is no longer queued'); destroyWorldOverlay(); - assert.equal(isOverlayEntryQueued('cards', 'SHOWN'), false); + assert.equal(getQueuedOverlayEntryAlpha('cards', 'SHOWN'), 0); env.cleanup(); }); diff --git a/src/overlays/worldOverlayAllocation.worker.mjs b/src/overlays/worldOverlayAllocation.worker.mjs index e2a4c6240..93b47e7f3 100644 --- a/src/overlays/worldOverlayAllocation.worker.mjs +++ b/src/overlays/worldOverlayAllocation.worker.mjs @@ -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 { From 1015b1666463f98bdcb512b8e0565e5f9d96e53a Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Fri, 6 Nov 2026 14:45:37 +0400 Subject: [PATCH 5/8] 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. --- src/overlays/worldOverlay.js | 10 ++++++- src/overlays/worldOverlay.test.mjs | 47 ++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/src/overlays/worldOverlay.js b/src/overlays/worldOverlay.js index aabcc7be0..df3055de6 100644 --- a/src/overlays/worldOverlay.js +++ b/src/overlays/worldOverlay.js @@ -2298,7 +2298,15 @@ function addPaintItem(record, placement, temporalAlpha, selected) { function pruneQueuedItemIndex() { for (const [sourceId, bySource] of _queuedItemBySource) { for (const [entryId, item] of bySource) { - if (item.stamp !== _frameStamp) bySource.delete(entryId); + // 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); } diff --git a/src/overlays/worldOverlay.test.mjs b/src/overlays/worldOverlay.test.mjs index 6e713a5da..39bc74c44 100644 --- a/src/overlays/worldOverlay.test.mjs +++ b/src/overlays/worldOverlay.test.mjs @@ -2353,3 +2353,50 @@ 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 the per-source map the same way the + // projection-record test captures its map, through Map.prototype.set. + 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 painted card whose identity changes every frame: the same pooled + // paint item serves every id (a vessel cohort republished as the camera + // moves, a CCTV or radio card swapping). + 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(); + } +}); From c9f40a837cd08d025d5c1d58d31d3dff5ef37561 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Fri, 6 Nov 2026 20:20:11 +0400 Subject: [PATCH 6/8] Tighten callout fade comments and docs --- docs/CURRENT-STATE.md | 2 +- src/data/detection.js | 9 +++------ src/data/detectionHost.test.mjs | 6 ++---- src/layers/vessels/queries.js | 3 +-- src/layers/vessels/records.js | 3 +-- src/overlays/worldOverlay.js | 11 +++++------ src/overlays/worldOverlay.test.mjs | 14 ++++++-------- 7 files changed, 19 insertions(+), 29 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index c4d66de8a..6d9193078 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -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'`; a vessel's detection callout fades out while its own ambient card paints in the same frame, judged by the card's painted opacity including the keyhole fade, so a card dropped by budget, collisions or UI chrome, or faded out 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. +- **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. diff --git a/src/data/detection.js b/src/data/detection.js index f4d1ddf3b..b4dc29522 100644 --- a/src/data/detection.js +++ b/src/data/detection.js @@ -110,10 +110,7 @@ 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; -/** - * A contact's own card at this opacity or more hides its callout; below it the - * callout fades in, so a card fading in or out never snaps the callout. - */ +/** 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; @@ -1149,8 +1146,8 @@ function _paintCalloutLane(frame) { ctx.textBaseline = 'alphabetic'; for (let i = 0; i < _calloutCount; i++) { const row = _calloutPool[i]; - // Under the contact's own card (an ambient vessel card) the callout would - // show through the plate. Only a card painting this frame counts. + // 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 diff --git a/src/data/detectionHost.test.mjs b/src/data/detectionHost.test.mjs index 4c1c6dddc..f479869f9 100644 --- a/src/data/detectionHost.test.mjs +++ b/src/data/detectionHost.test.mjs @@ -589,10 +589,8 @@ 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. + // The callout shows through its card's plate. The host paints only some + // published cards (budget, collisions, UI chrome); an unpainted one keeps it. const env = installEnvironment({ width: 900, height: 600, dpr: 1 }); try { const ships = [ diff --git a/src/layers/vessels/queries.js b/src/layers/vessels/queries.js index f26ada86b..0be276913 100644 --- a/src/layers/vessels/queries.js +++ b/src/layers/vessels/queries.js @@ -505,8 +505,7 @@ 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. + // Detection hides the callout while this card paints. cardSourceId: VESSEL_OVERLAY_SOURCE_ID, cardEntryId: components.cards.vesselOverlayEntryId(record), klass: record.type diff --git a/src/layers/vessels/records.js b/src/layers/vessels/records.js index aeb2f39bf..f6f581784 100644 --- a/src/layers/vessels/records.js +++ b/src/layers/vessels/records.js @@ -23,8 +23,7 @@ export function normalizeVessel(row) { lastPositionUtc: String(row.last_position_UTC || ''), lastPositionEpoch: finiteNumber(row.last_position_epoch), missedRefreshes: 0, - // Filled on first use by the cards' `vesselOverlayEntryId`. The id reads - // only the MMSI, or for an unkeyed row fields it never updates. + // Cached by `vesselOverlayEntryId`; the fields it reads never change. overlayEntryId: null, }; } diff --git a/src/overlays/worldOverlay.js b/src/overlays/worldOverlay.js index c569e03b8..71f4b849f 100644 --- a/src/overlays/worldOverlay.js +++ b/src/overlays/worldOverlay.js @@ -1047,9 +1047,9 @@ export function getOverlayPaintRect(sourceId, entryId) { } /** - * Opacity an entry will paint with in the frame being painted, or 0 when it is - * not queued. Valid from the custom lanes, which paint before the entries in - * later lanes publish their paint rects. Leaves out only the Cyber sonar fade. + * 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} @@ -2253,9 +2253,8 @@ function addPaintItem(record, placement, temporalAlpha, selected) { item.collisionHidden = false; item.stamp = _frameStamp; _paintQueue[_paintCount++] = item; - // Indexed by source and id rather than by `record.key`, so a lookup builds - // no string. A card fading out after it left its source's entries is still - // queued here, though `_records` no longer has it. + // 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(); diff --git a/src/overlays/worldOverlay.test.mjs b/src/overlays/worldOverlay.test.mjs index dfc82cebd..1b8223bbc 100644 --- a/src/overlays/worldOverlay.test.mjs +++ b/src/overlays/worldOverlay.test.mjs @@ -1224,9 +1224,8 @@ test('custom detection lane receives the shared host frame and paints below ordi }); 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 alpha is what lets a - // contact's callout give way to that contact's own card in the same frame. + // 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 = []; @@ -2529,8 +2528,8 @@ test('promoting an ambient entry to selected paints it once with no fading dupli }); test('the queued-card index stays bounded while card identities churn', () => { - // The index is internal: capture the per-source map the same way the - // projection-record test captures its map, through Map.prototype.set. + // 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) { @@ -2546,9 +2545,8 @@ test('the queued-card index stays bounded while card identities churn', () => { initWorldOverlay(env.viewer); const FRAMES = 1300; for (let frame = 0; frame < FRAMES; frame++) { - // One painted card whose identity changes every frame: the same pooled - // paint item serves every id (a vessel cohort republished as the camera - // moves, a CCTV or radio card swapping). + // 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), })]); From 4c62435910a191403dc7368ed9d31b5cbafd759e Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Fri, 6 Nov 2026 22:01:39 +0400 Subject: [PATCH 7/8] Drop the duplicate callout test and the card id cache --- src/data/aisLiveVessels.test.mjs | 1 - src/data/detection.js | 1 - src/data/detectionHost.test.mjs | 65 +++----------------------------- src/layers/vessels/cards.js | 8 ---- src/layers/vessels/records.js | 2 - 5 files changed, 5 insertions(+), 72 deletions(-) diff --git a/src/data/aisLiveVessels.test.mjs b/src/data/aisLiveVessels.test.mjs index e1c2fcc5a..edb3505f2 100644 --- a/src/data/aisLiveVessels.test.mjs +++ b/src/data/aisLiveVessels.test.mjs @@ -1821,7 +1821,6 @@ test('detection objects name the vessel card their callout gives way to', () => 'vessel:unkeyed:NO MMSI:37.80000:-122.40000', 'the same identity the vessel card is published under', ); - assert.equal(keyed.overlayEntryId, 'vessel:200', 'cached on the record for later frames'); } finally { _setVesselStateForTest({ enabled: false }); } diff --git a/src/data/detection.js b/src/data/detection.js index b4dc29522..0ec9e9ba2 100644 --- a/src/data/detection.js +++ b/src/data/detection.js @@ -1114,7 +1114,6 @@ function _stashCallout(entry, acquireFade, keyhole) { row.alpha = alpha; row.cardSourceId = candidate.cardSourceId; row.cardEntryId = candidate.cardEntryId; - row.paintAlpha = alpha; _calloutCount++; } diff --git a/src/data/detectionHost.test.mjs b/src/data/detectionHost.test.mjs index f479869f9..db01ed18a 100644 --- a/src/data/detectionHost.test.mjs +++ b/src/data/detectionHost.test.mjs @@ -19,12 +19,10 @@ import { suspendDetection, } from './detection.js'; import { - clearOverlaySource, destroyWorldOverlay, getOverlayPaintRect, initWorldOverlay, setOverlayEntries, - setOverlaySourceVisible, } from '../overlays/worldOverlay.js'; import { applyVesselOverlayPolicy } from './vesselLabels.js'; import { @@ -588,64 +586,6 @@ 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', () => { - // The callout shows through its card's plate. The host paints only some - // published cards (budget, collisions, UI chrome); an unpainted one keeps it. - 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(); - } -}); - /** A vessel detection contact that names its ambient card, like the AIS layer does. */ function shipWithCard(mmsi, position) { return { @@ -776,6 +716,11 @@ test('a vessel whose ambient card loses the collision keeps its callout', () => 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(); diff --git a/src/layers/vessels/cards.js b/src/layers/vessels/cards.js index 2563bf420..bcf3459f1 100644 --- a/src/layers/vessels/cards.js +++ b/src/layers/vessels/cards.js @@ -117,14 +117,6 @@ export function createCards({ /** Stable overlay identity for MMSI-keyed and source-retained unkeyed rows. */ function vesselOverlayEntryId(record) { - // Cached on the record: detection asks for it every frame for every vessel. - if (record?.overlayEntryId) return record.overlayEntryId; - const id = composeVesselOverlayEntryId(record); - if (record) record.overlayEntryId = id; - return id; - } - - function composeVesselOverlayEntryId(record) { const mmsi = String(record?.mmsi || '').trim(); if (mmsi) return `vessel:${mmsi}`; const name = String(record?.name || 'VESSEL').trim() || 'VESSEL'; diff --git a/src/layers/vessels/records.js b/src/layers/vessels/records.js index f6f581784..ad6d85cbd 100644 --- a/src/layers/vessels/records.js +++ b/src/layers/vessels/records.js @@ -23,8 +23,6 @@ export function normalizeVessel(row) { lastPositionUtc: String(row.last_position_UTC || ''), lastPositionEpoch: finiteNumber(row.last_position_epoch), missedRefreshes: 0, - // Cached by `vesselOverlayEntryId`; the fields it reads never change. - overlayEntryId: null, }; } From 20d58cf8885dd77cb93b55f674d1f047226c3e21 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Tue, 10 Nov 2026 13:50:27 +0400 Subject: [PATCH 8/8] Report zero queued alpha for an entry the yield pass hid getQueuedOverlayEntryAlpha now returns 0 for a yielding entry that declutterYieldingEntries marked collisionHidden this frame. That pass runs before custom lanes, so they no longer read full opacity for an entry that will not paint. --- src/overlays/worldOverlay.js | 8 ++++--- src/overlays/worldOverlay.test.mjs | 37 ++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 3 deletions(-) diff --git a/src/overlays/worldOverlay.js b/src/overlays/worldOverlay.js index 71f4b849f..e0ca77038 100644 --- a/src/overlays/worldOverlay.js +++ b/src/overlays/worldOverlay.js @@ -1047,9 +1047,9 @@ export function getOverlayPaintRect(sourceId, entryId) { } /** - * 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. + * Opacity an entry paints with this frame, or 0 when it is not queued or the + * yield-collision pass hid it. Custom lanes may call it before later lanes + * publish paint rects. Leaves out the Cyber sonar fade. * @param {string} sourceId * @param {string} entryId * @returns {number} @@ -1059,6 +1059,8 @@ export function getQueuedOverlayEntryAlpha(sourceId, entryId) { 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; + // Declutter has already run when custom lanes call this. + if (item.collisionHidden) return 0; const record = item.record; if (record.entry.id !== entryId || record.entry.source !== sourceId) return 0; return ( diff --git a/src/overlays/worldOverlay.test.mjs b/src/overlays/worldOverlay.test.mjs index 1b8223bbc..30dd021c2 100644 --- a/src/overlays/worldOverlay.test.mjs +++ b/src/overlays/worldOverlay.test.mjs @@ -1259,6 +1259,43 @@ test('a detection lane sees which cards this frame will paint before they publis env.cleanup(); }); +test('a detection lane reads zero for a yielding entry the declutter pass hid this frame', () => { + // Declutter runs before custom lanes, so a yielding entry it hid must not + // report its full queued alpha to them. + const env = installMockEnvironment({ width: 400, height: 300, dpr: 1 }); + initWorldOverlay(env.viewer); + const seen = []; + registerWorldOverlayPaintLane('detection', () => { + seen.push({ + winner: getQueuedOverlayEntryAlpha('cards', 'ABOVE'), + yielder: getQueuedOverlayEntryAlpha('labels', 'YIELDER'), + }); + }, { id: 'detection', active: true, target: 'detection' }); + // Two cards take both vertical slots, so a vertical-only yielder has none. + const at = positionAtScreen(200, 150); + const card = (id, placement) => selectedEntry(id, { + position: at, + placement, + verticalOnly: true, + collisionGroup: 'shared-card', + }); + setOverlayEntries('cards', [card('ABOVE', 'above'), card('BELOW', 'below')]); + setOverlayEntries('labels', [selectedEntry('YIELDER', { + position: at, + verticalOnly: true, + collisionGroup: 'yielding-label', + yieldToCollisionGroup: 'shared-card', + })]); + + env.postRender.raise(); + assert.ok(getOverlayPaintRect('cards', 'ABOVE'), 'the winner painted'); + assert.equal(getOverlayPaintRect('labels', 'YIELDER'), null, 'the yielder was hidden'); + assert.ok(seen[0].winner > 0, 'the winner reads its queued alpha'); + assert.equal(seen[0].yielder, 0, 'the yielder hidden this frame reads zero'); + destroyWorldOverlay(); + 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