From 47742f335d365962186d453e561b7ac83dab42d9 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Fri, 2 Oct 2026 14:20:52 -0700 Subject: [PATCH] Keep an inline panel drawing when its host reports it hidden Claude reports a panel on screen as hidden, which stopped the app's render loop and the browser's animation frames, so no map was drawn. An inline panel no longer suspends rendering when hidden, and draws from a timer while animation frames stop arriving. --- src/app/embed.js | 41 +++++++++++++++++++++++++++++++++++++++- src/app/embed.test.mjs | 43 +++++++++++++++++++++++++++++++++++++++++- src/app/tools.js | 6 ++++-- 3 files changed, 86 insertions(+), 4 deletions(-) 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) {