mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 18:34:02 +00:00
Main now has #1282, which pauses periodic layer polling in a hidden page with its own catch-up and listener cleanup, so this PR drops its duplicate lifecycle.js change and test in favor of main's. Review fixes: - data.js passes panelAwareVisibility(document) (new in app/embed.js) as the lifecycle's visibility target, so an inline panel whose host reports it hidden keeps polling. The HUD's 250 ms readout follows the same document.hidden && !isEmbeddedInline() rule as tools.js. - Tests: embed.test.mjs (layers poll in an inline panel reported hidden, pause without the flag) and hudPageVisibility.test.mjs (HUD readout). Both use mock timers and clean up in finally. - changelog.d/1344-inline-panel-visibility.md and a note in docs/CURRENT-STATE.md. Co-Authored-By: Claude Opus 5.5 <[email protected]>
328 lines
10 KiB
JavaScript
328 lines
10 KiB
JavaScript
/**
|
|
* Embed mode and views in the running app.
|
|
*
|
|
* With `?embed=1` the app shows only the globe: clean view, with the HUD,
|
|
* welcome and setup prompts hidden. A page that frames it changes what it
|
|
* shows by posting `{ type: 'gev:view', id, view }`; the app applies the view
|
|
* through its own actions and answers `{ type: 'gev:view-applied', id, ok,
|
|
* steps }`. It announces `{ type: 'gev:ready' }` once it can take views.
|
|
*
|
|
* Annotations in a share link are drawn once the link has been restored,
|
|
* embedded or not.
|
|
*/
|
|
|
|
import * as Cesium from 'cesium';
|
|
import { announceNavigationAuthority } from '../navigationPolicy.js';
|
|
import { annotationsFromParams, createView } from '../view/index.js';
|
|
|
|
const EMBED_VIEW_MESSAGE = 'gev:view';
|
|
const EMBED_APPLIED_MESSAGE = 'gev:view-applied';
|
|
const EMBED_READY_MESSAGE = 'gev:ready';
|
|
|
|
const FOLLOW_LAYERS = {
|
|
aircraft: 'flights',
|
|
military_aircraft: 'military',
|
|
satellite: 'satellites',
|
|
};
|
|
const FOLLOW_ATTEMPTS = 20;
|
|
const FOLLOW_RETRY_MS = 1000;
|
|
const FLIGHT_SECONDS = 2;
|
|
|
|
/**
|
|
* Whether this page shows the app embedded: `?embed=1` when another page
|
|
* frames it, or inline when a panel page loads the app into itself and sets
|
|
* `globalThis.GEV_EMBED_INLINE` first.
|
|
*/
|
|
export function isEmbedded(location = globalThis.location) {
|
|
return (
|
|
isEmbeddedInline() ||
|
|
new URLSearchParams(location?.search || '').get('embed') === '1'
|
|
);
|
|
}
|
|
|
|
/** Whether a panel page loaded the app into itself. */
|
|
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;
|
|
const MISSED_FRAMES_MS = 250;
|
|
|
|
/** A render error's details, including the plain objects workers report. */
|
|
function describeError(error) {
|
|
if (error instanceof Error)
|
|
return `${error.name}: ${error.message}\n${error.stack}`;
|
|
try {
|
|
return JSON.stringify(error, Object.getOwnPropertyNames(error ?? {}));
|
|
} catch {
|
|
return String(error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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);
|
|
// Cesium stops drawing after a render error, and so does the timer.
|
|
// Report the error in full: workers report plain objects, which Cesium
|
|
// prints as [object Object].
|
|
let failed = false;
|
|
const removeErrorListener = viewer.scene?.renderError?.addEventListener(
|
|
(_scene, error) => {
|
|
failed = true;
|
|
console.error(
|
|
"[God's Eye View panel] render error:",
|
|
describeError(error),
|
|
);
|
|
},
|
|
);
|
|
const timer = windowRef.setInterval(() => {
|
|
if (
|
|
failed ||
|
|
now() - lastFrame < MISSED_FRAMES_MS ||
|
|
viewer.isDestroyed?.()
|
|
)
|
|
return;
|
|
viewer.resize();
|
|
viewer.render();
|
|
}, PANEL_FRAME_MS);
|
|
return () => {
|
|
windowRef.clearInterval(timer);
|
|
windowRef.cancelAnimationFrame(frameRequest);
|
|
removeErrorListener?.();
|
|
};
|
|
}
|
|
|
|
const delay = (ms, signal) =>
|
|
new Promise((resolve) => {
|
|
const timer = setTimeout(resolve, ms);
|
|
signal?.addEventListener(
|
|
'abort',
|
|
() => {
|
|
clearTimeout(timer);
|
|
resolve();
|
|
},
|
|
{ once: true },
|
|
);
|
|
});
|
|
|
|
/** Fly the camera to a view's camera and resolve when the flight ends. */
|
|
function flyTo(viewer, camera) {
|
|
announceNavigationAuthority('embed-view');
|
|
return new Promise((resolve) => {
|
|
viewer.camera.flyTo({
|
|
destination: Cesium.Cartesian3.fromDegrees(
|
|
camera.lon,
|
|
camera.lat,
|
|
camera.altitude_m,
|
|
),
|
|
orientation: {
|
|
heading: Cesium.Math.toRadians(camera.heading_deg),
|
|
pitch: Cesium.Math.toRadians(camera.pitch_deg),
|
|
roll: 0,
|
|
},
|
|
duration: FLIGHT_SECONDS,
|
|
complete: () => resolve(true),
|
|
cancel: () => resolve(false),
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Make the app show a view: style and map, exactly the view's layers,
|
|
* annotations, the camera, and the followed entity. Each step runs through the
|
|
* app's own actions (`run(name, args)`); a failed step is reported and the
|
|
* rest still run. Resolves to the steps and whether each succeeded.
|
|
*/
|
|
export async function applyView(
|
|
view,
|
|
{ viewer, dataManager, run, signal, retryMs = FOLLOW_RETRY_MS },
|
|
) {
|
|
const steps = [];
|
|
const act = async (name, args) => {
|
|
let result = null;
|
|
try {
|
|
result = await run(name, args);
|
|
} catch (error) {
|
|
result = { ok: false, error: error?.message || String(error) };
|
|
}
|
|
steps.push({
|
|
step: name,
|
|
ok: result?.ok !== false,
|
|
...(result?.error ? { error: result.error } : {}),
|
|
});
|
|
return result;
|
|
};
|
|
if (view.style) await act('set_visual_style', { style: view.style });
|
|
if (view.map) await act('set_map_stack', { stack: view.map });
|
|
const wanted = new Set(view.layers);
|
|
for (const layer of dataManager.getAll()) {
|
|
if (layer.enabled && !wanted.has(layer.id))
|
|
await act('set_layer_visibility', { layerId: layer.id, enabled: false });
|
|
}
|
|
for (const layerId of view.layers) {
|
|
if (!dataManager.isEnabled(layerId))
|
|
await act('set_layer_visibility', { layerId, enabled: true });
|
|
}
|
|
if (!view.follow) await act('stop_tracking', {});
|
|
// Annotations go first: drawing them may frame the marks, and the view's
|
|
// own camera should have the last word.
|
|
await act('clear_annotations', {});
|
|
if (view.annotations.length)
|
|
await act('annotate_map', { annotations: [...view.annotations] });
|
|
if (!view.follow) {
|
|
steps.push({ step: 'camera', ok: await flyTo(viewer, view.camera) });
|
|
return steps;
|
|
}
|
|
// A followed entity owns the camera, and a camera flight would end the
|
|
// follow, so fly to the view only while the entity is not there yet. It
|
|
// appears once its layer has data; retry until then.
|
|
const layerId = FOLLOW_LAYERS[view.follow.kind];
|
|
const follow = async () => {
|
|
try {
|
|
return (
|
|
(await run('track_entity', { query: view.follow.id, layerId }))?.ok ===
|
|
true
|
|
);
|
|
} catch {
|
|
return false;
|
|
}
|
|
};
|
|
let followed = await follow();
|
|
if (!followed) {
|
|
steps.push({ step: 'camera', ok: await flyTo(viewer, view.camera) });
|
|
for (let attempt = 1; attempt < FOLLOW_ATTEMPTS && !followed; attempt++) {
|
|
if (signal?.aborted) break;
|
|
await delay(retryMs, signal);
|
|
followed = await follow();
|
|
}
|
|
}
|
|
steps.push({ step: 'follow', ok: followed });
|
|
if (followed && view.follow.cockpit)
|
|
await act('control_cockpit', { action: 'enter', targetLayer: layerId });
|
|
return steps;
|
|
}
|
|
|
|
/**
|
|
* Install embed mode when the page asks for it, and draw any annotations the
|
|
* opening link carries. Returns a function that removes the message handler.
|
|
*/
|
|
export function installViews({
|
|
shell,
|
|
viewer,
|
|
dataManager,
|
|
run,
|
|
signal,
|
|
location = globalThis.location,
|
|
windowRef = globalThis.window,
|
|
}) {
|
|
const ready = Promise.resolve(shell.initialRestorePromise).catch(() => {});
|
|
const linked = annotationsFromParams(
|
|
new URLSearchParams(String(location?.hash || '').replace(/^#/, '')),
|
|
);
|
|
if (linked.length)
|
|
void ready.then(() => {
|
|
if (!signal?.aborted) void run('annotate_map', { annotations: linked });
|
|
});
|
|
if (!isEmbedded(location)) return () => {};
|
|
|
|
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;
|
|
// Answers go back only to the origin that asked. An inline panel posts to
|
|
// its own window, and a sandboxed framing page has no origin to name.
|
|
const post = (message, origin = '*') => {
|
|
if (peer && (peer !== windowRef || isEmbeddedInline()))
|
|
peer.postMessage(message, origin);
|
|
};
|
|
// Views apply one at a time, in the order they arrive.
|
|
let queue = ready;
|
|
const onMessage = (event) => {
|
|
if (event.source !== peer || event.data?.type !== EMBED_VIEW_MESSAGE)
|
|
return;
|
|
const { id = null } = event.data;
|
|
const replyOrigin =
|
|
isEmbeddedInline() || !event.origin || event.origin === 'null'
|
|
? '*'
|
|
: event.origin;
|
|
let view;
|
|
try {
|
|
view = createView(event.data.view);
|
|
} catch (error) {
|
|
post(
|
|
{
|
|
type: EMBED_APPLIED_MESSAGE,
|
|
id,
|
|
ok: false,
|
|
error: error.message,
|
|
},
|
|
replyOrigin,
|
|
);
|
|
return;
|
|
}
|
|
queue = queue.then(async () => {
|
|
if (signal?.aborted) return;
|
|
const steps = await applyView(view, { viewer, dataManager, run, signal });
|
|
post(
|
|
{
|
|
type: EMBED_APPLIED_MESSAGE,
|
|
id,
|
|
ok: steps.every((step) => step.ok),
|
|
steps,
|
|
},
|
|
replyOrigin,
|
|
);
|
|
});
|
|
};
|
|
windowRef.addEventListener('message', onMessage);
|
|
void ready.then(() => {
|
|
if (!signal?.aborted) post({ type: EMBED_READY_MESSAGE });
|
|
});
|
|
return () => {
|
|
stopRendering();
|
|
windowRef.removeEventListener('message', onMessage);
|
|
};
|
|
}
|