From a2463ca2d2ea23a26621ea0cef3d82e0b957dd63 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Fri, 16 Oct 2026 17:37:58 +0400 Subject: [PATCH] Revert hiding vessel detection callouts under ambient cards The vessels layer hid the detection callout for every vessel whose card it published to the world overlay. The overlay paints only part of the published cards (shared budget, collisions, UI chrome), so vessels whose card was dropped lost all their text: with 60 vessels and Detection on, 21 showed only an arrow. Back to main's behaviour: the callout always draws. Hiding it only under a painted card is HAL-628. The translated speed unit in the callout stays. Co-Authored-By: Claude Opus 5.5 --- src/data/detection.js | 3 - src/data/detectionHost.test.mjs | 631 ++++++++++++++------- src/layers/vessels/detectionLabel.test.mjs | 18 +- src/layers/vessels/lifecycle.js | 1 - src/layers/vessels/queries.js | 6 +- src/layers/vessels/rendering.js | 23 - src/layers/vessels/state.js | 1 - 7 files changed, 433 insertions(+), 250 deletions(-) diff --git a/src/data/detection.js b/src/data/detection.js index 46926cf09..5c5e90396 100644 --- a/src/data/detection.js +++ b/src/data/detection.js @@ -1450,9 +1450,6 @@ function _drawOverlay(frame) { if (bracketAlpha > 0) protectedVisibleCount++; continue; } - // The source shows its own card for this object (e.g. an ambient vessel - // card): keep the bracket, draw no callout over the card. - if (obj.hideLabel) continue; const primary = String(obj.id || ''); const micro = String(obj.metric || ''); diff --git a/src/data/detectionHost.test.mjs b/src/data/detectionHost.test.mjs index d9660cbae..6d050474b 100644 --- a/src/data/detectionHost.test.mjs +++ b/src/data/detectionHost.test.mjs @@ -27,17 +27,25 @@ import { DETECTION_THEME_MAP } from '../overlays/worldOverlayTokens.js'; import { composeLabel, resolveTier } from './detectionDraw.js'; test('detection diagnostics count rendered fading rows instead of absent selected identities', () => { - assert.equal(countFadingRenderEntries([ - { selected: true }, - { selected: false }, - { selected: true }, - { selected: false }, - ]), 2); - assert.equal(countFadingRenderEntries([{ selected: true }, { selected: true }]), 0); + assert.equal( + countFadingRenderEntries([ + { selected: true }, + { selected: false }, + { selected: true }, + { selected: false }, + ]), + 2, + ); + assert.equal( + countFadingRenderEntries([{ selected: true }, { selected: true }]), + 0, + ); }); class MockEvent { - constructor() { this.listeners = new Set(); } + constructor() { + this.listeners = new Set(); + } addEventListener(listener) { this.listeners.add(listener); @@ -69,62 +77,127 @@ function mockContext(target, trace) { return { calls, font: '', - get filter() { return filter; }, - set filter(value) { filter = value; record('filter', value); }, + get filter() { + return filter; + }, + set filter(value) { + filter = value; + record('filter', value); + }, fillStyle: '', - get strokeStyle() { return strokeStyle; }, - set strokeStyle(value) { strokeStyle = value; record('strokeStyle', value); }, + get strokeStyle() { + return strokeStyle; + }, + set strokeStyle(value) { + strokeStyle = value; + record('strokeStyle', value); + }, globalAlpha: 1, - get globalCompositeOperation() { return globalCompositeOperation; }, - set globalCompositeOperation(value) { globalCompositeOperation = value; record('globalCompositeOperation', value); }, + get globalCompositeOperation() { + return globalCompositeOperation; + }, + set globalCompositeOperation(value) { + globalCompositeOperation = value; + record('globalCompositeOperation', value); + }, lineWidth: 1, - measureText(text) { return { width: String(text).length * 6 }; }, - setTransform(...args) { record('setTransform', ...args); }, - clearRect(...args) { record('clearRect', ...args); }, - save() { record('save'); }, - restore() { record('restore'); }, - translate(...args) { record('translate', ...args); }, - scale(...args) { record('scale', ...args); }, - beginPath() { record('beginPath'); }, - rect(...args) { record('rect', ...args); }, - clip(...args) { record('clip', ...args); }, - moveTo(...args) { record('moveTo', ...args); }, - lineTo(...args) { record('lineTo', ...args); }, - arc(...args) { record('arc', ...args); }, - arcTo(...args) { record('arcTo', ...args); }, - closePath() { record('closePath'); }, + measureText(text) { + return { width: String(text).length * 6 }; + }, + setTransform(...args) { + record('setTransform', ...args); + }, + clearRect(...args) { + record('clearRect', ...args); + }, + save() { + record('save'); + }, + restore() { + record('restore'); + }, + translate(...args) { + record('translate', ...args); + }, + scale(...args) { + record('scale', ...args); + }, + beginPath() { + record('beginPath'); + }, + rect(...args) { + record('rect', ...args); + }, + clip(...args) { + record('clip', ...args); + }, + moveTo(...args) { + record('moveTo', ...args); + }, + lineTo(...args) { + record('lineTo', ...args); + }, + arc(...args) { + record('arc', ...args); + }, + arcTo(...args) { + record('arcTo', ...args); + }, + closePath() { + record('closePath'); + }, // The style is recorded AT fill time, not on assignment, so a pin can prove // which plate token actually reached the canvas rather than which one was // set at some point during the frame. globalAlpha rides along for the same // reason: the backdrop feather is an alpha, not a colour. - fill(path) { record('fill', path, this.fillStyle, this.globalAlpha); }, - stroke(path) { record('stroke', path, this.strokeStyle, this.lineWidth); }, - fillRect(...args) { record('fillRect', ...args); }, - fillText(...args) { record('fillText', ...args); }, - drawImage(...args) { record('drawImage', ...args); }, + fill(path) { + record('fill', path, this.fillStyle, this.globalAlpha); + }, + stroke(path) { + record('stroke', path, this.strokeStyle, this.lineWidth); + }, + fillRect(...args) { + record('fillRect', ...args); + }, + fillText(...args) { + record('fillText', ...args); + }, + drawImage(...args) { + record('drawImage', ...args); + }, }; } // `search` seeds window.location.search. The detection mode banner is developer // telemetry gated behind ?detectDebug=1, so any test that uses the banner as its // "did this frame repaint" probe must opt in explicitly. -function installEnvironment({ width = 800, height = 600, dpr = 2, search = '' } = {}) { +function installEnvironment({ + width = 800, + height = 600, + dpr = 2, + search = '', +} = {}) { const byId = new Map(); const paintTrace = []; const ctx = mockContext('shared', paintTrace); const detectionCtx = mockContext('detection', paintTrace); - const originalPerformance = Object.getOwnPropertyDescriptor(globalThis, 'performance'); + const originalPerformance = Object.getOwnPropertyDescriptor( + globalThis, + 'performance', + ); const originalDateNow = Date.now; const originalPath2D = globalThis.Path2D; let currentTime = 1_000; let performanceStep = 0; Object.defineProperty(globalThis, 'performance', { configurable: true, - value: { now: () => { - const value = currentTime; - currentTime += performanceStep; - return value; - } }, + value: { + now: () => { + const value = currentTime; + currentTime += performanceStep; + return value; + }, + }, }); Date.now = () => currentTime; globalThis.Path2D = MockPath2D; @@ -167,7 +240,9 @@ function installEnvironment({ width = 800, height = 600, dpr = 2, search = '' } register(this); } - getBoundingClientRect() { return this._rect; } + getBoundingClientRect() { + return this._rect; + } getContext() { if (this.tagName !== 'CANVAS') return null; return this.id === 'world-overlay-detection-surface' ? detectionCtx : ctx; @@ -180,13 +255,17 @@ function installEnvironment({ width = 800, height = 600, dpr = 2, search = '' } remove() { if (this.parentElement) { - this.parentElement.children = this.parentElement.children.filter((child) => child !== this); + this.parentElement.children = this.parentElement.children.filter( + (child) => child !== this, + ); } unregister(this); this.parentElement = null; } - get nextSibling() { return null; } + get nextSibling() { + return null; + } } function register(element) { @@ -200,14 +279,26 @@ function installEnvironment({ width = 800, height = 600, dpr = 2, search = '' } } const body = new MockElement('body'); - body.classList = { contains() { return false; } }; + body.classList = { + contains() { + return false; + }, + }; const document = { body, - createElement(tagName) { return new MockElement(tagName); }, - getElementById(id) { return byId.get(id) || null; }, - querySelector(selector) { return byId.get(selector.slice(1)) || null; }, + createElement(tagName) { + return new MockElement(tagName); + }, + getElementById(id) { + return byId.get(id) || null; + }, + querySelector(selector) { + return byId.get(selector.slice(1)) || null; + }, querySelectorAll(selector) { - const element = selector.startsWith('#') ? byId.get(selector.slice(1)) : null; + const element = selector.startsWith('#') + ? byId.get(selector.slice(1)) + : null; return element ? [element] : []; }, }; @@ -216,10 +307,18 @@ function installEnvironment({ width = 800, height = 600, dpr = 2, search = '' } location: { search }, addEventListener() {}, removeEventListener() {}, - getComputedStyle() { return { display: 'block', visibility: 'visible', opacity: '1' }; }, + getComputedStyle() { + return { display: 'block', visibility: 'visible', opacity: '1' }; + }, + }; + globalThis.ResizeObserver = class { + observe() {} + disconnect() {} + }; + globalThis.MutationObserver = class { + observe() {} + disconnect() {} }; - globalThis.ResizeObserver = class { observe() {} disconnect() {} }; - globalThis.MutationObserver = class { observe() {} disconnect() {} }; globalThis.document = document; globalThis.window = window; @@ -241,7 +340,9 @@ function installEnvironment({ width = 800, height = 600, dpr = 2, search = '' } positionWC: new Cesium.Cartesian3(0, 0, 10_000_000), positionCartographic: { height: 1_000_000 }, viewMatrix: Cesium.Matrix4.clone(Cesium.Matrix4.IDENTITY), - frustum: { projectionMatrix: Cesium.Matrix4.clone(Cesium.Matrix4.IDENTITY) }, + frustum: { + projectionMatrix: Cesium.Matrix4.clone(Cesium.Matrix4.IDENTITY), + }, moveEnd: new MockEvent(), }, scene: { postRender, requestRender() {} }, @@ -254,8 +355,12 @@ function installEnvironment({ width = 800, height = 600, dpr = 2, search = '' } ctx, detectionCtx, paintTrace, - advance(ms) { currentTime += ms; }, - setPerformanceStep(ms) { performanceStep = ms; }, + advance(ms) { + currentTime += ms; + }, + setPerformanceStep(ms) { + performanceStep = ms; + }, cleanup() { destroyDetection(); destroyWorldOverlay(); @@ -266,7 +371,8 @@ function installEnvironment({ width = 800, height = 600, dpr = 2, search = '' } delete globalThis.window; delete globalThis.ResizeObserver; delete globalThis.MutationObserver; - if (originalPerformance) Object.defineProperty(globalThis, 'performance', originalPerformance); + if (originalPerformance) + Object.defineProperty(globalThis, 'performance', originalPerformance); else delete globalThis.performance; }, }; @@ -295,14 +401,16 @@ function militaryLayer() { return { id: 'military', getDetectableObjects() { - return [{ - position: new Cesium.Cartesian3(0, 0, 6_356_752), - sourceId: 'military-pin', - id: 'MIL-PIN', - metric: 'FL450', - type: 'AIR', - tier: 'military', - }]; + return [ + { + position: new Cesium.Cartesian3(0, 0, 6_356_752), + sourceId: 'military-pin', + id: 'MIL-PIN', + metric: 'FL450', + type: 'AIR', + tier: 'military', + }, + ]; }, }; } @@ -358,7 +466,9 @@ test('detection lifecycle re-hosts unchanged painters behind the sole host liste assert.equal(env.postRender.listeners.size, 1); assert.equal(env.document.getElementById('detection-overlay'), null); assert.ok(env.document.getElementById('world-overlay-canvas')); - const surface = env.document.getElementById('world-overlay-detection-surface'); + const surface = env.document.getElementById( + 'world-overlay-detection-surface', + ); const root = env.document.getElementById('world-overlay-root'); const mainCanvas = env.document.getElementById('world-overlay-canvas'); assert.ok(surface); @@ -381,19 +491,36 @@ test('detection lifecycle re-hosts unchanged painters behind the sole host liste assert.equal(first.profile, 'DENSE'); assert.ok(Array.isArray(first.calloutRects)); if (first.calloutRects.length) { - const x=first.calloutRects[0].x; - first.calloutRects[0].x=NaN; - assert.equal(getDetectionDiagnostics().calloutRects[0].x,x,'QA owns a copy, never a live plate'); + const x = first.calloutRects[0].x; + first.calloutRects[0].x = NaN; + assert.equal( + getDetectionDiagnostics().calloutRects[0].x, + x, + 'QA owns a copy, never a live plate', + ); } assert.equal(first.observationCount, 2); assert.equal(first.didSolve, true); assert.ok(first.solveRevision > 0); assert.ok(first.collectiveLabelBudget > 0); - assert.ok(first.bracketOpacityCounts.full + first.bracketOpacityCounts.partial > 0); - assert.ok(env.detectionCtx.calls.some(([name, text]) => name === 'fillText' - && String(text).startsWith('DENSE VIS:'))); - assert.ok(env.detectionCtx.calls.some(([name, path]) => name === 'stroke' && path instanceof MockPath2D)); - assert.ok(env.detectionCtx.calls.some(([name]) => name === 'fillRect'), 'scanlines and banner still paint'); + assert.ok( + first.bracketOpacityCounts.full + first.bracketOpacityCounts.partial > 0, + ); + assert.ok( + env.detectionCtx.calls.some( + ([name, text]) => + name === 'fillText' && String(text).startsWith('DENSE VIS:'), + ), + ); + assert.ok( + env.detectionCtx.calls.some( + ([name, path]) => name === 'stroke' && path instanceof MockPath2D, + ), + ); + assert.ok( + env.detectionCtx.calls.some(([name]) => name === 'fillRect'), + 'scanlines and banner still paint', + ); assert.equal(env.detectionCtx.globalCompositeOperation, 'source-over'); assert.equal(env.detectionCtx.filter, 'none'); assert.equal(surface.style.display, 'block'); @@ -409,8 +536,16 @@ test('detection lifecycle re-hosts unchanged painters behind the sole host liste // screen-blended sensor surface: `screen` can only lighten, so a callout's // dark backing plate is a no-op over sunlit ground and the text dissolves // into it. Brackets, banner and scanlines stay on the sensor surface. - assert.ok(env.ctx.calls.some(([name, text]) => name === 'fillText' && text === 'TEST0')); - assert.ok(!env.detectionCtx.calls.some(([name, text]) => name === 'fillText' && text === 'TEST0')); + assert.ok( + env.ctx.calls.some( + ([name, text]) => name === 'fillText' && text === 'TEST0', + ), + ); + assert.ok( + !env.detectionCtx.calls.some( + ([name, text]) => name === 'fillText' && text === 'TEST0', + ), + ); env.advance(1); env.postRender.raise(); assert.ok(getDetectionDiagnostics().solveRevision > first.solveRevision); @@ -420,7 +555,10 @@ test('detection lifecycle re-hosts unchanged painters behind the sole host liste assert.equal(getMode(), 'SPARSE'); assert.equal(getDetectionDiagnostics().profile, 'SPARSE'); assert.equal(getDetectionDiagnostics().allocationStrategy, 'WEIGHTED'); - assert.ok(env.detectionCtx.calls.some(([name]) => name === 'arc'), 'sparse focus ring remains available'); + assert.ok( + env.detectionCtx.calls.some(([name]) => name === 'arc'), + 'sparse focus ring remains available', + ); setMode('BALANCED'); env.postRender.raise(); @@ -429,9 +567,12 @@ test('detection lifecycle re-hosts unchanged painters behind the sole host liste env.postRender.raise(); assert.equal(getDetectionDiagnostics().profile, 'DENSE'); - const bannerCount = () => env.detectionCtx.calls.filter(([name, text]) => ( - name === 'fillText' && /^(SPARSE|BALANCED|DENSE) VIS:/.test(String(text)) - )).length; + const bannerCount = () => + env.detectionCtx.calls.filter( + ([name, text]) => + name === 'fillText' && + /^(SPARSE|BALANCED|DENSE) VIS:/.test(String(text)), + ).length; const beforeSuspend = bannerCount(); suspendDetection('intercity'); assert.equal(isDetectionSuspended(), true); @@ -448,9 +589,12 @@ test('detection lifecycle re-hosts unchanged painters behind the sole host liste // These are the exact engine calls made by military style presets after // their production UI gate chooses CRT/NVG/FLIR defaults. const expectedThemes = { - retro: 'contrast(1.08) saturate(1.04) drop-shadow(0 0 3px rgba(255, 176, 56, 0.45))', - surveillance: 'contrast(1.12) saturate(1.12) drop-shadow(0 0 3px rgba(120, 255, 120, 0.42))', - thermal: 'contrast(1.1) saturate(1.08) drop-shadow(0 0 3px rgba(255, 224, 170, 0.42))', + retro: + 'contrast(1.08) saturate(1.04) drop-shadow(0 0 3px rgba(255, 176, 56, 0.45))', + surveillance: + 'contrast(1.12) saturate(1.12) drop-shadow(0 0 3px rgba(120, 255, 120, 0.42))', + thermal: + 'contrast(1.1) saturate(1.08) drop-shadow(0 0 3px rgba(255, 224, 170, 0.42))', }; for (const style of ['retro', 'surveillance', 'thermal']) { setMode('OFF'); @@ -476,17 +620,28 @@ test('detection lifecycle re-hosts unchanged painters behind the sole host liste assert.equal(getMode(), 'OFF'); assert.equal(surface.style.display, 'none'); assert.equal(env.postRender.listeners.size, 1); - assert.ok(modes.includes('SPARSE') && modes.includes('BALANCED') && modes.includes('DENSE')); + assert.ok( + modes.includes('SPARSE') && + modes.includes('BALANCED') && + modes.includes('DENSE'), + ); destroyDetection(); - assert.equal(env.postRender.listeners.size, 1, 'detection never owns the listener'); + assert.equal( + env.postRender.listeners.size, + 1, + 'detection never owns the listener', + ); setMode('DENSE'); env.postRender.raise(); assert.equal(env.document.getElementById('detection-overlay'), null); destroyWorldOverlay(); assert.equal(env.postRender.listeners.size, 0); assert.equal(env.document.getElementById('world-overlay-canvas'), null); - assert.equal(env.document.getElementById('world-overlay-detection-surface'), null); + assert.equal( + env.document.getElementById('world-overlay-detection-surface'), + null, + ); } finally { env.cleanup(); } @@ -499,16 +654,22 @@ test('callouts stop painting the moment the last detectable object goes away', ( // clear the replay buffer, or the final callsigns stay stranded on the // shared canvas after the last data layer is switched off. const env = installEnvironment(); - let objects = [{ - position: new Cesium.Cartesian3(0, 0, 6_356_752), - sourceId: 'lonely-1', - id: 'LASTONE', - metric: 'FL120', - type: 'AIR', - }]; + let objects = [ + { + position: new Cesium.Cartesian3(0, 0, 6_356_752), + sourceId: 'lonely-1', + id: 'LASTONE', + metric: 'FL120', + type: 'AIR', + }, + ]; try { initWorldOverlay(env.viewer); - initDetection(env.viewer, [{ id: 'flights', getDetectableObjects: () => objects }], () => {}); + initDetection( + env.viewer, + [{ id: 'flights', getDetectableObjects: () => objects }], + () => {}, + ); setMode('DENSE'); env.advance(250); env.postRender.raise(); @@ -517,7 +678,9 @@ test('callouts stop painting the moment the last detectable object goes away', ( env.advance(250); env.postRender.raise(); assert.ok( - env.ctx.calls.some(([name, text]) => name === 'fillText' && text === 'LASTONE'), + env.ctx.calls.some( + ([name, text]) => name === 'fillText' && text === 'LASTONE', + ), 'the callout paints on the shared canvas while the contact exists', ); @@ -528,7 +691,9 @@ test('callouts stop painting the moment the last detectable object goes away', ( env.advance(250); env.postRender.raise(); assert.ok( - !env.ctx.calls.some(([name, text]) => name === 'fillText' && text === 'LASTONE'), + !env.ctx.calls.some( + ([name, text]) => name === 'fillText' && text === 'LASTONE', + ), 'an empty field must drop the replay buffer, not strand the last callsign', ); } finally { @@ -595,12 +760,24 @@ test('space-tier callouts take the heavier plate while air contacts keep the lig theme.calloutPlateSpace, 'the fixture is only meaningful while the two tiers differ', ); - const painted = (text) => env.ctx.calls.some(([name, value]) => name === 'fillText' && value === text); - assert.ok(painted('AIRONE') && painted('SATONE'), 'both tiers reached the frame under test'); + const painted = (text) => + env.ctx.calls.some( + ([name, value]) => name === 'fillText' && value === text, + ); + assert.ok( + painted('AIRONE') && painted('SATONE'), + 'both tiers reached the frame under test', + ); const fills = sharedFillStyles(env); - assert.ok(fills.includes(theme.calloutPlate), 'the air contact keeps the light plate'); - assert.ok(fills.includes(theme.calloutPlateSpace), 'the space contact takes the heavier plate'); + assert.ok( + fills.includes(theme.calloutPlate), + 'the air contact keeps the light plate', + ); + assert.ok( + fills.includes(theme.calloutPlateSpace), + 'the space contact takes the heavier plate', + ); } finally { env.cleanup(); } @@ -617,11 +794,16 @@ test('surveillance military tier reaches the dedicated render target as shipped env.postRender.raise(); assert.ok( - env.detectionCtx.calls.some(([name, value]) => name === 'strokeStyle' && value === '#ff5a47'), + env.detectionCtx.calls.some( + ([name, value]) => name === 'strokeStyle' && value === '#ff5a47', + ), 'the production projection/tier/batched-bracket paint path emits surveillance military red', ); - assert.ok(env.detectionCtx.calls.some(([name, path]) => name === 'stroke' - && path instanceof MockPath2D)); + assert.ok( + env.detectionCtx.calls.some( + ([name, path]) => name === 'stroke' && path instanceof MockPath2D, + ), + ); } finally { env.cleanup(); } @@ -633,44 +815,71 @@ test('pathological detection paint holds alternate frames without freezing share try { initWorldOverlay(env.viewer); initDetection(env.viewer, [detectableLayer()], () => {}); - setOverlayEntries('valve-card', [{ - id: 'ambient-card', - position: new Cesium.Cartesian3(0, 0, 0), - variant: 'label', - title: 'AMBIENT-CONTINUES', - selected: true, - protected: true, - horizonCull: false, - edgeFade: 'none', - }]); + setOverlayEntries('valve-card', [ + { + id: 'ambient-card', + position: new Cesium.Cartesian3(0, 0, 0), + variant: 'label', + title: 'AMBIENT-CONTINUES', + selected: true, + protected: true, + horizonCull: false, + edgeFade: 'none', + }, + ]); setMode('DENSE'); env.advance(250); env.setPerformanceStep(30); env.postRender.raise(); env.postRender.raise(); - const detectionClears = env.detectionCtx.calls.filter(([name]) => name === 'clearRect').length; - const detectionBanners = env.detectionCtx.calls.filter(([name, text]) => name === 'fillText' - && String(text).startsWith('DENSE VIS:')).length; - const sharedClears = env.ctx.calls.filter(([name]) => name === 'clearRect').length; - const sharedCards = env.ctx.calls.filter(([name, text]) => name === 'fillText' - && text === 'AMBIENT-CONTINUES').length; + const detectionClears = env.detectionCtx.calls.filter( + ([name]) => name === 'clearRect', + ).length; + const detectionBanners = env.detectionCtx.calls.filter( + ([name, text]) => + name === 'fillText' && String(text).startsWith('DENSE VIS:'), + ).length; + const sharedClears = env.ctx.calls.filter( + ([name]) => name === 'clearRect', + ).length; + const sharedCards = env.ctx.calls.filter( + ([name, text]) => name === 'fillText' && text === 'AMBIENT-CONTINUES', + ).length; env.postRender.raise(); - assert.equal(env.detectionCtx.calls.filter(([name]) => name === 'clearRect').length, detectionClears, - 'the host preserves detection pixels on the held odd frame'); - assert.equal(env.detectionCtx.calls.filter(([name, text]) => name === 'fillText' - && String(text).startsWith('DENSE VIS:')).length, detectionBanners); - assert.equal(env.ctx.calls.filter(([name]) => name === 'clearRect').length, sharedClears + 1, - 'the shared surface still clears'); - assert.equal(env.ctx.calls.filter(([name, text]) => name === 'fillText' - && text === 'AMBIENT-CONTINUES').length, sharedCards + 1, - 'the shared lane still repaints'); + assert.equal( + env.detectionCtx.calls.filter(([name]) => name === 'clearRect').length, + detectionClears, + 'the host preserves detection pixels on the held odd frame', + ); + assert.equal( + env.detectionCtx.calls.filter( + ([name, text]) => + name === 'fillText' && String(text).startsWith('DENSE VIS:'), + ).length, + detectionBanners, + ); + assert.equal( + env.ctx.calls.filter(([name]) => name === 'clearRect').length, + sharedClears + 1, + 'the shared surface still clears', + ); + assert.equal( + env.ctx.calls.filter( + ([name, text]) => name === 'fillText' && text === 'AMBIENT-CONTINUES', + ).length, + sharedCards + 1, + 'the shared lane still repaints', + ); assert.equal(getDetectionDiagnostics().throttleSkipCount, 1); env.postRender.raise(); - assert.equal(env.detectionCtx.calls.filter(([name]) => name === 'clearRect').length, - detectionClears + 1, 'the next even frame repaints detection'); + assert.equal( + env.detectionCtx.calls.filter(([name]) => name === 'clearRect').length, + detectionClears + 1, + 'the next even frame repaints detection', + ); assert.equal(getDetectionDiagnostics().throttleSkipCount, 1); } finally { env.cleanup(); @@ -678,9 +887,14 @@ test('pathological detection paint holds alternate frames without freezing share }); test('detection cannot resurrect a private canvas, listener, matrix, resize, clear, or UI inventory', () => { - const source = readFileSync(new URL('./detection.js', import.meta.url), 'utf8'); - const uiSource = readShellSource() - + '\n' + readFileSync(new URL('../ui/visualPresets.js', import.meta.url), 'utf8'); + const source = readFileSync( + new URL('./detection.js', import.meta.url), + 'utf8', + ); + const uiSource = + readShellSource() + + '\n' + + readFileSync(new URL('../ui/visualPresets.js', import.meta.url), 'utf8'); assert.doesNotMatch(source, /createElement\(\s*['"]canvas['"]\s*\)/); assert.doesNotMatch(source, /postRender\.addEventListener/); assert.doesNotMatch(source, /['"]detection-overlay['"]/); @@ -690,11 +904,20 @@ test('detection cannot resurrect a private canvas, listener, matrix, resize, cle assert.doesNotMatch(source, /CALLOUT_OCCLUSION_SELECTORS/); assert.doesNotMatch(source, /_ctx\.(?:filter|globalCompositeOperation)\s*=/); assert.match(source, /_hostSurface\.style\.mixBlendMode = _theme\.blend;/); - assert.match(source, /_hostSurface\.style\.filter = `\$\{_theme\.filter\} drop-shadow\(0 0 \$\{GLOW_PX\}px \$\{_theme\.glow\}\)`;/); + assert.match( + source, + /_hostSurface\.style\.filter = `\$\{_theme\.filter\} drop-shadow\(0 0 \$\{GLOW_PX\}px \$\{_theme\.glow\}\)`;/, + ); assert.match(source, /const LABEL_SOLVE_INTERVAL_MS = 125;/); assert.match(source, /const _labelArbiter = new LabelArbiter\(\);/); - assert.match(source, /const clipW = vp3 \* px \+ vp7 \* py \+ vp11 \* pz \+ vp15;/); - assert.match(source, /registerWorldOverlayPaintLane\('detection', _paintDetectionLane/); + assert.match( + source, + /const clipW = vp3 \* px \+ vp7 \* py \+ vp11 \* pz \+ vp15;/, + ); + assert.match( + source, + /registerWorldOverlayPaintLane\('detection', _paintDetectionLane/, + ); assert.match(source, /target: 'detection'/); assert.match(source, /shouldPaint: _shouldPaintDetectionLane/); // The three military styles used to carry three copies of these numbers. @@ -711,7 +934,10 @@ test('detection cannot resurrect a private canvas, listener, matrix, resize, cle 3, 'CRT, NVG, and FLIR retain their Dense auto-enable defaults', ); - assert.match(uiSource, /preset\.detection && !this\._detectionUserOverridden/); + assert.match( + uiSource, + /preset\.detection && !this\._detectionUserOverridden/, + ); }); // ── Developer telemetry gate (2026-08-20 QA hunt) ─────────────────────────── @@ -725,15 +951,22 @@ test('detectionDebugRequested parses the query-string gate and nothing else', () assert.equal(detectionDebugRequested(''), false); assert.equal(detectionDebugRequested('?detectDebug=0'), false); assert.equal(detectionDebugRequested('?detectDebug'), false); - assert.equal(detectionDebugRequested('?detectdebug=1'), false, 'the flag is case-sensitive'); + assert.equal( + detectionDebugRequested('?detectdebug=1'), + false, + 'the flag is case-sensitive', + ); assert.equal(detectionDebugRequested(undefined), false); assert.equal(detectionDebugRequested(null), false); }); test('the mode banner is absent by default and present behind the flag', () => { - const bannerPaints = (env) => env.detectionCtx.calls.filter(([name, text]) => ( - name === 'fillText' && /^(SPARSE|BALANCED|DENSE) VIS:/.test(String(text)) - )).length; + const bannerPaints = (env) => + env.detectionCtx.calls.filter( + ([name, text]) => + name === 'fillText' && + /^(SPARSE|BALANCED|DENSE) VIS:/.test(String(text)), + ).length; const painted = (search) => { const env = installEnvironment({ search }); @@ -745,7 +978,9 @@ test('the mode banner is absent by default and present behind the flag', () => { env.postRender.raise(); // The overlay is alive either way — brackets still stroke. assert.ok( - env.detectionCtx.calls.some(([name, path]) => name === 'stroke' && path instanceof MockPath2D), + env.detectionCtx.calls.some( + ([name, path]) => name === 'stroke' && path instanceof MockPath2D, + ), 'detection still paints its contacts regardless of the debug gate', ); return bannerPaints(env); @@ -756,8 +991,15 @@ test('the mode banner is absent by default and present behind the flag', () => { } }; - assert.equal(painted(''), 0, 'users must never see the engine telemetry banner'); - assert.ok(painted('?detectDebug=1') > 0, 'the flag brings the telemetry back'); + assert.equal( + painted(''), + 0, + 'users must never see the engine telemetry banner', + ); + assert.ok( + painted('?detectDebug=1') > 0, + 'the flag brings the telemetry back', + ); }); test('detection telemetry stays reachable programmatically with the banner hidden', () => { @@ -794,7 +1036,11 @@ test('civilian and military AIR brackets cover front, left, and right at Sparse type: 'AIR', })); initWorldOverlay(env.viewer); - initDetection(env.viewer, [{ id: layerId, getDetectableObjects: () => objects }], () => {}); + initDetection( + env.viewer, + [{ id: layerId, getDetectableObjects: () => objects }], + () => {}, + ); setDetectionTuning({ densityPct: 25 }); setMode('SPARSE'); settleFrame(env); @@ -807,8 +1053,10 @@ test('civilian and military AIR brackets cover front, left, and right at Sparse assert.equal(diagnostics.visibleCount, 3); assert.equal(diagnostics.densityPct, 25); assert.equal(diagnostics.profile, 'SPARSE'); - assert.ok(diagnostics.selectedCount <= diagnostics.collectiveLabelBudget, - 'Sparse keeps its documented budget instead of silently becoming Dense'); + assert.ok( + diagnostics.selectedCount <= diagnostics.collectiveLabelBudget, + 'Sparse keeps its documented budget instead of silently becoming Dense', + ); } finally { destroyDetection(); destroyWorldOverlay(); @@ -833,11 +1081,17 @@ function backdropLayer() { return [ { position: new Cesium.Cartesian3(-0.4, 0.2, 6_356_752), - sourceId: 'ground-1', id: 'GROUNDED', metric: 'FL100', type: 'AIR', + sourceId: 'ground-1', + id: 'GROUNDED', + metric: 'FL100', + type: 'AIR', }, { position: new Cesium.Cartesian3(0.4, -0.2, 20_000_000), - sourceId: 'sky-1', id: 'SKYBACK', metric: 'FL400', type: 'AIR', + sourceId: 'sky-1', + id: 'SKYBACK', + metric: 'FL400', + type: 'AIR', }, ]; }, @@ -859,8 +1113,14 @@ test('the backdrop feather reaches the canvas as a lighter plate against sky', ( env.ctx.calls.length = 0; settleFrame(env); - const painted = (text) => env.ctx.calls.some(([name, value]) => name === 'fillText' && value === text); - assert.ok(painted('GROUNDED') && painted('SKYBACK'), 'both backdrops reached the frame under test'); + const painted = (text) => + env.ctx.calls.some( + ([name, value]) => name === 'fillText' && value === text, + ); + assert.ok( + painted('GROUNDED') && painted('SKYBACK'), + 'both backdrops reached the frame under test', + ); const plate = DETECTION_THEME_MAP._default.calloutPlate; const plateAlphas = env.ctx.calls @@ -871,7 +1131,10 @@ test('the backdrop feather reaches the canvas as a lighter plate against sky', ( const heaviest = Math.max(...plateAlphas); const lightest = Math.min(...plateAlphas); assert.ok(heaviest > 0, 'the grounded contact still gets a plate'); - assert.ok(lightest > 0, 'the sky contact keeps a whisper rather than vanishing'); + assert.ok( + lightest > 0, + 'the sky contact keeps a whisper rather than vanishing', + ); // Reverting the feature makes both plates equal, which fails here first. assert.ok( lightest < heaviest * 0.5, @@ -937,9 +1200,23 @@ test('a transit vehicle is a contact: box, route, mode and operator on the share painted.some((text) => String(text).includes('34 KM/H')), 'and so does what the vehicle is doing', ); - assert.ok(env.ctx.calls.some(([name, , color, width]) => name === 'stroke' && color === '#5EF08A' && width === 1.25)); - assert.ok(env.ctx.calls.some(([name, , color, width]) => name === 'stroke' && color === '#05080C' && width === 3.25)); - assert.ok(!env.detectionCtx.calls.some(([name, , color]) => name === 'stroke' && color === '#5EF08A')); + assert.ok( + env.ctx.calls.some( + ([name, , color, width]) => + name === 'stroke' && color === '#5EF08A' && width === 1.25, + ), + ); + assert.ok( + env.ctx.calls.some( + ([name, , color, width]) => + name === 'stroke' && color === '#05080C' && width === 3.25, + ), + ); + assert.ok( + !env.detectionCtx.calls.some( + ([name, , color]) => name === 'stroke' && color === '#5EF08A', + ), + ); // The operator travels on the object for the two-tier card form, which is // where `klass` is read; the host's track label does not paint it. assert.equal(composeLabel(objects[0]).secondary, 'MBTA · BUS 34 KM/H'); @@ -955,52 +1232,6 @@ test('a transit vehicle is a contact: box, route, mode and operator on the share } }); -test('hideLabel keeps the bracket but paints no callout over the source card', () => { - // C6: an ambient vessel card already names the vessel with its speed; the - // callout drawn beside it covered that card's speed and course. - const env = installEnvironment(); - const objects = [ - { - position: new Cesium.Cartesian3(0, 0, 6_356_752), - sourceId: '200', - id: 'GOLDEN BEAR', - type: 'VEH', - metric: '10 kn', - hideLabel: true, - }, - { - position: new Cesium.Cartesian3(0.5, 0.5, 6_356_752), - sourceId: '300', - id: 'BAY PILOT', - type: 'VEH', - metric: '4 kn', - }, - ]; - try { - initWorldOverlay(env.viewer); - initDetection( - env.viewer, - [{ id: 'transit', getDetectableObjects: () => objects }], - () => {}, - ); - setMode('DENSE'); - env.advance(250); - env.postRender.raise(); - env.advance(250); - env.postRender.raise(); - const painted = env.ctx.calls - .filter(([name]) => name === 'fillText') - .map(([, text]) => String(text)); - assert.ok(painted.includes('BAY PILOT'), `painted ${JSON.stringify(painted)}`); - assert.ok( - !painted.some((text) => text.includes('GOLDEN BEAR')), - `no callout under the card; painted ${JSON.stringify(painted)}`, - ); - } finally { - env.cleanup(); - } -}); - test('transit contacts take the vehicle tier, not the civil-air one', () => { assert.equal(resolveTier({ type: 'VEH' }), 'vehicle'); assert.equal(resolveTier({ type: 'AIR' }), 'civil'); diff --git a/src/layers/vessels/detectionLabel.test.mjs b/src/layers/vessels/detectionLabel.test.mjs index 2707f3a18..85d17cc6d 100644 --- a/src/layers/vessels/detectionLabel.test.mjs +++ b/src/layers/vessels/detectionLabel.test.mjs @@ -1,6 +1,4 @@ -// C6: a vessel with an ambient card gets no detection callout under it (the -// card already shows name, speed and course); the speed unit of a callout -// follows the UI language. +// The speed unit of a vessel's detection callout follows the UI language. import test from 'node:test'; import assert from 'node:assert/strict'; import * as Cesium from 'cesium'; @@ -61,20 +59,6 @@ function detectable(cameraZ) { } } -test('a vessel with a card on screen draws no detection label', () => { - const objects = detectable(2_500); - const hidden = Object.values(objects).filter((object) => object.hideLabel); - assert.equal(hidden.length, 1, 'exactly the carded vessel hides its label'); - const shown = Object.values(objects).filter((object) => !object.hideLabel); - assert.equal(shown.length, 1, 'the vessel without a card keeps its label'); - assert.equal(hidden[0].skipLabel, false, 'not treated as the tracked vessel'); -}); - -test('past the card fade the detection label comes back', () => { - const objects = detectable(4_000_000); - assert.ok(Object.values(objects).every((object) => !object.hideLabel)); -}); - test('ru: the callout speed unit is translated', () => { setLocale('ru'); try { diff --git a/src/layers/vessels/lifecycle.js b/src/layers/vessels/lifecycle.js index ffbe45c72..bb566320b 100644 --- a/src/layers/vessels/lifecycle.js +++ b/src/layers/vessels/lifecycle.js @@ -129,7 +129,6 @@ export function createLifecycle({ state.lastFocusUpdate = 0; state.activeFocusCount = 0; state.activeLabelCount = 0; - state.cardedRecords = new Set(); state.selectedRecord = null; state.trail = null; state.trailPositions = []; diff --git a/src/layers/vessels/queries.js b/src/layers/vessels/queries.js index d677cc453..d8aaf0870 100644 --- a/src/layers/vessels/queries.js +++ b/src/layers/vessels/queries.js @@ -471,7 +471,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, hideLabel: boolean}>} + * @returns {Array<{position: Cesium.Cartesian3, id: string, type: string, skipLabel: boolean}>} */ getDetectableObjects(options = {}) { if ( @@ -492,7 +492,6 @@ export function createQueries({ const start = seed % stride; const selected = state.selectedRecord; - const carded = state.cardedRecords; const result = []; for (let idx = 0; idx < records.length; idx += 1) { if ((idx - start) % stride !== 0) continue; @@ -507,9 +506,6 @@ export function createQueries({ id: record.name || record.mmsi || 'VESSEL', type: 'SEA', skipLabel: record === selected, - // An open ambient card already names the vessel with its speed and - // course; a detection label under it would only cover them. - hideLabel: carded?.has(record) === true, klass: record.type ? String(record.type).toUpperCase().slice(0, 14) : undefined, diff --git a/src/layers/vessels/rendering.js b/src/layers/vessels/rendering.js index b595f3925..f29ced5aa 100644 --- a/src/layers/vessels/rendering.js +++ b/src/layers/vessels/rendering.js @@ -6,7 +6,6 @@ import { VESSEL_OVERLAY_SOURCE_ID, applyVesselOverlayPolicy, VESSEL_CARD_FADE_DISTANCE_M, - VESSEL_CARD_FADE_START_RATIO, } from '../../data/vesselLabels.js'; import { cameraPoseSignature, @@ -367,7 +366,6 @@ export function createRendering({ if (!scene || !records.length || maxLabels <= 0) { state.activeLabelCount = entries.length; - state.cardedRecords = new Set(); publishVesselOverlayEntries(entries); return; } @@ -405,9 +403,6 @@ export function createRendering({ if (screen) accepted.push({ x: screen.x, y: screen.y }); } const ranked = [...cells.values()].sort((a, b) => b.score - a.score); - // Vessels with an ambient card: the card already shows name, speed and - // course, so the detection overlay draws no label of its own under it. - const carded = new Set(); for (const candidate of ranked) { if (entries.length >= maxLabels) break; if ( @@ -420,10 +415,8 @@ export function createRendering({ continue; accepted.push({ x: candidate.x, y: candidate.y }); entries.push(components.cards.buildVesselCard(candidate.record)); - if (cardFullyShown(scene, candidate.record)) carded.add(candidate.record); } state.activeLabelCount = entries.length; - state.cardedRecords = carded; publishVesselOverlayEntries(entries); } @@ -476,22 +469,6 @@ export function createRendering({ ); } - /** - * Whether the host paints this ambient card at full strength: inside the - * start of its distance fade (applyVesselOverlayPolicy). Farther out the - * card fades away and the detection label stays the vessel's only text. - */ - function cardFullyShown(scene, record) { - const camera = scene.camera?.positionWC; - const visual = getVisual(record); - const position = visual.billboard?.position || visual.position; - if (!camera || !position) return true; - return ( - Cesium.Cartesian3.distance(camera, position) < - VESSEL_CARD_FADE_DISTANCE_M * VESSEL_CARD_FADE_START_RATIO - ); - } - function labelPriority(record, selected) { if (record === selected) return 100000; let score = 0; diff --git a/src/layers/vessels/state.js b/src/layers/vessels/state.js index 186f263c1..cbb81766e 100644 --- a/src/layers/vessels/state.js +++ b/src/layers/vessels/state.js @@ -70,7 +70,6 @@ export function createVesselState({ source, services }) { activeFocusCount: 0, activeLabelCount: 0, /** @type {Set} Records that have an ambient card this pass. */ - cardedRecords: new Set(), selectedRecord: null, /** @type {{setPositions: Function, clear: Function, destroy: Function}|null} Selected-vessel fading trail */ trail: null,