diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index f1244947d..a9095937d 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1579,6 +1579,33 @@ 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. 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. + +- **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. +- **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/` 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 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 diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 2c589d8cc..5b53a5bd1 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -545,6 +545,8 @@ "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/layers/cctv/lifecycle.js", "src/layers/cctv/controls.js", "src/layers/cctv/geometry.js", diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index d008ceaaa..559ef2ba1 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -148,6 +148,7 @@ "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", @@ -2802,6 +2803,7 @@ "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", diff --git a/src/app/scene.js b/src/app/scene.js index 1455b8268..858335ae0 100644 --- a/src/app/scene.js +++ b/src/app/scene.js @@ -14,6 +14,8 @@ import { governorRequestRender, } from '../renderGovernor.js'; import { describeError } from './errors.js'; +import { isEmbeddedInline } from './embed.js'; +import { installRenderFailureNotice } from '../ui/renderFailure.js'; import { onLocaleChange, t } from '../i18n/index.js'; /** Construct the application globe using the caller's local configuration. */ @@ -52,11 +54,17 @@ 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)); registerDataCredits(viewer, credits); configureCreditKeyboardAccess(document); diff --git a/src/app/viewer.js b/src/app/viewer.js index c8b841352..0a5ce32ab 100644 --- a/src/app/viewer.js +++ b/src/app/viewer.js @@ -103,8 +103,15 @@ export function installTrackpadPinchZoom( }; } -/** 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 { timeline: false, animation: false, @@ -124,16 +131,21 @@ export function applicationViewerOptions(creditContainer) { creditContainer, msaaSamples: 4, contextOptions: { webgl: { preserveDrawingBuffer: true } }, + showRenderLoopErrors, }; } /** Create the standard globe viewer in caller-owned, visible containers. */ -export function createApplicationViewer({ container, creditContainer }) { +export function createApplicationViewer({ + container, + creditContainer, + showRenderLoopErrors, +}) { if (!container || !creditContainer) throw new TypeError('Viewer and credit containers are required'); const viewer = new Cesium.Viewer( container, - applicationViewerOptions(creditContainer), + applicationViewerOptions(creditContainer, { showRenderLoopErrors }), ); try { viewer.targetFrameRate = 60; diff --git a/src/i18n/locales/ar.json b/src/i18n/locales/ar.json index c9fca14ce..4f7d2c1c7 100644 --- a/src/i18n/locales/ar.json +++ b/src/i18n/locales/ar.json @@ -3404,5 +3404,12 @@ "FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "المجال الجوي الخاص المحظور والمقيّد لدى FAA فقط، ولا يشمل UAS Facility Map أو قيود طيران UAS. للعلم فقط — ولا يغني عن الأدوات الرسمية لتخطيط رحلات UAS (B4UFLY / LAANC).", "Prohibited": "محظور", "Restricted": "مقيّد", - "Prohibited & Restricted Airspace": "المجال الجوي المحظور والمقيّد" + "Prohibited & Restricted Airspace": "المجال الجوي المحظور والمقيّد", + "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": "إعادة ضبط العرض" } diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index bb683ed9f..7e9fa4ca6 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -3104,5 +3104,12 @@ "The provider rejected the API key. Check it in Provider Settings.": "Der Anbieter hat den API-Schlüssel abgelehnt. Den Schlüssel in den Anbieter-Einstellungen prüfen.", "Too many chat requests. Try again in {seconds} s.": "Zu viele Chat-Anfragen. In {seconds} s erneut versuchen.", "The provider refused access for this key. Check the key's project, region or permissions.": "Der Anbieter hat den Zugriff für diesen Schlüssel verweigert. Projekt, Region oder Berechtigungen des Schlüssels prüfen.", - "The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "Das Anbieterkonto hat kein Guthaben mehr. Die Abrechnung prüfen oder in den Einstellungen einen anderen KI-Anbieter wählen." + "The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "Das Anbieterkonto hat kein Guthaben mehr. Die Abrechnung prüfen oder in den Einstellungen einen anderen KI-Anbieter wählen.", + "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" } diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 10dcbef8f..0aa82b851 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -3179,5 +3179,12 @@ "The provider rejected the API key. Check it in Provider Settings.": "El proveedor rechazó la clave de API. Revísala en Ajustes de proveedores.", "Too many chat requests. Try again in {seconds} s.": "Demasiadas solicitudes de chat. Vuelve a intentarlo en {seconds} s.", "The provider refused access for this key. Check the key's project, region or permissions.": "El proveedor denegó el acceso a esta clave. Revisa el proyecto, la región o los permisos de la clave.", - "The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "La cuenta del proveedor no tiene crédito. Revisa la facturación o elige otro proveedor de IA en Ajustes." + "The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "La cuenta del proveedor no tiene crédito. Revisa la facturación o elige otro proveedor de IA en Ajustes.", + "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" } diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 108e56f36..7ed394a1d 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -3179,5 +3179,12 @@ "The provider rejected the API key. Check it in Provider Settings.": "Le fournisseur a refusé la clé API. Vérifiez-la dans les paramètres des fournisseurs.", "Too many chat requests. Try again in {seconds} s.": "Trop de requêtes de discussion. Réessayez dans {seconds} s.", "The provider refused access for this key. Check the key's project, region or permissions.": "Le fournisseur a refusé l'accès pour cette clé. Vérifiez le projet, la région ou les autorisations de la clé.", - "The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "Le compte du fournisseur n'a plus de crédit. Vérifiez la facturation ou choisissez un autre fournisseur d'IA dans les paramètres." + "The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "Le compte du fournisseur n'a plus de crédit. Vérifiez la facturation ou choisissez un autre fournisseur d'IA dans les paramètres.", + "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" } diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index 6768f93c7..9bd79592b 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -3179,5 +3179,12 @@ "The provider rejected the API key. Check it in Provider Settings.": "Il provider ha rifiutato la chiave API. Controllala in Impostazioni provider.", "Too many chat requests. Try again in {seconds} s.": "Troppe richieste di chat. Riprova tra {seconds} s.", "The provider refused access for this key. Check the key's project, region or permissions.": "Il provider ha negato l'accesso per questa chiave. Controlla progetto, regione o autorizzazioni della chiave.", - "The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "L'account del provider ha esaurito il credito. Controlla la fatturazione o scegli un altro provider IA in Impostazioni." + "The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "L'account del provider ha esaurito il credito. Controlla la fatturazione o scegli un altro provider IA in Impostazioni.", + "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" } diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index b4566b4cb..9661fcf69 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -3029,5 +3029,12 @@ "The provider rejected the API key. Check it in Provider Settings.": "プロバイダーがAPIキーを拒否しました。プロバイダー設定で確認してください。", "Too many chat requests. Try again in {seconds} s.": "チャットのリクエストが多すぎます。{seconds}秒後にもう一度お試しください。", "The provider refused access for this key. Check the key's project, region or permissions.": "プロバイダーがこのキーでのアクセスを拒否しました。キーのプロジェクト、リージョン、権限を確認してください。", - "The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "プロバイダーアカウントのクレジットが不足しています。課金状況を確認するか、設定で別のAIプロバイダーを選んでください。" + "The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "プロバイダーアカウントのクレジットが不足しています。課金状況を確認するか、設定で別のAIプロバイダーを選んでください。", + "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": "ビューをリセット" } diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index 3f37cde2e..abe5cb58e 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -3179,5 +3179,12 @@ "The provider rejected the API key. Check it in Provider Settings.": "O provedor recusou a chave de API. Verifique-a em \"Configurações de provedores\".", "Too many chat requests. Try again in {seconds} s.": "Chat: limite de requisições atingido. Tente novamente em {seconds} s.", "The provider refused access for this key. Check the key's project, region or permissions.": "O provedor negou o acesso para esta chave. Verifique o projeto, a região ou as permissões da chave.", - "The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "A conta do provedor está sem crédito. Verifique o faturamento ou escolha outro provedor de IA em \"Configurações\"." + "The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "A conta do provedor está sem crédito. Verifique o faturamento ou escolha outro provedor de IA em \"Configurações\".", + "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" } diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index e279508ef..b0c4db994 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -3254,5 +3254,12 @@ "The provider rejected the API key. Check it in Provider Settings.": "Провайдер отклонил ключ API. Проверьте его в «Настройках провайдеров».", "Too many chat requests. Try again in {seconds} s.": "Слишком много запросов в чате. Повторите попытку через {seconds} с.", "The provider refused access for this key. Check the key's project, region or permissions.": "Провайдер отказал этому ключу в доступе. Проверьте проект, регион или разрешения ключа.", - "The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "На вашем счёте у провайдера закончились средства. Проверьте оплату или выберите другого провайдера ИИ в настройках." + "The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "На вашем счёте у провайдера закончились средства. Проверьте оплату или выберите другого провайдера ИИ в настройках.", + "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": "Сбросить вид" } diff --git a/src/i18n/locales/uk.json b/src/i18n/locales/uk.json index 8c3b634b9..e9ce1a133 100644 --- a/src/i18n/locales/uk.json +++ b/src/i18n/locales/uk.json @@ -3254,5 +3254,12 @@ "The provider rejected the API key. Check it in Provider Settings.": "Постачальник відхилив ключ API. Перевірте його в «Налаштуваннях постачальників».", "Too many chat requests. Try again in {seconds} s.": "Забагато запитів до чату. Спробуйте ще раз через {seconds} с.", "The provider refused access for this key. Check the key's project, region or permissions.": "Постачальник відмовив цьому ключу в доступі. Перевірте проєкт, регіон або дозволи ключа.", - "The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "На вашому рахунку в постачальника закінчилися кошти. Перевірте оплату або виберіть іншого постачальника ШІ в налаштуваннях." + "The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "На вашому рахунку в постачальника закінчилися кошти. Перевірте оплату або виберіть іншого постачальника ШІ в налаштуваннях.", + "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": "Скинути вигляд" } diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 6125315f2..f33ce5850 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -3029,5 +3029,12 @@ "The provider rejected the API key. Check it in Provider Settings.": "服务商拒绝了 API 密钥。请在服务商设置中检查。", "Too many chat requests. Try again in {seconds} s.": "聊天请求过多。请在 {seconds} 秒后重试。", "The provider refused access for this key. Check the key's project, region or permissions.": "服务商拒绝了此密钥的访问请求。请检查该密钥所属的项目、区域或权限。", - "The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "服务商账户余额不足。请检查账户的计费信息,或在设置中选择其他 AI 服务商。" + "The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "服务商账户余额不足。请检查账户的计费信息,或在设置中选择其他 AI 服务商。", + "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": "重置视图" } diff --git a/src/i18n/locales/zh-Hant.json b/src/i18n/locales/zh-Hant.json index 66eb37b96..ca90fdf90 100644 --- a/src/i18n/locales/zh-Hant.json +++ b/src/i18n/locales/zh-Hant.json @@ -3029,5 +3029,12 @@ "The provider rejected the API key. Check it in Provider Settings.": "服務商拒絕了 API 密鑰。請在服務商設置中檢查。", "Too many chat requests. Try again in {seconds} s.": "聊天請求過多。請在 {seconds} 秒後重試。", "The provider refused access for this key. Check the key's project, region or permissions.": "服務商拒絕了此密鑰的訪問請求。請檢查該密鑰所屬的項目、區域或權限。", - "The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "服務商賬戶餘額不足。請檢查賬戶的計費信息,或在設置中選擇其他 AI 服務商。" + "The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "服務商賬戶餘額不足。請檢查賬戶的計費信息,或在設置中選擇其他 AI 服務商。", + "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": "重置視圖" } diff --git a/src/ui/renderFailure.js b/src/ui/renderFailure.js new file mode 100644 index 000000000..45010a44f --- /dev/null +++ b/src/ui/renderFailure.js @@ -0,0 +1,114 @@ +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. + * + * The notice is published to the status store, which the scene chrome + * renders. Retry restarts the render loop after an error, and the notice stays + * until a frame renders without one; after a context loss it reloads the page, + * whose address keeps the view. Reset opens the app's root address, without + * the view restored from the address bar. Where the page must not reload + * itself (`allowReload: false`), Reset is left out, and so is Retry after a + * context loss. + * + * Returns a function that removes the listeners and 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?.scene; + if (!scene?.renderError) return () => {}; + const canvas = viewer.canvas ?? scene.canvas; + let notice = null; + let shown = 0; + let contextLost = false; + 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; + }); + 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, + shown: notice ? notice.shown : ++shown, + retry: contextLost && !allowReload ? null : retry, + reset: allowReload ? reset : null, + }; + store.publishRenderFailure(notice); + watchFrames(); + } + + const onRenderError = (_scene, error) => { + frameFailed = true; + logError(error); + present(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); + canvas?.addEventListener?.('webglcontextlost', onContextLost); + return () => { + removeRenderError(); + canvas?.removeEventListener?.('webglcontextlost', onContextLost); + clear(); + }; +} diff --git a/src/ui/renderFailure.test.mjs b/src/ui/renderFailure.test.mjs new file mode 100644 index 000000000..e513ec8cc --- /dev/null +++ b/src/ui/renderFailure.test.mjs @@ -0,0 +1,184 @@ +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(notice.shown, 1); + 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.equal(f.store.renderFailure.shown, first.shown, '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.equal(f.store.renderFailure.shown, 2); +}); + +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.reason, 'context-lost'); + 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); +}); + +test('a viewer without a scene installs nothing', () => { + const uninstall = installRenderFailureNotice(null, { + store: { publishRenderFailure: () => assert.fail('nothing to publish') }, + }); + uninstall(); +}); diff --git a/src/ui/renderFailureView.test.mjs b/src/ui/renderFailureView.test.mjs new file mode 100644 index 000000000..ee28836c6 --- /dev/null +++ b/src/ui/renderFailureView.test.mjs @@ -0,0 +1,90 @@ +// 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', + shown: 1, + 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: '', + shown: 1, + 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: '', + shown: 1, + retry: null, + reset: null, + }); + await nextTick(); + assert.deepEqual(buttons(), []); + assert.equal(notice().querySelector('.render-failure-hint'), null); +}); diff --git a/src/ui/stores/status.js b/src/ui/stores/status.js index 34f66233d..1293503fa 100644 --- a/src/ui/stores/status.js +++ b/src/ui/stores/status.js @@ -7,7 +7,9 @@ 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. + * 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', () => { /** @@ -32,6 +34,14 @@ export const useStatusStore = defineStore('gev-status', () => { */ const fps = shallowRef(null); + /** + * The globe's render failure, `{reason, detail, shown, retry, reset}`; null + * while the globe draws. `reason` is 'render-error' or 'context-lost', + * `shown` changes each time the notice appears, 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); @@ -57,6 +67,11 @@ 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, @@ -68,5 +83,7 @@ export const useStatusStore = defineStore('gev-status', () => { publishTraffic, cctv, publishCctv, + renderFailure, + publishRenderFailure, }; }); diff --git a/src/ui/styles/status.css b/src/ui/styles/status.css index bba1ef788..0b3e66d75 100644 --- a/src/ui/styles/status.css +++ b/src/ui/styles/status.css @@ -387,6 +387,69 @@ html:not([lang='en']) #global-loading-detail { } } +/* ── Render failure notice ───────────────────── */ +/* Above the loading screen: a globe that fails while it loads says so too. */ +#render-failure { + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + z-index: 1001; + 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; +} + /* ── Toast Notification ──────────────────────── */ #toast { position: fixed; diff --git a/src/ui/views/SceneChromeView.vue b/src/ui/views/SceneChromeView.vue index f77dad3d2..7c4fc53ec 100644 --- a/src/ui/views/SceneChromeView.vue +++ b/src/ui/views/SceneChromeView.vue @@ -18,6 +18,7 @@ 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'; // The ACTIVE STYLE corner shows the style VisualSettings publishes. const { style } = storeToRefs(useDisplayStore()); @@ -60,6 +61,7 @@ const vToastText = {