Compare commits

..
Author SHA1 Message Date
German Khokhlov 4c5fbd3223 Changelog fragment for #1699 2026-11-13 18:47:10 +04:00
German Khokhlov 5d28d614e0 i18n: generate zh-Hant from zh-CN
zh-Hant is the zh-CN text in Traditional characters, but contributors
still translated it by hand from `--missing zh-Hant`, and a model asked
for Traditional Chinese writes Taiwan vocabulary (影片, 伺服器, 載入).

`npm run i18n -- --zh-hant` now rewrites zh-Hant.json from zh-CN.json
with OpenCC s2tw (opencc-js 1.4.2, cn -> tw, characters only), then the
fixed list of characters it picks wrongly. The check that `npm test`
runs fails when zh-Hant differs from that conversion, naming the key,
and `--missing zh-Hant` points to the command instead of printing a
template.

The conversion reproduces every one of the 3346 entries on main. 25
keys were out of zh-CN order and move; no value changes.

The converter loads on first use, so a copy of the script without
node_modules (stale-index.test.mjs) still extracts messages.
2026-11-13 18:46:27 +04:00
41 changed files with 263 additions and 1701 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).
+4
View File
@@ -0,0 +1,4 @@
- Generate the Traditional Chinese catalog instead of translating it:
`npm run i18n -- --zh-hant` writes `zh-Hant` from `zh-CN` with OpenCC `s2tw`
and its character corrections, and `npm test` fails, naming the key, when
`zh-Hant` is edited by hand (German, @gekh, #1699).
+3 -65
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 detection is the backstop for when no probe context can be created. Applied
during viewer construction, before any tile builds a draw command. during viewer construction, before any tile builds a draw command.
## Render failure notice
When the globe stops drawing, the scene chrome says so instead of leaving a
black globe behind controls that still respond. `installRenderFailureNotice()`
(`src/ui/renderFailure.js`) listens for Cesium's `renderError`, after which
Cesium stops its render loop, and for the canvas' `webglcontextlost`. It logs
the error and publishes a notice to the status store, which
`RenderFailure.vue` renders as an alert dialog that takes focus. When the
globe draws again, focus goes back to the element that had it, or, if that
element is gone or cannot take focus, to the globe's canvas, which the map's
keys (Enter to finish a drawing, Space to talk) read as the map. The canvas
gets `tabindex="-1"` only until focus moves on or the globe is pressed, and a
press leaves focus on the page, as Cesium has it. Focus the user moved
meanwhile stays put. The
application scene creates its viewer with `showRenderLoopErrors: false`, so
Cesium's own "Rendering has stopped" panel, which offers no way back, no longer
appears; `applicationViewerOptions()` and `createApplicationViewer()` keep
Cesium's panel by default for other callers. An error thrown outside Scene's
own error handling (a `preRender` or `postRender` listener, the clock's
`onTick`, a resize) also stops the loop without a `renderError`, so the notice
wraps the widget's `render()` and `resize()` and raises such an error as a
render error before Cesium's loop handles it as before; one Scene has raised
already and throws on (`rethrowRenderErrors`) is not raised again. A lost
context fails every later frame, and those errors are only logged, so the
notice keeps saying why. The notice sits above floating panels such as the
Québec camera viewer, and its buttons are 44 px tall on touch screens.
- **Retry** after a render error restarts the render loop. The notice stays
until a frame starts and ends without a render error (Cesium raises
`postRender` after a failed frame too), and a loop that fails again keeps it.
A listener that throws leaves its Cesium event mid-raise, with listeners
added or removed since only queued, so the `render()` and `resize()` wrapper
finishes the events it cut short (`finishRaise()`) before it raises the
render error. These are the events Cesium raises outside Scene's error
handling (`frameEvents()`): Scene's frame events, the camera's and the
clock's, the globe's tile load progress, and the load events of the scene's
top-level tilesets. So the notice's frame watchers start with the next
frame, a listener removed while the loop is stopped, for a layer turned off,
is not called again, and one turned off and on again keeps running.
- **Reload** after a lost context reloads the page: Cesium cannot draw into a
context it has lost, and the address keeps the view.
- **Reset view** opens the app's root address (`appRootUrl()`), without the
hash or `/share/<code>` path the view was restored from; the query, such as
`?lang=`, is kept.
An inline conversation panel never reloads or navigates its host's page, so it
offers Retry after a render error only, and nothing after a lost context, where
the panel runtime's own notice applies. The panel's timer
(`keepPanelRendering()`) draws only while Cesium's render loop is on, so it
stops after a render error and draws again after Retry. An error a frame of
the timer throws stops there: it has been raised as a render error, and
thrown on it would only reach the host page.
A render error marks Cesium's loop stopped, but a frame drawn while the loop
ran leaves its next animation frame queued: an error Scene catches itself
returns to the loop, which queues the frame, and a frame the panel's timer
draws leaves it queued. The `render()` and `resize()` wrapper keeps the loop
marked running after such a call, so a Retry before that frame, or a tab shown
again (`src/app/tools.js`), does not start a second loop that would draw every
frame twice. The queued frame stops the loop itself if nothing restarted it.
The `webglcontextlost` listener does not `preventDefault()` the context loss or
restore the context; it only reports it.
## Map Source control ownership ## Map Source control ownership
Map Source controls own chip listeners, source-state subscriptions and selection Map Source controls own chip listeners, source-state subscriptions and selection
@@ -5160,7 +5096,9 @@ Shipped locales: **en** (source and fallback), **ar**, **de**, **es**, **fr**, *
- **Gate.** `npm run i18n` (and `src/i18n/catalog.test.mjs` under `npm test`) - **Gate.** `npm run i18n` (and `src/i18n/catalog.test.mjs` under `npm test`)
extracts every message and fails on missing, empty, unused, extracts every message and fails on missing, empty, unused,
placeholder-mismatched, still-English (plural forms included) or placeholder-mismatched, still-English (plural forms included) or
wrongly-shaped plural entries. wrongly-shaped plural entries. `zh-Hant` is generated from `zh-CN`
(`npm run i18n -- --zh-hant`: OpenCC `s2tw` through `opencc-js`, then a
fixed list of character corrections), and the gate fails when it differs.
- **Not translated by design:** layer ids, share-link and hash parameters, API - **Not translated by design:** layer ids, share-link and hash parameters, API
fields, voice tool schemas, provider names, feed data (callsigns, vessel and fields, voice tool schemas, provider names, feed data (callsigns, vessel and
station names) and attribution. station names) and attribution.
+2
View File
@@ -104,6 +104,8 @@ npm run i18n -- --prune # drop entries whose English no longer exists
Changing the English changes the key: the old translation goes unused and the new text shows as missing. Add the translation to every `src/i18n/locales/<code>.json` (or ask a translator), prune, and run `npm test`; it fails until every language is complete. Changing the English changes the key: the old translation goes unused and the new text shows as missing. Add the translation to every `src/i18n/locales/<code>.json` (or ask a translator), prune, and run `npm test`; it fails until every language is complete.
Do not translate `zh-Hant`: it is generated. After `zh-CN`, run `npm run i18n -- --zh-hant` (see [zh-Hant.md](./translations/zh-Hant.md)).
To see a language, open the app with `?lang=ru`, or use the language picker (the globe button in the bottom-right corner). To see a language, open the app with `?lang=ru`, or use the language picker (the globe button in the bottom-right corner).
An empty translation is reported, and so is one left as the English, plural forms included. When English is right on purpose (a code such as CRT, a product name), list it under the language in `src/i18n/same-as-english.json`. An empty translation is reported, and so is one left as the English, plural forms included. When English is right on purpose (a code such as CRT, a product name), list it under the language in `src/i18n/same-as-english.json`.
+2
View File
@@ -36,6 +36,8 @@ npm run i18n -- --prune # drop entries the code no longer uses
Changing English text changes its key: the old translation becomes unused and the new text shows as missing. Translate it and prune. Changing English text changes its key: the old translation becomes unused and the new text shows as missing. Translate it and prune.
Do not translate `zh-Hant`: it is generated. Translate `zh-CN`, then run `npm run i18n -- --zh-hant` (see [zh-Hant.md](./translations/zh-Hant.md)).
## Fixing a translation ## Fixing a translation
Edit `src/i18n/locales/<code>.json` and run `npm run i18n`. To see your change, open the app with `?lang=<code>`. Edit `src/i18n/locales/<code>.json` and run `npm run i18n`. To see your change, open the app with `?lang=<code>`.
+12 -4
View File
@@ -2,16 +2,24 @@
Catalog: `src/i18n/locales/zh-Hant.json`. General rules: [TRANSLATORS.md](../TRANSLATORS.md). Catalog: `src/i18n/locales/zh-Hant.json`. General rules: [TRANSLATORS.md](../TRANSLATORS.md).
`zh-Hant` is the Simplified Chinese catalog (`zh-CN`, credited to abiyeenzo, #914, and EDIT-Aeterna, #979) written in Traditional characters with OpenCC character conversion. Wording and terminology are the same as in `zh-CN`: change a term in [zh-CN.md](./zh-CN.md) and the `zh-CN` catalog first, then convert. Only the script differs. **Do not translate or edit this catalog: it is generated.** `zh-Hant` is the Simplified Chinese catalog (`zh-CN`, credited to abiyeenzo, #914, and EDIT-Aeterna, #979) written in Traditional characters. Wording and terminology are the same as in `zh-CN`; only the script differs. To add or change an entry, edit [zh-CN.md](./zh-CN.md) and the `zh-CN` catalog, then run:
After `s2tw` conversion, fix the characters OpenCC picks wrongly: 年曆史 → 年歷史, 下遊 → 下游, 並道 → 併道, 發佈 → 發布, 公佈 → 公布, 控制檯 → 控制台, and 周 → 週 where it means a week (1 週/秒). ```sh
npm run i18n -- --zh-hant # rewrite zh-Hant.json from zh-CN.json
```
`npm test` fails when `zh-Hant.json` differs from the conversion of `zh-CN.json`, naming the entry, and `npm run i18n -- --missing zh-Hant` points to this command instead of printing a template.
The command converts with OpenCC `s2tw` ([opencc-js](https://github.com/nk2028/opencc-js), `cn` → `tw`), which changes characters only. Never `s2twp`: it also swaps in Taiwan vocabulary (影片, 伺服器, 載入, 圖示), which this catalog does not use. After the conversion it fixes the characters OpenCC picks wrongly here: 年曆史 → 年歷史, 下遊 → 下游, 並道 → 併道, 發佈 → 發布, 公佈 → 公布, 控制檯 → 控制台, and 周 → 週 where it means a week (1 週/秒). The list is `ZH_HANT_CORRECTIONS` in `scripts/i18n.mjs`; when the conversion picks another wrong character, add the pair there and run the command.
An entry `zh-CN` keeps in English on purpose needs the same line under `zh-Hant` in `src/i18n/same-as-english.json`.
## Style ## Style
- Follow [zh-CN.md](./zh-CN.md) for wording, punctuation, spacing, units and the glossary, written in Traditional characters. - Wording, punctuation, spacing, units and the glossary are those of [zh-CN.md](./zh-CN.md), written in Traditional characters.
- Relative-time placeholders may already include 前. In `LIVE · updated {age}`, the caller supplies the complete relative age; do not append another 前. - Relative-time placeholders may already include 前. In `LIVE · updated {age}`, the caller supplies the complete relative age; do not append another 前.
- The picker displays 中文(繁體) / Chinese (Traditional); explicit Hans tags select Simplified Chinese. - The picker displays 中文(繁體) / Chinese (Traditional); explicit Hans tags select Simplified Chinese.
## Plurals ## Plurals
`Intl.PluralRules('zh-Hant')` uses only `other`. Keep the measure words of the `zh-CN` entry: 「{count} 架飛機」、「{count} 艘船」. `Intl.PluralRules('zh-Hant')` uses only `other`. The measure words come from the `zh-CN` entry: 「{count} 架飛機」、「{count} 艘船」.
+8
View File
@@ -32,6 +32,7 @@
"@vue/test-utils": "^2.5.1", "@vue/test-utils": "^2.5.1",
"flag-icons": "7.5.0", "flag-icons": "7.5.0",
"happy-dom": "^20.14.6", "happy-dom": "^20.14.6",
"opencc-js": "1.4.2",
"prettier": "3.9.6", "prettier": "3.9.6",
"puppeteer": "^25.16.0", "puppeteer": "^25.16.0",
"sharp": "^0.35.4", "sharp": "^0.35.4",
@@ -2976,6 +2977,13 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/opencc-js": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/opencc-js/-/opencc-js-1.4.2.tgz",
"integrity": "sha512-EdcspgdROiCCZDM+jZR6Pa670xW+EW2QhctPBQ8gRjg6lrx4IHEl+2s+kt5izk/TkxVRgdWuFgaGkaEdIUfdaw==",
"dev": true,
"license": "MIT AND Apache-2.0"
},
"node_modules/pako": { "node_modules/pako": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz", "resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz",
+1
View File
@@ -85,6 +85,7 @@
"@vue/test-utils": "^2.5.1", "@vue/test-utils": "^2.5.1",
"flag-icons": "7.5.0", "flag-icons": "7.5.0",
"happy-dom": "^20.14.6", "happy-dom": "^20.14.6",
"opencc-js": "1.4.2",
"prettier": "3.9.6", "prettier": "3.9.6",
"puppeteer": "^25.16.0", "puppeteer": "^25.16.0",
"sharp": "^0.35.4", "sharp": "^0.35.4",
-6
View File
@@ -557,12 +557,6 @@
"src/ui/frameRateMonitor.js", "src/ui/frameRateMonitor.js",
"src/ui/frameRateMonitor.test.mjs", "src/ui/frameRateMonitor.test.mjs",
"src/ui/frameRateView.test.mjs", "src/ui/frameRateView.test.mjs",
"src/ui/renderFailure.test.mjs",
"src/ui/renderFailureView.test.mjs",
"src/ui/renderFailureLoopErrors.test.mjs",
"src/ui/renderFailureEventRetry.test.mjs",
"src/app/renderFailureInlineRetry.test.mjs",
"src/ui/renderFailureSecondLoop.test.mjs",
"src/layers/cctv/lifecycle.js", "src/layers/cctv/lifecycle.js",
"src/layers/cctv/controls.js", "src/layers/cctv/controls.js",
"src/layers/cctv/geometry.js", "src/layers/cctv/geometry.js",
+121 -8
View File
@@ -3,17 +3,21 @@
// npm run i18n check every catalog and find unmarked UI text // npm run i18n check every catalog and find unmarked UI text
// npm run i18n -- --prune also drop entries the code no longer uses // npm run i18n -- --prune also drop entries the code no longer uses
// npm run i18n -- --missing ru print what ru lacks, as JSON to translate // npm run i18n -- --missing ru print what ru lacks, as JSON to translate
// npm run i18n -- --zh-hant rewrite zh-Hant from zh-CN (see toZhHant)
// //
// A translation left as the English fails unless src/i18n/same-as-english.json // A translation left as the English fails unless src/i18n/same-as-english.json
// lists it. // lists it. zh-Hant is not translated: it is generated from zh-CN, and the
// check fails when it differs.
// //
// Messages are the English literals passed to t(), tn() and msg() in src/, // Messages are the English literals passed to t(), tn() and msg() in src/,
// plus the markup opted in with data-i18n* in index.html and the Vue views. // plus the markup opted in with data-i18n* in index.html and the Vue views.
// It also lists UI text that is not marked at all (see findUnmarked). // It also lists UI text that is not marked at all (see findUnmarked).
import { existsSync, readFileSync, writeFileSync } from 'node:fs'; import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import { execFileSync } from 'node:child_process'; import { execFileSync } from 'node:child_process';
import { createRequire } from 'node:module';
import path from 'node:path'; import path from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { isDeepStrictEqual } from 'node:util';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const LOCALES_DIR = path.join(ROOT, 'src/i18n/locales'); const LOCALES_DIR = path.join(ROOT, 'src/i18n/locales');
@@ -618,16 +622,123 @@ export function missingTemplate(locale, catalog, messages) {
return out; return out;
} }
// zh-Hant is zh-CN in Traditional characters (docs/translations/zh-Hant.md):
// the same wording, only the script differs. OpenCC s2tw (opencc-js, cn → tw)
// converts characters only; s2twp would also swap in Taiwan vocabulary
// (影片, 伺服器, 載入), which the catalog does not use.
const ZH_HANT = 'zh-Hant';
const ZH_HANT_SOURCE = 'zh-CN';
const ZH_HANT_COMMAND = 'npm run i18n -- --zh-hant';
// Characters s2tw picks wrongly in this text, replaced after the conversion.
const ZH_HANT_CORRECTIONS = [
['年曆史', '年歷史'],
['下遊', '下游'],
['並道', '併道'],
['發佈', '發布'],
['公佈', '公布'],
['控制檯', '控制台'],
// 周 as a week: the time-lapse speed in weeks per second.
[' 周/秒', ' 週/秒'],
];
let s2tw;
/** zh-CN text in Traditional characters, as zh-Hant shows it. */
export function toZhHant(text) {
// Loaded on first use: extracting messages needs no converter, and a copy
// of this script without node_modules (stale-index.test.mjs) still runs.
s2tw ??= createRequire(import.meta.url)('opencc-js/cn2t').Converter({
from: 'cn',
to: 'tw',
});
return ZH_HANT_CORRECTIONS.reduce(
(out, [wrong, right]) => out.replaceAll(wrong, right),
s2tw(text),
);
}
/** The zh-Hant catalog for a zh-CN catalog: the same keys in the same order. */
export function zhHantCatalog(zhCN) {
const convert = (entry) => {
if (typeof entry === 'string') return toZhHant(entry);
if (entry && typeof entry === 'object')
return Object.fromEntries(
Object.entries(entry).map(([category, form]) => [
category,
convert(form),
]),
);
return entry;
};
return Object.fromEntries(
Object.entries(zhCN).map(([text, entry]) => [text, convert(entry)]),
);
}
/**
* Where a zh-Hant catalog is not the conversion of the zh-CN one.
* @returns {string[]} One error per entry, naming its key.
*/
export function checkZhHant(zhCN, zhHant) {
const expected = zhHantCatalog(zhCN);
const fix = `zh-Hant is generated: edit ${ZH_HANT_SOURCE}, then run ${ZH_HANT_COMMAND}`;
const errors = [];
for (const [text, entry] of Object.entries(expected)) {
if (!Object.hasOwn(zhHant, text))
errors.push(`"${text}" is in ${ZH_HANT_SOURCE} but not here. ${fix}`);
else if (!isDeepStrictEqual(zhHant[text], entry))
errors.push(
`"${text}" is ${JSON.stringify(zhHant[text])}, not ${ZH_HANT_SOURCE} converted (${JSON.stringify(entry)}). ${fix}`,
);
}
for (const text of Object.keys(zhHant)) {
if (!Object.hasOwn(expected, text))
errors.push(`"${text}" is not in ${ZH_HANT_SOURCE}. ${fix}`);
}
if (!errors.length) {
const order = Object.keys(expected);
const keys = Object.keys(zhHant);
const at = order.findIndex((text, i) => keys[i] !== text);
if (at >= 0)
errors.push(
`entries leave the ${ZH_HANT_SOURCE} order at "${order[at]}": run ${ZH_HANT_COMMAND}`,
);
}
return errors;
}
const serialize = (catalog) => `${JSON.stringify(catalog, null, 2)}\n`;
/** Rewrite zh-Hant.json as the conversion of zh-CN.json. */
export function writeZhHant() {
const catalog = zhHantCatalog(readCatalog(ZH_HANT_SOURCE));
writeFileSync(catalogPath(ZH_HANT), serialize(catalog));
return Object.keys(catalog).length;
}
function main(args) { function main(args) {
const messages = extractMessages(); const messages = extractMessages();
const missingFor = args.includes('--missing') const missingFor = args.includes('--missing')
? args[args.indexOf('--missing') + 1] ? args[args.indexOf('--missing') + 1]
: null; : null;
if (missingFor?.toLowerCase() === ZH_HANT.toLowerCase()) {
console.error(
`${ZH_HANT} is generated, not translated: add the entries to ${ZH_HANT_SOURCE} (npm run i18n -- --missing ${ZH_HANT_SOURCE}), then run ${ZH_HANT_COMMAND}`,
);
return 1;
}
if (missingFor) { if (missingFor) {
const out = missingTemplate(missingFor, readCatalog(missingFor), messages); const out = missingTemplate(missingFor, readCatalog(missingFor), messages);
console.log(JSON.stringify(out, null, 2)); console.log(JSON.stringify(out, null, 2));
return 0; return 0;
} }
if (args.includes('--zh-hant')) {
const count = writeZhHant();
console.log(
`${ZH_HANT}: wrote ${count} entries converted from ${ZH_HANT_SOURCE}`,
);
}
let failed = false; let failed = false;
for (const locale of shippedLocales()) { for (const locale of shippedLocales()) {
let catalog = readCatalog(locale); let catalog = readCatalog(locale);
@@ -636,10 +747,7 @@ function main(args) {
catalog = Object.fromEntries( catalog = Object.fromEntries(
Object.entries(catalog).filter(([text]) => messages.has(text)), Object.entries(catalog).filter(([text]) => messages.has(text)),
); );
writeFileSync( writeFileSync(catalogPath(locale), serialize(catalog));
catalogPath(locale),
`${JSON.stringify(catalog, null, 2)}\n`,
);
console.log(`${locale}: pruned ${stale.length} unused entries`); console.log(`${locale}: pruned ${stale.length} unused entries`);
} else if (stale.length) { } else if (stale.length) {
failed = true; failed = true;
@@ -648,11 +756,16 @@ function main(args) {
); );
for (const text of stale) console.log(` ${JSON.stringify(text)}`); for (const text of stale) console.log(` ${JSON.stringify(text)}`);
} }
// shippedLocales() is sorted, so --prune has already pruned zh-CN.
if (locale === ZH_HANT)
errors.push(...checkZhHant(readCatalog(ZH_HANT_SOURCE), catalog));
if (missing.length) { if (missing.length) {
failed = true; failed = true;
console.log( const fill =
`${locale}: ${missing.length} missing (npm run i18n -- --missing ${locale}):`, locale === ZH_HANT
); ? `add them to ${ZH_HANT_SOURCE}, then ${ZH_HANT_COMMAND}`
: `npm run i18n -- --missing ${locale}`;
console.log(`${locale}: ${missing.length} missing (${fill}):`);
for (const text of missing) for (const text of missing)
console.log(` ${JSON.stringify(text)} ${messages.get(text).where}`); console.log(` ${JSON.stringify(text)} ${messages.get(text).where}`);
} }
-2
View File
@@ -163,7 +163,6 @@
"src/ui/kit/UiBadge.vue", "src/ui/kit/UiBadge.vue",
"src/ui/stores/globeActions.js", "src/ui/stores/globeActions.js",
"src/ui/views/panels/GlobeActions.vue", "src/ui/views/panels/GlobeActions.vue",
"src/ui/views/panels/RenderFailure.vue",
"src/ui/views/panels/GlobeCompass.vue", "src/ui/views/panels/GlobeCompass.vue",
"src/ui/stores/recording.js", "src/ui/stores/recording.js",
"src/ui/styleParameters.js", "src/ui/styleParameters.js",
@@ -3114,7 +3113,6 @@
"src/ui/displayControls.js", "src/ui/displayControls.js",
"src/ui/effects.js", "src/ui/effects.js",
"src/ui/frameRateMonitor.js", "src/ui/frameRateMonitor.js",
"src/ui/renderFailure.js",
"src/ui/layerPanel.js", "src/ui/layerPanel.js",
"src/ui/weatherPanel.js", "src/ui/weatherPanel.js",
"src/ui/railCards.js", "src/ui/railCards.js",
@@ -400,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 () => { test('the live preview hangs at the height the click landed on', async () => {
const h = harness(); const h = harness();
try { try {
+11 -13
View File
@@ -122,30 +122,28 @@ export function keepPanelRendering(
frameRequest = windowRef.requestAnimationFrame(onFrame); frameRequest = windowRef.requestAnimationFrame(onFrame);
}; };
frameRequest = windowRef.requestAnimationFrame(onFrame); frameRequest = windowRef.requestAnimationFrame(onFrame);
// Cesium stops its render loop after a render error, and the timer stops // Cesium stops drawing after a render error, and so does the timer.
// with it; Retry restarts both. Report the error in full: workers report // Report the error in full: workers report plain objects, which Cesium
// plain objects, which Cesium prints as [object Object]. // prints as [object Object].
let failed = false;
const removeErrorListener = viewer.scene?.renderError?.addEventListener( const removeErrorListener = viewer.scene?.renderError?.addEventListener(
(_scene, error) => (_scene, error) => {
failed = true;
console.error( console.error(
"[God's Eye View panel] render error:", "[God's Eye View panel] render error:",
describeError(error), describeError(error),
), );
},
); );
const timer = windowRef.setInterval(() => { const timer = windowRef.setInterval(() => {
if ( if (
viewer.useDefaultRenderLoop === false || failed ||
now() - lastFrame < MISSED_FRAMES_MS || now() - lastFrame < MISSED_FRAMES_MS ||
viewer.isDestroyed?.() viewer.isDestroyed?.()
) )
return; return;
try { viewer.resize();
viewer.resize(); viewer.render();
viewer.render();
} catch {
// Raised as a render error by now (src/ui/renderFailure.js), which
// stops this timer; thrown on, it would only reach the host page.
}
}, PANEL_FRAME_MS); }, PANEL_FRAME_MS);
return () => { return () => {
windowRef.clearInterval(timer); windowRef.clearInterval(timer);
-2
View File
@@ -482,8 +482,6 @@ test('a panel reports a render error in full and stops drawing', () => {
try { try {
let clock = 0; let clock = 0;
const stop = keepPanelRendering(viewer, { windowRef, now: () => clock }); const stop = keepPanelRendering(viewer, { windowRef, now: () => clock });
// Cesium stops its render loop after a render error.
viewer.useDefaultRenderLoop = false;
listener(viewer.scene, { name: 'RuntimeError', message: 'worker failed' }); listener(viewer.scene, { name: 'RuntimeError', message: 'worker failed' });
assert.match(logged[0], /render error: .*"message":"worker failed"/); assert.match(logged[0], /render error: .*"message":"worker failed"/);
clock = 1000; clock = 1000;
-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, governorRequestRender,
} from '../renderGovernor.js'; } from '../renderGovernor.js';
import { describeError } from './errors.js'; import { describeError } from './errors.js';
import { isEmbeddedInline } from './embed.js';
import { installRenderFailureNotice } from '../ui/renderFailure.js';
import { worldClock, worldTimeFor } from '../time/worldClock.js'; import { worldClock, worldTimeFor } from '../time/worldClock.js';
import { followWorldTime } from '../time/viewerClock.js'; import { followWorldTime } from '../time/viewerClock.js';
import { onLocaleChange, t } from '../i18n/index.js'; import { onLocaleChange, t } from '../i18n/index.js';
@@ -57,17 +55,11 @@ export async function createApplicationScene({
const viewer = createApplicationViewer({ const viewer = createApplicationViewer({
container: 'cesiumContainer', container: 'cesiumContainer',
creditContainer, creditContainer,
// The render failure notice below replaces Cesium's dead-end panel.
showRenderLoopErrors: false,
}); });
defer(() => { defer(() => {
uninstallRenderGovernor(viewer); uninstallRenderGovernor(viewer);
if (!viewer.isDestroyed()) viewer.destroy(); if (!viewer.isDestroyed()) viewer.destroy();
}); });
// An inline panel must not reload or navigate its host's page.
defer(
installRenderFailureNotice(viewer, { allowReload: !isEmbeddedInline() }),
);
defer(installTrackpadPinchZoom(viewer)); defer(installTrackpadPinchZoom(viewer));
defer( defer(
followWorldTime(viewer, worldTimeFor('sky'), { followWorldTime(viewer, worldTimeFor('sky'), {
+4 -16
View File
@@ -375,15 +375,8 @@ export function installGlobeInViewRecovery(
}; };
} }
/** /** Cesium.Viewer options of the standard globe viewer. */
* Cesium.Viewer options of the standard globe viewer. `showRenderLoopErrors` export function applicationViewerOptions(creditContainer) {
* false leaves render errors to the caller's own notice instead of Cesium's
* panel.
*/
export function applicationViewerOptions(
creditContainer,
{ showRenderLoopErrors = true } = {},
) {
return { return {
timeline: false, timeline: false,
animation: false, animation: false,
@@ -403,21 +396,16 @@ export function applicationViewerOptions(
creditContainer, creditContainer,
msaaSamples: 4, msaaSamples: 4,
contextOptions: { webgl: { preserveDrawingBuffer: true } }, contextOptions: { webgl: { preserveDrawingBuffer: true } },
showRenderLoopErrors,
}; };
} }
/** Create the standard globe viewer in caller-owned, visible containers. */ /** Create the standard globe viewer in caller-owned, visible containers. */
export function createApplicationViewer({ export function createApplicationViewer({ container, creditContainer }) {
container,
creditContainer,
showRenderLoopErrors,
}) {
if (!container || !creditContainer) if (!container || !creditContainer)
throw new TypeError('Viewer and credit containers are required'); throw new TypeError('Viewer and credit containers are required');
const viewer = new Cesium.Viewer( const viewer = new Cesium.Viewer(
container, container,
applicationViewerOptions(creditContainer, { showRenderLoopErrors }), applicationViewerOptions(creditContainer),
); );
try { try {
viewer.targetFrameRate = 60; viewer.targetFrameRate = 60;
+56
View File
@@ -3,13 +3,16 @@ import assert from 'node:assert/strict';
import { import {
checkCatalog, checkCatalog,
checkZhHant,
extractMessages, extractMessages,
findUnmarked, findUnmarked,
markedTemplate, markedTemplate,
readCatalog, readCatalog,
shippedLocales, shippedLocales,
toZhHant,
unmarkedMarkup, unmarkedMarkup,
unmarkedScript, unmarkedScript,
zhHantCatalog,
} from '../../scripts/i18n.mjs'; } from '../../scripts/i18n.mjs';
import { LOCALES } from './locale.js'; import { LOCALES } from './locale.js';
@@ -36,6 +39,59 @@ for (const locale of shippedLocales()) {
}); });
} }
test('zh-Hant is zh-CN converted to Traditional characters (npm run i18n -- --zh-hant)', () => {
assert.deepEqual(
checkZhHant(readCatalog('zh-CN'), readCatalog('zh-Hant')),
[],
);
});
test('toZhHant converts characters, not vocabulary, and fixes what s2tw picks wrongly', () => {
// s2twp would write 影片, 伺服器 and 載入; zh-Hant keeps the zh-CN words.
assert.equal(toZhHant('视频'), '視頻');
assert.equal(toZhHant('服务器'), '服務器');
assert.equal(toZhHant('加载'), '加載');
assert.equal(toZhHant('50 年历史'), '50 年歷史');
assert.equal(toZhHant('发布于 {date}'), '發布於 {date}');
assert.equal(toZhHant('1 周/秒'), '1 週/秒');
});
test('checkZhHant names each entry that is not the conversion of zh-CN', () => {
const zhCN = {
Video: '视频',
Server: '服务器',
'{count} ships': { other: '{count} 艘船' },
};
const generated = zhHantCatalog(zhCN);
assert.deepEqual(generated, {
Video: '視頻',
Server: '服務器',
'{count} ships': { other: '{count} 艘船' },
});
assert.deepEqual(checkZhHant(zhCN, generated), []);
const fix =
'zh-Hant is generated: edit zh-CN, then run npm run i18n -- --zh-hant';
const { Server, ...withoutServer } = generated;
assert.deepEqual(
checkZhHant(zhCN, {
...withoutServer,
Video: '影片',
'{count} ships': { other: '{count} 艘船隻' },
Extra: '多餘',
}),
[
`"Video" is "影片", not zh-CN converted ("視頻"). ${fix}`,
`"Server" is in zh-CN but not here. ${fix}`,
`"{count} ships" is {"other":"{count} 艘船隻"}, not zh-CN converted ({"other":"{count} 艘船"}). ${fix}`,
`"Extra" is not in zh-CN. ${fix}`,
],
);
assert.deepEqual(checkZhHant(zhCN, { Server, ...withoutServer }), [
'entries leave the zh-CN order at "Video": run npm run i18n -- --zh-hant',
]);
});
test('every UI string is marked for translation or kept on purpose (npm run i18n)', () => { test('every UI string is marked for translation or kept on purpose (npm run i18n)', () => {
assert.deepEqual( assert.deepEqual(
findUnmarked().map(({ where, text }) => `${where} ${text}`), findUnmarked().map(({ where, text }) => `${where} ${text}`),
+1 -8
View File
@@ -3953,12 +3953,5 @@
"Confirm: remove every added entry from Data Layers": "تأكيد: إزالة كل الإدخالات المُضافة من طبقات البيانات", "Confirm: remove every added entry from Data Layers": "تأكيد: إزالة كل الإدخالات المُضافة من طبقات البيانات",
"Remove every added entry from Data Layers": "إزالة كل الإدخالات المُضافة من طبقات البيانات", "Remove every added entry from Data Layers": "إزالة كل الإدخالات المُضافة من طبقات البيانات",
"Showing {shown} of {total}": "عرض {shown} من {total}", "Showing {shown} of {total}": "عرض {shown} من {total}",
"BROWSE DATA LIBRARY": "تصفّح مكتبة البيانات", "BROWSE DATA LIBRARY": "تصفّح مكتبة البيانات"
"The globe stopped drawing": "توقفت الكرة الأرضية عن الرسم",
"The browser took back the globe's 3D graphics. Layers and controls still work, but the map cannot draw until the page reloads.": "استعاد المتصفح رسومات الكرة الأرضية ثلاثية الأبعاد. لا تزال الطبقات وعناصر التحكم تعمل، لكن الخريطة لن تُرسم حتى تُعاد تحميل الصفحة.",
"Rendering stopped after an error. Layers and controls still work, but the map is not updating.": "توقف العرض بعد خطأ. لا تزال الطبقات وعناصر التحكم تعمل، لكن الخريطة لا يتم تحديثها.",
"Reset view reloads the app without the view restored from the address bar.": "تعيد «إعادة ضبط العرض» تحميل التطبيق دون العرض المستعاد من شريط العنوان.",
"Reload": "إعادة التحميل",
"Retry": "إعادة المحاولة",
"Reset view": "إعادة ضبط العرض"
} }
+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", "Confirm: remove every added entry from Data Layers": "Bestätigen: alle hinzugefügten Einträge aus Datenebenen entfernen",
"Remove every added entry from Data Layers": "Alle hinzugefügten Einträge aus Datenebenen entfernen", "Remove every added entry from Data Layers": "Alle hinzugefügten Einträge aus Datenebenen entfernen",
"Showing {shown} of {total}": "{shown} von {total} angezeigt", "Showing {shown} of {total}": "{shown} von {total} angezeigt",
"BROWSE DATA LIBRARY": "DATENBIBLIOTHEK DURCHSUCHEN", "BROWSE DATA LIBRARY": "DATENBIBLIOTHEK DURCHSUCHEN"
"The globe stopped drawing": "Der Globus zeichnet nicht mehr",
"The browser took back the globe's 3D graphics. Layers and controls still work, but the map cannot draw until the page reloads.": "Der Browser hat dem Globus die 3D-Grafik entzogen. Ebenen und Bedienelemente funktionieren weiter, aber die Karte kann erst nach dem Neuladen der Seite wieder zeichnen.",
"Rendering stopped after an error. Layers and controls still work, but the map is not updating.": "Das Rendern wurde nach einem Fehler angehalten. Ebenen und Bedienelemente funktionieren weiter, aber die Karte wird nicht aktualisiert.",
"Reset view reloads the app without the view restored from the address bar.": "Ansicht zurücksetzen lädt die App ohne die aus der Adressleiste wiederhergestellte Ansicht neu.",
"Reload": "Neu laden",
"Retry": "Erneut versuchen",
"Reset view": "Ansicht zurücksetzen"
} }
+1 -8
View File
@@ -3692,12 +3692,5 @@
"500–1,000 m": "500–1000 m", "500–1,000 m": "500–1000 m",
"1,000–2,000 m": "1000–2000 m", "1,000–2,000 m": "1000–2000 m",
"2,000–3,000 m": "2000–3000 m", "2,000–3,000 m": "2000–3000 m",
"3,000 m and above": "3000 m o más", "3,000 m and above": "3000 m o más"
"The globe stopped drawing": "El globo dejó de dibujarse",
"The browser took back the globe's 3D graphics. Layers and controls still work, but the map cannot draw until the page reloads.": "El navegador retiró los gráficos 3D del globo. Las capas y los controles siguen funcionando, pero el mapa no se dibujará hasta que se recargue la página.",
"Rendering stopped after an error. Layers and controls still work, but the map is not updating.": "El renderizado se detuvo tras un error. Las capas y los controles siguen funcionando, pero el mapa no se actualiza.",
"Reset view reloads the app without the view restored from the address bar.": "Restablecer la vista recarga la app sin la vista restaurada desde la barra de direcciones.",
"Reload": "Recargar",
"Retry": "Reintentar",
"Reset view": "Restablecer la vista"
} }
+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", "Confirm: remove every added entry from Data Layers": "Confirmer : retirer du panneau Couches toutes les entrées ajoutées",
"Remove every added entry from Data Layers": "Retirer du panneau Couches toutes les entrées ajoutées", "Remove every added entry from Data Layers": "Retirer du panneau Couches toutes les entrées ajoutées",
"Showing {shown} of {total}": "Affichées : {shown} sur {total}", "Showing {shown} of {total}": "Affichées : {shown} sur {total}",
"BROWSE DATA LIBRARY": "PARCOURIR LA BIBLIOTHÈQUE DE DONNÉES", "BROWSE DATA LIBRARY": "PARCOURIR LA BIBLIOTHÈQUE DE DONNÉES"
"The globe stopped drawing": "Le globe ne s’affiche plus",
"The browser took back the globe's 3D graphics. Layers and controls still work, but the map cannot draw until the page reloads.": "Le navigateur a retiré au globe ses graphismes 3D. Les calques et les commandes fonctionnent toujours, mais la carte ne pourra s’afficher qu’après le rechargement de la page.",
"Rendering stopped after an error. Layers and controls still work, but the map is not updating.": "Le rendu s’est arrêté après une erreur. Les calques et les commandes fonctionnent toujours, mais la carte ne se met plus à jour.",
"Reset view reloads the app without the view restored from the address bar.": "Réinitialiser la vue recharge l’app sans la vue restaurée depuis la barre d’adresse.",
"Reload": "Recharger",
"Retry": "Réessayer",
"Reset view": "Réinitialiser la vue"
} }
+1 -8
View File
@@ -3692,12 +3692,5 @@
"Confirm: remove every added entry from Data Layers": "אישור: הסרת כל הפריטים שנוספו משכבות נתונים", "Confirm: remove every added entry from Data Layers": "אישור: הסרת כל הפריטים שנוספו משכבות נתונים",
"Remove every added entry from Data Layers": "הסרת כל הפריטים שנוספו משכבות נתונים", "Remove every added entry from Data Layers": "הסרת כל הפריטים שנוספו משכבות נתונים",
"Showing {shown} of {total}": "מוצגים {shown} מתוך {total}", "Showing {shown} of {total}": "מוצגים {shown} מתוך {total}",
"BROWSE DATA LIBRARY": "עיון בספריית הנתונים", "BROWSE DATA LIBRARY": "עיון בספריית הנתונים"
"The globe stopped drawing": "הגלובוס הפסיק להצטייר",
"The browser took back the globe's 3D graphics. Layers and controls still work, but the map cannot draw until the page reloads.": "הדפדפן לקח בחזרה את הגרפיקה התלת-ממדית של הגלובוס. השכבות והפקדים עדיין פועלים, אבל המפה לא תוכל להצטייר עד שהעמוד ייטען מחדש.",
"Rendering stopped after an error. Layers and controls still work, but the map is not updating.": "הרינדור נעצר בעקבות שגיאה. השכבות והפקדים עדיין פועלים, אבל המפה אינה מתעדכנת.",
"Reset view reloads the app without the view restored from the address bar.": "\"איפוס תצוגה\" טוען מחדש את האפליקציה בלי התצוגה ששוחזרה משורת הכתובת.",
"Reload": "טען מחדש",
"Retry": "נסה שוב",
"Reset view": "איפוס תצוגה"
} }
+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", "Confirm: remove every added entry from Data Layers": "Conferma: rimuovi da Livelli dati tutte le voci aggiunte",
"Remove every added entry from Data Layers": "Rimuovi da Livelli dati tutte le voci aggiunte", "Remove every added entry from Data Layers": "Rimuovi da Livelli dati tutte le voci aggiunte",
"Showing {shown} of {total}": "Mostrati: {shown} su {total}", "Showing {shown} of {total}": "Mostrati: {shown} su {total}",
"BROWSE DATA LIBRARY": "ESPLORA LIBRERIA DATI", "BROWSE DATA LIBRARY": "ESPLORA LIBRERIA DATI"
"The globe stopped drawing": "Il globo ha smesso di disegnare",
"The browser took back the globe's 3D graphics. Layers and controls still work, but the map cannot draw until the page reloads.": "Il browser ha ritirato la grafica 3D del globo. Livelli e controlli funzionano ancora, ma la mappa non può essere disegnata finché la pagina non viene ricaricata.",
"Rendering stopped after an error. Layers and controls still work, but the map is not updating.": "Il rendering si è fermato dopo un errore. Livelli e controlli funzionano ancora, ma la mappa non si aggiorna.",
"Reset view reloads the app without the view restored from the address bar.": "Ripristina vista ricarica l’app senza la vista ripristinata dalla barra degli indirizzi.",
"Reload": "Ricarica",
"Retry": "Riprova",
"Reset view": "Ripristina vista"
} }
+1 -8
View File
@@ -3518,12 +3518,5 @@
"Confirm: remove every added entry from Data Layers": "確認:追加したすべての項目をデータレイヤーから削除", "Confirm: remove every added entry from Data Layers": "確認:追加したすべての項目をデータレイヤーから削除",
"Remove every added entry from Data Layers": "追加したすべての項目をデータレイヤーから削除", "Remove every added entry from Data Layers": "追加したすべての項目をデータレイヤーから削除",
"Showing {shown} of {total}": "{total}件中{shown}件を表示", "Showing {shown} of {total}": "{total}件中{shown}件を表示",
"BROWSE DATA LIBRARY": "データライブラリを見る", "BROWSE DATA LIBRARY": "データライブラリを見る"
"The globe stopped drawing": "地球儀の描画が止まりました",
"The browser took back the globe's 3D graphics. Layers and controls still work, but the map cannot draw until the page reloads.": "ブラウザーが地球儀の 3D グラフィックスを解放しました。レイヤーと操作は引き続き使えますが、ページを再読み込みするまで地図は描画されません。",
"Rendering stopped after an error. Layers and controls still work, but the map is not updating.": "エラーのため描画が停止しました。レイヤーと操作は引き続き使えますが、地図は更新されません。",
"Reset view reloads the app without the view restored from the address bar.": "「ビューをリセット」は、アドレスバーから復元したビューを使わずにアプリを再読み込みします。",
"Reload": "再読み込み",
"Retry": "再試行",
"Reset view": "ビューをリセット"
} }
+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", "Confirm: remove every added entry from Data Layers": "Confirmar: remover de \"Camadas de dados\" todas as entradas adicionadas",
"Remove every added entry from Data Layers": "Remover de \"Camadas de dados\" todas as entradas adicionadas", "Remove every added entry from Data Layers": "Remover de \"Camadas de dados\" todas as entradas adicionadas",
"Showing {shown} of {total}": "Mostrando {shown} de {total}", "Showing {shown} of {total}": "Mostrando {shown} de {total}",
"BROWSE DATA LIBRARY": "EXPLORAR BIBLIOTECA DE DADOS", "BROWSE DATA LIBRARY": "EXPLORAR BIBLIOTECA DE DADOS"
"The globe stopped drawing": "O globo parou de desenhar",
"The browser took back the globe's 3D graphics. Layers and controls still work, but the map cannot draw until the page reloads.": "O navegador retirou os gráficos 3D do globo. As camadas e os controles continuam funcionando, mas o mapa só volta a ser desenhado depois de recarregar a página.",
"Rendering stopped after an error. Layers and controls still work, but the map is not updating.": "A renderização parou após um erro. As camadas e os controles continuam funcionando, mas o mapa não está sendo atualizado.",
"Reset view reloads the app without the view restored from the address bar.": "Redefinir vista recarrega o app sem a vista restaurada da barra de endereços.",
"Reload": "Recarregar",
"Retry": "Tentar novamente",
"Reset view": "Redefinir vista"
} }
+1 -8
View File
@@ -3779,12 +3779,5 @@
"500–1,000 m": "500–1000 м", "500–1,000 m": "500–1000 м",
"1,000–2,000 m": "1000–2000 м", "1,000–2,000 m": "1000–2000 м",
"2,000–3,000 m": "2000–3000 м", "2,000–3,000 m": "2000–3000 м",
"3,000 m and above": "3000 м и выше", "3,000 m and above": "3000 м и выше"
"The globe stopped drawing": "Глобус перестал отрисовываться",
"The browser took back the globe's 3D graphics. Layers and controls still work, but the map cannot draw until the page reloads.": "Браузер отключил 3D-графику глобуса. Слои и элементы управления работают, но карта не отрисуется, пока страница не будет перезагружена.",
"Rendering stopped after an error. Layers and controls still work, but the map is not updating.": "Отрисовка остановилась из-за ошибки. Слои и элементы управления работают, но карта не обновляется.",
"Reset view reloads the app without the view restored from the address bar.": "«Сбросить вид» перезагружает приложение без вида, восстановленного из адресной строки.",
"Reload": "Перезагрузить",
"Retry": "Повторить",
"Reset view": "Сбросить вид"
} }
+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", "Confirm: remove every added entry from Data Layers": "Bekräfta: ta bort alla tillagda poster från Datalager",
"Remove every added entry from Data Layers": "Ta bort alla tillagda poster från Datalager", "Remove every added entry from Data Layers": "Ta bort alla tillagda poster från Datalager",
"Showing {shown} of {total}": "Visar {shown} av {total}", "Showing {shown} of {total}": "Visar {shown} av {total}",
"BROWSE DATA LIBRARY": "BLÄDDRA I DATABIBLIOTEKET", "BROWSE DATA LIBRARY": "BLÄDDRA I DATABIBLIOTEKET"
"The globe stopped drawing": "Globen ritas inte längre",
"The browser took back the globe's 3D graphics. Layers and controls still work, but the map cannot draw until the page reloads.": "Webbläsaren tog tillbaka globens 3D-grafik. Lager och reglage fungerar fortfarande, men kartan kan inte ritas förrän sidan laddas om.",
"Rendering stopped after an error. Layers and controls still work, but the map is not updating.": "Renderingen stoppades efter ett fel. Lager och reglage fungerar fortfarande, men kartan uppdateras inte.",
"Reset view reloads the app without the view restored from the address bar.": "”Återställ vy” laddar om appen utan vyn som återställdes från adressfältet.",
"Reload": "Ladda om",
"Retry": "Försök igen",
"Reset view": "Återställ vy"
} }
+1 -8
View File
@@ -3779,12 +3779,5 @@
"Confirm: remove every added entry from Data Layers": "Підтвердити: видалити всі додані записи з «Шарів даних»", "Confirm: remove every added entry from Data Layers": "Підтвердити: видалити всі додані записи з «Шарів даних»",
"Remove every added entry from Data Layers": "Видалити всі додані записи з «Шарів даних»", "Remove every added entry from Data Layers": "Видалити всі додані записи з «Шарів даних»",
"Showing {shown} of {total}": "Показано {shown} з {total}", "Showing {shown} of {total}": "Показано {shown} з {total}",
"BROWSE DATA LIBRARY": "ВІДКРИТИ БІБЛІОТЕКУ ДАНИХ", "BROWSE DATA LIBRARY": "ВІДКРИТИ БІБЛІОТЕКУ ДАНИХ"
"The globe stopped drawing": "Глобус перестав відмальовуватися",
"The browser took back the globe's 3D graphics. Layers and controls still work, but the map cannot draw until the page reloads.": "Браузер вимкнув 3D-графіку глобуса. Шари та елементи керування працюють, але карта не відмалюється, доки сторінку не буде перезавантажено.",
"Rendering stopped after an error. Layers and controls still work, but the map is not updating.": "Відмальовування зупинилося через помилку. Шари та елементи керування працюють, але карта не оновлюється.",
"Reset view reloads the app without the view restored from the address bar.": "«Скинути вигляд» перезавантажує застосунок без вигляду, відновленого з адресного рядка.",
"Reload": "Перезавантажити",
"Retry": "Повторити",
"Reset view": "Скинути вигляд"
} }
+1 -8
View File
@@ -3518,12 +3518,5 @@
"Confirm: remove every added entry from Data Layers": "确认:从数据图层中移除所有已添加条目", "Confirm: remove every added entry from Data Layers": "确认:从数据图层中移除所有已添加条目",
"Remove every added entry from Data Layers": "从数据图层中移除所有已添加条目", "Remove every added entry from Data Layers": "从数据图层中移除所有已添加条目",
"Showing {shown} of {total}": "显示 {shown} / {total}", "Showing {shown} of {total}": "显示 {shown} / {total}",
"BROWSE DATA LIBRARY": "浏览数据资源库", "BROWSE DATA LIBRARY": "浏览数据资源库"
"The globe stopped drawing": "地球已停止绘制",
"The browser took back the globe's 3D graphics. Layers and controls still work, but the map cannot draw until the page reloads.": "浏览器收回了地球的 3D 图形。图层和控件仍可使用,但在重新加载页面之前地图无法绘制。",
"Rendering stopped after an error. Layers and controls still work, but the map is not updating.": "渲染因错误而停止。图层和控件仍可使用,但地图不再更新。",
"Reset view reloads the app without the view restored from the address bar.": "“重置视图”会重新加载应用,不使用从地址栏恢复的视图。",
"Reload": "重新加载",
"Retry": "重试",
"Reset view": "重置视图"
} }
+26 -33
View File
@@ -612,6 +612,7 @@
"Cyclone advisories unavailable": "氣旋公告不可用", "Cyclone advisories unavailable": "氣旋公告不可用",
"Cyclone data unavailable": "氣旋數據不可用", "Cyclone data unavailable": "氣旋數據不可用",
"Potential tropical cyclone": "潛在熱帶氣旋", "Potential tropical cyclone": "潛在熱帶氣旋",
"Post-tropical cyclone": "後熱帶氣旋",
"Hurricane": "颶風", "Hurricane": "颶風",
"Tropical storm": "熱帶風暴", "Tropical storm": "熱帶風暴",
"Tropical depression": "熱帶低壓", "Tropical depression": "熱帶低壓",
@@ -707,11 +708,15 @@
"Merge onto {road}": "併入 {road}", "Merge onto {road}": "併入 {road}",
"maneuver|Merge": "併道", "maneuver|Merge": "併道",
"Take the ramp on the {direction} onto {road}": "{direction}駛入匝道,進入 {road}", "Take the ramp on the {direction} onto {road}": "{direction}駛入匝道,進入 {road}",
"Take the ramp {direction} onto {road}": "{direction}駛入匝道,進入 {road}",
"Take the ramp on the {direction}": "{direction}駛入匝道", "Take the ramp on the {direction}": "{direction}駛入匝道",
"Take the ramp {direction}": "{direction}駛入匝道",
"Take the ramp onto {road}": "駛入匝道,進入 {road}", "Take the ramp onto {road}": "駛入匝道,進入 {road}",
"Take the ramp": "駛入匝道", "Take the ramp": "駛入匝道",
"Take the exit on the {direction} onto {road}": "從出口{direction}駛出,進入 {road}", "Take the exit on the {direction} onto {road}": "從出口{direction}駛出,進入 {road}",
"Take the exit {direction} onto {road}": "從出口{direction}駛出,進入 {road}",
"Take the exit on the {direction}": "從出口{direction}駛出", "Take the exit on the {direction}": "從出口{direction}駛出",
"Take the exit {direction}": "從出口{direction}駛出",
"Take the exit onto {road}": "從出口駛出,進入 {road}", "Take the exit onto {road}": "從出口駛出,進入 {road}",
"Take the exit": "從出口駛出", "Take the exit": "從出口駛出",
"At the roundabout, take the {exit} exit onto {road}": "在環島從{exit}出口駛出,進入 {road}", "At the roundabout, take the {exit} exit onto {road}": "在環島從{exit}出口駛出,進入 {road}",
@@ -1599,6 +1604,9 @@
"LIVE SIGNALS": "實時信號", "LIVE SIGNALS": "實時信號",
"CCTV layer unavailable": "CCTV 圖層不可用", "CCTV layer unavailable": "CCTV 圖層不可用",
"Link copied!": "鏈接已複製!", "Link copied!": "鏈接已複製!",
"Link shared": "鏈接已分享",
"This share link can't be opened": "無法打開此分享鏈接",
"Shared vessel is unavailable": "分享的船舶不可用",
"Copy failed": "複製失敗", "Copy failed": "複製失敗",
"Return map to straight-down view": "將地圖恢復為垂直俯視", "Return map to straight-down view": "將地圖恢復為垂直俯視",
"Tilt map to oblique view": "將地圖傾斜至斜視角", "Tilt map to oblique view": "將地圖傾斜至斜視角",
@@ -1952,6 +1960,8 @@
"ORBIT": "環繞", "ORBIT": "環繞",
"Location not found": "未找到位置", "Location not found": "未找到位置",
"Search failed": "搜索失敗", "Search failed": "搜索失敗",
"Check the spelling and press Enter to search again.": "請檢查拼寫,然後按 Enter 重新搜索。",
"Press Enter to try again.": "按 Enter 重試。",
"Detailed outline unavailable": "無法獲取詳細輪廓", "Detailed outline unavailable": "無法獲取詳細輪廓",
"Fly to a POI first": "請先飛往一個 POI", "Fly to a POI first": "請先飛往一個 POI",
"Reset to full globe view": "重置為完整地球視圖", "Reset to full globe view": "重置為完整地球視圖",
@@ -2977,16 +2987,6 @@
"Search": "搜索", "Search": "搜索",
"More": "更多", "More": "更多",
"Panels": "面板", "Panels": "面板",
"Post-tropical cyclone": "後熱帶氣旋",
"Take the ramp {direction} onto {road}": "{direction}駛入匝道,進入 {road}",
"Take the ramp {direction}": "{direction}駛入匝道",
"Take the exit {direction} onto {road}": "從出口{direction}駛出,進入 {road}",
"Take the exit {direction}": "從出口{direction}駛出",
"Link shared": "鏈接已分享",
"This share link can't be opened": "無法打開此分享鏈接",
"Shared vessel is unavailable": "分享的船舶不可用",
"Check the spelling and press Enter to search again.": "請檢查拼寫,然後按 Enter 重新搜索。",
"Press Enter to try again.": "按 Enter 重試。",
"Recent speech": "最近語音", "Recent speech": "最近語音",
"Voice Settings": "語音設置", "Voice Settings": "語音設置",
"Preview": "預覽", "Preview": "預覽",
@@ -3025,8 +3025,19 @@
"other": "{count} 個地標" "other": "{count} 個地標"
}, },
"S2 Underground publishes these KMZ products to GitHub. New uploads are detected within seconds and replace the previous edition automatically. Click a marker for its report.": "S2 Underground 在 GitHub 發布這些 KMZ 產品。新上傳的內容會在數秒內被檢測到,並自動替換舊版本。點擊標記查看報告。", "S2 Underground publishes these KMZ products to GitHub. New uploads are detected within seconds and replace the previous edition automatically. Click a marker for its report.": "S2 Underground 在 GitHub 發布這些 KMZ 產品。新上傳的內容會在數秒內被檢測到,並自動替換舊版本。點擊標記查看報告。",
"routing busy — too many routes at once": "路線服務繁忙:同時請求的路線過多", "fire confidence filter|All": "全部",
"route proxy error": "路線代理出錯", "fire confidence filter|Nominal+": "中+",
"fire confidence filter|High": "高",
"Every detection — many of the strongest fires are rated low confidence": "所有火點探測——許多強度最高的火點被評為低置信度",
"Nominal and high confidence detections": "中、高置信度的火點探測",
"High confidence detections only": "僅高置信度的火點探測",
"Fire intensity (FRP)": "火勢強度(FRP)",
"≥ {value} MW": "≥ {value} MW",
"Confidence": "置信度",
"Minimum fire radiative power, megawatts": "最小火輻射功率(兆瓦)",
"fire intensity filter|All": "全部",
"Detection confidence": "探測置信度",
"{shown} of {total} shown": "顯示 {shown} / {total}",
"{lower} to {upper}": "{lower} 至 {upper}", "{lower} to {upper}": "{lower} 至 {upper}",
"Up to {upper}": "最高 {upper}", "Up to {upper}": "最高 {upper}",
"From {lower}": "最低 {lower}", "From {lower}": "最低 {lower}",
@@ -3039,6 +3050,8 @@
"Prohibited": "禁區", "Prohibited": "禁區",
"Restricted": "限制區", "Restricted": "限制區",
"Prohibited & Restricted Airspace": "禁區與限制區空域", "Prohibited & Restricted Airspace": "禁區與限制區空域",
"routing busy — too many routes at once": "路線服務繁忙:同時請求的路線過多",
"route proxy error": "路線代理出錯",
"AUTHORING": "創作", "AUTHORING": "創作",
"Create and edit scenes": "創建和編輯場景", "Create and edit scenes": "創建和編輯場景",
"SCENE": "場景", "SCENE": "場景",
@@ -3064,19 +3077,6 @@
"Download this scene as JSON or an asset bundle": "將此場景下載為 JSON 或資源包", "Download this scene as JSON or an asset bundle": "將此場景下載為 JSON 或資源包",
"Left panels": "左側面板", "Left panels": "左側面板",
"Right panels": "右側面板", "Right panels": "右側面板",
"fire confidence filter|All": "全部",
"fire confidence filter|Nominal+": "中+",
"fire confidence filter|High": "高",
"Every detection — many of the strongest fires are rated low confidence": "所有火點探測——許多強度最高的火點被評為低置信度",
"Nominal and high confidence detections": "中、高置信度的火點探測",
"High confidence detections only": "僅高置信度的火點探測",
"Fire intensity (FRP)": "火勢強度(FRP)",
"≥ {value} MW": "≥ {value} MW",
"Confidence": "置信度",
"Minimum fire radiative power, megawatts": "最小火輻射功率(兆瓦)",
"fire intensity filter|All": "全部",
"Detection confidence": "探測置信度",
"{shown} of {total} shown": "顯示 {shown} / {total}",
"UTILITIES": "工具", "UTILITIES": "工具",
"Exit cockpit to fly to a drawing": "請先退出座艙,再飛往已繪製的形狀", "Exit cockpit to fly to a drawing": "請先退出座艙,再飛往已繪製的形狀",
"Directions, drawing and your own files": "路線導航、繪製和你自己的文件", "Directions, drawing and your own files": "路線導航、繪製和你自己的文件",
@@ -3518,12 +3518,5 @@
"Confirm: remove every added entry from Data Layers": "確認:從數據圖層中移除所有已添加條目", "Confirm: remove every added entry from Data Layers": "確認:從數據圖層中移除所有已添加條目",
"Remove every added entry from Data Layers": "從數據圖層中移除所有已添加條目", "Remove every added entry from Data Layers": "從數據圖層中移除所有已添加條目",
"Showing {shown} of {total}": "顯示 {shown} / {total}", "Showing {shown} of {total}": "顯示 {shown} / {total}",
"BROWSE DATA LIBRARY": "瀏覽數據資源庫", "BROWSE DATA LIBRARY": "瀏覽數據資源庫"
"The globe stopped drawing": "地球已停止繪製",
"The browser took back the globe's 3D graphics. Layers and controls still work, but the map cannot draw until the page reloads.": "瀏覽器收回了地球的 3D 圖形。圖層和控件仍可使用,但在重新加載頁面之前地圖無法繪製。",
"Rendering stopped after an error. Layers and controls still work, but the map is not updating.": "渲染因錯誤而停止。圖層和控件仍可使用,但地圖不再更新。",
"Reset view reloads the app without the view restored from the address bar.": "「重置視圖」會重新加載應用,不使用從地址欄恢復的視圖。",
"Reload": "重新加載",
"Retry": "重試",
"Reset view": "重置視圖"
} }
-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 * road network chip, CctvControls the camera grid chip; the toast shows
* what any owner's createToast() (src/ui/toast.js) publishes, and the title * what any owner's createToast() (src/ui/toast.js) publishes, and the title
* bar the FPS readout createFrameRateMonitor() publishes. Each label keeps its * bar the FPS readout createFrameRateMonitor() publishes. Each label keeps its
* last text while hidden, so the next change flaps from what was shown. The * last text while hidden, so the next change flaps from what was shown.
* render failure notice is what installRenderFailureNotice()
* (src/ui/renderFailure.js) publishes while the globe cannot draw.
*/ */
export const useStatusStore = defineStore('gev-status', () => { export const useStatusStore = defineStore('gev-status', () => {
/** /**
@@ -34,13 +32,6 @@ export const useStatusStore = defineStore('gev-status', () => {
*/ */
const fps = shallowRef(null); const fps = shallowRef(null);
/**
* The globe's render failure, `{reason, detail, retry, reset}`; null while
* the globe draws. `reason` is 'render-error' or 'context-lost', and `retry`
* and `reset` are null where that action is not offered.
*/
const renderFailure = shallowRef(null);
/** @param {?object} next A fresh presentation of the loading status. */ /** @param {?object} next A fresh presentation of the loading status. */
function publishLoading(next) { function publishLoading(next) {
loading.value = next && markRaw(next); loading.value = next && markRaw(next);
@@ -66,11 +57,6 @@ export const useStatusStore = defineStore('gev-status', () => {
fps.value = next && markRaw(next); fps.value = next && markRaw(next);
} }
/** @param {?object} next A fresh presentation of the render failure. */
function publishRenderFailure(next) {
renderFailure.value = next && markRaw(next);
}
return { return {
fps, fps,
publishFps, publishFps,
@@ -82,7 +68,5 @@ export const useStatusStore = defineStore('gev-status', () => {
publishTraffic, publishTraffic,
cctv, cctv,
publishCctv, 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 Notification ──────────────────────── */
#toast { #toast {
position: fixed; position: fixed;
-2
View File
@@ -18,7 +18,6 @@ import ScreenAnnotations from './panels/ScreenAnnotations.vue';
import AwarenessDirection from './panels/AwarenessDirection.vue'; import AwarenessDirection from './panels/AwarenessDirection.vue';
import UiButton from '../kit/UiButton.vue'; import UiButton from '../kit/UiButton.vue';
import GlobeActions from './panels/GlobeActions.vue'; import GlobeActions from './panels/GlobeActions.vue';
import RenderFailure from './panels/RenderFailure.vue';
import WorldTimeline from './panels/WorldTimeline.vue'; import WorldTimeline from './panels/WorldTimeline.vue';
// The ACTIVE STYLE corner shows the style VisualSettings publishes. // The ACTIVE STYLE corner shows the style VisualSettings publishes.
@@ -62,7 +61,6 @@ const vToastText = {
<template> <template>
<div id="cesiumContainer"></div> <div id="cesiumContainer"></div>
<RenderFailure />
<div id="world-overlay-root" aria-hidden="true"> <div id="world-overlay-root" aria-hidden="true">
<canvas id="world-overlay-canvas"></canvas> <canvas id="world-overlay-canvas"></canvas>
</div> </div>
-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>