mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-14 15:22:21 +00:00
Merge branch 'main' into feat/viewport-context-decision-upstream
This commit is contained in:
9 files changed
+449
-19
No files matched your search
@@ -1802,3 +1802,42 @@ test('vessel focus wire skips hidden sprites at rest while a target is tracked',
|
||||
assert.equal(result.activeCount, 1);
|
||||
for (const billboard of hidden) assert.equal(billboard.color.alpha, 1);
|
||||
});
|
||||
|
||||
test('detection objects name the vessel card their callout gives way to', () => {
|
||||
const vessel = (mmsi, name) => ({
|
||||
name,
|
||||
mmsi,
|
||||
type: 'Cargo',
|
||||
destination: '',
|
||||
speed: 9.5,
|
||||
course: 80,
|
||||
heading: 80,
|
||||
lastPositionUtc: '',
|
||||
missedRefreshes: 0,
|
||||
position: POS,
|
||||
billboard: null,
|
||||
});
|
||||
const keyed = vessel('200', 'GOLDEN BEAR');
|
||||
const unkeyed = { ...vessel('', 'NO MMSI'), lat: 37.8, lon: -122.4 };
|
||||
_setVesselStateForTest({
|
||||
viewer: {},
|
||||
records: [keyed, unkeyed],
|
||||
billboardCollection: { show: true, remove() {} },
|
||||
});
|
||||
try {
|
||||
const objects = Object.fromEntries(
|
||||
aisLiveVesselsLayer
|
||||
.getDetectableObjects()
|
||||
.map((object) => [object.id, object]),
|
||||
);
|
||||
assert.equal(objects['GOLDEN BEAR'].cardSourceId, 'ais-live-vessels');
|
||||
assert.equal(objects['GOLDEN BEAR'].cardEntryId, 'vessel:200');
|
||||
assert.equal(
|
||||
objects['NO MMSI'].cardEntryId,
|
||||
'vessel:unkeyed:NO MMSI:37.80000:-122.40000',
|
||||
'the same identity the vessel card is published under',
|
||||
);
|
||||
} finally {
|
||||
_setVesselStateForTest({ enabled: false });
|
||||
}
|
||||
});
|
||||
+24
-3
@@ -21,7 +21,10 @@ import {
|
||||
getKeyholeFadeTuning,
|
||||
keyholeLabelAlphaFromGeometry,
|
||||
} from '../celestialRing.js';
|
||||
import { registerWorldOverlayPaintLane } from '../overlays/worldOverlay.js';
|
||||
import {
|
||||
getQueuedOverlayEntryAlpha,
|
||||
registerWorldOverlayPaintLane,
|
||||
} from '../overlays/worldOverlay.js';
|
||||
import {
|
||||
DETECTION_STYLE,
|
||||
DETECTION_THEME_MAP,
|
||||
@@ -107,6 +110,8 @@ const THEME_MAP = DETECTION_THEME_MAP;
|
||||
const SPARSE_ZONE_FRACTION = 0.5;
|
||||
/** Solve cadence for membership/collision; accepted identities still reproject every frame. */
|
||||
const LABEL_SOLVE_INTERVAL_MS = 125;
|
||||
/** Own-card opacity that fully hides a callout; below it the callout fades in. */
|
||||
const CARD_ALPHA_HIDING_CALLOUT = 0.5;
|
||||
/** Alpha bands retain bracket batching while approximating a continuous radial fade. */
|
||||
const BRACKET_ALPHA_STEPS = 4;
|
||||
/** Finer bands for Global Context echo brackets, whose fade is animated. */
|
||||
@@ -576,7 +581,8 @@ export function getDetectionDiagnostics() {
|
||||
// sprites/strokes. Nothing is copied on the production paint path.
|
||||
result.calloutRects = _calloutPool
|
||||
.slice(0, _calloutCount)
|
||||
.map(({ x, y, w, h, alpha }) => ({ x, y, w, h, alpha }));
|
||||
.filter((row) => row.paintAlpha > 0.001)
|
||||
.map(({ x, y, w, h, paintAlpha }) => ({ x, y, w, h, alpha: paintAlpha }));
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -1077,6 +1083,9 @@ function _stashCallout(entry, acquireFade, keyhole) {
|
||||
font: FONT,
|
||||
microFont: MICRO_FONT,
|
||||
alpha: 1,
|
||||
cardSourceId: '',
|
||||
cardEntryId: '',
|
||||
paintAlpha: 1,
|
||||
};
|
||||
_calloutPool[_calloutCount] = row;
|
||||
}
|
||||
@@ -1103,6 +1112,8 @@ function _stashCallout(entry, acquireFade, keyhole) {
|
||||
row.font = FONT;
|
||||
row.microFont = MICRO_FONT;
|
||||
row.alpha = alpha;
|
||||
row.cardSourceId = candidate.cardSourceId;
|
||||
row.cardEntryId = candidate.cardEntryId;
|
||||
_calloutCount++;
|
||||
}
|
||||
|
||||
@@ -1134,7 +1145,15 @@ function _paintCalloutLane(frame) {
|
||||
ctx.textBaseline = 'alphabetic';
|
||||
for (let i = 0; i < _calloutCount; i++) {
|
||||
const row = _calloutPool[i];
|
||||
paintDetectionCallout(ctx, row, row.alpha);
|
||||
// The callout would show through its contact's own card: fade it by the
|
||||
// opacity that card paints with this frame.
|
||||
const cardAlpha =
|
||||
row.cardEntryId === ''
|
||||
? 0
|
||||
: getQueuedOverlayEntryAlpha(row.cardSourceId, row.cardEntryId);
|
||||
row.paintAlpha =
|
||||
row.alpha * Math.max(0, 1 - cardAlpha / CARD_ALPHA_HIDING_CALLOUT);
|
||||
if (row.paintAlpha > 0.001) paintDetectionCallout(ctx, row, row.paintAlpha);
|
||||
}
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
@@ -1211,6 +1230,8 @@ function _materializeCandidate(
|
||||
primary,
|
||||
micro,
|
||||
hasMicro: card.hasMicro,
|
||||
cardSourceId: obj.cardSourceId ? String(obj.cardSourceId) : '',
|
||||
cardEntryId: obj.cardEntryId ? String(obj.cardEntryId) : '',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -20,9 +20,16 @@ import {
|
||||
} from './detection.js';
|
||||
import {
|
||||
destroyWorldOverlay,
|
||||
getOverlayPaintRect,
|
||||
initWorldOverlay,
|
||||
setOverlayEntries,
|
||||
} from '../overlays/worldOverlay.js';
|
||||
import { applyVesselOverlayPolicy } from './vesselLabels.js';
|
||||
import {
|
||||
getKeyholeFadeTuning,
|
||||
getKeyholeGeometry,
|
||||
keyholeLabelAlphaFromGeometry,
|
||||
} from '../celestialRing.js';
|
||||
import { DETECTION_THEME_MAP } from '../overlays/worldOverlayTokens.js';
|
||||
import {
|
||||
clearAwarenessEchoField,
|
||||
@@ -579,6 +586,178 @@ test('every style paints its OWN plate token through the production callout path
|
||||
}
|
||||
});
|
||||
|
||||
/** A vessel detection contact that names its ambient card, like the AIS layer does. */
|
||||
function shipWithCard(mmsi, position) {
|
||||
return {
|
||||
position,
|
||||
sourceId: mmsi,
|
||||
id: `SHIP-${mmsi}`,
|
||||
metric: '6 kn',
|
||||
type: 'SEA',
|
||||
cardSourceId: 'ais-live-vessels',
|
||||
cardEntryId: `vessel:${mmsi}`,
|
||||
};
|
||||
}
|
||||
|
||||
/** The production ambient vessel card (not protected: it goes through the arbiter). */
|
||||
function ambientVesselCard(mmsi, position) {
|
||||
return applyVesselOverlayPolicy({
|
||||
id: `vessel:${mmsi}`,
|
||||
position,
|
||||
title: `CARD-${mmsi}`,
|
||||
details: ['CARGO · 6KT · 80°'],
|
||||
gapPx: 10,
|
||||
});
|
||||
}
|
||||
|
||||
test('a callout stays readable when its own card is faded out at the keyhole rim', () => {
|
||||
// Default keyhole tuning: 7% feather, 1% outside opacity.
|
||||
assert.deepEqual(getKeyholeFadeTuning(), { fadeRatio: 0.07, outsideOpacity: 0.01 });
|
||||
const width = 1600;
|
||||
const height = 900;
|
||||
const keyhole = getKeyholeGeometry(width, height);
|
||||
const env = installEnvironment({ width, height, dpr: 1 });
|
||||
// Just outside the keyhole's left rim: the card sits above or below the
|
||||
// anchor (outside, 1% alpha), the callout to its right (inside the feather).
|
||||
const position = new Cesium.Cartesian3(270 / (width / 2) - 1, 1 - 460 / (height / 2), 6_356_752);
|
||||
const ship = shipWithCard('200', position);
|
||||
try {
|
||||
initWorldOverlay(env.viewer);
|
||||
initDetection(env.viewer, [{ id: 'ais-live-vessels', getDetectableObjects: () => [ship] }], () => {});
|
||||
setMode('DENSE');
|
||||
for (let i = 0; i < 4; i++) settleFrame(env);
|
||||
const [alone] = getDetectionDiagnostics().calloutRects;
|
||||
assert.ok(alone && alone.alpha >= 0.5, 'without a card the callout is clearly visible');
|
||||
|
||||
setOverlayEntries('ais-live-vessels', [ambientVesselCard('200', position)]);
|
||||
for (let i = 0; i < 4; i++) settleFrame(env);
|
||||
const rect = getOverlayPaintRect('ais-live-vessels', 'vessel:200');
|
||||
assert.ok(rect, 'the host paints the card (at the outside opacity)');
|
||||
const cardAlpha = keyholeLabelAlphaFromGeometry(rect.x + rect.w / 2, rect.y + rect.h / 2, keyhole);
|
||||
assert.ok(cardAlpha <= 0.05, `the card is invisible at the rim (keyhole alpha ${cardAlpha})`);
|
||||
|
||||
const callouts = getDetectionDiagnostics().calloutRects;
|
||||
assert.equal(callouts.length, 1, 'the callout keeps painting while its card is faded out');
|
||||
assert.ok(callouts[0].alpha >= 0.4, 'and stays readable');
|
||||
} finally {
|
||||
destroyDetection();
|
||||
destroyWorldOverlay();
|
||||
env.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('a callout stays hidden while its card fades out after leaving the published cohort', () => {
|
||||
const env = installEnvironment({ width: 1600, height: 900, dpr: 1 });
|
||||
const at = (x, y) => new Cesium.Cartesian3(x, y, 6_356_752);
|
||||
const ship = shipWithCard('200', at(0.1, 0.1));
|
||||
const painted = (text) => env.ctx.calls.some(([name, value]) => name === 'fillText' && value === text);
|
||||
const frame = (ms) => {
|
||||
env.ctx.calls.length = 0;
|
||||
env.advance(ms);
|
||||
env.postRender.raise();
|
||||
};
|
||||
try {
|
||||
initWorldOverlay(env.viewer);
|
||||
initDetection(env.viewer, [{ id: 'ais-live-vessels', getDetectableObjects: () => [ship] }], () => {});
|
||||
setMode('DENSE');
|
||||
setOverlayEntries('ais-live-vessels', [ambientVesselCard('200', ship.position)]);
|
||||
for (let i = 0; i < 6; i++) frame(250);
|
||||
frame(16);
|
||||
assert.ok(painted('CARD-200') && !painted('SHIP-200'), 'steady state: card only');
|
||||
|
||||
// The vessel layer republishes its screen-grid cohort after a camera move:
|
||||
// this vessel's card is replaced by another one, the vessel stays detectable.
|
||||
setOverlayEntries('ais-live-vessels', [ambientVesselCard('300', at(-0.5, -0.4))]);
|
||||
frame(16);
|
||||
assert.ok(getOverlayPaintRect('ais-live-vessels', 'vessel:200'), 'the dropped card is still fading out');
|
||||
assert.ok(painted('CARD-200'), 'and its text still paints');
|
||||
assert.ok(!painted('SHIP-200'), 'no full callout under the fading card');
|
||||
|
||||
for (let i = 0; i < 4; i++) frame(250);
|
||||
assert.ok(!painted('CARD-200'), 'the card has faded out');
|
||||
assert.ok(painted('SHIP-200'), 'and the callout is back');
|
||||
} finally {
|
||||
destroyDetection();
|
||||
destroyWorldOverlay();
|
||||
env.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('a vessel whose ambient card loses the collision keeps its callout', () => {
|
||||
const env = installEnvironment({ width: 900, height: 600, dpr: 1 });
|
||||
// Four vessels a few pixels apart: their cards collide (a card goes only
|
||||
// above or below its anchor), so the arbiter paints only some of them.
|
||||
const ships = [0, 1, 2, 3].map((i) =>
|
||||
shipWithCard(String(100 + i), new Cesium.Cartesian3(-0.2 + i * 0.005, i * 0.005, 6_356_752)),
|
||||
);
|
||||
const painted = (text) => env.ctx.calls.some(([name, value]) => name === 'fillText' && value === text);
|
||||
try {
|
||||
initWorldOverlay(env.viewer);
|
||||
initDetection(env.viewer, [{ id: 'ais-live-vessels', getDetectableObjects: () => ships }], () => {});
|
||||
setOverlayEntries(
|
||||
'ais-live-vessels',
|
||||
ships.map((ship) => ambientVesselCard(ship.sourceId, ship.position)),
|
||||
);
|
||||
setMode('DENSE');
|
||||
for (let i = 0; i < 4; i++) settleFrame(env);
|
||||
env.ctx.calls.length = 0;
|
||||
settleFrame(env);
|
||||
|
||||
const winners = ships.filter((ship) => getOverlayPaintRect('ais-live-vessels', ship.cardEntryId));
|
||||
assert.ok(
|
||||
winners.length >= 1 && winners.length < ships.length,
|
||||
`the colliding cards leave some unpainted (${winners.length} of ${ships.length} painted)`,
|
||||
);
|
||||
for (const ship of ships) {
|
||||
const carded = winners.includes(ship);
|
||||
assert.equal(
|
||||
painted(ship.id),
|
||||
!carded,
|
||||
carded ? `${ship.id} draws no callout under its card` : `${ship.id} keeps its callout`,
|
||||
);
|
||||
}
|
||||
assert.equal(
|
||||
getDetectionDiagnostics().calloutRects.length,
|
||||
ships.length - winners.length,
|
||||
'only the callouts that painted are reported',
|
||||
);
|
||||
} finally {
|
||||
destroyDetection();
|
||||
destroyWorldOverlay();
|
||||
env.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('a callout fades out while its card fades in instead of vanishing at once', () => {
|
||||
const env = installEnvironment({ width: 1600, height: 900, dpr: 1 });
|
||||
const ship = shipWithCard('200', new Cesium.Cartesian3(0.1, 0.1, 6_356_752));
|
||||
try {
|
||||
initWorldOverlay(env.viewer);
|
||||
initDetection(env.viewer, [{ id: 'ais-live-vessels', getDetectableObjects: () => [ship] }], () => {});
|
||||
setMode('DENSE');
|
||||
for (let i = 0; i < 4; i++) settleFrame(env);
|
||||
const [alone] = getDetectionDiagnostics().calloutRects;
|
||||
assert.ok(alone.alpha >= 0.9, 'the callout starts fully visible');
|
||||
|
||||
setOverlayEntries('ais-live-vessels', [ambientVesselCard('200', ship.position)]);
|
||||
const alphas = [];
|
||||
for (let i = 0; i < 30; i++) {
|
||||
env.advance(16);
|
||||
env.postRender.raise();
|
||||
alphas.push(getDetectionDiagnostics().calloutRects[0]?.alpha ?? 0);
|
||||
}
|
||||
assert.ok(
|
||||
alphas.some((alpha) => alpha > 0.1 && alpha < alone.alpha - 0.1),
|
||||
`the callout passes through partial opacity: ${alphas.map((a) => a.toFixed(2)).join(' ')}`,
|
||||
);
|
||||
assert.equal(alphas.at(-1), 0, 'and is gone once the card is fully in');
|
||||
} finally {
|
||||
destroyDetection();
|
||||
destroyWorldOverlay();
|
||||
env.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('space-tier callouts take the heavier plate while air contacts keep the light one', () => {
|
||||
// Satellites sit over the high-albedo lit Earth disc far more often than
|
||||
// aircraft do, so the space tier carries the heavier backing. Losing the
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
FOCUS_EVIDENCE_DEV,
|
||||
VESSEL_LIFT_M,
|
||||
} from './policy.js';
|
||||
import { VESSEL_OVERLAY_SOURCE_ID } from '../../data/vesselLabels.js';
|
||||
|
||||
export function createQueries({
|
||||
vesselState,
|
||||
@@ -575,7 +576,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 (
|
||||
@@ -610,6 +611,9 @@ export function createQueries({
|
||||
id: record.name || record.mmsi || 'VESSEL',
|
||||
type: 'SEA',
|
||||
skipLabel: record === selected,
|
||||
// Detection hides the callout while this card paints.
|
||||
cardSourceId: VESSEL_OVERLAY_SOURCE_ID,
|
||||
cardEntryId: components.cards.vesselOverlayEntryId(record),
|
||||
klass: record.type
|
||||
? String(record.type).toUpperCase().slice(0, 14)
|
||||
: undefined,
|
||||
|
||||
@@ -212,6 +212,10 @@ const _paintQueue = [];
|
||||
const _paintItemPool = [];
|
||||
const _paintRectPool = [];
|
||||
const _paintRectByKey = new Map();
|
||||
/** @type {Map<string, Map<string, object>>} This frame's paint items by source and entry id. */
|
||||
const _queuedItemBySource = new Map();
|
||||
/** Index rows that stop painting are dropped this many frames apart. */
|
||||
const QUEUED_ITEM_PRUNE_FRAMES = 600;
|
||||
const _hitRects = [];
|
||||
const _paintedBySource = Object.create(null);
|
||||
const _paintedSourceKeys = [];
|
||||
@@ -1042,6 +1046,32 @@ export function getOverlayPaintRect(sourceId, entryId) {
|
||||
return rect && rect.key === key && rect.stamp === _frameStamp ? rect : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Opacity an entry paints with this frame, or 0 when it is not queued 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}
|
||||
*/
|
||||
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;
|
||||
// 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 (
|
||||
record.sourceAlpha *
|
||||
item.temporalAlpha *
|
||||
record.distanceAlpha *
|
||||
record.altitudeAlpha *
|
||||
(_keyhole ? entryKeyholeAlpha(record.entry, item.placement, _keyhole) : 1)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the topmost interactive world-overlay entry at CSS-pixel coords.
|
||||
* @param {number} x
|
||||
@@ -2223,7 +2253,34 @@ function addPaintItem(record, placement, temporalAlpha, selected) {
|
||||
item.zIndex = record.entry.zIndex;
|
||||
item.key = record.key;
|
||||
item.collisionHidden = false;
|
||||
item.stamp = _frameStamp;
|
||||
_paintQueue[_paintCount++] = item;
|
||||
// Keyed by source and id, not `record.key`, so a lookup builds no string.
|
||||
// Also holds cards fading out after they left `_records`.
|
||||
let bySource = _queuedItemBySource.get(record.entry.source);
|
||||
if (!bySource) {
|
||||
bySource = new Map();
|
||||
_queuedItemBySource.set(record.entry.source, bySource);
|
||||
}
|
||||
bySource.set(record.entry.id, item);
|
||||
}
|
||||
|
||||
/** Drop index rows for entries that stopped painting (bounded, not per frame). */
|
||||
function pruneQueuedItemIndex() {
|
||||
for (const [sourceId, bySource] of _queuedItemBySource) {
|
||||
for (const [entryId, item] of bySource) {
|
||||
// A pooled item that now paints another entry keeps a fresh stamp.
|
||||
const entry = item.record.entry;
|
||||
if (
|
||||
item.stamp !== _frameStamp ||
|
||||
entry.id !== entryId ||
|
||||
entry.source !== sourceId
|
||||
) {
|
||||
bySource.delete(entryId);
|
||||
}
|
||||
}
|
||||
if (bySource.size === 0) _queuedItemBySource.delete(sourceId);
|
||||
}
|
||||
}
|
||||
|
||||
function sourceCanPaint(sourceId) {
|
||||
@@ -2295,6 +2352,7 @@ function solveDomains(timestamp) {
|
||||
solveRevision += domain.arbiter.solveRevision;
|
||||
}
|
||||
_paintQueue.length = _paintCount;
|
||||
if (_frameStamp % QUEUED_ITEM_PRUNE_FRAMES === 0) pruneQueuedItemIndex();
|
||||
_solveDirty = false;
|
||||
_diagnostics.solveMs = solveMs;
|
||||
_diagnostics.selectedCount = selectedCount;
|
||||
@@ -2423,24 +2481,26 @@ function paintCustomLane(lane) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Keyhole edge fade of one placed entry (1 for entries that do not fade). */
|
||||
function entryKeyholeAlpha(entry, placement, keyhole) {
|
||||
if (entry.edgeFade !== 'keyhole') return 1;
|
||||
const keyholeX = placement.centerX - keyhole.centerX;
|
||||
const keyholeY = placement.centerY - keyhole.centerY;
|
||||
return keyholeX * keyholeX + keyholeY * keyholeY <=
|
||||
keyhole.radius * keyhole.radius
|
||||
? 1
|
||||
: keyholeLabelAlphaFromGeometry(
|
||||
placement.centerX,
|
||||
placement.centerY,
|
||||
keyhole,
|
||||
);
|
||||
}
|
||||
|
||||
function paintEntryItem(item, keyhole, sonar) {
|
||||
if (item.collisionHidden) return;
|
||||
const { record, placement } = item;
|
||||
const entry = record.entry;
|
||||
let keyholeAlpha = 1;
|
||||
if (entry.edgeFade === 'keyhole') {
|
||||
const keyholeX = placement.centerX - keyhole.centerX;
|
||||
const keyholeY = placement.centerY - keyhole.centerY;
|
||||
keyholeAlpha =
|
||||
keyholeX * keyholeX + keyholeY * keyholeY <=
|
||||
keyhole.radius * keyhole.radius
|
||||
? 1
|
||||
: keyholeLabelAlphaFromGeometry(
|
||||
placement.centerX,
|
||||
placement.centerY,
|
||||
keyhole,
|
||||
);
|
||||
}
|
||||
const keyholeAlpha = entryKeyholeAlpha(entry, placement, keyhole);
|
||||
// All five channels are normalized at their source. Keep the multiply on
|
||||
// the hot paint path so its intermediate doubles remain unboxed; the pure
|
||||
// `combinedOverlayAlpha` export still specifies/tests the same binding.
|
||||
@@ -2829,6 +2889,7 @@ export function destroyWorldOverlay() {
|
||||
_accessibleActivatorByKey.clear();
|
||||
_accessibilitySignature = '';
|
||||
_paintRectByKey.clear();
|
||||
_queuedItemBySource.clear();
|
||||
_paintCount = 0;
|
||||
_paintRectCount = 0;
|
||||
_hitRectCount = 0;
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
activateWorldOverlayAction,
|
||||
initWorldOverlay,
|
||||
subscribeWorldOverlayActions,
|
||||
getQueuedOverlayEntryAlpha,
|
||||
isOverlayPointVisible,
|
||||
normalizeOverlayEntry,
|
||||
paintLaneForOverlayEntry,
|
||||
@@ -1222,6 +1223,79 @@ test('custom detection lane receives the shared host frame and paints below ordi
|
||||
env.cleanup();
|
||||
});
|
||||
|
||||
test('a detection lane sees which cards this frame will paint before they publish paint rects', () => {
|
||||
// The detection lane paints before the cards publish this frame's paint
|
||||
// rects, so it reads the queued alpha instead.
|
||||
const env = installMockEnvironment({ width: 400, height: 300, dpr: 1 });
|
||||
initWorldOverlay(env.viewer);
|
||||
const seen = [];
|
||||
registerWorldOverlayPaintLane('detection', () => {
|
||||
seen.push({
|
||||
shown: getQueuedOverlayEntryAlpha('cards', 'SHOWN'),
|
||||
hidden: getQueuedOverlayEntryAlpha('hidden-cards', 'HIDDEN'),
|
||||
unknown: getQueuedOverlayEntryAlpha('cards', 'NOT-PUBLISHED'),
|
||||
rect: getOverlayPaintRect('cards', 'SHOWN'),
|
||||
});
|
||||
}, { id: 'detection', active: true, target: 'detection' });
|
||||
setOverlayEntries('cards', [selectedEntry('SHOWN', {
|
||||
position: positionAtScreen(200, 150),
|
||||
})]);
|
||||
setOverlayEntries('hidden-cards', [selectedEntry('HIDDEN', {
|
||||
position: positionAtScreen(100, 100),
|
||||
})]);
|
||||
setOverlaySourceVisible('hidden-cards', false);
|
||||
|
||||
env.postRender.raise();
|
||||
assert.deepEqual(seen[0], { shown: 1, hidden: 0, unknown: 0, rect: null },
|
||||
'the card is queued while its paint rect is still unpublished');
|
||||
assert.ok(getOverlayPaintRect('cards', 'SHOWN'), 'the card did paint after the lane');
|
||||
assert.equal(getOverlayPaintRect('hidden-cards', 'HIDDEN'), null);
|
||||
|
||||
clearOverlaySource('cards');
|
||||
env.postRender.raise();
|
||||
assert.equal(seen[1].shown, 0, 'a card cleared since the last frame is no longer queued');
|
||||
destroyWorldOverlay();
|
||||
assert.equal(getQueuedOverlayEntryAlpha('cards', 'SHOWN'), 0);
|
||||
env.cleanup();
|
||||
});
|
||||
|
||||
test('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
|
||||
@@ -2489,3 +2563,49 @@ test('promoting an ambient entry to selected paints it once with no fading dupli
|
||||
assert.equal(paint(), 1, 'deselect restores the ambient card without re-entry cooldown');
|
||||
} finally { env.cleanup(); }
|
||||
});
|
||||
|
||||
test('the queued-card index stays bounded while card identities churn', () => {
|
||||
// The index is internal: capture it through Map.prototype.set, like the
|
||||
// projection-record test.
|
||||
const originalSet = Map.prototype.set;
|
||||
let queuedIndex = null;
|
||||
Map.prototype.set = function captureQueuedIndex(key, value) {
|
||||
if (typeof key === 'string' && key.startsWith('churn-card-')
|
||||
&& value && typeof value === 'object' && 'temporalAlpha' in value
|
||||
&& 'placement' in value && 'stamp' in value) {
|
||||
queuedIndex = this;
|
||||
}
|
||||
return originalSet.call(this, key, value);
|
||||
};
|
||||
const env = installMockEnvironment({ width: 400, height: 300, dpr: 1 });
|
||||
try {
|
||||
initWorldOverlay(env.viewer);
|
||||
const FRAMES = 1300;
|
||||
for (let frame = 0; frame < FRAMES; frame++) {
|
||||
// One card whose id changes every frame reuses one pooled paint item,
|
||||
// like a vessel cohort republished as the camera moves.
|
||||
setOverlayEntries('cards', [selectedEntry(`churn-card-${frame}`, {
|
||||
position: positionAtScreen(200, 150),
|
||||
})]);
|
||||
env.advanceTime(16);
|
||||
env.postRender.raise();
|
||||
assert.equal(getQueuedOverlayEntryAlpha('cards', `churn-card-${frame}`), 1,
|
||||
'the live card is queued in the frame just painted');
|
||||
if (frame > 0) {
|
||||
assert.equal(getQueuedOverlayEntryAlpha('cards', `churn-card-${frame - 1}`), 0,
|
||||
'a stale row never reports the previous card as queued');
|
||||
}
|
||||
}
|
||||
assert.ok(queuedIndex, 'the queued-card index was never populated');
|
||||
// Rows are pruned every 600 painted frames, so with one new id per frame
|
||||
// the index holds at most one prune window of ids plus the live card.
|
||||
const bound = 600 + 1;
|
||||
assert.ok(
|
||||
queuedIndex.size <= bound,
|
||||
`queued-card index holds ${queuedIndex.size} rows after ${FRAMES} identities (bound ${bound})`,
|
||||
);
|
||||
} finally {
|
||||
Map.prototype.set = originalSet;
|
||||
env.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -1217,6 +1217,9 @@ function buildPhase6DetectionWorkload(count) {
|
||||
: `FL${String(180 + (index % 220)).padStart(3, '0')}`,
|
||||
type: ['AIR', 'SAT', 'SEA', 'VEH'][index % 4],
|
||||
tier: index % 17 === 0 ? 'military' : undefined,
|
||||
// Vessels name their ambient card, so each callout asks the host for it.
|
||||
cardSourceId: index % 4 === 2 ? 'ais-live-vessels' : undefined,
|
||||
cardEntryId: index % 4 === 2 ? `vessel:${index}` : undefined,
|
||||
});
|
||||
}
|
||||
return {
|
||||
|
||||
Reference in new issue
Block a user