mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-13 14:52:19 +00:00
Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d42b6fe764 | ||
|
|
29cf9ff8f6 | ||
|
|
897a74ea27 | ||
|
|
bd1da596b8 | ||
|
|
6976cbc941 | ||
|
|
771ed98e3b | ||
|
|
282ad6c5f6 | ||
|
|
d1f4d1acdc | ||
|
|
ebaa439739 | ||
|
|
265074aa54 | ||
|
|
c763c38193 | ||
|
|
2448662574 | ||
|
|
d9e34f10c6 | ||
|
|
02a8442e6e | ||
|
|
628de8386d | ||
|
|
abfc85e03d | ||
|
|
fbec1488ac |
No files matched your search
@@ -0,0 +1,6 @@
|
|||||||
|
- When the globe stops drawing after a render error or a lost WebGL context,
|
||||||
|
an alert dialog says so instead of leaving a frozen or black globe behind
|
||||||
|
working controls. Retry restarts drawing (Reload after a lost context), and
|
||||||
|
Reset view reopens the app without the view restored from the address bar.
|
||||||
|
Cesium's "Rendering has stopped" panel no longer appears in the app
|
||||||
|
(German, @gekh, #1579).
|
||||||
@@ -2442,6 +2442,70 @@ future driver fix restores the effect with no code change; iOS/iPadOS platform
|
|||||||
detection is the backstop for when no probe context can be created. Applied
|
detection is the backstop for when no probe context can be created. Applied
|
||||||
during viewer construction, before any tile builds a draw command.
|
during viewer construction, before any tile builds a draw command.
|
||||||
|
|
||||||
|
## Render failure notice
|
||||||
|
|
||||||
|
When the globe stops drawing, the scene chrome says so instead of leaving a
|
||||||
|
black globe behind controls that still respond. `installRenderFailureNotice()`
|
||||||
|
(`src/ui/renderFailure.js`) listens for Cesium's `renderError`, after which
|
||||||
|
Cesium stops its render loop, and for the canvas' `webglcontextlost`. It logs
|
||||||
|
the error and publishes a notice to the status store, which
|
||||||
|
`RenderFailure.vue` renders as an alert dialog that takes focus. When the
|
||||||
|
globe draws again, focus goes back to the element that had it, or, if that
|
||||||
|
element is gone or cannot take focus, to the globe's canvas, which the map's
|
||||||
|
keys (Enter to finish a drawing, Space to talk) read as the map. The canvas
|
||||||
|
gets `tabindex="-1"` only until focus moves on or the globe is pressed, and a
|
||||||
|
press leaves focus on the page, as Cesium has it. Focus the user moved
|
||||||
|
meanwhile stays put. The
|
||||||
|
application scene creates its viewer with `showRenderLoopErrors: false`, so
|
||||||
|
Cesium's own "Rendering has stopped" panel, which offers no way back, no longer
|
||||||
|
appears; `applicationViewerOptions()` and `createApplicationViewer()` keep
|
||||||
|
Cesium's panel by default for other callers. An error thrown outside Scene's
|
||||||
|
own error handling (a `preRender` or `postRender` listener, the clock's
|
||||||
|
`onTick`, a resize) also stops the loop without a `renderError`, so the notice
|
||||||
|
wraps the widget's `render()` and `resize()` and raises such an error as a
|
||||||
|
render error before Cesium's loop handles it as before; one Scene has raised
|
||||||
|
already and throws on (`rethrowRenderErrors`) is not raised again. A lost
|
||||||
|
context fails every later frame, and those errors are only logged, so the
|
||||||
|
notice keeps saying why. The notice sits above floating panels such as the
|
||||||
|
Québec camera viewer, and its buttons are 44 px tall on touch screens.
|
||||||
|
|
||||||
|
- **Retry** after a render error restarts the render loop. The notice stays
|
||||||
|
until a frame starts and ends without a render error (Cesium raises
|
||||||
|
`postRender` after a failed frame too), and a loop that fails again keeps it.
|
||||||
|
A listener that throws leaves its Cesium event mid-raise, with listeners
|
||||||
|
added or removed since only queued, so the `render()` and `resize()` wrapper
|
||||||
|
finishes the events it cut short (`finishRaise()`) before it raises the
|
||||||
|
render error. These are the events Cesium raises outside Scene's error
|
||||||
|
handling (`frameEvents()`): Scene's frame events, the camera's and the
|
||||||
|
clock's, the globe's tile load progress, and the load events of the scene's
|
||||||
|
top-level tilesets. So the notice's frame watchers start with the next
|
||||||
|
frame, a listener removed while the loop is stopped, for a layer turned off,
|
||||||
|
is not called again, and one turned off and on again keeps running.
|
||||||
|
- **Reload** after a lost context reloads the page: Cesium cannot draw into a
|
||||||
|
context it has lost, and the address keeps the view.
|
||||||
|
- **Reset view** opens the app's root address (`appRootUrl()`), without the
|
||||||
|
hash or `/share/<code>` path the view was restored from; the query, such as
|
||||||
|
`?lang=`, is kept.
|
||||||
|
|
||||||
|
An inline conversation panel never reloads or navigates its host's page, so it
|
||||||
|
offers Retry after a render error only, and nothing after a lost context, where
|
||||||
|
the panel runtime's own notice applies. The panel's timer
|
||||||
|
(`keepPanelRendering()`) draws only while Cesium's render loop is on, so it
|
||||||
|
stops after a render error and draws again after Retry. An error a frame of
|
||||||
|
the timer throws stops there: it has been raised as a render error, and
|
||||||
|
thrown on it would only reach the host page.
|
||||||
|
|
||||||
|
A render error marks Cesium's loop stopped, but a frame drawn while the loop
|
||||||
|
ran leaves its next animation frame queued: an error Scene catches itself
|
||||||
|
returns to the loop, which queues the frame, and a frame the panel's timer
|
||||||
|
draws leaves it queued. The `render()` and `resize()` wrapper keeps the loop
|
||||||
|
marked running after such a call, so a Retry before that frame, or a tab shown
|
||||||
|
again (`src/app/tools.js`), does not start a second loop that would draw every
|
||||||
|
frame twice. The queued frame stops the loop itself if nothing restarted it.
|
||||||
|
|
||||||
|
The `webglcontextlost` listener does not `preventDefault()` the context loss or
|
||||||
|
restore the context; it only reports it.
|
||||||
|
|
||||||
## Map Source control ownership
|
## Map Source control ownership
|
||||||
|
|
||||||
Map Source controls own chip listeners, source-state subscriptions and selection
|
Map Source controls own chip listeners, source-state subscriptions and selection
|
||||||
|
|||||||
@@ -557,6 +557,12 @@
|
|||||||
"src/ui/frameRateMonitor.js",
|
"src/ui/frameRateMonitor.js",
|
||||||
"src/ui/frameRateMonitor.test.mjs",
|
"src/ui/frameRateMonitor.test.mjs",
|
||||||
"src/ui/frameRateView.test.mjs",
|
"src/ui/frameRateView.test.mjs",
|
||||||
|
"src/ui/renderFailure.test.mjs",
|
||||||
|
"src/ui/renderFailureView.test.mjs",
|
||||||
|
"src/ui/renderFailureLoopErrors.test.mjs",
|
||||||
|
"src/ui/renderFailureEventRetry.test.mjs",
|
||||||
|
"src/app/renderFailureInlineRetry.test.mjs",
|
||||||
|
"src/ui/renderFailureSecondLoop.test.mjs",
|
||||||
"src/layers/cctv/lifecycle.js",
|
"src/layers/cctv/lifecycle.js",
|
||||||
"src/layers/cctv/controls.js",
|
"src/layers/cctv/controls.js",
|
||||||
"src/layers/cctv/geometry.js",
|
"src/layers/cctv/geometry.js",
|
||||||
|
|||||||
@@ -163,6 +163,7 @@
|
|||||||
"src/ui/kit/UiBadge.vue",
|
"src/ui/kit/UiBadge.vue",
|
||||||
"src/ui/stores/globeActions.js",
|
"src/ui/stores/globeActions.js",
|
||||||
"src/ui/views/panels/GlobeActions.vue",
|
"src/ui/views/panels/GlobeActions.vue",
|
||||||
|
"src/ui/views/panels/RenderFailure.vue",
|
||||||
"src/ui/views/panels/GlobeCompass.vue",
|
"src/ui/views/panels/GlobeCompass.vue",
|
||||||
"src/ui/stores/recording.js",
|
"src/ui/stores/recording.js",
|
||||||
"src/ui/styleParameters.js",
|
"src/ui/styleParameters.js",
|
||||||
@@ -3113,6 +3114,7 @@
|
|||||||
"src/ui/displayControls.js",
|
"src/ui/displayControls.js",
|
||||||
"src/ui/effects.js",
|
"src/ui/effects.js",
|
||||||
"src/ui/frameRateMonitor.js",
|
"src/ui/frameRateMonitor.js",
|
||||||
|
"src/ui/renderFailure.js",
|
||||||
"src/ui/layerPanel.js",
|
"src/ui/layerPanel.js",
|
||||||
"src/ui/weatherPanel.js",
|
"src/ui/weatherPanel.js",
|
||||||
"src/ui/railCards.js",
|
"src/ui/railCards.js",
|
||||||
|
|||||||
@@ -400,6 +400,27 @@ test('Enter finishes from the canvas and the label field, never from a focused b
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('Enter on the globe canvas finishes, as after the render failure notice hands focus back', async () => {
|
||||||
|
const h = harness();
|
||||||
|
try {
|
||||||
|
h.act('toggleDraw');
|
||||||
|
h.tool.setShape('area');
|
||||||
|
h.tool.addVertex(-97.75, 30.26);
|
||||||
|
h.tool.addVertex(-97.74, 30.26);
|
||||||
|
h.tool.addVertex(-97.74, 30.27);
|
||||||
|
|
||||||
|
// src/ui/views/panels/RenderFailure.vue focuses the canvas when the globe
|
||||||
|
// draws again and the control that had focus is gone.
|
||||||
|
h.documentDouble.emit('keydown', { key: 'Enter', target: h.viewer.scene.canvas });
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
assert.equal(h.annotations.calls.annotate.length, 1, 'Enter on the canvas finishes');
|
||||||
|
assert.equal(h.annotations.calls.annotate[0].specs[0].type, 'area');
|
||||||
|
} finally {
|
||||||
|
h.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('the live preview hangs at the height the click landed on', async () => {
|
test('the live preview hangs at the height the click landed on', async () => {
|
||||||
const h = harness();
|
const h = harness();
|
||||||
try {
|
try {
|
||||||
|
|||||||
+13
-11
@@ -122,28 +122,30 @@ export function keepPanelRendering(
|
|||||||
frameRequest = windowRef.requestAnimationFrame(onFrame);
|
frameRequest = windowRef.requestAnimationFrame(onFrame);
|
||||||
};
|
};
|
||||||
frameRequest = windowRef.requestAnimationFrame(onFrame);
|
frameRequest = windowRef.requestAnimationFrame(onFrame);
|
||||||
// Cesium stops drawing after a render error, and so does the timer.
|
// Cesium stops its render loop after a render error, and the timer stops
|
||||||
// Report the error in full: workers report plain objects, which Cesium
|
// with it; Retry restarts both. Report the error in full: workers report
|
||||||
// prints as [object Object].
|
// plain objects, which Cesium prints as [object Object].
|
||||||
let failed = false;
|
|
||||||
const removeErrorListener = viewer.scene?.renderError?.addEventListener(
|
const removeErrorListener = viewer.scene?.renderError?.addEventListener(
|
||||||
(_scene, error) => {
|
(_scene, error) =>
|
||||||
failed = true;
|
|
||||||
console.error(
|
console.error(
|
||||||
"[God's Eye View panel] render error:",
|
"[God's Eye View panel] render error:",
|
||||||
describeError(error),
|
describeError(error),
|
||||||
);
|
),
|
||||||
},
|
|
||||||
);
|
);
|
||||||
const timer = windowRef.setInterval(() => {
|
const timer = windowRef.setInterval(() => {
|
||||||
if (
|
if (
|
||||||
failed ||
|
viewer.useDefaultRenderLoop === false ||
|
||||||
now() - lastFrame < MISSED_FRAMES_MS ||
|
now() - lastFrame < MISSED_FRAMES_MS ||
|
||||||
viewer.isDestroyed?.()
|
viewer.isDestroyed?.()
|
||||||
)
|
)
|
||||||
return;
|
return;
|
||||||
viewer.resize();
|
try {
|
||||||
viewer.render();
|
viewer.resize();
|
||||||
|
viewer.render();
|
||||||
|
} catch {
|
||||||
|
// Raised as a render error by now (src/ui/renderFailure.js), which
|
||||||
|
// stops this timer; thrown on, it would only reach the host page.
|
||||||
|
}
|
||||||
}, PANEL_FRAME_MS);
|
}, PANEL_FRAME_MS);
|
||||||
return () => {
|
return () => {
|
||||||
windowRef.clearInterval(timer);
|
windowRef.clearInterval(timer);
|
||||||
|
|||||||
@@ -482,6 +482,8 @@ test('a panel reports a render error in full and stops drawing', () => {
|
|||||||
try {
|
try {
|
||||||
let clock = 0;
|
let clock = 0;
|
||||||
const stop = keepPanelRendering(viewer, { windowRef, now: () => clock });
|
const stop = keepPanelRendering(viewer, { windowRef, now: () => clock });
|
||||||
|
// Cesium stops its render loop after a render error.
|
||||||
|
viewer.useDefaultRenderLoop = false;
|
||||||
listener(viewer.scene, { name: 'RuntimeError', message: 'worker failed' });
|
listener(viewer.scene, { name: 'RuntimeError', message: 'worker failed' });
|
||||||
assert.match(logged[0], /render error: .*"message":"worker failed"/);
|
assert.match(logged[0], /render error: .*"message":"worker failed"/);
|
||||||
clock = 1000;
|
clock = 1000;
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
// Retry in an inline panel whose host stops animation frames, so the panel
|
||||||
|
// draws from keepPanelRendering's timer. Drives Cesium's real startRenderLoop
|
||||||
|
// and useDefaultRenderLoop setter (CesiumWidget.prototype).
|
||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import * as Cesium from 'cesium';
|
||||||
|
import { keepPanelRendering } from './embed.js';
|
||||||
|
import { installRenderFailureNotice } from '../ui/renderFailure.js';
|
||||||
|
|
||||||
|
function setup() {
|
||||||
|
const frames = [];
|
||||||
|
let tick = null;
|
||||||
|
let clock = 0;
|
||||||
|
// The host reports the panel hidden: its own animation frames never arrive.
|
||||||
|
const windowRef = {
|
||||||
|
requestAnimationFrame: () => 1,
|
||||||
|
cancelAnimationFrame() {},
|
||||||
|
setInterval: (fn) => ((tick = fn), 1),
|
||||||
|
clearInterval() {},
|
||||||
|
};
|
||||||
|
const previousRaf = globalThis.requestAnimationFrame;
|
||||||
|
globalThis.requestAnimationFrame = (fn) => frames.push(fn);
|
||||||
|
const previousError = console.error;
|
||||||
|
console.error = () => {};
|
||||||
|
const state = { failure: null, drawn: 0 };
|
||||||
|
const scene = {
|
||||||
|
renderError: new Cesium.Event(),
|
||||||
|
preUpdate: new Cesium.Event(),
|
||||||
|
postRender: new Cesium.Event(),
|
||||||
|
requestRender() {},
|
||||||
|
initializeFrame() {},
|
||||||
|
// Scene.render: a failing primitive update raises renderError
|
||||||
|
// (tryAndCatchError) and returns; a failing listener throws.
|
||||||
|
render() {
|
||||||
|
state.drawn += 1;
|
||||||
|
this.preUpdate.raiseEvent(this);
|
||||||
|
if (state.failure === 'caught')
|
||||||
|
this.renderError.raiseEvent(this, new Error('boom'));
|
||||||
|
if (state.failure === 'thrown') throw new Error('boom');
|
||||||
|
this.postRender.raiseEvent(this);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const widget = Object.create(Cesium.CesiumWidget.prototype);
|
||||||
|
Object.assign(widget, {
|
||||||
|
_scene: scene,
|
||||||
|
_clock: { tick() {} },
|
||||||
|
_canRender: true,
|
||||||
|
_useDefaultRenderLoop: false,
|
||||||
|
_renderLoopRunning: false,
|
||||||
|
_showRenderLoopErrors: false,
|
||||||
|
_targetFrameRate: undefined,
|
||||||
|
isDestroyed: () => false,
|
||||||
|
resize() {},
|
||||||
|
});
|
||||||
|
// CesiumWidget's own renderError handler, which marks the loop stopped.
|
||||||
|
scene.renderError.addEventListener(() => {
|
||||||
|
widget._useDefaultRenderLoop = false;
|
||||||
|
widget._renderLoopRunning = false;
|
||||||
|
});
|
||||||
|
const viewer = {
|
||||||
|
scene,
|
||||||
|
cesiumWidget: widget,
|
||||||
|
canvas: { addEventListener() {}, removeEventListener() {} },
|
||||||
|
get useDefaultRenderLoop() {
|
||||||
|
return widget.useDefaultRenderLoop;
|
||||||
|
},
|
||||||
|
set useDefaultRenderLoop(value) {
|
||||||
|
widget.useDefaultRenderLoop = value;
|
||||||
|
},
|
||||||
|
isDestroyed: () => false,
|
||||||
|
render: () => widget.render(),
|
||||||
|
resize: () => widget.resize(),
|
||||||
|
};
|
||||||
|
const store = {
|
||||||
|
renderFailure: null,
|
||||||
|
publishRenderFailure(next) {
|
||||||
|
this.renderFailure = next;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const uninstall = installRenderFailureNotice(viewer, {
|
||||||
|
store,
|
||||||
|
allowReload: false,
|
||||||
|
locationRef: { href: 'https://gev.example/', reload() {}, assign() {} },
|
||||||
|
logError() {},
|
||||||
|
});
|
||||||
|
const stop = keepPanelRendering(viewer, { windowRef, now: () => clock });
|
||||||
|
viewer.useDefaultRenderLoop = true;
|
||||||
|
const timerFrame = () => {
|
||||||
|
clock += 400;
|
||||||
|
tick();
|
||||||
|
};
|
||||||
|
const animationFrames = (n) => {
|
||||||
|
for (let frame = 1; frame <= n; frame += 1)
|
||||||
|
for (const render of frames.splice(0)) render(frame * 16);
|
||||||
|
};
|
||||||
|
const restore = () => {
|
||||||
|
stop();
|
||||||
|
uninstall();
|
||||||
|
globalThis.requestAnimationFrame = previousRaf;
|
||||||
|
console.error = previousError;
|
||||||
|
};
|
||||||
|
return { frames, state, store, timerFrame, animationFrames, restore };
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const failure of ['caught', 'thrown']) {
|
||||||
|
test(`an error ${failure} in a frame the timer draws: Retry draws again with one render loop`, () => {
|
||||||
|
const { frames, state, store, timerFrame, animationFrames, restore } =
|
||||||
|
setup();
|
||||||
|
try {
|
||||||
|
// The host stops animation frames with the loop's next one queued.
|
||||||
|
assert.equal(frames.length, 1);
|
||||||
|
state.failure = failure;
|
||||||
|
timerFrame(); // an error thrown here does not reach the host page
|
||||||
|
assert.equal(store.renderFailure?.reason, 'render-error');
|
||||||
|
assert.equal(typeof store.renderFailure.retry, 'function');
|
||||||
|
state.failure = null;
|
||||||
|
store.renderFailure.retry();
|
||||||
|
const before = state.drawn;
|
||||||
|
timerFrame();
|
||||||
|
assert.ok(state.drawn > before, 'the panel draws again after Retry');
|
||||||
|
assert.equal(
|
||||||
|
store.renderFailure,
|
||||||
|
null,
|
||||||
|
'a clean frame clears the notice',
|
||||||
|
);
|
||||||
|
assert.equal(frames.length, 1, 'Retry starts no second loop');
|
||||||
|
// The host resumes frames: the globe draws once a frame.
|
||||||
|
state.drawn = 0;
|
||||||
|
animationFrames(3);
|
||||||
|
assert.equal(state.drawn, 3);
|
||||||
|
} finally {
|
||||||
|
restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('an error Scene caught in an animation frame right before the host stopped frames: Retry keeps one render loop', () => {
|
||||||
|
const { frames, state, store, timerFrame, animationFrames, restore } =
|
||||||
|
setup();
|
||||||
|
try {
|
||||||
|
state.failure = 'caught';
|
||||||
|
animationFrames(1);
|
||||||
|
assert.equal(store.renderFailure?.reason, 'render-error');
|
||||||
|
assert.equal(frames.length, 1);
|
||||||
|
state.failure = null;
|
||||||
|
store.renderFailure.retry();
|
||||||
|
timerFrame();
|
||||||
|
assert.equal(store.renderFailure, null);
|
||||||
|
assert.equal(frames.length, 1, 'Retry starts no second loop');
|
||||||
|
state.drawn = 0;
|
||||||
|
animationFrames(3);
|
||||||
|
assert.equal(state.drawn, 3);
|
||||||
|
} finally {
|
||||||
|
restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -15,6 +15,8 @@ import {
|
|||||||
governorRequestRender,
|
governorRequestRender,
|
||||||
} from '../renderGovernor.js';
|
} from '../renderGovernor.js';
|
||||||
import { describeError } from './errors.js';
|
import { describeError } from './errors.js';
|
||||||
|
import { isEmbeddedInline } from './embed.js';
|
||||||
|
import { installRenderFailureNotice } from '../ui/renderFailure.js';
|
||||||
import { worldClock, worldTimeFor } from '../time/worldClock.js';
|
import { worldClock, worldTimeFor } from '../time/worldClock.js';
|
||||||
import { followWorldTime } from '../time/viewerClock.js';
|
import { followWorldTime } from '../time/viewerClock.js';
|
||||||
import { onLocaleChange, t } from '../i18n/index.js';
|
import { onLocaleChange, t } from '../i18n/index.js';
|
||||||
@@ -55,11 +57,17 @@ export async function createApplicationScene({
|
|||||||
const viewer = createApplicationViewer({
|
const viewer = createApplicationViewer({
|
||||||
container: 'cesiumContainer',
|
container: 'cesiumContainer',
|
||||||
creditContainer,
|
creditContainer,
|
||||||
|
// The render failure notice below replaces Cesium's dead-end panel.
|
||||||
|
showRenderLoopErrors: false,
|
||||||
});
|
});
|
||||||
defer(() => {
|
defer(() => {
|
||||||
uninstallRenderGovernor(viewer);
|
uninstallRenderGovernor(viewer);
|
||||||
if (!viewer.isDestroyed()) viewer.destroy();
|
if (!viewer.isDestroyed()) viewer.destroy();
|
||||||
});
|
});
|
||||||
|
// An inline panel must not reload or navigate its host's page.
|
||||||
|
defer(
|
||||||
|
installRenderFailureNotice(viewer, { allowReload: !isEmbeddedInline() }),
|
||||||
|
);
|
||||||
defer(installTrackpadPinchZoom(viewer));
|
defer(installTrackpadPinchZoom(viewer));
|
||||||
defer(
|
defer(
|
||||||
followWorldTime(viewer, worldTimeFor('sky'), {
|
followWorldTime(viewer, worldTimeFor('sky'), {
|
||||||
|
|||||||
+16
-4
@@ -375,8 +375,15 @@ export function installGlobeInViewRecovery(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Cesium.Viewer options of the standard globe viewer. */
|
/**
|
||||||
export function applicationViewerOptions(creditContainer) {
|
* Cesium.Viewer options of the standard globe viewer. `showRenderLoopErrors`
|
||||||
|
* false leaves render errors to the caller's own notice instead of Cesium's
|
||||||
|
* panel.
|
||||||
|
*/
|
||||||
|
export function applicationViewerOptions(
|
||||||
|
creditContainer,
|
||||||
|
{ showRenderLoopErrors = true } = {},
|
||||||
|
) {
|
||||||
return {
|
return {
|
||||||
timeline: false,
|
timeline: false,
|
||||||
animation: false,
|
animation: false,
|
||||||
@@ -396,16 +403,21 @@ export function applicationViewerOptions(creditContainer) {
|
|||||||
creditContainer,
|
creditContainer,
|
||||||
msaaSamples: 4,
|
msaaSamples: 4,
|
||||||
contextOptions: { webgl: { preserveDrawingBuffer: true } },
|
contextOptions: { webgl: { preserveDrawingBuffer: true } },
|
||||||
|
showRenderLoopErrors,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Create the standard globe viewer in caller-owned, visible containers. */
|
/** Create the standard globe viewer in caller-owned, visible containers. */
|
||||||
export function createApplicationViewer({ container, creditContainer }) {
|
export function createApplicationViewer({
|
||||||
|
container,
|
||||||
|
creditContainer,
|
||||||
|
showRenderLoopErrors,
|
||||||
|
}) {
|
||||||
if (!container || !creditContainer)
|
if (!container || !creditContainer)
|
||||||
throw new TypeError('Viewer and credit containers are required');
|
throw new TypeError('Viewer and credit containers are required');
|
||||||
const viewer = new Cesium.Viewer(
|
const viewer = new Cesium.Viewer(
|
||||||
container,
|
container,
|
||||||
applicationViewerOptions(creditContainer),
|
applicationViewerOptions(creditContainer, { showRenderLoopErrors }),
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
viewer.targetFrameRate = 60;
|
viewer.targetFrameRate = 60;
|
||||||
|
|||||||
@@ -1,129 +0,0 @@
|
|||||||
// An explicit pick of the camera that a settled move just re-opened must end
|
|
||||||
// the opening pick. That pick goes through setActiveCamera's UNCHANGED path
|
|
||||||
// (same camera, already activated); if that path keeps the provisional flag,
|
|
||||||
// the next settled move (a pan, or the landing of NEAREST's own flight)
|
|
||||||
// switches the camera away from the one the user chose.
|
|
||||||
import assert from 'node:assert/strict';
|
|
||||||
import test from 'node:test';
|
|
||||||
import * as Cesium from 'cesium';
|
|
||||||
import cctvLayer, {
|
|
||||||
_setCctvCoverageStateForTest,
|
|
||||||
_setCctvOverlayHostForTest,
|
|
||||||
reopenNearestCamera,
|
|
||||||
} from './cctv.js';
|
|
||||||
|
|
||||||
const BASE = {
|
|
||||||
headingDeg: 41,
|
|
||||||
pitchDeg: -5,
|
|
||||||
fovDeg: 56,
|
|
||||||
rangeM: 210,
|
|
||||||
mountHeightM: 100,
|
|
||||||
groundElevationM: 0,
|
|
||||||
city: 'London',
|
|
||||||
};
|
|
||||||
|
|
||||||
function record(id, lat, lon) {
|
|
||||||
return {
|
|
||||||
camera: { ...BASE, id, name: `Camera ${id}`, lat, lon },
|
|
||||||
coverageEntities: [],
|
|
||||||
projection: null,
|
|
||||||
activationDone: true,
|
|
||||||
groundResolved: {},
|
|
||||||
groundSamples: {},
|
|
||||||
groundPrior: 0,
|
|
||||||
frustumGeometry: null,
|
|
||||||
frustumPositions: null,
|
|
||||||
probeClampRangeM: null,
|
|
||||||
position: Cesium.Cartesian3.fromDegrees(lon, lat, 100),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function viewerAt(lat, lon) {
|
|
||||||
return {
|
|
||||||
entities: new Cesium.EntityCollection(),
|
|
||||||
isDestroyed: () => false,
|
|
||||||
trackedEntity: null,
|
|
||||||
camera: {
|
|
||||||
positionWC: Cesium.Cartesian3.fromDegrees(lon, lat, 300),
|
|
||||||
positionCartographic: Cesium.Cartographic.fromDegrees(lon, lat, 300),
|
|
||||||
heading: 0.4,
|
|
||||||
pitch: -0.7,
|
|
||||||
roll: 0,
|
|
||||||
transform: Cesium.Matrix4.clone(Cesium.Matrix4.IDENTITY),
|
|
||||||
flyToBoundingSphere() {},
|
|
||||||
},
|
|
||||||
scene: {
|
|
||||||
canvas: { clientWidth: 1200, clientHeight: 800 },
|
|
||||||
cartesianToCanvasCoordinates: () => undefined,
|
|
||||||
primitives: { add: (p) => p, remove: () => true },
|
|
||||||
requestRender() {},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function moveViewer(viewer, lat, lon) {
|
|
||||||
viewer.camera.positionCartographic = Cesium.Cartographic.fromDegrees(
|
|
||||||
lon,
|
|
||||||
lat,
|
|
||||||
300,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function withReopenedLayer(fn) {
|
|
||||||
_setCctvOverlayHostForTest({
|
|
||||||
setEntries() {},
|
|
||||||
setVisible() {},
|
|
||||||
clearSource() {},
|
|
||||||
});
|
|
||||||
// Activation builds the projection canvas and frame image.
|
|
||||||
const originalDocument = globalThis.document;
|
|
||||||
const originalImage = globalThis.Image;
|
|
||||||
globalThis.document = { createElement: () => ({ getContext: () => null }) };
|
|
||||||
globalThis.Image = class {};
|
|
||||||
try {
|
|
||||||
const trafalgar = record('a-trafalgar', 51.508, -0.128);
|
|
||||||
const cambridge = record('b-cambridge', 51.513, -0.129);
|
|
||||||
// enable() assigned the opening camera without activating it.
|
|
||||||
trafalgar.activationDone = false;
|
|
||||||
const viewer = viewerAt(51.5081, -0.1281);
|
|
||||||
_setCctvCoverageStateForTest({
|
|
||||||
viewer,
|
|
||||||
records: [trafalgar, cambridge],
|
|
||||||
activeCameraId: 'a-trafalgar',
|
|
||||||
enabled: true,
|
|
||||||
openingCameraProvisional: true,
|
|
||||||
});
|
|
||||||
// A settled move next to Cambridge Circus re-opens there, still as the
|
|
||||||
// opening pick.
|
|
||||||
moveViewer(viewer, 51.5131, -0.1291);
|
|
||||||
assert.equal(reopenNearestCamera(), true);
|
|
||||||
assert.equal(cctvLayer.getUIState().activeCameraId, 'b-cambridge');
|
|
||||||
fn(viewer);
|
|
||||||
} finally {
|
|
||||||
globalThis.document = originalDocument;
|
|
||||||
globalThis.Image = originalImage;
|
|
||||||
_setCctvOverlayHostForTest();
|
|
||||||
_setCctvCoverageStateForTest({ enabled: false });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [name, pick] of [
|
|
||||||
['NEAREST (focusNearest)', () => cctvLayer.focusNearest({ focus: false })],
|
|
||||||
['selectCamera', () => cctvLayer.selectCamera('b-cambridge')],
|
|
||||||
[
|
|
||||||
'setParams({ selectedCameraId })',
|
|
||||||
() => cctvLayer.setParams({ selectedCameraId: 'b-cambridge' }),
|
|
||||||
],
|
|
||||||
]) {
|
|
||||||
test(`an explicit ${name} pick of the re-opened camera ends the opening pick`, () => {
|
|
||||||
withReopenedLayer((viewer) => {
|
|
||||||
pick();
|
|
||||||
assert.equal(cctvLayer.getUIState().activeCameraId, 'b-cambridge');
|
|
||||||
// A later settled move (a pan, or the landing of the pick's own flight)
|
|
||||||
// leaves the explicit choice alone.
|
|
||||||
moveViewer(viewer, 51.5081, -0.1281);
|
|
||||||
assert.equal(reopenNearestCamera(), false);
|
|
||||||
assert.equal(cctvLayer.getUIState().activeCameraId, 'b-cambridge');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -3953,5 +3953,12 @@
|
|||||||
"Confirm: remove every added entry from Data Layers": "تأكيد: إزالة كل الإدخالات المُضافة من طبقات البيانات",
|
"Confirm: remove every added entry from Data Layers": "تأكيد: إزالة كل الإدخالات المُضافة من طبقات البيانات",
|
||||||
"Remove every added entry from Data Layers": "إزالة كل الإدخالات المُضافة من طبقات البيانات",
|
"Remove every added entry from Data Layers": "إزالة كل الإدخالات المُضافة من طبقات البيانات",
|
||||||
"Showing {shown} of {total}": "عرض {shown} من {total}",
|
"Showing {shown} of {total}": "عرض {shown} من {total}",
|
||||||
"BROWSE DATA LIBRARY": "تصفّح مكتبة البيانات"
|
"BROWSE DATA LIBRARY": "تصفّح مكتبة البيانات",
|
||||||
|
"The globe stopped drawing": "توقفت الكرة الأرضية عن الرسم",
|
||||||
|
"The browser took back the globe's 3D graphics. Layers and controls still work, but the map cannot draw until the page reloads.": "استعاد المتصفح رسومات الكرة الأرضية ثلاثية الأبعاد. لا تزال الطبقات وعناصر التحكم تعمل، لكن الخريطة لن تُرسم حتى تُعاد تحميل الصفحة.",
|
||||||
|
"Rendering stopped after an error. Layers and controls still work, but the map is not updating.": "توقف العرض بعد خطأ. لا تزال الطبقات وعناصر التحكم تعمل، لكن الخريطة لا يتم تحديثها.",
|
||||||
|
"Reset view reloads the app without the view restored from the address bar.": "تعيد «إعادة ضبط العرض» تحميل التطبيق دون العرض المستعاد من شريط العنوان.",
|
||||||
|
"Reload": "إعادة التحميل",
|
||||||
|
"Retry": "إعادة المحاولة",
|
||||||
|
"Reset view": "إعادة ضبط العرض"
|
||||||
}
|
}
|
||||||
@@ -3605,5 +3605,12 @@
|
|||||||
"Confirm: remove every added entry from Data Layers": "Bestätigen: alle hinzugefügten Einträge aus Datenebenen entfernen",
|
"Confirm: remove every added entry from Data Layers": "Bestätigen: alle hinzugefügten Einträge aus Datenebenen entfernen",
|
||||||
"Remove every added entry from Data Layers": "Alle hinzugefügten Einträge aus Datenebenen entfernen",
|
"Remove every added entry from Data Layers": "Alle hinzugefügten Einträge aus Datenebenen entfernen",
|
||||||
"Showing {shown} of {total}": "{shown} von {total} angezeigt",
|
"Showing {shown} of {total}": "{shown} von {total} angezeigt",
|
||||||
"BROWSE DATA LIBRARY": "DATENBIBLIOTHEK DURCHSUCHEN"
|
"BROWSE DATA LIBRARY": "DATENBIBLIOTHEK DURCHSUCHEN",
|
||||||
|
"The globe stopped drawing": "Der Globus zeichnet nicht mehr",
|
||||||
|
"The browser took back the globe's 3D graphics. Layers and controls still work, but the map cannot draw until the page reloads.": "Der Browser hat dem Globus die 3D-Grafik entzogen. Ebenen und Bedienelemente funktionieren weiter, aber die Karte kann erst nach dem Neuladen der Seite wieder zeichnen.",
|
||||||
|
"Rendering stopped after an error. Layers and controls still work, but the map is not updating.": "Das Rendern wurde nach einem Fehler angehalten. Ebenen und Bedienelemente funktionieren weiter, aber die Karte wird nicht aktualisiert.",
|
||||||
|
"Reset view reloads the app without the view restored from the address bar.": "Ansicht zurücksetzen lädt die App ohne die aus der Adressleiste wiederhergestellte Ansicht neu.",
|
||||||
|
"Reload": "Neu laden",
|
||||||
|
"Retry": "Erneut versuchen",
|
||||||
|
"Reset view": "Ansicht zurücksetzen"
|
||||||
}
|
}
|
||||||
@@ -3692,5 +3692,12 @@
|
|||||||
"500–1,000 m": "500–1000 m",
|
"500–1,000 m": "500–1000 m",
|
||||||
"1,000–2,000 m": "1000–2000 m",
|
"1,000–2,000 m": "1000–2000 m",
|
||||||
"2,000–3,000 m": "2000–3000 m",
|
"2,000–3,000 m": "2000–3000 m",
|
||||||
"3,000 m and above": "3000 m o más"
|
"3,000 m and above": "3000 m o más",
|
||||||
|
"The globe stopped drawing": "El globo dejó de dibujarse",
|
||||||
|
"The browser took back the globe's 3D graphics. Layers and controls still work, but the map cannot draw until the page reloads.": "El navegador retiró los gráficos 3D del globo. Las capas y los controles siguen funcionando, pero el mapa no se dibujará hasta que se recargue la página.",
|
||||||
|
"Rendering stopped after an error. Layers and controls still work, but the map is not updating.": "El renderizado se detuvo tras un error. Las capas y los controles siguen funcionando, pero el mapa no se actualiza.",
|
||||||
|
"Reset view reloads the app without the view restored from the address bar.": "Restablecer la vista recarga la app sin la vista restaurada desde la barra de direcciones.",
|
||||||
|
"Reload": "Recargar",
|
||||||
|
"Retry": "Reintentar",
|
||||||
|
"Reset view": "Restablecer la vista"
|
||||||
}
|
}
|
||||||
@@ -3692,5 +3692,12 @@
|
|||||||
"Confirm: remove every added entry from Data Layers": "Confirmer : retirer du panneau Couches toutes les entrées ajoutées",
|
"Confirm: remove every added entry from Data Layers": "Confirmer : retirer du panneau Couches toutes les entrées ajoutées",
|
||||||
"Remove every added entry from Data Layers": "Retirer du panneau Couches toutes les entrées ajoutées",
|
"Remove every added entry from Data Layers": "Retirer du panneau Couches toutes les entrées ajoutées",
|
||||||
"Showing {shown} of {total}": "Affichées : {shown} sur {total}",
|
"Showing {shown} of {total}": "Affichées : {shown} sur {total}",
|
||||||
"BROWSE DATA LIBRARY": "PARCOURIR LA BIBLIOTHÈQUE DE DONNÉES"
|
"BROWSE DATA LIBRARY": "PARCOURIR LA BIBLIOTHÈQUE DE DONNÉES",
|
||||||
|
"The globe stopped drawing": "Le globe ne s’affiche plus",
|
||||||
|
"The browser took back the globe's 3D graphics. Layers and controls still work, but the map cannot draw until the page reloads.": "Le navigateur a retiré au globe ses graphismes 3D. Les calques et les commandes fonctionnent toujours, mais la carte ne pourra s’afficher qu’après le rechargement de la page.",
|
||||||
|
"Rendering stopped after an error. Layers and controls still work, but the map is not updating.": "Le rendu s’est arrêté après une erreur. Les calques et les commandes fonctionnent toujours, mais la carte ne se met plus à jour.",
|
||||||
|
"Reset view reloads the app without the view restored from the address bar.": "Réinitialiser la vue recharge l’app sans la vue restaurée depuis la barre d’adresse.",
|
||||||
|
"Reload": "Recharger",
|
||||||
|
"Retry": "Réessayer",
|
||||||
|
"Reset view": "Réinitialiser la vue"
|
||||||
}
|
}
|
||||||
@@ -3692,5 +3692,12 @@
|
|||||||
"Confirm: remove every added entry from Data Layers": "אישור: הסרת כל הפריטים שנוספו משכבות נתונים",
|
"Confirm: remove every added entry from Data Layers": "אישור: הסרת כל הפריטים שנוספו משכבות נתונים",
|
||||||
"Remove every added entry from Data Layers": "הסרת כל הפריטים שנוספו משכבות נתונים",
|
"Remove every added entry from Data Layers": "הסרת כל הפריטים שנוספו משכבות נתונים",
|
||||||
"Showing {shown} of {total}": "מוצגים {shown} מתוך {total}",
|
"Showing {shown} of {total}": "מוצגים {shown} מתוך {total}",
|
||||||
"BROWSE DATA LIBRARY": "עיון בספריית הנתונים"
|
"BROWSE DATA LIBRARY": "עיון בספריית הנתונים",
|
||||||
|
"The globe stopped drawing": "הגלובוס הפסיק להצטייר",
|
||||||
|
"The browser took back the globe's 3D graphics. Layers and controls still work, but the map cannot draw until the page reloads.": "הדפדפן לקח בחזרה את הגרפיקה התלת-ממדית של הגלובוס. השכבות והפקדים עדיין פועלים, אבל המפה לא תוכל להצטייר עד שהעמוד ייטען מחדש.",
|
||||||
|
"Rendering stopped after an error. Layers and controls still work, but the map is not updating.": "הרינדור נעצר בעקבות שגיאה. השכבות והפקדים עדיין פועלים, אבל המפה אינה מתעדכנת.",
|
||||||
|
"Reset view reloads the app without the view restored from the address bar.": "\"איפוס תצוגה\" טוען מחדש את האפליקציה בלי התצוגה ששוחזרה משורת הכתובת.",
|
||||||
|
"Reload": "טען מחדש",
|
||||||
|
"Retry": "נסה שוב",
|
||||||
|
"Reset view": "איפוס תצוגה"
|
||||||
}
|
}
|
||||||
@@ -3692,5 +3692,12 @@
|
|||||||
"Confirm: remove every added entry from Data Layers": "Conferma: rimuovi da Livelli dati tutte le voci aggiunte",
|
"Confirm: remove every added entry from Data Layers": "Conferma: rimuovi da Livelli dati tutte le voci aggiunte",
|
||||||
"Remove every added entry from Data Layers": "Rimuovi da Livelli dati tutte le voci aggiunte",
|
"Remove every added entry from Data Layers": "Rimuovi da Livelli dati tutte le voci aggiunte",
|
||||||
"Showing {shown} of {total}": "Mostrati: {shown} su {total}",
|
"Showing {shown} of {total}": "Mostrati: {shown} su {total}",
|
||||||
"BROWSE DATA LIBRARY": "ESPLORA LIBRERIA DATI"
|
"BROWSE DATA LIBRARY": "ESPLORA LIBRERIA DATI",
|
||||||
|
"The globe stopped drawing": "Il globo ha smesso di disegnare",
|
||||||
|
"The browser took back the globe's 3D graphics. Layers and controls still work, but the map cannot draw until the page reloads.": "Il browser ha ritirato la grafica 3D del globo. Livelli e controlli funzionano ancora, ma la mappa non può essere disegnata finché la pagina non viene ricaricata.",
|
||||||
|
"Rendering stopped after an error. Layers and controls still work, but the map is not updating.": "Il rendering si è fermato dopo un errore. Livelli e controlli funzionano ancora, ma la mappa non si aggiorna.",
|
||||||
|
"Reset view reloads the app without the view restored from the address bar.": "Ripristina vista ricarica l’app senza la vista ripristinata dalla barra degli indirizzi.",
|
||||||
|
"Reload": "Ricarica",
|
||||||
|
"Retry": "Riprova",
|
||||||
|
"Reset view": "Ripristina vista"
|
||||||
}
|
}
|
||||||
@@ -3518,5 +3518,12 @@
|
|||||||
"Confirm: remove every added entry from Data Layers": "確認:追加したすべての項目をデータレイヤーから削除",
|
"Confirm: remove every added entry from Data Layers": "確認:追加したすべての項目をデータレイヤーから削除",
|
||||||
"Remove every added entry from Data Layers": "追加したすべての項目をデータレイヤーから削除",
|
"Remove every added entry from Data Layers": "追加したすべての項目をデータレイヤーから削除",
|
||||||
"Showing {shown} of {total}": "{total}件中{shown}件を表示",
|
"Showing {shown} of {total}": "{total}件中{shown}件を表示",
|
||||||
"BROWSE DATA LIBRARY": "データライブラリを見る"
|
"BROWSE DATA LIBRARY": "データライブラリを見る",
|
||||||
|
"The globe stopped drawing": "地球儀の描画が止まりました",
|
||||||
|
"The browser took back the globe's 3D graphics. Layers and controls still work, but the map cannot draw until the page reloads.": "ブラウザーが地球儀の 3D グラフィックスを解放しました。レイヤーと操作は引き続き使えますが、ページを再読み込みするまで地図は描画されません。",
|
||||||
|
"Rendering stopped after an error. Layers and controls still work, but the map is not updating.": "エラーのため描画が停止しました。レイヤーと操作は引き続き使えますが、地図は更新されません。",
|
||||||
|
"Reset view reloads the app without the view restored from the address bar.": "「ビューをリセット」は、アドレスバーから復元したビューを使わずにアプリを再読み込みします。",
|
||||||
|
"Reload": "再読み込み",
|
||||||
|
"Retry": "再試行",
|
||||||
|
"Reset view": "ビューをリセット"
|
||||||
}
|
}
|
||||||
@@ -3692,5 +3692,12 @@
|
|||||||
"Confirm: remove every added entry from Data Layers": "Confirmar: remover de \"Camadas de dados\" todas as entradas adicionadas",
|
"Confirm: remove every added entry from Data Layers": "Confirmar: remover de \"Camadas de dados\" todas as entradas adicionadas",
|
||||||
"Remove every added entry from Data Layers": "Remover de \"Camadas de dados\" todas as entradas adicionadas",
|
"Remove every added entry from Data Layers": "Remover de \"Camadas de dados\" todas as entradas adicionadas",
|
||||||
"Showing {shown} of {total}": "Mostrando {shown} de {total}",
|
"Showing {shown} of {total}": "Mostrando {shown} de {total}",
|
||||||
"BROWSE DATA LIBRARY": "EXPLORAR BIBLIOTECA DE DADOS"
|
"BROWSE DATA LIBRARY": "EXPLORAR BIBLIOTECA DE DADOS",
|
||||||
|
"The globe stopped drawing": "O globo parou de desenhar",
|
||||||
|
"The browser took back the globe's 3D graphics. Layers and controls still work, but the map cannot draw until the page reloads.": "O navegador retirou os gráficos 3D do globo. As camadas e os controles continuam funcionando, mas o mapa só volta a ser desenhado depois de recarregar a página.",
|
||||||
|
"Rendering stopped after an error. Layers and controls still work, but the map is not updating.": "A renderização parou após um erro. As camadas e os controles continuam funcionando, mas o mapa não está sendo atualizado.",
|
||||||
|
"Reset view reloads the app without the view restored from the address bar.": "Redefinir vista recarrega o app sem a vista restaurada da barra de endereços.",
|
||||||
|
"Reload": "Recarregar",
|
||||||
|
"Retry": "Tentar novamente",
|
||||||
|
"Reset view": "Redefinir vista"
|
||||||
}
|
}
|
||||||
@@ -3779,5 +3779,12 @@
|
|||||||
"500–1,000 m": "500–1000 м",
|
"500–1,000 m": "500–1000 м",
|
||||||
"1,000–2,000 m": "1000–2000 м",
|
"1,000–2,000 m": "1000–2000 м",
|
||||||
"2,000–3,000 m": "2000–3000 м",
|
"2,000–3,000 m": "2000–3000 м",
|
||||||
"3,000 m and above": "3000 м и выше"
|
"3,000 m and above": "3000 м и выше",
|
||||||
|
"The globe stopped drawing": "Глобус перестал отрисовываться",
|
||||||
|
"The browser took back the globe's 3D graphics. Layers and controls still work, but the map cannot draw until the page reloads.": "Браузер отключил 3D-графику глобуса. Слои и элементы управления работают, но карта не отрисуется, пока страница не будет перезагружена.",
|
||||||
|
"Rendering stopped after an error. Layers and controls still work, but the map is not updating.": "Отрисовка остановилась из-за ошибки. Слои и элементы управления работают, но карта не обновляется.",
|
||||||
|
"Reset view reloads the app without the view restored from the address bar.": "«Сбросить вид» перезагружает приложение без вида, восстановленного из адресной строки.",
|
||||||
|
"Reload": "Перезагрузить",
|
||||||
|
"Retry": "Повторить",
|
||||||
|
"Reset view": "Сбросить вид"
|
||||||
}
|
}
|
||||||
@@ -3605,5 +3605,12 @@
|
|||||||
"Confirm: remove every added entry from Data Layers": "Bekräfta: ta bort alla tillagda poster från Datalager",
|
"Confirm: remove every added entry from Data Layers": "Bekräfta: ta bort alla tillagda poster från Datalager",
|
||||||
"Remove every added entry from Data Layers": "Ta bort alla tillagda poster från Datalager",
|
"Remove every added entry from Data Layers": "Ta bort alla tillagda poster från Datalager",
|
||||||
"Showing {shown} of {total}": "Visar {shown} av {total}",
|
"Showing {shown} of {total}": "Visar {shown} av {total}",
|
||||||
"BROWSE DATA LIBRARY": "BLÄDDRA I DATABIBLIOTEKET"
|
"BROWSE DATA LIBRARY": "BLÄDDRA I DATABIBLIOTEKET",
|
||||||
|
"The globe stopped drawing": "Globen ritas inte längre",
|
||||||
|
"The browser took back the globe's 3D graphics. Layers and controls still work, but the map cannot draw until the page reloads.": "Webbläsaren tog tillbaka globens 3D-grafik. Lager och reglage fungerar fortfarande, men kartan kan inte ritas förrän sidan laddas om.",
|
||||||
|
"Rendering stopped after an error. Layers and controls still work, but the map is not updating.": "Renderingen stoppades efter ett fel. Lager och reglage fungerar fortfarande, men kartan uppdateras inte.",
|
||||||
|
"Reset view reloads the app without the view restored from the address bar.": "”Återställ vy” laddar om appen utan vyn som återställdes från adressfältet.",
|
||||||
|
"Reload": "Ladda om",
|
||||||
|
"Retry": "Försök igen",
|
||||||
|
"Reset view": "Återställ vy"
|
||||||
}
|
}
|
||||||
@@ -3779,5 +3779,12 @@
|
|||||||
"Confirm: remove every added entry from Data Layers": "Підтвердити: видалити всі додані записи з «Шарів даних»",
|
"Confirm: remove every added entry from Data Layers": "Підтвердити: видалити всі додані записи з «Шарів даних»",
|
||||||
"Remove every added entry from Data Layers": "Видалити всі додані записи з «Шарів даних»",
|
"Remove every added entry from Data Layers": "Видалити всі додані записи з «Шарів даних»",
|
||||||
"Showing {shown} of {total}": "Показано {shown} з {total}",
|
"Showing {shown} of {total}": "Показано {shown} з {total}",
|
||||||
"BROWSE DATA LIBRARY": "ВІДКРИТИ БІБЛІОТЕКУ ДАНИХ"
|
"BROWSE DATA LIBRARY": "ВІДКРИТИ БІБЛІОТЕКУ ДАНИХ",
|
||||||
|
"The globe stopped drawing": "Глобус перестав відмальовуватися",
|
||||||
|
"The browser took back the globe's 3D graphics. Layers and controls still work, but the map cannot draw until the page reloads.": "Браузер вимкнув 3D-графіку глобуса. Шари та елементи керування працюють, але карта не відмалюється, доки сторінку не буде перезавантажено.",
|
||||||
|
"Rendering stopped after an error. Layers and controls still work, but the map is not updating.": "Відмальовування зупинилося через помилку. Шари та елементи керування працюють, але карта не оновлюється.",
|
||||||
|
"Reset view reloads the app without the view restored from the address bar.": "«Скинути вигляд» перезавантажує застосунок без вигляду, відновленого з адресного рядка.",
|
||||||
|
"Reload": "Перезавантажити",
|
||||||
|
"Retry": "Повторити",
|
||||||
|
"Reset view": "Скинути вигляд"
|
||||||
}
|
}
|
||||||
@@ -3518,5 +3518,12 @@
|
|||||||
"Confirm: remove every added entry from Data Layers": "确认:从数据图层中移除所有已添加条目",
|
"Confirm: remove every added entry from Data Layers": "确认:从数据图层中移除所有已添加条目",
|
||||||
"Remove every added entry from Data Layers": "从数据图层中移除所有已添加条目",
|
"Remove every added entry from Data Layers": "从数据图层中移除所有已添加条目",
|
||||||
"Showing {shown} of {total}": "显示 {shown} / {total}",
|
"Showing {shown} of {total}": "显示 {shown} / {total}",
|
||||||
"BROWSE DATA LIBRARY": "浏览数据资源库"
|
"BROWSE DATA LIBRARY": "浏览数据资源库",
|
||||||
|
"The globe stopped drawing": "地球已停止绘制",
|
||||||
|
"The browser took back the globe's 3D graphics. Layers and controls still work, but the map cannot draw until the page reloads.": "浏览器收回了地球的 3D 图形。图层和控件仍可使用,但在重新加载页面之前地图无法绘制。",
|
||||||
|
"Rendering stopped after an error. Layers and controls still work, but the map is not updating.": "渲染因错误而停止。图层和控件仍可使用,但地图不再更新。",
|
||||||
|
"Reset view reloads the app without the view restored from the address bar.": "“重置视图”会重新加载应用,不使用从地址栏恢复的视图。",
|
||||||
|
"Reload": "重新加载",
|
||||||
|
"Retry": "重试",
|
||||||
|
"Reset view": "重置视图"
|
||||||
}
|
}
|
||||||
@@ -3518,5 +3518,12 @@
|
|||||||
"Confirm: remove every added entry from Data Layers": "確認:從數據圖層中移除所有已添加條目",
|
"Confirm: remove every added entry from Data Layers": "確認:從數據圖層中移除所有已添加條目",
|
||||||
"Remove every added entry from Data Layers": "從數據圖層中移除所有已添加條目",
|
"Remove every added entry from Data Layers": "從數據圖層中移除所有已添加條目",
|
||||||
"Showing {shown} of {total}": "顯示 {shown} / {total}",
|
"Showing {shown} of {total}": "顯示 {shown} / {total}",
|
||||||
"BROWSE DATA LIBRARY": "瀏覽數據資源庫"
|
"BROWSE DATA LIBRARY": "瀏覽數據資源庫",
|
||||||
|
"The globe stopped drawing": "地球已停止繪製",
|
||||||
|
"The browser took back the globe's 3D graphics. Layers and controls still work, but the map cannot draw until the page reloads.": "瀏覽器收回了地球的 3D 圖形。圖層和控件仍可使用,但在重新加載頁面之前地圖無法繪製。",
|
||||||
|
"Rendering stopped after an error. Layers and controls still work, but the map is not updating.": "渲染因錯誤而停止。圖層和控件仍可使用,但地圖不再更新。",
|
||||||
|
"Reset view reloads the app without the view restored from the address bar.": "「重置視圖」會重新加載應用,不使用從地址欄恢復的視圖。",
|
||||||
|
"Reload": "重新加載",
|
||||||
|
"Retry": "重試",
|
||||||
|
"Reset view": "重置視圖"
|
||||||
}
|
}
|
||||||
@@ -79,9 +79,6 @@ export function createSelection({
|
|||||||
record,
|
record,
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
// Picking the camera a settled move re-opened is still a choice: later
|
|
||||||
// settled moves must leave it alone.
|
|
||||||
layerState._openingCameraProvisional = false;
|
|
||||||
// Browser-only feeds have a separate player; a reselect retries it
|
// Browser-only feeds have a separate player; a reselect retries it
|
||||||
// without re-running activation or touching the projection geometry.
|
// without re-running activation or touching the projection geometry.
|
||||||
if (parts.model.isBrowserOverlayCamera(record.camera)) {
|
if (parts.model.isBrowserOverlayCamera(record.camera)) {
|
||||||
|
|||||||
@@ -0,0 +1,221 @@
|
|||||||
|
import { useStatusStore } from './stores/status.js';
|
||||||
|
import { applicationPinia } from './vueRuntime.js';
|
||||||
|
import { describeError } from '../app/errors.js';
|
||||||
|
import { appRootUrl } from '../share/shareLinkPath.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tell the user when the globe stops drawing, rather than leave a black globe
|
||||||
|
* behind controls that still respond. Cesium stops its render loop after a
|
||||||
|
* render error, and a page whose WebGL context was lost never draws again.
|
||||||
|
*
|
||||||
|
* Retry restarts the loop, and the notice stays until a frame renders without
|
||||||
|
* an error; after a context loss Retry reloads the page. Reset opens the app's
|
||||||
|
* root address. With `allowReload: false`, Reset is left out, and so is Retry
|
||||||
|
* after a context loss. Returns a function that removes the notice.
|
||||||
|
*/
|
||||||
|
export function installRenderFailureNotice(
|
||||||
|
viewer,
|
||||||
|
{
|
||||||
|
store = useStatusStore(applicationPinia()),
|
||||||
|
locationRef = globalThis.location,
|
||||||
|
allowReload = true,
|
||||||
|
logError = (error) =>
|
||||||
|
console.error('[Render] The globe stopped drawing:', error),
|
||||||
|
} = {},
|
||||||
|
) {
|
||||||
|
const { scene } = viewer;
|
||||||
|
const canvas = viewer.canvas ?? scene.canvas;
|
||||||
|
let notice = null;
|
||||||
|
let contextLost = false;
|
||||||
|
// The error Scene raised as a render error and, with rethrowRenderErrors,
|
||||||
|
// throws on: the render() wrapper must not raise it a second time.
|
||||||
|
let raisedByScene = null;
|
||||||
|
let frameFailed = false;
|
||||||
|
let removeFrameListeners = null;
|
||||||
|
|
||||||
|
function retry() {
|
||||||
|
if (contextLost) {
|
||||||
|
locationRef.reload();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
viewer.useDefaultRenderLoop = true;
|
||||||
|
scene.requestRender?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
locationRef.assign(appRootUrl(locationRef.href));
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopWatchingFrames() {
|
||||||
|
removeFrameListeners?.();
|
||||||
|
removeFrameListeners = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clear() {
|
||||||
|
stopWatchingFrames();
|
||||||
|
if (store.renderFailure === notice) store.publishRenderFailure(null);
|
||||||
|
notice = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cesium raises postRender after a failed frame too, so only a frame that
|
||||||
|
// starts and ends without a render error clears the notice.
|
||||||
|
function watchFrames() {
|
||||||
|
if (removeFrameListeners || contextLost) return;
|
||||||
|
const removeStart = scene.preUpdate.addEventListener(() => {
|
||||||
|
frameFailed = false;
|
||||||
|
raisedByScene = null;
|
||||||
|
});
|
||||||
|
const removeEnd = scene.postRender.addEventListener(() => {
|
||||||
|
if (!frameFailed) clear();
|
||||||
|
});
|
||||||
|
removeFrameListeners = () => {
|
||||||
|
removeStart();
|
||||||
|
removeEnd();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function present(detail) {
|
||||||
|
const reason = contextLost ? 'context-lost' : 'render-error';
|
||||||
|
// A loop restarted into the same error keeps the notice as it was.
|
||||||
|
if (notice?.reason === reason && notice.detail === detail) return;
|
||||||
|
notice = {
|
||||||
|
reason,
|
||||||
|
detail,
|
||||||
|
retry: contextLost && !allowReload ? null : retry,
|
||||||
|
reset: allowReload ? reset : null,
|
||||||
|
};
|
||||||
|
store.publishRenderFailure(notice);
|
||||||
|
watchFrames();
|
||||||
|
}
|
||||||
|
|
||||||
|
const onRenderError = (_scene, error) => {
|
||||||
|
raisedByScene = { error };
|
||||||
|
frameFailed = true;
|
||||||
|
logError(error);
|
||||||
|
// A lost context fails every frame after it; the notice keeps saying why.
|
||||||
|
if (contextLost) return;
|
||||||
|
present(error ? describeError(error) : '');
|
||||||
|
};
|
||||||
|
const onContextLost = () => {
|
||||||
|
contextLost = true;
|
||||||
|
// No frame can clear this one; only a reload draws again.
|
||||||
|
stopWatchingFrames();
|
||||||
|
present('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeRenderError = scene.renderError.addEventListener(onRenderError);
|
||||||
|
// An error thrown outside Scene's own error handling (a preRender or
|
||||||
|
// postRender listener, the clock's onTick, a resize) also stops Cesium's
|
||||||
|
// render loop, but raises no renderError, and Cesium reports it only in the
|
||||||
|
// panel the app turns off. Raise it as a render error, so Cesium's handler,
|
||||||
|
// an inline panel's timer and this notice all see it. Such an error passes
|
||||||
|
// here before anything else runs, so finish the raises of frameEvents() it
|
||||||
|
// cut short first: listeners added or removed while the loop is stopped
|
||||||
|
// then apply at once, in order.
|
||||||
|
const unguard = ['resize', 'render'].map((name) =>
|
||||||
|
reportThrown(viewer.cesiumWidget, name, (error) => {
|
||||||
|
frameEvents(viewer).forEach(finishRaise);
|
||||||
|
if (!raisedByScene || raisedByScene.error !== error)
|
||||||
|
scene.renderError.raiseEvent(scene, error);
|
||||||
|
raisedByScene = null;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
canvas?.addEventListener?.('webglcontextlost', onContextLost);
|
||||||
|
return () => {
|
||||||
|
removeRenderError();
|
||||||
|
unguard.forEach((restore) => restore());
|
||||||
|
canvas?.removeEventListener?.('webglcontextlost', onContextLost);
|
||||||
|
clear();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The events Cesium raises in a frame outside Scene's own error handling, so
|
||||||
|
* that a listener's error cuts their raise short: Scene's frame events, the
|
||||||
|
* camera's and the clock's, and those raised after the frame renders, the
|
||||||
|
* globe's tile load progress and the load events of the scene's tilesets.
|
||||||
|
* Tilesets nested in a collection are not reached.
|
||||||
|
*/
|
||||||
|
function frameEvents(viewer) {
|
||||||
|
const { scene } = viewer;
|
||||||
|
const events = [
|
||||||
|
scene.preUpdate,
|
||||||
|
scene.postUpdate,
|
||||||
|
scene.preRender,
|
||||||
|
scene.postRender,
|
||||||
|
scene.camera?.changed,
|
||||||
|
scene.camera?.moveStart,
|
||||||
|
scene.camera?.moveEnd,
|
||||||
|
viewer.clock?.onTick,
|
||||||
|
viewer.clock?.onStop,
|
||||||
|
scene.globe?.tileLoadProgressEvent,
|
||||||
|
];
|
||||||
|
const primitives = scene.primitives;
|
||||||
|
for (let i = 0; i < (primitives?.length ?? 0); i += 1) {
|
||||||
|
const tileset = primitives.get(i);
|
||||||
|
events.push(
|
||||||
|
tileset?.loadProgress,
|
||||||
|
tileset?.allTilesLoaded,
|
||||||
|
tileset?.initialTilesLoaded,
|
||||||
|
tileset?.tileLoad,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return events;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finish the raise a throwing listener cut short. Cesium's `Event.raiseEvent`
|
||||||
|
* marks the event as raising until every listener returns, and until then it
|
||||||
|
* only queues the listeners added or removed. Apply the queue as the raise
|
||||||
|
* would have, so listeners added or removed from here on take effect at once.
|
||||||
|
*/
|
||||||
|
function finishRaise(event) {
|
||||||
|
if (!event?._invokingListeners) return;
|
||||||
|
event._invokingListeners = false;
|
||||||
|
for (const [listener, scopes] of event._toAdd) {
|
||||||
|
const added = event._listeners.get(listener) ?? new Set();
|
||||||
|
scopes.forEach((scope) => added.add(scope));
|
||||||
|
event._listeners.set(listener, added);
|
||||||
|
}
|
||||||
|
event._toAdd.clear();
|
||||||
|
for (const [listener, scopes] of event._toRemove) {
|
||||||
|
const left = event._listeners.get(listener);
|
||||||
|
scopes.forEach((scope) => left?.delete(scope));
|
||||||
|
if (left?.size === 0) event._listeners.delete(listener);
|
||||||
|
}
|
||||||
|
event._toRemove.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Report an error the widget's `name` method throws, and throw it on.
|
||||||
|
*
|
||||||
|
* A render error marks Cesium's render loop stopped, but a call made while
|
||||||
|
* the loop ran leaves the loop's next animation frame queued: the loop queues
|
||||||
|
* it after a call that returns, and a frame an inline panel's timer draws
|
||||||
|
* leaves it queued. That frame stops the loop itself, and a Retry or a shown
|
||||||
|
* tab that comes first must not start a second loop beside it, so the loop
|
||||||
|
* stays marked running. A call from the loop that throws is still marked
|
||||||
|
* stopped by the loop's own catch.
|
||||||
|
*/
|
||||||
|
function reportThrown(widget, name, report) {
|
||||||
|
const original = widget?.[name];
|
||||||
|
if (typeof original !== 'function') return () => {};
|
||||||
|
const own = Object.hasOwn(widget, name);
|
||||||
|
const guarded = function (...args) {
|
||||||
|
const looping = widget._renderLoopRunning;
|
||||||
|
try {
|
||||||
|
return original.apply(this, args);
|
||||||
|
} catch (error) {
|
||||||
|
report(error);
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
if (looping) widget._renderLoopRunning = true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
widget[name] = guarded;
|
||||||
|
return () => {
|
||||||
|
if (widget[name] !== guarded) return;
|
||||||
|
if (own) widget[name] = original;
|
||||||
|
else delete widget[name];
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { installRenderFailureNotice } from './renderFailure.js';
|
||||||
|
|
||||||
|
function event() {
|
||||||
|
const listeners = new Set();
|
||||||
|
return {
|
||||||
|
listeners,
|
||||||
|
addEventListener(listener) {
|
||||||
|
listeners.add(listener);
|
||||||
|
return () => listeners.delete(listener);
|
||||||
|
},
|
||||||
|
raise(...args) {
|
||||||
|
for (const listener of [...listeners]) listener(...args);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function fixture(t, options = {}) {
|
||||||
|
const canvasListeners = new Map();
|
||||||
|
const scene = {
|
||||||
|
renderError: event(),
|
||||||
|
preUpdate: event(),
|
||||||
|
postRender: event(),
|
||||||
|
requestRender() {
|
||||||
|
this.requests = (this.requests ?? 0) + 1;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const viewer = {
|
||||||
|
scene,
|
||||||
|
useDefaultRenderLoop: true,
|
||||||
|
canvas: {
|
||||||
|
addEventListener: (name, listener) => canvasListeners.set(name, listener),
|
||||||
|
removeEventListener: (name, listener) => {
|
||||||
|
if (canvasListeners.get(name) === listener)
|
||||||
|
canvasListeners.delete(name);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const store = {
|
||||||
|
renderFailure: null,
|
||||||
|
published: [],
|
||||||
|
publishRenderFailure(next) {
|
||||||
|
this.renderFailure = next;
|
||||||
|
this.published.push(next);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const visits = [];
|
||||||
|
const locationRef = {
|
||||||
|
href: 'https://gev.example/share/AQc?lang=ru#lat=48.8584&lon=2.2945',
|
||||||
|
reload: () => visits.push('reload'),
|
||||||
|
assign: (url) => visits.push(String(url)),
|
||||||
|
};
|
||||||
|
const logged = [];
|
||||||
|
const uninstall = installRenderFailureNotice(viewer, {
|
||||||
|
store,
|
||||||
|
locationRef,
|
||||||
|
logError: (error) => logged.push(error),
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
t.after(uninstall);
|
||||||
|
// One Cesium frame: preUpdate, an optional render error, then postRender,
|
||||||
|
// which Cesium raises after a failed frame too.
|
||||||
|
const frame = (error) => {
|
||||||
|
scene.preUpdate.raise(scene);
|
||||||
|
if (error) {
|
||||||
|
viewer.useDefaultRenderLoop = false;
|
||||||
|
scene.renderError.raise(scene, error);
|
||||||
|
}
|
||||||
|
scene.postRender.raise(scene);
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
viewer,
|
||||||
|
scene,
|
||||||
|
store,
|
||||||
|
visits,
|
||||||
|
logged,
|
||||||
|
uninstall,
|
||||||
|
frame,
|
||||||
|
loseContext: () => canvasListeners.get('webglcontextlost')?.({}),
|
||||||
|
canvasListeners,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('a render error shows a notice that survives the failed frame', (t) => {
|
||||||
|
const f = fixture(t);
|
||||||
|
assert.equal(f.store.renderFailure, null);
|
||||||
|
const error = new Error('Expected width to be greater than 0');
|
||||||
|
f.frame(error);
|
||||||
|
const notice = f.store.renderFailure;
|
||||||
|
assert.equal(notice.reason, 'render-error');
|
||||||
|
assert.equal(notice.detail, 'Expected width to be greater than 0');
|
||||||
|
assert.equal(typeof notice.retry, 'function');
|
||||||
|
assert.equal(typeof notice.reset, 'function');
|
||||||
|
assert.deepEqual(f.logged, [error]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Retry restarts the render loop and a clean frame clears the notice', (t) => {
|
||||||
|
const f = fixture(t);
|
||||||
|
f.frame(new Error('boom'));
|
||||||
|
f.store.renderFailure.retry();
|
||||||
|
assert.equal(f.viewer.useDefaultRenderLoop, true);
|
||||||
|
assert.equal(f.scene.requests, 1);
|
||||||
|
assert.ok(f.store.renderFailure, 'the notice waits for a frame');
|
||||||
|
f.frame();
|
||||||
|
assert.equal(f.store.renderFailure, null);
|
||||||
|
// Clearing stops the per-frame listeners.
|
||||||
|
assert.equal(f.scene.preUpdate.listeners.size, 0);
|
||||||
|
assert.equal(f.scene.postRender.listeners.size, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a loop restarted into the same error keeps one notice', (t) => {
|
||||||
|
const f = fixture(t);
|
||||||
|
f.frame(new Error('boom'));
|
||||||
|
const first = f.store.renderFailure;
|
||||||
|
f.store.renderFailure.retry();
|
||||||
|
f.frame(new Error('boom'));
|
||||||
|
assert.equal(f.store.renderFailure, first);
|
||||||
|
assert.equal(f.store.published.length, 1);
|
||||||
|
f.frame(new Error('other'));
|
||||||
|
assert.equal(f.store.renderFailure.detail, 'other');
|
||||||
|
assert.ok(!f.store.published.includes(null), 'no new appearance');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a new failure after recovery is a new appearance', (t) => {
|
||||||
|
const f = fixture(t);
|
||||||
|
f.frame(new Error('boom'));
|
||||||
|
f.store.renderFailure.retry();
|
||||||
|
f.frame();
|
||||||
|
f.frame(new Error('boom'));
|
||||||
|
assert.deepEqual(
|
||||||
|
f.store.published.map((notice) => notice?.reason ?? null),
|
||||||
|
['render-error', null, 'render-error'],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a render error without a value shows no detail', (t) => {
|
||||||
|
const f = fixture(t);
|
||||||
|
f.viewer.useDefaultRenderLoop = false;
|
||||||
|
f.scene.renderError.raise(f.scene, undefined);
|
||||||
|
assert.equal(f.store.renderFailure.reason, 'render-error');
|
||||||
|
assert.equal(f.store.renderFailure.detail, '');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Reset opens the app root without the restored view', (t) => {
|
||||||
|
const f = fixture(t);
|
||||||
|
f.frame(new Error('boom'));
|
||||||
|
f.store.renderFailure.reset();
|
||||||
|
assert.deepEqual(f.visits, ['https://gev.example/?lang=ru']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a lost context offers a reload and no frame clears it', (t) => {
|
||||||
|
const f = fixture(t);
|
||||||
|
f.loseContext();
|
||||||
|
const notice = f.store.renderFailure;
|
||||||
|
assert.equal(notice.reason, 'context-lost');
|
||||||
|
assert.equal(notice.detail, '');
|
||||||
|
// Cesium's next frame then fails on the dead drawing buffer.
|
||||||
|
f.frame(new Error('Expected width to be greater than 0'));
|
||||||
|
assert.equal(f.store.renderFailure, notice, 'the notice keeps saying why');
|
||||||
|
f.frame();
|
||||||
|
assert.ok(f.store.renderFailure, 'a lost context never draws again');
|
||||||
|
f.store.renderFailure.retry();
|
||||||
|
assert.deepEqual(f.visits, ['reload']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a page that must not reload offers neither reload nor reset', (t) => {
|
||||||
|
const f = fixture(t, { allowReload: false });
|
||||||
|
f.frame(new Error('boom'));
|
||||||
|
assert.equal(typeof f.store.renderFailure.retry, 'function');
|
||||||
|
assert.equal(f.store.renderFailure.reset, null);
|
||||||
|
f.loseContext();
|
||||||
|
assert.equal(f.store.renderFailure.retry, null);
|
||||||
|
assert.equal(f.store.renderFailure.reset, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('uninstall removes the listeners and the notice', (t) => {
|
||||||
|
const f = fixture(t);
|
||||||
|
f.frame(new Error('boom'));
|
||||||
|
f.uninstall();
|
||||||
|
assert.equal(f.store.renderFailure, null);
|
||||||
|
assert.equal(f.scene.renderError.listeners.size, 0);
|
||||||
|
assert.equal(f.scene.postRender.listeners.size, 0);
|
||||||
|
assert.equal(f.canvasListeners.size, 0);
|
||||||
|
f.frame(new Error('later'));
|
||||||
|
assert.equal(f.store.renderFailure, null);
|
||||||
|
});
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
// Retry after an error thrown by a scene event listener. Cesium's
|
||||||
|
// Event.raiseEvent has no try/finally: a throwing listener leaves the event
|
||||||
|
// with _invokingListeners = true, so listeners added or removed afterwards are
|
||||||
|
// only queued (_toAdd/_toRemove) until the next raise of that event completes.
|
||||||
|
// Drives Cesium's real Event and startRenderLoop (CesiumWidget.prototype).
|
||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import * as Cesium from 'cesium';
|
||||||
|
import { installRenderFailureNotice } from './renderFailure.js';
|
||||||
|
|
||||||
|
function setup() {
|
||||||
|
const frames = [];
|
||||||
|
const previousRaf = globalThis.requestAnimationFrame;
|
||||||
|
globalThis.requestAnimationFrame = (fn) => frames.push(fn);
|
||||||
|
// Scene.render as in Scene.js:4369: preUpdate always, preRender/postRender
|
||||||
|
// only when a frame is rendered (requestRenderMode idle skips them).
|
||||||
|
const scene = {
|
||||||
|
renderError: new Cesium.Event(),
|
||||||
|
preUpdate: new Cesium.Event(),
|
||||||
|
preRender: new Cesium.Event(),
|
||||||
|
postRender: new Cesium.Event(),
|
||||||
|
requestRenderMode: true,
|
||||||
|
_renderRequested: false,
|
||||||
|
rendered: 0,
|
||||||
|
requestRender() {
|
||||||
|
this._renderRequested = true;
|
||||||
|
},
|
||||||
|
initializeFrame() {},
|
||||||
|
render(time) {
|
||||||
|
this.preUpdate.raiseEvent(this, time);
|
||||||
|
const shouldRender = !this.requestRenderMode || this._renderRequested;
|
||||||
|
if (!shouldRender) return;
|
||||||
|
this._renderRequested = false;
|
||||||
|
this.preRender.raiseEvent(this, time);
|
||||||
|
this.rendered += 1;
|
||||||
|
this.postRender.raiseEvent(this, time);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const widget = Object.create(Cesium.CesiumWidget.prototype);
|
||||||
|
Object.assign(widget, {
|
||||||
|
_scene: scene,
|
||||||
|
_clock: { tick() {} },
|
||||||
|
_canRender: true,
|
||||||
|
_useDefaultRenderLoop: false,
|
||||||
|
_renderLoopRunning: false,
|
||||||
|
_showRenderLoopErrors: false,
|
||||||
|
_targetFrameRate: undefined,
|
||||||
|
isDestroyed: () => false,
|
||||||
|
resize() {},
|
||||||
|
});
|
||||||
|
scene.renderError.addEventListener(() => {
|
||||||
|
widget._useDefaultRenderLoop = false;
|
||||||
|
widget._renderLoopRunning = false;
|
||||||
|
});
|
||||||
|
const viewer = {
|
||||||
|
scene,
|
||||||
|
cesiumWidget: widget,
|
||||||
|
canvas: { addEventListener() {}, removeEventListener() {} },
|
||||||
|
get useDefaultRenderLoop() {
|
||||||
|
return widget.useDefaultRenderLoop;
|
||||||
|
},
|
||||||
|
set useDefaultRenderLoop(value) {
|
||||||
|
widget.useDefaultRenderLoop = value;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const store = {
|
||||||
|
renderFailure: null,
|
||||||
|
publishRenderFailure(next) {
|
||||||
|
this.renderFailure = next;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const uninstall = installRenderFailureNotice(viewer, {
|
||||||
|
store,
|
||||||
|
locationRef: { href: 'https://gev.example/', reload() {}, assign() {} },
|
||||||
|
logError() {},
|
||||||
|
});
|
||||||
|
const runFrames = (n) => {
|
||||||
|
for (let i = 0; i < n; i += 1)
|
||||||
|
for (const fn of frames.splice(0)) fn(16 * (i + 1));
|
||||||
|
};
|
||||||
|
const restore = () => {
|
||||||
|
uninstall();
|
||||||
|
globalThis.requestAnimationFrame = previousRaf;
|
||||||
|
};
|
||||||
|
return { frames, scene, widget, viewer, store, runFrames, restore };
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const eventName of ['postRender', 'preUpdate']) {
|
||||||
|
test(`idle: a ${eventName} listener that threw once; a successful Retry clears the notice`, () => {
|
||||||
|
const { scene, viewer, store, runFrames, restore } = setup();
|
||||||
|
try {
|
||||||
|
let broken = true;
|
||||||
|
scene[eventName].addEventListener(() => {
|
||||||
|
if (broken) throw new TypeError('listener failed');
|
||||||
|
});
|
||||||
|
scene.requestRender();
|
||||||
|
viewer.useDefaultRenderLoop = true;
|
||||||
|
runFrames(1);
|
||||||
|
assert.equal(store.renderFailure?.reason, 'render-error');
|
||||||
|
assert.equal(viewer.useDefaultRenderLoop, false);
|
||||||
|
broken = false; // the cause goes away
|
||||||
|
store.renderFailure.retry();
|
||||||
|
const before = scene.rendered;
|
||||||
|
runFrames(10); // idle: no camera move, no render request
|
||||||
|
assert.ok(scene.rendered > before, 'the globe drew again');
|
||||||
|
assert.equal(store.renderFailure, null, 'the notice is cleared');
|
||||||
|
} finally {
|
||||||
|
restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('a throwing postRender listener removed before Retry is not called again', () => {
|
||||||
|
const { scene, viewer, store, runFrames, restore } = setup();
|
||||||
|
try {
|
||||||
|
let calls = 0;
|
||||||
|
const remove = scene.postRender.addEventListener(() => {
|
||||||
|
calls += 1;
|
||||||
|
throw new TypeError('layer listener failed');
|
||||||
|
});
|
||||||
|
scene.requestRender();
|
||||||
|
viewer.useDefaultRenderLoop = true;
|
||||||
|
runFrames(1);
|
||||||
|
assert.equal(store.renderFailure?.reason, 'render-error');
|
||||||
|
remove(); // the user turns the layer off
|
||||||
|
store.renderFailure.retry();
|
||||||
|
runFrames(5);
|
||||||
|
assert.equal(calls, 1, 'the removed listener ran again after Retry');
|
||||||
|
assert.equal(viewer.useDefaultRenderLoop, true, 'the loop keeps running');
|
||||||
|
assert.equal(store.renderFailure, null, 'the notice is cleared');
|
||||||
|
} finally {
|
||||||
|
restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const listenerCount = (event) =>
|
||||||
|
[...event._listeners.values()].reduce((sum, scopes) => sum + scopes.size, 0);
|
||||||
|
|
||||||
|
// While the loop is stopped, a layer turned off and on again, or a ground
|
||||||
|
// task in worldAnnotationRenderer that starts, times out and starts again.
|
||||||
|
const RESUBSCRIBE = {
|
||||||
|
'turned off and on': (event, listener) => {
|
||||||
|
event.removeEventListener(listener);
|
||||||
|
event.addEventListener(listener);
|
||||||
|
},
|
||||||
|
'added, removed and added again': (event, listener) => {
|
||||||
|
event.addEventListener(listener);
|
||||||
|
event.removeEventListener(listener);
|
||||||
|
event.addEventListener(listener);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const [name, resubscribe] of Object.entries(RESUBSCRIBE)) {
|
||||||
|
test(`a listener ${name} while the loop is stopped runs after Retry`, () => {
|
||||||
|
const { scene, viewer, store, runFrames, restore } = setup();
|
||||||
|
try {
|
||||||
|
let broken = true;
|
||||||
|
scene.postRender.addEventListener(() => {
|
||||||
|
if (broken) throw new TypeError('layer listener failed');
|
||||||
|
});
|
||||||
|
let calls = 0;
|
||||||
|
const listener = () => {
|
||||||
|
calls += 1;
|
||||||
|
};
|
||||||
|
if (name === 'turned off and on')
|
||||||
|
scene.postRender.addEventListener(listener);
|
||||||
|
scene.requestRender();
|
||||||
|
viewer.useDefaultRenderLoop = true;
|
||||||
|
runFrames(1);
|
||||||
|
assert.equal(store.renderFailure?.reason, 'render-error');
|
||||||
|
resubscribe(scene.postRender, listener);
|
||||||
|
broken = false;
|
||||||
|
store.renderFailure.retry();
|
||||||
|
calls = 0;
|
||||||
|
scene.requestRender();
|
||||||
|
runFrames(3);
|
||||||
|
assert.ok(calls > 0, 'the re-added listener runs after Retry');
|
||||||
|
assert.equal(
|
||||||
|
scene.postRender.numberOfListeners,
|
||||||
|
listenerCount(scene.postRender),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every event a listener's error can cut short is finished when the error
|
||||||
|
// surfaces, so a listener added at once is subscribed.
|
||||||
|
const FRAME_EVENTS = {
|
||||||
|
'scene.preUpdate': (v) => v.scene.preUpdate,
|
||||||
|
'scene.postUpdate': (v) => v.scene.postUpdate,
|
||||||
|
'scene.preRender': (v) => v.scene.preRender,
|
||||||
|
'scene.postRender': (v) => v.scene.postRender,
|
||||||
|
'camera.changed': (v) => v.scene.camera.changed,
|
||||||
|
'camera.moveStart': (v) => v.scene.camera.moveStart,
|
||||||
|
'camera.moveEnd': (v) => v.scene.camera.moveEnd,
|
||||||
|
'clock.onTick': (v) => v.clock.onTick,
|
||||||
|
'clock.onStop': (v) => v.clock.onStop,
|
||||||
|
'globe.tileLoadProgressEvent': (v) => v.scene.globe.tileLoadProgressEvent,
|
||||||
|
'tileset.loadProgress': (v) => v.scene.primitives.get(0).loadProgress,
|
||||||
|
'tileset.allTilesLoaded': (v) => v.scene.primitives.get(0).allTilesLoaded,
|
||||||
|
'tileset.initialTilesLoaded': (v) =>
|
||||||
|
v.scene.primitives.get(0).initialTilesLoaded,
|
||||||
|
'tileset.tileLoad': (v) => v.scene.primitives.get(0).tileLoad,
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const [name, pick] of Object.entries(FRAME_EVENTS)) {
|
||||||
|
test(`a listener that throws in ${name} leaves it finished`, () => {
|
||||||
|
const events = () => ({
|
||||||
|
changed: new Cesium.Event(),
|
||||||
|
moveStart: new Cesium.Event(),
|
||||||
|
moveEnd: new Cesium.Event(),
|
||||||
|
});
|
||||||
|
const tileset = {
|
||||||
|
loadProgress: new Cesium.Event(),
|
||||||
|
allTilesLoaded: new Cesium.Event(),
|
||||||
|
initialTilesLoaded: new Cesium.Event(),
|
||||||
|
tileLoad: new Cesium.Event(),
|
||||||
|
};
|
||||||
|
const viewer = {
|
||||||
|
scene: {
|
||||||
|
renderError: new Cesium.Event(),
|
||||||
|
preUpdate: new Cesium.Event(),
|
||||||
|
postUpdate: new Cesium.Event(),
|
||||||
|
preRender: new Cesium.Event(),
|
||||||
|
postRender: new Cesium.Event(),
|
||||||
|
camera: events(),
|
||||||
|
globe: { tileLoadProgressEvent: new Cesium.Event() },
|
||||||
|
primitives: { length: 1, get: () => tileset },
|
||||||
|
},
|
||||||
|
clock: { onTick: new Cesium.Event(), onStop: new Cesium.Event() },
|
||||||
|
canvas: { addEventListener() {}, removeEventListener() {} },
|
||||||
|
cesiumWidget: {
|
||||||
|
_renderLoopRunning: false,
|
||||||
|
render: () => pick(viewer).raiseEvent(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const store = { publishRenderFailure() {} };
|
||||||
|
const uninstall = installRenderFailureNotice(viewer, {
|
||||||
|
store,
|
||||||
|
locationRef: { href: 'https://gev.example/', reload() {}, assign() {} },
|
||||||
|
logError() {},
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const event = pick(viewer);
|
||||||
|
event.addEventListener(() => {
|
||||||
|
throw new TypeError('listener failed');
|
||||||
|
});
|
||||||
|
assert.throws(() => viewer.cesiumWidget.render(), TypeError);
|
||||||
|
assert.equal(event._invokingListeners, false);
|
||||||
|
const listener = () => {};
|
||||||
|
event.addEventListener(listener);
|
||||||
|
assert.ok(event._listeners.has(listener));
|
||||||
|
} finally {
|
||||||
|
uninstall();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
// Errors thrown in the render loop outside Scene's tryAndCatchError (a
|
||||||
|
// preRender/postRender listener, clock.onTick, resize). Drives Cesium's real
|
||||||
|
// startRenderLoop through the CesiumWidget.prototype.useDefaultRenderLoop
|
||||||
|
// setter.
|
||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import * as Cesium from 'cesium';
|
||||||
|
import { installRenderFailureNotice } from './renderFailure.js';
|
||||||
|
|
||||||
|
// What src/app/scene.js passes to the viewer (showRenderLoopErrors).
|
||||||
|
const SHOW_RENDER_LOOP_ERRORS = false;
|
||||||
|
|
||||||
|
function runLoop(render) {
|
||||||
|
const frames = [];
|
||||||
|
const previousRaf = globalThis.requestAnimationFrame;
|
||||||
|
globalThis.requestAnimationFrame = (fn) => frames.push(fn);
|
||||||
|
try {
|
||||||
|
const scene = {
|
||||||
|
renderError: new Cesium.Event(),
|
||||||
|
preUpdate: new Cesium.Event(),
|
||||||
|
postRender: new Cesium.Event(),
|
||||||
|
requestRender() {},
|
||||||
|
initializeFrame() {},
|
||||||
|
render,
|
||||||
|
};
|
||||||
|
const widget = Object.create(Cesium.CesiumWidget.prototype);
|
||||||
|
Object.assign(widget, {
|
||||||
|
_scene: scene,
|
||||||
|
_clock: { tick() {} },
|
||||||
|
_canRender: true,
|
||||||
|
_useDefaultRenderLoop: false,
|
||||||
|
_renderLoopRunning: false,
|
||||||
|
_showRenderLoopErrors: SHOW_RENDER_LOOP_ERRORS,
|
||||||
|
_targetFrameRate: undefined,
|
||||||
|
isDestroyed: () => false,
|
||||||
|
resize() {},
|
||||||
|
showErrorPanel() {},
|
||||||
|
});
|
||||||
|
const viewer = {
|
||||||
|
scene,
|
||||||
|
cesiumWidget: widget,
|
||||||
|
canvas: { addEventListener() {}, removeEventListener() {} },
|
||||||
|
get useDefaultRenderLoop() {
|
||||||
|
return widget.useDefaultRenderLoop;
|
||||||
|
},
|
||||||
|
set useDefaultRenderLoop(value) {
|
||||||
|
widget.useDefaultRenderLoop = value;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const store = {
|
||||||
|
renderFailure: null,
|
||||||
|
publishRenderFailure(next) {
|
||||||
|
this.renderFailure = next;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const logged = [];
|
||||||
|
const uninstall = installRenderFailureNotice(viewer, {
|
||||||
|
store,
|
||||||
|
locationRef: { href: 'https://gev.example/', reload() {}, assign() {} },
|
||||||
|
logError: (error) => logged.push(error),
|
||||||
|
});
|
||||||
|
viewer.useDefaultRenderLoop = true;
|
||||||
|
while (frames.length) frames.shift()(16);
|
||||||
|
return { widget, store, logged, uninstall };
|
||||||
|
} finally {
|
||||||
|
globalThis.requestAnimationFrame = previousRaf;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test('an error thrown outside tryAndCatchError still shows the notice', () => {
|
||||||
|
// Scene.render raises preRender outside tryAndCatchError (Scene.js:4439),
|
||||||
|
// so a listener's exception propagates.
|
||||||
|
const { widget, store, logged, uninstall } = runLoop(function () {
|
||||||
|
this.preUpdate.raiseEvent(this);
|
||||||
|
throw new TypeError("Cannot read properties of undefined (reading 'x')");
|
||||||
|
});
|
||||||
|
assert.equal(widget.useDefaultRenderLoop, false);
|
||||||
|
assert.equal(store.renderFailure?.reason, 'render-error');
|
||||||
|
assert.equal(logged.length, 1);
|
||||||
|
uninstall();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an error Scene raised and threw on with rethrowRenderErrors is reported once', () => {
|
||||||
|
const { store, logged, uninstall } = runLoop(function () {
|
||||||
|
this.preUpdate.raiseEvent(this);
|
||||||
|
const error = new Error('boom');
|
||||||
|
this.renderError.raiseEvent(this, error);
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
assert.equal(store.renderFailure?.detail, 'boom');
|
||||||
|
assert.equal(logged.length, 1);
|
||||||
|
uninstall();
|
||||||
|
});
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
// Retry after an error Scene catches itself (Scene.tryAndCatchError raises
|
||||||
|
// renderError and returns). CesiumWidget's renderError handler marks the loop
|
||||||
|
// stopped, but startRenderLoop still queues the loop's next animation frame.
|
||||||
|
// A Retry that lands before that frame runs must not start a second loop.
|
||||||
|
// Drives Cesium's real startRenderLoop and useDefaultRenderLoop setter
|
||||||
|
// (CesiumWidget.prototype).
|
||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import * as Cesium from 'cesium';
|
||||||
|
import { installRenderFailureNotice } from './renderFailure.js';
|
||||||
|
|
||||||
|
function setup({ targetFrameRate } = {}) {
|
||||||
|
const frames = [];
|
||||||
|
const previousRaf = globalThis.requestAnimationFrame;
|
||||||
|
globalThis.requestAnimationFrame = (fn) => frames.push(fn);
|
||||||
|
const scene = {
|
||||||
|
renderError: new Cesium.Event(),
|
||||||
|
preUpdate: new Cesium.Event(),
|
||||||
|
preRender: new Cesium.Event(),
|
||||||
|
postRender: new Cesium.Event(),
|
||||||
|
broken: false,
|
||||||
|
drawn: 0,
|
||||||
|
requestRender() {},
|
||||||
|
initializeFrame() {},
|
||||||
|
// Scene.render (Scene.js:4369): an update error is caught by
|
||||||
|
// tryAndCatchError, which raises renderError and returns normally.
|
||||||
|
render(time) {
|
||||||
|
this.drawn += 1;
|
||||||
|
this.preUpdate.raiseEvent(this, time);
|
||||||
|
if (this.broken) this.renderError.raiseEvent(this, new Error('boom'));
|
||||||
|
this.preRender.raiseEvent(this, time);
|
||||||
|
this.postRender.raiseEvent(this, time);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const widget = Object.create(Cesium.CesiumWidget.prototype);
|
||||||
|
Object.assign(widget, {
|
||||||
|
_scene: scene,
|
||||||
|
_clock: { tick() {} },
|
||||||
|
_canRender: true,
|
||||||
|
_useDefaultRenderLoop: false,
|
||||||
|
_renderLoopRunning: false,
|
||||||
|
_showRenderLoopErrors: false,
|
||||||
|
_targetFrameRate: targetFrameRate,
|
||||||
|
isDestroyed: () => false,
|
||||||
|
resize() {},
|
||||||
|
});
|
||||||
|
// CesiumWidget's own renderError handler (CesiumWidget.js:414).
|
||||||
|
scene.renderError.addEventListener(() => {
|
||||||
|
widget._useDefaultRenderLoop = false;
|
||||||
|
widget._renderLoopRunning = false;
|
||||||
|
});
|
||||||
|
const viewer = {
|
||||||
|
scene,
|
||||||
|
cesiumWidget: widget,
|
||||||
|
canvas: { addEventListener() {}, removeEventListener() {} },
|
||||||
|
get useDefaultRenderLoop() {
|
||||||
|
return widget.useDefaultRenderLoop;
|
||||||
|
},
|
||||||
|
set useDefaultRenderLoop(value) {
|
||||||
|
widget.useDefaultRenderLoop = value;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const store = {
|
||||||
|
renderFailure: null,
|
||||||
|
publishRenderFailure(next) {
|
||||||
|
this.renderFailure = next;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const uninstall = installRenderFailureNotice(viewer, {
|
||||||
|
store,
|
||||||
|
locationRef: { href: 'https://gev.example/', reload() {}, assign() {} },
|
||||||
|
logError() {},
|
||||||
|
});
|
||||||
|
let time = 0;
|
||||||
|
const runFrames = (n) => {
|
||||||
|
for (let i = 0; i < n; i += 1) {
|
||||||
|
time += 17;
|
||||||
|
for (const fn of frames.splice(0)) fn(time);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const restore = () => {
|
||||||
|
uninstall();
|
||||||
|
globalThis.requestAnimationFrame = previousRaf;
|
||||||
|
};
|
||||||
|
return { frames, scene, viewer, store, runFrames, restore };
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const targetFrameRate of [undefined, 60]) {
|
||||||
|
test(`targetFrameRate ${targetFrameRate}: Retry before the next frame after an error Scene caught keeps one render loop`, () => {
|
||||||
|
const { frames, scene, viewer, store, runFrames, restore } = setup({
|
||||||
|
targetFrameRate,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
viewer.useDefaultRenderLoop = true;
|
||||||
|
runFrames(2);
|
||||||
|
scene.broken = true;
|
||||||
|
runFrames(1);
|
||||||
|
assert.equal(store.renderFailure?.reason, 'render-error');
|
||||||
|
// The cause is gone; Retry runs (a queued Enter on the focused Retry)
|
||||||
|
// before the loop's next animation frame.
|
||||||
|
scene.broken = false;
|
||||||
|
store.renderFailure.retry();
|
||||||
|
assert.equal(frames.length, 1, 'one animation frame queued after Retry');
|
||||||
|
scene.drawn = 0;
|
||||||
|
runFrames(3);
|
||||||
|
assert.equal(store.renderFailure, null);
|
||||||
|
assert.equal(scene.drawn, 3, 'the globe draws once a frame');
|
||||||
|
} finally {
|
||||||
|
restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('control: Retry after the next frame ran keeps one render loop', () => {
|
||||||
|
const { frames, scene, viewer, store, runFrames, restore } = setup();
|
||||||
|
try {
|
||||||
|
viewer.useDefaultRenderLoop = true;
|
||||||
|
runFrames(2);
|
||||||
|
scene.broken = true;
|
||||||
|
runFrames(1);
|
||||||
|
scene.broken = false;
|
||||||
|
runFrames(1);
|
||||||
|
assert.equal(frames.length, 0, 'the stopped loop queued nothing');
|
||||||
|
store.renderFailure.retry();
|
||||||
|
scene.drawn = 0;
|
||||||
|
runFrames(3);
|
||||||
|
assert.equal(scene.drawn, 3);
|
||||||
|
} finally {
|
||||||
|
restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a tab hidden and shown again before the next frame after an error Scene caught keeps one render loop', () => {
|
||||||
|
const { scene, viewer, runFrames, restore } = setup();
|
||||||
|
try {
|
||||||
|
viewer.useDefaultRenderLoop = true;
|
||||||
|
runFrames(2);
|
||||||
|
scene.broken = true;
|
||||||
|
runFrames(1);
|
||||||
|
scene.broken = false;
|
||||||
|
// src/app/tools.js turns the loop off while the tab is hidden and back on
|
||||||
|
// when it is shown, before the queued frame runs.
|
||||||
|
viewer.useDefaultRenderLoop = false;
|
||||||
|
viewer.useDefaultRenderLoop = true;
|
||||||
|
scene.drawn = 0;
|
||||||
|
runFrames(3);
|
||||||
|
assert.equal(scene.drawn, 3, 'the globe draws once a frame');
|
||||||
|
} finally {
|
||||||
|
restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
// src/ui/renderFailureView.test.mjs — the scene chrome renders the render
|
||||||
|
// failure notice installRenderFailureNotice() publishes, with its actions.
|
||||||
|
import '../testSupport/registerDom.mjs';
|
||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { createApp, nextTick } from 'vue';
|
||||||
|
import { createPinia } from 'pinia';
|
||||||
|
import { useStatusStore } from './stores/status.js';
|
||||||
|
import SceneChromeView from './views/SceneChromeView.vue';
|
||||||
|
|
||||||
|
function mount(t) {
|
||||||
|
document.body.replaceChildren();
|
||||||
|
const pinia = createPinia();
|
||||||
|
const app = createApp(SceneChromeView).use(pinia);
|
||||||
|
app.mount(document.body.appendChild(document.createElement('div')));
|
||||||
|
t.after(() => app.unmount());
|
||||||
|
return useStatusStore(pinia);
|
||||||
|
}
|
||||||
|
|
||||||
|
const notice = () => document.getElementById('render-failure');
|
||||||
|
const buttons = () =>
|
||||||
|
[...(notice()?.querySelectorAll('button') ?? [])].map((button) =>
|
||||||
|
button.textContent.trim(),
|
||||||
|
);
|
||||||
|
|
||||||
|
test('a render failure shows an alert dialog that moves focus to Retry', async (t) => {
|
||||||
|
const store = mount(t);
|
||||||
|
assert.equal(notice(), null, 'no notice while the globe draws');
|
||||||
|
const calls = [];
|
||||||
|
store.publishRenderFailure({
|
||||||
|
reason: 'render-error',
|
||||||
|
detail: 'Expected width to be greater than 0',
|
||||||
|
retry: () => calls.push('retry'),
|
||||||
|
reset: () => calls.push('reset'),
|
||||||
|
});
|
||||||
|
await nextTick();
|
||||||
|
await nextTick();
|
||||||
|
const element = notice();
|
||||||
|
assert.equal(element.getAttribute('role'), 'alertdialog');
|
||||||
|
assert.equal(
|
||||||
|
document.getElementById(element.getAttribute('aria-labelledby'))
|
||||||
|
.textContent,
|
||||||
|
'The globe stopped drawing',
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
document.getElementById(element.getAttribute('aria-describedby'))
|
||||||
|
.textContent,
|
||||||
|
/^Rendering stopped after an error\./,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
element.querySelector('.render-failure-detail').textContent,
|
||||||
|
'Expected width to be greater than 0',
|
||||||
|
);
|
||||||
|
assert.deepEqual(buttons(), ['Retry', 'Reset view']);
|
||||||
|
assert.equal(document.activeElement?.textContent.trim(), 'Retry');
|
||||||
|
for (const button of element.querySelectorAll('button')) button.click();
|
||||||
|
assert.deepEqual(calls, ['retry', 'reset']);
|
||||||
|
store.publishRenderFailure(null);
|
||||||
|
await nextTick();
|
||||||
|
assert.equal(notice(), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a lost context offers Reload, and a panel that cannot reload offers nothing', async (t) => {
|
||||||
|
const store = mount(t);
|
||||||
|
store.publishRenderFailure({
|
||||||
|
reason: 'context-lost',
|
||||||
|
detail: '',
|
||||||
|
retry: () => {},
|
||||||
|
reset: () => {},
|
||||||
|
});
|
||||||
|
await nextTick();
|
||||||
|
assert.match(
|
||||||
|
document.getElementById('render-failure-message').textContent,
|
||||||
|
/3D graphics/,
|
||||||
|
);
|
||||||
|
assert.equal(notice().querySelector('.render-failure-detail'), null);
|
||||||
|
assert.deepEqual(buttons(), ['Reload', 'Reset view']);
|
||||||
|
store.publishRenderFailure({
|
||||||
|
reason: 'context-lost',
|
||||||
|
detail: '',
|
||||||
|
retry: null,
|
||||||
|
reset: null,
|
||||||
|
});
|
||||||
|
await nextTick();
|
||||||
|
assert.deepEqual(buttons(), []);
|
||||||
|
assert.equal(notice().querySelector('.render-failure-hint'), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
const failure = () => ({
|
||||||
|
reason: 'render-error',
|
||||||
|
detail: '',
|
||||||
|
retry: () => {},
|
||||||
|
reset: null,
|
||||||
|
});
|
||||||
|
const settle = async () => {
|
||||||
|
await nextTick();
|
||||||
|
await nextTick();
|
||||||
|
};
|
||||||
|
|
||||||
|
test('focus goes back where it was when the globe draws again', async (t) => {
|
||||||
|
const store = mount(t);
|
||||||
|
const search = document.body.appendChild(document.createElement('input'));
|
||||||
|
search.focus();
|
||||||
|
store.publishRenderFailure(failure());
|
||||||
|
await settle();
|
||||||
|
assert.equal(document.activeElement?.textContent.trim(), 'Retry');
|
||||||
|
store.publishRenderFailure(null);
|
||||||
|
await settle();
|
||||||
|
assert.equal(document.activeElement, search);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The globe's canvas, as CesiumWidget builds it inside the container.
|
||||||
|
const addGlobeCanvas = () => {
|
||||||
|
const widget = document.createElement('div');
|
||||||
|
widget.className = 'cesium-widget';
|
||||||
|
document.getElementById('cesiumContainer').appendChild(widget);
|
||||||
|
return widget.appendChild(document.createElement('canvas'));
|
||||||
|
};
|
||||||
|
|
||||||
|
test('focus goes to the globe canvas when the element it came from is gone', async (t) => {
|
||||||
|
const store = mount(t);
|
||||||
|
const canvas = addGlobeCanvas();
|
||||||
|
const search = document.body.appendChild(document.createElement('input'));
|
||||||
|
search.focus();
|
||||||
|
store.publishRenderFailure(failure());
|
||||||
|
await settle();
|
||||||
|
search.remove();
|
||||||
|
store.publishRenderFailure(null);
|
||||||
|
await settle();
|
||||||
|
// The map's keys, Enter to finish a drawing among them, read the canvas
|
||||||
|
// as the map (src/annotations/drawTool.js).
|
||||||
|
assert.equal(document.activeElement, canvas);
|
||||||
|
assert.equal(canvas.getAttribute('tabindex'), '-1');
|
||||||
|
canvas.blur();
|
||||||
|
assert.equal(canvas.hasAttribute('tabindex'), false, 'only this once');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('focus goes to the globe canvas when the element it came from cannot take it', async (t) => {
|
||||||
|
const store = mount(t);
|
||||||
|
const canvas = addGlobeCanvas();
|
||||||
|
const button = document.body.appendChild(document.createElement('button'));
|
||||||
|
button.focus();
|
||||||
|
store.publishRenderFailure(failure());
|
||||||
|
await settle();
|
||||||
|
button.disabled = true;
|
||||||
|
store.publishRenderFailure(null);
|
||||||
|
await settle();
|
||||||
|
assert.equal(document.activeElement, canvas);
|
||||||
|
// A press on the globe leaves focus on the page again, as Cesium has it.
|
||||||
|
canvas.dispatchEvent(new Event('pointerdown'));
|
||||||
|
assert.equal(document.activeElement, document.body);
|
||||||
|
assert.equal(canvas.hasAttribute('tabindex'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('focus the user moved while the notice was up stays put', async (t) => {
|
||||||
|
const store = mount(t);
|
||||||
|
store.publishRenderFailure(failure());
|
||||||
|
await settle();
|
||||||
|
const chat = document.body.appendChild(document.createElement('textarea'));
|
||||||
|
chat.focus();
|
||||||
|
store.publishRenderFailure(null);
|
||||||
|
await settle();
|
||||||
|
assert.equal(document.activeElement, chat);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the globe drawing from the start leaves focus alone', async (t) => {
|
||||||
|
mount(t);
|
||||||
|
await settle();
|
||||||
|
assert.equal(document.activeElement, document.body);
|
||||||
|
assert.equal(
|
||||||
|
document.getElementById('cesiumContainer').hasAttribute('tabindex'),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
+17
-1
@@ -7,7 +7,9 @@ import { markRaw, shallowRef } from 'vue';
|
|||||||
* road network chip, CctvControls the camera grid chip; the toast shows
|
* road network chip, CctvControls the camera grid chip; the toast shows
|
||||||
* what any owner's createToast() (src/ui/toast.js) publishes, and the title
|
* what any owner's createToast() (src/ui/toast.js) publishes, and the title
|
||||||
* bar the FPS readout createFrameRateMonitor() publishes. Each label keeps its
|
* bar the FPS readout createFrameRateMonitor() publishes. Each label keeps its
|
||||||
* last text while hidden, so the next change flaps from what was shown.
|
* last text while hidden, so the next change flaps from what was shown. The
|
||||||
|
* render failure notice is what installRenderFailureNotice()
|
||||||
|
* (src/ui/renderFailure.js) publishes while the globe cannot draw.
|
||||||
*/
|
*/
|
||||||
export const useStatusStore = defineStore('gev-status', () => {
|
export const useStatusStore = defineStore('gev-status', () => {
|
||||||
/**
|
/**
|
||||||
@@ -32,6 +34,13 @@ export const useStatusStore = defineStore('gev-status', () => {
|
|||||||
*/
|
*/
|
||||||
const fps = shallowRef(null);
|
const fps = shallowRef(null);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The globe's render failure, `{reason, detail, retry, reset}`; null while
|
||||||
|
* the globe draws. `reason` is 'render-error' or 'context-lost', and `retry`
|
||||||
|
* and `reset` are null where that action is not offered.
|
||||||
|
*/
|
||||||
|
const renderFailure = shallowRef(null);
|
||||||
|
|
||||||
/** @param {?object} next A fresh presentation of the loading status. */
|
/** @param {?object} next A fresh presentation of the loading status. */
|
||||||
function publishLoading(next) {
|
function publishLoading(next) {
|
||||||
loading.value = next && markRaw(next);
|
loading.value = next && markRaw(next);
|
||||||
@@ -57,6 +66,11 @@ export const useStatusStore = defineStore('gev-status', () => {
|
|||||||
fps.value = next && markRaw(next);
|
fps.value = next && markRaw(next);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @param {?object} next A fresh presentation of the render failure. */
|
||||||
|
function publishRenderFailure(next) {
|
||||||
|
renderFailure.value = next && markRaw(next);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
fps,
|
fps,
|
||||||
publishFps,
|
publishFps,
|
||||||
@@ -68,5 +82,7 @@ export const useStatusStore = defineStore('gev-status', () => {
|
|||||||
publishTraffic,
|
publishTraffic,
|
||||||
cctv,
|
cctv,
|
||||||
publishCctv,
|
publishCctv,
|
||||||
|
renderFailure,
|
||||||
|
publishRenderFailure,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -405,6 +405,76 @@ html:not([lang='en']) #global-loading-detail {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Render failure notice ───────────────────── */
|
||||||
|
/* Above the loading screen, so a globe that fails while it loads says so too,
|
||||||
|
and above floating panels such as the Québec camera viewer (2000). */
|
||||||
|
#render-failure {
|
||||||
|
position: fixed;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
z-index: 2001;
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: min(420px, calc(100vw - 32px));
|
||||||
|
max-height: calc(100vh - 32px);
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 20px 22px;
|
||||||
|
background: var(--glass-bg);
|
||||||
|
border: 1px solid #ff7272;
|
||||||
|
border-radius: 12px;
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
-webkit-backdrop-filter: blur(16px);
|
||||||
|
box-shadow: 0 0 24px rgba(255, 88, 88, 0.18);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
#render-failure-title {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #ff7272;
|
||||||
|
}
|
||||||
|
|
||||||
|
#render-failure p {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#render-failure .render-failure-detail {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
#render-failure .render-failure-hint {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
#render-failure .render-failure-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#render-failure .gev-action-button {
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (pointer: coarse) {
|
||||||
|
#render-failure .gev-action-button {
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Toast Notification ──────────────────────── */
|
/* ── Toast Notification ──────────────────────── */
|
||||||
#toast {
|
#toast {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import ScreenAnnotations from './panels/ScreenAnnotations.vue';
|
|||||||
import AwarenessDirection from './panels/AwarenessDirection.vue';
|
import AwarenessDirection from './panels/AwarenessDirection.vue';
|
||||||
import UiButton from '../kit/UiButton.vue';
|
import UiButton from '../kit/UiButton.vue';
|
||||||
import GlobeActions from './panels/GlobeActions.vue';
|
import GlobeActions from './panels/GlobeActions.vue';
|
||||||
|
import RenderFailure from './panels/RenderFailure.vue';
|
||||||
import WorldTimeline from './panels/WorldTimeline.vue';
|
import WorldTimeline from './panels/WorldTimeline.vue';
|
||||||
|
|
||||||
// The ACTIVE STYLE corner shows the style VisualSettings publishes.
|
// The ACTIVE STYLE corner shows the style VisualSettings publishes.
|
||||||
@@ -61,6 +62,7 @@ const vToastText = {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div id="cesiumContainer"></div>
|
<div id="cesiumContainer"></div>
|
||||||
|
<RenderFailure />
|
||||||
<div id="world-overlay-root" aria-hidden="true">
|
<div id="world-overlay-root" aria-hidden="true">
|
||||||
<canvas id="world-overlay-canvas"></canvas>
|
<canvas id="world-overlay-canvas"></canvas>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
<script setup>
|
||||||
|
import { nextTick, ref, watch } from 'vue';
|
||||||
|
import { storeToRefs } from 'pinia';
|
||||||
|
import { msg, t } from '../../../i18n/index.js';
|
||||||
|
import { useStatusStore } from '../../stores/status.js';
|
||||||
|
import UiButton from '../../kit/UiButton.vue';
|
||||||
|
|
||||||
|
// The notice installRenderFailureNotice() (src/ui/renderFailure.js) publishes
|
||||||
|
// while the globe cannot draw, with the actions it offers.
|
||||||
|
const { renderFailure: failure } = storeToRefs(useStatusStore());
|
||||||
|
const panel = ref(null);
|
||||||
|
const MESSAGES = {
|
||||||
|
'context-lost': msg(
|
||||||
|
"The browser took back the globe's 3D graphics. Layers and controls still work, but the map cannot draw until the page reloads.",
|
||||||
|
),
|
||||||
|
'render-error': msg(
|
||||||
|
'Rendering stopped after an error. Layers and controls still work, but the map is not updating.',
|
||||||
|
),
|
||||||
|
};
|
||||||
|
// Focus moves to the first action each time the notice appears, so a
|
||||||
|
// keyboard user can act on it at once. When the globe draws again, it goes
|
||||||
|
// back where it was, or to the map if that cannot take it, unless the user
|
||||||
|
// has moved it since.
|
||||||
|
let focusBefore;
|
||||||
|
watch(
|
||||||
|
() => Boolean(failure.value),
|
||||||
|
async (shown) => {
|
||||||
|
if (shown) {
|
||||||
|
focusBefore = document.activeElement;
|
||||||
|
await nextTick();
|
||||||
|
panel.value?.querySelector('button')?.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (focusBefore === undefined) return;
|
||||||
|
const previous = focusBefore;
|
||||||
|
focusBefore = undefined;
|
||||||
|
await nextTick();
|
||||||
|
const active = document.activeElement;
|
||||||
|
if (active && active !== document.body) return;
|
||||||
|
if (previous?.isConnected && previous !== document.body) {
|
||||||
|
previous.focus({ preventScroll: true });
|
||||||
|
// A control disabled, hidden or made inert since does not take it.
|
||||||
|
if (document.activeElement === previous) return;
|
||||||
|
}
|
||||||
|
focusMap();
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
// The globe's canvas, which the map's keys (Enter to finish a drawing, Space
|
||||||
|
// to talk) read as the map. It takes focus only this once: when focus moves
|
||||||
|
// on, or the globe is pressed, it goes back to a canvas that cannot take
|
||||||
|
// focus, and a press leaves focus on the page, as Cesium has it.
|
||||||
|
function focusMap() {
|
||||||
|
const canvas = document.querySelector(
|
||||||
|
'#cesiumContainer .cesium-widget canvas',
|
||||||
|
);
|
||||||
|
if (!canvas) return;
|
||||||
|
if (!canvas.hasAttribute('tabindex')) {
|
||||||
|
canvas.tabIndex = -1;
|
||||||
|
const done = new AbortController();
|
||||||
|
const release = () => {
|
||||||
|
done.abort();
|
||||||
|
canvas.removeAttribute('tabindex');
|
||||||
|
};
|
||||||
|
canvas.addEventListener('blur', release, { signal: done.signal });
|
||||||
|
canvas.addEventListener(
|
||||||
|
'pointerdown',
|
||||||
|
() => {
|
||||||
|
release();
|
||||||
|
canvas.blur();
|
||||||
|
},
|
||||||
|
{ capture: true, signal: done.signal },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
canvas.focus({ preventScroll: true });
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-if="failure" id="render-failure" ref="panel" role="alertdialog" aria-labelledby="render-failure-title" aria-describedby="render-failure-message">
|
||||||
|
<h2 id="render-failure-title">{{ t('The globe stopped drawing') }}</h2>
|
||||||
|
<p id="render-failure-message">{{ t(MESSAGES[failure.reason] ?? MESSAGES['render-error']) }}</p>
|
||||||
|
<p v-if="failure.detail" class="render-failure-detail" translate="no">{{ failure.detail }}</p>
|
||||||
|
<p v-if="failure.reset" class="render-failure-hint">{{ t('Reset view reloads the app without the view restored from the address bar.') }}</p>
|
||||||
|
<div v-if="failure.retry || failure.reset" class="render-failure-actions">
|
||||||
|
<UiButton v-if="failure.retry" class="gev-action-button render-failure-retry" :onClick="failure.retry">{{ failure.reason === 'context-lost' ? t('Reload') : t('Retry') }}</UiButton>
|
||||||
|
<UiButton v-if="failure.reset" class="gev-action-button render-failure-reset" :onClick="failure.reset">{{ t('Reset view') }}</UiButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
Reference in new issue
Block a user