mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
i18n: fix review findings and polish ru/es strings
Fixes the 51 P0–P2 findings from the full review of the i18n branch and the issues found while checking every new string on screen in ru and es. - Translate errors and statuses that still reached ru/es in English: feed and tile errors, provider statuses, the ALPR chip, the traffic mode chip, voice plan step labels, fire feed values, receiver bands, scene save reasons and the Cesium attribution link. - Show preset city names in the UI language (src/ui/placeNames.js); data and everything sent to the model stay English. - Share layer names between the layer panel, HUD and voice card (src/ui/layerNames.js). - Keep long ru/es strings whole: voice card error lines, analyst titles, the voice tier status, the Nepal evidence button, the ALPR retry chip. English layout is unchanged. - Ask before a language switch drops unsaved work. - Catalog fixes: glossary terms (пуск, пресет), gender agreement and consistent "peticiones" in es, fewer calques in both languages. - Fix English text that was itself wrong (e.g. "Exit cockpit to fly to a weather", raw tool ids in voice plan steps). Catalogs: es/ru 2535 messages each, complete. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
8029747b7e
commit
00704ddc89
212 files changed
+9871
-909
No files matched your search
+11
-4
@@ -3098,11 +3098,18 @@ Shipped locales: **en** (source and fallback), **es** and **ru**. The code lives
|
||||
`data-i18n-title` / `-aria-label` / `-placeholder` (that attribute's English
|
||||
value). English has no catalog; every other locale is
|
||||
`src/i18n/locales/<code>.json`, and a missing entry falls back to English.
|
||||
- **Locale choice.** `?lang=` (one-shot) → stored choice (`gev:locale:v1`) →
|
||||
`navigator.languages` → English. The language switch in the Visual Presets dock
|
||||
stores the choice and reloads, so nothing re-renders live.
|
||||
- **Locale choice.** `?lang=` → stored choice (`gev:locale:v1`) →
|
||||
`navigator.languages` → English. `?lang=` is not stored: it applies while it
|
||||
stays in the address, and the copied share link drops it, so a recipient keeps
|
||||
their own language. The language switch in the Visual Presets dock stores the
|
||||
choice, removes `?lang=` (keeps it only where storage is blocked) and reloads,
|
||||
so nothing re-renders live; it asks first when the reload would lose work
|
||||
(unsaved CCTV calibration, the open cockpit, Draw marks on the board,
|
||||
imported map files, an active voice session — each registered through
|
||||
`registerUnsavedWork`).
|
||||
- **Gate.** `npm run i18n` (and `src/i18n/catalog.test.mjs` under `npm test`)
|
||||
extracts every message and fails on missing, unused, placeholder-mismatched or
|
||||
extracts every message and fails on missing, empty, unused,
|
||||
placeholder-mismatched, still-English (plural forms included) or
|
||||
wrongly-shaped plural entries.
|
||||
- **Not translated by design:** layer ids, share-link and hash parameters, API
|
||||
fields, voice tool schemas, provider names, feed data (callsigns, vessel and
|
||||
|
||||
+3
-3
@@ -33,7 +33,7 @@ label.textContent = t(FEED_STATE_LABELS[state]);
|
||||
<input placeholder="Search any location..." data-i18n-placeholder />
|
||||
```
|
||||
|
||||
`data-i18n` replaces the element's text, so put it on the element that directly holds the text, never on a parent with child elements. `data-i18n-title`, `data-i18n-aria-label` and `data-i18n-placeholder` translate that attribute. Markup is translated once at boot, and switching language reloads the page.
|
||||
`data-i18n` replaces the element's text, so put it on the element that directly holds the text, never on a parent with child elements. `data-i18n-title`, `data-i18n-aria-label` and `data-i18n-placeholder` translate that attribute. Markup is translated once at boot, and switching language reloads the page. A module that holds work a reload would lose (an unsaved edit, an open session) registers it with `registerUnsavedWork(() => busy ? msg('unsaved …') : null)` from `src/i18n/index.js`, and the language switch asks before reloading.
|
||||
|
||||
## Rules
|
||||
|
||||
@@ -58,14 +58,14 @@ Changing the English changes the key, so the old translation goes unused and the
|
||||
|
||||
To see a language, open the app with `?lang=ru`, or use the language switch in the Visual Presets dock.
|
||||
|
||||
A translation that is exactly the English ("Bloom" → "Bloom") is reported as still English. When it is right on purpose, such as a code (HDG, CRT), a product name or a word spelled the same in that language, list it under the language in `src/i18n/same-as-english.json`.
|
||||
An empty translation is reported, and so is one that is exactly the English ("Bloom" → "Bloom"), including any plural form left as the English: it is reported as still English. When it is right on purpose, such as a code (HDG, CRT), a product name or a word spelled the same in that language, list it under the language in `src/i18n/same-as-english.json`.
|
||||
|
||||
## Text the check reports as unmarked
|
||||
|
||||
`npm run i18n` also lists UI text that is not marked at all, and fails while there is any; `npm test` runs the same check:
|
||||
|
||||
- **Markup:** element text without `data-i18n`, and `title`, `aria-label` or `placeholder` without the matching `data-i18n-*`.
|
||||
- **Code:** string literals that reach the page without `t()`, `tn()` or `msg()`: assigned to `textContent`, `innerText`, `innerHTML`, `title`, `placeholder`; passed to `setAttribute('title' | 'aria-label' | 'aria-valuetext' | 'placeholder' | 'alt', …)`, `createTextNode`, `insertAdjacentHTML`, `showToast`, `toast`, `setHint`, `announce`; or kept in `label:`, `title:`, `tooltip:`, `placeholder:`, `hint:`, `ariaLabel:`, `caption:`, `subtitle:`, `kicker:`, `emptyText:`, `blurb:`, `countLabel:` properties.
|
||||
- **Code:** string literals that reach the page without `t()`, `tn()` or `msg()`: assigned to `textContent`, `innerText`, `innerHTML`, `title`, `placeholder`; passed to `setAttribute('title' | 'aria-label' | 'aria-valuetext' | 'placeholder' | 'alt', …)`, `createTextNode`, `insertAdjacentHTML`, `showToast`, `toast`, `setHint`, `announce`, `say`; or kept in `label:`, `title:`, `tooltip:`, `placeholder:`, `hint:`, `ariaLabel:`, `accessibilityLabel:`, `caption:`, `subtitle:`, `kicker:`, `emptyText:`, `blurb:`, `countLabel:` properties.
|
||||
|
||||
Fix it by marking the text. When the text must stay as it is, say so where it lives:
|
||||
|
||||
|
||||
+2
-2
@@ -22,7 +22,7 @@ Every other language is one flat JSON file, `src/i18n/locales/<code>.json`, keye
|
||||
|
||||
A `tn()` entry is keyed by the English plural form and holds one string per `Intl.PluralRules` category of that language. A missing entry falls back to English.
|
||||
|
||||
The page picks its language from `?lang=`, then the choice stored by the language switch in the Visual Presets dock, then the browser languages, then English. Switching reloads the page.
|
||||
The page picks its language from `?lang=`, then the choice stored by the language switch in the Visual Presets dock, then the browser languages, then English. `?lang=` is not stored, and copied share links leave it out. Switching reloads the page.
|
||||
|
||||
## The check
|
||||
|
||||
@@ -32,7 +32,7 @@ npm run i18n -- --missing ru # missing ru entries as JSON, ready to transla
|
||||
npm run i18n -- --prune # drop entries the code no longer uses
|
||||
```
|
||||
|
||||
`npm test` runs the same check (`src/i18n/catalog.test.mjs`). It fails when a translation is missing, when an entry is no longer used, when placeholders differ from the English, when a plural entry lacks a form, or when a translation is still the English text. If an entry is right as it is in English (a code such as HDG, a product name, a word spelled the same in your language), add it under your language code in `src/i18n/same-as-english.json`.
|
||||
`npm test` runs the same check (`src/i18n/catalog.test.mjs`). It fails when a translation is missing or empty, when an entry is no longer used, when placeholders differ from the English, when a plural entry lacks a form, or when a translation, or any form of a plural entry, is still the English text. The `--missing` template is not a translation: its strings are empty and its plural forms are the English, so the check fails until every one is translated. If an entry is right as it is in English (a code such as HDG, a product name, a word spelled the same in your language), add it under your language code in `src/i18n/same-as-english.json`.
|
||||
|
||||
Changing English text changes its key: the old translation becomes unused and the new text shows as missing. Translate it and prune.
|
||||
|
||||
|
||||
+76
-35
@@ -11,7 +11,7 @@
|
||||
// Messages are the English literals passed to t(), tn() and msg() in src/,
|
||||
// plus the markup opted in with data-i18n* in src/ui/templates and index.html.
|
||||
// It also lists UI text that is not marked at all (see findUnmarked).
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
@@ -62,23 +62,27 @@ function decodeHtml(text) {
|
||||
const normalize = (text) => text.replace(/\s+/g, ' ').trim();
|
||||
|
||||
function trackedFiles(...pathspecs) {
|
||||
return execFileSync(
|
||||
'git',
|
||||
[
|
||||
'ls-files',
|
||||
'--cached',
|
||||
'--others',
|
||||
'--exclude-standard',
|
||||
'--',
|
||||
...pathspecs,
|
||||
],
|
||||
{
|
||||
cwd: ROOT,
|
||||
encoding: 'utf8',
|
||||
},
|
||||
)
|
||||
.split('\n')
|
||||
.filter(Boolean);
|
||||
return (
|
||||
execFileSync(
|
||||
'git',
|
||||
[
|
||||
'ls-files',
|
||||
'--cached',
|
||||
'--others',
|
||||
'--exclude-standard',
|
||||
'--',
|
||||
...pathspecs,
|
||||
],
|
||||
{
|
||||
cwd: ROOT,
|
||||
encoding: 'utf8',
|
||||
},
|
||||
)
|
||||
.split('\n')
|
||||
.filter(Boolean)
|
||||
// The index still lists a file moved or deleted without git mv / git rm.
|
||||
.filter((file) => existsSync(path.join(ROOT, file)))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -213,8 +217,8 @@ const JS_SINKS = [
|
||||
/\bsetAttribute\(\s*['"](?:title|aria-label|aria-valuetext|placeholder|alt)['"]\s*,\s*/g,
|
||||
/\b(?:createTextNode|insertAdjacentText)\(\s*(?:['"][a-z]+['"]\s*,\s*)?/g,
|
||||
/\binsertAdjacentHTML\(\s*['"][a-z]+['"]\s*,\s*/g,
|
||||
/\b(?:showToast|toast|setHint|announce)\(\s*/g,
|
||||
/(?:^|[{,\s])(?:label|title|tooltip|placeholder|hint|ariaLabel|caption|subtitle|kicker|emptyText|blurb|countLabel)\s*:\s*/gm,
|
||||
/\b(?:showToast|toast|setHint|announce|say)\(\s*/g,
|
||||
/(?:^|[{,\s])(?:label|title|tooltip|placeholder|hint|ariaLabel|accessibilityLabel|caption|subtitle|kicker|emptyText|blurb|countLabel)\s*:\s*/gm,
|
||||
];
|
||||
const MARKERS = /\b(?:t|tn|msg)\(/y;
|
||||
|
||||
@@ -438,6 +442,15 @@ export function readSameAsEnglish(locale) {
|
||||
/** The English a message shows: its text without the "context|" prefix. */
|
||||
const englishOf = (text) => text.slice(text.indexOf('|') + 1);
|
||||
|
||||
/** A translation with nothing to show: t() falls back to English for it. */
|
||||
const blank = (value) => value.trim() === '';
|
||||
|
||||
/** Words to read, beyond {placeholders}, codes and punctuation. */
|
||||
const hasLetters = (value) => /\p{L}{2}/u.test(value.replace(/\{[^}]*\}/g, ''));
|
||||
|
||||
const STILL_ENGLISH = (text, locale) =>
|
||||
`"${text}" is still English: translate it, or list it under "${locale}" in src/i18n/same-as-english.json`;
|
||||
|
||||
/**
|
||||
* Compare one catalog with the extracted messages.
|
||||
* @param {Set<string>} [sameAsEnglish] Messages allowed to read as English.
|
||||
@@ -464,44 +477,72 @@ export function checkCatalog(
|
||||
errors.push(`"${text}" needs plural forms ${categories.join('/')}`);
|
||||
continue;
|
||||
}
|
||||
const english = new Set([info.one, englishOf(text)]);
|
||||
const stillEnglish = [];
|
||||
for (const category of categories) {
|
||||
if (typeof entry[category] !== 'string')
|
||||
const form = entry[category];
|
||||
if (typeof form !== 'string')
|
||||
errors.push(`"${text}" lacks the "${category}" form`);
|
||||
else if (placeholders(entry[category]) !== placeholders(text))
|
||||
errors.push(
|
||||
`"${text}" [${category}] placeholders differ: ${entry[category]}`,
|
||||
);
|
||||
else if (blank(form))
|
||||
errors.push(`"${text}" [${category}] is empty: translate it`);
|
||||
else if (placeholders(form) !== placeholders(text))
|
||||
errors.push(`"${text}" [${category}] placeholders differ: ${form}`);
|
||||
else if (english.has(form) && hasLetters(form))
|
||||
stillEnglish.push(category);
|
||||
}
|
||||
if (stillEnglish.length && !sameAsEnglish.has(text))
|
||||
errors.push(
|
||||
`${STILL_ENGLISH(text, locale)} (forms ${stillEnglish.join('/')})`,
|
||||
);
|
||||
} else if (typeof entry !== 'string') {
|
||||
errors.push(`"${text}" must be a string`);
|
||||
} else if (blank(entry)) {
|
||||
errors.push(`"${text}" is empty: translate it`);
|
||||
} else if (placeholders(entry) !== placeholders(text)) {
|
||||
errors.push(`"${text}" placeholders differ: ${entry}`);
|
||||
} else if (
|
||||
entry === englishOf(text) &&
|
||||
/\p{L}{2}/u.test(entry.replace(/\{[^}]*\}/g, '')) &&
|
||||
hasLetters(entry) &&
|
||||
!sameAsEnglish.has(text)
|
||||
) {
|
||||
errors.push(
|
||||
`"${text}" is still English: translate it, or list it under "${locale}" in src/i18n/same-as-english.json`,
|
||||
);
|
||||
errors.push(STILL_ENGLISH(text, locale));
|
||||
}
|
||||
}
|
||||
const stale = Object.keys(catalog).filter((text) => !messages.has(text));
|
||||
return { missing, stale, errors };
|
||||
}
|
||||
|
||||
/**
|
||||
* What `--missing <locale>` prints: every missing entry, ready to translate.
|
||||
* A template, not a translation: the check rejects a string left empty and a
|
||||
* plural form left as the English.
|
||||
*/
|
||||
export function missingTemplate(locale, catalog, messages) {
|
||||
const categories = new Intl.PluralRules(locale).resolvedOptions()
|
||||
.pluralCategories;
|
||||
const out = {};
|
||||
for (const text of checkCatalog(locale, catalog, messages, new Set())
|
||||
.missing) {
|
||||
const info = messages.get(text);
|
||||
out[text] = info.plural
|
||||
? Object.fromEntries(
|
||||
categories.map((category) => [
|
||||
category,
|
||||
category === 'one' ? info.one : text,
|
||||
]),
|
||||
)
|
||||
: '';
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function main(args) {
|
||||
const messages = extractMessages();
|
||||
const missingFor = args.includes('--missing')
|
||||
? args[args.indexOf('--missing') + 1]
|
||||
: null;
|
||||
if (missingFor) {
|
||||
const catalog = readCatalog(missingFor);
|
||||
const out = {};
|
||||
for (const text of checkCatalog(missingFor, catalog, messages).missing) {
|
||||
const info = messages.get(text);
|
||||
out[text] = info.plural ? { one: info.one, other: text } : '';
|
||||
}
|
||||
const out = missingTemplate(missingFor, readCatalog(missingFor), messages);
|
||||
console.log(JSON.stringify(out, null, 2));
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -439,6 +439,7 @@
|
||||
"modules": [
|
||||
"src/ui/layers.js",
|
||||
"src/ui/layerPanel.js",
|
||||
"src/ui/layerNames.js",
|
||||
"src/ui/weatherPanel.js",
|
||||
"src/ui/railCards.js",
|
||||
"src/ui/railCardBlocks.js",
|
||||
@@ -467,6 +468,7 @@
|
||||
"src/ui/location.js",
|
||||
"src/ui/locationControls.js",
|
||||
"src/ui/locationSearch.js",
|
||||
"src/ui/placeNames.js",
|
||||
"src/locationStatus.js",
|
||||
"src/app/stateChannel.js",
|
||||
"src/i18n/index.js",
|
||||
@@ -527,7 +529,8 @@
|
||||
"src/i18n/index.js",
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/ru.json"
|
||||
"src/i18n/locales/ru.json",
|
||||
"src/ui/layerNames.js"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
@@ -581,6 +584,7 @@
|
||||
"ui-shell": {
|
||||
"exports": ["./ui/shell"],
|
||||
"modules": [
|
||||
"src/ui/placeNames.js",
|
||||
"src/hudLayouts.js",
|
||||
"src/hudLayoutPolicy.js",
|
||||
"src/cyberSonar.js",
|
||||
@@ -602,6 +606,7 @@
|
||||
"src/data/layerStateCoordinator.js",
|
||||
"src/data/layerStateTokenReservations.json",
|
||||
"src/data/militaryAwarenessEngine.js",
|
||||
"src/data/installationNames.js",
|
||||
"src/data/scenePick.js",
|
||||
"src/keySetupCore.mjs",
|
||||
"src/localRequestGate.mjs",
|
||||
@@ -719,7 +724,8 @@
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/ru.json",
|
||||
"src/ui/unitLabels.js",
|
||||
"src/ui/keyGuidance.js"
|
||||
"src/ui/keyGuidance.js",
|
||||
"src/ui/layerNames.js"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
},
|
||||
@@ -786,6 +792,7 @@
|
||||
"src/data/bhoteKoshiFloodPath.js",
|
||||
"src/maps/catalog.js",
|
||||
"src/scenes/director.js",
|
||||
"src/scenes/sceneSaveError.js",
|
||||
"src/scenes/playbackAdapter.js",
|
||||
"src/director/playback.js",
|
||||
"src/scenes/recipes.js",
|
||||
@@ -1078,7 +1085,8 @@
|
||||
"src/i18n/index.js",
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/ru.json"
|
||||
"src/i18n/locales/ru.json",
|
||||
"src/ui/unitLabels.js"
|
||||
],
|
||||
"external": ["cesium", "satellite.js"]
|
||||
},
|
||||
@@ -1119,6 +1127,7 @@
|
||||
"src/data/cameraSensitivity.js",
|
||||
"src/data/installationFeedback.js",
|
||||
"src/data/militaryInstallationData.js",
|
||||
"src/data/installationNames.js",
|
||||
"src/layers/installations/controls.js",
|
||||
"src/layers/installations/index.js",
|
||||
"src/layers/installations/ingestion.js",
|
||||
@@ -1160,6 +1169,7 @@
|
||||
"src/cockpitMath.js",
|
||||
"src/data/iconOrientation.js",
|
||||
"src/data/militaryAwarenessEngine.js",
|
||||
"src/data/installationNames.js",
|
||||
"src/layers/awareness/controls.js",
|
||||
"src/layers/awareness/dependencies.js",
|
||||
"src/layers/awareness/focus.js",
|
||||
@@ -1219,6 +1229,7 @@
|
||||
"src/sources/capability.js",
|
||||
"src/sources/httpBody.js",
|
||||
"src/sources/openFreeMap.js",
|
||||
"src/data/installationNames.js",
|
||||
"src/sources/militaryTileGeometry.js",
|
||||
"src/sources/vectorTiles.js",
|
||||
"src/sources/phaseTiming.js",
|
||||
@@ -1537,6 +1548,9 @@
|
||||
"src/voice/referents.js",
|
||||
"src/voice/layerManifest.js",
|
||||
"src/voice/speech.js",
|
||||
"src/ui/layerNames.js",
|
||||
"src/ui/unitLabels.js",
|
||||
"src/ui/placeNames.js",
|
||||
"src/voice/narration.js",
|
||||
"src/voice/earcon.js",
|
||||
"src/voice/pointerContext.js",
|
||||
@@ -1568,6 +1582,7 @@
|
||||
"./standalone/catalog"
|
||||
],
|
||||
"modules": [
|
||||
"src/ui/placeNames.js",
|
||||
"src/cyberSonarScene.js",
|
||||
"src/cyberSonar.js",
|
||||
"src/hudLayouts.js",
|
||||
@@ -1710,6 +1725,7 @@
|
||||
"src/data/meshFloorSampler.js",
|
||||
"src/data/militaryAwareness.js",
|
||||
"src/data/militaryAwarenessEngine.js",
|
||||
"src/data/installationNames.js",
|
||||
"src/data/militaryFlights.js",
|
||||
"src/data/militaryInstallationData.js",
|
||||
"src/data/militaryInstallations.js",
|
||||
@@ -2005,6 +2021,7 @@
|
||||
"src/renderGovernor.js",
|
||||
"src/rightRailPolicy.js",
|
||||
"src/scenes/director.js",
|
||||
"src/scenes/sceneSaveError.js",
|
||||
"src/scenes/playbackAdapter.js",
|
||||
"src/director/playback.js",
|
||||
"src/scenes/recipes.js",
|
||||
@@ -2123,6 +2140,7 @@
|
||||
"src/voice/control.js",
|
||||
"src/voice/gevActions.js",
|
||||
"src/voice/speech.js",
|
||||
"src/ui/layerNames.js",
|
||||
"src/voice/earcon.js",
|
||||
"src/voice/narration.js",
|
||||
"src/voice/turnMetrics.js",
|
||||
@@ -2156,6 +2174,7 @@
|
||||
"src/ui/geoFileImport.js",
|
||||
"src/data/routeSteps.js",
|
||||
"src/layers/directions/index.js",
|
||||
"src/data/pointerOwnerNames.js",
|
||||
"src/layers/directions/source.js",
|
||||
"src/data/directions.js",
|
||||
"src/app/layers/directions.js",
|
||||
@@ -2486,6 +2505,7 @@
|
||||
"src/data/local_data/natural_earth/regions.json",
|
||||
"src/data/local_data/neighborhoods/san-francisco.json",
|
||||
"src/data/militaryAwarenessEngine.js",
|
||||
"src/data/installationNames.js",
|
||||
"src/data/militaryInstallationData.js",
|
||||
"src/data/modelVisualAnchor.js",
|
||||
"src/data/motionModel.js",
|
||||
@@ -2767,6 +2787,7 @@
|
||||
"src/services/groundFloor.js",
|
||||
"src/data/routeSteps.js",
|
||||
"src/layers/directions/index.js",
|
||||
"src/data/pointerOwnerNames.js",
|
||||
"src/layers/directions/source.js",
|
||||
"src/services/terrainHeights.js",
|
||||
"src/data/terrainHeights.js",
|
||||
@@ -3016,7 +3037,11 @@
|
||||
"src/navigationPolicy.js",
|
||||
"src/ui/navigationController.js",
|
||||
"src/app/stateChannel.js",
|
||||
"src/data/cameraArrival.js"
|
||||
"src/data/cameraArrival.js",
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/ru.json",
|
||||
"src/i18n/index.js"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
},
|
||||
@@ -3170,6 +3195,7 @@
|
||||
"exports": ["./layers/directions"],
|
||||
"modules": [
|
||||
"src/layers/directions/index.js",
|
||||
"src/data/pointerOwnerNames.js",
|
||||
"src/layers/directions/source.js",
|
||||
"src/data/routeSteps.js",
|
||||
"src/i18n/index.js",
|
||||
@@ -3212,6 +3238,7 @@
|
||||
"voice-session-controls": {
|
||||
"exports": ["./voice/session-controls"],
|
||||
"modules": [
|
||||
"src/ui/placeNames.js",
|
||||
"src/voice/sessionCommands.js",
|
||||
"src/voice/session.js",
|
||||
"src/voice/control.js",
|
||||
@@ -3221,6 +3248,8 @@
|
||||
"src/voice/referents.js",
|
||||
"src/voice/layerManifest.js",
|
||||
"src/voice/speech.js",
|
||||
"src/ui/layerNames.js",
|
||||
"src/ui/unitLabels.js",
|
||||
"src/voice/pointerReticle.js",
|
||||
"src/i18n/index.js",
|
||||
"src/i18n/locale.js",
|
||||
@@ -3271,6 +3300,7 @@
|
||||
"modules": [
|
||||
"src/layers/installations/source.js",
|
||||
"src/data/militaryInstallationData.js",
|
||||
"src/data/installationNames.js",
|
||||
"src/data/tomtomTiles.js",
|
||||
"src/sources/capability.js",
|
||||
"src/sources/httpBody.js",
|
||||
@@ -3312,6 +3342,7 @@
|
||||
"src/sources/overpassRoads.js",
|
||||
"src/sources/httpBody.js",
|
||||
"src/sources/openFreeMap.js",
|
||||
"src/data/installationNames.js",
|
||||
"src/sources/militaryTileGeometry.js",
|
||||
"src/sources/vectorTiles.js",
|
||||
"src/sources/phaseTiming.js",
|
||||
@@ -3496,7 +3527,11 @@
|
||||
"scripts/qa-transit-controls.mjs",
|
||||
"scripts/qa-transit-browser.mjs",
|
||||
"src/layers/transit/source.js",
|
||||
"src/sources/transitHistory.js"
|
||||
"src/sources/transitHistory.js",
|
||||
"src/i18n/index.js",
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/ru.json"
|
||||
],
|
||||
"external": ["cesium", "pbf"]
|
||||
},
|
||||
@@ -3670,6 +3705,7 @@
|
||||
"src/data/retryableLoad.js",
|
||||
"src/data/local_data/osm_military_names/names.json",
|
||||
"src/sources/openFreeMap.js",
|
||||
"src/data/installationNames.js",
|
||||
"src/sources/vectorTiles.js",
|
||||
"src/sources/phaseTiming.js",
|
||||
"src/data/tomtomTiles.js",
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* qa-scene-dialog-file-drop — the SHARE SCENE dialog's file control accepts a
|
||||
* file dropped from the desktop, proved in a real browser.
|
||||
*
|
||||
* The dialog replaces the browser-drawn "Choose files / No file chosen" line
|
||||
* with a translated one (src/ui/sceneSharing.js, fileChooser). The native
|
||||
* input must stay the whole control underneath it: a shrunken 1 px input
|
||||
* accepts drops only on its own pixel, so dragging a data-pack file onto the
|
||||
* visible row selected nothing. This harness drops a file onto the centre of
|
||||
* the visible row through CDP and expects `input.files` to hold it.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/qa-scene-dialog-file-drop.mjs
|
||||
*
|
||||
* Needs the repository's puppeteer (devDependencies). Exit status 0 when the
|
||||
* drop selects the file, 1 otherwise.
|
||||
*/
|
||||
import http from 'node:http';
|
||||
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import puppeteer from 'puppeteer';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const TYPES = {
|
||||
'.js': 'text/javascript',
|
||||
'.mjs': 'text/javascript',
|
||||
'.json': 'application/json',
|
||||
'.html': 'text/html',
|
||||
};
|
||||
|
||||
const PAGE = `<!doctype html><html lang="en"><body>
|
||||
<script type="module">
|
||||
import { createSceneDialog } from '/src/ui/sceneSharing.js';
|
||||
window.openDialog = () => {
|
||||
const dialog = createSceneDialog('Share selected scene', () => {});
|
||||
const files = dialog.input('Choose data-pack files', '', { type: 'file' });
|
||||
files.multiple = true;
|
||||
window.filesInput = files;
|
||||
// Centre of the visible row (the wrapper's last child).
|
||||
const row = files.parentElement.lastElementChild;
|
||||
const r = row.getBoundingClientRect();
|
||||
return { x: r.x + r.width / 2, y: r.y + r.height / 2 };
|
||||
};
|
||||
window.ready = true;
|
||||
</script></body></html>`;
|
||||
|
||||
async function dropOn(client, x, y, file) {
|
||||
const data = { items: [], files: [file], dragOperationsMask: 1 };
|
||||
for (const type of ['dragEnter', 'dragOver', 'drop']) {
|
||||
await client.send('Input.dispatchDragEvent', { type, x, y, data });
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), 'qa-scene-dialog-drop-'));
|
||||
const pack = path.join(dir, 'pack.json');
|
||||
await writeFile(pack, '{}');
|
||||
const server = http.createServer(async (req, res) => {
|
||||
if (req.url === '/') {
|
||||
res.writeHead(200, { 'content-type': 'text/html' });
|
||||
res.end(PAGE);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const body = await readFile(path.join(ROOT, req.url));
|
||||
res.writeHead(200, {
|
||||
'content-type': TYPES[path.extname(req.url)] || 'text/plain',
|
||||
});
|
||||
res.end(body);
|
||||
} catch {
|
||||
res.writeHead(404).end();
|
||||
}
|
||||
});
|
||||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
const url = `http://127.0.0.1:${server.address().port}/`;
|
||||
const browser = await puppeteer.launch({ headless: true });
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 1000, height: 800 });
|
||||
const errors = [];
|
||||
page.on('pageerror', (error) => errors.push(String(error)));
|
||||
await page.goto(url);
|
||||
await page.waitForFunction(() => window.ready === true);
|
||||
const point = await page.evaluate(() => window.openDialog());
|
||||
const client = await page.createCDPSession();
|
||||
await dropOn(client, point.x, point.y, pack);
|
||||
const names = await page.evaluate(() =>
|
||||
[...window.filesInput.files].map((file) => file.name),
|
||||
);
|
||||
console.log(JSON.stringify({ names, errors }));
|
||||
if (errors.length) throw new Error(`page errors: ${errors.join('; ')}`);
|
||||
if (names.length !== 1 || names[0] !== 'pack.json') {
|
||||
throw new Error(
|
||||
`expected the dropped pack.json to be selected, got ${JSON.stringify(names)}`,
|
||||
);
|
||||
}
|
||||
console.log('OK: a file dropped on the visible control is selected');
|
||||
} finally {
|
||||
await browser.close();
|
||||
server.close();
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -4,6 +4,8 @@ const STATUS_URL = 'https://www.nhc.noaa.gov/CurrentStorms.json';
|
||||
const GIS =
|
||||
'https://mapservices.weather.noaa.gov/tropical/rest/services/tropical/NHC_tropical_weather_summary/MapServer';
|
||||
const HOUR = 3600_000;
|
||||
// Data, not UI copy: src/layers/cyclones/index.js keeps the same English in
|
||||
// msg() and translates it when shown (likewise 'Cyclone data unavailable').
|
||||
const COVERAGE =
|
||||
'Atlantic and eastern/central North Pacific; not worldwide cyclone coverage.';
|
||||
const LAYERS = [
|
||||
|
||||
@@ -3,6 +3,10 @@ import { fetchIfsWind } from './wind/ifs.js';
|
||||
import { decodeWindGribMessage } from './wind/decode.js';
|
||||
import { weatherScalarMetadata, weatherScalarError } from './wind/grid.js';
|
||||
|
||||
// Data, not UI copy: the client (src/layers/wind/index.js) marks this exact
|
||||
// English with msg() and translates it when shown, so keep the two in sync.
|
||||
const UPSTREAM_UNAVAILABLE = 'Wind upstream unavailable';
|
||||
|
||||
/** Serve bounded, single-flight, per-model/overlay forecast snapshots from fixed providers. */
|
||||
export function windProxy({
|
||||
fetchImpl = fetch,
|
||||
@@ -25,7 +29,7 @@ export function windProxy({
|
||||
...(overlay === 'none' ? {} : { overlay }),
|
||||
unavailable: true,
|
||||
stale: true,
|
||||
reason: 'Wind upstream unavailable',
|
||||
reason: UPSTREAM_UNAVAILABLE,
|
||||
},
|
||||
});
|
||||
const sendJson = (res, value, status = 200) => {
|
||||
@@ -139,7 +143,7 @@ export function windProxy({
|
||||
old.manifest = {
|
||||
...old.manifest,
|
||||
stale: true,
|
||||
reason: 'Wind upstream unavailable',
|
||||
reason: UPSTREAM_UNAVAILABLE,
|
||||
};
|
||||
return old;
|
||||
}
|
||||
|
||||
@@ -134,6 +134,9 @@ function keySetupEndpoint({ sourceRoot = defaultSourceRoot } = {}) {
|
||||
};
|
||||
// The gate itself is pure and unit-tested (admitKeySetupRequest in
|
||||
// src/keySetupCore.mjs) — this just feeds it the request.
|
||||
// A refusal as the panel reads it: `code` (and `params`) pick its localized
|
||||
// sentence in src/keySetup.js; `error` is the English for logs and scripts.
|
||||
const refusal = ({ error, code, params }) => ({ error, code, params });
|
||||
const admit = (req) =>
|
||||
admitKeySetupRequest({
|
||||
method: req.method,
|
||||
@@ -247,18 +250,24 @@ function keySetupEndpoint({ sourceRoot = defaultSourceRoot } = {}) {
|
||||
configureServer(server) {
|
||||
server.middlewares.use('/api/setup/status', (req, res) => {
|
||||
if (req.method !== 'GET')
|
||||
return respond(res, 405, { error: 'Method not allowed' });
|
||||
return respond(res, 405, {
|
||||
error: 'Method not allowed',
|
||||
code: 'method',
|
||||
});
|
||||
const admission = admit(req);
|
||||
if (!admission.ok)
|
||||
return respond(res, admission.status, { error: admission.error });
|
||||
return respond(res, admission.status, refusal(admission));
|
||||
respond(res, 200, providerStatus());
|
||||
});
|
||||
server.middlewares.use('/api/setup/keys', (req, res) => {
|
||||
if (req.method !== 'POST')
|
||||
return respond(res, 405, { error: 'Method not allowed' });
|
||||
return respond(res, 405, {
|
||||
error: 'Method not allowed',
|
||||
code: 'method',
|
||||
});
|
||||
const admission = admit(req);
|
||||
if (!admission.ok)
|
||||
return respond(res, admission.status, { error: admission.error });
|
||||
return respond(res, admission.status, refusal(admission));
|
||||
let body = '';
|
||||
let overflowed = false;
|
||||
req.on('data', (chunk) => {
|
||||
@@ -270,15 +279,21 @@ function keySetupEndpoint({ sourceRoot = defaultSourceRoot } = {}) {
|
||||
});
|
||||
req.on('end', () => {
|
||||
if (overflowed)
|
||||
return respond(res, 413, { error: 'Request too large' });
|
||||
return respond(res, 413, {
|
||||
error: 'Request too large',
|
||||
code: 'too-large',
|
||||
});
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(body || '{}');
|
||||
} catch {
|
||||
return respond(res, 400, { error: 'Invalid JSON' });
|
||||
return respond(res, 400, {
|
||||
error: 'Invalid JSON',
|
||||
code: 'invalid-json',
|
||||
});
|
||||
}
|
||||
const verdict = validateKeySetupUpdates(parsed);
|
||||
if (!verdict.ok) return respond(res, 400, { error: verdict.error });
|
||||
if (!verdict.ok) return respond(res, 400, refusal(verdict));
|
||||
// Neither a replace NOR a removal may touch an externally-supplied
|
||||
// credential (shell env, Keychain, another workflow). This backs the
|
||||
// UI's read-only "configured externally" state with a real contract —
|
||||
@@ -289,6 +304,8 @@ function keySetupEndpoint({ sourceRoot = defaultSourceRoot } = {}) {
|
||||
if (isExternallyManaged(name, inStore)) {
|
||||
return respond(res, 409, {
|
||||
error: `${name} is configured outside Provider Settings and can only be changed where it was set`,
|
||||
code: 'external',
|
||||
params: { name },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -305,10 +322,16 @@ function keySetupEndpoint({ sourceRoot = defaultSourceRoot } = {}) {
|
||||
) {
|
||||
return respond(res, 500, {
|
||||
error: `The key was not saved: ${error.message}`,
|
||||
code:
|
||||
error.code === 'GEV_HARDEN_FAILED'
|
||||
? 'harden-failed'
|
||||
: 'store-unreadable',
|
||||
});
|
||||
}
|
||||
return respond(res, 500, {
|
||||
error: `Could not write the ${storeName()} store`,
|
||||
code: 'write-failed',
|
||||
params: { store: storeName() },
|
||||
});
|
||||
}
|
||||
// Live for the server-side proxies immediately; the restart below is
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
pointerOwner,
|
||||
releasePointer,
|
||||
} from '../data/inputOwnership.js';
|
||||
import { pointerOwnerName } from '../data/pointerOwnerNames.js';
|
||||
import {
|
||||
DRAW_SHAPES,
|
||||
MAX_VERTICES,
|
||||
@@ -48,7 +49,7 @@ import {
|
||||
removeLastVertex,
|
||||
} from './drawMode.js';
|
||||
|
||||
import { t } from '../i18n/index.js';
|
||||
import { msg, registerUnsavedWork, t, translateKnown } from '../i18n/index.js';
|
||||
/** The id this tool claims the pointer under. */
|
||||
export const DRAW_POINTER_OWNER = 'draw';
|
||||
const PREVIEW_DATA_SOURCE_NAME = 'gev-draw-preview';
|
||||
@@ -101,6 +102,17 @@ export function initDrawTool({ viewer, annotations }) {
|
||||
target.addEventListener(type, listener, options);
|
||||
domListeners.push([target, type, listener, options]);
|
||||
};
|
||||
// The board lives only in memory: switching language (a reload) asks first
|
||||
// while it holds a lasting mark or a shape is half drawn.
|
||||
const unregisterUnsavedWork = registerUnsavedWork(() => {
|
||||
if (destroyed) return null;
|
||||
const lasting = (annotations.list?.() || []).some(
|
||||
(mark) => mark && mark.ttlMs == null && !mark.expiring,
|
||||
);
|
||||
return lasting || session?.vertices?.length > 0
|
||||
? msg('drawings on the map')
|
||||
: null;
|
||||
});
|
||||
|
||||
const dataSource = new Cesium.CustomDataSource(PREVIEW_DATA_SOURCE_NAME);
|
||||
// `add()` resolves on the NEXT tick, so an init-then-immediate-destroy would
|
||||
@@ -334,7 +346,7 @@ export function initDrawTool({ viewer, annotations }) {
|
||||
// half-starting.
|
||||
setHint(
|
||||
t('{owner} is using the pointer — close it first.', {
|
||||
owner: pointerOwner(),
|
||||
owner: translateKnown(pointerOwnerName(pointerOwner())),
|
||||
}),
|
||||
);
|
||||
return active;
|
||||
@@ -514,6 +526,7 @@ export function initDrawTool({ viewer, annotations }) {
|
||||
generation += 1;
|
||||
if (active) setActive(false);
|
||||
destroyed = true;
|
||||
unregisterUnsavedWork();
|
||||
releaseSceneHandler();
|
||||
releasePointer(lease);
|
||||
lease = null;
|
||||
|
||||
@@ -6,6 +6,7 @@ import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import { initDrawTool } from './drawTool.js';
|
||||
import { describeUnsavedWork } from '../i18n/index.js';
|
||||
import {
|
||||
isPointerFree,
|
||||
pointerOwner,
|
||||
@@ -240,6 +241,30 @@ const LEFT_DOUBLE_CLICK = Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK;
|
||||
|
||||
/* ── the tests ─────────────────────────────────────────────────────────── */
|
||||
|
||||
// K-27. First in the file: the tools other tests leave behind would answer the
|
||||
// same registry.
|
||||
test('a lasting mark or a half-drawn shape makes the language switch ask first', async () => {
|
||||
const h = harness();
|
||||
try {
|
||||
let marks = [];
|
||||
h.annotations.list = () => marks;
|
||||
assert.deepEqual(describeUnsavedWork(), [], 'an empty board loses nothing');
|
||||
marks = [{ id: 'a', ttlMs: 8000, expiring: false }];
|
||||
assert.deepEqual(describeUnsavedWork(), [], 'a fading mark is not work');
|
||||
marks = [{ id: 'b', ttlMs: null, expiring: false }];
|
||||
assert.deepEqual(describeUnsavedWork(), ['drawings on the map']);
|
||||
marks = [];
|
||||
h.element('draw-toggle').emit('click');
|
||||
h.tool.addVertex(-97.75, 30.26);
|
||||
assert.deepEqual(describeUnsavedWork(), ['drawings on the map'], 'a shape in progress');
|
||||
marks = [{ id: 'c', ttlMs: null, expiring: false }];
|
||||
await h.tool.destroy();
|
||||
assert.deepEqual(describeUnsavedWork(), [], 'a destroyed tool unregisters');
|
||||
} finally {
|
||||
h.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('turning Draw on takes a lease and turning it off gives it back', async () => {
|
||||
const h = harness();
|
||||
try {
|
||||
@@ -263,7 +288,11 @@ test('Draw refuses to start when something else holds the pointer', async () =>
|
||||
h.element('draw-toggle').emit('click');
|
||||
assert.equal(h.tool.active, false, 'it must not half-start');
|
||||
assert.equal(pointerOwner(), 'directions', 'and must not steal the claim');
|
||||
assert.match(h.element('draw-hint').textContent, /directions/);
|
||||
// Named by its tool, not by the internal owner id (E3/E12).
|
||||
assert.equal(
|
||||
h.element('draw-hint').textContent,
|
||||
'Directions is using the pointer — close it first.',
|
||||
);
|
||||
assert.equal(h.tool.diagnostics().sceneHandler, false, 'no handler was bound');
|
||||
|
||||
releasePointer(other);
|
||||
|
||||
@@ -569,15 +569,20 @@ export function panelRuntime(config) {
|
||||
queued = view;
|
||||
if (started) return;
|
||||
started = true;
|
||||
// i18n-ignore: this script runs as source text in the host panel, without the app catalogs
|
||||
say("Loading God's Eye View…");
|
||||
startApp(url, view).catch((error) =>
|
||||
// i18n-ignore: this script runs as source text in the host panel, without the app catalogs
|
||||
say(`God's Eye View could not load here: ${error?.message || error}.`),
|
||||
);
|
||||
setTimeout(() => {
|
||||
// i18n-ignore: this script runs as source text in the host panel
|
||||
if (!ready)
|
||||
say(
|
||||
// i18n-ignore: see above
|
||||
"God's Eye View did not load here" +
|
||||
(blocked ? ` (${blocked})` : '') +
|
||||
// i18n-ignore: see above
|
||||
". Use Open in God's Eye View above.",
|
||||
);
|
||||
}, config.loadTimeoutMs);
|
||||
@@ -658,6 +663,7 @@ export function panelRuntime(config) {
|
||||
},
|
||||
(error) =>
|
||||
say(
|
||||
// i18n-ignore: this script runs as source text in the host panel, without the app catalogs
|
||||
"This client did not accept the God's Eye View panel" +
|
||||
(error?.message ? `: ${error.message}` : '.'),
|
||||
),
|
||||
|
||||
@@ -9,6 +9,16 @@ const CONTEXT_DEPENDENCIES = Object.freeze({
|
||||
'space-missions': new Set(['rocket-launches', 'satellites']),
|
||||
});
|
||||
const CONTEXT_COMPANIONS = new Set(['radio']);
|
||||
// This module is shared with voice actions and scene policy, so the refusal
|
||||
// reason stays English data; the Context shell translates it with t() at the
|
||||
// toast (src/ui/contextLayerChanges.js).
|
||||
const msg = (text) => text;
|
||||
/** The Space Missions refusal, with the blocked layer's name as {layer}. */
|
||||
export const SPACE_MISSIONS_LAYER_BLOCK_REASON = msg(
|
||||
'Space Missions isolates replay data. Exit the mode to enable {layer}.',
|
||||
);
|
||||
const [BLOCK_REASON_PREFIX, BLOCK_REASON_SUFFIX] =
|
||||
SPACE_MISSIONS_LAYER_BLOCK_REASON.split('{layer}');
|
||||
/** Return whether an origin represents a direct user choice on this route. */
|
||||
export function isExplicitUserIntentOrigin(origin, layerId = null) {
|
||||
return origin === 'user' || origin === 'voice';
|
||||
@@ -336,7 +346,30 @@ export function contextLayerEnableBlockReason({
|
||||
return null;
|
||||
}
|
||||
const label = String(layerName || change.layerId || 'that layer');
|
||||
return `Space Missions isolates replay data. Exit the mode to enable ${label}.`;
|
||||
return SPACE_MISSIONS_LAYER_BLOCK_REASON.replace('{layer}', label);
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a Space Missions refusal back into its template and layer name so
|
||||
* the presentation can translate the sentence and localize the name.
|
||||
* @param {string} reason
|
||||
* @returns {{layer: string}|null} The blocked layer's name, or null when the
|
||||
* reason is not a Space Missions refusal.
|
||||
*/
|
||||
export function parseContextLayerBlockReason(reason) {
|
||||
if (
|
||||
typeof reason !== 'string' ||
|
||||
!reason.startsWith(BLOCK_REASON_PREFIX) ||
|
||||
!reason.endsWith(BLOCK_REASON_SUFFIX) ||
|
||||
reason.length <= BLOCK_REASON_PREFIX.length + BLOCK_REASON_SUFFIX.length
|
||||
)
|
||||
return null;
|
||||
return {
|
||||
layer: reason.slice(
|
||||
BLOCK_REASON_PREFIX.length,
|
||||
reason.length - BLOCK_REASON_SUFFIX.length,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Adds the keyboard semantics omitted by Cesium's href-less attribution links.
|
||||
* Cesium retains ownership of showing and hiding its required credit lightbox.
|
||||
*/
|
||||
import { t } from './i18n/index.js';
|
||||
import { getLocale, t } from './i18n/index.js';
|
||||
|
||||
export function configureCreditKeyboardAccess(root = document) {
|
||||
const expand = root?.querySelector?.(
|
||||
@@ -13,6 +13,14 @@ export function configureCreditKeyboardAccess(root = document) {
|
||||
if (!expand || !lightbox || !close) return false;
|
||||
const overlay = lightbox.parentElement;
|
||||
|
||||
// Cesium writes these two texts in English once, when it builds the credit
|
||||
// display, and never rewrites them; English keeps Cesium's own text.
|
||||
if (getLocale() !== 'en') {
|
||||
expand.textContent = t('Data attribution');
|
||||
const title = lightbox.querySelector?.('.cesium-credit-lightbox-title');
|
||||
if (title) title.textContent = t('Data provided by:');
|
||||
}
|
||||
|
||||
if (!lightbox.id) lightbox.id = 'cesium-credit-lightbox';
|
||||
expand.setAttribute('role', 'button');
|
||||
expand.setAttribute('tabindex', '0');
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { configureCreditKeyboardAccess } from './creditKeyboard.js';
|
||||
import { setLocale } from './i18n/index.js';
|
||||
|
||||
function fakeElement() {
|
||||
const attributes = new Map();
|
||||
@@ -186,3 +187,37 @@ test('credit keyboard setup is idempotent and safely declines incomplete markup'
|
||||
assert.equal(f.overlay.listenerCount('click'), 1);
|
||||
assert.equal(configureCreditKeyboardAccess({ querySelector: () => null }), false);
|
||||
});
|
||||
|
||||
function textFixture() {
|
||||
const f = fixture();
|
||||
const title = fakeElement();
|
||||
title.textContent = 'Data provided by:';
|
||||
f.expand.textContent = 'Data attribution';
|
||||
f.lightbox.querySelector = (selector) =>
|
||||
selector === '.cesium-credit-lightbox-title' ? title : f.close;
|
||||
return { ...f, title };
|
||||
}
|
||||
|
||||
test('English keeps Cesium attribution texts', () => {
|
||||
setLocale('en');
|
||||
const f = textFixture();
|
||||
configureCreditKeyboardAccess(f.root);
|
||||
assert.equal(f.expand.textContent, 'Data attribution');
|
||||
assert.equal(f.title.textContent, 'Data provided by:');
|
||||
});
|
||||
|
||||
for (const [locale, link, title] of [
|
||||
['ru', 'Источники данных', 'Данные предоставлены:'],
|
||||
['es', 'Atribución de datos', 'Datos proporcionados por:'],
|
||||
])
|
||||
test(`${locale}: Cesium attribution link and lightbox title are translated`, () => {
|
||||
setLocale(locale);
|
||||
try {
|
||||
const f = textFixture();
|
||||
configureCreditKeyboardAccess(f.root);
|
||||
assert.equal(f.expand.textContent, link);
|
||||
assert.equal(f.title.textContent, title);
|
||||
} finally {
|
||||
setLocale('en');
|
||||
}
|
||||
});
|
||||
+15
-11
@@ -438,18 +438,22 @@ export function classifyAircraft({ typeCode, category } = {}) {
|
||||
return 'airliner';
|
||||
}
|
||||
|
||||
/** Readable name of each class, for cards and readouts. */
|
||||
// Portable flight records import this module, so it has no i18n: msg() only
|
||||
// marks the labels, and the card translates them where it shows them.
|
||||
const msg = (text) => text;
|
||||
|
||||
/** Readable name of each class, for cards and readouts (English data). */
|
||||
export const AIRCRAFT_CLASS_LABELS = Object.freeze({
|
||||
light: 'Light aircraft',
|
||||
glider: 'Glider',
|
||||
turboprop: 'Turboprop',
|
||||
airliner: 'Airliner',
|
||||
widebody: 'Widebody',
|
||||
quadjet: 'Four-engine jet',
|
||||
helicopter: 'Helicopter',
|
||||
fastjet: 'Fast jet',
|
||||
bizjet: 'Business jet',
|
||||
uav: 'Drone',
|
||||
light: msg('Light aircraft'),
|
||||
glider: msg('Glider'),
|
||||
turboprop: msg('Turboprop'),
|
||||
airliner: msg('Airliner'),
|
||||
widebody: msg('Widebody'),
|
||||
quadjet: msg('Four-engine jet'),
|
||||
helicopter: msg('Helicopter'),
|
||||
fastjet: msg('Fast jet'),
|
||||
bizjet: msg('Business jet'),
|
||||
uav: msg('Drone'),
|
||||
});
|
||||
|
||||
/** Billboard scale multipliers (skylight GLYPH_SCALE, + fastjet). */
|
||||
|
||||
@@ -729,6 +729,19 @@ function ellipsizeEvidenceText(value, limit) {
|
||||
return `${text.slice(0, Math.max(1, limit - 3)).trimEnd()}...`;
|
||||
}
|
||||
|
||||
// The pack names the kind of source in `media.publisher` (event.json); the
|
||||
// frame shows it in the UI language. Any other publisher shows as given.
|
||||
const EVIDENCE_PUBLISHER_TEXT = new Set([
|
||||
msg('YouTube source'),
|
||||
msg('Facebook source'),
|
||||
]);
|
||||
|
||||
/** An evidence item's publisher as its frame shows it. */
|
||||
function evidencePublisherText(publisher) {
|
||||
const text = String(publisher);
|
||||
return EVIDENCE_PUBLISHER_TEXT.has(text) ? t(text) : bhoteEventText(text);
|
||||
}
|
||||
|
||||
/** Repaint one stable frame with a poster or the current source-video frame. */
|
||||
export function updateEvidenceFrame(
|
||||
canvas,
|
||||
@@ -790,20 +803,73 @@ export function updateEvidenceFrame(
|
||||
infoY + 27,
|
||||
);
|
||||
ctx.fillStyle = WORLD_OVERLAY_STYLE.detail;
|
||||
ctx.font = scaledOverlayFont(WORLD_OVERLAY_STYLE.fontDetail);
|
||||
const publisher = String(
|
||||
observation.media?.publisher || t('SOURCE LINK'),
|
||||
const publisher = (
|
||||
observation.media?.publisher
|
||||
? evidencePublisherText(observation.media.publisher)
|
||||
: t('SOURCE LINK')
|
||||
).toUpperCase();
|
||||
ctx.fillText(
|
||||
t('{publisher} / OPEN', {
|
||||
publisher: ellipsizeEvidenceText(publisher, 24),
|
||||
}),
|
||||
fitEvidenceSourceLine(
|
||||
ctx,
|
||||
publisher,
|
||||
scaledOverlayFont(WORLD_OVERLAY_STYLE.fontDetail),
|
||||
canvas.width - 32,
|
||||
),
|
||||
16,
|
||||
infoY + 53,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
// The smallest the source line's font may shrink to before the publisher is
|
||||
// shortened instead.
|
||||
const EVIDENCE_SOURCE_MIN_FONT_SCALE = 0.8;
|
||||
|
||||
/**
|
||||
* Set the font for the «{publisher} / OPEN» line and return the line so it
|
||||
* fits `maxWidth`: a translation («ИСТОЧНИК YOUTUBE / ОТКРЫТЬ») is longer than
|
||||
* the English and the portrait card cut its action word. A line that fits is
|
||||
* drawn exactly as before; one that does not first shrinks its font (to 80%),
|
||||
* then shortens the publisher — the «/ OPEN» action always stays whole.
|
||||
* @param {CanvasRenderingContext2D} ctx
|
||||
* @param {string} publisher Display publisher, already upper-cased.
|
||||
* @param {string} font CSS font with a px size.
|
||||
* @param {number} maxWidth
|
||||
* @returns {string}
|
||||
*/
|
||||
function fitEvidenceSourceLine(ctx, publisher, font, maxWidth) {
|
||||
const line = (limit) =>
|
||||
t('{publisher} / OPEN', {
|
||||
publisher: ellipsizeEvidenceText(publisher, limit),
|
||||
});
|
||||
let limit = 24;
|
||||
let text = line(limit);
|
||||
ctx.font = font;
|
||||
if (typeof ctx.measureText !== 'function' || !(maxWidth > 0)) return text;
|
||||
const width = () => Number(ctx.measureText(text)?.width) || 0;
|
||||
if (width() <= maxWidth) return text;
|
||||
const size = /(\d+(?:\.\d+)?)px/.exec(font);
|
||||
if (size) {
|
||||
const base = Number(size[1]);
|
||||
const floor = base * EVIDENCE_SOURCE_MIN_FONT_SCALE;
|
||||
// Width scales with the font size; step down until it fits or bottoms out.
|
||||
let next = Math.max(
|
||||
floor,
|
||||
Math.floor(((base * maxWidth) / width()) * 10) / 10,
|
||||
);
|
||||
for (;;) {
|
||||
ctx.font = font.replace(size[0], `${next}px`);
|
||||
if (width() <= maxWidth || next <= floor) break;
|
||||
next = Math.max(floor, next - 0.5);
|
||||
}
|
||||
}
|
||||
while (width() > maxWidth && limit > 4) {
|
||||
limit = Math.min(limit, publisher.length) - 1;
|
||||
text = line(limit);
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
/** Compose source imagery and caveats into one stable renderer-owned frame. */
|
||||
export function createEvidenceFrame(observation, poster = null, options = {}) {
|
||||
const canvas = evidenceFrameCanvas(observation);
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
// Proposal 5 / E4: the evidence frame's «{publisher} / OPEN» line is drawn on
|
||||
// a canvas, so CSS cannot widen it; on the portrait card the Russian
|
||||
// «ИСТОЧНИК YOUTUBE / ОТКРЫТЬ» lost its last letter. The line is measured and
|
||||
// shrunk to fit, keeping the action word whole; English draws as before.
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import test from 'node:test';
|
||||
|
||||
import { setLocale } from '../i18n/index.js';
|
||||
import { evidenceCardLayout, updateEvidenceFrame } from './bhoteKoshiEvent.js';
|
||||
|
||||
const eventUrl = new URL(
|
||||
'../../public/events/bhote-koshi-2026/event.json',
|
||||
import.meta.url,
|
||||
);
|
||||
|
||||
// JetBrains Mono advances every glyph by 0.6 em.
|
||||
const fontPx = (font) => Number(/(\d+(?:\.\d+)?)px/.exec(font)[1]);
|
||||
const measure = (text, font) => text.length * 0.6 * fontPx(font);
|
||||
|
||||
function drawSourceLine(observation, publisher) {
|
||||
const layout = evidenceCardLayout(observation);
|
||||
const drawn = [];
|
||||
const context = {
|
||||
font: '',
|
||||
beginPath() {},
|
||||
clearRect() {},
|
||||
moveTo() {},
|
||||
lineTo() {},
|
||||
stroke() {},
|
||||
fillRect() {},
|
||||
drawImage() {},
|
||||
measureText(text) {
|
||||
return { width: measure(String(text), this.font) };
|
||||
},
|
||||
fillText(value, x) {
|
||||
drawn.push({ text: String(value), font: this.font, x });
|
||||
},
|
||||
};
|
||||
const canvas = {
|
||||
width: layout.canvasWidth,
|
||||
height: layout.canvasHeight,
|
||||
_bhoteEvidenceLayout: layout,
|
||||
getContext: () => context,
|
||||
};
|
||||
const subject = publisher
|
||||
? { ...observation, media: { ...observation.media, publisher } }
|
||||
: observation;
|
||||
assert.equal(updateEvidenceFrame(canvas, subject), true);
|
||||
const line = drawn.find((entry) => entry.text.includes(' / '));
|
||||
assert.ok(line, 'source line drawn');
|
||||
return { ...line, maxWidth: layout.canvasWidth - 32 };
|
||||
}
|
||||
|
||||
async function portraitObservations() {
|
||||
const event = JSON.parse(await readFile(eventUrl, 'utf8'));
|
||||
const portrait = event.evidenceSpine.filter(
|
||||
(o) => evidenceCardLayout(o).kind === 'portrait',
|
||||
);
|
||||
const youtube = portrait.find((o) => o.media?.publisher === 'YouTube source');
|
||||
const facebook = portrait.find(
|
||||
(o) => o.media?.publisher === 'Facebook source',
|
||||
);
|
||||
assert.ok(youtube, 'a portrait YouTube card exists');
|
||||
return [youtube, facebook].filter(Boolean);
|
||||
}
|
||||
|
||||
test('English source lines draw unchanged on every card', async () => {
|
||||
setLocale('en');
|
||||
const event = JSON.parse(await readFile(eventUrl, 'utf8'));
|
||||
for (const observation of event.evidenceSpine) {
|
||||
const line = drawSourceLine(observation);
|
||||
assert.equal(fontPx(line.font), 21, `${observation.id}: font kept`);
|
||||
assert.match(
|
||||
line.text,
|
||||
/^(YOUTUBE|FACEBOOK) SOURCE \/ OPEN$|^SOURCE LINK \/ OPEN$/,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('ru/es source lines fit the portrait card with the action word whole', async () => {
|
||||
const observations = await portraitObservations();
|
||||
try {
|
||||
for (const [locale, action] of [
|
||||
['ru', '/ ОТКРЫТЬ'],
|
||||
['es', '/ ABRIR'],
|
||||
]) {
|
||||
setLocale(locale);
|
||||
for (const observation of observations) {
|
||||
const line = drawSourceLine(observation);
|
||||
assert.ok(line.text.endsWith(action), `${locale}: ${line.text}`);
|
||||
assert.ok(
|
||||
measure(line.text, line.font) <= line.maxWidth,
|
||||
`${locale}: «${line.text}» at ${line.font} overflows`,
|
||||
);
|
||||
assert.ok(fontPx(line.font) >= 21 * 0.8, 'font stays readable');
|
||||
}
|
||||
}
|
||||
setLocale('ru');
|
||||
const ru = drawSourceLine(observations[0]);
|
||||
assert.equal(ru.text, 'ИСТОЧНИК YOUTUBE / ОТКРЫТЬ', 'publisher kept whole');
|
||||
} finally {
|
||||
setLocale('en');
|
||||
}
|
||||
});
|
||||
|
||||
test('a publisher too long even at the smallest font is shortened, not the action', async () => {
|
||||
const [observation] = await portraitObservations();
|
||||
try {
|
||||
setLocale('ru');
|
||||
const line = drawSourceLine(
|
||||
observation,
|
||||
'A very long regional broadcaster',
|
||||
);
|
||||
assert.ok(line.text.endsWith('... / ОТКРЫТЬ'), line.text);
|
||||
assert.ok(measure(line.text, line.font) <= line.maxWidth, line.text);
|
||||
assert.equal(fontPx(line.font), 21 * 0.8);
|
||||
} finally {
|
||||
setLocale('en');
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import test from 'node:test';
|
||||
|
||||
// K-30: the publisher on the evidence frame ("YouTube source") is pack text
|
||||
// and is shown in the UI language; English stays "YOUTUBE SOURCE / OPEN".
|
||||
import { setLocale } from '../i18n/index.js';
|
||||
import { createEvidenceFrame } from './bhoteKoshiEvent.js';
|
||||
|
||||
const eventUrl = new URL(
|
||||
'../../public/events/bhote-koshi-2026/event.json',
|
||||
import.meta.url,
|
||||
);
|
||||
|
||||
function captureFrameText(observation) {
|
||||
const originalDocument = globalThis.document;
|
||||
const text = [];
|
||||
globalThis.document = {
|
||||
createElement() {
|
||||
const context = {
|
||||
beginPath() {},
|
||||
clearRect() {},
|
||||
moveTo() {},
|
||||
lineTo() {},
|
||||
stroke() {},
|
||||
fillRect() {},
|
||||
fillText(value) {
|
||||
text.push(String(value));
|
||||
},
|
||||
drawImage() {},
|
||||
};
|
||||
return { width: 0, height: 0, getContext: () => context };
|
||||
},
|
||||
};
|
||||
try {
|
||||
assert.ok(createEvidenceFrame(observation));
|
||||
} finally {
|
||||
globalThis.document = originalDocument;
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
test('evidence card source line is fully in the UI language (es, ru)', async () => {
|
||||
const event = JSON.parse(await readFile(eventUrl, 'utf8'));
|
||||
const youtube = event.evidenceSpine.find(
|
||||
(o) => o.media?.publisher === 'YouTube source',
|
||||
);
|
||||
const facebook = event.evidenceSpine.find(
|
||||
(o) => o.media?.publisher === 'Facebook source',
|
||||
);
|
||||
assert.ok(youtube && facebook);
|
||||
try {
|
||||
for (const locale of ['es', 'ru']) {
|
||||
setLocale(locale);
|
||||
for (const observation of [youtube, facebook]) {
|
||||
const lines = captureFrameText(observation);
|
||||
const sourceLine = lines.find((line) => line.includes(' / '));
|
||||
assert.ok(sourceLine, 'source line drawn');
|
||||
assert.doesNotMatch(
|
||||
sourceLine,
|
||||
/SOURCE/,
|
||||
`${locale}: English "SOURCE" left in "${sourceLine}"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setLocale('en');
|
||||
}
|
||||
});
|
||||
|
||||
test('evidence card source line keeps its English wording', async () => {
|
||||
const event = JSON.parse(await readFile(eventUrl, 'utf8'));
|
||||
const youtube = event.evidenceSpine.find(
|
||||
(o) => o.media?.publisher === 'YouTube source',
|
||||
);
|
||||
setLocale('en');
|
||||
const sourceLine = captureFrameText(youtube).find((line) =>
|
||||
line.includes(' / '),
|
||||
);
|
||||
assert.equal(sourceLine, 'YOUTUBE SOURCE / OPEN');
|
||||
});
|
||||
@@ -574,8 +574,9 @@ export function createBhoteKoshiLocatorLayer({
|
||||
protected: true,
|
||||
active: true,
|
||||
interactive: true,
|
||||
accessibilityLabel:
|
||||
accessibilityLabel: t(
|
||||
'Open the USGS initial report for the 26 August 2026 Nepal landslide',
|
||||
),
|
||||
activate: () => {
|
||||
const opened = window.open(
|
||||
TRIGGER_SOURCE_URL,
|
||||
|
||||
@@ -1450,6 +1450,9 @@ function _drawOverlay(frame) {
|
||||
if (bracketAlpha > 0) protectedVisibleCount++;
|
||||
continue;
|
||||
}
|
||||
// The source shows its own card for this object (e.g. an ambient vessel
|
||||
// card): keep the bracket, draw no callout over the card.
|
||||
if (obj.hideLabel) continue;
|
||||
|
||||
const primary = String(obj.id || '');
|
||||
const micro = String(obj.metric || '');
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
* GPU/MSDF renderer. The Canvas2D overlay (detection.js) imports these and
|
||||
* supplies the actual drawing surface.
|
||||
*/
|
||||
import { formatNumber, t } from '../i18n/index.js';
|
||||
|
||||
/** @constant {number} Max characters shown for a label's primary (id) line. */
|
||||
const MAX_PRIMARY = 18;
|
||||
@@ -30,14 +31,15 @@ export function formatFlightLevel(altitudeMeters) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a speed in knots as a short rounded label. Returns '' for
|
||||
* non-positive or non-finite speeds (declutters moored/unknown vessels).
|
||||
* Formats a speed in knots as a short rounded label in the UI language.
|
||||
* Returns '' for non-positive or non-finite speeds (declutters moored/unknown
|
||||
* vessels).
|
||||
* @param {number} knots - Speed over ground in knots.
|
||||
* @returns {string} e.g. '14 kn', or '' when not applicable.
|
||||
* @returns {string} e.g. '14 kn' (ru '14 уз'), or '' when not applicable.
|
||||
*/
|
||||
export function formatKnots(knots) {
|
||||
if (!Number.isFinite(knots) || knots <= 0) return '';
|
||||
return Math.round(knots) + ' kn';
|
||||
return t('{speed} kn', { speed: formatNumber(Math.round(knots)) });
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -192,3 +192,14 @@ test('transit bracket stroke centres have full pixel coverage at the failing fra
|
||||
for(const [x,y] of points){assert.equal(x%1,0.5);assert.equal(y%1,0.5);}
|
||||
}
|
||||
});
|
||||
|
||||
test('formatKnots writes the unit in the UI language', async () => {
|
||||
const { setLocale } = await import('../i18n/index.js');
|
||||
setLocale('ru');
|
||||
try {
|
||||
assert.equal(formatKnots(9.5), '10 уз');
|
||||
} finally {
|
||||
setLocale('en');
|
||||
}
|
||||
assert.equal(formatKnots(9.5), '10 kn');
|
||||
});
|
||||
@@ -955,6 +955,52 @@ test('a transit vehicle is a contact: box, route, mode and operator on the share
|
||||
}
|
||||
});
|
||||
|
||||
test('hideLabel keeps the bracket but paints no callout over the source card', () => {
|
||||
// C6: an ambient vessel card already names the vessel with its speed; the
|
||||
// callout drawn beside it covered that card's speed and course.
|
||||
const env = installEnvironment();
|
||||
const objects = [
|
||||
{
|
||||
position: new Cesium.Cartesian3(0, 0, 6_356_752),
|
||||
sourceId: '200',
|
||||
id: 'GOLDEN BEAR',
|
||||
type: 'VEH',
|
||||
metric: '10 kn',
|
||||
hideLabel: true,
|
||||
},
|
||||
{
|
||||
position: new Cesium.Cartesian3(0.5, 0.5, 6_356_752),
|
||||
sourceId: '300',
|
||||
id: 'BAY PILOT',
|
||||
type: 'VEH',
|
||||
metric: '4 kn',
|
||||
},
|
||||
];
|
||||
try {
|
||||
initWorldOverlay(env.viewer);
|
||||
initDetection(
|
||||
env.viewer,
|
||||
[{ id: 'transit', getDetectableObjects: () => objects }],
|
||||
() => {},
|
||||
);
|
||||
setMode('DENSE');
|
||||
env.advance(250);
|
||||
env.postRender.raise();
|
||||
env.advance(250);
|
||||
env.postRender.raise();
|
||||
const painted = env.ctx.calls
|
||||
.filter(([name]) => name === 'fillText')
|
||||
.map(([, text]) => String(text));
|
||||
assert.ok(painted.includes('BAY PILOT'), `painted ${JSON.stringify(painted)}`);
|
||||
assert.ok(
|
||||
!painted.some((text) => text.includes('GOLDEN BEAR')),
|
||||
`no callout under the card; painted ${JSON.stringify(painted)}`,
|
||||
);
|
||||
} finally {
|
||||
env.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('transit contacts take the vehicle tier, not the civil-air one', () => {
|
||||
assert.equal(resolveTier({ type: 'VEH' }), 'vehicle');
|
||||
assert.equal(resolveTier({ type: 'AIR' }), 'civil');
|
||||
|
||||
@@ -740,6 +740,11 @@ test('a refused arming names the tool holding the pointer and how to leave it',
|
||||
pointerBlockedMessage('measure', 'a'),
|
||||
'measure is active — turn measure off, then set A',
|
||||
);
|
||||
// A registered owner without an exit is named by its tool, not its id.
|
||||
assert.equal(
|
||||
pointerBlockedMessage('recent-imagery-box', 'a'),
|
||||
'Recent imagery is active — turn Recent imagery off, then set A',
|
||||
);
|
||||
// Nothing to say when nothing is in the way, or when we are the holder.
|
||||
assert.equal(pointerBlockedMessage(null, 'a'), null);
|
||||
assert.equal(pointerBlockedMessage('', 'a'), null);
|
||||
|
||||
@@ -1,25 +1,45 @@
|
||||
/** Explain mapped-site availability without claiming an unobserved overload. */
|
||||
export function installationFeedback(stats = {}, now = Date.now()) {
|
||||
const reasons = {
|
||||
rate_limited: 'Overpass rate-limited',
|
||||
timeout: 'Overpass timed out',
|
||||
query_failed: 'Overpass could not complete the query',
|
||||
tiles_unavailable: 'Map tiles temporarily unavailable',
|
||||
names_unavailable: 'Mapped names temporarily unavailable',
|
||||
};
|
||||
const reason =
|
||||
reasons[stats.failureReason] || 'Overpass temporarily unavailable';
|
||||
// Portable node/browser module (the HUD summary in loadingFeedback.js reads
|
||||
// it too), so it marks its copy with a local msg() instead of importing i18n;
|
||||
// installations/controls.js translates describeInstallationFeedback().
|
||||
const msg = (text) => text;
|
||||
|
||||
const REASONS = Object.freeze({
|
||||
rate_limited: msg('Overpass rate-limited'),
|
||||
timeout: msg('Overpass timed out'),
|
||||
query_failed: msg('Overpass could not complete the query'),
|
||||
tiles_unavailable: msg('Map tiles temporarily unavailable'),
|
||||
names_unavailable: msg('Mapped names temporarily unavailable'),
|
||||
});
|
||||
const FALLBACK_REASON = msg('Overpass temporarily unavailable');
|
||||
|
||||
/**
|
||||
* Mapped-site availability as a message to render, without claiming an
|
||||
* unobserved overload. Either `{ text, reason?, seconds? }`, where `text` is
|
||||
* English (`{reason}` / `{seconds}` placeholders, `reason` English too), or
|
||||
* `{ count, km }` for a found-sites count (`km` is null for the view).
|
||||
* @param {object} [stats]
|
||||
* @param {number} [now]
|
||||
* @returns {{text: string, reason?: string, seconds?: number}|{count: number, km: ?number}}
|
||||
*/
|
||||
export function describeInstallationFeedback(stats = {}, now = Date.now()) {
|
||||
const reason = REASONS[stats.failureReason] || FALLBACK_REASON;
|
||||
if (stats.loading)
|
||||
return stats.retrying ? 'Retrying mapped sites…' : 'Fetching mapped sites…';
|
||||
return {
|
||||
text: stats.retrying
|
||||
? msg('Retrying mapped sites…')
|
||||
: msg('Fetching mapped sites…'),
|
||||
};
|
||||
if (stats.retryAt > 0) {
|
||||
const seconds = Math.max(0, Math.ceil((stats.retryAt - now) / 1000));
|
||||
return `${reason} — ${seconds ? `retrying in ${seconds}s` : 'retry pending'}`;
|
||||
return seconds
|
||||
? { text: msg('{reason} — retrying in {seconds}s'), reason, seconds }
|
||||
: { text: msg('{reason} — retry pending'), reason };
|
||||
}
|
||||
if (stats.status === 'unavailable') return reason;
|
||||
if (stats.status === 'unavailable') return { text: reason };
|
||||
if (stats.status === 'zoom-in')
|
||||
return 'Zoom in to search mapped installations';
|
||||
if (stats.stale) return 'Showing cached mapped sites';
|
||||
if (stats.status === 'idle') return 'Mapped sites not loaded';
|
||||
return { text: msg('Zoom in to search mapped installations') };
|
||||
if (stats.stale) return { text: msg('Showing cached mapped sites') };
|
||||
if (stats.status === 'idle') return { text: msg('Mapped sites not loaded') };
|
||||
// With a count, say what was found and where; an empty area is not "loaded".
|
||||
if (Number.isFinite(stats.count)) {
|
||||
const km =
|
||||
@@ -27,9 +47,21 @@ export function installationFeedback(stats = {}, now = Date.now()) {
|
||||
Number.isFinite(stats.coverage.radiusM)
|
||||
? Math.round(stats.coverage.radiusM / 1000)
|
||||
: null;
|
||||
const where = km ? ` within ${km} km of the contact` : ' in view';
|
||||
if (stats.count === 0) return `No mapped sites${where}`;
|
||||
return `${stats.count} mapped site${stats.count === 1 ? '' : 's'}${where}`;
|
||||
return { count: stats.count, km: km || null };
|
||||
}
|
||||
return 'Mapped sites loaded';
|
||||
return { text: msg('Mapped sites loaded') };
|
||||
}
|
||||
|
||||
/** Explain mapped-site availability in English. */
|
||||
export function installationFeedback(stats = {}, now = Date.now()) {
|
||||
const feedback = describeInstallationFeedback(stats, now);
|
||||
if (feedback.text !== undefined)
|
||||
return feedback.text
|
||||
.replace('{reason}', feedback.reason)
|
||||
.replace('{seconds}', feedback.seconds);
|
||||
const where = feedback.km
|
||||
? ` within ${feedback.km} km of the contact`
|
||||
: ' in view';
|
||||
if (feedback.count === 0) return `No mapped sites${where}`;
|
||||
return `${feedback.count} mapped site${feedback.count === 1 ? '' : 's'}${where}`;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* The English names the data gives a military installation that has no
|
||||
* mapped name. They stay English in data — the server provider, the AI tools
|
||||
* and the voice context read the records — and the display translates them
|
||||
* (installationTitle in src/layers/installations/classLabel.js and
|
||||
* formatAwarenessLabel in src/data/militaryAwarenessEngine.js).
|
||||
*
|
||||
* The server, tools and source modules load this file, so it marks the names
|
||||
* with a local msg() instead of importing i18n.
|
||||
*/
|
||||
const msg = (text) => text;
|
||||
|
||||
/** Name of an unnamed feature, by installation class. */
|
||||
export const UNNAMED_INSTALLATION_LABEL_BY_CLASS = Object.freeze({
|
||||
airfield: msg('Military airfield'),
|
||||
naval_base: msg('Naval base'),
|
||||
range: msg('Firing range'),
|
||||
military_land: msg('Military land'),
|
||||
});
|
||||
|
||||
/** Name of an unnamed feature without a usable class. */
|
||||
export const UNNAMED_INSTALLATION_FALLBACK = msg('Mapped installation');
|
||||
|
||||
/** Name of a military polygon the names pack has not named. */
|
||||
export const UNNAMED_MILITARY_AREA = msg('Military area');
|
||||
|
||||
/** Every name above, for the display to translate. */
|
||||
export const UNNAMED_PLACE_NAMES = Object.freeze([
|
||||
...Object.values(UNNAMED_INSTALLATION_LABEL_BY_CLASS),
|
||||
UNNAMED_INSTALLATION_FALLBACK,
|
||||
UNNAMED_MILITARY_AREA,
|
||||
]);
|
||||
@@ -1,6 +1,9 @@
|
||||
/** Pure proximity helpers for Global Context. No capability modeling. */
|
||||
|
||||
import { t } from '../i18n/index.js';
|
||||
import { UNNAMED_PLACE_NAMES } from './installationNames.js';
|
||||
|
||||
const UNNAMED_NAMES = new Set(UNNAMED_PLACE_NAMES);
|
||||
|
||||
export const AWARENESS_RADIUS_M = 250000;
|
||||
export const AWARENESS_MAX_FLIGHT_SEARCH_RADIUS_M = 16000000;
|
||||
@@ -49,7 +52,11 @@ export function formatAwarenessDistance(meters) {
|
||||
: `${(meters / 1000).toFixed(meters < 10000 ? 1 : 0)} km`;
|
||||
}
|
||||
|
||||
/** Return a stable visible label without leaking missing values into UI text. */
|
||||
/**
|
||||
* Return a stable visible label without leaking missing values into UI text.
|
||||
* The English name the data gives an unnamed installation ("Military area")
|
||||
* is shown in the UI language; mapped names and callsigns show as they are.
|
||||
*/
|
||||
export function formatAwarenessLabel(value) {
|
||||
if (value && typeof value === 'object') {
|
||||
for (const candidate of [
|
||||
@@ -65,7 +72,11 @@ export function formatAwarenessLabel(value) {
|
||||
}
|
||||
return '—';
|
||||
}
|
||||
if (typeof value === 'string') return value.trim() || '—';
|
||||
if (typeof value === 'string') {
|
||||
const text = value.trim();
|
||||
if (!text) return '—';
|
||||
return UNNAMED_NAMES.has(text) ? t(text) : text;
|
||||
}
|
||||
if (Number.isFinite(value)) return String(value);
|
||||
return '—';
|
||||
}
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
import {
|
||||
UNNAMED_INSTALLATION_FALLBACK,
|
||||
UNNAMED_INSTALLATION_LABEL_BY_CLASS,
|
||||
} from './installationNames.js';
|
||||
|
||||
/**
|
||||
* Normalize the deliberately small, allow-listed installation context returned
|
||||
* by `/api/military-installations`. These are mapped features, not assertions
|
||||
@@ -22,12 +27,17 @@ const CLASS_BY_MILITARY_TAG = {
|
||||
* nouns. The OSM id is not lost — it stays on `id` and on `sources[].id`, where
|
||||
* attribution and the details panel read it.
|
||||
*/
|
||||
const LABEL_BY_CLASS = {
|
||||
airfield: 'Military airfield',
|
||||
naval_base: 'Naval base',
|
||||
range: 'Firing range',
|
||||
military_land: 'Military land',
|
||||
};
|
||||
// The labels stay English in data (the server provider and AI tools read the
|
||||
// records too); the map card and Contacts translate them (installationNames.js).
|
||||
const LABEL_BY_CLASS = UNNAMED_INSTALLATION_LABEL_BY_CLASS;
|
||||
const UNNAMED_FALLBACK = UNNAMED_INSTALLATION_FALLBACK;
|
||||
|
||||
/** Every name humanizeInstallationClass gives a record of a class
|
||||
* CLASS_BY_MILITARY_TAG can produce, for the display to translate. */
|
||||
export const UNNAMED_INSTALLATION_NAMES = Object.freeze([
|
||||
...Object.values(LABEL_BY_CLASS),
|
||||
UNNAMED_FALLBACK,
|
||||
]);
|
||||
|
||||
const MAX_FOOTPRINT_POINTS = 400;
|
||||
|
||||
@@ -44,7 +54,7 @@ export function humanizeInstallationClass(klass) {
|
||||
const words = key.replaceAll('_', ' ').trim();
|
||||
return words
|
||||
? `${words[0].toUpperCase()}${words.slice(1)}`
|
||||
: 'Mapped installation';
|
||||
: UNNAMED_FALLBACK;
|
||||
}
|
||||
|
||||
function finiteLatitude(value) {
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* The tool name to show for a pointer owner.
|
||||
*
|
||||
* Pointer-owner ids (src/data/inputOwnership.js) are stable internal
|
||||
* identifiers — 'draw', 'recent-imagery-box' — and never belong on screen.
|
||||
* This maps each to the English name of the tool, which the caller translates
|
||||
* with translateKnown() where it is shown. Pure and stateless, so any package
|
||||
* can carry it without a second copy of the pointer claim.
|
||||
*/
|
||||
|
||||
// English marker for the i18n extractor; this module imports no i18n.
|
||||
const msg = (text) => text;
|
||||
|
||||
const POINTER_OWNER_NAMES = Object.freeze({
|
||||
draw: msg('Draw'),
|
||||
directions: msg('Directions'),
|
||||
'recent-imagery-box': msg('Recent imagery'),
|
||||
});
|
||||
|
||||
/**
|
||||
* English display name of a pointer owner. An owner without a registered name
|
||||
* falls back to its id.
|
||||
* @param {string|null} owner Pointer-owner id, as `pointerOwner()` returns it.
|
||||
* @returns {string}
|
||||
*/
|
||||
export function pointerOwnerName(owner) {
|
||||
const id = typeof owner === 'string' ? owner.trim() : '';
|
||||
if (!id) return '';
|
||||
return Object.hasOwn(POINTER_OWNER_NAMES, id) ? POINTER_OWNER_NAMES[id] : id;
|
||||
}
|
||||
@@ -19,8 +19,10 @@ import {
|
||||
import satellitesLayer from './satellites.js';
|
||||
import { createTrackedOverlayEntry } from './trackedReadout.js';
|
||||
|
||||
const L1 = '1 25544U 98067A 08264.51782528 -.00002182 00000-0 -11606-4 0 2927';
|
||||
const L2 = '2 25544 51.6416 247.4627 0006703 130.5360 325.0288 15.72125391563537';
|
||||
const L1 =
|
||||
'1 25544U 98067A 08264.51782528 -.00002182 00000-0 -11606-4 0 2927';
|
||||
const L2 =
|
||||
'2 25544 51.6416 247.4627 0006703 130.5360 325.0288 15.72125391563537';
|
||||
|
||||
test('partial and dense catalogs do not make an early false missing decision', async () => {
|
||||
_setSatelliteTrackingRefreshOutcomeForTest({
|
||||
@@ -32,31 +34,45 @@ test('partial and dense catalogs do not make an early false missing decision', a
|
||||
assert.match(partial.reason, /partial CelesTrak catalog/);
|
||||
|
||||
let releaseDense;
|
||||
const densePromise = new Promise((resolve) => { releaseDense = resolve; });
|
||||
const densePromise = new Promise((resolve) => {
|
||||
releaseDense = resolve;
|
||||
});
|
||||
_setSatelliteTrackingRefreshOutcomeForTest({
|
||||
status: 'accepted',
|
||||
catalog: 'dense',
|
||||
densePromise,
|
||||
});
|
||||
let settled = false;
|
||||
const resolution = satellitesLayer.resolveTrackingRestoreTarget(987654)
|
||||
.then((result) => { settled = true; return result; });
|
||||
const resolution = satellitesLayer
|
||||
.resolveTrackingRestoreTarget(987654)
|
||||
.then((result) => {
|
||||
settled = true;
|
||||
return result;
|
||||
});
|
||||
await Promise.resolve();
|
||||
assert.equal(settled, false, 'dense absence must wait for the dense catalog');
|
||||
releaseDense({ status: 'ready' });
|
||||
assert.equal((await resolution).status, 'missing');
|
||||
_setSatelliteTrackingRefreshOutcomeForTest({ status: 'accepted', catalog: 'core' });
|
||||
_setSatelliteTrackingRefreshOutcomeForTest({
|
||||
status: 'accepted',
|
||||
catalog: 'core',
|
||||
});
|
||||
});
|
||||
|
||||
test('satellite first update aborts every parallel catalog request', async () => {
|
||||
const realFetch = globalThis.fetch;
|
||||
const observedSignals = [];
|
||||
globalThis.fetch = (_url, options = {}) => new Promise((_resolve, reject) => {
|
||||
observedSignals.push(options.signal);
|
||||
options.signal?.addEventListener('abort', () => {
|
||||
reject(new DOMException('aborted', 'AbortError'));
|
||||
}, { once: true });
|
||||
});
|
||||
globalThis.fetch = (_url, options = {}) =>
|
||||
new Promise((_resolve, reject) => {
|
||||
observedSignals.push(options.signal);
|
||||
options.signal?.addEventListener(
|
||||
'abort',
|
||||
() => {
|
||||
reject(new DOMException('aborted', 'AbortError'));
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
});
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const work = satellitesLayer.update({}, { signal: controller.signal });
|
||||
@@ -64,7 +80,10 @@ test('satellite first update aborts every parallel catalog request', async () =>
|
||||
assert.ok(observedSignals.length > 1);
|
||||
controller.abort();
|
||||
await assert.rejects(work, { name: 'AbortError' });
|
||||
assert.equal(observedSignals.every((signal) => signal?.aborted), true);
|
||||
assert.equal(
|
||||
observedSignals.every((signal) => signal?.aborted),
|
||||
true,
|
||||
);
|
||||
} finally {
|
||||
globalThis.fetch = realFetch;
|
||||
}
|
||||
@@ -94,7 +113,9 @@ test('selected satellite params survive delayed arrival and yield to newer expli
|
||||
point,
|
||||
});
|
||||
try {
|
||||
assert.doesNotThrow(() => satellitesLayer.setParams({ selectedSatTrackingId: 25544 }));
|
||||
assert.doesNotThrow(() =>
|
||||
satellitesLayer.setParams({ selectedSatTrackingId: 25544 }),
|
||||
);
|
||||
assert.equal(satellitesLayer.getParams().selectedSatTrackingId, 25544);
|
||||
assert.equal(viewer.trackedEntity?.gevTrackedId, 'satellites:25544');
|
||||
|
||||
@@ -120,7 +141,11 @@ test('selected satellite params survive delayed arrival and yield to newer expli
|
||||
);
|
||||
assert.equal(_pendingSatelliteTrackingRestoreForTest(), 99999);
|
||||
assert.equal(satellitesLayer.trackById(25544, { origin: 'user' }), true);
|
||||
assert.equal(_pendingSatelliteTrackingRestoreForTest(), null, 'new user selection cancels stale restore');
|
||||
assert.equal(
|
||||
_pendingSatelliteTrackingRestoreForTest(),
|
||||
null,
|
||||
'new user selection cancels stale restore',
|
||||
);
|
||||
_applyPendingSatelliteTrackingRestoreForTest();
|
||||
assert.equal(satellitesLayer.getParams().selectedSatTrackingId, 25544);
|
||||
|
||||
@@ -132,7 +157,11 @@ test('selected satellite params survive delayed arrival and yield to newer expli
|
||||
);
|
||||
assert.equal(_pendingSatelliteTrackingRestoreForTest(), 25544);
|
||||
satellitesLayer.stopTracking({ origin: 'user' });
|
||||
assert.equal(_pendingSatelliteTrackingRestoreForTest(), null, 'explicit clear cancels stale restore');
|
||||
assert.equal(
|
||||
_pendingSatelliteTrackingRestoreForTest(),
|
||||
null,
|
||||
'explicit clear cancels stale restore',
|
||||
);
|
||||
_setSatelliteLabelLifecycleStateForTest({
|
||||
viewer,
|
||||
satrec: twoline2satrec(L1, L2),
|
||||
@@ -187,8 +216,8 @@ test('a tracked docked cluster consolidates its companions onto one card', () =>
|
||||
viewer,
|
||||
now: () => nowMs,
|
||||
neighbours: [
|
||||
{ noradId: 55555, name: 'PROGRESS-MS 34', point: near(120) }, // berthed
|
||||
{ noradId: 55556, name: 'SOYUZ-MS 12', point: near(-90) }, // berthed
|
||||
{ noradId: 55555, name: 'PROGRESS-MS 34', point: near(120) }, // berthed
|
||||
{ noradId: 55556, name: 'SOYUZ-MS 12', point: near(-90) }, // berthed
|
||||
{ noradId: 99999, name: 'UNRELATED SAT', point: near(50_000) }, // 50 km away
|
||||
],
|
||||
});
|
||||
@@ -203,10 +232,17 @@ test('a tracked docked cluster consolidates its companions onto one card', () =>
|
||||
const details = entity.gevLabelModel.details;
|
||||
// Class leads the detail block; the altitude line follows it, and the
|
||||
// consolidated companions stay last.
|
||||
assert.equal(details[0], 'STATION · ISS', 'the class line names what this is');
|
||||
assert.equal(
|
||||
details[0],
|
||||
'STATION · ISS',
|
||||
'the class line names what this is',
|
||||
);
|
||||
assert.match(details[1], /NORAD 25544$/, 'the altitude line is unchanged');
|
||||
assert.equal(details[2], 'DOCKED · PROGRESS-MS 34 · +1',
|
||||
'companions are consolidated, named, and counted on the tracked card');
|
||||
assert.equal(
|
||||
details[2],
|
||||
'DOCKED · PROGRESS-MS 34 · +1',
|
||||
'companions are consolidated, named, and counted on the tracked card',
|
||||
);
|
||||
|
||||
// Scoping is encoded in that string: THREE neighbours were seeded but only two
|
||||
// are counted (the named one plus "+1"). The satellite 50 km away is not part
|
||||
@@ -222,10 +258,7 @@ test('satellite pre-render refreshes the tracked altitude on each propagated fra
|
||||
camera: null,
|
||||
scene: { frameState: { frameNumber: 1 } },
|
||||
};
|
||||
const epochs = [
|
||||
Date.UTC(2008, 8, 20, 12, 30),
|
||||
Date.UTC(2008, 8, 20, 13, 0),
|
||||
];
|
||||
const epochs = [Date.UTC(2008, 8, 20, 12, 30), Date.UTC(2008, 8, 20, 13, 0)];
|
||||
let epochIndex = 0;
|
||||
_setTrackedSatelliteRefreshStateForTest({
|
||||
noradId: 25544,
|
||||
@@ -240,14 +273,18 @@ test('satellite pre-render refreshes the tracked altitude on each propagated fra
|
||||
_runSatellitePreRenderForTest();
|
||||
assert.equal(entity.gevLabelModel.title, 'ISS (ZARYA)');
|
||||
assert.equal(entity.gevLabelModel.details[0], 'STATION · ISS');
|
||||
assert.equal(entity.gevLabelModel.details[1], '353 km · NORAD 25544');
|
||||
// formatQuantity keeps the number and its unit together with a no-break space.
|
||||
assert.equal(entity.gevLabelModel.details[1], '353\u00a0km · NORAD 25544');
|
||||
|
||||
epochIndex = 1;
|
||||
viewer.scene.frameState.frameNumber = 2;
|
||||
_runSatellitePreRenderForTest();
|
||||
assert.equal(entity.gevLabelModel.details[1], '366 km · NORAD 25544');
|
||||
assert.equal(entity.gevLabelModel.details[0], 'STATION · ISS',
|
||||
'the class line survives an altitude-only republish');
|
||||
assert.equal(entity.gevLabelModel.details[1], '366\u00a0km · NORAD 25544');
|
||||
assert.equal(
|
||||
entity.gevLabelModel.details[0],
|
||||
'STATION · ISS',
|
||||
'the class line survives an altitude-only republish',
|
||||
);
|
||||
});
|
||||
|
||||
test('ISS ambient and tracked lifecycle uses cached host entries with no native labels or duplicate text', () => {
|
||||
@@ -288,34 +325,59 @@ test('ISS ambient and tracked lifecycle uses cached host entries with no native
|
||||
assert.deepEqual(ambientPublication[3], ISS_OVERLAY_SOURCE_OPTIONS);
|
||||
const ambient = ambientPublication[2][0];
|
||||
assert.equal(ambient.title, 'ISS');
|
||||
assert.equal(ambient.position(), point.position, 'ISS getter reads the existing point cache');
|
||||
assert.equal(point.label, undefined, 'ISS point primitive carries no label graphic');
|
||||
assert.equal(
|
||||
ambient.position(),
|
||||
point.position,
|
||||
'ISS getter reads the existing point cache',
|
||||
);
|
||||
assert.equal(
|
||||
point.label,
|
||||
undefined,
|
||||
'ISS point primitive carries no label graphic',
|
||||
);
|
||||
|
||||
const trackedEntity = _trackIssForTest();
|
||||
assert.ok(trackedEntity);
|
||||
assert.equal(viewer.entities.values.length, 1, 'runtime guard requires a real tracked entity');
|
||||
assert.equal(
|
||||
viewer.entities.values.length,
|
||||
1,
|
||||
'runtime guard requires a real tracked entity',
|
||||
);
|
||||
assert.equal(trackedEntity.label, undefined);
|
||||
assert.ok(trackedEntity.point, 'tracked dot remains on the entity for camera framing');
|
||||
assert.ok(
|
||||
trackedEntity.point,
|
||||
'tracked dot remains on the entity for camera framing',
|
||||
);
|
||||
const trackedEntry = createTrackedOverlayEntry(trackedEntity);
|
||||
assert.ok(trackedEntry);
|
||||
assert.equal(trackedEntry.id, 'satellites:25544');
|
||||
assert.equal(trackedEntry.protected, true);
|
||||
assert.equal(trackedEntry.paintLane, 'tracked');
|
||||
assert.equal(
|
||||
calls.filter(([type, sourceId]) => type === 'entries' && sourceId === 'satellites-iss').length,
|
||||
calls.filter(
|
||||
([type, sourceId]) =>
|
||||
type === 'entries' && sourceId === 'satellites-iss',
|
||||
).length,
|
||||
2,
|
||||
'tracking may refresh before ownership changes but must not publish a second ISS entry',
|
||||
);
|
||||
assert.deepEqual(calls.slice(-2), [
|
||||
['clear', 'satellites-iss'],
|
||||
['visible', 'satellites-iss', false],
|
||||
], 'tracked ISS suppresses ambient text so the tracked card is the only text surface');
|
||||
assert.deepEqual(
|
||||
calls.slice(-2),
|
||||
[
|
||||
['clear', 'satellites-iss'],
|
||||
['visible', 'satellites-iss', false],
|
||||
],
|
||||
'tracked ISS suppresses ambient text so the tracked card is the only text surface',
|
||||
);
|
||||
|
||||
_clearSatelliteLabelLifecycleForTest();
|
||||
cleaned = true;
|
||||
assert.equal(viewer.entities.values.length, 0);
|
||||
assert.equal(
|
||||
calls.filter(([type, sourceId]) => type === 'entries' && sourceId === 'satellites-iss').length,
|
||||
calls.filter(
|
||||
([type, sourceId]) =>
|
||||
type === 'entries' && sourceId === 'satellites-iss',
|
||||
).length,
|
||||
3,
|
||||
'untracking republishes exactly one persistent ISS ambient entry before teardown',
|
||||
);
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import * as Cesium from 'cesium';
|
||||
import { twoline2satrec } from 'satellite.js';
|
||||
import {
|
||||
_runSatellitePreRenderForTest,
|
||||
_setTrackedSatelliteRefreshStateForTest,
|
||||
_trackedFrameCartesianForTest,
|
||||
} from './satellites.js';
|
||||
import { setLocale } from '../i18n/index.js';
|
||||
|
||||
const L1 =
|
||||
'1 25544U 98067A 08264.51782528 -.00002182 00000-0 -11606-4 0 2927';
|
||||
const L2 =
|
||||
'2 25544 51.6416 247.4627 0006703 130.5360 325.0288 15.72125391563537';
|
||||
|
||||
function run(locale) {
|
||||
setLocale(locale);
|
||||
const entity = { gevLabelModel: { title: 'OLD', details: ['? km'] } };
|
||||
const point = { position: new Cesium.Cartesian3() };
|
||||
const viewer = { camera: null, scene: { frameState: { frameNumber: 1 } } };
|
||||
let nowMs = Date.UTC(2008, 8, 20, 12, 30);
|
||||
const near = (metres) => ({
|
||||
get position() {
|
||||
const tracked = _trackedFrameCartesianForTest();
|
||||
return new Cesium.Cartesian3(tracked.x + metres, tracked.y, tracked.z);
|
||||
},
|
||||
});
|
||||
_setTrackedSatelliteRefreshStateForTest({
|
||||
noradId: 25544,
|
||||
name: 'ISS (ZARYA)',
|
||||
satrec: twoline2satrec(L1, L2),
|
||||
entity,
|
||||
point,
|
||||
viewer,
|
||||
now: () => nowMs,
|
||||
neighbours: [
|
||||
{ noradId: 55555, name: 'PROGRESS-MS 34', point: near(120) },
|
||||
{ noradId: 55556, name: 'SOYUZ-MS 12', point: near(-90) },
|
||||
],
|
||||
});
|
||||
_runSatellitePreRenderForTest();
|
||||
nowMs += 2000;
|
||||
viewer.scene.frameState.frameNumber = 2;
|
||||
_runSatellitePreRenderForTest();
|
||||
const details = entity.gevLabelModel.details;
|
||||
setLocale('en');
|
||||
return details;
|
||||
}
|
||||
|
||||
test('K-32 es: tracked ISS card', () => {
|
||||
const details = run('es');
|
||||
console.log('es details:', JSON.stringify(details));
|
||||
assert.equal(details[0], 'ESTACIÓN · ISS');
|
||||
assert.doesNotMatch(
|
||||
details[2],
|
||||
/^DOCKED/,
|
||||
'companions line should be localized',
|
||||
);
|
||||
});
|
||||
|
||||
test('K-32 ru: tracked ISS card', () => {
|
||||
const details = run('ru');
|
||||
console.log('ru details:', JSON.stringify(details));
|
||||
assert.equal(details[0], 'СТАНЦИЯ · ISS');
|
||||
assert.match(details[1], /км/, 'ru glossary: km → км');
|
||||
assert.doesNotMatch(
|
||||
details[2],
|
||||
/^DOCKED/,
|
||||
'companions line should be localized',
|
||||
);
|
||||
});
|
||||
@@ -15,6 +15,8 @@ export const VESSEL_DEFAULT_LABEL_LIMIT = 900;
|
||||
export const VESSEL_OVERLAY_MAX_COHORT = VESSEL_DEFAULT_LABEL_LIMIT;
|
||||
/** Existing ambient vessel-card distance fade reaches zero at 5000 km. */
|
||||
export const VESSEL_CARD_FADE_DISTANCE_M = 5_000_000;
|
||||
/** Share of VESSEL_CARD_FADE_DISTANCE_M at which ambient cards begin to fade. */
|
||||
export const VESSEL_CARD_FADE_START_RATIO = 0.7;
|
||||
|
||||
/**
|
||||
* AIS type family → chevron hue + card accent. Single source of truth for
|
||||
@@ -187,7 +189,7 @@ export function applyVesselOverlayPolicy(
|
||||
verticalOnly: !selected,
|
||||
viewportMargin: 4,
|
||||
maxDistance: selected ? Number.POSITIVE_INFINITY : fadeDistance,
|
||||
distanceFadeStartRatio: 0.7,
|
||||
distanceFadeStartRatio: VESSEL_CARD_FADE_START_RATIO,
|
||||
edgeFade: 'keyhole',
|
||||
horizonCull: true,
|
||||
terrainOcclusion: false,
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
// K-12: a translated vessel type is never cut mid-word on the card.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import { buildVesselCard, buildSelectedVesselCard } from './aisLiveVessels.js';
|
||||
import { displayVesselType } from './vesselLabels.js';
|
||||
import { setLocale } from '../i18n/index.js';
|
||||
|
||||
const POS = new Cesium.Cartesian3(1, 2, 3);
|
||||
const rec = (type) => ({
|
||||
name: 'X',
|
||||
mmsi: '1',
|
||||
type,
|
||||
destination: '',
|
||||
speed: 5,
|
||||
course: 10,
|
||||
heading: 10,
|
||||
lastPositionUtc: '',
|
||||
missedRefreshes: 0,
|
||||
position: POS,
|
||||
billboard: null,
|
||||
});
|
||||
// Every AIS ShipType code with its own display token.
|
||||
const CODES = [
|
||||
'30',
|
||||
'31',
|
||||
'33',
|
||||
'34',
|
||||
'35',
|
||||
'36',
|
||||
'37',
|
||||
'50',
|
||||
'51',
|
||||
'52',
|
||||
'53',
|
||||
'54',
|
||||
'55',
|
||||
'58',
|
||||
'40',
|
||||
'60',
|
||||
'70',
|
||||
'80',
|
||||
'90',
|
||||
];
|
||||
|
||||
// The card label is acceptable when it is the whole translation, or when it
|
||||
// was shortened at a word boundary and marked with an ellipsis.
|
||||
function assertNotCutMidWord(label, full, where) {
|
||||
if (label === full) return;
|
||||
const head = label.replace(/(…|\.\.\.)$/, '');
|
||||
assert.notEqual(
|
||||
head,
|
||||
label,
|
||||
`${where}: "${label}" cut from "${full}" without an ellipsis`,
|
||||
);
|
||||
assert.ok(
|
||||
full.startsWith(head),
|
||||
`${where}: "${label}" is not a prefix of "${full}"`,
|
||||
);
|
||||
assert.match(
|
||||
full.slice(head.length),
|
||||
/^[\s-]/,
|
||||
`${where}: "${label}" cuts "${full}" mid-word`,
|
||||
);
|
||||
}
|
||||
|
||||
for (const locale of ['es', 'ru']) {
|
||||
test(`${locale}: vessel type on cards is never cut mid-word`, () => {
|
||||
setLocale(locale);
|
||||
try {
|
||||
for (const code of CODES) {
|
||||
const full = displayVesselType(code).toUpperCase();
|
||||
const ambient = buildVesselCard(rec(code)).details[0].split(' · ')[0];
|
||||
const selected = buildSelectedVesselCard(rec(code)).details[0].split(
|
||||
' · ',
|
||||
)[0];
|
||||
assertNotCutMidWord(ambient, full, `${locale} ${code} ambient`);
|
||||
assertNotCutMidWord(selected, full, `${locale} ${code} selected`);
|
||||
}
|
||||
} finally {
|
||||
setLocale('en');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('en: free feed text is still bounded to the card width', () => {
|
||||
setLocale('en');
|
||||
assert.equal(
|
||||
buildVesselCard(rec('Crude Oil Tanker')).details[0].split(' · ')[0],
|
||||
'CRUDE OIL TANK',
|
||||
);
|
||||
assert.equal(
|
||||
buildSelectedVesselCard(rec('54')).details[0].split(' · ')[0],
|
||||
'ANTI-POLLUTION',
|
||||
);
|
||||
});
|
||||
@@ -15,6 +15,7 @@ export class SceneDocumentError extends Error {
|
||||
super(`${path}: ${reason}`);
|
||||
this.name = 'SceneDocumentError';
|
||||
this.path = path;
|
||||
this.reason = reason;
|
||||
}
|
||||
}
|
||||
export const fail = (path, reason) => {
|
||||
|
||||
+2
-1
@@ -38,6 +38,7 @@ import {
|
||||
import { formatNumber, msg, t } from './i18n/index.js';
|
||||
import { unitLabel } from './ui/unitLabels.js';
|
||||
import { localizeLayerName } from './ui/layerPanel.js';
|
||||
import { localizePlaceName } from './ui/placeNames.js';
|
||||
|
||||
/** Color palettes keyed by shader mode; applied as CSS custom properties. */
|
||||
const HUD_COLORS = {
|
||||
@@ -157,7 +158,7 @@ const LOCALIZED_LOCALITY = Object.freeze({
|
||||
near: ({ poi, city, distKm }) =>
|
||||
t('NEAR {poi} ({city}) {distance}', {
|
||||
poi,
|
||||
city,
|
||||
city: localizePlaceName(city),
|
||||
distance: hudQuantity(Math.round(distKm), 'km'),
|
||||
}),
|
||||
sector: ({ latDeg, lonDeg }) =>
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
// K-21: an empty or blank translation must not count as done.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
checkCatalog,
|
||||
extractMessages,
|
||||
readCatalog,
|
||||
} from '../../scripts/i18n.mjs';
|
||||
import { getCatalog, setLocale, t, tn, translateKnown } from './index.js';
|
||||
|
||||
const messages = extractMessages();
|
||||
const EMPTY = 'DISCONNECT';
|
||||
const BLANK = 'CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR';
|
||||
|
||||
for (const locale of ['es', 'ru']) {
|
||||
test(`${locale}: checkCatalog rejects an empty or blank translation`, () => {
|
||||
const catalog = { ...readCatalog(locale), [EMPTY]: '', [BLANK]: ' ' };
|
||||
const { errors } = checkCatalog(locale, catalog, messages);
|
||||
assert.ok(
|
||||
errors.some((e) => e.includes(`"${EMPTY}"`)),
|
||||
'empty string passes the check',
|
||||
);
|
||||
assert.ok(
|
||||
errors.some((e) => e.includes(`"${BLANK}"`)),
|
||||
'blank string passes the check',
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
test('t() and translateKnown() fall back to English for an empty entry', () => {
|
||||
const es = getCatalog('es');
|
||||
const saved = es[EMPTY];
|
||||
es[EMPTY] = '';
|
||||
setLocale('es');
|
||||
try {
|
||||
assert.equal(t(EMPTY), EMPTY);
|
||||
assert.equal(translateKnown(EMPTY), EMPTY);
|
||||
} finally {
|
||||
es[EMPTY] = saved;
|
||||
setLocale('en');
|
||||
}
|
||||
});
|
||||
|
||||
test('tn() skips an empty plural form', () => {
|
||||
const es = getCatalog('es');
|
||||
const KEY = '{count} INPUTS UNKNOWN';
|
||||
const saved = es[KEY];
|
||||
setLocale('es');
|
||||
try {
|
||||
es[KEY] = { ...saved, one: ' ' };
|
||||
assert.equal(
|
||||
tn(1, '{count} INPUT UNKNOWN', KEY),
|
||||
saved.other.replace('{count}', '1'),
|
||||
);
|
||||
es[KEY] = { one: '', many: '', other: '' };
|
||||
assert.equal(tn(1, '{count} INPUT UNKNOWN', KEY), '1 INPUT UNKNOWN');
|
||||
assert.equal(tn(3, '{count} INPUT UNKNOWN', KEY), '3 INPUTS UNKNOWN');
|
||||
} finally {
|
||||
es[KEY] = saved;
|
||||
setLocale('en');
|
||||
}
|
||||
});
|
||||
+46
-8
@@ -9,7 +9,7 @@
|
||||
//
|
||||
// English needs no catalog. Every other locale is one flat JSON file,
|
||||
// locales/<code>.json, keyed by the English text (for tn, by the English
|
||||
// plural form). A missing entry falls back to English.
|
||||
// plural form). A missing or empty entry falls back to English.
|
||||
// `npm run i18n` keeps the catalogs in step with the code. Guide: docs/I18N.md.
|
||||
import {
|
||||
DEFAULT_LOCALE,
|
||||
@@ -66,10 +66,14 @@ function interpolate(pattern, values) {
|
||||
);
|
||||
}
|
||||
|
||||
/** A catalog string with something to show; an empty one counts as missing. */
|
||||
const filled = (value) => typeof value === 'string' && value.trim() !== '';
|
||||
|
||||
function lookup(text) {
|
||||
const catalog = CATALOGS[currentLocale];
|
||||
if (!catalog) return undefined;
|
||||
if (Object.hasOwn(catalog, text)) return catalog[text];
|
||||
const entry = Object.hasOwn(catalog, text) ? catalog[text] : undefined;
|
||||
if (filled(entry) || (entry && typeof entry === 'object')) return entry;
|
||||
warnMissing(text);
|
||||
return undefined;
|
||||
}
|
||||
@@ -101,8 +105,9 @@ export function tn(count, one, other, values) {
|
||||
const entry = lookup(other);
|
||||
const category = pluralRules(currentLocale).select(count);
|
||||
let pattern;
|
||||
if (entry && typeof entry === 'object') {
|
||||
pattern = entry[category] ?? entry.other ?? other;
|
||||
const forms = entry && typeof entry === 'object' ? entry : null;
|
||||
if (forms && (filled(forms[category]) || filled(forms.other))) {
|
||||
pattern = filled(forms[category]) ? forms[category] : forms.other;
|
||||
} else {
|
||||
pattern = pluralRules(DEFAULT_LOCALE).select(count) === 'one' ? one : other;
|
||||
}
|
||||
@@ -117,8 +122,8 @@ export function tn(count, one, other, values) {
|
||||
export function translateKnown(text) {
|
||||
const catalog = CATALOGS[currentLocale];
|
||||
if (!catalog || typeof text !== 'string') return text;
|
||||
const entry = catalog[text];
|
||||
return typeof entry === 'string' ? entry : text;
|
||||
const entry = Object.hasOwn(catalog, text) ? catalog[text] : undefined;
|
||||
return filled(entry) ? entry : text;
|
||||
}
|
||||
|
||||
/** Mark English text that is stored in data and translated later by t(). */
|
||||
@@ -247,10 +252,43 @@ export function applyDocumentTranslations(doc) {
|
||||
return applied;
|
||||
}
|
||||
|
||||
const unsavedWorkChecks = new Set();
|
||||
|
||||
/**
|
||||
* Name work that switching language would lose: the switch reloads the page,
|
||||
* and the language switch asks first while any check reports something.
|
||||
* @param {() => string|null|undefined} check Returns a msg() label (e.g.
|
||||
* msg('unsaved CCTV calibration')) while there is such work, else nothing.
|
||||
* @returns {() => void} Removes the check.
|
||||
*/
|
||||
export function registerUnsavedWork(check) {
|
||||
if (typeof check !== 'function') return () => {};
|
||||
unsavedWorkChecks.add(check);
|
||||
return () => unsavedWorkChecks.delete(check);
|
||||
}
|
||||
|
||||
/**
|
||||
* What a reload would lose right now, translated, without repeats.
|
||||
* @returns {string[]}
|
||||
*/
|
||||
export function describeUnsavedWork() {
|
||||
const labels = new Set();
|
||||
for (const check of unsavedWorkChecks) {
|
||||
try {
|
||||
const label = check();
|
||||
if (typeof label === 'string' && label) labels.add(t(label));
|
||||
} catch {
|
||||
// A failing check must not block the switch.
|
||||
}
|
||||
}
|
||||
return [...labels];
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist a locale choice, then reload preserving window.location.hash exactly
|
||||
* and stripping ?lang from the search string, so the override is one-shot.
|
||||
* Where storage is blocked, ?lang carries the choice instead.
|
||||
* and stripping ?lang from the search string, so the stored choice applies.
|
||||
* Where storage is blocked, ?lang carries the choice instead (share links
|
||||
* drop it: ShareLinkManager.copyLink).
|
||||
* @returns {boolean} true when a reload was triggered.
|
||||
*/
|
||||
export function persistLocaleAndReload(
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
// K-194: the KartaView provider's failure and state texts are English data
|
||||
// (msg() in kartaview/{policy,source,viewer,index}.js) that the Street Level
|
||||
// panel and the chip tooltip show through translateKnown, so every one needs
|
||||
// an entry in each language's catalog.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { setLocale, translateKnown } from './index.js';
|
||||
|
||||
const MESSAGES = [
|
||||
// kartaview/policy.js
|
||||
'KartaView is rate-limiting requests — they resume on their own',
|
||||
'KartaView’s photos here are archived and cannot be shown',
|
||||
'KartaView did not filter this area in time — move the map to ask again',
|
||||
'None of KartaView’s photos here pass the imagery filter',
|
||||
// kartaview/source.js
|
||||
'KartaView did not answer in time — try again',
|
||||
'KartaView could not be reached — check the network',
|
||||
'KartaView sent an answer that could not be read',
|
||||
'KartaView is rate-limiting requests',
|
||||
'KartaView is rate-limiting requests — try again in a moment',
|
||||
'KartaView sent too much data',
|
||||
'KartaView has no such photo',
|
||||
// kartaview/viewer.js, index.js
|
||||
'The KartaView photo could not be loaded',
|
||||
'KartaView has no position for this photo',
|
||||
];
|
||||
|
||||
for (const locale of ['es', 'ru']) {
|
||||
test(`KartaView provider messages are translated in ${locale}`, () => {
|
||||
setLocale(locale);
|
||||
try {
|
||||
const untranslated = MESSAGES.filter((m) => translateKnown(m) === m);
|
||||
assert.deepEqual(untranslated, []);
|
||||
} finally {
|
||||
setLocale('en');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// kartaview/source.js refusals with a number in them: English data, shown
|
||||
// through placeholder entries by the Street Level panel and the layer panel.
|
||||
test('KartaView HTTP and refusal codes keep their number in every language', async () => {
|
||||
const { translateStreetLevelError } =
|
||||
await import('../ui/streetLevelPresentation.js');
|
||||
const http = 'KartaView answered HTTP 503';
|
||||
const refused = 'KartaView refused the request (code 610)';
|
||||
assert.equal(translateStreetLevelError(http), http);
|
||||
assert.equal(translateStreetLevelError(refused), refused);
|
||||
for (const locale of ['es', 'ru']) {
|
||||
setLocale(locale);
|
||||
try {
|
||||
const shownHttp = translateStreetLevelError(http);
|
||||
const shownRefused = translateStreetLevelError(refused);
|
||||
assert.notEqual(shownHttp, http);
|
||||
assert.match(shownHttp, /503/);
|
||||
assert.notEqual(shownRefused, refused);
|
||||
assert.match(shownRefused, /610/);
|
||||
} finally {
|
||||
setLocale('en');
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { setLocale, translateKnown } from './index.js';
|
||||
|
||||
// Status and error texts the layers write straight into their state
|
||||
// (loadingLabel / _error / street.error) as English data; the layer panel and
|
||||
// the Street Level panel show them through translateKnown, so each needs a
|
||||
// catalog entry in every language (K-36).
|
||||
const SITES = [
|
||||
['../layers/streetLevel/index.js', 'loading coverage...'],
|
||||
['../layers/firms/ingestion.js', 'live feed unavailable'],
|
||||
['../layers/bikeshare/viewport.js', 'GBFS fetch error'],
|
||||
['../layers/bikeshare/ingestion.js', 'GBFS status update failed'],
|
||||
[
|
||||
'../layers/streetLevel/viewerHost.js',
|
||||
'Open the Street Level panel to view imagery',
|
||||
],
|
||||
['../layers/streetLevel/viewerHost.js', 'Image could not be opened'],
|
||||
['../layers/satellites/ingestion.js', 'CelesTrak unreachable'],
|
||||
[
|
||||
'../layers/streetLevel/providers/mapillary/policy.js',
|
||||
'Coverage requests are being rate-limited — they resume on their own',
|
||||
],
|
||||
['../layers/alpr/index.js', 'Camera source temporarily unavailable'],
|
||||
['../layers/perimeters/index.js', 'Perimeter source unavailable'],
|
||||
['../layers/recentImagery/index.js', 'Imagery catalog unavailable'],
|
||||
['../layers/installations/ingestion.js', 'Installation context unavailable'],
|
||||
['../layers/earthquakes/index.js', 'Earthquake source unavailable'],
|
||||
['../layers/submarineCables/ingestion.js', 'TeleGeography load failed'],
|
||||
['../layers/vessels/ingestion.js', 'AIS live load failed'],
|
||||
['../layers/vessels/lifecycle.js', 'AIS live load failed'],
|
||||
['../layers/wind/index.js', 'Wind unavailable'],
|
||||
['../layers/wind/source.js', 'Wind request timed out'],
|
||||
['../layers/wind/source.js', 'Malformed wind manifest'],
|
||||
['../layers/wind/source.js', 'Malformed wind grid'],
|
||||
['../layers/wind/source.js', 'Temperature field unavailable'],
|
||||
['../layers/wind/source.js', 'Mean sea level pressure field unavailable'],
|
||||
];
|
||||
|
||||
// A fallback that reaches the panel must be marked, or `npm run i18n` cannot
|
||||
// see it and the catalog entry would be pruned as unused.
|
||||
for (const [file, text] of SITES) {
|
||||
test(`"${text}" in ${file} is marked`, () => {
|
||||
const source = readFileSync(new URL(file, import.meta.url), 'utf8');
|
||||
assert.ok(
|
||||
new RegExp(
|
||||
String.raw`\b(?:msg|t)\(\s*'${text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}'`,
|
||||
).test(source),
|
||||
`${text} is not marked in ${file}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
for (const locale of ['es', 'ru']) {
|
||||
for (const [file, text] of SITES) {
|
||||
test(`${locale}: "${text}" from ${file} is translated`, () => {
|
||||
const source = readFileSync(new URL(file, import.meta.url), 'utf8');
|
||||
assert.ok(source.includes(`'${text}'`), `literal still in ${file}`);
|
||||
setLocale(locale);
|
||||
try {
|
||||
assert.notEqual(translateKnown(text), text);
|
||||
} finally {
|
||||
setLocale('en');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
+558
-5
@@ -10,7 +10,7 @@
|
||||
"LOAD FAILED": "CARGA FALLIDA",
|
||||
"LOAD CANCELLED": "CARGA CANCELADA",
|
||||
"syncing road network": "sincronizando la red vial",
|
||||
"retrying in {seconds}s": "reintentando en {seconds} s",
|
||||
"retrying in {seconds}s": "reintento en {seconds} s",
|
||||
"retry pending": "reintento pendiente",
|
||||
"Clear selected data layers": "Limpiar las capas de datos seleccionadas",
|
||||
"Turn off all selected data layers": "Desactivar todas las capas de datos seleccionadas",
|
||||
@@ -812,7 +812,7 @@
|
||||
"Check microphone permission and network access, then try again.": "Revisa el permiso del micrófono y el acceso a la red, y vuelve a intentarlo.",
|
||||
"AI AGENT": "AGENTE DE IA",
|
||||
"VOICE CONTROL": "CONTROL DE VOZ",
|
||||
"{tier} applies next session": "{tier} se aplica en la próxima sesión",
|
||||
"{tier} applies next session": "{tier} desde la próxima sesión",
|
||||
"Voice model tier — applies next session": "Nivel del modelo de voz — se aplica en la próxima sesión",
|
||||
"Estimated session cost": "Costo estimado de la sesión",
|
||||
"Voice control — activate to toggle voice; hold Space to speak": "Control de voz — púlsalo para activar o desactivar la voz; mantén pulsado Space para hablar",
|
||||
@@ -989,7 +989,7 @@
|
||||
"Coverage colours": "Colores de cobertura",
|
||||
"That shape already has {count} points — finish it or press Backspace.": "Esa forma ya tiene {count} puntos — termínala o pulsa Backspace.",
|
||||
"Could not place the shape: {error}": "No se pudo colocar la forma: {error}",
|
||||
"{owner} is using the pointer — close it first.": "{owner} está usando el puntero — ciérralo primero.",
|
||||
"{owner} is using the pointer — close it first.": "Puntero en uso: {owner}. Ciérralo primero.",
|
||||
"{label} · Detailed outline unavailable": "{label} · Contorno detallado no disponible",
|
||||
"Witness source": "Fuente de testigo",
|
||||
"{provider} · EMBEDDED SOURCE": "{provider} · FUENTE INSERTADA",
|
||||
@@ -1758,7 +1758,7 @@
|
||||
"Mark {targets} +{more}": "Marcar {targets} +{more}",
|
||||
"Mark {targets}": "Marcar: {targets}",
|
||||
"Fly to {place}": "Volar a {place}",
|
||||
"Nearest aircraft to {place}": "Aeronaves más cercanas a {place}",
|
||||
"Nearest aircraft to {place}": "Aeronave más cercana a {place}",
|
||||
"Read the view": "Consultar la vista",
|
||||
"Check view state": "Comprobar el estado de la vista",
|
||||
"Frame {target}": "Encuadrar {target}",
|
||||
@@ -2208,5 +2208,558 @@
|
||||
"Show your own GeoJSON, KML, KMZ or GPX files on the globe (or drop them onto it). File bytes stay on this device; overlays can appear in map screenshots shared with voice.": "Muestra en el globo tus propios archivos GeoJSON, KML, KMZ o GPX (o suéltalos sobre él). Los archivos no salen de este dispositivo; las capas pueden aparecer en capturas del mapa compartidas con la voz.",
|
||||
"Import": "Importar",
|
||||
"Choose GeoJSON, KML, KMZ or GPX files": "Elige archivos GeoJSON, KML, KMZ o GPX",
|
||||
"Imported files": "Archivos importados"
|
||||
"Imported files": "Archivos importados",
|
||||
"DOCKED · {name}": "ACOPLE · {name}",
|
||||
"DOCKED · {name} · +{extra}": "ACOPLE · {name} · +{extra}",
|
||||
"loading coverage...": "cargando cobertura...",
|
||||
"live feed unavailable": "fuente en vivo no disponible",
|
||||
"GBFS fetch error": "error al obtener datos GBFS",
|
||||
"GBFS status update failed": "falló la actualización del estado GBFS",
|
||||
"Open the Street Level panel to view imagery": "Abre el panel \"A pie de calle\" para ver las imágenes",
|
||||
"Image could not be opened": "No se pudo abrir la imagen",
|
||||
"Mapillary rejected MAPILLARY_CLIENT_TOKEN — replace it in Provider Settings": "Mapillary rechazó MAPILLARY_CLIENT_TOKEN — sustitúyelo en Ajustes de proveedores",
|
||||
"Coverage requests are being rate-limited — they resume on their own": "Las peticiones de cobertura están limitadas por frecuencia — se reanudan solas",
|
||||
"No street-level imagery within {radius} m of that point": "No hay imágenes a pie de calle a menos de {radius} m de ese punto",
|
||||
"No street-level imagery within {radius} m of the view centre": "No hay imágenes a pie de calle a menos de {radius} m del centro de la vista",
|
||||
"Aim the middle of the view at a street to look for imagery there": "Apunta el centro de la vista a una calle para buscar imágenes allí",
|
||||
"Street View has no imagery for this place": "Street View no tiene imágenes de este lugar",
|
||||
"GOOGLE_MAPS_API_KEY is not set": "GOOGLE_MAPS_API_KEY no está configurada",
|
||||
"The Google Maps JavaScript API could not load": "No se pudo cargar la API de Maps JavaScript de Google",
|
||||
"Weather request timed out": "La petición del clima agotó el tiempo de espera",
|
||||
"Malformed weather manifest": "Manifiesto del clima mal formado",
|
||||
"Weather HTTP {status}": "Clima: HTTP {status}",
|
||||
"Weather image HTTP {status}": "Imagen del clima: HTTP {status}",
|
||||
"Weather prefetch unavailable": "Precarga del clima no disponible",
|
||||
"Infrared decode failed": "Falló la decodificación del infrarrojo",
|
||||
"rate limited": "límite de peticiones superado",
|
||||
"routing service is rate limited": "el servicio de rutas superó el límite de peticiones",
|
||||
"route leg too long": "tramo de la ruta demasiado largo",
|
||||
"route too long": "ruta demasiado larga",
|
||||
"Routing is rate limited — try again in a moment": "Se alcanzó el límite de peticiones de rutas — inténtalo de nuevo en un momento",
|
||||
"{error} — try again in a moment": "{error} — inténtalo de nuevo en un momento",
|
||||
"Routing unavailable (HTTP {status})": "Rutas no disponibles (HTTP {status})",
|
||||
"CelesTrak unreachable": "CelesTrak inaccesible",
|
||||
"KartaView is rate-limiting requests — they resume on their own": "KartaView está limitando las peticiones — se reanudan solas",
|
||||
"KartaView’s photos here are archived and cannot be shown": "Las fotos de KartaView de aquí están archivadas y no se pueden mostrar",
|
||||
"KartaView did not filter this area in time — move the map to ask again": "KartaView no filtró esta zona a tiempo — mueve el mapa para pedirlo de nuevo",
|
||||
"None of KartaView’s photos here pass the imagery filter": "Ninguna foto de KartaView de aquí pasa el filtro de imágenes",
|
||||
"KartaView did not answer in time — try again": "KartaView no respondió a tiempo — inténtalo de nuevo",
|
||||
"KartaView could not be reached — check the network": "No se pudo conectar con KartaView — comprueba la red",
|
||||
"KartaView sent an answer that could not be read": "KartaView envió una respuesta que no se pudo leer",
|
||||
"KartaView is rate-limiting requests": "KartaView está limitando las peticiones",
|
||||
"KartaView is rate-limiting requests — try again in a moment": "KartaView está limitando las peticiones — inténtalo de nuevo en un momento",
|
||||
"KartaView sent too much data": "KartaView envió demasiados datos",
|
||||
"KartaView has no such photo": "KartaView no tiene esa foto",
|
||||
"The KartaView photo could not be loaded": "No se pudo cargar la foto de KartaView",
|
||||
"KartaView has no position for this photo": "KartaView no tiene la posición de esta foto",
|
||||
"Map tiles temporarily unavailable": "Teselas del mapa temporalmente no disponibles",
|
||||
"Mapped names temporarily unavailable": "Nombres mapeados temporalmente no disponibles",
|
||||
"No mapped sites in view": "No hay sitios mapeados a la vista",
|
||||
"No mapped sites within {km} km of the contact": "No hay sitios mapeados a menos de {km} km del contacto",
|
||||
"{count} mapped sites in view": {
|
||||
"one": "{count} sitio mapeado a la vista",
|
||||
"many": "{count} de sitios mapeados a la vista",
|
||||
"other": "{count} sitios mapeados a la vista"
|
||||
},
|
||||
"{count} mapped sites within {km} km of the contact": {
|
||||
"one": "{count} sitio mapeado a menos de {km} km del contacto",
|
||||
"many": "{count} de sitios mapeados a menos de {km} km del contacto",
|
||||
"other": "{count} sitios mapeados a menos de {km} km del contacto"
|
||||
},
|
||||
"Too many mapped sites in view to list them all": "Hay demasiados sitios mapeados a la vista para listarlos todos",
|
||||
"Installation context unavailable": "Contexto de instalaciones no disponible",
|
||||
"Focus vessel {name}, MMSI {mmsi}": "Enfocar el buque {name}, MMSI {mmsi}",
|
||||
"Open the USGS initial report for the 26 August 2026 Nepal landslide": "Abrir el informe inicial del USGS sobre el deslizamiento del 26 de agosto de 2026 en Nepal",
|
||||
"YouTube source": "Fuente de YouTube",
|
||||
"Facebook source": "Fuente de Facebook",
|
||||
"Military airfield": "Aeródromo militar",
|
||||
"Naval base": "Base naval",
|
||||
"Firing range": "Campo de tiro",
|
||||
"Military land": "Terreno militar",
|
||||
"Mapped installation": "Instalación mapeada",
|
||||
"Military area": "Zona militar",
|
||||
"SIMULATED — {reason}": "SIMULADO — {reason}",
|
||||
"TomTom key unavailable": "Clave de TomTom no disponible",
|
||||
"TomTom daily budget reached": "Se alcanzó el límite diario de TomTom",
|
||||
"TomTom upstream unreachable": "Servidor de TomTom inaccesible",
|
||||
"TomTom flow unavailable": "Flujo de TomTom no disponible",
|
||||
"API key rejected — check AISSTREAM_API_KEY": "Clave de API rechazada — revisa AISSTREAM_API_KEY",
|
||||
"feed silent {seconds}s — no AIS data": "fuente en silencio {seconds} s — sin datos AIS",
|
||||
"feed silent — no AIS data": "fuente en silencio — sin datos AIS",
|
||||
"feed down — retrying slowly (attempt {attempt})": "fuente caída — reintentando despacio (intento {attempt})",
|
||||
"feed down — retrying slowly": "fuente caída — reintentando despacio",
|
||||
"reconnecting to feed… (attempt {attempt})": "reconectando con la fuente… (intento {attempt})",
|
||||
"reconnecting to feed…": "reconectando con la fuente…",
|
||||
"AISSTREAM_API_KEY not set": "AISSTREAM_API_KEY no está configurada",
|
||||
"live feed unsupported": "fuente en vivo no compatible",
|
||||
"connecting to feed…": "conectando con la fuente…",
|
||||
"feed disconnected": "fuente desconectada",
|
||||
"feed down": "fuente caída",
|
||||
"feed idle": "fuente inactiva",
|
||||
"AIS live load failed": "Falló la carga de AIS en vivo",
|
||||
"age|{count}m": "{count} min",
|
||||
"{acres} ac": {
|
||||
"one": "{acres} acre",
|
||||
"many": "{acres} de acres",
|
||||
"other": "{acres} acres"
|
||||
},
|
||||
"{percent}% contained": "contenido al {percent}%",
|
||||
"containment unknown": "contención desconocida",
|
||||
"{cause} cause": "causa: {cause}",
|
||||
"{personnel} personnel": {
|
||||
"one": "{personnel} efectivo",
|
||||
"many": "{personnel} de efectivos",
|
||||
"other": "{personnel} efectivos"
|
||||
},
|
||||
"{county} County": "condado de {county}",
|
||||
"{cost} to date": "{cost} hasta la fecha",
|
||||
"discovered {age} ago": "detectado hace {age}",
|
||||
"updated {age} ago": "actualizado hace {age}",
|
||||
"part of {complex}": "parte de {complex}",
|
||||
"InciWeb ↗ · click card to open": "InciWeb ↗ · haz clic en la tarjeta para abrir",
|
||||
"FIRE · {name}": "INCENDIO · {name}",
|
||||
"Unnamed incident": "Sin nombre",
|
||||
"Open {title} on InciWeb": "Abrir {title} en InciWeb",
|
||||
"Vehicle {vehicle}": "Vehículo {vehicle}",
|
||||
"Bus": "Autobús",
|
||||
"Tram": "Tranvía",
|
||||
"Subway": "Metro",
|
||||
"Train": "Tren",
|
||||
"Ferry": "Ferri",
|
||||
"Transit vehicle": "Vehículo de transporte público",
|
||||
"No recent fix": "Sin posición reciente",
|
||||
"Waiting for update": "Esperando actualización",
|
||||
"Moving": "En movimiento",
|
||||
"reported {speed} km/h": "velocidad notificada: {speed} km/h",
|
||||
"reported hdg {heading}°": "rumbo notificado: {heading}°",
|
||||
"Last report: stopped at stop {stop}": "Último informe: detenido en la parada {stop}",
|
||||
"Last report: arriving at stop {stop}": "Último informe: llegando a la parada {stop}",
|
||||
"Last report: next stop {stop}": "Último informe: próxima parada {stop}",
|
||||
"occupancy|empty": "vacío",
|
||||
"occupancy|many seats available": "muchos asientos libres",
|
||||
"occupancy|few seats available": "pocos asientos libres",
|
||||
"occupancy|standing room only": "solo de pie",
|
||||
"occupancy|crushed standing room only": "abarrotado, solo de pie",
|
||||
"occupancy|full": "lleno",
|
||||
"occupancy|not accepting passengers": "no admite pasajeros",
|
||||
"occupancy|not boardable": "no se puede subir",
|
||||
"Shown at street level · depth not in the feed": "Se muestra a nivel de calle · la fuente no indica la profundidad",
|
||||
"Reported {seconds} s ago": "Notificado hace {seconds} s",
|
||||
"Reported {minutes} min ago": "Notificado hace {minutes} min",
|
||||
"{reported} (feed time)": "{reported} (hora de la fuente)",
|
||||
"shown {seconds} s behind": "se muestra con {seconds} s de retraso",
|
||||
"Available trail: {minutes} min": "Rastro disponible: {minutes} min",
|
||||
"Available trail: {minutes} min (limited)": "Rastro disponible: {minutes} min (limitado)",
|
||||
"UNAVAILABLE · {source} · Roads unavailable": "NO DISPONIBLE · {source} · Vías no disponibles",
|
||||
"Roads: {source}": "Vías: {source}",
|
||||
"TomTom roads unavailable while the traffic service is unreachable": "Vías de TomTom no disponibles mientras el servicio de tráfico esté inaccesible",
|
||||
"Hybrid unavailable while the traffic service is unreachable": "Modo híbrido no disponible mientras el servicio de tráfico esté inaccesible",
|
||||
"TomTom roads need a TomTom key": "Las vías de TomTom necesitan una clave de TomTom",
|
||||
"Hybrid needs a TomTom key": "El modo híbrido necesita una clave de TomTom",
|
||||
"Local surface still loading": "La superficie bajo las vías aún se está cargando",
|
||||
"Partial coverage": "Cobertura parcial",
|
||||
"Detailed roads unavailable": "Vías detalladas no disponibles",
|
||||
"Reduced detail coverage": "Cobertura con menos detalle",
|
||||
"OpenFreeMap tiles rate-limited": "Teselas de OpenFreeMap con límite de peticiones",
|
||||
"OpenFreeMap tiles timed out": "Las teselas de OpenFreeMap agotaron el tiempo de espera",
|
||||
"OpenFreeMap tiles unavailable": "Teselas de OpenFreeMap no disponibles",
|
||||
"Heard by your receiver": "Captado por tu receptor",
|
||||
"browser SDR": "SDR del navegador",
|
||||
"decoder feed": "fuente del decodificador",
|
||||
"Your RTL-SDR receiver and decoder feed": "Tu receptor RTL-SDR y la fuente del decodificador",
|
||||
"Your decoder feed": "Tu fuente del decodificador",
|
||||
"Your RTL-SDR receiver": "Tu receptor RTL-SDR",
|
||||
"NO CALLSIGN": "SIN INDICATIVO",
|
||||
"ALT {altitude} FT · GS {speed} KT · TRK {track}": "ALT {altitude} FT · VEL. SUELO {speed} KN · TRK {track}",
|
||||
"V/S {rate}": "VEL. VERT. {rate}",
|
||||
"V/S {rate} FPM": "VEL. VERT. {rate} FT/MIN",
|
||||
"POSITION {age}": "POSICIÓN {age}",
|
||||
"POSITION {seconds} S AGO": "POSICIÓN HACE {seconds} S",
|
||||
"{count} MSGS": {
|
||||
"one": "{count} MENSAJE",
|
||||
"many": "{count} DE MENSAJES",
|
||||
"other": "{count} MENSAJES"
|
||||
},
|
||||
"installation class|BARRACKS": "CUARTEL",
|
||||
"installation class|TRAINING AREA": "ZONA DE INSTRUCCIÓN",
|
||||
"Seeking up…": "Buscando hacia arriba…",
|
||||
"Seeking down…": "Buscando hacia abajo…",
|
||||
"Scanning {mhz} MHz…": "Escaneando {mhz} MHz…",
|
||||
"Found {mhz} MHz · {snr} dB": "Encontrado {mhz} MHz · {snr} dB",
|
||||
"Broadcaster stream is unavailable or blocked by the browser.": "El flujo de la emisora no está disponible o el navegador lo ha bloqueado.",
|
||||
"Playback requires a direct click or tap.": "La reproducción requiere un clic o un toque directo.",
|
||||
"Broadcaster stream could not be started.": "No se pudo iniciar el flujo de la emisora.",
|
||||
"Directory refresh degraded; showing the previous station catalog.": "La actualización del directorio fue parcial; se muestra el catálogo de emisoras anterior.",
|
||||
"Radio directory coverage is degraded.": "El directorio de radio funciona con interrupciones.",
|
||||
"Directory refresh failed; showing the previous station catalog.": "La actualización del directorio falló; se muestra el catálogo de emisoras anterior.",
|
||||
"Radio directory is temporarily unavailable.": "El directorio de radio no está disponible temporalmente.",
|
||||
"Decoder feeds: {feeds}": "Fuentes del decodificador: {feeds}",
|
||||
" · read while Local ADS-B is on": " · se leen mientras ADS-B local está activo",
|
||||
"idle": "en espera",
|
||||
"running": "activo",
|
||||
"suspended": "suspendido",
|
||||
"closed": "cerrado",
|
||||
"Local FM stopped: internet radio started": "FM local detenida: se inició la radio por Internet",
|
||||
"ALPR cameras": "cámaras ALPR",
|
||||
"OVERPASS RATE-LIMITED": "OVERPASS CON LÍMITE DE PETICIONES",
|
||||
"OVERPASS TIMED OUT": "OVERPASS AGOTÓ EL TIEMPO DE ESPERA",
|
||||
"OVERPASS COULD NOT COMPLETE THE QUERY": "OVERPASS NO PUDO COMPLETAR LA CONSULTA",
|
||||
"MAP TILES TEMPORARILY UNAVAILABLE": "TESELAS DEL MAPA TEMPORALMENTE NO DISPONIBLES",
|
||||
"MAPPED NAMES TEMPORARILY UNAVAILABLE": "NOMBRES MAPEADOS TEMPORALMENTE NO DISPONIBLES",
|
||||
"Shared flight follow expired": "El seguimiento del vuelo compartido ha caducado",
|
||||
"Shared military flight follow expired": "El seguimiento del vuelo militar compartido ha caducado",
|
||||
"Shared satellite follow expired": "El seguimiento del satélite compartido ha caducado",
|
||||
"Shared flight could not be restored — feed unavailable": "No se pudo restaurar el vuelo compartido — fuente no disponible",
|
||||
"Shared military flight could not be restored — feed unavailable": "No se pudo restaurar el vuelo militar compartido — fuente no disponible",
|
||||
"Shared satellite could not be restored — feed unavailable": "No se pudo restaurar el satélite compartido — fuente no disponible",
|
||||
"Shared flight is unavailable": "El vuelo compartido no está disponible",
|
||||
"Shared military flight is unavailable": "El vuelo militar compartido no está disponible",
|
||||
"Shared satellite is unavailable": "El satélite compartido no está disponible",
|
||||
"Scene not saved — browser storage unavailable": "Escena no guardada — almacenamiento del navegador no disponible",
|
||||
"Scene not saved — existing saved project could not be read. Export edits or import a valid file.": "Escena no guardada — no se pudo leer el proyecto guardado. Exporta los cambios o importa un archivo válido.",
|
||||
"Saved project could not be read; storage preserved. Import a valid file to resume saving.": "No se pudo leer el proyecto guardado; el almacenamiento se conserva. Importa un archivo válido para volver a guardar.",
|
||||
"Scene not saved — {reason}": "Escena no guardada — {reason}",
|
||||
"Stopped": "Detenido",
|
||||
"Stopped (Esc)": "Detenido (Esc)",
|
||||
"Camera ownership changed": "Cambió el control de la cámara",
|
||||
"Scene layer turned off": "Capa de la escena desactivada",
|
||||
"Seeking scene clock": "Desplazando el reloj de la escena",
|
||||
"Importing project": "Importando proyecto",
|
||||
"Camera move interrupted": "Movimiento de cámara interrumpido",
|
||||
"Scene media timed out": "El contenido multimedia de la escena agotó el tiempo de espera",
|
||||
"Space Missions isolates replay data. Exit the mode to enable {layer}.": "El modo Misiones espaciales aísla los datos de repetición. Sal del modo para activar {layer}.",
|
||||
"Tilted view": "Vista inclinada",
|
||||
"Straight-down view": "Vista cenital",
|
||||
"North up": "Norte arriba",
|
||||
"Exit cockpit to fly to a camera": "Sal de la cabina para volar a una cámara",
|
||||
"Exit cockpit to fly to a location": "Sal de la cabina para volar a una ubicación",
|
||||
"Exit cockpit to fly to a photo": "Sal de la cabina para volar a una foto",
|
||||
"Exit cockpit to fly to a shared view": "Sal de la cabina para volar a una vista compartida",
|
||||
"Exit cockpit to fly to a target": "Sal de la cabina para volar a un objetivo",
|
||||
"Exit cockpit to fly to a vessel": "Sal de la cabina para volar a un buque",
|
||||
"Exit cockpit to fly to a fire": "Sal de la cabina para volar a un incendio",
|
||||
"Exit cockpit to fly to a route": "Sal de la cabina para volar a una ruta",
|
||||
"Exit cockpit to fly to a file": "Sal de la cabina para volar a un archivo",
|
||||
"Exit cockpit to fly to a scene": "Sal de la cabina para volar a una escena",
|
||||
"Exit cockpit to fly to a {noun}": "Sal de la cabina para volar a {noun}",
|
||||
"Go to expanded Radio section": "Ir a la sección de Radio expandida",
|
||||
"Expand Radio section in Context": "Expandir la sección de Radio en Contexto",
|
||||
"Double-click the header to snap the panel back": "Haz doble clic en el encabezado para devolver el panel a su sitio",
|
||||
"New scene name": "Nombre de la nueva escena",
|
||||
"Delete scene \"{scene}\" and all shots?": "¿Eliminar la escena \"{scene}\" y todos sus planos?",
|
||||
"Detailed outline unavailable": "Contorno detallado no disponible",
|
||||
"Provider Settings does not answer proxied requests": "Ajustes de proveedores no responde a peticiones a través de un proxy",
|
||||
"Provider Settings is disabled while sharing is enabled": "El panel \"Ajustes de proveedores\" está desactivado mientras el uso compartido esté activado",
|
||||
"Provider Settings answers only the machine running the server": "Ajustes de proveedores solo responde al equipo que ejecuta el servidor",
|
||||
"Provider Settings answers only local hostnames": "Ajustes de proveedores solo responde a nombres de host locales",
|
||||
"Provider Settings requires an exact local Origin": "Ajustes de proveedores requiere un Origin local exacto",
|
||||
"Unrecognized Origin refused": "Origin no reconocido: petición rechazada",
|
||||
"Cross-origin requests are refused": "Se rechazan las peticiones de otro origen",
|
||||
"Content-Type must be application/json": "Content-Type debe ser application/json",
|
||||
"Method not allowed": "Método no permitido",
|
||||
"Request too large": "Petición demasiado grande",
|
||||
"Invalid JSON": "JSON no válido",
|
||||
"Body must be a JSON object of {ENV_VAR: value}": "El cuerpo debe ser un objeto JSON de {ENV_VAR: value}",
|
||||
"No keys provided": "No se envió ninguna clave",
|
||||
"At most {limit} keys per save": "Como máximo {limit} claves por guardado",
|
||||
"Unknown key: {name}": "Clave desconocida: {name}",
|
||||
"{name} must be a string": "{name}: el valor debe ser una cadena de texto",
|
||||
"{name} is empty": "{name}: el valor está vacío",
|
||||
"{name} is longer than any real key ({limit} max)": "{name}: el valor es más largo que cualquier clave real (máximo {limit})",
|
||||
"{name} may only contain printable characters with no spaces": "{name}: solo se admiten caracteres imprimibles, sin espacios",
|
||||
"{name} contains a character that is not valid in a key (#, quotes, $, \\, or backtick)": "{name}: hay un carácter no válido en una clave (#, comillas, $, \\ o acento grave)",
|
||||
"{name} is configured outside Provider Settings and can only be changed where it was set": "{name} se configuró fuera de Ajustes de proveedores y solo se puede cambiar donde se definió",
|
||||
"The key was not saved: could not restrict the credential file to your account; nothing was saved": "La clave no se guardó: no se pudo restringir el archivo de credenciales a tu cuenta; no se guardó nada",
|
||||
"The key was not saved: the existing configuration could not be read, so nothing was changed": "La clave no se guardó: no se pudo leer la configuración existente, así que no se cambió nada",
|
||||
"Could not write the {store} store": "No se pudo escribir en el almacén {store}",
|
||||
"Could not check ChatGPT sign-in. Try again.": "No se pudo comprobar el inicio de sesión de ChatGPT. Inténtalo de nuevo.",
|
||||
"Cloud voice will use OPENAI_API_KEY on the next session.": "La voz en la nube usará OPENAI_API_KEY en la próxima sesión.",
|
||||
"Checking local ChatGPT OAuth sign-in…": "Comprobando el inicio de sesión OAuth local de ChatGPT…",
|
||||
"Could not check ChatGPT sign-in.": "No se pudo comprobar el inicio de sesión de ChatGPT.",
|
||||
"ChatGPT OAuth selected for cloud voice. Your API key stays saved and available.": "OAuth de ChatGPT seleccionado para la voz en la nube. Tu clave de API sigue guardada y disponible.",
|
||||
"ChatGPT sign-in expired. Opening sign-in again…": "El inicio de sesión de ChatGPT caducó. Abriéndolo de nuevo…",
|
||||
"Opening ChatGPT sign-in in your browser…": "Abriendo el inicio de sesión de ChatGPT en tu navegador…",
|
||||
"Could not start ChatGPT sign-in on this machine.": "No se pudo abrir el inicio de sesión de ChatGPT en este equipo.",
|
||||
"Finish ChatGPT sign-in in the browser. Waiting for it to complete…": "Termina de iniciar sesión en ChatGPT en el navegador. Esperando a que se complete…",
|
||||
"ChatGPT sign-in timed out. Click {button} to try again.": "Se agotó el tiempo para iniciar sesión en ChatGPT. Haz clic en \"{button}\" para intentarlo de nuevo.",
|
||||
"ChatGPT sign-in complete. OAuth will be used for the next cloud voice session.": "Sesión de ChatGPT iniciada. Se usará OAuth en la próxima sesión de voz en la nube.",
|
||||
"OAuth check failed: {detail}": "Error al comprobar OAuth: {detail}",
|
||||
"unsaved CCTV calibration": "la calibración de CCTV sin guardar",
|
||||
"the open cockpit": "la cabina abierta",
|
||||
"Switching language reloads the page. This will be lost: {items}. Switch anyway?": "Cambiar de idioma recarga la página. Se perderá lo siguiente: {items}. ¿Cambiar de todos modos?",
|
||||
"Wind upstream unavailable": "Origen de datos de viento no disponible",
|
||||
"WebUSB needs desktop Chrome or Edge": "WebUSB requiere Chrome o Edge de escritorio",
|
||||
"connect a receiver in Radio": "conecta un receptor en Radio",
|
||||
"receiver is in FM mode": "el receptor está en modo FM",
|
||||
"Cyclone advisories unavailable": "Avisos de ciclones no disponibles",
|
||||
"Cyclone data unavailable": "Datos de ciclones no disponibles",
|
||||
"Drag to size the box": "Arrastra para dar tamaño al recuadro",
|
||||
"Press on the ground, not the sky": "Pulsa sobre el suelo, no sobre el cielo",
|
||||
"Point the camera at the ground to use the view": "Apunta la cámara al suelo para usar la vista",
|
||||
"That point cannot anchor a box": "Ese punto no puede anclar un recuadro",
|
||||
"Box edges must be finite": "Coordenadas del recuadro no válidas",
|
||||
"Longitudes must be within ±180°": "Las longitudes deben estar dentro de ±180°",
|
||||
"Select one side of the dateline": "No cruces la línea de cambio de fecha",
|
||||
"Box must enclose an area": "El recuadro debe abarcar un área",
|
||||
"Imagery stops at ±{limit}° latitude": "Las imágenes llegan hasta ±{limit}° de latitud",
|
||||
"{owner} is using the pointer — close it first": "Puntero en uso: {owner}. Ciérralo primero",
|
||||
"OpenSky OAuth client missing/invalid": "Cliente OAuth de OpenSky ausente o no válido",
|
||||
"OpenSky OAuth rejected credentials": "OpenSky OAuth rechazó las credenciales",
|
||||
"OpenSky username/password rejected": "OpenSky rechazó el usuario o la contraseña",
|
||||
"OpenSky auth missing": "Falta la autenticación de OpenSky",
|
||||
"OpenSky auth required": "OpenSky requiere autenticación",
|
||||
"OpenSky auth invalid": "Autenticación de OpenSky no válida",
|
||||
"OpenSky auth failed": "Falló la autenticación de OpenSky",
|
||||
"Source snapshot {minutes} min old": "La instantánea de la fuente tiene {minutes} min",
|
||||
"{source} rate limited (anonymous)": "{source}: límite de peticiones (anónimo)",
|
||||
"{source} rate limited": "{source}: límite de peticiones",
|
||||
"{source} HTTP {status}": "{source}: HTTP {status}",
|
||||
"{source} network error": "{source}: error de red",
|
||||
"Malformed {source} aircraft rows": "{source}: filas de aeronaves mal formadas",
|
||||
"Malformed {source} response": "{source}: respuesta mal formada",
|
||||
"Source snapshot time unavailable": "Hora de la instantánea de la fuente no disponible",
|
||||
"Live data unavailable": "Datos en vivo no disponibles",
|
||||
"{n} aircraft": {
|
||||
"one": "{n} aeronave",
|
||||
"many": "{n} de aeronaves",
|
||||
"other": "{n} aeronaves"
|
||||
},
|
||||
"{n} military aircraft": {
|
||||
"one": "{n} aeronave militar",
|
||||
"many": "{n} de aeronaves militares",
|
||||
"other": "{n} aeronaves militares"
|
||||
},
|
||||
"{n} ships": {
|
||||
"one": "{n} buque",
|
||||
"many": "{n} de buques",
|
||||
"other": "{n} buques"
|
||||
},
|
||||
"{n} satellites": {
|
||||
"one": "{n} satélite",
|
||||
"many": "{n} de satélites",
|
||||
"other": "{n} satélites"
|
||||
},
|
||||
"{n} launches": {
|
||||
"one": "{n} lanzamiento",
|
||||
"many": "{n} de lanzamientos",
|
||||
"other": "{n} lanzamientos"
|
||||
},
|
||||
"{n} earthquakes": {
|
||||
"one": "{n} terremoto",
|
||||
"many": "{n} de terremotos",
|
||||
"other": "{n} terremotos"
|
||||
},
|
||||
"{n} fires": {
|
||||
"one": "{n} incendio",
|
||||
"many": "{n} de incendios",
|
||||
"other": "{n} incendios"
|
||||
},
|
||||
"{n} fire perimeters": {
|
||||
"one": "{n} perímetro de incendio",
|
||||
"many": "{n} de perímetros de incendio",
|
||||
"other": "{n} perímetros de incendio"
|
||||
},
|
||||
"{n} cyclones": {
|
||||
"one": "{n} ciclón",
|
||||
"many": "{n} de ciclones",
|
||||
"other": "{n} ciclones"
|
||||
},
|
||||
"{n} buses": {
|
||||
"one": "{n} autobús",
|
||||
"many": "{n} de autobuses",
|
||||
"other": "{n} autobuses"
|
||||
},
|
||||
"{n} bike share": {
|
||||
"one": "{n} estación de bicicletas compartidas",
|
||||
"many": "{n} de estaciones de bicicletas compartidas",
|
||||
"other": "{n} estaciones de bicicletas compartidas"
|
||||
},
|
||||
"{n} flock cameras": {
|
||||
"one": "{n} cámara ALPR",
|
||||
"many": "{n} de cámaras ALPR",
|
||||
"other": "{n} cámaras ALPR"
|
||||
},
|
||||
"{n} military bases": {
|
||||
"one": "{n} base militar",
|
||||
"many": "{n} de bases militares",
|
||||
"other": "{n} bases militares"
|
||||
},
|
||||
"{n} datacenters": {
|
||||
"one": "{n} centro de datos",
|
||||
"many": "{n} de centros de datos",
|
||||
"other": "{n} centros de datos"
|
||||
},
|
||||
"{n} dams": {
|
||||
"one": "{n} presa",
|
||||
"many": "{n} de presas",
|
||||
"other": "{n} presas"
|
||||
},
|
||||
"{n} results": {
|
||||
"one": "{n} resultado",
|
||||
"many": "{n} de resultados",
|
||||
"other": "{n} resultados"
|
||||
},
|
||||
"anywhere in the loaded data": "en todos los datos cargados",
|
||||
"in view": "a la vista",
|
||||
"within {radius} of the view centre": "en un radio de {radius} del centro de la vista",
|
||||
"within {radius} of {place} (Contacts window)": "en un radio de {radius} de {place} (ventana de Contactos)",
|
||||
"within {radius} of {place}": "en un radio de {radius} de {place}",
|
||||
"within {radius}": "en un radio de {radius}",
|
||||
"over {region}": "en {region}",
|
||||
"At least {items}": "Al menos {items}",
|
||||
"{items} {scope}": "{items} {scope}",
|
||||
"last 24 h": "últimas 24 h",
|
||||
"past 30 d and upcoming": "últimos 30 d y próximos",
|
||||
"the flights layer loads by viewport": "la capa de vuelos se carga según la vista",
|
||||
"distance is ground distance, not overhead": "la distancia es sobre el terreno hasta el punto subsatélite, no hasta el satélite",
|
||||
"the count is a lower bound because at least one layer reached its retained limit.": "el recuento es un mínimo porque al menos una capa alcanzó su límite de retención.",
|
||||
"{label} loading": "{label}: cargando",
|
||||
"{label} error": "{label}: error",
|
||||
"counted the first {returned} loaded records": "se contaron los primeros {returned} registros cargados",
|
||||
"counted {returned} of {total} loaded records": "se contaron {returned} de {total} registros cargados",
|
||||
"{layers} just turned on — still loading, counts will rise": "{layers}: recién activado — aún se está cargando, los recuentos subirán",
|
||||
"Contacts panel snapshot at {time} — the same retained cohort supplies the card, speech, and follow-up memory": "Instantánea del panel Contactos ({time}) — el mismo conjunto retenido alimenta la tarjeta, la voz y la memoria de seguimiento",
|
||||
"Contacts panel snapshot — the same retained cohort supplies the card, speech, and follow-up memory": "Instantánea del panel Contactos — el mismo conjunto retenido alimenta la tarjeta, la voz y la memoria de seguimiento",
|
||||
"Not answered: {layers}": "Sin respuesta: {layers}",
|
||||
"The flights layer is not enabled": "La capa de vuelos no está activada",
|
||||
"The military layer is not enabled": "La capa de vuelos militares no está activada",
|
||||
"The satellites layer is not enabled": "La capa de satélites no está activada",
|
||||
"The ais-live-vessels layer is not enabled": "La capa de buques AIS no está activada",
|
||||
"Could not enable the requested layer": "No se pudo activar la capa solicitada",
|
||||
"Could not disable the requested layer": "No se pudo desactivar la capa solicitada",
|
||||
"Radio layer unavailable": "La capa de radio no está disponible",
|
||||
"Camera navigation is unavailable in the current view": "La navegación de la cámara no está disponible en la vista actual",
|
||||
"Camera navigation policy unavailable": "La política de navegación de la cámara no está disponible",
|
||||
"Nearest-aircraft selection supports Flights or Military Flights only": "La selección de la aeronave más cercana solo funciona con vuelos o vuelos militares",
|
||||
"Nearest-aircraft selection needs a preset, place name, or latitude and longitude": "Para seleccionar la aeronave más cercana se necesita un preajuste, un nombre de lugar o la latitud y la longitud",
|
||||
"Could not arrive at the requested place": "No se pudo llegar al lugar solicitado",
|
||||
"The nearest airborne aircraft could not be selected": "No se pudo seleccionar la aeronave en vuelo más cercana",
|
||||
"No flights within {radius} of the current view": "No hay vuelos en un radio de {radius} de la vista actual",
|
||||
"No military within {radius} of the current view": "No hay vuelos militares en un radio de {radius} de la vista actual",
|
||||
"No satellites within {radius} of the current view": "No hay satélites en un radio de {radius} de la vista actual",
|
||||
"No vessels within {radius} of the current view": "No hay buques en un radio de {radius} de la vista actual",
|
||||
"{layer} is enabled on the {source} feed, but no airborne aircraft is loaded in the {place} view yet": "La capa {layer} está activada con la fuente {source}, pero aún no hay aeronaves en vuelo cargadas en la vista de {place}",
|
||||
"{layer} is enabled, but no airborne aircraft is loaded in the {place} view yet": "La capa {layer} está activada, pero aún no hay aeronaves en vuelo cargadas en la vista de {place}",
|
||||
"{layer} is enabled, but its aircraft feed is unavailable": "La capa {layer} está activada, pero su fuente de aeronaves no está disponible",
|
||||
"{layer} is enabled, but its destination refresh did not complete": "La capa {layer} está activada, pero la actualización en el destino no terminó",
|
||||
"{layer} is enabled, but {source} is unavailable": "La capa {layer} está activada, pero {source} no está disponible",
|
||||
"Could not arrive at {place}": "No se pudo llegar a {place}",
|
||||
"{name} is no longer loaded": "{name} ya no está cargado",
|
||||
"Unknown target layer: {target}": "Capa de destino desconocida: {target}",
|
||||
"Layer": "Capa",
|
||||
"there": "allí",
|
||||
"Frame flights": "Encuadrar aeronaves",
|
||||
"Frame military": "Encuadrar aeronaves militares",
|
||||
"Frame vessels": "Encuadrar buques",
|
||||
"Fly to Location": "Volar al lugar",
|
||||
"Nearest aircraft to Here": "Aeronave más cercana aquí",
|
||||
"Estimate is incomplete — a response was still in flight when the session ended, so its usage was never reported.": "La estimación está incompleta: una respuesta seguía en curso cuando terminó la sesión, así que su uso nunca se informó.",
|
||||
"STANDARD": "ESTÁNDAR",
|
||||
"vehicle motion|Stopped": "Detenido",
|
||||
"ACQUIRING FRAME": "OBTENIENDO FOTOGRAMA",
|
||||
"Camera source temporarily unavailable": "Fuente de cámaras no disponible temporalmente",
|
||||
"Perimeter source unavailable": "Fuente de perímetros no disponible",
|
||||
"Imagery catalog unavailable": "Catálogo de imágenes no disponible",
|
||||
"Earthquake source unavailable": "Fuente de terremotos no disponible",
|
||||
"TeleGeography load failed": "Falló la carga de TeleGeography",
|
||||
"Wind request timed out": "La petición del viento agotó el tiempo de espera",
|
||||
"Malformed wind manifest": "Manifiesto del viento mal formado",
|
||||
"Malformed wind grid": "Malla del viento mal formada",
|
||||
"Unknown wind model": "Modelo de viento desconocido",
|
||||
"Unknown weather overlay": "Capa de clima desconocida",
|
||||
"Temperature field unavailable": "Campo de temperatura no disponible",
|
||||
"Mean sea level pressure field unavailable": "Campo de presión a nivel del mar no disponible",
|
||||
"KartaView answered HTTP {status}": "KartaView respondió HTTP {status}",
|
||||
"KartaView refused the request (code {code})": "KartaView rechazó la petición (código {code})",
|
||||
"Stopped by voice": "Detenido por voz",
|
||||
"drawings on the map": "los dibujos del mapa",
|
||||
"imported map files": "los archivos importados en el mapa",
|
||||
"the active voice session": "la sesión de voz activa",
|
||||
"Light aircraft": "Avioneta",
|
||||
"Glider": "Planeador",
|
||||
"Turboprop": "Turbohélice",
|
||||
"Airliner": "Avión comercial",
|
||||
"Widebody": "Avión de fuselaje ancho",
|
||||
"Four-engine jet": "Reactor cuatrimotor",
|
||||
"Helicopter": "Helicóptero",
|
||||
"Fast jet": "Avión de alto rendimiento",
|
||||
"Business jet": "Reactor ejecutivo",
|
||||
"Drone": "Dron",
|
||||
"fire cause|Human": "humana",
|
||||
"fire cause|Natural": "natural",
|
||||
"fire cause|Undetermined": "indeterminada",
|
||||
"fire cause|Unknown": "desconocida",
|
||||
"fire behavior|Minimal": "actividad mínima",
|
||||
"fire behavior|Moderate": "actividad moderada",
|
||||
"fire behavior|Active": "actividad alta",
|
||||
"fire behavior|Extreme": "actividad extrema",
|
||||
"Type {level} Incident": "incidente de tipo {level}",
|
||||
"${amount}K": "{amount} mil $",
|
||||
"${amount}M": "{amount} M$",
|
||||
"${amount}B": "{amount} mil M$",
|
||||
"{speed} kn": "{speed} kn",
|
||||
"1090 MHz": "1090 MHz",
|
||||
"978 MHz UAT": "978 MHz UAT",
|
||||
"feed status|live": "en vivo",
|
||||
"feed status|stale": "desactualizada",
|
||||
"feed status|unreachable": "inaccesible",
|
||||
"feed status|invalid": "no válida",
|
||||
"Vector tile metadata unavailable": "Metadatos de teselas vectoriales no disponibles",
|
||||
"Vector tiles unavailable": "Teselas vectoriales no disponibles",
|
||||
"Vector tiles unavailable (HTTP {status})": "Teselas vectoriales no disponibles (HTTP {status})",
|
||||
"Vector tile request timed out": "Las teselas vectoriales agotaron el tiempo de espera",
|
||||
"Zoom in for vector tile coverage": "Acércate para cargar las teselas vectoriales",
|
||||
"Camera coverage unavailable": "Cobertura de cámaras no disponible",
|
||||
"Hybrid": "Híbrido",
|
||||
"TomTom roads only, each with its live flow": "Solo vías de TomTom, cada una con su flujo en vivo",
|
||||
"TomTom roads only, each with its live flow (needs a TomTom key)": "Solo vías de TomTom, cada una con su flujo en vivo (necesita una clave de TomTom)",
|
||||
"OpenStreetMap roads, with TomTom flow matched when available": "Vías de OpenStreetMap, con el flujo de TomTom emparejado cuando está disponible",
|
||||
"TomTom roads first; OpenStreetMap fills the rest, simulated": "Primero las vías de TomTom; OpenStreetMap completa el resto con flujo simulado",
|
||||
"TomTom roads first; OpenStreetMap fills the rest, simulated (needs a TomTom key)": "Primero las vías de TomTom; OpenStreetMap completa el resto con flujo simulado (necesita una clave de TomTom)",
|
||||
"Austin": "Austin",
|
||||
"San Francisco": "San Francisco",
|
||||
"New York": "Nueva York",
|
||||
"Tokyo": "Tokio",
|
||||
"London": "Londres",
|
||||
"Paris": "París",
|
||||
"Dubai": "Dubái",
|
||||
"Washington DC": "Washington D. C.",
|
||||
"Tallinn": "Tallin",
|
||||
"Los Angeles": "Los Ángeles",
|
||||
"Data attribution": "Atribución de datos",
|
||||
"Data provided by:": "Datos proporcionados por:",
|
||||
"Exit cockpit to view weather coverage": "Sal de la cabina para ver la cobertura de la capa meteorológica",
|
||||
"Exit cockpit to fly to a cyclone": "Sal de la cabina para volar a un ciclón",
|
||||
"the title of scene {scene} is empty or longer than {max} characters": "el título de la escena {scene} está vacío o supera los {max} caracteres",
|
||||
"the title of shot {shot} in scene {scene} is empty or longer than {max} characters": "el título del plano {shot} de la escena {scene} está vacío o supera los {max} caracteres",
|
||||
"the project is larger than 5 MiB": "el proyecto pesa más de 5 MiB",
|
||||
"the project has too many shots": "el proyecto tiene demasiados planos",
|
||||
"the scene data failed validation": "los datos de la escena no superaron la validación",
|
||||
"Layer on": "Capa: activada",
|
||||
"Layer off": "Capa: desactivada",
|
||||
"Zoom in": "Acercar",
|
||||
"Zoom out": "Alejar",
|
||||
"Zoom out to globe": "Alejar hasta el globo",
|
||||
"Open data layers": "Abrir las capas de datos",
|
||||
"Open or close a panel": "Abrir o cerrar un panel",
|
||||
"Change context mode": "Cambiar el modo de contexto",
|
||||
"Control cockpit": "Controlar la cabina",
|
||||
"Change visual style": "Cambiar el estilo visual",
|
||||
"Adjust HUD": "Ajustar el HUD",
|
||||
"Adjust cyber sonar": "Ajustar el sonar Ciber",
|
||||
"Adjust detection overlay": "Ajustar la superposición de detección",
|
||||
"Change map stack": "Cambiar el conjunto de mapas",
|
||||
"Adjust post-processing": "Ajustar el posprocesado",
|
||||
"Control scene": "Controlar la escena",
|
||||
"Control CCTV": "Controlar las cámaras CCTV",
|
||||
"Control radio": "Controlar la radio",
|
||||
"Track contact": "Rastrear el contacto",
|
||||
"Stop tracking": "Detener el rastreo",
|
||||
"Clear marks": "Borrar las marcas",
|
||||
"Move camera": "Mover la cámara",
|
||||
"Fly route": "Recorrer la ruta",
|
||||
"Analyst query": "Consulta analítica",
|
||||
"Next ISS pass": "Próximo paso de la EEI",
|
||||
"Next satellite pass": "Próximo paso del satélite",
|
||||
"Action": "Acción"
|
||||
}
|
||||
+578
-4
@@ -857,7 +857,7 @@
|
||||
"Voice session could not be started.": "Не удалось запустить голосовую сессию.",
|
||||
"CONNECTING": "ПОДКЛЮЧЕНИЕ",
|
||||
"ERROR": "ОШИБКА",
|
||||
"{tier} applies next session": "{tier} вступит в силу со следующей сессии",
|
||||
"{tier} applies next session": "{tier} — со следующей сессии",
|
||||
"Session ended — cost cap {cost}": "Сессия завершена — лимит стоимости {cost}",
|
||||
"Next session: {pendingId} — this session stays on {modelId}": "Следующая сессия: {pendingId} — текущая остаётся на {modelId}",
|
||||
"Voice model: {pendingId} — click to switch to {target}; applies next session": "Голосовая модель: {pendingId} — нажмите, чтобы переключиться на {target}; вступит в силу со следующей сессии",
|
||||
@@ -1001,7 +1001,7 @@
|
||||
"Coverage colours": "Цвета покрытия",
|
||||
"That shape already has {count} points — finish it or press Backspace.": "В этой фигуре уже точек: {count} — завершите её или нажмите Backspace.",
|
||||
"Could not place the shape: {error}": "Не удалось разместить фигуру: {error}",
|
||||
"{owner} is using the pointer — close it first.": "Указатель занят инструментом {owner} — сначала закройте его.",
|
||||
"{owner} is using the pointer — close it first.": "Указатель занят: «{owner}». Закройте его.",
|
||||
"{label} · Detailed outline unavailable": "{label} · Подробный контур недоступен",
|
||||
"Witness source": "Свидетельство очевидца",
|
||||
"{provider} · EMBEDDED SOURCE": "{provider} · ВСТРОЕННЫЙ ИСТОЧНИК",
|
||||
@@ -1790,7 +1790,7 @@
|
||||
"Mark {targets} +{more}": "Отметить {targets} +{more}",
|
||||
"Mark {targets}": "Отметить {targets}",
|
||||
"Fly to {place}": "Перелёт: {place}",
|
||||
"Nearest aircraft to {place}": "Ближайшие самолёты: {place}",
|
||||
"Nearest aircraft to {place}": "Ближайший самолёт: {place}",
|
||||
"Read the view": "Осмотреть видимую область",
|
||||
"Check view state": "Проверить состояние вида",
|
||||
"Frame {target}": "Показать в кадре: {target}",
|
||||
@@ -2244,5 +2244,579 @@
|
||||
"Show your own GeoJSON, KML, KMZ or GPX files on the globe (or drop them onto it). File bytes stay on this device; overlays can appear in map screenshots shared with voice.": "Показать на глобусе свои файлы GeoJSON, KML, KMZ или GPX (или перетащите их на глобус). Содержимое файлов остаётся на этом устройстве; наложения могут попасть в снимки карты, которые передаются голосовому ассистенту.",
|
||||
"Import": "Импорт",
|
||||
"Choose GeoJSON, KML, KMZ or GPX files": "Выберите файлы GeoJSON, KML, KMZ или GPX",
|
||||
"Imported files": "Импортированные файлы"
|
||||
"Imported files": "Импортированные файлы",
|
||||
"DOCKED · {name}": "СТЫКОВКА · {name}",
|
||||
"DOCKED · {name} · +{extra}": "СТЫКОВКА · {name} · +{extra}",
|
||||
"loading coverage...": "загрузка покрытия...",
|
||||
"live feed unavailable": "поток в реальном времени недоступен",
|
||||
"GBFS fetch error": "ошибка загрузки GBFS",
|
||||
"GBFS status update failed": "не удалось обновить статус GBFS",
|
||||
"Open the Street Level panel to view imagery": "Откройте панель «Уличные снимки», чтобы их посмотреть",
|
||||
"Image could not be opened": "Не удалось открыть снимок",
|
||||
"Mapillary rejected MAPILLARY_CLIENT_TOKEN — replace it in Provider Settings": "Mapillary отклонил MAPILLARY_CLIENT_TOKEN — замените его в «Настройках провайдеров»",
|
||||
"Coverage requests are being rate-limited — they resume on their own": "Запросы покрытия ограничены по частоте — они возобновятся сами",
|
||||
"No street-level imagery within {radius} m of that point": "В радиусе {radius} м от этой точки уличных снимков нет",
|
||||
"No street-level imagery within {radius} m of the view centre": "В радиусе {radius} м от центра вида уличных снимков нет",
|
||||
"Aim the middle of the view at a street to look for imagery there": "Наведите центр вида на улицу, чтобы поискать там снимки",
|
||||
"Street View has no imagery for this place": "В Просмотре улиц нет снимков этого места",
|
||||
"GOOGLE_MAPS_API_KEY is not set": "GOOGLE_MAPS_API_KEY не задан",
|
||||
"The Google Maps JavaScript API could not load": "Не удалось загрузить Google Maps JavaScript API",
|
||||
"Weather request timed out": "Истекло время ожидания запроса погоды",
|
||||
"Malformed weather manifest": "Некорректный манифест погоды",
|
||||
"Weather HTTP {status}": "Погода: HTTP {status}",
|
||||
"Weather image HTTP {status}": "Снимок погоды: HTTP {status}",
|
||||
"Weather prefetch unavailable": "Предзагрузка погоды недоступна",
|
||||
"Infrared decode failed": "Не удалось декодировать инфракрасный снимок",
|
||||
"rate limited": "превышен лимит запросов",
|
||||
"routing service is rate limited": "сервис маршрутизации превысил лимит запросов",
|
||||
"route leg too long": "участок маршрута слишком длинный",
|
||||
"route too long": "маршрут слишком длинный",
|
||||
"Routing is rate limited — try again in a moment": "Построение маршрута: превышен лимит запросов — попробуйте ещё раз чуть позже",
|
||||
"{error} — try again in a moment": "{error} — попробуйте ещё раз чуть позже",
|
||||
"Routing unavailable (HTTP {status})": "Построение маршрута недоступно (HTTP {status})",
|
||||
"CelesTrak unreachable": "CelesTrak недоступен",
|
||||
"KartaView is rate-limiting requests — they resume on their own": "KartaView ограничивает частоту запросов — они возобновятся сами",
|
||||
"KartaView’s photos here are archived and cannot be shown": "Фотографии KartaView здесь в архиве, показать их нельзя",
|
||||
"KartaView did not filter this area in time — move the map to ask again": "KartaView не успел отфильтровать эту область — сдвиньте карту, чтобы запросить снова",
|
||||
"None of KartaView’s photos here pass the imagery filter": "Ни одна фотография KartaView здесь не проходит фильтр снимков",
|
||||
"KartaView did not answer in time — try again": "KartaView не ответил вовремя — попробуйте снова",
|
||||
"KartaView could not be reached — check the network": "Не удалось связаться с KartaView — проверьте сеть",
|
||||
"KartaView sent an answer that could not be read": "KartaView прислал ответ, который не удалось прочитать",
|
||||
"KartaView is rate-limiting requests": "KartaView ограничивает частоту запросов",
|
||||
"KartaView is rate-limiting requests — try again in a moment": "KartaView ограничивает частоту запросов — попробуйте ещё раз чуть позже",
|
||||
"KartaView sent too much data": "KartaView прислал слишком много данных",
|
||||
"KartaView has no such photo": "У KartaView нет такой фотографии",
|
||||
"The KartaView photo could not be loaded": "Не удалось загрузить фотографию KartaView",
|
||||
"KartaView has no position for this photo": "У KartaView нет координат этой фотографии",
|
||||
"Map tiles temporarily unavailable": "Тайлы карты временно недоступны",
|
||||
"Mapped names temporarily unavailable": "Названия объектов временно недоступны",
|
||||
"No mapped sites in view": "В кадре нет объектов",
|
||||
"No mapped sites within {km} km of the contact": "Нет объектов в радиусе {km} км от контакта",
|
||||
"{count} mapped sites in view": {
|
||||
"one": "{count} объект в кадре",
|
||||
"few": "{count} объекта в кадре",
|
||||
"many": "{count} объектов в кадре",
|
||||
"other": "{count} объекта в кадре"
|
||||
},
|
||||
"{count} mapped sites within {km} km of the contact": {
|
||||
"one": "{count} объект в радиусе {km} км от контакта",
|
||||
"few": "{count} объекта в радиусе {km} км от контакта",
|
||||
"many": "{count} объектов в радиусе {km} км от контакта",
|
||||
"other": "{count} объекта в радиусе {km} км от контакта"
|
||||
},
|
||||
"Too many mapped sites in view to list them all": "В кадре слишком много объектов, чтобы перечислить все",
|
||||
"Installation context unavailable": "Контекст объектов недоступен",
|
||||
"Focus vessel {name}, MMSI {mmsi}": "Фокус на судне {name}, MMSI {mmsi}",
|
||||
"Open the USGS initial report for the 26 August 2026 Nepal landslide": "Открыть первичное сообщение USGS об оползне в Непале 26 августа 2026 года",
|
||||
"YouTube source": "Источник YouTube",
|
||||
"Facebook source": "Источник Facebook",
|
||||
"Military airfield": "Военный аэродром",
|
||||
"Naval base": "Военно-морская база",
|
||||
"Firing range": "Стрельбище",
|
||||
"Military land": "Военная территория",
|
||||
"Mapped installation": "Объект на карте",
|
||||
"Military area": "Военная зона",
|
||||
"SIMULATED — {reason}": "СИМУЛЯЦИЯ — {reason}",
|
||||
"TomTom key unavailable": "Ключ TomTom недоступен",
|
||||
"TomTom daily budget reached": "Исчерпан дневной лимит TomTom",
|
||||
"TomTom upstream unreachable": "Сервер TomTom не отвечает",
|
||||
"TomTom flow unavailable": "Поток TomTom недоступен",
|
||||
"API key rejected — check AISSTREAM_API_KEY": "Ключ API отклонён — проверьте AISSTREAM_API_KEY",
|
||||
"feed silent {seconds}s — no AIS data": "нет данных AIS от потока {seconds} с",
|
||||
"feed silent — no AIS data": "нет данных AIS от потока",
|
||||
"feed down — retrying slowly (attempt {attempt})": "поток недоступен — повторяем реже (попытка {attempt})",
|
||||
"feed down — retrying slowly": "поток недоступен — повторяем реже",
|
||||
"reconnecting to feed… (attempt {attempt})": "переподключение к потоку… (попытка {attempt})",
|
||||
"reconnecting to feed…": "переподключение к потоку…",
|
||||
"AISSTREAM_API_KEY not set": "AISSTREAM_API_KEY не задан",
|
||||
"live feed unsupported": "поток в реальном времени не поддерживается",
|
||||
"connecting to feed…": "подключение к потоку…",
|
||||
"feed disconnected": "поток отключён",
|
||||
"feed down": "поток недоступен",
|
||||
"feed idle": "поток простаивает",
|
||||
"AIS live load failed": "Не удалось загрузить AIS в реальном времени",
|
||||
"age|{count}m": "{count} мин",
|
||||
"{acres} ac": {
|
||||
"one": "{acres} акр",
|
||||
"few": "{acres} акра",
|
||||
"many": "{acres} акров",
|
||||
"other": "{acres} акра"
|
||||
},
|
||||
"{percent}% contained": "локализован на {percent}%",
|
||||
"containment unknown": "локализация неизвестна",
|
||||
"{cause} cause": "причина: {cause}",
|
||||
"{personnel} personnel": {
|
||||
"one": "{personnel} человек",
|
||||
"few": "{personnel} человека",
|
||||
"many": "{personnel} человек",
|
||||
"other": "{personnel} человека"
|
||||
},
|
||||
"{county} County": "округ {county}",
|
||||
"{cost} to date": "{cost} на сегодня",
|
||||
"discovered {age} ago": "обнаружен {age} назад",
|
||||
"updated {age} ago": "обновлён {age} назад",
|
||||
"part of {complex}": "входит в {complex}",
|
||||
"InciWeb ↗ · click card to open": "InciWeb ↗ · нажмите на карточку, чтобы открыть",
|
||||
"FIRE · {name}": "ПОЖАР · {name}",
|
||||
"Unnamed incident": "Без названия",
|
||||
"Open {title} on InciWeb": "Открыть {title} в InciWeb",
|
||||
"Vehicle {vehicle}": "Борт {vehicle}",
|
||||
"Bus": "Автобус",
|
||||
"Tram": "Трамвай",
|
||||
"Subway": "Метро",
|
||||
"Train": "Поезд",
|
||||
"Ferry": "Паром",
|
||||
"Transit vehicle": "Общественный транспорт",
|
||||
"No recent fix": "Нет свежих координат",
|
||||
"Waiting for update": "Ожидание обновления",
|
||||
"Moving": "В движении",
|
||||
"reported {speed} km/h": "по отчёту {speed} км/ч",
|
||||
"reported hdg {heading}°": "по отчёту курс {heading}°",
|
||||
"Last report: stopped at stop {stop}": "Последний отчёт: стоит на остановке {stop}",
|
||||
"Last report: arriving at stop {stop}": "Последний отчёт: прибывает на остановку {stop}",
|
||||
"Last report: next stop {stop}": "Последний отчёт: следующая остановка {stop}",
|
||||
"occupancy|empty": "пусто",
|
||||
"occupancy|many seats available": "много свободных мест",
|
||||
"occupancy|few seats available": "мало свободных мест",
|
||||
"occupancy|standing room only": "только стоячие места",
|
||||
"occupancy|crushed standing room only": "переполнен, только стоя",
|
||||
"occupancy|full": "мест нет",
|
||||
"occupancy|not accepting passengers": "посадки нет",
|
||||
"occupancy|not boardable": "посадка невозможна",
|
||||
"Shown at street level · depth not in the feed": "Показан на уровне улицы · глубины в потоке нет",
|
||||
"Reported {seconds} s ago": "Отчёт {seconds} с назад",
|
||||
"Reported {minutes} min ago": "Отчёт {minutes} мин назад",
|
||||
"{reported} (feed time)": "{reported} (время потока)",
|
||||
"shown {seconds} s behind": "показ отстаёт на {seconds} с",
|
||||
"Available trail: {minutes} min": "Доступный след: {minutes} мин",
|
||||
"Available trail: {minutes} min (limited)": "Доступный след: {minutes} мин (ограничен)",
|
||||
"UNAVAILABLE · {source} · Roads unavailable": "НЕДОСТУПНО · {source} · Дороги недоступны",
|
||||
"Roads: {source}": "Дороги: {source}",
|
||||
"TomTom roads unavailable while the traffic service is unreachable": "Дороги TomTom недоступны, пока сервис трафика не отвечает",
|
||||
"Hybrid unavailable while the traffic service is unreachable": "Гибридный режим недоступен, пока сервис трафика не отвечает",
|
||||
"TomTom roads need a TomTom key": "Для дорог TomTom нужен ключ TomTom",
|
||||
"Hybrid needs a TomTom key": "Для гибридного режима нужен ключ TomTom",
|
||||
"Local surface still loading": "Поверхность под дорогами ещё загружается",
|
||||
"Partial coverage": "Частичное покрытие",
|
||||
"Detailed roads unavailable": "Нет детальных данных о дорогах",
|
||||
"Reduced detail coverage": "Покрытие с меньшей детализацией",
|
||||
"OpenFreeMap tiles rate-limited": "Тайлы OpenFreeMap: превышен лимит запросов",
|
||||
"OpenFreeMap tiles timed out": "Тайлы OpenFreeMap: истекло время ожидания",
|
||||
"OpenFreeMap tiles unavailable": "Тайлы OpenFreeMap недоступны",
|
||||
"Heard by your receiver": "Принято вашим приёмником",
|
||||
"browser SDR": "SDR в браузере",
|
||||
"decoder feed": "поток декодера",
|
||||
"Your RTL-SDR receiver and decoder feed": "Ваш приёмник RTL-SDR и поток декодера",
|
||||
"Your decoder feed": "Ваш поток декодера",
|
||||
"Your RTL-SDR receiver": "Ваш приёмник RTL-SDR",
|
||||
"NO CALLSIGN": "БЕЗ ПОЗЫВНОГО",
|
||||
"ALT {altitude} FT · GS {speed} KT · TRK {track}": "ВЫС. {altitude} ФТ · ПУТ. СКОР. {speed} УЗ · ПУТЬ {track}",
|
||||
"V/S {rate}": "ВЕРТ. СКОР. {rate}",
|
||||
"V/S {rate} FPM": "ВЕРТ. СКОР. {rate} ФТ/МИН",
|
||||
"POSITION {age}": "ПОЗИЦИЯ {age}",
|
||||
"POSITION {seconds} S AGO": "ПОЗИЦИЯ {seconds} С НАЗАД",
|
||||
"{count} MSGS": {
|
||||
"one": "{count} СООБЩЕНИЕ",
|
||||
"few": "{count} СООБЩЕНИЯ",
|
||||
"many": "{count} СООБЩЕНИЙ",
|
||||
"other": "{count} СООБЩЕНИЯ"
|
||||
},
|
||||
"installation class|BARRACKS": "КАЗАРМЫ",
|
||||
"installation class|TRAINING AREA": "УЧЕБНЫЙ ПОЛИГОН",
|
||||
"Seeking up…": "Поиск вверх…",
|
||||
"Seeking down…": "Поиск вниз…",
|
||||
"Scanning {mhz} MHz…": "Сканирование {mhz} МГц…",
|
||||
"Found {mhz} MHz · {snr} dB": "Найдено {mhz} МГц · {snr} дБ",
|
||||
"Broadcaster stream is unavailable or blocked by the browser.": "Поток вещателя недоступен или заблокирован браузером.",
|
||||
"Playback requires a direct click or tap.": "Чтобы начать воспроизведение, нажмите «Пуск».",
|
||||
"Broadcaster stream could not be started.": "Не удалось запустить поток вещателя.",
|
||||
"Directory refresh degraded; showing the previous station catalog.": "Обновление каталога прошло с перебоями; показан предыдущий каталог станций.",
|
||||
"Radio directory coverage is degraded.": "Каталог радиостанций работает с перебоями.",
|
||||
"Directory refresh failed; showing the previous station catalog.": "Не удалось обновить каталог; показан предыдущий каталог станций.",
|
||||
"Radio directory is temporarily unavailable.": "Каталог радиостанций временно недоступен.",
|
||||
"Decoder feeds: {feeds}": "Потоки декодера: {feeds}",
|
||||
" · read while Local ADS-B is on": " · читаются при включённом слое «Локальный ADS-B»",
|
||||
"idle": "ожидание",
|
||||
"running": "работает",
|
||||
"suspended": "приостановлено",
|
||||
"closed": "закрыто",
|
||||
"Local FM stopped: internet radio started": "Локальный FM остановлен: запущено интернет-радио",
|
||||
"ALPR cameras": "камеры ALPR",
|
||||
"OVERPASS RATE-LIMITED": "OVERPASS: ПРЕВЫШЕН ЛИМИТ ЗАПРОСОВ",
|
||||
"OVERPASS TIMED OUT": "OVERPASS: ИСТЕКЛО ВРЕМЯ ОЖИДАНИЯ",
|
||||
"OVERPASS COULD NOT COMPLETE THE QUERY": "OVERPASS: НЕ УДАЛОСЬ ЗАВЕРШИТЬ ЗАПРОС",
|
||||
"MAP TILES TEMPORARILY UNAVAILABLE": "ТАЙЛЫ КАРТЫ ВРЕМЕННО НЕДОСТУПНЫ",
|
||||
"MAPPED NAMES TEMPORARILY UNAVAILABLE": "НАЗВАНИЯ ОБЪЕКТОВ ВРЕМЕННО НЕДОСТУПНЫ",
|
||||
"Shared flight follow expired": "Срок отслеживания рейса по ссылке истёк",
|
||||
"Shared military flight follow expired": "Срок отслеживания военного рейса по ссылке истёк",
|
||||
"Shared satellite follow expired": "Срок отслеживания спутника по ссылке истёк",
|
||||
"Shared flight could not be restored — feed unavailable": "Не удалось восстановить рейс по ссылке — поток данных недоступен",
|
||||
"Shared military flight could not be restored — feed unavailable": "Не удалось восстановить военный рейс по ссылке — поток данных недоступен",
|
||||
"Shared satellite could not be restored — feed unavailable": "Не удалось восстановить спутник по ссылке — поток данных недоступен",
|
||||
"Shared flight is unavailable": "Рейс по ссылке недоступен",
|
||||
"Shared military flight is unavailable": "Военный рейс по ссылке недоступен",
|
||||
"Shared satellite is unavailable": "Спутник по ссылке недоступен",
|
||||
"Scene not saved — browser storage unavailable": "Сцена не сохранена — хранилище браузера недоступно",
|
||||
"Scene not saved — existing saved project could not be read. Export edits or import a valid file.": "Сцена не сохранена — не удалось прочитать сохранённый проект. Экспортируйте правки или импортируйте корректный файл.",
|
||||
"Saved project could not be read; storage preserved. Import a valid file to resume saving.": "Не удалось прочитать сохранённый проект; хранилище не тронуто. Импортируйте корректный файл, чтобы снова сохранять.",
|
||||
"Scene not saved — {reason}": "Сцена не сохранена — {reason}",
|
||||
"Stopped": "Остановлено",
|
||||
"Stopped (Esc)": "Остановлено (Esc)",
|
||||
"Camera ownership changed": "Управление камерой перехвачено",
|
||||
"Scene layer turned off": "Слой сцены выключен",
|
||||
"Seeking scene clock": "Перемотка сцены",
|
||||
"Importing project": "Импорт проекта",
|
||||
"Camera move interrupted": "Перелёт камеры прерван",
|
||||
"Scene media timed out": "Истекло время ожидания медиа сцены",
|
||||
"Space Missions isolates replay data. Exit the mode to enable {layer}.": "Режим «Космические миссии» изолирует данные воспроизведения. Выйдите из режима, чтобы включить слой «{layer}».",
|
||||
"Tilted view": "Вид с наклоном",
|
||||
"Straight-down view": "Вид сверху",
|
||||
"North up": "Север вверху",
|
||||
"Exit cockpit to fly to a camera": "Выйдите из кабины, чтобы перелететь к камере",
|
||||
"Exit cockpit to fly to a location": "Выйдите из кабины, чтобы перелететь к месту",
|
||||
"Exit cockpit to fly to a photo": "Выйдите из кабины, чтобы перелететь к фото",
|
||||
"Exit cockpit to fly to a shared view": "Выйдите из кабины, чтобы перелететь к виду по ссылке",
|
||||
"Exit cockpit to fly to a target": "Выйдите из кабины, чтобы перелететь к цели",
|
||||
"Exit cockpit to fly to a vessel": "Выйдите из кабины, чтобы перелететь к судну",
|
||||
"Exit cockpit to fly to a fire": "Выйдите из кабины, чтобы перелететь к пожару",
|
||||
"Exit cockpit to fly to a route": "Выйдите из кабины, чтобы перелететь к маршруту",
|
||||
"Exit cockpit to fly to a file": "Выйдите из кабины, чтобы перелететь к файлу",
|
||||
"Exit cockpit to fly to a scene": "Выйдите из кабины, чтобы перелететь к сцене",
|
||||
"Exit cockpit to fly to a {noun}": "Выйдите из кабины, чтобы перелететь: {noun}",
|
||||
"Go to expanded Radio section": "Перейти к развёрнутому разделу радио",
|
||||
"Expand Radio section in Context": "Развернуть раздел «Радио» в панели «Контекст»",
|
||||
"Double-click the header to snap the panel back": "Двойной щелчок по заголовку — вернуть панель на место",
|
||||
"New scene name": "Название новой сцены",
|
||||
"Delete scene \"{scene}\" and all shots?": "Удалить сцену «{scene}» и все кадры?",
|
||||
"Detailed outline unavailable": "Подробный контур недоступен",
|
||||
"Provider Settings does not answer proxied requests": "«Настройки провайдеров» не отвечают на запросы через прокси",
|
||||
"Provider Settings is disabled while sharing is enabled": "«Настройки провайдеров» отключены, пока включён общий доступ",
|
||||
"Provider Settings answers only the machine running the server": "«Настройки провайдеров» отвечают только компьютеру, на котором запущен сервер",
|
||||
"Provider Settings answers only local hostnames": "«Настройки провайдеров» отвечают только на локальные имена хостов",
|
||||
"Provider Settings requires an exact local Origin": "«Настройкам провайдеров» нужен точный локальный Origin",
|
||||
"Unrecognized Origin refused": "Нераспознанный Origin отклонён",
|
||||
"Cross-origin requests are refused": "Запросы с другого источника отклоняются",
|
||||
"Content-Type must be application/json": "Content-Type должен быть application/json",
|
||||
"Method not allowed": "Метод не разрешён",
|
||||
"Request too large": "Слишком большой запрос",
|
||||
"Invalid JSON": "Некорректный JSON",
|
||||
"Body must be a JSON object of {ENV_VAR: value}": "Тело запроса должно быть JSON-объектом вида {ENV_VAR: value}",
|
||||
"No keys provided": "Ключи не переданы",
|
||||
"At most {limit} keys per save": "Ключей за одно сохранение — не больше {limit}",
|
||||
"Unknown key: {name}": "Неизвестный ключ: {name}",
|
||||
"{name} must be a string": "{name}: значение должно быть строкой",
|
||||
"{name} is empty": "{name}: пустое значение",
|
||||
"{name} is longer than any real key ({limit} max)": "{name}: значение длиннее любого настоящего ключа (максимум {limit})",
|
||||
"{name} may only contain printable characters with no spaces": "{name}: допустимы только печатные символы без пробелов",
|
||||
"{name} contains a character that is not valid in a key (#, quotes, $, \\, or backtick)": "{name}: недопустимый для ключа символ (#, кавычки, $, \\ или обратный апостроф)",
|
||||
"{name} is configured outside Provider Settings and can only be changed where it was set": "{name}: задан вне «Настроек провайдеров», изменить его можно только там, где его задали",
|
||||
"The key was not saved: could not restrict the credential file to your account; nothing was saved": "Ключ не сохранён: не удалось закрыть файл учётных данных от всех, кроме вашей учётной записи; ничего не сохранено",
|
||||
"The key was not saved: the existing configuration could not be read, so nothing was changed": "Ключ не сохранён: не удалось прочитать текущую конфигурацию, поэтому ничего не изменено",
|
||||
"Could not write the {store} store": "Не удалось записать хранилище {store}",
|
||||
"Could not check ChatGPT sign-in. Try again.": "Не удалось проверить вход в ChatGPT. Попробуйте ещё раз.",
|
||||
"Cloud voice will use OPENAI_API_KEY on the next session.": "Следующая облачная голосовая сессия будет использовать OPENAI_API_KEY.",
|
||||
"Checking local ChatGPT OAuth sign-in…": "Проверка локального входа в ChatGPT через OAuth…",
|
||||
"Could not check ChatGPT sign-in.": "Не удалось проверить вход в ChatGPT.",
|
||||
"ChatGPT OAuth selected for cloud voice. Your API key stays saved and available.": "Для облачного голоса выбран ChatGPT OAuth. Ваш ключ API остаётся сохранённым и доступным.",
|
||||
"ChatGPT sign-in expired. Opening sign-in again…": "Сеанс ChatGPT истёк. Открываем вход заново…",
|
||||
"Opening ChatGPT sign-in in your browser…": "Открываем вход в ChatGPT в браузере…",
|
||||
"Could not start ChatGPT sign-in on this machine.": "Не удалось начать вход в ChatGPT на этом компьютере.",
|
||||
"Finish ChatGPT sign-in in the browser. Waiting for it to complete…": "Завершите вход в ChatGPT в браузере. Ожидание завершения…",
|
||||
"ChatGPT sign-in timed out. Click {button} to try again.": "Время на вход в ChatGPT истекло. Нажмите «{button}», чтобы попробовать ещё раз.",
|
||||
"ChatGPT sign-in complete. OAuth will be used for the next cloud voice session.": "Вход в ChatGPT выполнен. В следующей облачной голосовой сессии будет использоваться OAuth.",
|
||||
"OAuth check failed: {detail}": "Не удалось проверить OAuth: {detail}",
|
||||
"unsaved CCTV calibration": "несохранённая калибровка CCTV",
|
||||
"the open cockpit": "открытая кабина",
|
||||
"Switching language reloads the page. This will be lost: {items}. Switch anyway?": "Смена языка перезагрузит страницу. Будет потеряно: {items}. Всё равно сменить язык?",
|
||||
"Wind upstream unavailable": "Источник данных о ветре недоступен",
|
||||
"WebUSB needs desktop Chrome or Edge": "Для WebUSB нужен настольный Chrome или Edge",
|
||||
"connect a receiver in Radio": "подключите приёмник в разделе «Радио»",
|
||||
"receiver is in FM mode": "приёмник в режиме FM",
|
||||
"Cyclone advisories unavailable": "Сводки о циклонах недоступны",
|
||||
"Cyclone data unavailable": "Данные о циклонах недоступны",
|
||||
"Drag to size the box": "Потяните, чтобы задать размер области",
|
||||
"Press on the ground, not the sky": "Нажмите на землю, а не на небо",
|
||||
"Point the camera at the ground to use the view": "Наведите камеру на землю, чтобы использовать вид",
|
||||
"That point cannot anchor a box": "От этой точки нельзя построить область",
|
||||
"Box edges must be finite": "Некорректные координаты области",
|
||||
"Longitudes must be within ±180°": "Долгота должна быть в пределах ±180°",
|
||||
"Select one side of the dateline": "Не пересекайте линию перемены дат",
|
||||
"Box must enclose an area": "Область должна иметь площадь",
|
||||
"Imagery stops at ±{limit}° latitude": "Снимки есть только до ±{limit}° широты",
|
||||
"{owner} is using the pointer — close it first": "Указатель занят: «{owner}». Закройте его",
|
||||
"OpenSky OAuth client missing/invalid": "Клиент OAuth OpenSky отсутствует или недействителен",
|
||||
"OpenSky OAuth rejected credentials": "OpenSky OAuth отклонил учётные данные",
|
||||
"OpenSky username/password rejected": "OpenSky отклонил имя пользователя или пароль",
|
||||
"OpenSky auth missing": "Нет данных для входа в OpenSky",
|
||||
"OpenSky auth required": "OpenSky требует входа",
|
||||
"OpenSky auth invalid": "Неверные данные для входа в OpenSky",
|
||||
"OpenSky auth failed": "Не удалось войти в OpenSky",
|
||||
"Source snapshot {minutes} min old": "Возраст снимка источника: {minutes} мин",
|
||||
"{source} rate limited (anonymous)": "{source}: лимит запросов (анонимно)",
|
||||
"{source} rate limited": "{source}: лимит запросов",
|
||||
"{source} HTTP {status}": "{source}: HTTP {status}",
|
||||
"{source} network error": "{source}: ошибка сети",
|
||||
"Malformed {source} aircraft rows": "{source}: некорректные записи о самолётах",
|
||||
"Malformed {source} response": "{source}: некорректный ответ",
|
||||
"Source snapshot time unavailable": "Время снимка источника неизвестно",
|
||||
"Live data unavailable": "Данные в реальном времени недоступны",
|
||||
"{n} aircraft": {
|
||||
"one": "{n} самолёт",
|
||||
"few": "{n} самолёта",
|
||||
"many": "{n} самолётов",
|
||||
"other": "{n} самолёта"
|
||||
},
|
||||
"{n} military aircraft": {
|
||||
"one": "{n} военный самолёт",
|
||||
"few": "{n} военных самолёта",
|
||||
"many": "{n} военных самолётов",
|
||||
"other": "{n} военного самолёта"
|
||||
},
|
||||
"{n} ships": {
|
||||
"one": "{n} судно",
|
||||
"few": "{n} судна",
|
||||
"many": "{n} судов",
|
||||
"other": "{n} судна"
|
||||
},
|
||||
"{n} satellites": {
|
||||
"one": "{n} спутник",
|
||||
"few": "{n} спутника",
|
||||
"many": "{n} спутников",
|
||||
"other": "{n} спутника"
|
||||
},
|
||||
"{n} launches": {
|
||||
"one": "{n} пуск",
|
||||
"few": "{n} пуска",
|
||||
"many": "{n} пусков",
|
||||
"other": "{n} пуска"
|
||||
},
|
||||
"{n} earthquakes": {
|
||||
"one": "{n} землетрясение",
|
||||
"few": "{n} землетрясения",
|
||||
"many": "{n} землетрясений",
|
||||
"other": "{n} землетрясения"
|
||||
},
|
||||
"{n} fires": {
|
||||
"one": "{n} пожар",
|
||||
"few": "{n} пожара",
|
||||
"many": "{n} пожаров",
|
||||
"other": "{n} пожара"
|
||||
},
|
||||
"{n} fire perimeters": {
|
||||
"one": "{n} контур пожара",
|
||||
"few": "{n} контура пожаров",
|
||||
"many": "{n} контуров пожаров",
|
||||
"other": "{n} контура пожаров"
|
||||
},
|
||||
"{n} cyclones": {
|
||||
"one": "{n} циклон",
|
||||
"few": "{n} циклона",
|
||||
"many": "{n} циклонов",
|
||||
"other": "{n} циклона"
|
||||
},
|
||||
"{n} buses": {
|
||||
"one": "{n} автобус",
|
||||
"few": "{n} автобуса",
|
||||
"many": "{n} автобусов",
|
||||
"other": "{n} автобуса"
|
||||
},
|
||||
"{n} bike share": {
|
||||
"one": "{n} станция проката велосипедов",
|
||||
"few": "{n} станции проката велосипедов",
|
||||
"many": "{n} станций проката велосипедов",
|
||||
"other": "{n} станции проката велосипедов"
|
||||
},
|
||||
"{n} flock cameras": {
|
||||
"one": "{n} камера ALPR",
|
||||
"few": "{n} камеры ALPR",
|
||||
"many": "{n} камер ALPR",
|
||||
"other": "{n} камеры ALPR"
|
||||
},
|
||||
"{n} military bases": {
|
||||
"one": "{n} военная база",
|
||||
"few": "{n} военные базы",
|
||||
"many": "{n} военных баз",
|
||||
"other": "{n} военной базы"
|
||||
},
|
||||
"{n} datacenters": {
|
||||
"one": "{n} дата-центр",
|
||||
"few": "{n} дата-центра",
|
||||
"many": "{n} дата-центров",
|
||||
"other": "{n} дата-центра"
|
||||
},
|
||||
"{n} dams": {
|
||||
"one": "{n} плотина",
|
||||
"few": "{n} плотины",
|
||||
"many": "{n} плотин",
|
||||
"other": "{n} плотины"
|
||||
},
|
||||
"{n} results": {
|
||||
"one": "{n} результат",
|
||||
"few": "{n} результата",
|
||||
"many": "{n} результатов",
|
||||
"other": "{n} результата"
|
||||
},
|
||||
"anywhere in the loaded data": "во всех загруженных данных",
|
||||
"in view": "в кадре",
|
||||
"within {radius} of the view centre": "в радиусе {radius} от центра вида",
|
||||
"within {radius} of {place} (Contacts window)": "в радиусе {radius} — {place} (окно «Контактов»)",
|
||||
"within {radius} of {place}": "в радиусе {radius} — {place}",
|
||||
"within {radius}": "в радиусе {radius}",
|
||||
"over {region}": "в регионе {region}",
|
||||
"At least {items}": "{items} или больше",
|
||||
"{items} {scope}": "{items} {scope}",
|
||||
"last 24 h": "за последние 24 ч",
|
||||
"past 30 d and upcoming": "за последние 30 дн. и предстоящие",
|
||||
"the flights layer loads by viewport": "слой рейсов загружается по области вида",
|
||||
"distance is ground distance, not overhead": "расстояние — по поверхности земли до подспутниковой точки, а не до самого аппарата",
|
||||
"the count is a lower bound because at least one layer reached its retained limit.": "это нижняя граница: хотя бы один слой достиг предела хранимых записей.",
|
||||
"{label} loading": "{label}: загрузка",
|
||||
"{label} error": "{label}: ошибка",
|
||||
"counted the first {returned} loaded records": "учтены только первые загруженные записи: {returned}",
|
||||
"counted {returned} of {total} loaded records": "учтено загруженных записей: {returned} из {total}",
|
||||
"{layers} just turned on — still loading, counts will rise": "{layers}: только что включено — данные ещё загружаются, значения вырастут",
|
||||
"Contacts panel snapshot at {time} — the same retained cohort supplies the card, speech, and follow-up memory": "Снимок панели «Контакты» ({time}) — та же сохранённая выборка используется для карточки, речи и памяти уточняющих вопросов",
|
||||
"Contacts panel snapshot — the same retained cohort supplies the card, speech, and follow-up memory": "Снимок панели «Контакты» — та же сохранённая выборка используется для карточки, речи и памяти уточняющих вопросов",
|
||||
"Not answered: {layers}": "Нет ответа: {layers}",
|
||||
"The flights layer is not enabled": "Слой рейсов не включён",
|
||||
"The military layer is not enabled": "Слой военных рейсов не включён",
|
||||
"The satellites layer is not enabled": "Слой спутников не включён",
|
||||
"The ais-live-vessels layer is not enabled": "Слой судов AIS не включён",
|
||||
"Could not enable the requested layer": "Не удалось включить запрошенный слой",
|
||||
"Could not disable the requested layer": "Не удалось выключить запрошенный слой",
|
||||
"Radio layer unavailable": "Слой радио недоступен",
|
||||
"Camera navigation is unavailable in the current view": "Управление камерой недоступно в текущем виде",
|
||||
"Camera navigation policy unavailable": "Правила навигации камеры недоступны",
|
||||
"Nearest-aircraft selection supports Flights or Military Flights only": "Выбор ближайшего самолёта работает только для рейсов и военных рейсов",
|
||||
"Nearest-aircraft selection needs a preset, place name, or latitude and longitude": "Для выбора ближайшего самолёта нужен пресет, название места или широта и долгота",
|
||||
"Could not arrive at the requested place": "Не удалось долететь до запрошенного места",
|
||||
"The nearest airborne aircraft could not be selected": "Не удалось выбрать ближайший самолёт в воздухе",
|
||||
"No flights within {radius} of the current view": "В радиусе {radius} от текущего вида нет рейсов",
|
||||
"No military within {radius} of the current view": "В радиусе {radius} от текущего вида нет военных рейсов",
|
||||
"No satellites within {radius} of the current view": "В радиусе {radius} от текущего вида нет спутников",
|
||||
"No vessels within {radius} of the current view": "В радиусе {radius} от текущего вида нет судов",
|
||||
"{layer} is enabled on the {source} feed, but no airborne aircraft is loaded in the {place} view yet": "{layer}: включено (поток {source}), но в виде «{place}» пока не загружено ни одного самолёта в воздухе",
|
||||
"{layer} is enabled, but no airborne aircraft is loaded in the {place} view yet": "{layer}: включено, но в виде «{place}» пока не загружено ни одного самолёта в воздухе",
|
||||
"{layer} is enabled, but its aircraft feed is unavailable": "{layer}: включено, но поток данных о самолётах недоступен",
|
||||
"{layer} is enabled, but its destination refresh did not complete": "{layer}: включено, но обновление в точке назначения не завершилось",
|
||||
"{layer} is enabled, but {source} is unavailable": "{layer}: включено, но источник {source} недоступен",
|
||||
"Could not arrive at {place}": "Не удалось долететь: {place}",
|
||||
"{name} is no longer loaded": "{name}: объект больше не загружен",
|
||||
"Unknown target layer: {target}": "Неизвестный слой цели: {target}",
|
||||
"Layer": "Слой",
|
||||
"there": "там",
|
||||
"Frame flights": "Показать самолёты в кадре",
|
||||
"Frame military": "Показать военные самолёты в кадре",
|
||||
"Frame vessels": "Показать суда в кадре",
|
||||
"Fly to Location": "Перелёт к месту",
|
||||
"Nearest aircraft to Here": "Ближайший самолёт здесь",
|
||||
"Estimate is incomplete — a response was still in flight when the session ended, so its usage was never reported.": "Оценка неполная: к моменту завершения сессии один из ответов ещё не был получен, поэтому его использование не учтено.",
|
||||
"STANDARD": "СТАНДАРТ",
|
||||
"vehicle motion|Stopped": "Стоит",
|
||||
"ACQUIRING FRAME": "ПОЛУЧЕНИЕ КАДРА",
|
||||
"Camera source temporarily unavailable": "Источник камер временно недоступен",
|
||||
"Perimeter source unavailable": "Источник контуров пожаров недоступен",
|
||||
"Imagery catalog unavailable": "Каталог снимков недоступен",
|
||||
"Earthquake source unavailable": "Источник данных о землетрясениях недоступен",
|
||||
"TeleGeography load failed": "Не удалось загрузить данные TeleGeography",
|
||||
"Wind request timed out": "Истекло время ожидания запроса ветра",
|
||||
"Malformed wind manifest": "Некорректный манифест ветра",
|
||||
"Malformed wind grid": "Некорректная сетка ветра",
|
||||
"Unknown wind model": "Неизвестная модель ветра",
|
||||
"Unknown weather overlay": "Неизвестный погодный слой",
|
||||
"Temperature field unavailable": "Поле температуры недоступно",
|
||||
"Mean sea level pressure field unavailable": "Поле давления на уровне моря недоступно",
|
||||
"KartaView answered HTTP {status}": "KartaView ответил: HTTP {status}",
|
||||
"KartaView refused the request (code {code})": "KartaView отклонил запрос (код {code})",
|
||||
"Stopped by voice": "Остановлено голосовой командой",
|
||||
"drawings on the map": "рисунки на карте",
|
||||
"imported map files": "импортированные файлы на карте",
|
||||
"the active voice session": "активная голосовая сессия",
|
||||
"Light aircraft": "Лёгкий самолёт",
|
||||
"Glider": "Планёр",
|
||||
"Turboprop": "Турбовинтовой самолёт",
|
||||
"Airliner": "Авиалайнер",
|
||||
"Widebody": "Широкофюзеляжный самолёт",
|
||||
"Four-engine jet": "Четырёхмоторный реактивный самолёт",
|
||||
"Helicopter": "Вертолёт",
|
||||
"Fast jet": "Высокоманёвренный самолёт",
|
||||
"Business jet": "Бизнес-джет",
|
||||
"Drone": "Беспилотник",
|
||||
"fire cause|Human": "деятельность человека",
|
||||
"fire cause|Natural": "природная",
|
||||
"fire cause|Undetermined": "не установлена",
|
||||
"fire cause|Unknown": "неизвестна",
|
||||
"fire behavior|Minimal": "минимальная активность",
|
||||
"fire behavior|Moderate": "умеренная активность",
|
||||
"fire behavior|Active": "высокая активность",
|
||||
"fire behavior|Extreme": "экстремальная активность",
|
||||
"Type {level} Incident": "инцидент {level}-го типа",
|
||||
"${amount}K": "{amount} тыс. $",
|
||||
"${amount}M": "{amount} млн $",
|
||||
"${amount}B": "{amount} млрд $",
|
||||
"{speed} kn": "{speed} уз",
|
||||
"1090 MHz": "1090 МГц",
|
||||
"978 MHz UAT": "978 МГц UAT",
|
||||
"feed status|live": "онлайн",
|
||||
"feed status|stale": "устарел",
|
||||
"feed status|unreachable": "недоступен",
|
||||
"feed status|invalid": "некорректный",
|
||||
"Vector tile metadata unavailable": "Метаданные векторных тайлов недоступны",
|
||||
"Vector tiles unavailable": "Векторные тайлы недоступны",
|
||||
"Vector tiles unavailable (HTTP {status})": "Векторные тайлы недоступны (HTTP {status})",
|
||||
"Vector tile request timed out": "Векторные тайлы: истекло время ожидания",
|
||||
"Zoom in for vector tile coverage": "Приблизьте карту, чтобы загрузить векторные тайлы",
|
||||
"Camera coverage unavailable": "Покрытие камер недоступно",
|
||||
"Hybrid": "Гибрид",
|
||||
"TomTom roads only, each with its live flow": "Только дороги TomTom, у каждой — свой поток онлайн",
|
||||
"TomTom roads only, each with its live flow (needs a TomTom key)": "Только дороги TomTom, у каждой — свой поток онлайн (нужен ключ TomTom)",
|
||||
"OpenStreetMap roads, with TomTom flow matched when available": "Дороги OpenStreetMap; поток TomTom сопоставляется, где он есть",
|
||||
"TomTom roads first; OpenStreetMap fills the rest, simulated": "Сначала дороги TomTom; остальное дополняет OpenStreetMap с симуляцией потока",
|
||||
"TomTom roads first; OpenStreetMap fills the rest, simulated (needs a TomTom key)": "Сначала дороги TomTom; остальное дополняет OpenStreetMap с симуляцией потока (нужен ключ TomTom)",
|
||||
"Austin": "Остин",
|
||||
"San Francisco": "Сан-Франциско",
|
||||
"New York": "Нью-Йорк",
|
||||
"Tokyo": "Токио",
|
||||
"London": "Лондон",
|
||||
"Paris": "Париж",
|
||||
"Dubai": "Дубай",
|
||||
"Washington DC": "Вашингтон",
|
||||
"Tallinn": "Таллин",
|
||||
"Los Angeles": "Лос-Анджелес",
|
||||
"Data attribution": "Источники данных",
|
||||
"Data provided by:": "Данные предоставлены:",
|
||||
"Exit cockpit to view weather coverage": "Выйдите из кабины, чтобы показать зону покрытия погодного слоя",
|
||||
"Exit cockpit to fly to a cyclone": "Выйдите из кабины, чтобы перелететь к циклону",
|
||||
"the title of scene {scene} is empty or longer than {max} characters": "название сцены {scene} пустое или длиннее {max} символов",
|
||||
"the title of shot {shot} in scene {scene} is empty or longer than {max} characters": "название кадра {shot} в сцене {scene} пустое или длиннее {max} символов",
|
||||
"the project is larger than 5 MiB": "проект больше 5 МиБ",
|
||||
"the project has too many shots": "в проекте слишком много кадров",
|
||||
"the scene data failed validation": "данные сцены не прошли проверку",
|
||||
"Layer on": "Слой: вкл.",
|
||||
"Layer off": "Слой: выкл.",
|
||||
"Zoom in": "Приблизить",
|
||||
"Zoom out": "Отдалить",
|
||||
"Zoom out to globe": "Отдалить до глобуса",
|
||||
"Open data layers": "Открыть слои данных",
|
||||
"Open or close a panel": "Открыть или закрыть панель",
|
||||
"Change context mode": "Сменить режим контекста",
|
||||
"Control cockpit": "Управлять кабиной",
|
||||
"Change visual style": "Сменить визуальный стиль",
|
||||
"Adjust HUD": "Настроить HUD",
|
||||
"Adjust cyber sonar": "Настроить кибер-сонар",
|
||||
"Adjust detection overlay": "Настроить слой детекции",
|
||||
"Change map stack": "Сменить набор карт",
|
||||
"Adjust post-processing": "Настроить постобработку",
|
||||
"Control scene": "Управлять сценой",
|
||||
"Control CCTV": "Управлять камерами CCTV",
|
||||
"Control radio": "Управлять радио",
|
||||
"Track contact": "Отслеживать контакт",
|
||||
"Stop tracking": "Остановить отслеживание",
|
||||
"Clear marks": "Убрать отметки",
|
||||
"Move camera": "Переместить камеру",
|
||||
"Fly route": "Пролететь по маршруту",
|
||||
"Analyst query": "Аналитический запрос",
|
||||
"Next ISS pass": "Следующий пролёт МКС",
|
||||
"Next satellite pass": "Следующий пролёт спутника",
|
||||
"Action": "Действие"
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
checkCatalog,
|
||||
extractMessages,
|
||||
missingTemplate,
|
||||
} from '../../scripts/i18n.mjs';
|
||||
|
||||
// A plural entry whose every form is still the English text must be reported
|
||||
// the same way a string entry is ("is still English"). Today the plural
|
||||
// branch of checkCatalog never compares forms with the English.
|
||||
const fixture = new Map([
|
||||
['{count} cameras', { plural: true, one: '{count} camera' }],
|
||||
['Save keys', { plural: false }],
|
||||
]);
|
||||
|
||||
test('string entry left in English is reported (control)', () => {
|
||||
const { errors } = checkCatalog(
|
||||
'de',
|
||||
{
|
||||
'Save keys': 'Save keys',
|
||||
'{count} cameras': { one: 'x {count}', other: 'y {count}' },
|
||||
},
|
||||
fixture,
|
||||
new Set(),
|
||||
);
|
||||
assert.equal(errors.length, 1);
|
||||
assert.match(errors[0], /is still English/);
|
||||
});
|
||||
|
||||
for (const [locale, entry] of [
|
||||
['de', { one: '{count} camera', other: '{count} cameras' }],
|
||||
['ja', { other: '{count} cameras' }],
|
||||
['zh-CN', { other: '{count} cameras' }],
|
||||
[
|
||||
'es',
|
||||
{
|
||||
one: '{count} camera',
|
||||
many: '{count} cámaras',
|
||||
other: '{count} cameras',
|
||||
},
|
||||
],
|
||||
[
|
||||
'ru',
|
||||
{
|
||||
one: '{count} camera',
|
||||
few: '{count} камеры',
|
||||
many: '{count} камер',
|
||||
other: '{count} cameras',
|
||||
},
|
||||
],
|
||||
]) {
|
||||
test(`${locale}: plural entry with English forms is reported`, () => {
|
||||
const { errors } = checkCatalog(
|
||||
locale,
|
||||
{ 'Save keys': 'Schlüssel', '{count} cameras': entry },
|
||||
fixture,
|
||||
new Set(),
|
||||
);
|
||||
assert.ok(
|
||||
errors.some((e) => /is still English/.test(e)),
|
||||
`expected an "is still English" error, got ${JSON.stringify(errors)}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// `npm run i18n -- --missing <code>` prints a template; pasted without edits,
|
||||
// none of its entries may pass as a translation (K-21, K-52), and a plural
|
||||
// entry carries every form the language needs (K-97).
|
||||
const messages = extractMessages();
|
||||
for (const locale of ['de', 'ja', 'es', 'ru']) {
|
||||
test(`${locale}: an untouched --missing template fails the check entry by entry`, () => {
|
||||
const template = missingTemplate(locale, {}, messages);
|
||||
assert.equal(Object.keys(template).length, messages.size);
|
||||
const categories = new Intl.PluralRules(locale).resolvedOptions()
|
||||
.pluralCategories;
|
||||
for (const [text, info] of messages)
|
||||
if (info.plural)
|
||||
assert.deepEqual(Object.keys(template[text]), categories, text);
|
||||
const { missing, errors } = checkCatalog(
|
||||
locale,
|
||||
template,
|
||||
messages,
|
||||
new Set(),
|
||||
);
|
||||
assert.deepEqual(missing, []);
|
||||
const accepted = [...messages.keys()].filter(
|
||||
(text) => !errors.some((error) => error.startsWith(`"${text}" `)),
|
||||
);
|
||||
assert.deepEqual(accepted, []);
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
{
|
||||
"es": [
|
||||
"1090 MHz",
|
||||
"978 MHz UAT",
|
||||
"AIS: --",
|
||||
"AIS: {name}",
|
||||
"ANIME",
|
||||
@@ -7,6 +9,7 @@
|
||||
"AUTO",
|
||||
"Ambient",
|
||||
"Anime",
|
||||
"Austin",
|
||||
"BRG {value}°",
|
||||
"BRG —",
|
||||
"Blues",
|
||||
@@ -79,6 +82,7 @@
|
||||
"SRC {value}",
|
||||
"SSE",
|
||||
"STD",
|
||||
"San Francisco",
|
||||
"Sector",
|
||||
"Sonar",
|
||||
"Soul",
|
||||
@@ -103,6 +107,7 @@
|
||||
"panel",
|
||||
"view band|GLOBAL",
|
||||
"voice|ERROR",
|
||||
"{speed} kn",
|
||||
"{type} SPD: {speed} HDG: {heading}",
|
||||
"{value} KM",
|
||||
"{value} KM/H",
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
// K-23: scripts/i18n.mjs must skip index entries that are gone from the
|
||||
// working tree (plain `mv`/`rm` without `git mv`/`git rm`).
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import {
|
||||
mkdtempSync,
|
||||
mkdirSync,
|
||||
writeFileSync,
|
||||
copyFileSync,
|
||||
renameSync,
|
||||
rmSync,
|
||||
} from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
|
||||
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
function sandbox() {
|
||||
const root = mkdtempSync(path.join(tmpdir(), 'k23-'));
|
||||
mkdirSync(path.join(root, 'scripts'));
|
||||
mkdirSync(path.join(root, 'src/i18n/locales'), { recursive: true });
|
||||
copyFileSync(
|
||||
path.join(HERE, '../../scripts/i18n.mjs'),
|
||||
path.join(root, 'scripts/i18n.mjs'),
|
||||
);
|
||||
writeFileSync(path.join(root, 'src/a.js'), "export const x = t('Hello');\n");
|
||||
writeFileSync(path.join(root, 'index.html'), '<p data-i18n>Hello</p>\n');
|
||||
const git = (...args) =>
|
||||
execFileSync('git', args, { cwd: root, encoding: 'utf8' });
|
||||
git('init', '-q');
|
||||
git('add', '-A');
|
||||
git('-c', 'user.email=k@23', '-c', 'user.name=k23', 'commit', '-qm', 'init');
|
||||
return root;
|
||||
}
|
||||
|
||||
test('extractMessages survives a module renamed with plain mv', async () => {
|
||||
const root = sandbox();
|
||||
try {
|
||||
renameSync(path.join(root, 'src/a.js'), path.join(root, 'src/b.js'));
|
||||
const mod = await import(
|
||||
pathToFileURL(path.join(root, 'scripts/i18n.mjs')).href
|
||||
);
|
||||
const messages = mod.extractMessages();
|
||||
assert.ok(messages.has('Hello'));
|
||||
assert.doesNotThrow(() => mod.findUnmarked());
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
+126
-20
@@ -54,6 +54,74 @@ export function collectKeyUpdates(fields) {
|
||||
return updates;
|
||||
}
|
||||
|
||||
/**
|
||||
* Why /api/setup/keys refused a save, by the `code` the server sends with its
|
||||
* English `error` (src/keySetupCore.mjs, server/standalone/key-setup.js). The
|
||||
* English here is the server's own sentence, so the default locale reads the
|
||||
* same as before.
|
||||
*/
|
||||
const SAVE_REFUSALS = Object.freeze({
|
||||
proxied: msg('Provider Settings does not answer proxied requests'),
|
||||
sharing: msg('Provider Settings is disabled while sharing is enabled'),
|
||||
'not-loopback': msg(
|
||||
'Provider Settings answers only the machine running the server',
|
||||
),
|
||||
'not-local-host': msg('Provider Settings answers only local hostnames'),
|
||||
'origin-required': msg('Provider Settings requires an exact local Origin'),
|
||||
'origin-unrecognized': msg('Unrecognized Origin refused'),
|
||||
'cross-origin': msg('Cross-origin requests are refused'),
|
||||
'content-type': msg('Content-Type must be application/json'),
|
||||
method: msg('Method not allowed'),
|
||||
'too-large': msg('Request too large'),
|
||||
'invalid-json': msg('Invalid JSON'),
|
||||
'not-object': msg('Body must be a JSON object of {ENV_VAR: value}'),
|
||||
'no-keys': msg('No keys provided'),
|
||||
'too-many': msg('At most {limit} keys per save'),
|
||||
'unknown-key': msg('Unknown key: {name}'),
|
||||
'not-string': msg('{name} must be a string'),
|
||||
empty: msg('{name} is empty'),
|
||||
'too-long': msg('{name} is longer than any real key ({limit} max)'),
|
||||
'not-printable': msg(
|
||||
'{name} may only contain printable characters with no spaces',
|
||||
),
|
||||
'invalid-char': msg(
|
||||
'{name} contains a character that is not valid in a key (#, quotes, $, \\, or backtick)',
|
||||
),
|
||||
external: msg(
|
||||
'{name} is configured outside Provider Settings and can only be changed where it was set',
|
||||
),
|
||||
'harden-failed': msg(
|
||||
'The key was not saved: could not restrict the credential file to your account; nothing was saved',
|
||||
),
|
||||
'store-unreadable': msg(
|
||||
'The key was not saved: the existing configuration could not be read, so nothing was changed',
|
||||
),
|
||||
'write-failed': msg('Could not write the {store} store'),
|
||||
});
|
||||
|
||||
/**
|
||||
* The status line for a refused save — pure, exported for tests. A known
|
||||
* `code` reads in the active language; anything else shows the server's text.
|
||||
* @param {{code?: string, params?: object, error?: string}} payload
|
||||
* @param {number} status HTTP status of the response.
|
||||
* @returns {string}
|
||||
*/
|
||||
export function saveRefusalText(payload, status) {
|
||||
const pattern = Object.hasOwn(SAVE_REFUSALS, payload?.code)
|
||||
? SAVE_REFUSALS[payload.code]
|
||||
: null;
|
||||
if (pattern) {
|
||||
const values = {};
|
||||
for (const name of ['name', 'limit', 'store']) {
|
||||
const value = payload.params?.[name];
|
||||
if (typeof value === 'string' || typeof value === 'number')
|
||||
values[name] = value;
|
||||
}
|
||||
return t(pattern, values);
|
||||
}
|
||||
return payload?.error || t('Save failed ({status}).', { status });
|
||||
}
|
||||
|
||||
function sleepWithSignal(ms, signal) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (signal?.aborted) {
|
||||
@@ -72,6 +140,36 @@ function sleepWithSignal(ms, signal) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* An OAuth check error. A server-sent reason is kept as-is and later wrapped
|
||||
* in «OAuth check failed: …»; when there is none, the error carries our own
|
||||
* already-translated sentence and is flagged so the toast shows it unwrapped
|
||||
* (the wrapper would only repeat it).
|
||||
* @param {unknown} serverError
|
||||
* @param {string} fallback Already-translated fallback sentence.
|
||||
* @returns {Error}
|
||||
*/
|
||||
function oauthCheckError(serverError, fallback) {
|
||||
if (serverError) return new Error(String(serverError));
|
||||
const error = new Error(fallback);
|
||||
error.oauthFallback = true;
|
||||
return error;
|
||||
}
|
||||
|
||||
/**
|
||||
* Status text for a failed OAuth check — pure, exported for tests. A fallback
|
||||
* sentence of our own already says what failed and is shown alone; any other
|
||||
* reason (server text, network error) gets the «OAuth check failed» wrapper.
|
||||
* @param {unknown} error
|
||||
* @returns {string}
|
||||
*/
|
||||
export function oauthCheckFailureText(error) {
|
||||
if (error?.oauthFallback && error.message) return error.message;
|
||||
return t('OAuth check failed: {detail}', {
|
||||
detail: error?.message || error,
|
||||
});
|
||||
}
|
||||
|
||||
export async function waitForChatGptOAuth({
|
||||
fetchImpl,
|
||||
signal,
|
||||
@@ -90,8 +188,9 @@ export async function waitForChatGptOAuth({
|
||||
if (signal?.aborted) return false;
|
||||
if (response.ok && payload.available) return true;
|
||||
if (!response.ok || payload.loginFailed) {
|
||||
throw new Error(
|
||||
payload.error || 'Could not check ChatGPT sign-in. Try again.',
|
||||
throw oauthCheckError(
|
||||
payload.error,
|
||||
t('Could not check ChatGPT sign-in. Try again.'),
|
||||
);
|
||||
}
|
||||
if (now() >= deadline) break;
|
||||
@@ -415,11 +514,7 @@ export async function initKeySetup({
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (disposed) return;
|
||||
if (!response.ok || !payload.ok) {
|
||||
// payload.error is a server-authored message (API value) — shown as-is.
|
||||
say(
|
||||
payload.error ||
|
||||
t('Save failed ({status}).', { status: response.status }),
|
||||
);
|
||||
say(saveRefusalText(payload, response.status));
|
||||
return;
|
||||
}
|
||||
for (const input of root.querySelectorAll('input[data-env-var]'))
|
||||
@@ -496,12 +591,12 @@ export async function initKeySetup({
|
||||
if (current === 'oauth') {
|
||||
writeStoredCloudVoiceAuthMode('api-key');
|
||||
syncCloudVoiceAuth();
|
||||
say('Cloud voice will use OPENAI_API_KEY on the next session.');
|
||||
say(t('Cloud voice will use OPENAI_API_KEY on the next session.'));
|
||||
return;
|
||||
}
|
||||
oauthBusy = true;
|
||||
syncCloudVoiceAuth();
|
||||
say('Checking local ChatGPT OAuth sign-in…');
|
||||
say(t('Checking local ChatGPT OAuth sign-in…'));
|
||||
try {
|
||||
const response = await doFetch('/api/realtime/oauth-status', {
|
||||
cache: 'no-store',
|
||||
@@ -510,22 +605,25 @@ export async function initKeySetup({
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (lifetime.signal.aborted) return;
|
||||
if (!response.ok)
|
||||
throw new Error(
|
||||
payload.error || 'Could not check ChatGPT sign-in.',
|
||||
throw oauthCheckError(
|
||||
payload.error,
|
||||
t('Could not check ChatGPT sign-in.'),
|
||||
);
|
||||
if (payload.available) {
|
||||
writeStoredCloudVoiceAuthMode('oauth');
|
||||
syncCloudVoiceAuth();
|
||||
say(
|
||||
'ChatGPT OAuth selected for cloud voice. Your API key stays saved and available.',
|
||||
t(
|
||||
'ChatGPT OAuth selected for cloud voice. Your API key stays saved and available.',
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
say(
|
||||
payload.code === 'CODEX_OAUTH_REAUTH_REQUIRED'
|
||||
? 'ChatGPT sign-in expired. Opening sign-in again…'
|
||||
: 'Opening ChatGPT sign-in in your browser…',
|
||||
? t('ChatGPT sign-in expired. Opening sign-in again…')
|
||||
: t('Opening ChatGPT sign-in in your browser…'),
|
||||
);
|
||||
const loginResponse = await doFetch('/api/realtime/oauth-login', {
|
||||
method: 'POST',
|
||||
@@ -538,7 +636,7 @@ export async function initKeySetup({
|
||||
say(
|
||||
loginPayload.error ||
|
||||
payload.error ||
|
||||
'Could not start ChatGPT sign-in on this machine.',
|
||||
t('Could not start ChatGPT sign-in on this machine.'),
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -546,13 +644,17 @@ export async function initKeySetup({
|
||||
writeStoredCloudVoiceAuthMode('oauth');
|
||||
syncCloudVoiceAuth();
|
||||
say(
|
||||
'ChatGPT OAuth selected for cloud voice. Your API key stays saved and available.',
|
||||
t(
|
||||
'ChatGPT OAuth selected for cloud voice. Your API key stays saved and available.',
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
say(
|
||||
'Finish ChatGPT sign-in in the browser. Waiting for it to complete…',
|
||||
t(
|
||||
'Finish ChatGPT sign-in in the browser. Waiting for it to complete…',
|
||||
),
|
||||
);
|
||||
const available = await waitForChatGptOAuth({
|
||||
fetchImpl: doFetch,
|
||||
@@ -561,18 +663,22 @@ export async function initKeySetup({
|
||||
if (lifetime.signal.aborted) return;
|
||||
if (!available) {
|
||||
say(
|
||||
'ChatGPT sign-in timed out. Click USE CHATGPT OAUTH to try again.',
|
||||
t('ChatGPT sign-in timed out. Click {button} to try again.', {
|
||||
button: t('USE CHATGPT OAUTH'),
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
writeStoredCloudVoiceAuthMode('oauth');
|
||||
syncCloudVoiceAuth();
|
||||
say(
|
||||
'ChatGPT sign-in complete. OAuth will be used for the next cloud voice session.',
|
||||
t(
|
||||
'ChatGPT sign-in complete. OAuth will be used for the next cloud voice session.',
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
if (lifetime.signal.aborted) return;
|
||||
say(`OAuth check failed: ${error?.message || error}`);
|
||||
say(oauthCheckFailureText(error));
|
||||
} finally {
|
||||
oauthBusy = false;
|
||||
if (!disposed) syncCloudVoiceAuth();
|
||||
|
||||
+49
-6
@@ -241,7 +241,9 @@ export function isSharingEnabled(env = {}) {
|
||||
* preflight this server never answers, closing the simple-request CSRF
|
||||
* write primitive.
|
||||
*
|
||||
* @returns {{ok: true} | {ok: false, status: number, error: string}}
|
||||
* A refusal also carries `code`, which the panel turns into its own localized
|
||||
* sentence (keySetup.js); `error` stays the English for logs and API callers.
|
||||
* @returns {{ok: true} | {ok: false, status: number, error: string, code: string}}
|
||||
*/
|
||||
export function admitKeySetupRequest({
|
||||
method,
|
||||
@@ -262,6 +264,7 @@ export function admitKeySetupRequest({
|
||||
ok: false,
|
||||
status: 403,
|
||||
error: 'Provider Settings does not answer proxied requests',
|
||||
code: 'proxied',
|
||||
};
|
||||
}
|
||||
// Every sharing signal the launcher recognizes (scripts/pinokio-preflight.mjs)
|
||||
@@ -279,6 +282,7 @@ export function admitKeySetupRequest({
|
||||
ok: false,
|
||||
status: 403,
|
||||
error: 'Provider Settings is disabled while sharing is enabled',
|
||||
code: 'sharing',
|
||||
};
|
||||
}
|
||||
if (!LOOPBACK_ADDRESSES.has(String(remoteAddress || ''))) {
|
||||
@@ -286,6 +290,7 @@ export function admitKeySetupRequest({
|
||||
ok: false,
|
||||
status: 403,
|
||||
error: 'Provider Settings answers only the machine running the server',
|
||||
code: 'not-loopback',
|
||||
};
|
||||
}
|
||||
const authority = localAuthority(hostHeader, protocol);
|
||||
@@ -294,6 +299,7 @@ export function admitKeySetupRequest({
|
||||
ok: false,
|
||||
status: 403,
|
||||
error: 'Provider Settings answers only local hostnames',
|
||||
code: 'not-local-host',
|
||||
};
|
||||
}
|
||||
if (
|
||||
@@ -304,6 +310,7 @@ export function admitKeySetupRequest({
|
||||
ok: false,
|
||||
status: 403,
|
||||
error: 'Provider Settings requires an exact local Origin',
|
||||
code: 'origin-required',
|
||||
};
|
||||
}
|
||||
if (origin !== undefined && origin !== null && origin !== '') {
|
||||
@@ -311,7 +318,12 @@ export function admitKeySetupRequest({
|
||||
try {
|
||||
parsedOrigin = new URL(String(origin));
|
||||
} catch {
|
||||
return { ok: false, status: 403, error: 'Unrecognized Origin refused' };
|
||||
return {
|
||||
ok: false,
|
||||
status: 403,
|
||||
error: 'Unrecognized Origin refused',
|
||||
code: 'origin-unrecognized',
|
||||
};
|
||||
}
|
||||
const exactOrigin =
|
||||
parsedOrigin.username === '' &&
|
||||
@@ -325,6 +337,7 @@ export function admitKeySetupRequest({
|
||||
ok: false,
|
||||
status: 403,
|
||||
error: 'Cross-origin requests are refused',
|
||||
code: 'cross-origin',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -338,6 +351,7 @@ export function admitKeySetupRequest({
|
||||
ok: false,
|
||||
status: 415,
|
||||
error: 'Content-Type must be application/json',
|
||||
code: 'content-type',
|
||||
};
|
||||
}
|
||||
return { ok: true };
|
||||
@@ -447,6 +461,8 @@ export function keySetupStatus(env = {}) {
|
||||
* below safe to write without quoting rules. A `null` value means REMOVE:
|
||||
* the writer comments the assignment back out, returning the file to its
|
||||
* template state for that key.
|
||||
* A refusal carries `code` and `params` for the panel's localized sentence
|
||||
* (keySetup.js) besides the English `error`.
|
||||
* @param {unknown} body Parsed JSON from the request.
|
||||
*/
|
||||
export function validateKeySetupUpdates(body) {
|
||||
@@ -454,38 +470,63 @@ export function validateKeySetupUpdates(body) {
|
||||
return {
|
||||
ok: false,
|
||||
error: 'Body must be a JSON object of {ENV_VAR: value}',
|
||||
code: 'not-object',
|
||||
};
|
||||
}
|
||||
const entries = Object.entries(body);
|
||||
if (entries.length === 0) return { ok: false, error: 'No keys provided' };
|
||||
if (entries.length === 0)
|
||||
return { ok: false, error: 'No keys provided', code: 'no-keys' };
|
||||
if (entries.length > KEY_SETUP_UPDATE_LIMIT) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `At most ${KEY_SETUP_UPDATE_LIMIT} keys per save`,
|
||||
code: 'too-many',
|
||||
params: { limit: KEY_SETUP_UPDATE_LIMIT },
|
||||
};
|
||||
}
|
||||
const known = knownKeySetupEnvVars();
|
||||
const updates = {};
|
||||
for (const [name, raw] of entries) {
|
||||
if (!known.has(name)) return { ok: false, error: `Unknown key: ${name}` };
|
||||
if (!known.has(name))
|
||||
return {
|
||||
ok: false,
|
||||
error: `Unknown key: ${name}`,
|
||||
code: 'unknown-key',
|
||||
params: { name },
|
||||
};
|
||||
if (raw === null) {
|
||||
updates[name] = null;
|
||||
continue;
|
||||
}
|
||||
if (typeof raw !== 'string')
|
||||
return { ok: false, error: `${name} must be a string` };
|
||||
return {
|
||||
ok: false,
|
||||
error: `${name} must be a string`,
|
||||
code: 'not-string',
|
||||
params: { name },
|
||||
};
|
||||
const value = raw.trim();
|
||||
if (!value) return { ok: false, error: `${name} is empty` };
|
||||
if (!value)
|
||||
return {
|
||||
ok: false,
|
||||
error: `${name} is empty`,
|
||||
code: 'empty',
|
||||
params: { name },
|
||||
};
|
||||
if (value.length > KEY_SETUP_VALUE_LIMIT) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `${name} is longer than any real key (${KEY_SETUP_VALUE_LIMIT} max)`,
|
||||
code: 'too-long',
|
||||
params: { name, limit: KEY_SETUP_VALUE_LIMIT },
|
||||
};
|
||||
}
|
||||
if (!/^[\x21-\x7e]+$/.test(value)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `${name} may only contain printable characters with no spaces`,
|
||||
code: 'not-printable',
|
||||
params: { name },
|
||||
};
|
||||
}
|
||||
// Reject the dotenv metacharacters that would round-trip WRONG when written
|
||||
@@ -497,6 +538,8 @@ export function validateKeySetupUpdates(body) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `${name} contains a character that is not valid in a key (#, quotes, $, \\, or backtick)`,
|
||||
code: 'invalid-char',
|
||||
params: { name },
|
||||
};
|
||||
}
|
||||
updates[name] = value;
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
// K-25: a refused key save reads in the panel's language. The server sends a
|
||||
// `code` (and `params`) beside its English `error`; the panel builds the
|
||||
// sentence through t(), so English stays the server's own text and es/ru get
|
||||
// the catalog's.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
admitKeySetupRequest,
|
||||
KEY_SETUP_UPDATE_LIMIT,
|
||||
validateKeySetupUpdates,
|
||||
} from './keySetupCore.mjs';
|
||||
import { collectKeyUpdates, saveRefusalText } from './keySetup.js';
|
||||
import { setLocale } from './i18n/index.js';
|
||||
import { extractMessages } from '../scripts/i18n.mjs';
|
||||
|
||||
const local = {
|
||||
method: 'POST',
|
||||
remoteAddress: '127.0.0.1',
|
||||
hostHeader: 'localhost:4173',
|
||||
origin: 'http://localhost:4173',
|
||||
contentType: 'application/json',
|
||||
};
|
||||
|
||||
// Every refusal the core can return, plus the ones only the endpoint sends
|
||||
// (server/standalone/key-setup.js), with the English the server writes.
|
||||
const refusals = () => [
|
||||
validateKeySetupUpdates(
|
||||
collectKeyUpdates([{ envVar: 'OPENAI_API_KEY', value: '"sk-abc123"' }]),
|
||||
),
|
||||
validateKeySetupUpdates(
|
||||
collectKeyUpdates([{ envVar: 'OPENAI_API_KEY', value: 'sk abc123' }]),
|
||||
),
|
||||
validateKeySetupUpdates({ OPENAI_API_KEY: 'x'.repeat(600) }),
|
||||
validateKeySetupUpdates({ OPENAI_API_KEY: ' ' }),
|
||||
validateKeySetupUpdates({ OPENAI_API_KEY: 42 }),
|
||||
validateKeySetupUpdates({ PATH: '/usr/bin' }),
|
||||
validateKeySetupUpdates({}),
|
||||
validateKeySetupUpdates([]),
|
||||
validateKeySetupUpdates(
|
||||
Object.fromEntries(
|
||||
Array.from({ length: KEY_SETUP_UPDATE_LIMIT + 1 }, (_, i) => [
|
||||
`K${i}`,
|
||||
'v',
|
||||
]),
|
||||
),
|
||||
),
|
||||
admitKeySetupRequest({ ...local, proxyHeaders: { 'x-forwarded-for': '1' } }),
|
||||
admitKeySetupRequest({
|
||||
...local,
|
||||
env: { PINOKIO_SHARE_CLOUDFLARE: 'true' },
|
||||
}),
|
||||
admitKeySetupRequest({ ...local, remoteAddress: '192.168.1.20' }),
|
||||
admitKeySetupRequest({ ...local, hostHeader: 'workstation.local:4173' }),
|
||||
admitKeySetupRequest({ ...local, origin: undefined }),
|
||||
admitKeySetupRequest({ ...local, origin: 'not a url' }),
|
||||
admitKeySetupRequest({ ...local, origin: 'https://evil.example' }),
|
||||
admitKeySetupRequest({ ...local, contentType: 'text/plain' }),
|
||||
{ error: 'Method not allowed', code: 'method' },
|
||||
{ error: 'Request too large', code: 'too-large' },
|
||||
{ error: 'Invalid JSON', code: 'invalid-json' },
|
||||
{
|
||||
error:
|
||||
'OPENAI_API_KEY is configured outside Provider Settings and can only be changed where it was set',
|
||||
code: 'external',
|
||||
params: { name: 'OPENAI_API_KEY' },
|
||||
},
|
||||
{
|
||||
error:
|
||||
'The key was not saved: could not restrict the credential file to your account; nothing was saved',
|
||||
code: 'harden-failed',
|
||||
},
|
||||
{
|
||||
error:
|
||||
'The key was not saved: the existing configuration could not be read, so nothing was changed',
|
||||
code: 'store-unreadable',
|
||||
},
|
||||
{
|
||||
error: 'Could not write the env-file store',
|
||||
code: 'write-failed',
|
||||
params: { store: 'env-file' },
|
||||
},
|
||||
];
|
||||
|
||||
test('every refusal carries a code', () => {
|
||||
for (const refusal of refusals()) {
|
||||
assert.equal(refusal.ok ?? false, false);
|
||||
assert.equal(typeof refusal.code, 'string', refusal.error);
|
||||
}
|
||||
});
|
||||
|
||||
test('in English the panel shows exactly the server sentence', () => {
|
||||
setLocale('en');
|
||||
for (const refusal of refusals())
|
||||
assert.equal(saveRefusalText(refusal, 400), refusal.error);
|
||||
});
|
||||
|
||||
test('an unknown code or a bare error falls back as before', () => {
|
||||
setLocale('en');
|
||||
assert.equal(
|
||||
saveRefusalText({ error: 'Something new', code: 'new' }, 400),
|
||||
'Something new',
|
||||
);
|
||||
assert.equal(saveRefusalText({}, 502), 'Save failed (502).');
|
||||
});
|
||||
|
||||
test('the refusal sentences are catalog messages', () => {
|
||||
const messages = extractMessages();
|
||||
setLocale('en');
|
||||
for (const refusal of refusals()) {
|
||||
const pattern = saveRefusalText(
|
||||
{
|
||||
code: refusal.code,
|
||||
params: { name: '{name}', limit: '{limit}', store: '{store}' },
|
||||
},
|
||||
400,
|
||||
);
|
||||
assert.ok(messages.has(pattern), pattern);
|
||||
}
|
||||
});
|
||||
|
||||
for (const locale of ['es', 'ru']) {
|
||||
test(`${locale}: a refused save reads in the panel language`, () => {
|
||||
setLocale(locale);
|
||||
try {
|
||||
for (const refusal of refusals()) {
|
||||
const text = saveRefusalText(refusal, 400);
|
||||
assert.notEqual(text, refusal.error, `${locale}: ${refusal.error}`);
|
||||
if (refusal.params?.name) assert.ok(text.includes(refusal.params.name));
|
||||
}
|
||||
} finally {
|
||||
setLocale('en');
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
// C9: a failed ChatGPT OAuth check whose only reason is our own fallback
|
||||
// sentence shows that sentence alone — «OAuth check failed: Could not check
|
||||
// ChatGPT sign-in. Try again.» repeated itself. A server-sent reason keeps the
|
||||
// wrapper.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { oauthCheckFailureText, waitForChatGptOAuth } from './keySetup.js';
|
||||
import { setLocale } from './i18n/index.js';
|
||||
|
||||
const failWith = (payload, ok = true) =>
|
||||
waitForChatGptOAuth({
|
||||
fetchImpl: async () => ({ ok, json: async () => payload }),
|
||||
sleep: () => assert.fail('failed login must stop polling'),
|
||||
}).then(
|
||||
() => assert.fail('expected a rejection'),
|
||||
(error) => error,
|
||||
);
|
||||
|
||||
test('a fallback-only OAuth failure is shown without the wrapper in every locale', async (t) => {
|
||||
t.after(() => setLocale('en'));
|
||||
const expected = {
|
||||
en: 'Could not check ChatGPT sign-in. Try again.',
|
||||
ru: 'Не удалось проверить вход в ChatGPT. Попробуйте ещё раз.',
|
||||
es: 'No se pudo comprobar el inicio de sesión de ChatGPT. Inténtalo de nuevo.',
|
||||
};
|
||||
for (const [locale, text] of Object.entries(expected)) {
|
||||
setLocale(locale);
|
||||
const loginFailed = await failWith({ available: false, loginFailed: true });
|
||||
assert.equal(oauthCheckFailureText(loginFailed), text);
|
||||
const httpFailed = await failWith({}, false);
|
||||
assert.equal(oauthCheckFailureText(httpFailed), text);
|
||||
}
|
||||
});
|
||||
|
||||
test('a server-sent or network OAuth failure keeps the «OAuth check failed» wrapper', async (t) => {
|
||||
t.after(() => setLocale('en'));
|
||||
setLocale('en');
|
||||
const server = await failWith({
|
||||
loginFailed: true,
|
||||
error: 'Sign-in is local only.',
|
||||
});
|
||||
assert.equal(
|
||||
oauthCheckFailureText(server),
|
||||
'OAuth check failed: Sign-in is local only.',
|
||||
);
|
||||
assert.equal(
|
||||
oauthCheckFailureText(new TypeError('Failed to fetch')),
|
||||
'OAuth check failed: Failed to fetch',
|
||||
);
|
||||
setLocale('ru');
|
||||
assert.match(
|
||||
oauthCheckFailureText(server),
|
||||
/^Не удалось проверить OAuth: Sign-in is local only\.$/,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
// K-26: every status line of the ChatGPT OAuth flow in Provider Settings goes
|
||||
// through t(), and the i18n check treats say() as a place UI text lands.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
import {
|
||||
extractMessages,
|
||||
readCatalog,
|
||||
shippedLocales,
|
||||
unmarkedScript,
|
||||
} from '../scripts/i18n.mjs';
|
||||
|
||||
// Status-line texts of the ChatGPT OAuth flow in Provider Settings
|
||||
// (src/keySetup.js, handler of [data-cloud-voice-auth-toggle]).
|
||||
const OAUTH_STATUS_TEXTS = [
|
||||
'Cloud voice will use OPENAI_API_KEY on the next session.',
|
||||
'Checking local ChatGPT OAuth sign-in…',
|
||||
'ChatGPT OAuth selected for cloud voice. Your API key stays saved and available.',
|
||||
'ChatGPT sign-in expired. Opening sign-in again…',
|
||||
'Opening ChatGPT sign-in in your browser…',
|
||||
'Finish ChatGPT sign-in in the browser. Waiting for it to complete…',
|
||||
'ChatGPT sign-in timed out. Click {button} to try again.',
|
||||
'OAuth check failed: {detail}',
|
||||
'Could not check ChatGPT sign-in.',
|
||||
'Could not check ChatGPT sign-in. Try again.',
|
||||
'Could not start ChatGPT sign-in on this machine.',
|
||||
'ChatGPT sign-in complete. OAuth will be used for the next cloud voice session.',
|
||||
];
|
||||
|
||||
test('K-26: OAuth status-line texts are extractable messages with es/ru entries', () => {
|
||||
const messages = extractMessages();
|
||||
const missing = OAUTH_STATUS_TEXTS.filter((text) => !messages.has(text));
|
||||
assert.deepEqual(missing, [], 'texts not wrapped in t() in src/keySetup.js');
|
||||
for (const locale of shippedLocales()) {
|
||||
const catalog = readCatalog(locale);
|
||||
const untranslated = OAUTH_STATUS_TEXTS.filter((text) => !catalog[text]);
|
||||
assert.deepEqual(untranslated, [], `${locale}.json lacks entries`);
|
||||
}
|
||||
});
|
||||
|
||||
test('K-26: the i18n check sees an unmarked literal passed to say()', () => {
|
||||
const sample =
|
||||
"const say = (x) => { el.textContent = x; };\nsay('Checking local ChatGPT OAuth sign-in…');\n";
|
||||
const found = unmarkedScript('sample.js', sample);
|
||||
assert.ok(
|
||||
found.some((f) => f.text.includes('Checking local ChatGPT OAuth')),
|
||||
'say(...) is not among JS_SINKS in scripts/i18n.mjs',
|
||||
);
|
||||
});
|
||||
|
||||
test('K-26: src/keySetup.js has no unmarked say() literals once say is a sink', () => {
|
||||
const source = readFileSync(
|
||||
new URL('./keySetup.js', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
const literal = /\bsay\(\s*['"`]/g;
|
||||
const hits = [...source.matchAll(literal)].map(
|
||||
(m) => source.slice(0, m.index).split('\n').length,
|
||||
);
|
||||
assert.deepEqual(
|
||||
hits,
|
||||
[],
|
||||
`say() called with a bare literal at lines ${hits.join(', ')}`,
|
||||
);
|
||||
});
|
||||
@@ -276,7 +276,7 @@ export function createAlprCamerasLayer({ source, services } = {}) {
|
||||
state.stale = Boolean(state.lastUpdate);
|
||||
setAlprStatus(
|
||||
'unavailable',
|
||||
error?.message || 'Camera source temporarily unavailable',
|
||||
error?.message || msg('Camera source temporarily unavailable'),
|
||||
);
|
||||
if (!isUnavailableCapability(error)) scheduleUnavailableRetry();
|
||||
} finally {
|
||||
|
||||
@@ -139,7 +139,7 @@ export function createAlprTileSource({
|
||||
const metadata = await source.getMetadata(signal);
|
||||
const [west, south, east, north] = metadata.bounds || [];
|
||||
if (![west, south, east, north].every(Number.isFinite))
|
||||
throw new Error('Camera coverage unavailable');
|
||||
throw new Error(msg('Camera coverage unavailable'));
|
||||
if (
|
||||
box.east < west ||
|
||||
box.west > east ||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { msg } from '../../i18n/index.js';
|
||||
import { CITY_BY_ID } from './registry.js';
|
||||
|
||||
export function createIngestion({
|
||||
@@ -188,7 +189,8 @@ export function createIngestion({
|
||||
`[Data:Bikeshare] ${cityId} status update error:`,
|
||||
error,
|
||||
);
|
||||
layerState._error = 'GBFS status update failed';
|
||||
// English data (stats reach the voice tools); the panel translates it.
|
||||
layerState._error = msg('GBFS status update failed');
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { msg } from '../../i18n/index.js';
|
||||
import { GBFS_CITY_REGISTRY } from './registry.js';
|
||||
import {
|
||||
ACTIVATION_EXIT_ALTITUDE_M,
|
||||
@@ -172,7 +173,8 @@ export function createViewport({ state: layerState, services, parts, source }) {
|
||||
} catch (error) {
|
||||
if (error?.name === 'AbortError') return;
|
||||
console.warn(`[Data:Bikeshare] ${cityId} activate error:`, error);
|
||||
layerState._error = 'GBFS fetch error';
|
||||
// English data (stats reach the voice tools); the panel translates it.
|
||||
layerState._error = msg('GBFS fetch error');
|
||||
deactivateCity(cityId);
|
||||
layerState._activeCityIds.delete(cityId);
|
||||
} finally {
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createCyclonesLayer } from './index.js';
|
||||
import { setLocale, t } from '../../i18n/index.js';
|
||||
|
||||
// The exact string the server sends (server/providers/cyclones.js:8).
|
||||
const SERVER_COVERAGE =
|
||||
'Atlantic and eastern/central North Pacific; not worldwide cyclone coverage.';
|
||||
const time = '2026-09-16T03:00:00.000Z';
|
||||
const storm = {
|
||||
id: 'ep152026',
|
||||
name: 'Fifteen-E',
|
||||
classification: 'PTC',
|
||||
basin: 'EP',
|
||||
position: { longitude: -125.8, latitude: 15.5 },
|
||||
positionAt: time,
|
||||
issuedAt: time,
|
||||
advisoryNumber: '10',
|
||||
windKt: 25,
|
||||
pressureHpa: 1006,
|
||||
geometryStatus: 'pending',
|
||||
geometryAdvisoryNumber: null,
|
||||
forecastPoints: [],
|
||||
track: null,
|
||||
cone: null,
|
||||
advisoryUrl: 'https://www.nhc.noaa.gov/text/MIATCMEP5.shtml',
|
||||
};
|
||||
const snapshot = (storms, extra = {}) => ({
|
||||
schemaVersion: 1,
|
||||
storms,
|
||||
unavailable: false,
|
||||
stale: false,
|
||||
fetchedAt: Date.parse(time),
|
||||
coverage: SERVER_COVERAGE,
|
||||
reason: null,
|
||||
...extra,
|
||||
});
|
||||
function harness(feed) {
|
||||
const rendering = {
|
||||
async setSnapshot() {
|
||||
return true;
|
||||
},
|
||||
setSelection() {},
|
||||
getFocusSphere: () => ({ radius: 500000 }),
|
||||
clear() {},
|
||||
destroy() {},
|
||||
getDiagnostics: () => ({ entities: 0, timerActive: false }),
|
||||
};
|
||||
const layer = createCyclonesLayer({
|
||||
feed,
|
||||
createRendering: () => rendering,
|
||||
matchMedia: () => ({ matches: false }),
|
||||
openLink: () => {},
|
||||
});
|
||||
layer.init({ camera: { flyToBoundingSphere() {} } });
|
||||
layer.attachShellServices({ runNavigation: (fn) => fn() });
|
||||
return layer;
|
||||
}
|
||||
|
||||
const translated = () => t(SERVER_COVERAGE);
|
||||
|
||||
test('K-35a: es, server snapshot without storms -> coverage line in Spanish', async () => {
|
||||
setLocale('es');
|
||||
try {
|
||||
assert.notEqual(translated(), SERVER_COVERAGE, 'es.json has a translation');
|
||||
const layer = harness({ getSnapshot: async () => snapshot([]) });
|
||||
layer.enable();
|
||||
await layer.update();
|
||||
const info = layer.getRowControls().info;
|
||||
assert.ok(
|
||||
info.endsWith(translated()),
|
||||
`expected Spanish coverage, got:\n${info}`,
|
||||
);
|
||||
layer.destroy();
|
||||
} finally {
|
||||
setLocale('en');
|
||||
}
|
||||
});
|
||||
|
||||
test('K-35b: es, server snapshot with a storm -> coverage line in Spanish', async () => {
|
||||
setLocale('es');
|
||||
try {
|
||||
const layer = harness({ getSnapshot: async () => snapshot([storm]) });
|
||||
layer.enable();
|
||||
await layer.update();
|
||||
const info = layer.getRowControls().info;
|
||||
assert.ok(
|
||||
info.endsWith(translated()),
|
||||
`expected Spanish coverage, got:\n${info}`,
|
||||
);
|
||||
layer.destroy();
|
||||
} finally {
|
||||
setLocale('en');
|
||||
}
|
||||
});
|
||||
|
||||
test('K-35c: es, unavailable without reason -> fallback status not the English literal', async () => {
|
||||
setLocale('es');
|
||||
try {
|
||||
const layer = harness({
|
||||
getSnapshot: async () =>
|
||||
snapshot([], { unavailable: true, reason: null }),
|
||||
});
|
||||
layer.enable();
|
||||
await layer.update();
|
||||
const status = layer.getRowControls().summary.status;
|
||||
assert.notEqual(
|
||||
status,
|
||||
'Cyclone advisories unavailable',
|
||||
`fallback shown in English: ${status}`,
|
||||
);
|
||||
layer.destroy();
|
||||
} finally {
|
||||
setLocale('en');
|
||||
}
|
||||
});
|
||||
|
||||
test('K-35: en coverage and fallback status read as before', async () => {
|
||||
const layer = harness({ getSnapshot: async () => snapshot([]) });
|
||||
layer.enable();
|
||||
await layer.update();
|
||||
assert.ok(layer.getRowControls().info.endsWith(SERVER_COVERAGE));
|
||||
layer.destroy();
|
||||
const down = harness({
|
||||
getSnapshot: async () => snapshot([], { unavailable: true, reason: null }),
|
||||
});
|
||||
down.enable();
|
||||
await down.update();
|
||||
assert.equal(
|
||||
down.getRowControls().summary.status,
|
||||
'Cyclone advisories unavailable',
|
||||
);
|
||||
down.destroy();
|
||||
});
|
||||
@@ -14,7 +14,13 @@ import {
|
||||
CYCLONE_OVERLAY_SOURCE_ID,
|
||||
cycloneStormIdFromEntryId,
|
||||
} from './labels.js';
|
||||
import { formatUtcDayMonth, t, tn, msg } from '../../i18n/index.js';
|
||||
import {
|
||||
formatUtcDayMonth,
|
||||
t,
|
||||
tn,
|
||||
msg,
|
||||
translateKnown,
|
||||
} from '../../i18n/index.js';
|
||||
import { formatQuantity } from '../../ui/unitLabels.js';
|
||||
|
||||
// "10-12 15:00 UTC" never breaks across lines: no-break spaces, and a word
|
||||
@@ -26,6 +32,15 @@ const utc = (value) =>
|
||||
const COVERAGE = msg(
|
||||
'Atlantic and eastern/central North Pacific; not worldwide cyclone coverage.',
|
||||
);
|
||||
// The server sends the same English coverage (server/providers/cyclones.js);
|
||||
// data stays English and is translated here, where it is shown.
|
||||
const coverageText = (coverage) =>
|
||||
!coverage || coverage === COVERAGE ? t(COVERAGE) : translateKnown(coverage);
|
||||
const ADVISORIES_UNAVAILABLE = msg('Cyclone advisories unavailable');
|
||||
// Reason the server gives with an unavailable snapshot, translated when shown.
|
||||
const SERVER_REASONS = new Set([msg('Cyclone data unavailable')]);
|
||||
const errorText = (error) =>
|
||||
error && (SERVER_REASONS.has(error) ? t(error) : translateKnown(error));
|
||||
const CLASSIFICATION_NAMES = Object.freeze({
|
||||
PTC: msg('Potential tropical cyclone'),
|
||||
HU: msg('Hurricane'),
|
||||
@@ -238,7 +253,7 @@ export function createCyclonesLayer({
|
||||
snapshot = next;
|
||||
selectedId = null;
|
||||
++navigationGeneration;
|
||||
error = next.reason || 'Cyclone advisories unavailable';
|
||||
error = next.reason || ADVISORIES_UNAVAILABLE;
|
||||
return true;
|
||||
}
|
||||
const applied = await rendering.setSnapshot(next, {
|
||||
@@ -263,7 +278,7 @@ export function createCyclonesLayer({
|
||||
return true;
|
||||
} catch (cause) {
|
||||
if (controller.signal.aborted || request !== controller) return false;
|
||||
error = cause?.message || 'Cyclone advisories unavailable';
|
||||
error = cause?.message || ADVISORIES_UNAVAILABLE;
|
||||
// Failed acquisition has no bounded last-good age guarantee at this layer.
|
||||
rendering?.clear();
|
||||
snapshot = null;
|
||||
@@ -332,7 +347,7 @@ export function createCyclonesLayer({
|
||||
})
|
||||
: t('Track/cone unavailable'));
|
||||
const status =
|
||||
error ||
|
||||
errorText(error) ||
|
||||
(snapshot?.stale
|
||||
? t('Cached advisory · stale source')
|
||||
: loading
|
||||
@@ -435,8 +450,8 @@ export function createCyclonesLayer({
|
||||
]
|
||||
: [],
|
||||
info: storm
|
||||
? `${detail}\n${position}\n${intensity}\n${geometry}${status && status !== geometry ? '\n' + status : ''}\n${snapshot.coverage}`
|
||||
: `${detail}${status ? '\n' + status : ''}\n${snapshot?.coverage || t(COVERAGE)}`,
|
||||
? `${detail}\n${position}\n${intensity}\n${geometry}${status && status !== geometry ? '\n' + status : ''}\n${coverageText(snapshot.coverage)}`
|
||||
: `${detail}${status ? '\n' + status : ''}\n${coverageText(snapshot?.coverage)}`,
|
||||
infoTitle: t(
|
||||
'Select a storm on the map, or choose a storm in the list to select it and move the camera. Click empty map space to clear the selection. NOAA NHC/CPHC advisory context. The cone describes forecast center-track uncertainty, not storm size or the full hazard area. Forecast point labels are source lead hours, not times computed from advisory issuance. Geometry follows the surface; height is not weather altitude. Consult the official advisory.',
|
||||
),
|
||||
|
||||
@@ -18,7 +18,8 @@
|
||||
|
||||
import * as Cesium from 'cesium';
|
||||
import { roadLabel } from '../../data/routeSteps.js';
|
||||
import { t, msg, formatNumber } from '../../i18n/index.js';
|
||||
import { t, msg, formatNumber, translateKnown } from '../../i18n/index.js';
|
||||
import { pointerOwnerName } from '../../data/pointerOwnerNames.js';
|
||||
import { formatQuantity } from '../../ui/unitLabels.js';
|
||||
|
||||
export const DIRECTIONS_STEP_OVERLAY_SOURCE_ID = 'directions-step';
|
||||
@@ -36,6 +37,36 @@ export const DIRECTIONS_MODES = Object.freeze({
|
||||
});
|
||||
export const DEFAULT_DIRECTIONS_MODE = 'car';
|
||||
|
||||
/**
|
||||
* The route proxy's refusal texts (server/providers/places/routes.js). They
|
||||
* stay English on the wire and in the AI tools; the row shows them translated.
|
||||
*/
|
||||
const ROUTING_REFUSALS = new Set([
|
||||
msg('rate limited'),
|
||||
msg('routing service is rate limited'),
|
||||
msg('route leg too long'),
|
||||
msg('route too long'),
|
||||
]);
|
||||
|
||||
/** A refusal text from the route proxy in the language; unknown ones as given. */
|
||||
export function presentRoutingRefusal(text) {
|
||||
return ROUTING_REFUSALS.has(text) ? t(text) : text;
|
||||
}
|
||||
|
||||
/** The row text for a failed route request (createDirectionsSource errors). */
|
||||
export function presentRoutingFailure(error) {
|
||||
if (error?.status === 429) {
|
||||
return error.refusal
|
||||
? t('{error} — try again in a moment', {
|
||||
error: presentRoutingRefusal(error.refusal),
|
||||
})
|
||||
: t('Routing is rate limited — try again in a moment');
|
||||
}
|
||||
if (Number.isFinite(error?.status) && error.status > 0)
|
||||
return t('Routing unavailable (HTTP {status})', { status: error.status });
|
||||
return error?.message || t('Routing unavailable');
|
||||
}
|
||||
|
||||
/** Route colour — the annotation palette's cyan, so voice routes and Directions match. */
|
||||
export const DIRECTIONS_ROUTE_COLOR = '#39d0ff';
|
||||
const MARKER_A_COLOR = Cesium.Color.fromCssColorString('#5dff9f');
|
||||
@@ -471,7 +502,7 @@ export function pointerBlockedMessage(owner, which) {
|
||||
const known = POINTER_TOOL_EXITS[id];
|
||||
if (!known)
|
||||
return t('{name} is active — turn {name} off, then set {endpoint}', {
|
||||
name: id,
|
||||
name: translateKnown(pointerOwnerName(id)),
|
||||
endpoint,
|
||||
});
|
||||
return t('{name} is active — {leave}, then set {endpoint}', {
|
||||
@@ -1118,7 +1149,9 @@ export function createDirectionsLayer({ services, source }) {
|
||||
payload?.error === 'no route found'
|
||||
? t('No route found between A and B')
|
||||
: payload?.error
|
||||
? t('Routing failed: {error}', { error: payload.error })
|
||||
? t('Routing failed: {error}', {
|
||||
error: presentRoutingRefusal(payload.error),
|
||||
})
|
||||
: t('No route found between A and B');
|
||||
return;
|
||||
}
|
||||
@@ -1139,7 +1172,7 @@ export function createDirectionsLayer({ services, source }) {
|
||||
? t('Routing timed out')
|
||||
: error?.name === 'TypeError'
|
||||
? t('Routing unavailable — no response from the routing service')
|
||||
: error?.message || t('Routing unavailable');
|
||||
: presentRoutingFailure(error);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
if (_routeAbort === controller) _routeAbort = null;
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
// A source module (no browser globals, see check:boundaries), so no i18n
|
||||
// import: failures stay English here and the layer translates them on show.
|
||||
const msg = (text) => text;
|
||||
|
||||
/** Build the existing route-proxy query from geographic endpoints. */
|
||||
export function directionsRequestUrl(mode, a, b) {
|
||||
const coords = `${a.lon.toFixed(6)},${a.lat.toFixed(6)};${b.lon.toFixed(6)},${b.lat.toFixed(6)}`;
|
||||
@@ -19,15 +23,27 @@ export function createDirectionsSource({
|
||||
});
|
||||
const payload = await response.json();
|
||||
signal?.throwIfAborted();
|
||||
// Failures keep their English message and carry the HTTP status and the
|
||||
// server's refusal text, so the layer can show them in the language.
|
||||
if (response.status === 429) {
|
||||
throw new Error(
|
||||
const refusal =
|
||||
typeof payload?.error === 'string' && payload.error
|
||||
? `${payload.error} — try again in a moment`
|
||||
: 'Routing is rate limited — try again in a moment',
|
||||
? payload.error
|
||||
: null;
|
||||
throw Object.assign(
|
||||
new Error(
|
||||
refusal
|
||||
? `${refusal} — try again in a moment`
|
||||
: msg('Routing is rate limited — try again in a moment'),
|
||||
),
|
||||
{ status: 429, refusal },
|
||||
);
|
||||
}
|
||||
if (!response.ok && !payload?.error)
|
||||
throw new Error(`Routing unavailable (HTTP ${response.status})`);
|
||||
throw Object.assign(
|
||||
new Error(`Routing unavailable (HTTP ${response.status})`),
|
||||
{ status: response.status },
|
||||
);
|
||||
return payload;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -11,6 +11,10 @@ import {
|
||||
export * from './model.js';
|
||||
export { createUsgsEarthquakeSource } from './source.js';
|
||||
|
||||
// A portable package (no i18n): the error stays English data and the layer
|
||||
// panel shows it translated.
|
||||
const msg = (text) => text;
|
||||
|
||||
/** Own one earthquake display and its refresh lifecycle. */
|
||||
export function createEarthquakesLayer({ source, overlayHost } = {}) {
|
||||
if (typeof source?.getSnapshot !== 'function')
|
||||
@@ -154,7 +158,7 @@ export function createEarthquakesLayer({ source, overlayHost } = {}) {
|
||||
if (request.signal.aborted || _request !== request || !_enabled)
|
||||
return false;
|
||||
console.warn('[Data:Earthquakes] Fetch error:', e);
|
||||
_lastError = e?.message || 'Earthquake source unavailable';
|
||||
_lastError = e?.message || msg('Earthquake source unavailable');
|
||||
return false;
|
||||
} finally {
|
||||
if (_request === request) _request = null;
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
// K-34: AIS degradation chip text and the traffic "SIMULATED — <reason>" text
|
||||
// must be in the UI language (they stayed English in es before the fix);
|
||||
// English keeps its wording.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { setLocale, translateKnown } from '../i18n/index.js';
|
||||
import { deriveAisFeedError } from '../data/aisLiveVessels.js';
|
||||
import { createModel } from './traffic/model.js';
|
||||
|
||||
test('K-34: AIS degraded-feed reasons are localized in es', () => {
|
||||
setLocale('es');
|
||||
try {
|
||||
const cases = [
|
||||
[
|
||||
{ status: 'down', reconnectAttempt: 3 },
|
||||
'feed down — retrying slowly (attempt 3)',
|
||||
],
|
||||
[{ status: 'missing-key' }, 'AISSTREAM_API_KEY not set'],
|
||||
[
|
||||
{ status: 'stale', silentForMs: 90000 },
|
||||
'feed silent 90s — no AIS data',
|
||||
],
|
||||
[{ status: 'auth-failed' }, 'API key rejected — check AISSTREAM_API_KEY'],
|
||||
[{ status: 'connecting' }, 'connecting to feed…'],
|
||||
[{ status: 'closed' }, 'feed disconnected'],
|
||||
];
|
||||
for (const [payload, english] of cases) {
|
||||
// The layer panel runs stats.error through translateKnown(); emulate it.
|
||||
const shown = translateKnown(deriveAisFeedError(payload, 0));
|
||||
assert.notEqual(shown, english, `status ${payload.status} still English`);
|
||||
}
|
||||
} finally {
|
||||
setLocale('en');
|
||||
}
|
||||
});
|
||||
|
||||
test('K-34: traffic "SIMULATED — <reason>" is localized in es', () => {
|
||||
setLocale('es');
|
||||
try {
|
||||
const { trafficFeedPresentation } = createModel({
|
||||
state: {},
|
||||
services: {},
|
||||
parts: {},
|
||||
source: {},
|
||||
});
|
||||
const degraded = trafficFeedPresentation({
|
||||
liveMode: true,
|
||||
flowError: 'TomTom daily budget reached',
|
||||
});
|
||||
const shown = translateKnown(degraded.error);
|
||||
assert.notEqual(shown, 'SIMULATED — TomTom daily budget reached');
|
||||
assert.ok(!shown.startsWith('SIMULATED'), `still English: ${shown}`);
|
||||
} finally {
|
||||
setLocale('en');
|
||||
}
|
||||
});
|
||||
|
||||
test('K-34: English degraded-feed wording is unchanged', () => {
|
||||
setLocale('en');
|
||||
assert.equal(
|
||||
deriveAisFeedError({ status: 'down', reconnectAttempt: 3 }, 0),
|
||||
'feed down — retrying slowly (attempt 3)',
|
||||
);
|
||||
assert.equal(
|
||||
deriveAisFeedError({ status: 'reconnecting' }, 0),
|
||||
'reconnecting to feed…',
|
||||
);
|
||||
assert.equal(
|
||||
deriveAisFeedError({ status: 'missing-key' }, 0),
|
||||
'AISSTREAM_API_KEY not set',
|
||||
);
|
||||
const { trafficFeedPresentation } = createModel({
|
||||
state: {},
|
||||
services: {},
|
||||
parts: {},
|
||||
source: {},
|
||||
});
|
||||
assert.equal(
|
||||
trafficFeedPresentation({
|
||||
liveMode: true,
|
||||
flowError: 'TomTom daily budget reached',
|
||||
}).error,
|
||||
'SIMULATED — TomTom daily budget reached',
|
||||
);
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { adaptFirmsRecords } from '../../data/firmsAdapt.js';
|
||||
import { fireDetectionKey } from '../../data/firmsLabels.js';
|
||||
import { msg } from '../../i18n/index.js';
|
||||
|
||||
export function createIngestion({
|
||||
layerState,
|
||||
@@ -72,7 +73,8 @@ export function createIngestion({
|
||||
)
|
||||
return;
|
||||
console.warn(`[Data:${id}] FIRMS live load failed:`, error);
|
||||
layerState._error = 'live feed unavailable';
|
||||
// English data (stats reach the voice tools); the panel translates it.
|
||||
layerState._error = msg('live feed unavailable');
|
||||
} finally {
|
||||
if (layerState.request === request) {
|
||||
layerState.request = null;
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { ERROR_BACKOFF_INTERVAL } from './recordPolicy.js';
|
||||
|
||||
// Marks English the Layers panel translates when it shows the error. Kept
|
||||
// local: this module stays free of browser globals (package boundaries).
|
||||
const msg = (text) => text;
|
||||
|
||||
/** Own acquisition, cancellation, freshness and backoff independently of rendering. */
|
||||
export function createIngestion({
|
||||
feed,
|
||||
@@ -43,7 +47,7 @@ export function createIngestion({
|
||||
feed._retryAt = 0;
|
||||
feed._lastError = sourceStale
|
||||
? sourceAgeMs == null
|
||||
? 'Source snapshot time unavailable'
|
||||
? msg('Source snapshot time unavailable')
|
||||
: `Source snapshot ${Math.max(2, Math.round(sourceAgeMs / 60_000))} min old`
|
||||
: null;
|
||||
feed._lastSource = snapshot.source;
|
||||
@@ -77,7 +81,9 @@ export function createIngestion({
|
||||
setSourceLabel(feed._lastSource);
|
||||
}
|
||||
feed._lastError =
|
||||
e?.name === 'LiveSourceError' ? e.message : 'Live data unavailable';
|
||||
e?.name === 'LiveSourceError'
|
||||
? e.message
|
||||
: msg('Live data unavailable');
|
||||
} finally {
|
||||
feed._activeUpdateControllers.delete(resourceController);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,19 @@
|
||||
import { t } from '../../i18n/index.js';
|
||||
import { UNNAMED_PLACE_NAMES } from '../../data/installationNames.js';
|
||||
|
||||
const UNNAMED_NAMES = new Set(UNNAMED_PLACE_NAMES);
|
||||
|
||||
/**
|
||||
* An installation's card title: a mapped name as it is; the English label
|
||||
* the data gives an unnamed feature ("Firing range", "Military area") in the
|
||||
* UI language. The record keeps the English name.
|
||||
* @param {string} name
|
||||
* @returns {string} Empty when the record has no name.
|
||||
*/
|
||||
export function installationTitle(name) {
|
||||
const text = String(name ?? '');
|
||||
return UNNAMED_NAMES.has(text.trim()) ? t(text.trim()) : text;
|
||||
}
|
||||
|
||||
/**
|
||||
* An installation class as a map card shows it: AIRFIELD, NAVAL BASE…, in
|
||||
@@ -19,6 +34,10 @@ export function installationClassLabel(klass) {
|
||||
return t('MILITARY LAND', { context: 'installation class' });
|
||||
case 'base':
|
||||
return t('BASE', { context: 'installation class' });
|
||||
case 'barracks':
|
||||
return t('BARRACKS', { context: 'installation class' });
|
||||
case 'training_area':
|
||||
return t('TRAINING AREA', { context: 'installation class' });
|
||||
case 'places_candidate':
|
||||
return t('PLACES CANDIDATE', { context: 'installation class' });
|
||||
case 'installation':
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
// K-50: every class the bundled OSM names pack can put on a record has a
|
||||
// catalog entry, so the selected card shows it in the UI language.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { setLocale, getCatalog } from '../../i18n/index.js';
|
||||
import { installationClassLabel } from './classLabel.js';
|
||||
|
||||
const pack = JSON.parse(
|
||||
readFileSync(
|
||||
new URL(
|
||||
'../../data/local_data/osm_military_names/names.json',
|
||||
import.meta.url,
|
||||
),
|
||||
'utf8',
|
||||
),
|
||||
);
|
||||
|
||||
setLocale('en');
|
||||
const englishLabel = Object.fromEntries(
|
||||
pack.classes.map((c) => [c, installationClassLabel(c)]),
|
||||
);
|
||||
|
||||
for (const locale of ['es', 'ru']) {
|
||||
test(`every names-pack class is in the ${locale} catalog (K-50)`, () => {
|
||||
const catalog = getCatalog(locale);
|
||||
const missing = pack.classes.filter(
|
||||
(c) => catalog[`installation class|${englishLabel[c]}`] === undefined,
|
||||
);
|
||||
assert.deepEqual(
|
||||
missing,
|
||||
[],
|
||||
`no ${locale} entry for: ${missing.join(', ')}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
Vendored
+26
-45
@@ -1,57 +1,38 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { installationFeedback } from '../../data/installationFeedback.js';
|
||||
import { describeInstallationFeedback } from '../../data/installationFeedback.js';
|
||||
import {
|
||||
LAYER_ID,
|
||||
DISTANCE_PREFILTER_MARGIN_M,
|
||||
ANCHOR_REFRESH_M,
|
||||
} from './policy.js';
|
||||
import { t, msg } from '../../i18n/index.js';
|
||||
import { formatNumber, t, tn } from '../../i18n/index.js';
|
||||
|
||||
// installationFeedback.js composes its guidance inside the portable
|
||||
// node/browser graph (the server-side HUD summary reads the same module), so
|
||||
// its copy stays English there. getStats is the browser presentation
|
||||
// boundary: map the closed message vocabulary to the catalog, passing any
|
||||
// live provider error through verbatim (docs/TRANSLATORS.md).
|
||||
const INSTALLATION_FEEDBACK_KEYS = Object.freeze({
|
||||
'Retrying mapped sites…': msg('Retrying mapped sites…'),
|
||||
'Fetching mapped sites…': msg('Fetching mapped sites…'),
|
||||
'Zoom in to search mapped installations': msg(
|
||||
'Zoom in to search mapped installations',
|
||||
),
|
||||
'Showing cached mapped sites': msg('Showing cached mapped sites'),
|
||||
'Mapped sites not loaded': msg('Mapped sites not loaded'),
|
||||
'Mapped sites loaded': msg('Mapped sites loaded'),
|
||||
'Overpass rate-limited': msg('Overpass rate-limited'),
|
||||
'Overpass timed out': msg('Overpass timed out'),
|
||||
'Overpass could not complete the query': msg(
|
||||
'Overpass could not complete the query',
|
||||
),
|
||||
'Overpass temporarily unavailable': msg('Overpass temporarily unavailable'),
|
||||
});
|
||||
|
||||
const INSTALLATION_RETRY_IN_RE = /^(.*) — retrying in (\d+)s$/;
|
||||
const INSTALLATION_RETRY_PENDING_RE = /^(.*) — retry pending$/;
|
||||
|
||||
function localizeInstallationFeedback(message) {
|
||||
if (typeof message !== 'string' || !message) return message;
|
||||
const key = INSTALLATION_FEEDBACK_KEYS[message];
|
||||
if (key) return t(key);
|
||||
const retryIn = message.match(INSTALLATION_RETRY_IN_RE);
|
||||
if (retryIn) {
|
||||
const reasonKey = INSTALLATION_FEEDBACK_KEYS[retryIn[1]];
|
||||
return t('{reason} — retrying in {seconds}s', {
|
||||
reason: reasonKey ? t(reasonKey) : retryIn[1],
|
||||
seconds: retryIn[2],
|
||||
});
|
||||
// node/browser graph (the HUD summary in loadingFeedback.js reads the same
|
||||
// module), so it hands back English marked with msg() plus the values;
|
||||
// getStats is the browser presentation boundary that translates them.
|
||||
function localizeInstallationFeedback(feedback) {
|
||||
if (feedback.text !== undefined) {
|
||||
const values = {};
|
||||
if (feedback.reason !== undefined) values.reason = t(feedback.reason);
|
||||
if (feedback.seconds !== undefined) values.seconds = feedback.seconds;
|
||||
return t(feedback.text, values);
|
||||
}
|
||||
const pending = message.match(INSTALLATION_RETRY_PENDING_RE);
|
||||
if (pending) {
|
||||
const reasonKey = INSTALLATION_FEEDBACK_KEYS[pending[1]];
|
||||
return t('{reason} — retry pending', {
|
||||
reason: reasonKey ? t(reasonKey) : pending[1],
|
||||
});
|
||||
const { count, km } = feedback;
|
||||
if (km) {
|
||||
const kmText = formatNumber(km);
|
||||
return count === 0
|
||||
? t('No mapped sites within {km} km of the contact', { km: kmText })
|
||||
: tn(
|
||||
count,
|
||||
'{count} mapped site within {km} km of the contact',
|
||||
'{count} mapped sites within {km} km of the contact',
|
||||
{ km: kmText },
|
||||
);
|
||||
}
|
||||
return message;
|
||||
return count === 0
|
||||
? t('No mapped sites in view')
|
||||
: tn(count, '{count} mapped site in view', '{count} mapped sites in view');
|
||||
}
|
||||
|
||||
export function createControls({ state: layerState, services, parts, source }) {
|
||||
@@ -274,7 +255,7 @@ export function createControls({ state: layerState, services, parts, source }) {
|
||||
retrying: layerState.loading && Boolean(layerState.failureReason),
|
||||
failureReason: layerState.failureReason,
|
||||
statusMessage: localizeInstallationFeedback(
|
||||
installationFeedback({
|
||||
describeInstallationFeedback({
|
||||
...layerState,
|
||||
count: layerState.lastUpdate ? mappedCount : undefined,
|
||||
retrying: layerState.loading && Boolean(layerState.failureReason),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { militaryOsmKey } from '../../data/militaryNames.js';
|
||||
import { isUnavailableCapability } from '../../sources/capability.js';
|
||||
import { msg } from '../../i18n/index.js';
|
||||
/** Keep named point coverage until its matching polygon takes over. */
|
||||
export function retainNamedHandoff(previous, records, box) {
|
||||
const named = new Map(
|
||||
@@ -255,7 +256,7 @@ export function createIngestion({
|
||||
payload.status === 'stale'
|
||||
? `Serving cached mapped context · ${payload.records[0]?.retrievedAt || 'date unknown'}`
|
||||
: saturated
|
||||
? 'Too many mapped sites in view to list them all'
|
||||
? msg('Too many mapped sites in view to list them all')
|
||||
: placesError,
|
||||
);
|
||||
parts.rendering.renderRecords();
|
||||
@@ -291,7 +292,7 @@ export function createIngestion({
|
||||
layerState.failureReason = error?.failureReason || 'unavailable';
|
||||
setInstallationStatus(
|
||||
'unavailable',
|
||||
error?.message || 'Installation context unavailable',
|
||||
error?.message || msg('Installation context unavailable'),
|
||||
);
|
||||
if (!isUnavailableCapability(error))
|
||||
parts.viewport.scheduleUnavailableRetry(error?.retryAfterMs);
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { setLocale, translateKnown } from '../../i18n/index.js';
|
||||
import { createControls } from './controls.js';
|
||||
|
||||
function stats(state) {
|
||||
const layerState = { records: [], enabled: true, ...state };
|
||||
return createControls({
|
||||
state: layerState,
|
||||
services: {},
|
||||
parts: {},
|
||||
source: {},
|
||||
}).methods.getStats();
|
||||
}
|
||||
|
||||
for (const locale of ['es', 'ru']) {
|
||||
test(`${locale}: installation feedback is in the UI language`, () => {
|
||||
setLocale(locale);
|
||||
const empty = stats({ status: 'empty', lastUpdate: 1 }).statusMessage;
|
||||
assert.notEqual(empty, 'No mapped sites in view');
|
||||
const found = stats({
|
||||
status: 'ready',
|
||||
lastUpdate: 1,
|
||||
records: [{ kind: 'installation' }, { kind: 'installation' }],
|
||||
}).statusMessage;
|
||||
assert.notEqual(found, '2 mapped sites in view');
|
||||
const tiles = stats({
|
||||
status: 'unavailable',
|
||||
failureReason: 'tiles_unavailable',
|
||||
}).statusMessage;
|
||||
assert.notEqual(tiles, 'Map tiles temporarily unavailable');
|
||||
const retry = stats({
|
||||
status: 'unavailable',
|
||||
failureReason: 'names_unavailable',
|
||||
retryAt: Date.now() + 5000,
|
||||
}).statusMessage;
|
||||
assert.doesNotMatch(retry, /^Mapped names temporarily unavailable/);
|
||||
for (const text of [
|
||||
'Too many mapped sites in view to list them all',
|
||||
'Installation context unavailable',
|
||||
]) {
|
||||
assert.notEqual(translateKnown(text), text);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('en: installation feedback keeps its English wording', () => {
|
||||
setLocale('en');
|
||||
assert.equal(
|
||||
stats({ status: 'empty', lastUpdate: 1 }).statusMessage,
|
||||
'No mapped sites in view',
|
||||
);
|
||||
assert.equal(
|
||||
stats({
|
||||
status: 'ready',
|
||||
lastUpdate: 1,
|
||||
records: [{ kind: 'installation' }],
|
||||
}).statusMessage,
|
||||
'1 mapped site in view',
|
||||
);
|
||||
assert.equal(
|
||||
stats({
|
||||
status: 'ready',
|
||||
lastUpdate: 1,
|
||||
wide: true,
|
||||
coverage: { kind: 'subject', radiusM: 25000 },
|
||||
records: [{ kind: 'installation' }, { kind: 'installation' }],
|
||||
}).statusMessage,
|
||||
'2 mapped sites within 25 km of the contact',
|
||||
);
|
||||
assert.equal(
|
||||
stats({
|
||||
status: 'unavailable',
|
||||
failureReason: 'names_unavailable',
|
||||
retryAt: Date.now() + 5000,
|
||||
}).statusMessage,
|
||||
'Mapped names temporarily unavailable — retrying in 5s',
|
||||
);
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { installationClassLabel } from './classLabel.js';
|
||||
import { installationClassLabel, installationTitle } from './classLabel.js';
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
MILITARY_LABEL_CAP,
|
||||
@@ -163,7 +163,7 @@ export function createNamedMarkers({ state, services, parts, overlayHost }) {
|
||||
id: record.id,
|
||||
source: LAYER_ID,
|
||||
position,
|
||||
title: record.name,
|
||||
title: installationTitle(record.name),
|
||||
priority: record.areaM2 || 0,
|
||||
accent: COLOR_BY_CLASS[record.class] || '#9ca6b0',
|
||||
}),
|
||||
@@ -183,7 +183,7 @@ export function createNamedMarkers({ state, services, parts, overlayHost }) {
|
||||
]
|
||||
: [];
|
||||
marker.entry.position = position;
|
||||
marker.entry.title = record.name;
|
||||
marker.entry.title = installationTitle(record.name);
|
||||
marker.entry.priority = record.areaM2 || 0;
|
||||
entries.push(marker.entry);
|
||||
ordered.push(marker);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { installationClassLabel } from './classLabel.js';
|
||||
import { installationClassLabel, installationTitle } from './classLabel.js';
|
||||
import * as Cesium from 'cesium';
|
||||
import { t } from '../../i18n/index.js';
|
||||
import { LAYER_ID, MAX_RENDERED, COLOR_BY_CLASS } from './policy.js';
|
||||
@@ -272,7 +272,7 @@ export function createRendering({
|
||||
entity.gevTrackedId = `installations:${record.id}`;
|
||||
entity.gevDisplayPosition = () => displayPosition;
|
||||
entity.gevLabelModel = {
|
||||
title: record.name || t('MAPPED INSTALLATION'),
|
||||
title: installationTitle(record.name) || t('MAPPED INSTALLATION'),
|
||||
details: [
|
||||
installationClassLabel(record.class),
|
||||
...(record.memberNames || []).slice(0, 3),
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import { normalizeMilitaryInstallations } from '../../data/militaryInstallationData.js';
|
||||
import { UNNAMED_MILITARY_AREA } from '../../sources/openFreeMap.js';
|
||||
import { setLocale } from '../../i18n/index.js';
|
||||
import { installationTitle } from './classLabel.js';
|
||||
import { formatAwarenessLabel } from '../../data/militaryAwarenessEngine.js';
|
||||
|
||||
// K-33: unnamed installations get an English label in data ("Firing range",
|
||||
// "Military land", "Military airfield"; OpenFreeMap polygons: "Military area").
|
||||
// The data stays English; the map card title reads in the UI language.
|
||||
const { records } = normalizeMilitaryInstallations({
|
||||
elements: [
|
||||
{ type: 'node', id: 1, lat: 1, lon: 1, tags: { military: 'range' } },
|
||||
{
|
||||
type: 'way',
|
||||
id: 2,
|
||||
center: { lat: 2, lon: 2 },
|
||||
tags: { landuse: 'military' },
|
||||
},
|
||||
{ type: 'node', id: 3, lat: 3, lon: 3, tags: { military: 'airfield' } },
|
||||
{ type: 'node', id: 4, lat: 4, lon: 4, tags: { military: 'naval_base' } },
|
||||
],
|
||||
});
|
||||
const names = [...records.map((r) => r.name), UNNAMED_MILITARY_AREA];
|
||||
|
||||
test('K-33: unnamed installations keep English names in data', () => {
|
||||
assert.deepEqual(names, [
|
||||
'Firing range',
|
||||
'Military land',
|
||||
'Military airfield',
|
||||
'Naval base',
|
||||
'Military area',
|
||||
]);
|
||||
});
|
||||
|
||||
test('K-33: unnamed installation card title reads in the UI language', () => {
|
||||
try {
|
||||
for (const locale of ['es', 'ru']) {
|
||||
setLocale(locale);
|
||||
for (const name of names) {
|
||||
const shown = installationTitle(name);
|
||||
assert.ok(shown, `${locale}: "${name}" has a title`);
|
||||
assert.notEqual(
|
||||
shown,
|
||||
name,
|
||||
`${locale}: "${name}" is shown in English`,
|
||||
);
|
||||
}
|
||||
// A mapped name is shown as it is.
|
||||
assert.equal(installationTitle('Fort Bragg'), 'Fort Bragg');
|
||||
}
|
||||
} finally {
|
||||
setLocale('en');
|
||||
}
|
||||
});
|
||||
|
||||
test('K-33: English card titles are unchanged', () => {
|
||||
setLocale('en');
|
||||
for (const name of names) assert.equal(installationTitle(name), name);
|
||||
assert.equal(installationTitle(''), '');
|
||||
});
|
||||
|
||||
test('K-33: the Contacts line names an unnamed installation in the UI language', () => {
|
||||
try {
|
||||
setLocale('en');
|
||||
for (const name of names)
|
||||
assert.equal(formatAwarenessLabel({ id: 'osm-1', name }), name);
|
||||
for (const locale of ['es', 'ru']) {
|
||||
setLocale(locale);
|
||||
for (const name of names)
|
||||
assert.equal(
|
||||
formatAwarenessLabel({ id: 'osm-1', name }),
|
||||
installationTitle(name),
|
||||
);
|
||||
assert.equal(
|
||||
formatAwarenessLabel({ id: 'osm-2', name: 'Fort Bragg' }),
|
||||
'Fort Bragg',
|
||||
);
|
||||
assert.equal(formatAwarenessLabel({ callsign: 'RCH123 ' }), 'RCH123');
|
||||
}
|
||||
} finally {
|
||||
setLocale('en');
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
// K-37: the Local ADS-B click card follows the UI language; English keeps its
|
||||
// wording (index.test.mjs pins the full English card).
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { setLocale } from '../../i18n/index.js';
|
||||
import { localAdsbCardModel, localAdsbSourceText } from './card.js';
|
||||
|
||||
const record = {
|
||||
icao: 'abc123',
|
||||
callsign: '',
|
||||
altitudeFt: 12500,
|
||||
groundSpeedKt: 240,
|
||||
trackDeg: 90,
|
||||
verticalRateFpm: -1200,
|
||||
lastPositionAt: 1000,
|
||||
messageCount: 48,
|
||||
bands: ['1090'],
|
||||
sources: ['webusb', 'feed'],
|
||||
};
|
||||
|
||||
test('en: the local ADS-B card keeps its English wording', () => {
|
||||
setLocale('en');
|
||||
assert.deepEqual(localAdsbCardModel(record, 5000).details, [
|
||||
'ICAO ABC123 · NO CALLSIGN',
|
||||
'ALT 12,500 FT · GS 240 KT · TRK 90°',
|
||||
'V/S -1,200 FPM',
|
||||
'POSITION 4 S AGO · 48 MSGS',
|
||||
'Heard by your receiver · 1090 MHz · browser SDR + decoder feed',
|
||||
]);
|
||||
assert.equal(
|
||||
localAdsbSourceText(record),
|
||||
'Your RTL-SDR receiver and decoder feed',
|
||||
);
|
||||
});
|
||||
|
||||
for (const locale of ['es', 'ru']) {
|
||||
test(`${locale}: the local ADS-B card is in the UI language`, () => {
|
||||
setLocale(locale);
|
||||
try {
|
||||
const text = [
|
||||
...localAdsbCardModel(record, 5000).details,
|
||||
localAdsbSourceText(record),
|
||||
].join(' | ');
|
||||
for (const english of [
|
||||
'NO CALLSIGN',
|
||||
'GS ',
|
||||
'POSITION',
|
||||
'AGO',
|
||||
'MSGS',
|
||||
'Heard by',
|
||||
'browser SDR',
|
||||
'decoder feed',
|
||||
'Your RTL-SDR',
|
||||
'12,500',
|
||||
])
|
||||
assert.ok(
|
||||
!text.includes(english),
|
||||
`${locale}: "${english}" in ${text}`,
|
||||
);
|
||||
} finally {
|
||||
setLocale('en');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('the class line names the aircraft class in the UI language', async () => {
|
||||
const { localAdsbClassLine } = await import('./card.js');
|
||||
const { AIRCRAFT_CLASS_LABELS } = await import('../../data/aircraftClass.js');
|
||||
const heli = { category: 'A7' };
|
||||
try {
|
||||
setLocale('en');
|
||||
assert.equal(
|
||||
localAdsbClassLine(heli, { klass: 'helicopter', evidence: true }),
|
||||
'Helicopter · A7',
|
||||
);
|
||||
for (const locale of ['es', 'ru']) {
|
||||
setLocale(locale);
|
||||
for (const [klass, label] of Object.entries(AIRCRAFT_CLASS_LABELS)) {
|
||||
const line = localAdsbClassLine({}, { klass, evidence: true });
|
||||
assert.ok(line, `${locale}: ${klass} has a class line`);
|
||||
assert.notEqual(line, label, `${locale}: "${label}" is in English`);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setLocale('en');
|
||||
}
|
||||
});
|
||||
|
||||
// C8: the receiver bands follow the UI language too ("1090 МГц").
|
||||
test('ru: the receiver line names the bands in Russian', async () => {
|
||||
const { localAdsbReceiverLine } = await import('./card.js');
|
||||
setLocale('ru');
|
||||
try {
|
||||
const line = localAdsbReceiverLine({ ...record, bands: ['1090', '978'] });
|
||||
assert.match(line, /· 1090 МГц \+ 978 МГц UAT ·/);
|
||||
assert.doesNotMatch(line, /MHz/);
|
||||
} finally {
|
||||
setLocale('en');
|
||||
}
|
||||
});
|
||||
@@ -1,10 +1,6 @@
|
||||
import { AIRCRAFT_CLASS_LABELS } from '../../data/aircraftClass.js';
|
||||
import {
|
||||
BAND_LABELS,
|
||||
HEARD_BY_RECEIVER,
|
||||
LOCAL_ADSB_COLOR,
|
||||
SOURCE_LABELS,
|
||||
} from './policy.js';
|
||||
import { formatNumber, t, tn } from '../../i18n/index.js';
|
||||
import { BAND_LABELS, LOCAL_ADSB_COLOR, SOURCE_LABELS } from './policy.js';
|
||||
|
||||
const DASH = '—';
|
||||
|
||||
@@ -14,7 +10,7 @@ function rounded(value, digits = 0) {
|
||||
}
|
||||
|
||||
function grouped(value) {
|
||||
return value === null ? DASH : value.toLocaleString('en-US');
|
||||
return value === null ? DASH : formatNumber(value);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -55,17 +51,41 @@ export function localAdsbIsUat(record) {
|
||||
* @returns {string}
|
||||
*/
|
||||
export function localAdsbReceiverLine(record) {
|
||||
const bands = recordBands(record)
|
||||
.map((band) => BAND_LABELS[band])
|
||||
.filter(Boolean);
|
||||
const sources = recordSources(record)
|
||||
.map((source) => SOURCE_LABELS[source])
|
||||
.filter(Boolean);
|
||||
return [HEARD_BY_RECEIVER, bands.join(' + '), sources.join(' + ')]
|
||||
const bands = recordBands(record).map(bandLabel).filter(Boolean);
|
||||
const sources = recordSources(record).map(sourceLabel).filter(Boolean);
|
||||
return [t('Heard by your receiver'), bands.join(' + '), sources.join(' + ')]
|
||||
.filter(Boolean)
|
||||
.join(' · ');
|
||||
}
|
||||
|
||||
// policy.js keeps these labels in English (HEARD_BY_RECEIVER, BAND_LABELS,
|
||||
// SOURCE_LABELS); the card writes them out here so that it can show them
|
||||
// translated.
|
||||
|
||||
/** A receiver band (policy.js BAND_LABELS) in the UI language: "1090 МГц". */
|
||||
function bandLabel(band) {
|
||||
switch (String(band)) {
|
||||
case '1090':
|
||||
return t('1090 MHz');
|
||||
case '978':
|
||||
return t('978 MHz UAT');
|
||||
default:
|
||||
return BAND_LABELS[band];
|
||||
}
|
||||
}
|
||||
|
||||
/** A receiver source (policy.js SOURCE_LABELS) in the UI language. */
|
||||
function sourceLabel(source) {
|
||||
switch (source) {
|
||||
case 'webusb':
|
||||
return t('browser SDR');
|
||||
case 'feed':
|
||||
return t('decoder feed');
|
||||
default:
|
||||
return SOURCE_LABELS[source];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a heard aircraft came from, for the entity context's source field.
|
||||
* @param {object} record Local ADS-B record.
|
||||
@@ -74,9 +94,9 @@ export function localAdsbReceiverLine(record) {
|
||||
export function localAdsbSourceText(record) {
|
||||
const sources = recordSources(record);
|
||||
if (sources.includes('webusb') && sources.includes('feed'))
|
||||
return 'Your RTL-SDR receiver and decoder feed';
|
||||
if (sources.includes('feed')) return 'Your decoder feed';
|
||||
return 'Your RTL-SDR receiver';
|
||||
return t('Your RTL-SDR receiver and decoder feed');
|
||||
if (sources.includes('feed')) return t('Your decoder feed');
|
||||
return t('Your RTL-SDR receiver');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -89,7 +109,7 @@ export function localAdsbSourceText(record) {
|
||||
export function localAdsbClassLine(record, aircraftClass = {}) {
|
||||
if (!aircraftClass.evidence) return '';
|
||||
const label = AIRCRAFT_CLASS_LABELS[aircraftClass.klass];
|
||||
return [label, record?.category].filter(Boolean).join(' · ');
|
||||
return [label && t(label), record?.category].filter(Boolean).join(' · ');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -129,13 +149,26 @@ export function localAdsbCardModel(
|
||||
return {
|
||||
title: localAdsbTitle(record),
|
||||
details: [
|
||||
`ICAO ${icao} · ${record?.callsign || 'NO CALLSIGN'}`,
|
||||
`ICAO ${icao} · ${record?.callsign || t('NO CALLSIGN')}`,
|
||||
localAdsbClassLine(record, aircraftClass || {}),
|
||||
identity,
|
||||
routeLine,
|
||||
`ALT ${grouped(altitude)} FT · GS ${speed === null ? DASH : speed} KT · TRK ${track === null ? DASH : `${track}°`}`,
|
||||
`V/S ${vertical === null ? DASH : `${vertical > 0 ? '+' : ''}${grouped(vertical)} FPM`}`,
|
||||
`POSITION ${ageS === null ? DASH : `${ageS} S AGO`} · ${messages} ${messages === 1 ? 'MSG' : 'MSGS'}`,
|
||||
t('ALT {altitude} FT · GS {speed} KT · TRK {track}', {
|
||||
altitude: grouped(altitude),
|
||||
speed: speed === null ? DASH : speed,
|
||||
track: track === null ? DASH : `${track}°`,
|
||||
}),
|
||||
vertical === null
|
||||
? t('V/S {rate}', { rate: DASH })
|
||||
: t('V/S {rate} FPM', {
|
||||
rate: `${vertical > 0 ? '+' : ''}${grouped(vertical)}`,
|
||||
}),
|
||||
[
|
||||
ageS === null
|
||||
? t('POSITION {age}', { age: DASH })
|
||||
: t('POSITION {seconds} S AGO', { seconds: ageS }),
|
||||
tn(messages, '{count} MSG', '{count} MSGS'),
|
||||
].join(' · '),
|
||||
localAdsbReceiverLine(record),
|
||||
].filter(Boolean),
|
||||
accent: LOCAL_ADSB_COLOR,
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { localReceiverFeedName } from './feedNames.js';
|
||||
import { LAYER_SOURCE } from './policy.js';
|
||||
|
||||
// Marks English that the Layers panel translates when it shows the hint.
|
||||
const msg = (text) => text;
|
||||
|
||||
/**
|
||||
* Layers-panel status for Local ADS-B, from its two inputs: the browser
|
||||
* WebUSB receiver and the server's decoder feeds. Pure; no DOM or Cesium.
|
||||
@@ -58,12 +61,15 @@ function webUsbStatus(receiver, heard, feedState) {
|
||||
if (!receiver.webUsbSupported)
|
||||
return {
|
||||
status: 'idle',
|
||||
statusMessage: 'WebUSB needs desktop Chrome or Edge',
|
||||
statusMessage: msg('WebUSB needs desktop Chrome or Edge'),
|
||||
};
|
||||
if (!receiver.connected)
|
||||
return { status: 'idle', statusMessage: 'connect a receiver in Radio' };
|
||||
return {
|
||||
status: 'idle',
|
||||
statusMessage: msg('connect a receiver in Radio'),
|
||||
};
|
||||
if (receiver.mode !== 'adsb')
|
||||
return { status: 'idle', statusMessage: 'receiver is in FM mode' };
|
||||
return { status: 'idle', statusMessage: msg('receiver is in FM mode') };
|
||||
const rate = Number.isFinite(receiver.messagesPerSecond)
|
||||
? `${receiver.messagesPerSecond} msg/s`
|
||||
: 'listening';
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { ERROR_BACKOFF_INTERVAL } from './recordPolicy.js';
|
||||
|
||||
// Marks English the Layers panel translates when it shows the error. Kept
|
||||
// local: this module stays free of browser globals (package boundaries).
|
||||
const msg = (text) => text;
|
||||
|
||||
/** Own military source acquisition, cancellation, freshness and error backoff. */
|
||||
export function createIngestion({
|
||||
feed,
|
||||
@@ -42,7 +46,7 @@ export function createIngestion({
|
||||
feed._lastError =
|
||||
snapshot.reason ||
|
||||
(snapshot.freshness === 'unknown'
|
||||
? 'Source snapshot time unavailable'
|
||||
? msg('Source snapshot time unavailable')
|
||||
: null);
|
||||
const accepted = applySnapshot(snapshot, viewer);
|
||||
feed._count = accepted.count;
|
||||
@@ -69,7 +73,9 @@ export function createIngestion({
|
||||
setSourceLabel(feed._lastSource);
|
||||
}
|
||||
feed._lastError =
|
||||
e?.name === 'LiveSourceError' ? e.message : 'Live data unavailable';
|
||||
e?.name === 'LiveSourceError'
|
||||
? e.message
|
||||
: msg('Live data unavailable');
|
||||
} finally {
|
||||
feed._activeUpdateControllers.delete(resourceController);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
// K-37 / K-29: the selected-fire card of Fire Perimeters and its accessible
|
||||
// label follow the UI language, like the legend of the same layer
|
||||
// (perimeters/index.js). cards.test.mjs pins the English card.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { setLocale, t } from '../../i18n/index.js';
|
||||
import { buildIncidentCard } from './cards.js';
|
||||
|
||||
const now = Date.UTC(2026, 9, 14);
|
||||
const row = {
|
||||
stableId: 'x',
|
||||
name: 'Lake',
|
||||
acres: 1200,
|
||||
containedPct: 40,
|
||||
state: 'CA',
|
||||
discoveredTime: now - 2 * 3600e3,
|
||||
updatedTime: now - 3600e3,
|
||||
cause: 'Human',
|
||||
behavior: 'Moderate',
|
||||
complexity: 'Type 3 Incident',
|
||||
personnel: 1500,
|
||||
county: 'Plumas',
|
||||
costToDate: 4200000,
|
||||
complexName: 'NORTH COMPLEX',
|
||||
};
|
||||
|
||||
test('en: the fire card keeps its English wording', () => {
|
||||
setLocale('en');
|
||||
const card = buildIncidentCard(row, now, { link: 'https://inciweb.example' });
|
||||
assert.equal(card.title, 'FIRE · Lake');
|
||||
assert.deepEqual(card.details, [
|
||||
'1,200 ac · 40% contained · CA',
|
||||
'part of North Complex',
|
||||
'Human cause · Moderate · Type 3 Incident',
|
||||
'1,500 personnel · Plumas County · $4.2M to date',
|
||||
'discovered 2h ago · updated 1h ago',
|
||||
'InciWeb ↗ · click card to open',
|
||||
]);
|
||||
assert.equal(card.accessibilityLabel, 'Open FIRE · Lake on InciWeb');
|
||||
});
|
||||
|
||||
for (const locale of ['es', 'ru']) {
|
||||
test(`${locale}: selected fire card follows the UI language`, () => {
|
||||
setLocale(locale);
|
||||
try {
|
||||
assert.notEqual(
|
||||
t('Fully contained'),
|
||||
'Fully contained',
|
||||
'legend is translated',
|
||||
);
|
||||
const card = buildIncidentCard(row, now, {
|
||||
link: 'https://inciweb.example',
|
||||
});
|
||||
const text = [card.title, ...card.details, card.accessibilityLabel].join(
|
||||
' | ',
|
||||
);
|
||||
for (const english of [
|
||||
'FIRE',
|
||||
' ac ',
|
||||
'contained',
|
||||
'discovered',
|
||||
'updated',
|
||||
'part of',
|
||||
'cause',
|
||||
'personnel',
|
||||
'County',
|
||||
'to date',
|
||||
'click card to open',
|
||||
'Open ',
|
||||
])
|
||||
assert.ok(
|
||||
!text.includes(english),
|
||||
`${locale}: English "${english}" in ${text}`,
|
||||
);
|
||||
assert.ok(
|
||||
!text.includes('1,200'),
|
||||
`${locale}: en-US grouping in ${text}`,
|
||||
);
|
||||
} finally {
|
||||
setLocale('en');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// C5 / C11 / E7: known WFIGS values, the cost and the unnamed title follow the
|
||||
// UI language; the row keeps the feed's English for the AI tools.
|
||||
const expected = {
|
||||
es: {
|
||||
situation: 'causa: humana · actividad moderada · incidente de tipo 3',
|
||||
cost: '4,2 M$ hasta la fecha',
|
||||
unnamed: 'INCENDIO · Sin nombre',
|
||||
},
|
||||
ru: {
|
||||
situation:
|
||||
'причина: деятельность человека · умеренная активность · инцидент 3-го типа',
|
||||
cost: '4,2 млн $ на сегодня',
|
||||
unnamed: 'ПОЖАР · Без названия',
|
||||
},
|
||||
};
|
||||
|
||||
for (const [locale, want] of Object.entries(expected)) {
|
||||
test(`${locale}: feed values, cost and unnamed title are localized`, () => {
|
||||
setLocale(locale);
|
||||
try {
|
||||
const card = buildIncidentCard(row, now);
|
||||
assert.equal(card.details[2], want.situation);
|
||||
assert.ok(
|
||||
card.details[3].endsWith(want.cost),
|
||||
`${locale}: ${card.details[3]}`,
|
||||
);
|
||||
assert.equal(
|
||||
buildIncidentCard({ ...row, name: '' }, now).title,
|
||||
want.unnamed,
|
||||
);
|
||||
assert.equal(row.cause, 'Human', 'the row keeps the feed value');
|
||||
} finally {
|
||||
setLocale('en');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('unknown WFIGS values are shown as sent', () => {
|
||||
setLocale('ru');
|
||||
try {
|
||||
const card = buildIncidentCard(
|
||||
{
|
||||
...row,
|
||||
cause: 'Investigated',
|
||||
behavior: 'Smoldering',
|
||||
complexity: 'Complex Incident',
|
||||
},
|
||||
now,
|
||||
);
|
||||
assert.equal(
|
||||
card.details[2],
|
||||
'причина: Investigated · Smoldering · Complex Incident',
|
||||
);
|
||||
} finally {
|
||||
setLocale('en');
|
||||
}
|
||||
});
|
||||
|
||||
test('en: compact costs keep the dollar prefix', () => {
|
||||
setLocale('en');
|
||||
const cost = (costToDate) =>
|
||||
buildIncidentCard({ ...row, costToDate }, now).details[3].split(' · ')[2];
|
||||
assert.equal(cost(85_000), '$85K to date');
|
||||
assert.equal(cost(999_600), '$1M to date');
|
||||
assert.equal(cost(1_300_000_000), '$1.3B to date');
|
||||
});
|
||||
+102
-30
@@ -1,4 +1,5 @@
|
||||
/** Card model for one selected fire-perimeter incident. Pure — no Cesium types. */
|
||||
import { formatNumber, t, tn } from '../../i18n/index.js';
|
||||
|
||||
export const PERIMETER_OVERLAY_SOURCE_ID = 'fire-perimeters';
|
||||
|
||||
@@ -50,25 +51,70 @@ export function perimeterAnchorDegrees(polygons) {
|
||||
return { lon: lon / count, lat: lat / count };
|
||||
}
|
||||
|
||||
/** Compact USD, e.g. $85K / $4.2M / $1.3B. Null under $1,000. */
|
||||
/**
|
||||
* Compact USD in the UI language: en "$85K / $4.2M / $1.3B", ru "4,2 млн $",
|
||||
* es "4,2 M$". Null under $1,000.
|
||||
*/
|
||||
function formatCost(dollars) {
|
||||
if (!Number.isFinite(dollars) || dollars < 1000) return null;
|
||||
const units = [
|
||||
[1e3, 'K'],
|
||||
[1e6, 'M'],
|
||||
[1e9, 'B'],
|
||||
];
|
||||
const units = [1e3, 1e6, 1e9];
|
||||
let index = 0;
|
||||
for (let i = units.length - 1; i >= 0; i--) {
|
||||
if (dollars >= units[i][0]) {
|
||||
if (dollars >= units[i]) {
|
||||
index = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
// 999,500 must promote to $1M, never render as $1000K.
|
||||
if (dollars >= units[index][0] * 999.5 && index < units.length - 1) index++;
|
||||
const value = dollars / units[index][0];
|
||||
return `$${value >= 10 ? Math.round(value) : Math.round(value * 10) / 10}${units[index][1]}`;
|
||||
if (dollars >= units[index] * 999.5 && index < units.length - 1) index++;
|
||||
const value = dollars / units[index];
|
||||
const amount = formatNumber(
|
||||
value >= 10 ? Math.round(value) : Math.round(value * 10) / 10,
|
||||
);
|
||||
if (index === 0) return t('${amount}K', { amount });
|
||||
if (index === 1) return t('${amount}M', { amount });
|
||||
return t('${amount}B', { amount });
|
||||
}
|
||||
|
||||
/** WFIGS attr_FireCause values in the UI language; others as sent. */
|
||||
function causeLabel(cause) {
|
||||
switch (cause) {
|
||||
case 'Human':
|
||||
return t('Human', { context: 'fire cause' });
|
||||
case 'Natural':
|
||||
return t('Natural', { context: 'fire cause' });
|
||||
case 'Undetermined':
|
||||
return t('Undetermined', { context: 'fire cause' });
|
||||
case 'Unknown':
|
||||
return t('Unknown', { context: 'fire cause' });
|
||||
default:
|
||||
return cause;
|
||||
}
|
||||
}
|
||||
|
||||
/** WFIGS attr_FireBehaviorGeneral values in the UI language; others as sent. */
|
||||
function behaviorLabel(behavior) {
|
||||
switch (behavior) {
|
||||
case 'Minimal':
|
||||
return t('Minimal', { context: 'fire behavior' });
|
||||
case 'Moderate':
|
||||
return t('Moderate', { context: 'fire behavior' });
|
||||
case 'Active':
|
||||
return t('Active', { context: 'fire behavior' });
|
||||
case 'Extreme':
|
||||
return t('Extreme', { context: 'fire behavior' });
|
||||
default:
|
||||
return behavior;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* WFIGS attr_IncidentComplexityLevel ("Type 1 Incident" … "Type 5 Incident")
|
||||
* in the UI language; other values as sent.
|
||||
*/
|
||||
function complexityLabel(complexity) {
|
||||
const match = /^Type ([1-5]) Incident$/.exec(complexity);
|
||||
return match ? t('Type {level} Incident', { level: match[1] }) : complexity;
|
||||
}
|
||||
|
||||
/** WFIGS complex names arrive ALL-CAPS; render them in title case. */
|
||||
@@ -78,12 +124,17 @@ function titleCase(name) {
|
||||
.replace(/\b[a-z]/g, (letter) => letter.toUpperCase());
|
||||
}
|
||||
|
||||
/** Compact age, "5m" / "3h" / "2d", in the UI language ("3 h", "3 ч"). */
|
||||
function formatAge(deltaMs) {
|
||||
if (!Number.isFinite(deltaMs) || deltaMs < 0) return null;
|
||||
const hours = Math.floor(deltaMs / 3600000);
|
||||
if (hours < 1) return `${Math.max(1, Math.floor(deltaMs / 60000))}m`;
|
||||
if (hours < 24) return `${hours}h`;
|
||||
return `${Math.floor(hours / 24)}d`;
|
||||
if (hours < 1)
|
||||
return t('{count}m', {
|
||||
context: 'age',
|
||||
count: Math.max(1, Math.floor(deltaMs / 60000)),
|
||||
});
|
||||
if (hours < 24) return t('{count}h', { context: 'age', count: hours });
|
||||
return t('{count}d', { context: 'age', count: Math.floor(hours / 24) });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -95,48 +146,67 @@ function formatAge(deltaMs) {
|
||||
* @returns {Object} World-overlay entry without `position`.
|
||||
*/
|
||||
export function buildIncidentCard(row, nowMs, { link = null } = {}) {
|
||||
// Feed values stay as WFIGS sends them in the row (and in what the AI tools
|
||||
// read); the card translates the known cause, behavior and complexity values
|
||||
// and the wording around them. State, category, county and complex names
|
||||
// are shown as sent.
|
||||
const facts = [];
|
||||
if (Number.isFinite(row.acres))
|
||||
facts.push(`${Math.round(row.acres).toLocaleString('en-US')} ac`);
|
||||
if (Number.isFinite(row.acres)) {
|
||||
const acres = Math.round(row.acres);
|
||||
facts.push(
|
||||
tn(acres, '{acres} ac', '{acres} ac', { acres: formatNumber(acres) }),
|
||||
);
|
||||
}
|
||||
facts.push(
|
||||
Number.isFinite(row.containedPct)
|
||||
? `${Math.round(row.containedPct)}% contained`
|
||||
: 'containment unknown',
|
||||
? t('{percent}% contained', { percent: Math.round(row.containedPct) })
|
||||
: t('containment unknown'),
|
||||
);
|
||||
if (row.state) facts.push(row.state);
|
||||
else if (row.category) facts.push(row.category);
|
||||
|
||||
const situation = [];
|
||||
if (row.cause) situation.push(`${row.cause} cause`);
|
||||
if (row.behavior) situation.push(row.behavior);
|
||||
if (row.complexity) situation.push(row.complexity);
|
||||
if (row.cause)
|
||||
situation.push(t('{cause} cause', { cause: causeLabel(row.cause) }));
|
||||
if (row.behavior) situation.push(behaviorLabel(row.behavior));
|
||||
if (row.complexity) situation.push(complexityLabel(row.complexity));
|
||||
|
||||
const response = [];
|
||||
if (Number.isFinite(row.personnel))
|
||||
if (Number.isFinite(row.personnel)) {
|
||||
const personnel = Math.round(row.personnel);
|
||||
response.push(
|
||||
`${Math.round(row.personnel).toLocaleString('en-US')} personnel`,
|
||||
tn(personnel, '{personnel} personnel', '{personnel} personnel', {
|
||||
personnel: formatNumber(personnel),
|
||||
}),
|
||||
);
|
||||
if (row.county) response.push(`${row.county} County`);
|
||||
}
|
||||
if (row.county) response.push(t('{county} County', { county: row.county }));
|
||||
if (Number.isFinite(row.costToDate)) {
|
||||
const cost = formatCost(row.costToDate);
|
||||
if (cost) response.push(`${cost} to date`);
|
||||
if (cost) response.push(t('{cost} to date', { cost }));
|
||||
}
|
||||
|
||||
const ages = [];
|
||||
const discovered = formatAge(nowMs - row.discoveredTime);
|
||||
if (row.discoveredTime != null && discovered)
|
||||
ages.push(`discovered ${discovered} ago`);
|
||||
ages.push(t('discovered {age} ago', { age: discovered }));
|
||||
const updated = formatAge(nowMs - row.updatedTime);
|
||||
if (row.updatedTime != null && updated) ages.push(`updated ${updated} ago`);
|
||||
if (row.updatedTime != null && updated)
|
||||
ages.push(t('updated {age} ago', { age: updated }));
|
||||
|
||||
const details = [facts.join(' · ')];
|
||||
if (row.complexName) details.push(`part of ${titleCase(row.complexName)}`);
|
||||
if (row.complexName)
|
||||
details.push(
|
||||
t('part of {complex}', { complex: titleCase(row.complexName) }),
|
||||
);
|
||||
if (situation.length) details.push(situation.join(' · '));
|
||||
if (response.length) details.push(response.join(' · '));
|
||||
if (ages.length) details.push(ages.join(' · '));
|
||||
if (link) details.push('InciWeb ↗ · click card to open');
|
||||
if (link) details.push(t('InciWeb ↗ · click card to open'));
|
||||
|
||||
const title = `FIRE · ${row.name || 'Unnamed incident'}`;
|
||||
const title = t('FIRE · {name}', {
|
||||
name: row.name || t('Unnamed incident'),
|
||||
});
|
||||
return {
|
||||
id: `fire-perimeter-card:${row.stableId}`,
|
||||
// `selected` picks the paint lane and collision protection; an explicit
|
||||
@@ -145,7 +215,9 @@ export function buildIncidentCard(row, nowMs, { link = null } = {}) {
|
||||
// Only a linked card is clickable — the overlay host registers hit
|
||||
// rectangles solely for interactive entries.
|
||||
interactive: Boolean(link),
|
||||
...(link ? { accessibilityLabel: `Open ${title} on InciWeb` } : undefined),
|
||||
...(link
|
||||
? { accessibilityLabel: t('Open {title} on InciWeb', { title }) }
|
||||
: undefined),
|
||||
title,
|
||||
details,
|
||||
accent: containmentAccent(row.containedPct),
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
inciwebNodeId,
|
||||
isCurrentPublication,
|
||||
} from './inciweb.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
import { msg, t } from '../../i18n/index.js';
|
||||
export { normalizeFirePerimeterSnapshot } from './records.js';
|
||||
export { createWfigsPerimeterSource } from './source.js';
|
||||
export * from './cards.js';
|
||||
@@ -365,7 +365,7 @@ export function createFirePerimetersLayer({
|
||||
if (request.signal.aborted || _request !== request || !_enabled)
|
||||
return false;
|
||||
console.warn('[Data:FirePerimeters] Fetch error:', e);
|
||||
_lastError = e?.message || 'Perimeter source unavailable';
|
||||
_lastError = e?.message || msg('Perimeter source unavailable');
|
||||
return false;
|
||||
} finally {
|
||||
if (_request === request) _request = null;
|
||||
|
||||
@@ -1,8 +1,22 @@
|
||||
import { msg } from '../../i18n/index.js';
|
||||
import {
|
||||
RADIO_DIRECTORY_STALE_MS,
|
||||
RADIO_DIRECTORY_FUTURE_SKEW_MS,
|
||||
} from './policy.js';
|
||||
|
||||
// Directory errors stay English in layer state and stats; the layer row and
|
||||
// the Radio panel translate them when shown.
|
||||
const ERROR_REFRESH_DEGRADED = msg(
|
||||
'Directory refresh degraded; showing the previous station catalog.',
|
||||
);
|
||||
const ERROR_COVERAGE_DEGRADED = msg('Radio directory coverage is degraded.');
|
||||
const ERROR_REFRESH_FAILED = msg(
|
||||
'Directory refresh failed; showing the previous station catalog.',
|
||||
);
|
||||
const ERROR_DIRECTORY_UNAVAILABLE = msg(
|
||||
'Radio directory is temporarily unavailable.',
|
||||
);
|
||||
|
||||
export function createIngestion({
|
||||
state: layerState,
|
||||
services,
|
||||
@@ -136,9 +150,9 @@ export function createIngestion({
|
||||
layerState._degraded = body.degraded;
|
||||
layerState._stale = body.stale;
|
||||
layerState._error = preservingWarmCatalog
|
||||
? 'Directory refresh degraded; showing the previous station catalog.'
|
||||
? ERROR_REFRESH_DEGRADED
|
||||
: layerState._degraded
|
||||
? 'Radio directory coverage is degraded.'
|
||||
? ERROR_COVERAGE_DEGRADED
|
||||
: null;
|
||||
} catch (error) {
|
||||
if (
|
||||
@@ -153,8 +167,8 @@ export function createIngestion({
|
||||
)
|
||||
return;
|
||||
layerState._error = layerState._stations.length
|
||||
? 'Directory refresh failed; showing the previous station catalog.'
|
||||
: 'Radio directory is temporarily unavailable.';
|
||||
? ERROR_REFRESH_FAILED
|
||||
: ERROR_DIRECTORY_UNAVAILABLE;
|
||||
layerState._stale = layerState._stations.length > 0;
|
||||
layerState._degraded = layerState._stations.length > 0;
|
||||
} finally {
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { setLocale, t, translateKnown } from '../../i18n/index.js';
|
||||
import { createIngestion } from './ingestion.js';
|
||||
|
||||
/**
|
||||
* Directory errors are stored in English in layer state (layerPanel and the
|
||||
* Radio panel translate them when shown). Every error the ingestion can set
|
||||
* must therefore have a catalog entry in each shipped language.
|
||||
*/
|
||||
|
||||
function makeIngestion({ stations = [], body = null, failure = null } = {}) {
|
||||
const layerState = {
|
||||
_enabled: true,
|
||||
_requestGeneration: 0,
|
||||
_sessionGeneration: 0,
|
||||
_abortController: null,
|
||||
_loading: false,
|
||||
_error: null,
|
||||
_stale: false,
|
||||
_degraded: false,
|
||||
_stations: stations,
|
||||
_updatedAt: null,
|
||||
_tuningActive: false,
|
||||
_cancelledTuningPresentationStation: null,
|
||||
_acceptedCatalogSnapshot: { generation: null, instance: null },
|
||||
};
|
||||
const parts = {
|
||||
presentation: { emitState() {} },
|
||||
model: { radioRequestIsCurrent: () => true },
|
||||
catalogModel: {
|
||||
isValidRadioDirectoryStation: () => true,
|
||||
freezeRadioStation: (station) => station,
|
||||
createAcceptedCatalogSnapshot: () => null,
|
||||
},
|
||||
rendering: { reconcileStations() {}, updateSelectionEntity() {} },
|
||||
};
|
||||
const source = {
|
||||
async getDirectory() {
|
||||
if (failure) throw failure;
|
||||
return body;
|
||||
},
|
||||
};
|
||||
const { methods } = createIngestion({
|
||||
state: layerState,
|
||||
services: {},
|
||||
parts,
|
||||
source,
|
||||
});
|
||||
return { layerState, methods };
|
||||
}
|
||||
|
||||
const degradedBody = () => ({
|
||||
stations: [{ id: 'a' }],
|
||||
updatedAt: new Date().toISOString(),
|
||||
stale: false,
|
||||
degraded: true,
|
||||
acceptedGeneration: 1,
|
||||
catalogInstance: 'instance-1',
|
||||
});
|
||||
|
||||
const CASES = [
|
||||
{
|
||||
name: 'directory unavailable without a catalog',
|
||||
options: { failure: new Error('network') },
|
||||
expected: 'Radio directory is temporarily unavailable.',
|
||||
},
|
||||
{
|
||||
name: 'refresh failed with a warm catalog',
|
||||
options: { stations: [{ id: 'a' }], failure: new Error('network') },
|
||||
expected: 'Directory refresh failed; showing the previous station catalog.',
|
||||
},
|
||||
{
|
||||
name: 'degraded coverage without a catalog',
|
||||
options: { body: degradedBody() },
|
||||
expected: 'Radio directory coverage is degraded.',
|
||||
},
|
||||
{
|
||||
name: 'degraded refresh with a warm catalog',
|
||||
options: { stations: [{ id: 'a' }], body: degradedBody() },
|
||||
expected:
|
||||
'Directory refresh degraded; showing the previous station catalog.',
|
||||
},
|
||||
];
|
||||
|
||||
for (const { name, options, expected } of CASES) {
|
||||
test(`radio directory error is English in state and translated on show: ${name}`, async () => {
|
||||
const { layerState, methods } = makeIngestion(options);
|
||||
await methods.update();
|
||||
assert.equal(layerState._error, expected);
|
||||
try {
|
||||
for (const locale of ['es', 'ru']) {
|
||||
setLocale(locale);
|
||||
const shown = translateKnown(layerState._error);
|
||||
assert.notEqual(shown, expected, `${locale}: ${name}`);
|
||||
assert.equal(t(layerState._error), shown);
|
||||
}
|
||||
} finally {
|
||||
setLocale('en');
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,18 @@
|
||||
import { msg } from '../../i18n/index.js';
|
||||
import { RADIO_VOICE_PLAYBACK_TIMEOUT_MS } from './policy.js';
|
||||
|
||||
// Playback errors stay English in layer state; the Radio panel shows them
|
||||
// through t().
|
||||
const AUDIO_ERROR_STREAM_FAILED = msg(
|
||||
'Broadcaster stream is unavailable or blocked by the browser.',
|
||||
);
|
||||
const AUDIO_ERROR_GESTURE_REQUIRED = msg(
|
||||
'Playback requires a direct click or tap.',
|
||||
);
|
||||
const AUDIO_ERROR_START_FAILED = msg(
|
||||
'Broadcaster stream could not be started.',
|
||||
);
|
||||
|
||||
export function createPlayback({ state: layerState, services, parts, source }) {
|
||||
function audioEventBelongsToActiveAttempt(audio) {
|
||||
const attempt = layerState._activePlaybackAttempt;
|
||||
@@ -75,8 +88,7 @@ export function createPlayback({ state: layerState, services, parts, source }) {
|
||||
const attempt = layerState._activePlaybackAttempt;
|
||||
if (tryRadioFallback(failedId, attempt?.origin, attempt?.id)) return;
|
||||
layerState._audioState = 'error';
|
||||
layerState._audioError =
|
||||
'Broadcaster stream is unavailable or blocked by the browser.';
|
||||
layerState._audioError = AUDIO_ERROR_STREAM_FAILED;
|
||||
parts.presentation.emitState();
|
||||
});
|
||||
}
|
||||
@@ -202,8 +214,8 @@ export function createPlayback({ state: layerState, services, parts, source }) {
|
||||
layerState._audioState = 'error';
|
||||
layerState._audioError =
|
||||
error?.name === 'NotAllowedError'
|
||||
? 'Playback requires a direct click or tap.'
|
||||
: 'Broadcaster stream could not be started.';
|
||||
? AUDIO_ERROR_GESTURE_REQUIRED
|
||||
: AUDIO_ERROR_START_FAILED;
|
||||
parts.presentation.emitState();
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -632,7 +632,7 @@ export function createRecentImageryLayer({
|
||||
} catch (error) {
|
||||
if (abort.signal.aborted) return;
|
||||
resetCatalog();
|
||||
_catalogError = error?.message || 'Imagery catalog unavailable';
|
||||
_catalogError = error?.message || msg('Imagery catalog unavailable');
|
||||
} finally {
|
||||
if (_searchAbort === abort) {
|
||||
_searchAbort = null;
|
||||
|
||||
@@ -132,11 +132,12 @@ const refuse = (reason, message) => ({ ok: false, reason, message });
|
||||
*/
|
||||
export function validateBox(box) {
|
||||
const normalized = normalizeBox(box);
|
||||
if (!normalized) return refuse('invalid', 'Box edges must be finite');
|
||||
if (!normalized) return refuse('invalid', msg('Box edges must be finite'));
|
||||
const { west, south, east, north } = normalized;
|
||||
if (Math.abs(west) > 180 || Math.abs(east) > 180)
|
||||
return refuse('invalid', 'Longitudes must be within ±180°');
|
||||
if (west > east) return refuse('dateline', 'Select one side of the dateline');
|
||||
return refuse('invalid', msg('Longitudes must be within ±180°'));
|
||||
if (west > east)
|
||||
return refuse('dateline', msg('Select one side of the dateline'));
|
||||
if (
|
||||
Math.abs(south) > MERCATOR_LAT_LIMIT ||
|
||||
Math.abs(north) > MERCATOR_LAT_LIMIT
|
||||
@@ -146,7 +147,7 @@ export function validateBox(box) {
|
||||
`Imagery stops at ±${MERCATOR_LAT_LIMIT.toFixed(2)}° latitude`,
|
||||
);
|
||||
if (east === west || north === south)
|
||||
return refuse('degenerate', 'Box must enclose an area');
|
||||
return refuse('degenerate', msg('Box must enclose an area'));
|
||||
const { width, height } = boxSideKm(normalized);
|
||||
if (width > MAX_BOX_SIDE_KM || height > MAX_BOX_SIDE_KM)
|
||||
return refuse('too-large', tooLargeMessage(Math.max(width, height)));
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { twoline2satrec } from 'satellite.js';
|
||||
import * as Cesium from 'cesium';
|
||||
import { msg } from '../../i18n/index.js';
|
||||
import { CATALOG_GROUPS, ISS_NORAD, POINT_STYLES } from './policy.js';
|
||||
|
||||
export function createIngestion({
|
||||
@@ -59,7 +60,8 @@ export function createIngestion({
|
||||
// the chip still read "just now". Keep the existing (stale) catalog on
|
||||
// screen and surface the outage instead — do NOT stamp _lastUpdate.
|
||||
if (results.every((r) => !r.ok)) {
|
||||
layerState._lastError = 'CelesTrak unreachable';
|
||||
// English data (stats reach the voice tools); the panel translates it.
|
||||
layerState._lastError = msg('CelesTrak unreachable');
|
||||
console.warn(
|
||||
'[Data:Satellites] All CelesTrak groups failed — keeping existing catalog, surfacing outage',
|
||||
);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { satelliteClassLabel } from '../../data/satelliteClass.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
import { formatQuantity, unitLabel } from '../../ui/unitLabels.js';
|
||||
import {
|
||||
ISS_NORAD,
|
||||
CONTEXT_REFRESH_INTERVAL_MS,
|
||||
@@ -354,7 +356,12 @@ export function createTracking({ state: layerState, services, parts, source }) {
|
||||
const title = sat?.name?.trim() || `SAT-${layerState._trackedNorad}`;
|
||||
const altitudeM =
|
||||
layerState._trackedFrameGeo?.altitude ?? fallbackAltitudeM;
|
||||
const detail = `${Number.isFinite(altitudeM) ? Math.round(altitudeM / 1000) : '?'} km · NORAD ${layerState._trackedNorad}`;
|
||||
// The card is display only (the voice context slot keeps its own English
|
||||
// copy), so the altitude unit and the companions line follow the language.
|
||||
const altitudeText = Number.isFinite(altitudeM)
|
||||
? formatQuantity(Math.round(altitudeM / 1000), 'km')
|
||||
: `?\u00a0${unitLabel('km')}`;
|
||||
const detail = `${altitudeText} · NORAD ${layerState._trackedNorad}`;
|
||||
// Class leads the detail block: it is what tells the operator WHAT they are
|
||||
// looking at, and it stays readable under the IR styles that flatten the
|
||||
// dot colors to a single channel (the card is painted above post-FX).
|
||||
@@ -372,7 +379,9 @@ export function createTracking({ state: layerState, services, parts, source }) {
|
||||
if (companions.length > 0) {
|
||||
const extra = companions.length - 1;
|
||||
details.push(
|
||||
`DOCKED · ${companions[0]}${extra > 0 ? ` · +${extra}` : ''}`,
|
||||
extra > 0
|
||||
? t('DOCKED · {name} · +{extra}', { name: companions[0], extra })
|
||||
: t('DOCKED · {name}', { name: companions[0] }),
|
||||
);
|
||||
}
|
||||
const current = layerState._trackedEntity.gevLabelModel;
|
||||
|
||||
@@ -18,7 +18,7 @@ import { cameraHeightAboveGround, viewCentre, whenIdle } from './view.js';
|
||||
import { createGroundClick, groundClickHeightOk } from './groundClick.js';
|
||||
import { createGroundCaster, nextSurfaceMode } from './groundCast.js';
|
||||
import { createMeshSampler } from './meshSampler.js';
|
||||
import { t, translateKnown } from '../../i18n/index.js';
|
||||
import { msg, t, translateKnown } from '../../i18n/index.js';
|
||||
|
||||
/** Above this camera height the ground under the camera is not worth fetching. */
|
||||
const SURFACE_WARM_BELOW_M = 6000;
|
||||
@@ -583,10 +583,13 @@ export function createStreetLevelLayer({
|
||||
// The lifecycle polls this every second: summarise, don't snapshot.
|
||||
const snapshots = providerSnapshots();
|
||||
const coverage = summarizeCoverage(snapshots);
|
||||
// English data: the layer panel translates these through translateKnown.
|
||||
let loadingLabel = '';
|
||||
const keyLabel = coverage.keyRejected ? 'KEY REJECTED' : 'KEY REQUIRED';
|
||||
const keyLabel = coverage.keyRejected
|
||||
? msg('KEY REJECTED')
|
||||
: msg('KEY REQUIRED');
|
||||
if (coverage.keyRequired) loadingLabel = keyLabel;
|
||||
else if (coverage.loading) loadingLabel = 'loading coverage...';
|
||||
else if (coverage.loading) loadingLabel = msg('loading coverage...');
|
||||
else if (coverage.hint && state.enabled) loadingLabel = coverage.hint;
|
||||
return {
|
||||
source: layer.source,
|
||||
@@ -737,10 +740,21 @@ export function createStreetLevelLayer({
|
||||
const stillOn = entries.some(
|
||||
(entry) => entry.on && providerAvailable(entry),
|
||||
);
|
||||
// A provider's own refusal is English data that the panel translates;
|
||||
// the core's refusal is built here, for the panel only, in the language.
|
||||
if (state.enabled && stillOn)
|
||||
state.street.error =
|
||||
lastError?.message ||
|
||||
`No street-level imagery within ${NEAREST_RADIUS_M} m of ${point ? 'that point' : 'the view centre'}`;
|
||||
(point
|
||||
? t('No street-level imagery within {radius} m of that point', {
|
||||
radius: NEAREST_RADIUS_M,
|
||||
})
|
||||
: t(
|
||||
'No street-level imagery within {radius} m of the view centre',
|
||||
{
|
||||
radius: NEAREST_RADIUS_M,
|
||||
},
|
||||
));
|
||||
state.street.loading = false;
|
||||
notify();
|
||||
return false;
|
||||
|
||||
@@ -6,6 +6,7 @@ import { MAPILLARY_CREDIT_HTML } from './providers/mapillary/policy.js';
|
||||
import { createMapillaryProvider } from './providers/mapillary/index.js';
|
||||
import { createKartaViewProvider } from './providers/kartaview/index.js';
|
||||
import { fakeStreetLevelProvider as fakeProvider } from '../../testSupport/streetLevelFakes.mjs';
|
||||
import { setLocale } from '../../i18n/index.js';
|
||||
|
||||
/**
|
||||
* A stand-in Cesium viewer the layer can be enabled on: a canvas for the
|
||||
@@ -690,6 +691,24 @@ test('a lookup for the click-to-open providers asks only them and reports "that
|
||||
);
|
||||
});
|
||||
|
||||
test('the nearest-image refusal is written in the language of the page', async (t) => {
|
||||
const { layer } = await withStreetView(t, { nearest: () => null });
|
||||
layer.setProviderEnabled('google', true);
|
||||
await settle();
|
||||
setLocale('es');
|
||||
try {
|
||||
await layer.openNearest(
|
||||
{ lat: 38.58, lon: -121.49 },
|
||||
{ providerIds: ['google'], frame: false },
|
||||
);
|
||||
const error = layer.getUIState().street.error;
|
||||
assert.notEqual(error, 'No street-level imagery within 50 m of that point');
|
||||
assert.match(error, /50/, 'keeps the radius');
|
||||
} finally {
|
||||
setLocale('en');
|
||||
}
|
||||
});
|
||||
|
||||
test('an image opened from a ground click leaves the camera where it is', async (t) => {
|
||||
const { layer, viewer } = await withStreetView(t);
|
||||
layer.setProviderEnabled('google', true);
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
// Reached by the providers' source modules (no browser globals, see
|
||||
// check:boundaries), so no i18n import: the text stays English data here.
|
||||
const msg = (text) => text;
|
||||
|
||||
/** Identity and shared tuning for the provider-neutral Street Level layer. */
|
||||
export const STREET_LEVEL_LAYER_ID = 'street-level';
|
||||
|
||||
@@ -41,5 +45,7 @@ export const FOLLOW_EYE_HEIGHT_M = 2.4;
|
||||
/** Above this, a ground click is a misclick, not a street. */
|
||||
export const GROUND_CLICK_MAX_HEIGHT_M = 3_000;
|
||||
|
||||
export const AIM_AT_GROUND_MESSAGE =
|
||||
'Aim the middle of the view at a street to look for imagery there';
|
||||
/** English data: the Street Level panel translates it (translateKnown). */
|
||||
export const AIM_AT_GROUND_MESSAGE = msg(
|
||||
'Aim the middle of the view at a street to look for imagery there',
|
||||
);
|
||||
@@ -3,6 +3,8 @@
|
||||
* `gm_authFailure` (key refused) reaches every `onAuthFailure` listener.
|
||||
*/
|
||||
|
||||
import { msg } from '../../../../i18n/index.js';
|
||||
|
||||
const SCRIPT_ORIGIN = 'https://maps.googleapis.com';
|
||||
const READY_CALLBACK = '__gevGoogleMapsReady';
|
||||
/** The page's one script load, shared by every loader on it. */
|
||||
@@ -44,7 +46,7 @@ export function createMapsLoader({
|
||||
function load() {
|
||||
const key = getApiKey?.();
|
||||
if (!key)
|
||||
return Promise.reject(new Error('GOOGLE_MAPS_API_KEY is not set'));
|
||||
return Promise.reject(new Error(msg('GOOGLE_MAPS_API_KEY is not set')));
|
||||
installAuthHandler();
|
||||
if (global[PAGE_LOAD]) return global[PAGE_LOAD];
|
||||
if (global.google?.maps?.importLibrary)
|
||||
@@ -69,7 +71,7 @@ export function createMapsLoader({
|
||||
if (global[PAGE_LOAD] === loading) delete global[PAGE_LOAD];
|
||||
script.remove();
|
||||
delete global[READY_CALLBACK];
|
||||
reject(new Error('The Google Maps JavaScript API could not load'));
|
||||
reject(new Error(msg('The Google Maps JavaScript API could not load')));
|
||||
};
|
||||
document.head.append(script);
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
imageDateMs,
|
||||
streetViewUrl,
|
||||
} from './policy.js';
|
||||
import { msg } from '../../../../i18n/index.js';
|
||||
|
||||
const PANORAMA_OPTIONS = Object.freeze({
|
||||
visible: false,
|
||||
@@ -204,7 +205,7 @@ export function createGoogleViewer({ loader, getService, render } = {}) {
|
||||
else if (status)
|
||||
finish(
|
||||
reject,
|
||||
new Error('Street View has no imagery for this place'),
|
||||
new Error(msg('Street View has no imagery for this place')),
|
||||
);
|
||||
};
|
||||
const onPosition = () => {
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from './decode.js';
|
||||
import { createKartaViewSource } from './source.js';
|
||||
import { createKartaViewViewer } from './viewer.js';
|
||||
import { msg } from '../../../../i18n/index.js';
|
||||
import {
|
||||
ARCHIVED_MESSAGE,
|
||||
COLORS,
|
||||
@@ -195,7 +196,8 @@ export function createKartaViewProvider({
|
||||
const cached = photos.get(photoId);
|
||||
if (cached) return cached;
|
||||
const photo = remember(await source.getPhoto(photoId, { signal }));
|
||||
if (!photo) throw new Error('KartaView has no position for this photo');
|
||||
if (!photo)
|
||||
throw new Error(msg('KartaView has no position for this photo'));
|
||||
return photo;
|
||||
}
|
||||
|
||||
@@ -491,7 +493,8 @@ export function createKartaViewProvider({
|
||||
.open(photoId)
|
||||
.catch((error) =>
|
||||
context.actions.reportError(
|
||||
error?.message || 'The KartaView photo could not be loaded',
|
||||
error?.message ||
|
||||
msg('The KartaView photo could not be loaded'),
|
||||
),
|
||||
),
|
||||
render: context.services?.render,
|
||||
|
||||
@@ -50,8 +50,9 @@ export const COVERAGE_TUNING = Object.freeze({
|
||||
});
|
||||
export const TOO_HIGH_HINT = msg('zoom in to see KartaView coverage');
|
||||
export const NO_COVERAGE_HINT = msg('no KartaView photos in view');
|
||||
export const RATE_LIMITED_MESSAGE =
|
||||
'KartaView is rate-limiting requests — they resume on their own';
|
||||
export const RATE_LIMITED_MESSAGE = msg(
|
||||
'KartaView is rate-limiting requests — they resume on their own',
|
||||
);
|
||||
|
||||
/**
|
||||
* The photo API answers 429 to more than about three requests at once, so
|
||||
@@ -82,8 +83,9 @@ export const IMAGE_TIMEOUT_MS = 15_000;
|
||||
export const IMAGE_PROBE_TIMEOUT_MS = 8_000;
|
||||
/** Nearest photos probed (at once) before giving up on archived ones; ‹ › skip this many. */
|
||||
export const NEAREST_PROBES = 6;
|
||||
export const ARCHIVED_MESSAGE =
|
||||
'KartaView’s photos here are archived and cannot be shown';
|
||||
export const ARCHIVED_MESSAGE = msg(
|
||||
'KartaView’s photos here are archived and cannot be shown',
|
||||
);
|
||||
|
||||
/** Photos asked for around a point; the API returns them nearest first. */
|
||||
export const NEAREST_LIMIT = 10;
|
||||
@@ -136,10 +138,12 @@ export const MASK_MAX_BYTES = 2 * 1024 * 1024;
|
||||
export const MASK_KEEP_SHARE = 0.5;
|
||||
/** Points along a segment are tested this many mask pixels apart. */
|
||||
export const MASK_SAMPLE_PX = 2;
|
||||
export const MASK_SLOW_MESSAGE =
|
||||
'KartaView did not filter this area in time — move the map to ask again';
|
||||
export const FILTERED_OUT_MESSAGE =
|
||||
'None of KartaView’s photos here pass the imagery filter';
|
||||
export const MASK_SLOW_MESSAGE = msg(
|
||||
'KartaView did not filter this area in time — move the map to ask again',
|
||||
);
|
||||
export const FILTERED_OUT_MESSAGE = msg(
|
||||
'None of KartaView’s photos here pass the imagery filter',
|
||||
);
|
||||
/** Under the SINCE slider while KartaView is lit. */
|
||||
export const SINCE_NOTE = 'KartaView: by upload date';
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
TILE_TIMEOUT_MS,
|
||||
photoFilterParams,
|
||||
} from './policy.js';
|
||||
import { msg } from '../../../../i18n/index.js';
|
||||
|
||||
/**
|
||||
* An HTTP or API failure; `retryAfterSec` is set while KartaView rate-limits,
|
||||
@@ -40,9 +41,10 @@ export class KartaViewSourceError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
const NO_ANSWER = 'KartaView did not answer in time — try again';
|
||||
const UNREACHABLE = 'KartaView could not be reached — check the network';
|
||||
const UNREADABLE = 'KartaView sent an answer that could not be read';
|
||||
// Failure texts are English data: the Street Level panel translates them.
|
||||
const NO_ANSWER = msg('KartaView did not answer in time — try again');
|
||||
const UNREACHABLE = msg('KartaView could not be reached — check the network');
|
||||
const UNREADABLE = msg('KartaView sent an answer that could not be read');
|
||||
/** Statuses that mean a photo file is gone, not that storage hiccupped. */
|
||||
const GONE_STATUSES = Object.freeze([403, 404, 409, 410]);
|
||||
|
||||
@@ -169,10 +171,15 @@ export function createKartaViewSource({
|
||||
try {
|
||||
response = await fetchImpl(url, { signal: controller.signal });
|
||||
if (response.status === 429)
|
||||
throw new KartaViewSourceError('KartaView is rate-limiting requests', {
|
||||
status: 429,
|
||||
retryAfterSec: retryAfterSeconds(response) ?? 2,
|
||||
});
|
||||
throw new KartaViewSourceError(
|
||||
msg('KartaView is rate-limiting requests'),
|
||||
{
|
||||
status: 429,
|
||||
retryAfterSec: retryAfterSeconds(response) ?? 2,
|
||||
},
|
||||
);
|
||||
// English data with a number: the Street Level panel and the layer
|
||||
// panel show it through the 'KartaView answered HTTP {status}' entry.
|
||||
if (!response.ok)
|
||||
throw new KartaViewSourceError(
|
||||
`KartaView answered HTTP ${response.status}`,
|
||||
@@ -180,7 +187,7 @@ export function createKartaViewSource({
|
||||
);
|
||||
const length = Number(response.headers?.get?.('content-length'));
|
||||
const tooMuch = () =>
|
||||
new KartaViewSourceError('KartaView sent too much data', {
|
||||
new KartaViewSourceError(msg('KartaView sent too much data'), {
|
||||
status: response.status,
|
||||
});
|
||||
if (length > maxBytes) throw tooMuch();
|
||||
@@ -275,6 +282,7 @@ export function createKartaViewSource({
|
||||
);
|
||||
const code = Number(payload?.status?.apiCode);
|
||||
// 600 ok, 601 empty, 602 partial: anything else is a refusal.
|
||||
// Shown through 'KartaView refused the request (code {code})'.
|
||||
if (Number.isFinite(code) && (code < 600 || code > 603))
|
||||
throw new KartaViewSourceError(
|
||||
`KartaView refused the request (code ${code})`,
|
||||
@@ -288,7 +296,9 @@ export function createKartaViewSource({
|
||||
if (error?.status !== 429 || attempt >= PHOTO_API_RETRIES) {
|
||||
if (error?.status === 429)
|
||||
throw new KartaViewSourceError(
|
||||
'KartaView is rate-limiting requests — try again in a moment',
|
||||
msg(
|
||||
'KartaView is rate-limiting requests — try again in a moment',
|
||||
),
|
||||
{ status: 429, retryAfterSec: error.retryAfterSec },
|
||||
);
|
||||
throw error;
|
||||
@@ -419,7 +429,7 @@ export function createKartaViewSource({
|
||||
{ signal },
|
||||
);
|
||||
if (!result.data || typeof result.data !== 'object')
|
||||
throw new KartaViewSourceError('KartaView has no such photo');
|
||||
throw new KartaViewSourceError(msg('KartaView has no such photo'));
|
||||
return result.data;
|
||||
},
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
PANO_FOV_DEFAULT_DEG,
|
||||
wrapDegrees,
|
||||
} from './panorama.js';
|
||||
import { t } from '../../../../i18n/index.js';
|
||||
import { msg, t } from '../../../../i18n/index.js';
|
||||
import {
|
||||
FLAT_FULL_SIZE,
|
||||
FLAT_VIEW_SIZE,
|
||||
@@ -17,7 +17,8 @@ import {
|
||||
resizedImageUrl,
|
||||
} from './policy.js';
|
||||
|
||||
const LOAD_FAILED = 'The KartaView photo could not be loaded';
|
||||
/** English data: the Street Level panel translates it (translateKnown). */
|
||||
const LOAD_FAILED = msg('The KartaView photo could not be loaded');
|
||||
|
||||
/**
|
||||
* Load an image within the timeout. A CORS-clean one (for WebGL) is decoded
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { COLORS as SHARED_COLORS, PROVIDER_COLORS } from '../../policy.js';
|
||||
|
||||
// Reached by source.js (no browser globals, see check:boundaries), so no
|
||||
// i18n import: the messages stay English data, translated where shown.
|
||||
const msg = (text) => text;
|
||||
|
||||
export { NEAREST_RADIUS_M } from '../../policy.js';
|
||||
|
||||
/** Identity and tuning for the Mapillary street-level provider. */
|
||||
@@ -36,11 +40,13 @@ export const COVERAGE_MAX_TILES = 9;
|
||||
/** Overview (z0–5) coverage points seen from orbit. */
|
||||
export const COVERAGE_OVERVIEW_MAX_TILES = 16;
|
||||
/** Shown when Mapillary refuses the server's token (401/403). */
|
||||
export const KEY_REJECTED_MESSAGE =
|
||||
'Mapillary rejected MAPILLARY_CLIENT_TOKEN — replace it in Provider Settings';
|
||||
export const KEY_REJECTED_MESSAGE = msg(
|
||||
'Mapillary rejected MAPILLARY_CLIENT_TOKEN — replace it in Provider Settings',
|
||||
);
|
||||
/** Shown while Mapillary rate-limits tile requests (429). */
|
||||
export const RATE_LIMITED_MESSAGE =
|
||||
'Coverage requests are being rate-limited — they resume on their own';
|
||||
export const RATE_LIMITED_MESSAGE = msg(
|
||||
'Coverage requests are being rate-limited — they resume on their own',
|
||||
);
|
||||
/**
|
||||
* Street-zoom coverage reaches this far from the camera: camera height times
|
||||
* the factor, never below the floor, even when a tilted view sees further.
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { msg } from '../../i18n/index.js';
|
||||
import { freshStreet } from './state.js';
|
||||
|
||||
/**
|
||||
@@ -124,7 +125,8 @@ export function createViewerHost({ state, parts }) {
|
||||
if (!state.street.host) {
|
||||
// A nearest-image lookup may have set it; nothing is loading now.
|
||||
state.street.loading = false;
|
||||
state.street.error = 'Open the Street Level panel to view imagery';
|
||||
// English data: the Street Level panel translates it (translateKnown).
|
||||
state.street.error = msg('Open the Street Level panel to view imagery');
|
||||
notify();
|
||||
return false;
|
||||
}
|
||||
@@ -152,7 +154,7 @@ export function createViewerHost({ state, parts }) {
|
||||
if (ticket && !state.street.follow) parts.follow.lookAtPosition(ticket);
|
||||
} catch (error) {
|
||||
if (current())
|
||||
state.street.error = error?.message || 'Image could not be opened';
|
||||
state.street.error = error?.message || msg('Image could not be opened');
|
||||
} finally {
|
||||
if (current()) state.street.loading = false;
|
||||
notify();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { t } from '../../i18n/index.js';
|
||||
import { msg, t } from '../../i18n/index.js';
|
||||
import {
|
||||
featureLabel,
|
||||
featureReference,
|
||||
@@ -190,7 +190,7 @@ export function createIngestion({ state, parts, source }) {
|
||||
// A stale or aborted load reports nothing: its failure belongs to a
|
||||
// lifecycle the user already left.
|
||||
if (owns() && error?.name !== 'AbortError') {
|
||||
state._error = error?.message || 'TeleGeography load failed';
|
||||
state._error = error?.message || msg('TeleGeography load failed');
|
||||
console.warn(
|
||||
'[Data:telegeography-submarine-cables]',
|
||||
state._error,
|
||||
|
||||
Vendored
+34
-15
@@ -8,15 +8,17 @@ import {
|
||||
trafficStyleProfile,
|
||||
} from '../../data/trafficPresetStyle.js';
|
||||
import { TRAFFIC_TIMING_ENABLED } from './policy.js';
|
||||
import { t, translateKnown } from '../../i18n/index.js';
|
||||
|
||||
export function createControls({ state: layerState, services, parts, source }) {
|
||||
/** One status line: feed, drawn road source, then degraded-coverage notes. */
|
||||
function roadStatusLabel(feed) {
|
||||
const source = layerState._roadSource;
|
||||
if (layerState._roadError)
|
||||
return `UNAVAILABLE · ${source} · Roads unavailable`;
|
||||
return t('UNAVAILABLE · {source} · Roads unavailable', { source });
|
||||
const notes = [feed.loadingLabel];
|
||||
if (!layerState._liveMode || feed.error) notes.push(`Roads: ${source}`);
|
||||
if (!layerState._liveMode || feed.error)
|
||||
notes.push(t('Roads: {source}', { source }));
|
||||
// An explicit TomTom or Hybrid choice without a key draws OpenStreetMap.
|
||||
if (
|
||||
!layerState._liveMode &&
|
||||
@@ -24,16 +26,24 @@ export function createControls({ state: layerState, services, parts, source }) {
|
||||
)
|
||||
notes.push(
|
||||
layerState._flowStatusUnavailable
|
||||
? `${layerState._roadMode === 'tomtom' ? 'TomTom roads' : 'Hybrid'} unavailable while the traffic service is unreachable`
|
||||
? layerState._roadMode === 'tomtom'
|
||||
? t(
|
||||
'TomTom roads unavailable while the traffic service is unreachable',
|
||||
)
|
||||
: t('Hybrid unavailable while the traffic service is unreachable')
|
||||
: layerState._roadMode === 'tomtom'
|
||||
? 'TomTom roads need a TomTom key'
|
||||
: 'Hybrid needs a TomTom key',
|
||||
? t('TomTom roads need a TomTom key')
|
||||
: t('Hybrid needs a TomTom key'),
|
||||
);
|
||||
if (layerState._surfacePending) notes.push('Local surface still loading');
|
||||
if (layerState._roadWarning) notes.push(layerState._roadWarning);
|
||||
else if (layerState._roadPartial) notes.push('Partial coverage');
|
||||
if (layerState._detailError) notes.push('Detailed roads unavailable');
|
||||
else if (layerState._detailLimited) notes.push('Reduced detail coverage');
|
||||
if (layerState._surfacePending)
|
||||
notes.push(t('Local surface still loading'));
|
||||
// The warning is a road-source message (source.js marks the known ones).
|
||||
if (layerState._roadWarning)
|
||||
notes.push(translateKnown(layerState._roadWarning));
|
||||
else if (layerState._roadPartial) notes.push(t('Partial coverage'));
|
||||
if (layerState._detailError) notes.push(t('Detailed roads unavailable'));
|
||||
else if (layerState._detailLimited)
|
||||
notes.push(t('Reduced detail coverage'));
|
||||
return notes.join(' · ');
|
||||
}
|
||||
|
||||
@@ -161,13 +171,22 @@ export function createControls({ state: layerState, services, parts, source }) {
|
||||
getRowControls() {
|
||||
const selected =
|
||||
layerState._roadMode || resolveRoadMode(null, layerState._liveMode);
|
||||
const needsKey = layerState._liveMode ? '' : ' (needs a TomTom key)';
|
||||
const live = layerState._liveMode;
|
||||
const titles = {
|
||||
tomtom: `TomTom roads only, each with its live flow${needsKey}`,
|
||||
osm: 'OpenStreetMap roads, with TomTom flow matched when available',
|
||||
hybrid: `TomTom roads first; OpenStreetMap fills the rest, simulated${needsKey}`,
|
||||
tomtom: live
|
||||
? t('TomTom roads only, each with its live flow')
|
||||
: t(
|
||||
'TomTom roads only, each with its live flow (needs a TomTom key)',
|
||||
),
|
||||
osm: t('OpenStreetMap roads, with TomTom flow matched when available'),
|
||||
hybrid: live
|
||||
? t('TomTom roads first; OpenStreetMap fills the rest, simulated')
|
||||
: t(
|
||||
'TomTom roads first; OpenStreetMap fills the rest, simulated (needs a TomTom key)',
|
||||
),
|
||||
};
|
||||
const labels = { tomtom: 'TomTom', osm: 'OSM', hybrid: 'Hybrid' };
|
||||
// TomTom and OSM are provider names and stay as they are.
|
||||
const labels = { tomtom: 'TomTom', osm: 'OSM', hybrid: t('Hybrid') };
|
||||
return {
|
||||
chips: TRAFFIC_ROAD_MODES.map((mode) => ({
|
||||
id: `roads-${mode}`,
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
FAST_FETCH_ALTITUDE,
|
||||
roadDotBudget,
|
||||
} from './policy.js';
|
||||
import { msg } from '../../i18n/index.js';
|
||||
|
||||
export function createFlow({ state: layerState, services, parts, source }) {
|
||||
const { registerDynamicCredit, TOMTOM_CREDIT } = services.credits;
|
||||
@@ -36,11 +37,13 @@ export function createFlow({ state: layerState, services, parts, source }) {
|
||||
const status = Number.isFinite(error.status)
|
||||
? error.status
|
||||
: Number(message.match(/HTTP (\d{3})/)?.[1]);
|
||||
if (status === 503) return 'TomTom key unavailable';
|
||||
if (status === 429) return 'TomTom daily budget reached';
|
||||
if (status === 502 || status === 504) return 'TomTom upstream unreachable';
|
||||
// English in state; trafficFeedPresentation translates it for the panel.
|
||||
if (status === 503) return msg('TomTom key unavailable');
|
||||
if (status === 429) return msg('TomTom daily budget reached');
|
||||
if (status === 502 || status === 504)
|
||||
return msg('TomTom upstream unreachable');
|
||||
if (Number.isFinite(status)) return `TomTom flow error (HTTP ${status})`;
|
||||
return 'TomTom flow unavailable';
|
||||
return msg('TomTom flow unavailable');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { roadSurfaceChunks } from './surface.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
import { t, translateKnown } from '../../i18n/index.js';
|
||||
import {
|
||||
flowDensityMult,
|
||||
flowBucket,
|
||||
@@ -252,7 +252,10 @@ export function createModel({ state: layerState, services, parts, source }) {
|
||||
// drops `loadingLabel` in its error branch, so the owner's SIMULATED copy
|
||||
// has to BE the error text or the steady state reverts to a bare
|
||||
// "TomTom daily budget reached" that never says what is on screen.
|
||||
const degraded = `SIMULATED — ${flowError}`;
|
||||
// The reason is ours (flow.js marks it) or carries an HTTP code.
|
||||
const degraded = t('SIMULATED — {reason}', {
|
||||
reason: translateKnown(flowError),
|
||||
});
|
||||
return { mode, error: degraded, loadingLabel: degraded };
|
||||
}
|
||||
// TomTom and Hybrid name their geometry; OpenStreetMap keeps its match copy.
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
// K-37: the traffic road-status notes follow the UI language; English keeps
|
||||
// its wording.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { setLocale } from '../../i18n/index.js';
|
||||
import { createState } from './state.js';
|
||||
import { createControls } from './controls.js';
|
||||
|
||||
function loadingLabel(overrides) {
|
||||
const state = createState({ services: {} });
|
||||
Object.assign(state, overrides);
|
||||
const controls = createControls({
|
||||
state,
|
||||
services: {},
|
||||
source: { getFlowSessionStats: () => ({}) },
|
||||
parts: { model: { trafficFeedPresentation: () => ({ loadingLabel: '' }) } },
|
||||
});
|
||||
return controls.methods.getStats().loadingLabel;
|
||||
}
|
||||
|
||||
const DEGRADED = {
|
||||
_liveMode: false,
|
||||
_roadMode: 'tomtom',
|
||||
_roadSource: 'OpenStreetMap',
|
||||
_roadPartial: true,
|
||||
_detailLimited: true,
|
||||
_surfacePending: 2,
|
||||
};
|
||||
|
||||
test('en: road status notes keep their English wording', () => {
|
||||
setLocale('en');
|
||||
assert.equal(
|
||||
loadingLabel(DEGRADED),
|
||||
' · Roads: OpenStreetMap · TomTom roads need a TomTom key · Local surface still loading · Partial coverage · Reduced detail coverage',
|
||||
);
|
||||
assert.equal(
|
||||
loadingLabel({ _roadError: 'x', _roadSource: 'OpenStreetMap' }),
|
||||
'UNAVAILABLE · OpenStreetMap · Roads unavailable',
|
||||
);
|
||||
});
|
||||
|
||||
for (const locale of ['es', 'ru']) {
|
||||
test(`${locale}: road status notes are in the UI language`, () => {
|
||||
setLocale(locale);
|
||||
try {
|
||||
const label = loadingLabel({
|
||||
...DEGRADED,
|
||||
_roadPartial: false,
|
||||
_roadWarning: 'OpenFreeMap tiles rate-limited',
|
||||
});
|
||||
for (const english of [
|
||||
'Roads:',
|
||||
'TomTom key',
|
||||
'still loading',
|
||||
'rate-limited',
|
||||
'detail coverage',
|
||||
])
|
||||
assert.ok(
|
||||
!label.includes(english),
|
||||
`${locale}: "${english}" in ${label}`,
|
||||
);
|
||||
} finally {
|
||||
setLocale('en');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function roadChips(liveMode) {
|
||||
const state = createState({ services: {} });
|
||||
state._liveMode = liveMode;
|
||||
const controls = createControls({
|
||||
state,
|
||||
services: {},
|
||||
source: { getFlowSessionStats: () => ({}) },
|
||||
parts: {},
|
||||
});
|
||||
return controls.methods.getRowControls().chips;
|
||||
}
|
||||
|
||||
test('en: road-source chips keep their English labels and titles', () => {
|
||||
setLocale('en');
|
||||
assert.deepEqual(
|
||||
roadChips(false).map((chip) => [chip.label, chip.title]),
|
||||
[
|
||||
[
|
||||
'TomTom',
|
||||
'TomTom roads only, each with its live flow (needs a TomTom key)',
|
||||
],
|
||||
['OSM', 'OpenStreetMap roads, with TomTom flow matched when available'],
|
||||
[
|
||||
'Hybrid',
|
||||
'TomTom roads first; OpenStreetMap fills the rest, simulated (needs a TomTom key)',
|
||||
],
|
||||
],
|
||||
);
|
||||
assert.equal(
|
||||
roadChips(true)[2].title,
|
||||
'TomTom roads first; OpenStreetMap fills the rest, simulated',
|
||||
);
|
||||
});
|
||||
|
||||
for (const [locale, hybrid] of [
|
||||
['ru', 'Гибрид'],
|
||||
['es', 'Híbrido'],
|
||||
]) {
|
||||
test(`${locale}: the Hybrid chip and every chip title are translated`, () => {
|
||||
setLocale(locale);
|
||||
try {
|
||||
for (const live of [false, true]) {
|
||||
const chips = roadChips(live);
|
||||
assert.deepEqual(
|
||||
chips.map((chip) => chip.label),
|
||||
['TomTom', 'OSM', hybrid],
|
||||
);
|
||||
for (const chip of chips)
|
||||
assert.doesNotMatch(
|
||||
chip.title,
|
||||
/roads only|roads, with|fills the rest|needs a/,
|
||||
`${locale}: ${chip.title}`,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
setLocale('en');
|
||||
}
|
||||
});
|
||||
}
|
||||
Loaded 100 of 212 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user