Compare commits

..
Author SHA1 Message Date
German Khokhlov b65a7aaed8 Keep an explicit CCTV pick of the camera a move re-opened
Until someone picks a camera, a settled move re-opens CCTV on the camera
nearest the view and keeps the opening pick provisional. setActiveCamera
ends that opening pick, but for a camera that is already active and
activated it returns UNCHANGED before clearing the flag. So pressing
NEAREST on the camera a move had just re-opened (or selecting it from the
list, or through setParams) left the pick provisional, and the landing of
NEAREST's own flight, or the next pan, switched to another camera.

Clear the provisional flag on the UNCHANGED path too. reopenNearestCamera
only activates a different camera, so it is unaffected, and the same-camera
re-select still skips the activation work.
2026-11-13 18:47:30 +04:00
35 changed files with 161 additions and 1663 deletions

No files matched your search

@@ -1,6 +0,0 @@
- 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).
-64
View File
@@ -2442,70 +2442,6 @@ 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
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 controls own chip listeners, source-state subscriptions and selection
-6
View File
@@ -557,12 +557,6 @@
"src/ui/frameRateMonitor.js",
"src/ui/frameRateMonitor.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/controls.js",
"src/layers/cctv/geometry.js",
-2
View File
@@ -163,7 +163,6 @@
"src/ui/kit/UiBadge.vue",
"src/ui/stores/globeActions.js",
"src/ui/views/panels/GlobeActions.vue",
"src/ui/views/panels/RenderFailure.vue",
"src/ui/views/panels/GlobeCompass.vue",
"src/ui/stores/recording.js",
"src/ui/styleParameters.js",
@@ -3114,7 +3113,6 @@
"src/ui/displayControls.js",
"src/ui/effects.js",
"src/ui/frameRateMonitor.js",
"src/ui/renderFailure.js",
"src/ui/layerPanel.js",
"src/ui/weatherPanel.js",
"src/ui/railCards.js",
@@ -400,27 +400,6 @@ 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 () => {
const h = harness();
try {
+11 -13
View File
@@ -122,30 +122,28 @@ export function keepPanelRendering(
frameRequest = windowRef.requestAnimationFrame(onFrame);
};
frameRequest = windowRef.requestAnimationFrame(onFrame);
// Cesium stops its render loop after a render error, and the timer stops
// with it; Retry restarts both. Report the error in full: workers report
// plain objects, which Cesium prints as [object Object].
// Cesium stops drawing after a render error, and so does the timer.
// Report the error in full: workers report plain objects, which Cesium
// prints as [object Object].
let failed = false;
const removeErrorListener = viewer.scene?.renderError?.addEventListener(
(_scene, error) =>
(_scene, error) => {
failed = true;
console.error(
"[God's Eye View panel] render error:",
describeError(error),
),
);
},
);
const timer = windowRef.setInterval(() => {
if (
viewer.useDefaultRenderLoop === false ||
failed ||
now() - lastFrame < MISSED_FRAMES_MS ||
viewer.isDestroyed?.()
)
return;
try {
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.
}
viewer.resize();
viewer.render();
}, PANEL_FRAME_MS);
return () => {
windowRef.clearInterval(timer);
-2
View File
@@ -482,8 +482,6 @@ test('a panel reports a render error in full and stops drawing', () => {
try {
let clock = 0;
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' });
assert.match(logged[0], /render error: .*"message":"worker failed"/);
clock = 1000;
-156
View File
@@ -1,156 +0,0 @@
// 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();
}
});
-8
View File
@@ -15,8 +15,6 @@ import {
governorRequestRender,
} from '../renderGovernor.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 { followWorldTime } from '../time/viewerClock.js';
import { onLocaleChange, t } from '../i18n/index.js';
@@ -57,17 +55,11 @@ export async function createApplicationScene({
const viewer = createApplicationViewer({
container: 'cesiumContainer',
creditContainer,
// The render failure notice below replaces Cesium's dead-end panel.
showRenderLoopErrors: false,
});
defer(() => {
uninstallRenderGovernor(viewer);
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(
followWorldTime(viewer, worldTimeFor('sky'), {
+4 -16
View File
@@ -375,15 +375,8 @@ export function installGlobeInViewRecovery(
};
}
/**
* 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 } = {},
) {
/** Cesium.Viewer options of the standard globe viewer. */
export function applicationViewerOptions(creditContainer) {
return {
timeline: false,
animation: false,
@@ -403,21 +396,16 @@ export function applicationViewerOptions(
creditContainer,
msaaSamples: 4,
contextOptions: { webgl: { preserveDrawingBuffer: true } },
showRenderLoopErrors,
};
}
/** Create the standard globe viewer in caller-owned, visible containers. */
export function createApplicationViewer({
container,
creditContainer,
showRenderLoopErrors,
}) {
export function createApplicationViewer({ container, creditContainer }) {
if (!container || !creditContainer)
throw new TypeError('Viewer and credit containers are required');
const viewer = new Cesium.Viewer(
container,
applicationViewerOptions(creditContainer, { showRenderLoopErrors }),
applicationViewerOptions(creditContainer),
);
try {
viewer.targetFrameRate = 60;
@@ -0,0 +1,129 @@
// 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');
});
});
}
+1 -8
View File
@@ -3953,12 +3953,5 @@
"Confirm: remove every added entry from Data Layers": "تأكيد: إزالة كل الإدخالات المُضافة من طبقات البيانات",
"Remove every added entry from Data Layers": "إزالة كل الإدخالات المُضافة من طبقات البيانات",
"Showing {shown} of {total}": "عرض {shown} من {total}",
"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": "إعادة ضبط العرض"
"BROWSE DATA LIBRARY": "تصفّح مكتبة البيانات"
}
+1 -8
View File
@@ -3605,12 +3605,5 @@
"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",
"Showing {shown} of {total}": "{shown} von {total} angezeigt",
"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"
"BROWSE DATA LIBRARY": "DATENBIBLIOTHEK DURCHSUCHEN"
}
+1 -8
View File
@@ -3692,12 +3692,5 @@
"500–1,000 m": "500–1000 m",
"1,000–2,000 m": "1000–2000 m",
"2,000–3,000 m": "2000–3000 m",
"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"
"3,000 m and above": "3000 m o más"
}
+1 -8
View File
@@ -3692,12 +3692,5 @@
"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",
"Showing {shown} of {total}": "Affichées : {shown} sur {total}",
"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"
"BROWSE DATA LIBRARY": "PARCOURIR LA BIBLIOTHÈQUE DE DONNÉES"
}
+1 -8
View File
@@ -3692,12 +3692,5 @@
"Confirm: remove every added entry from Data Layers": "אישור: הסרת כל הפריטים שנוספו משכבות נתונים",
"Remove every added entry from Data Layers": "הסרת כל הפריטים שנוספו משכבות נתונים",
"Showing {shown} of {total}": "מוצגים {shown} מתוך {total}",
"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": "איפוס תצוגה"
"BROWSE DATA LIBRARY": "עיון בספריית הנתונים"
}
+1 -8
View File
@@ -3692,12 +3692,5 @@
"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",
"Showing {shown} of {total}": "Mostrati: {shown} su {total}",
"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"
"BROWSE DATA LIBRARY": "ESPLORA LIBRERIA DATI"
}
+1 -8
View File
@@ -3518,12 +3518,5 @@
"Confirm: remove every added entry from Data Layers": "確認:追加したすべての項目をデータレイヤーから削除",
"Remove every added entry from Data Layers": "追加したすべての項目をデータレイヤーから削除",
"Showing {shown} of {total}": "{total}件中{shown}件を表示",
"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": "ビューをリセット"
"BROWSE DATA LIBRARY": "データライブラリを見る"
}
+1 -8
View File
@@ -3692,12 +3692,5 @@
"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",
"Showing {shown} of {total}": "Mostrando {shown} de {total}",
"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"
"BROWSE DATA LIBRARY": "EXPLORAR BIBLIOTECA DE DADOS"
}
+1 -8
View File
@@ -3779,12 +3779,5 @@
"500–1,000 m": "500–1000 м",
"1,000–2,000 m": "1000–2000 м",
"2,000–3,000 m": "2000–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": "Сбросить вид"
"3,000 m and above": "3000 м и выше"
}
+1 -8
View File
@@ -3605,12 +3605,5 @@
"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",
"Showing {shown} of {total}": "Visar {shown} av {total}",
"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"
"BROWSE DATA LIBRARY": "BLÄDDRA I DATABIBLIOTEKET"
}
+1 -8
View File
@@ -3779,12 +3779,5 @@
"Confirm: remove every added entry from Data Layers": "Підтвердити: видалити всі додані записи з «Шарів даних»",
"Remove every added entry from Data Layers": "Видалити всі додані записи з «Шарів даних»",
"Showing {shown} of {total}": "Показано {shown} з {total}",
"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": "Скинути вигляд"
"BROWSE DATA LIBRARY": "ВІДКРИТИ БІБЛІОТЕКУ ДАНИХ"
}
+1 -8
View File
@@ -3518,12 +3518,5 @@
"Confirm: remove every added entry from Data Layers": "确认:从数据图层中移除所有已添加条目",
"Remove every added entry from Data Layers": "从数据图层中移除所有已添加条目",
"Showing {shown} of {total}": "显示 {shown} / {total}",
"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": "重置视图"
"BROWSE DATA LIBRARY": "浏览数据资源库"
}
+1 -8
View File
@@ -3518,12 +3518,5 @@
"Confirm: remove every added entry from Data Layers": "確認:從數據圖層中移除所有已添加條目",
"Remove every added entry from Data Layers": "從數據圖層中移除所有已添加條目",
"Showing {shown} of {total}": "顯示 {shown} / {total}",
"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": "重置視圖"
"BROWSE DATA LIBRARY": "瀏覽數據資源庫"
}
+3
View File
@@ -79,6 +79,9 @@ export function createSelection({
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
// without re-running activation or touching the projection geometry.
if (parts.model.isBrowserOverlayCamera(record.camera)) {
-221
View File
@@ -1,221 +0,0 @@
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];
};
}
-187
View File
@@ -1,187 +0,0 @@
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);
});
-259
View File
@@ -1,259 +0,0 @@
// 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();
}
});
}
-93
View File
@@ -1,93 +0,0 @@
// 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();
});
-151
View File
@@ -1,151 +0,0 @@
// 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();
}
});
-174
View File
@@ -1,174 +0,0 @@
// 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,
);
});
+1 -17
View File
@@ -7,9 +7,7 @@ import { markRaw, shallowRef } from 'vue';
* road network chip, CctvControls the camera grid chip; the toast shows
* what any owner's createToast() (src/ui/toast.js) publishes, and the title
* bar the FPS readout createFrameRateMonitor() publishes. Each label keeps its
* 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.
* last text while hidden, so the next change flaps from what was shown.
*/
export const useStatusStore = defineStore('gev-status', () => {
/**
@@ -34,13 +32,6 @@ export const useStatusStore = defineStore('gev-status', () => {
*/
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. */
function publishLoading(next) {
loading.value = next && markRaw(next);
@@ -66,11 +57,6 @@ export const useStatusStore = defineStore('gev-status', () => {
fps.value = next && markRaw(next);
}
/** @param {?object} next A fresh presentation of the render failure. */
function publishRenderFailure(next) {
renderFailure.value = next && markRaw(next);
}
return {
fps,
publishFps,
@@ -82,7 +68,5 @@ export const useStatusStore = defineStore('gev-status', () => {
publishTraffic,
cctv,
publishCctv,
renderFailure,
publishRenderFailure,
};
});
-70
View File
@@ -405,76 +405,6 @@ 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 {
position: fixed;
-2
View File
@@ -18,7 +18,6 @@ import ScreenAnnotations from './panels/ScreenAnnotations.vue';
import AwarenessDirection from './panels/AwarenessDirection.vue';
import UiButton from '../kit/UiButton.vue';
import GlobeActions from './panels/GlobeActions.vue';
import RenderFailure from './panels/RenderFailure.vue';
import WorldTimeline from './panels/WorldTimeline.vue';
// The ACTIVE STYLE corner shows the style VisualSettings publishes.
@@ -62,7 +61,6 @@ const vToastText = {
<template>
<div id="cesiumContainer"></div>
<RenderFailure />
<div id="world-overlay-root" aria-hidden="true">
<canvas id="world-overlay-canvas"></canvas>
</div>
-91
View File
@@ -1,91 +0,0 @@
<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>