diff --git a/src/app/embed.js b/src/app/embed.js index 71566f54c..4ef83f7fc 100644 --- a/src/app/embed.js +++ b/src/app/embed.js @@ -45,6 +45,39 @@ export function isEmbeddedInline() { return globalThis.GEV_EMBED_INLINE === true; } +// A panel keeps drawing at about this rate when its host stops animation +// frames; see keepPanelRendering. +const PANEL_FRAME_MS = 33; +const MISSED_FRAMES_MS = 250; + +/** + * Keep the globe drawing in an inline panel. Some hosts report a panel on + * screen as hidden, which stops the browser's animation frames and with them + * Cesium's render loop; while frames stop arriving, draw from a timer. + * Returns a function that stops it. + */ +export function keepPanelRendering( + viewer, + { windowRef = globalThis.window, now = () => performance.now() } = {}, +) { + let lastFrame = now(); + let frameRequest = null; + const onFrame = () => { + lastFrame = now(); + frameRequest = windowRef.requestAnimationFrame(onFrame); + }; + frameRequest = windowRef.requestAnimationFrame(onFrame); + const timer = windowRef.setInterval(() => { + if (now() - lastFrame < MISSED_FRAMES_MS || viewer.isDestroyed?.()) return; + viewer.resize(); + viewer.render(); + }, PANEL_FRAME_MS); + return () => { + windowRef.clearInterval(timer); + windowRef.cancelAnimationFrame(frameRequest); + }; +} + const delay = (ms, signal) => new Promise((resolve) => { const timer = setTimeout(resolve, ms); @@ -169,6 +202,9 @@ export function installViews({ windowRef.document.body.classList.add('ui-embed'); shell.setCleanView?.(true); + const stopRendering = isEmbeddedInline() + ? keepPanelRendering(viewer, { windowRef }) + : () => {}; // A framing page talks to the app across frames; an inline panel shares // the page with it and talks through the page's own window. const peer = isEmbeddedInline() ? windowRef : windowRef.parent; @@ -209,5 +245,8 @@ export function installViews({ void ready.then(() => { if (!signal?.aborted) post({ type: EMBED_READY_MESSAGE }); }); - return () => windowRef.removeEventListener('message', onMessage); + return () => { + stopRendering(); + windowRef.removeEventListener('message', onMessage); + }; } diff --git a/src/app/embed.test.mjs b/src/app/embed.test.mjs index b2e9b3c3f..3010fdb56 100644 --- a/src/app/embed.test.mjs +++ b/src/app/embed.test.mjs @@ -1,6 +1,11 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { applyView, installViews, isEmbedded } from './embed.js'; +import { + applyView, + installViews, + isEmbedded, + keepPanelRendering, +} from './embed.js'; import { createView } from '../view/index.js'; const fakeViewer = () => { @@ -159,6 +164,12 @@ test('an app loaded inline into a panel page talks through its own window', asyn windowRef.postMessage = (message) => posted.push(message); windowRef.document = { body: { classList: new Set() } }; windowRef.document.body.classList.add = Set.prototype.add; + Object.assign(windowRef, { + requestAnimationFrame: () => 1, + cancelAnimationFrame() {}, + setInterval: () => 1, + clearInterval() {}, + }); globalThis.GEV_EMBED_INLINE = true; try { assert.equal(isEmbedded({ search: '' }), true); @@ -205,3 +216,33 @@ test('annotations in a link are drawn after it restores, embedded or not', async ['annotate_map', { annotations: [{ type: 'pin', target: 'Austin' }] }], ]); }); + +test('a panel keeps drawing from a timer while animation frames stop', () => { + let clock = 0; + let tick = null; + let frame = null; + const windowRef = { + requestAnimationFrame: (fn) => ((frame = fn), 1), + cancelAnimationFrame: () => (frame = null), + setInterval: (fn) => ((tick = fn), 1), + clearInterval: () => (tick = null), + }; + const drawn = []; + const viewer = { + resize: () => drawn.push('resize'), + render: () => drawn.push('render'), + }; + const stop = keepPanelRendering(viewer, { windowRef, now: () => clock }); + // Frames arrive: the render loop draws, so the timer does nothing. + clock = 100; + frame(); + tick(); + assert.deepEqual(drawn, []); + // Frames stop, as in a panel its host reports hidden: the timer draws. + clock = 400; + tick(); + assert.deepEqual(drawn, ['resize', 'render']); + stop(); + assert.equal(tick, null); + assert.equal(frame, null); +}); diff --git a/src/app/tools.js b/src/app/tools.js index 32cc3cf71..25d763e2d 100644 --- a/src/app/tools.js +++ b/src/app/tools.js @@ -4,7 +4,7 @@ import { initDrawTool } from '../annotations/drawTool.js'; import { initImageryBoxTool } from '../ui/imageryBoxTool.js'; import { createRecentImageryPanel } from '../ui/recentImagery.js'; import { initGevVoiceCommands } from '../voice/gevRealtime.js'; -import { installViews } from './embed.js'; +import { installViews, isEmbeddedInline } from './embed.js'; import { installScopeMask, destroyScopeMask } from '../scopeMask.js'; import { installRenderGovernor, @@ -119,7 +119,9 @@ export function createApplicationTools({ // GPU. Holder/data state is untouched, so return is seamless: restore // the loop, refresh the one DOM surface we gated, render a frame. const syncVisibilitySuspension = () => { - const hidden = document.hidden; + // A panel's host may report it hidden while it is on screen; the panel + // keeps drawing itself (see keepPanelRendering in embed.js). + const hidden = document.hidden && !isEmbeddedInline(); viewer.useDefaultRenderLoop = !hidden; cockpitCloudEffects?.setSuspended?.(hidden); if (!hidden) {