diff --git a/changelog.d/1344-inline-panel-visibility.md b/changelog.d/1344-inline-panel-visibility.md new file mode 100644 index 000000000..7d0fd6689 --- /dev/null +++ b/changelog.d/1344-inline-panel-visibility.md @@ -0,0 +1,4 @@ +- Pause the HUD's camera readout while the page is hidden, as polled layers + already do. An app embedded inline in a panel keeps both updating, because the + panel's host can report the page as hidden while it is on screen (nu-stats, + #1344). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 00352adbc..3e7f333a1 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -798,6 +798,11 @@ rendered detection set. The legacy manager retains panel helper compatibility; registration sealing, restoration order and failed-transition rollback remain unchanged. +Polled layer refreshes and the HUD's camera readout pause while the page is +hidden. An app embedded inline in a panel keeps both running, because the +panel's host can report the page as hidden while it is on screen. This is the +same rule the render loop follows (`keepPanelRendering` in `app/embed.js`). + Application scene setup owns request services, terrain/floor caches and annotation lookup state. Controls, layers and voice share those owners, and cancellation clears caches and removes map-stack listeners. Standalone setup supplies the diff --git a/src/app/catalog.test.mjs b/src/app/catalog.test.mjs index ff6d73db5..2b558939b 100644 --- a/src/app/catalog.test.mjs +++ b/src/app/catalog.test.mjs @@ -94,6 +94,35 @@ test('data setup seals the caller catalog before controls can restore and drains assert.equal(dataManager.layers.size, 0); }); +test('data setup keeps layers polling in an inline panel its host reports hidden', async (t) => { + const previous = globalThis.document; + globalThis.document = { + hidden: true, + visibilityState: 'hidden', + getElementById: () => null, + addEventListener() {}, + removeEventListener() {}, + }; + t.after(() => { + delete globalThis.GEV_EMBED_INLINE; + if (previous === undefined) delete globalThis.document; + else globalThis.document = previous; + }); + const releases = []; + const { dataManager } = createApplicationData({ + scene: { viewer: {} }, + controls: { styleManager: { attachDataManager() {} } }, + catalog: createLayerCatalog([], []), + defer: (release) => releases.push(release), + }); + const visibility = dataManager._visibilityTarget; + assert.equal(visibility.hidden, true, 'a hidden page pauses outside a panel'); + globalThis.GEV_EMBED_INLINE = true; + assert.equal(visibility.hidden, false); + assert.equal(visibility.visibilityState, 'visible'); + for (const release of releases.reverse()) await release(); +}); + test('controls bind catalog instances rather than similarly named defaults', () => { const ids = [ 'traffic', diff --git a/src/app/data.js b/src/app/data.js index e6c7d25e0..022839cd2 100644 --- a/src/app/data.js +++ b/src/app/data.js @@ -1,6 +1,7 @@ import { LayerLifecycle } from '../data/lifecycle.js'; import { LayerPresentation } from './layerPresentation.js'; import { createCyberSonarScene } from '../cyberSonarScene.js'; +import { panelAwareVisibility } from './embed.js'; /** Register the application layer catalog before allowing state restoration. */ export function createApplicationData({ scene: { viewer, mapStackController }, @@ -13,7 +14,9 @@ export function createApplicationData({ // Initialize data layer manager const dataManager = new LayerLifecycle(viewer, { allowQaRegistration, - visibilityTarget: document, + // A panel's host may report it hidden while it is on screen; its layers + // keep polling (see keepPanelRendering in embed.js). + visibilityTarget: panelAwareVisibility(document), getSourceAvailability: catalog?.getSourceAvailability, }); defer(async () => { diff --git a/src/app/embed.js b/src/app/embed.js index f0d6cad0a..21e685a91 100644 --- a/src/app/embed.js +++ b/src/app/embed.js @@ -45,6 +45,28 @@ export function isEmbeddedInline() { return globalThis.GEV_EMBED_INLINE === true; } +/** + * `doc`'s visibility for work that pauses in a hidden page. A panel's host + * may report it hidden while it is on screen (see keepPanelRendering), so an + * inline panel always reads as visible. + */ +export function panelAwareVisibility(doc = globalThis.document) { + const hidden = () => + (doc.hidden === true || doc.visibilityState === 'hidden') && + !isEmbeddedInline(); + return { + get hidden() { + return hidden(); + }, + get visibilityState() { + return hidden() ? 'hidden' : 'visible'; + }, + addEventListener: (type, listener) => doc.addEventListener(type, listener), + removeEventListener: (type, listener) => + doc.removeEventListener(type, listener), + }; +} + // A panel keeps drawing at about this rate when its host stops animation // frames; see keepPanelRendering. const PANEL_FRAME_MS = 33; diff --git a/src/app/embed.test.mjs b/src/app/embed.test.mjs index 5ef80090d..54799567e 100644 --- a/src/app/embed.test.mjs +++ b/src/app/embed.test.mjs @@ -5,7 +5,9 @@ import { installViews, isEmbedded, keepPanelRendering, + panelAwareVisibility, } from './embed.js'; +import { LayerLifecycle } from '../data/lifecycle.js'; import { createView } from '../view/index.js'; const fakeViewer = () => { @@ -211,6 +213,60 @@ test('an app loaded inline into a panel page talks through its own window', asyn } }); +test('polled layers keep refreshing in an inline panel its host reports hidden', async (t) => { + const listeners = new Set(); + const doc = { + hidden: true, + visibilityState: 'hidden', + addEventListener: (_type, fn) => listeners.add(fn), + removeEventListener: (_type, fn) => listeners.delete(fn), + }; + const settle = async () => { + for (let i = 0; i < 3; i++) await Promise.resolve(); + }; + t.mock.timers.enable({ apis: ['setInterval', 'Date'], now: 1000 }); + let updates = 0; + const manager = new LayerLifecycle( + {}, + { visibilityTarget: panelAwareVisibility(doc) }, + ); + manager.register({ + id: 'flights', + name: 'flights', + icon: '', + source: 'test', + updateInterval: 1000, + async init() {}, + enable() {}, + disable() {}, + async update() { + updates++; + await Promise.resolve(); + }, + getStats: () => ({ count: 0, lastUpdate: null }), + }); + globalThis.GEV_EMBED_INLINE = true; + try { + await manager.setEnabled('flights', true); + await settle(); + const enabled = updates; + t.mock.timers.tick(1000); + await settle(); + t.mock.timers.tick(1000); + await settle(); + assert.equal(updates, enabled + 2, 'the panel keeps polling'); + + delete globalThis.GEV_EMBED_INLINE; + t.mock.timers.tick(1000); + await settle(); + assert.equal(updates, enabled + 2, 'outside a panel a hidden page pauses'); + } finally { + delete globalThis.GEV_EMBED_INLINE; + await manager.destroyAll(); + } + assert.equal(listeners.size, 0); +}); + test('annotations in a link are drawn after it restores, embedded or not', async () => { const { calls, run } = recorder(); const an = encodeURIComponent( diff --git a/src/hud.js b/src/hud.js index 954e3ea94..792fd30d9 100644 --- a/src/hud.js +++ b/src/hud.js @@ -39,6 +39,7 @@ import { } from './hudLayouts.js'; import { formatNumber, msg, t } from './i18n/index.js'; import { localizeLayerName } from './ui/layerPanel.js'; +import { isEmbeddedInline } from './app/embed.js'; /** Color palettes keyed by shader mode; applied as CSS custom properties. */ const HUD_COLORS = { @@ -363,9 +364,11 @@ export class IntelHUD { dot.style.visibility = this._recBlinkState ? 'visible' : 'hidden'; }, 800); - // Camera-derived data — 4 updates/second (250ms) + // Camera-derived data — 4 updates/second (250ms). Skipped in a hidden + // page, except an inline panel, whose host may report it hidden while it + // is on screen (see keepPanelRendering in app/embed.js). this._updateInterval = setInterval(() => { - if (!this._visible) return; + if (!this._visible || (document.hidden && !isEmbeddedInline())) return; this._updateCameraData(); }, 250); diff --git a/src/hudPageVisibility.test.mjs b/src/hudPageVisibility.test.mjs new file mode 100644 index 000000000..b205431fc --- /dev/null +++ b/src/hudPageVisibility.test.mjs @@ -0,0 +1,87 @@ +// The HUD's 250 ms telemetry readout skips ticks in a hidden page, except in +// an inline panel, whose host may report it hidden while it is on screen. +// +// hud.js imports `mgrs`, which Node's ESM loader cannot read named exports +// from, so a module hook swaps it for a stub before the import (the same +// setup as hudAltitudeDatum.test.mjs). +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { registerHooks } from 'node:module'; + +const MGRS_STUB_URL = 'gev-test-stub:mgrs'; +registerHooks({ + resolve(specifier, context, next) { + if (specifier === 'mgrs') return { url: MGRS_STUB_URL, shortCircuit: true }; + return next(specifier, context); + }, + load(url, context, next) { + if (url === MGRS_STUB_URL) { + return { + format: 'module', + shortCircuit: true, + source: 'export function forward() { return "10SEG55776339"; }\nexport default { forward };\n', + }; + } + return next(url, context); + }, +}); + +const { IntelHUD } = await import('./hud.js'); + +function installHiddenPage() { + const elements = new Map( + ['hud-alt', 'hud-summary', 'hud-mgrs', 'hud-latlon', 'hud-bottom-line', 'hud-gsd', 'hud-coll', 'hud-ona', 'hud-mode'] + .map((id) => [id, { textContent: '' }]), + ); + const previousDocument = globalThis.document; + globalThis.document = { + hidden: true, + visibilityState: 'hidden', + getElementById: (id) => elements.get(id) ?? null, + querySelectorAll: () => [], + addEventListener() {}, + removeEventListener() {}, + }; + const viewer = { + camera: { + pitch: -Math.PI / 2, + positionCartographic: { latitude: 0.7, longitude: -1.2, height: 500 }, + computeViewRectangle: () => undefined, + moveStart: { addEventListener() {}, removeEventListener() {} }, + moveEnd: { addEventListener() {}, removeEventListener() {} }, + }, + }; + return { + alt: () => elements.get('hud-alt').textContent, + viewer, + restore() { + if (previousDocument === undefined) delete globalThis.document; + else globalThis.document = previousDocument; + }, + }; +} + +test('the HUD readout pauses in a hidden page but keeps updating in an inline panel', (t) => { + t.mock.timers.enable({ apis: ['setInterval'] }); + const page = installHiddenPage(); + let hud; + try { + hud = new IntelHUD(page.viewer); + hud._visible = true; + + t.mock.timers.tick(250); + assert.equal(page.alt(), '', 'a hidden page skips the telemetry tick'); + + globalThis.GEV_EMBED_INLINE = true; + t.mock.timers.tick(250); + assert.match( + page.alt(), + /^ALT: 500m/, + 'an inline panel reported hidden keeps its readout updating', + ); + } finally { + delete globalThis.GEV_EMBED_INLINE; + hud?.destroy(); + page.restore(); + } +});