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,