Merge branch 'main' into upstream/terrain-partial-heights

This commit is contained in:
quanticsoul4772 authored and GitHub committed 2026-10-28 11:24:19 -07:00
commit 49b65ddc55
8 files changed
+212 -3

No files matched your search

@@ -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).
+5
View File
@@ -798,6 +798,11 @@ rendered detection set. The legacy manager retains panel helper compatibility;
registration sealing, restoration order and failed-transition rollback remain registration sealing, restoration order and failed-transition rollback remain
unchanged. 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 Application scene setup owns request services, terrain/floor caches and annotation
lookup state. Controls, layers and voice share those owners, and cancellation lookup state. Controls, layers and voice share those owners, and cancellation
clears caches and removes map-stack listeners. Standalone setup supplies the clears caches and removes map-stack listeners. Standalone setup supplies the
+29
View File
@@ -94,6 +94,35 @@ test('data setup seals the caller catalog before controls can restore and drains
assert.equal(dataManager.layers.size, 0); 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', () => { test('controls bind catalog instances rather than similarly named defaults', () => {
const ids = [ const ids = [
'traffic', 'traffic',
+4 -1
View File
@@ -1,6 +1,7 @@
import { LayerLifecycle } from '../data/lifecycle.js'; import { LayerLifecycle } from '../data/lifecycle.js';
import { LayerPresentation } from './layerPresentation.js'; import { LayerPresentation } from './layerPresentation.js';
import { createCyberSonarScene } from '../cyberSonarScene.js'; import { createCyberSonarScene } from '../cyberSonarScene.js';
import { panelAwareVisibility } from './embed.js';
/** Register the application layer catalog before allowing state restoration. */ /** Register the application layer catalog before allowing state restoration. */
export function createApplicationData({ export function createApplicationData({
scene: { viewer, mapStackController }, scene: { viewer, mapStackController },
@@ -13,7 +14,9 @@ export function createApplicationData({
// Initialize data layer manager // Initialize data layer manager
const dataManager = new LayerLifecycle(viewer, { const dataManager = new LayerLifecycle(viewer, {
allowQaRegistration, 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, getSourceAvailability: catalog?.getSourceAvailability,
}); });
defer(async () => { defer(async () => {
+22
View File
@@ -45,6 +45,28 @@ export function isEmbeddedInline() {
return globalThis.GEV_EMBED_INLINE === true; 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 // A panel keeps drawing at about this rate when its host stops animation
// frames; see keepPanelRendering. // frames; see keepPanelRendering.
const PANEL_FRAME_MS = 33; const PANEL_FRAME_MS = 33;
+56
View File
@@ -5,7 +5,9 @@ import {
installViews, installViews,
isEmbedded, isEmbedded,
keepPanelRendering, keepPanelRendering,
panelAwareVisibility,
} from './embed.js'; } from './embed.js';
import { LayerLifecycle } from '../data/lifecycle.js';
import { createView } from '../view/index.js'; import { createView } from '../view/index.js';
const fakeViewer = () => { 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 () => { test('annotations in a link are drawn after it restores, embedded or not', async () => {
const { calls, run } = recorder(); const { calls, run } = recorder();
const an = encodeURIComponent( const an = encodeURIComponent(
+5 -2
View File
@@ -39,6 +39,7 @@ import {
} from './hudLayouts.js'; } from './hudLayouts.js';
import { formatNumber, msg, t } from './i18n/index.js'; import { formatNumber, msg, t } from './i18n/index.js';
import { localizeLayerName } from './ui/layerPanel.js'; import { localizeLayerName } from './ui/layerPanel.js';
import { isEmbeddedInline } from './app/embed.js';
/** Color palettes keyed by shader mode; applied as CSS custom properties. */ /** Color palettes keyed by shader mode; applied as CSS custom properties. */
const HUD_COLORS = { const HUD_COLORS = {
@@ -363,9 +364,11 @@ export class IntelHUD {
dot.style.visibility = this._recBlinkState ? 'visible' : 'hidden'; dot.style.visibility = this._recBlinkState ? 'visible' : 'hidden';
}, 800); }, 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(() => { this._updateInterval = setInterval(() => {
if (!this._visible) return; if (!this._visible || (document.hidden && !isEmbeddedInline())) return;
this._updateCameraData(); this._updateCameraData();
}, 250); }, 250);
+87
View File
@@ -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();
}
});