mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
feat(i18n): translate the whole interface
Mark every UI string the unmarked-text check reported (534) plus text it cannot see (helpers, joined labels, runtime-built panels), and translate the result to Spanish and Russian: 1,679 messages each. - Kept on purpose, with the reason where the text lives: provider and product names, place and camera names, codes and units, dB values, the HUD terminal readouts, demo shot titles that saved projects look up by title, and text for AI tools and the voice model. - Modules shared with the server or AI tools stay free of the i18n import (local msg); the wind reading and voice session stay English and are translated where the page shows them. - npm test now runs the unmarked-text check. - Fixes on the way: a duplicated radio privacy paragraph, the contact panel toggle saying Expand while open, the CSS filter mistaking & for CSS. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
ee72133574
commit
3129e531e6
99 files changed
+4670
-1294
No files matched your search
+3
-1
@@ -10,7 +10,9 @@
|
||||
switch in the Visual Presets dock, then the browser language; English stays
|
||||
the default and the fallback. `npm run i18n`, also run by `npm test`, fails on
|
||||
missing, unused or broken translations. Built on the i18n foundation by
|
||||
@meirm (#601), with the Russian catalog started from #312.
|
||||
@meirm (#601), with the Russian catalog started from #312. It also fails on UI text that is
|
||||
not marked at all, so the whole interface, including surfaces added after
|
||||
the original extraction, is translated (1,679 messages).
|
||||
|
||||
- Treat explicitly current, complete empty vessel coverage as a successful update,
|
||||
clearing obsolete contacts while retaining stale or incomplete snapshots.
|
||||
|
||||
@@ -3030,8 +3030,11 @@ Shipped locales: **en** (source and fallback), **es** and **ru**. The code lives
|
||||
- **Not translated by design:** layer ids, share-link and hash parameters, API
|
||||
fields, voice tool schemas, provider names, feed data (callsigns, vessel and
|
||||
station names) and attribution.
|
||||
- **Gaps.** Strings added after #601's extraction (for example the Street Level
|
||||
panel) are still raw English; there is no lint for unmarked UI text yet.
|
||||
- **Unmarked text.** The same check lists UI text that is not marked at all
|
||||
(markup without `data-i18n*`, literals reaching the page without `t`/`tn`/`msg`)
|
||||
and fails on it. Text kept on purpose says why: `translate="no"`,
|
||||
`data-i18n-runtime`, or `// i18n-ignore: <reason>`. `src/tools/` is skipped:
|
||||
it writes for AI clients, not the page.
|
||||
|
||||
## Canonical Docs Order
|
||||
|
||||
|
||||
+2
-2
@@ -60,10 +60,10 @@ To see a language, open the app with `?lang=ru`, or use the language switch in t
|
||||
|
||||
## 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 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:` 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`; or kept in `label:`, `title:`, `tooltip:`, `placeholder:`, `hint:`, `ariaLabel:`, `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:
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ A `tn()` entry needs four forms. `Intl.PluralRules('ru')` picks:
|
||||
| `many` | 0, 5–20, 25–30, 111–114… | 5 камер |
|
||||
| `other` | fractions: 1.5, 2.25… | 1,5 камеры |
|
||||
|
||||
When a word next to a number cannot agree in every form, move the number after a colon instead: «доступно {bikes}», «ответов: {responses}».
|
||||
When a word next to a number cannot agree in every form, move the number after a colon instead: «доступно {bikes}», «ответов: {responses}». The same trick keeps place names and tool names out of a preposition's case: «откуда: {origin}», «Активно: {name}».
|
||||
|
||||
## Glossary
|
||||
|
||||
@@ -53,4 +53,9 @@ When a word next to a number cannot agree in every form, move the number after a
|
||||
| HDG / BRG / CRS / FOV / DEST / SPD / RANGE | КУРС / ПЕЛ. / ПУТЬ / ОБЗОР / НАЗН. / СКОР / ДАЛЬН. |
|
||||
| PREV / NEXT | ПРЕД. / СЛЕД. |
|
||||
| Previous / Next (button text) | Назад / Далее |
|
||||
| anchor / data pack / bundle (scene) | якорь / пакет данных / архив |
|
||||
| basemap / box (Recent Imagery area) | подложка / область |
|
||||
| camera move | перелёт |
|
||||
| SIG / NEWS / LOCAL (briefing tabs) | СИГН. / НОВОСТИ / МЕСТН. |
|
||||
| compass points (N, NNE, …) | С, ССВ, … |
|
||||
| L / R (relative bearing) | Л / П |
|
||||
+3
-3
@@ -207,7 +207,7 @@ const JS_SINKS = [
|
||||
/\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)\s*:\s*/gm,
|
||||
/(?:^|[{,\s])(?:label|title|tooltip|placeholder|hint|ariaLabel|caption|subtitle|kicker|emptyText|blurb|countLabel)\s*:\s*/gm,
|
||||
];
|
||||
const MARKERS = /\b(?:t|tn|msg)\(/y;
|
||||
|
||||
@@ -300,11 +300,11 @@ function visibleText(literal, html) {
|
||||
const attrs = [
|
||||
...body.matchAll(/\s(?:title|aria-label|placeholder|alt)="([^"]*)"/g),
|
||||
].map((m) => m[1]);
|
||||
body = [body.replace(/<[^>]*>/g, ' '), ...attrs].join(' ');
|
||||
body = decodeHtml([body.replace(/<[^>]*>/g, ' '), ...attrs].join(' '));
|
||||
}
|
||||
const text = normalize(body);
|
||||
if (!hasWords(text)) return null;
|
||||
if (/[{};]/.test(text) && /:\s*[^\s]/.test(text)) return null; // CSS
|
||||
if (/\{[^{}]*:[^{}]*\}|^[\w-]+\s*:\s*[^;]+;/.test(text)) return null; // CSS
|
||||
if (/^(?:rgba?|hsla?)\(|\b\d+px\b/.test(text)) return null; // colours, fonts
|
||||
if (/^[a-z0-9_.:/-]+$/.test(text) || /^https?:/.test(text)) return null;
|
||||
return text;
|
||||
|
||||
@@ -1003,7 +1003,11 @@
|
||||
"src/layers/recentImagery/catalog.js",
|
||||
"src/layers/recentImagery/rendering.js",
|
||||
"src/layers/recentImagery/thumbnails.js",
|
||||
"src/maps/imageryHost.js"
|
||||
"src/maps/imageryHost.js",
|
||||
"src/i18n/index.js",
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/ru.json"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
},
|
||||
@@ -1342,7 +1346,11 @@
|
||||
"src/sources/httpBody.js",
|
||||
"src/sources/vectorTiles.js",
|
||||
"src/sources/phaseTiming.js",
|
||||
"src/layers/alpr/floors.js"
|
||||
"src/layers/alpr/floors.js",
|
||||
"src/i18n/index.js",
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/ru.json"
|
||||
],
|
||||
"external": ["cesium", "@mapbox/vector-tile", "pbf"]
|
||||
},
|
||||
@@ -3080,7 +3088,11 @@
|
||||
"modules": [
|
||||
"src/layers/directions/index.js",
|
||||
"src/layers/directions/source.js",
|
||||
"src/data/routeSteps.js"
|
||||
"src/data/routeSteps.js",
|
||||
"src/i18n/index.js",
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/ru.json"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
},
|
||||
@@ -3358,7 +3370,11 @@
|
||||
"src/sources/overpassFeaturesRecords.js",
|
||||
"src/sources/overpassFeatures.js",
|
||||
"src/sources/featureSource.js",
|
||||
"src/sources/capability.js"
|
||||
"src/sources/capability.js",
|
||||
"src/i18n/index.js",
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/ru.json"
|
||||
],
|
||||
"external": ["cesium", "egm96-universal", "pbf"]
|
||||
},
|
||||
@@ -3440,7 +3456,11 @@
|
||||
"src/layers/perimeters/source.js",
|
||||
"src/layers/perimeters/records.js",
|
||||
"src/layers/perimeters/cards.js",
|
||||
"src/layers/perimeters/inciweb.js"
|
||||
"src/layers/perimeters/inciweb.js",
|
||||
"src/i18n/index.js",
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/ru.json"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
},
|
||||
|
||||
@@ -1137,12 +1137,14 @@ export function createAnnotationEngine({
|
||||
{
|
||||
type: 'highlight',
|
||||
target: 'Palace of Fine Arts, San Francisco',
|
||||
// i18n-ignore: dev demo standing in for voice-agent labels, which are not translated
|
||||
label: 'Palace of Fine Arts',
|
||||
color: 'amber',
|
||||
},
|
||||
{
|
||||
type: 'area',
|
||||
target: 'Presidio of San Francisco',
|
||||
// i18n-ignore: dev demo standing in for voice-agent labels, which are not translated
|
||||
label: 'The Presidio (former Army base)',
|
||||
color: 'green',
|
||||
footprint: true,
|
||||
@@ -1150,6 +1152,7 @@ export function createAnnotationEngine({
|
||||
{
|
||||
type: 'pin',
|
||||
target: 'Letterman Digital Arts Center, San Francisco',
|
||||
// i18n-ignore: dev demo standing in for voice-agent labels, which are not translated
|
||||
label: 'ILM / Lucasfilm',
|
||||
color: 'cyan',
|
||||
},
|
||||
@@ -1157,6 +1160,7 @@ export function createAnnotationEngine({
|
||||
type: 'arrow',
|
||||
target: 'Palace of Fine Arts, San Francisco',
|
||||
toTarget: 'Marina District, San Francisco',
|
||||
// i18n-ignore: dev demo standing in for voice-agent labels, which are not translated
|
||||
label: 'next to the Marina',
|
||||
},
|
||||
],
|
||||
@@ -1186,6 +1190,7 @@ export function createAnnotationEngine({
|
||||
{
|
||||
type: 'highlight',
|
||||
target: 'Palace of Fine Arts, San Francisco',
|
||||
// i18n-ignore: dev demo standing in for voice-agent labels, which are not translated
|
||||
label: 'Palace of Fine Arts',
|
||||
color: 'amber',
|
||||
},
|
||||
@@ -1200,6 +1205,7 @@ export function createAnnotationEngine({
|
||||
type: 'arrow',
|
||||
target: 'Palace of Fine Arts, San Francisco',
|
||||
toTarget: 'Marina Green, San Francisco',
|
||||
// i18n-ignore: dev demo standing in for voice-agent labels, which are not translated
|
||||
label: 'next to the Marina',
|
||||
color: 'cyan',
|
||||
},
|
||||
@@ -1223,6 +1229,7 @@ export function createAnnotationEngine({
|
||||
{
|
||||
type: 'area',
|
||||
target: 'Presidio of San Francisco',
|
||||
// i18n-ignore: dev demo standing in for voice-agent labels, which are not translated
|
||||
label: 'The Presidio — a former Army base',
|
||||
color: 'green',
|
||||
footprint: true,
|
||||
@@ -1237,6 +1244,7 @@ export function createAnnotationEngine({
|
||||
{
|
||||
type: 'pin',
|
||||
target: 'Letterman Digital Arts Center, San Francisco',
|
||||
// i18n-ignore: dev demo standing in for voice-agent labels, which are not translated
|
||||
label: 'ILM / Lucasfilm',
|
||||
color: 'red',
|
||||
},
|
||||
@@ -1250,6 +1258,7 @@ export function createAnnotationEngine({
|
||||
{
|
||||
type: 'route',
|
||||
color: 'amber',
|
||||
// i18n-ignore: dev demo standing in for voice-agent labels, which are not translated
|
||||
label: 'Crissy Field shoreline',
|
||||
points: [
|
||||
{ target: 'Palace of Fine Arts, San Francisco' },
|
||||
|
||||
@@ -198,7 +198,10 @@ export function initDrawTool({ viewer, annotations }) {
|
||||
// a limit rather than the harmless tail of a double-click.
|
||||
if (reason === 'full')
|
||||
setHint(
|
||||
`That shape already has ${MAX_VERTICES} points — finish it or press Backspace.`,
|
||||
t(
|
||||
'That shape already has {count} points — finish it or press Backspace.',
|
||||
{ count: MAX_VERTICES },
|
||||
),
|
||||
);
|
||||
else if (reason === 'invalid')
|
||||
setHint(t('That point is off the globe — click on the world.'));
|
||||
@@ -241,7 +244,11 @@ export function initDrawTool({ viewer, annotations }) {
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (destroyed || attempt !== generation) return null;
|
||||
setHint(`Could not place the shape: ${error?.message || error}`);
|
||||
setHint(
|
||||
t('Could not place the shape: {error}', {
|
||||
error: error?.message || error,
|
||||
}),
|
||||
);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
@@ -325,7 +332,11 @@ export function initDrawTool({ viewer, annotations }) {
|
||||
// Somebody else is using the pointer — possibly an older instance of
|
||||
// this same tool that has not finished tearing down. Say so instead of
|
||||
// half-starting.
|
||||
setHint(`${pointerOwner()} is using the pointer — close it first.`);
|
||||
setHint(
|
||||
t('{owner} is using the pointer — close it first.', {
|
||||
owner: pointerOwner(),
|
||||
}),
|
||||
);
|
||||
return active;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,12 @@ import {
|
||||
getActiveTrackedReadoutId,
|
||||
TRACKED_OVERLAY_SOURCE_ID,
|
||||
} from '../data/trackedReadout.js';
|
||||
import { t } from '../i18n/index.js';
|
||||
|
||||
/** A label whose detailed outline could not be resolved. */
|
||||
function outlineUnavailableLabel(label) {
|
||||
return t('{label} · Detailed outline unavailable', { label: label || '' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Screen-space annotation renderer (Direction B — the "whiteboard" aesthetic).
|
||||
@@ -163,7 +169,7 @@ export function createScreenAnnotationRenderer(
|
||||
group.appendChild(parts.poly);
|
||||
parts.label = makeCallout(
|
||||
anno.outlineUnavailable
|
||||
? `${anno.label || ''} · Detailed outline unavailable`
|
||||
? outlineUnavailableLabel(anno.label)
|
||||
: anno.label,
|
||||
c,
|
||||
);
|
||||
@@ -181,7 +187,7 @@ export function createScreenAnnotationRenderer(
|
||||
group.appendChild(parts.path);
|
||||
parts.label = makeCallout(
|
||||
anno.outlineUnavailable
|
||||
? `${anno.label || ''} · Detailed outline unavailable`
|
||||
? outlineUnavailableLabel(anno.label)
|
||||
: anno.label,
|
||||
c,
|
||||
);
|
||||
@@ -215,7 +221,7 @@ export function createScreenAnnotationRenderer(
|
||||
});
|
||||
parts.label = makeCallout(
|
||||
anno.outlineUnavailable
|
||||
? `${anno.label || ''} · Detailed outline unavailable`
|
||||
? outlineUnavailableLabel(anno.label)
|
||||
: anno.label,
|
||||
c,
|
||||
);
|
||||
@@ -259,7 +265,7 @@ export function createScreenAnnotationRenderer(
|
||||
group.appendChild(parts.leader);
|
||||
parts.label = makeCallout(
|
||||
anno.outlineUnavailable
|
||||
? `${anno.label || ''} · Detailed outline unavailable`
|
||||
? outlineUnavailableLabel(anno.label)
|
||||
: anno.label,
|
||||
c,
|
||||
);
|
||||
@@ -322,7 +328,7 @@ export function createScreenAnnotationRenderer(
|
||||
}
|
||||
rec.anno = anno;
|
||||
if (anno.outlineUnavailable && rec.parts.label) {
|
||||
rec.parts.label.text.textContent = `${anno.label || ''} · Detailed outline unavailable`;
|
||||
rec.parts.label.text.textContent = outlineUnavailableLabel(anno.label);
|
||||
sizeCallout(rec.parts.label);
|
||||
}
|
||||
|
||||
|
||||
@@ -541,6 +541,7 @@ export function panelRuntime(config) {
|
||||
const notice = document.createElement('div');
|
||||
notice.id = 'status';
|
||||
notice.textContent =
|
||||
// i18n-ignore: panel runtime runs as source text in the host page, with no i18n module or app locale
|
||||
"This panel lost its 3D graphics, likely because other God's Eye " +
|
||||
"View panels in this conversation hold them. Use Open in God's " +
|
||||
'Eye View above, or show it in a new conversation.';
|
||||
@@ -616,6 +617,7 @@ export function panelRuntime(config) {
|
||||
if (context?.displayMode) displayMode = context.displayMode;
|
||||
if (context?.availableDisplayModes)
|
||||
expand.hidden = !context.availableDisplayModes.includes('fullscreen');
|
||||
// i18n-ignore: panel runtime runs as source text in the host page, with no i18n module or app locale
|
||||
expand.textContent = displayMode === 'fullscreen' ? 'Collapse' : 'Expand';
|
||||
};
|
||||
expand.addEventListener('click', () => {
|
||||
@@ -640,6 +642,7 @@ export function panelRuntime(config) {
|
||||
appCapabilities: { availableDisplayModes: ['inline', 'fullscreen'] },
|
||||
appInfo: {
|
||||
name: 'gods-eye-view',
|
||||
// i18n-ignore: product name sent to the MCP host
|
||||
title: "God's Eye View",
|
||||
version: '1.0.0',
|
||||
},
|
||||
|
||||
+647
-183
File diff suppressed because it is too large.
Load diff
@@ -1,4 +1,5 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { t, msg } from '../i18n/index.js';
|
||||
|
||||
const YOUTUBE_HOSTS = new Set([
|
||||
'youtube.com',
|
||||
@@ -240,7 +241,7 @@ function loadFacebookSdk(documentRef, globalRef = globalThis) {
|
||||
prior?.();
|
||||
} finally {
|
||||
if (facebookSdkReady(globalRef.FB)) finish();
|
||||
else finish(new Error('Facebook player API did not initialize.'));
|
||||
else finish(new Error(msg('Facebook player API did not initialize.')));
|
||||
}
|
||||
};
|
||||
globalRef.fbAsyncInit = ready;
|
||||
@@ -256,12 +257,12 @@ function loadFacebookSdk(documentRef, globalRef = globalThis) {
|
||||
}
|
||||
script.addEventListener?.(
|
||||
'error',
|
||||
() => finish(new Error('Facebook player API could not load.')),
|
||||
() => finish(new Error(msg('Facebook player API could not load.'))),
|
||||
{ once: true },
|
||||
);
|
||||
timer =
|
||||
globalRef.setTimeout?.(
|
||||
() => finish(new Error('Facebook player API timed out.')),
|
||||
() => finish(new Error(msg('Facebook player API timed out.'))),
|
||||
EMBED_TIMEOUT_MS,
|
||||
) ?? null;
|
||||
}).catch((error) => {
|
||||
@@ -398,7 +399,7 @@ function loadXWidgets(documentRef, globalRef = globalThis) {
|
||||
};
|
||||
const ready = () => {
|
||||
if (xWidgetsReady(globalRef.twttr)) finish();
|
||||
else finish(new Error('X embed API did not initialize.'));
|
||||
else finish(new Error(msg('X embed API did not initialize.')));
|
||||
};
|
||||
let script = documentRef.querySelector?.(`script[src="${X_WIDGET_URL}"]`);
|
||||
if (!script) {
|
||||
@@ -411,7 +412,7 @@ function loadXWidgets(documentRef, globalRef = globalThis) {
|
||||
}
|
||||
script.addEventListener?.(
|
||||
'error',
|
||||
() => finish(new Error('X embed could not load.')),
|
||||
() => finish(new Error(msg('X embed could not load.'))),
|
||||
{
|
||||
once: true,
|
||||
},
|
||||
@@ -440,7 +441,7 @@ function loadXWidgets(documentRef, globalRef = globalThis) {
|
||||
},
|
||||
);
|
||||
timer = setTimeout(
|
||||
() => finish(new Error('X embed timed out.')),
|
||||
() => finish(new Error(msg('X embed timed out.'))),
|
||||
EMBED_TIMEOUT_MS,
|
||||
);
|
||||
}).catch((error) => {
|
||||
@@ -450,9 +451,17 @@ function loadXWidgets(documentRef, globalRef = globalThis) {
|
||||
return _xWidgetsPromise;
|
||||
}
|
||||
|
||||
// Outcomes a trimmed YouTube clip reports to the card status.
|
||||
const YOUTUBE_PHASE_LABELS = Object.freeze({
|
||||
unavailable: msg('UNAVAILABLE'),
|
||||
timeout: msg('TIMEOUT'),
|
||||
blocked: msg('BLOCKED'),
|
||||
});
|
||||
|
||||
function providerLabel(provider) {
|
||||
return (
|
||||
{ youtube: 'YOUTUBE', facebook: 'FACEBOOK', x: 'X' }[provider] || 'SOURCE'
|
||||
{ youtube: 'YOUTUBE', facebook: 'FACEBOOK', x: 'X' }[provider] ||
|
||||
t('SOURCE')
|
||||
);
|
||||
}
|
||||
|
||||
@@ -737,7 +746,9 @@ export function createBhoteKoshiEmbeddedMedia({
|
||||
globalRef.setTimeout?.(() => {
|
||||
if (!session.active || session.ready) return;
|
||||
session.error = new Error(
|
||||
'Facebook embed unavailable. The post may be private, removed, region-restricted, or blocked.',
|
||||
msg(
|
||||
'Facebook embed unavailable. The post may be private, removed, region-restricted, or blocked.',
|
||||
),
|
||||
);
|
||||
session.onError?.(session.error);
|
||||
}, EMBED_TIMEOUT_MS) ?? null;
|
||||
@@ -857,7 +868,9 @@ export function createBhoteKoshiEmbeddedMedia({
|
||||
startAtSec,
|
||||
endAtSec,
|
||||
});
|
||||
iframe.title = `Preloading ${providerLabel(source.provider)} media`;
|
||||
iframe.title = t('Preloading {provider} media', {
|
||||
provider: providerLabel(source.provider),
|
||||
});
|
||||
iframe.tabIndex = -1;
|
||||
iframe.setAttribute('aria-hidden', 'true');
|
||||
iframe.loading = 'eager';
|
||||
@@ -868,7 +881,7 @@ export function createBhoteKoshiEmbeddedMedia({
|
||||
}
|
||||
|
||||
async function mountX(current, token) {
|
||||
current.status.textContent = 'LOADING X POST…';
|
||||
current.status.textContent = t('LOADING X POST…');
|
||||
try {
|
||||
const api = await xLoader(documentRef, globalRef);
|
||||
if (token !== generation || record !== current) return;
|
||||
@@ -885,13 +898,17 @@ export function createBhoteKoshiEmbeddedMedia({
|
||||
);
|
||||
if (token !== generation || record !== current) return;
|
||||
if (!widget)
|
||||
throw new Error('The X post is unavailable or cannot be embedded.');
|
||||
current.status.textContent = 'X POST · PROVIDER CONTROLS';
|
||||
throw new Error(
|
||||
msg('The X post is unavailable or cannot be embedded.'),
|
||||
);
|
||||
current.status.textContent = t('X POST · PROVIDER CONTROLS');
|
||||
position();
|
||||
} catch (error) {
|
||||
if (token !== generation || record !== current) return;
|
||||
current.card.classList.add('is-unavailable');
|
||||
current.status.textContent = `${String(error?.message || 'X embed unavailable')} OPEN ORIGINAL.`;
|
||||
current.status.textContent = t('{reason} OPEN ORIGINAL.', {
|
||||
reason: t(String(error?.message || msg('X embed unavailable'))),
|
||||
});
|
||||
position();
|
||||
}
|
||||
}
|
||||
@@ -904,19 +921,23 @@ export function createBhoteKoshiEmbeddedMedia({
|
||||
session.shouldPlay = autoplay === true;
|
||||
session.onReady = () => {
|
||||
if (token !== generation || record !== current) return;
|
||||
current.status.textContent = 'FACEBOOK · PROVIDER CONTROLS';
|
||||
current.status.textContent = t('FACEBOOK · PROVIDER CONTROLS');
|
||||
current.card.classList.remove('is-unavailable');
|
||||
position();
|
||||
};
|
||||
session.onError = (error) => {
|
||||
if (token !== generation || record !== current) return;
|
||||
current.card.classList.add('is-unavailable');
|
||||
current.status.textContent = `${String(error?.message || 'Facebook embed unavailable')} OPEN ORIGINAL.`;
|
||||
current.status.textContent = t('{reason} OPEN ORIGINAL.', {
|
||||
reason: t(String(error?.message || msg('Facebook embed unavailable'))),
|
||||
});
|
||||
position();
|
||||
};
|
||||
session.onPlaybackBlocked = () => {
|
||||
if (token !== generation || record !== current) return;
|
||||
current.status.textContent = 'AUTOPLAY BLOCKED · USE PROVIDER CONTROLS';
|
||||
current.status.textContent = t(
|
||||
'AUTOPLAY BLOCKED · USE PROVIDER CONTROLS',
|
||||
);
|
||||
};
|
||||
if (session.ready) session.onReady();
|
||||
else if (session.error) session.onError(session.error);
|
||||
@@ -968,7 +989,10 @@ export function createBhoteKoshiEmbeddedMedia({
|
||||
card.dataset.provider = source.provider;
|
||||
card.setAttribute(
|
||||
'aria-label',
|
||||
`Embedded ${providerLabel(source.provider)} media for ${observation?.title || 'witness source'}`,
|
||||
t('Embedded {provider} media for {source}', {
|
||||
provider: providerLabel(source.provider),
|
||||
source: observation?.title || t('witness source'),
|
||||
}),
|
||||
);
|
||||
const leader = documentRef.createElementNS(
|
||||
'http://www.w3.org/2000/svg',
|
||||
@@ -983,16 +1007,18 @@ export function createBhoteKoshiEmbeddedMedia({
|
||||
const footer = documentRef.createElement('footer');
|
||||
const title = documentRef.createElement('strong');
|
||||
title.textContent = String(
|
||||
observation?.shortTitle || observation?.title || 'Witness source',
|
||||
observation?.shortTitle || observation?.title || t('Witness source'),
|
||||
).toUpperCase();
|
||||
const status = documentRef.createElement('small');
|
||||
status.setAttribute('role', 'status');
|
||||
status.textContent = `${providerLabel(source.provider)} · EMBEDDED SOURCE`;
|
||||
status.textContent = t('{provider} · EMBEDDED SOURCE', {
|
||||
provider: providerLabel(source.provider),
|
||||
});
|
||||
const link = documentRef.createElement('a');
|
||||
link.href = source.url;
|
||||
link.target = '_blank';
|
||||
link.rel = 'noopener noreferrer';
|
||||
link.textContent = 'OPEN ORIGINAL ↗';
|
||||
link.textContent = t('OPEN ORIGINAL ↗');
|
||||
footer.append(title, status, link);
|
||||
const body = documentRef.createElement('div');
|
||||
body.className = 'bhote-embedded-callout-body';
|
||||
@@ -1032,7 +1058,10 @@ export function createBhoteKoshiEmbeddedMedia({
|
||||
url.searchParams.set('origin', globalRef.location.origin);
|
||||
iframe.src = url.href;
|
||||
}
|
||||
iframe.title = `${providerLabel(source.provider)} media: ${observation?.title || 'witness source'}`;
|
||||
iframe.title = t('{provider} media: {source}', {
|
||||
provider: providerLabel(source.provider),
|
||||
source: observation?.title || t('witness source'),
|
||||
});
|
||||
iframe.allow =
|
||||
'autoplay; encrypted-media; picture-in-picture; fullscreen';
|
||||
iframe.allowFullscreen = true;
|
||||
@@ -1049,7 +1078,9 @@ export function createBhoteKoshiEmbeddedMedia({
|
||||
record.youtubeSession.getState().phase,
|
||||
)
|
||||
) {
|
||||
status.textContent = `${providerLabel(source.provider)} · PROVIDER CONTROLS`;
|
||||
status.textContent = t('{provider} · PROVIDER CONTROLS', {
|
||||
provider: providerLabel(source.provider),
|
||||
});
|
||||
}
|
||||
position();
|
||||
},
|
||||
@@ -1067,7 +1098,11 @@ export function createBhoteKoshiEmbeddedMedia({
|
||||
isCurrent: () => token === generation && record === current,
|
||||
onStatus: (phase) => {
|
||||
if (phase !== 'completed')
|
||||
status.textContent = `YOUTUBE ${phase.toUpperCase()} · OPEN ORIGINAL`;
|
||||
status.textContent = t('YOUTUBE {state} · OPEN ORIGINAL', {
|
||||
state: YOUTUBE_PHASE_LABELS[phase]
|
||||
? t(YOUTUBE_PHASE_LABELS[phase])
|
||||
: phase.toUpperCase(),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
+121
-61
@@ -24,6 +24,7 @@ import {
|
||||
} from './bhoteKoshiEmbeddedMedia.js';
|
||||
import { acquireImageryComparison } from '../maps/imageryComparison.js';
|
||||
import { createImagerySplit } from '../ui/imagerySplit.js';
|
||||
import { t, tn, msg } from '../i18n/index.js';
|
||||
|
||||
export const BHOTE_KOSHI_LAYER_ID = 'bhote-koshi-2026';
|
||||
export const BHOTE_KOSHI_OVERLAY_SOURCE_ID = 'bhote-koshi-witnesses';
|
||||
@@ -384,13 +385,38 @@ export function adjacentEvidenceIndex(progress, timeline, direction) {
|
||||
return Math.max(0, Math.min(beats.length - 1, current + delta));
|
||||
}
|
||||
|
||||
// Evidence phases from the event pack, shown as the beat meta prefix.
|
||||
const EVIDENCE_PHASE_LABELS = Object.freeze({
|
||||
initiation: msg('INITIATION'),
|
||||
damming: msg('DAMMING'),
|
||||
'secondary failure': msg('SECONDARY FAILURE'),
|
||||
'border arrival': msg('BORDER ARRIVAL'),
|
||||
propagation: msg('PROPAGATION'),
|
||||
'downstream passage': msg('DOWNSTREAM PASSAGE'),
|
||||
consequence: msg('CONSEQUENCE'),
|
||||
'downstream consequence': msg('DOWNSTREAM CONSEQUENCE'),
|
||||
});
|
||||
|
||||
/** Display label for an evidence phase; unknown phases show as authored. */
|
||||
function evidencePhaseLabel(phase) {
|
||||
const label = EVIDENCE_PHASE_LABELS[String(phase || '').toLowerCase()];
|
||||
if (label) return t(label);
|
||||
return phase ? String(phase).toUpperCase() : t('EVIDENCE');
|
||||
}
|
||||
|
||||
/** Compact, source-bounded disclosure for multi-source geolocation beats. */
|
||||
export function evidenceCorroborationLabel(observation) {
|
||||
const count = Math.max(
|
||||
0,
|
||||
Math.floor(Number(observation?.corroboration?.sourceCount) || 0),
|
||||
);
|
||||
return count > 1 ? `${count} GEOLOCATED SOURCE-MAP PLACEMENTS` : '';
|
||||
return count > 1
|
||||
? tn(
|
||||
count,
|
||||
'{count} GEOLOCATED SOURCE-MAP PLACEMENT',
|
||||
'{count} GEOLOCATED SOURCE-MAP PLACEMENTS',
|
||||
)
|
||||
: '';
|
||||
}
|
||||
|
||||
/** Interpolate headings across north using the shortest angular path. */
|
||||
@@ -641,7 +667,7 @@ function drawEvidenceFallback(ctx, observation, width, height) {
|
||||
}
|
||||
ctx.fillStyle = WORLD_OVERLAY_STYLE.accent;
|
||||
ctx.font = scaledOverlayFont(WORLD_OVERLAY_STYLE.fontLabel, 1.4);
|
||||
ctx.fillText('NO PREVIEW', 20, 30);
|
||||
ctx.fillText(t('NO PREVIEW'), 20, 30);
|
||||
}
|
||||
|
||||
function ellipsizeEvidenceText(value, limit) {
|
||||
@@ -713,10 +739,12 @@ export function updateEvidenceFrame(
|
||||
ctx.fillStyle = WORLD_OVERLAY_STYLE.detail;
|
||||
ctx.font = scaledOverlayFont(WORLD_OVERLAY_STYLE.fontDetail);
|
||||
const publisher = String(
|
||||
observation.media?.publisher || 'SOURCE LINK',
|
||||
observation.media?.publisher || t('SOURCE LINK'),
|
||||
).toUpperCase();
|
||||
ctx.fillText(
|
||||
`${ellipsizeEvidenceText(publisher, 24)} / OPEN`,
|
||||
t('{publisher} / OPEN', {
|
||||
publisher: ellipsizeEvidenceText(publisher, 24),
|
||||
}),
|
||||
16,
|
||||
infoY + 53,
|
||||
);
|
||||
@@ -813,28 +841,40 @@ function collectPanelReferences(panel) {
|
||||
});
|
||||
}
|
||||
|
||||
/** Escape translated text for the panel markup template. */
|
||||
function escapeMarkup(text) {
|
||||
return String(text)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function createPanel(event, handlers) {
|
||||
const panel = document.createElement('aside');
|
||||
panel.id = 'bhote-koshi-event-panel';
|
||||
panel.setAttribute('aria-label', 'Bhote Koshi flood reconstruction controls');
|
||||
panel.setAttribute(
|
||||
'aria-label',
|
||||
t('Bhote Koshi flood reconstruction controls'),
|
||||
);
|
||||
panel.innerHTML = `
|
||||
<div class="bhote-event-header">
|
||||
<div>
|
||||
<div class="bhote-event-kicker">EVENT RECONSTRUCTION · 26 AUG 2026</div>
|
||||
<div class="bhote-event-title">BHOTE KOSHI OUTBURST FLOOD</div>
|
||||
<div class="bhote-event-kicker">${escapeMarkup(t('EVENT RECONSTRUCTION · 26 AUG 2026'))}</div>
|
||||
<div class="bhote-event-title">${escapeMarkup(t('BHOTE KOSHI OUTBURST FLOOD'))}</div>
|
||||
</div>
|
||||
<button class="bhote-event-icon-btn" type="button" data-action="close" title="Close event layer" aria-label="Close event layer">×</button>
|
||||
<button class="bhote-event-icon-btn" type="button" data-action="close" title="${escapeMarkup(t('Close event layer'))}" aria-label="${escapeMarkup(t('Close event layer'))}">×</button>
|
||||
</div>
|
||||
<div class="bhote-event-status-row">
|
||||
<span class="bhote-event-status observed">OBSERVED IMAGERY</span>
|
||||
<span class="bhote-event-status reconstructed">SCHEMATIC CORRIDOR</span>
|
||||
<span class="bhote-event-status observed">${escapeMarkup(t('OBSERVED IMAGERY'))}</span>
|
||||
<span class="bhote-event-status reconstructed">${escapeMarkup(t('SCHEMATIC CORRIDOR'))}</span>
|
||||
</div>
|
||||
<section class="bhote-event-section" data-role="imagery-comparison">
|
||||
<div class="bhote-event-section-head">
|
||||
<span>2021 HISTORICAL REFERENCE / 2026 POST-EVENT</span>
|
||||
<span>${escapeMarkup(t('2021 HISTORICAL REFERENCE / 2026 POST-EVENT'))}</span>
|
||||
<span data-role="split-readout">50 / 50</span>
|
||||
</div>
|
||||
<input class="bhote-event-range" data-role="split" type="range" min="0" max="100" value="50" aria-label="Historical reference and post-event image split" />
|
||||
<input class="bhote-event-range" data-role="split" type="range" min="0" max="100" value="50" aria-label="${escapeMarkup(t('Historical reference and post-event image split'))}" />
|
||||
<div class="bhote-event-dates">
|
||||
<span data-role="before-date"></span>
|
||||
<span data-role="after-date"></span>
|
||||
@@ -842,37 +882,37 @@ function createPanel(event, handlers) {
|
||||
</section>
|
||||
<section class="bhote-event-section">
|
||||
<div class="bhote-event-section-head">
|
||||
<span data-role="timeline-label">RECONSTRUCTION CLOCK</span>
|
||||
<span data-role="timeline-label">${escapeMarkup(t('RECONSTRUCTION CLOCK'))}</span>
|
||||
<span data-role="time"></span>
|
||||
</div>
|
||||
<input class="bhote-event-range flood" data-role="progress" type="range" min="0" max="1000" value="0" aria-label="Schematic downstream progression" />
|
||||
<div class="bhote-event-story-nav" aria-label="Story beat navigation">
|
||||
<button type="button" data-action="previous-beat" title="Previous story beat" aria-label="Previous story beat">‹</button>
|
||||
<input class="bhote-event-range flood" data-role="progress" type="range" min="0" max="1000" value="0" aria-label="${escapeMarkup(t('Schematic downstream progression'))}" />
|
||||
<div class="bhote-event-story-nav" aria-label="${escapeMarkup(t('Story beat navigation'))}">
|
||||
<button type="button" data-action="previous-beat" title="${escapeMarkup(t('Previous story beat'))}" aria-label="${escapeMarkup(t('Previous story beat'))}">‹</button>
|
||||
<div class="bhote-event-story-current">
|
||||
<span data-role="beat-index">01 / 06</span>
|
||||
<strong data-role="beat-title" aria-live="polite">CAUSE</strong>
|
||||
<small data-role="beat-meta">CAPTURE TIME UNVERIFIED</small>
|
||||
<strong data-role="beat-title" aria-live="polite">${escapeMarkup(t('CAUSE'))}</strong>
|
||||
<small data-role="beat-meta">${escapeMarkup(t('CAPTURE TIME UNVERIFIED'))}</small>
|
||||
</div>
|
||||
<button type="button" data-action="next-beat" title="Next story beat" aria-label="Next story beat">›</button>
|
||||
<button type="button" data-action="next-beat" title="${escapeMarkup(t('Next story beat'))}" aria-label="${escapeMarkup(t('Next story beat'))}">›</button>
|
||||
</div>
|
||||
<div class="bhote-event-actions">
|
||||
<button type="button" data-action="play">▶ PLAY</button>
|
||||
<button type="button" data-action="play-scene" hidden title="Play the next shot and continue through this scene">▶ PLAY SCENE</button>
|
||||
<button type="button" data-action="cinematic" aria-pressed="false">◉ CINEMATIC</button>
|
||||
<button type="button" data-action="story-replay">↺ FULL STORY</button>
|
||||
<button type="button" data-action="open-source">↗ OPEN ORIGINAL</button>
|
||||
<button type="button" data-action="corridor">⌖ LOWER GORGE</button>
|
||||
<button type="button" data-action="play">${escapeMarkup(t('▶ PLAY'))}</button>
|
||||
<button type="button" data-action="play-scene" hidden title="${escapeMarkup(t('Play the next shot and continue through this scene'))}">${escapeMarkup(t('▶ PLAY SCENE'))}</button>
|
||||
<button type="button" data-action="cinematic" aria-pressed="false">${escapeMarkup(t('◉ CINEMATIC'))}</button>
|
||||
<button type="button" data-action="story-replay">${escapeMarkup(t('↺ FULL STORY'))}</button>
|
||||
<button type="button" data-action="open-source">${escapeMarkup(t('↗ OPEN ORIGINAL'))}</button>
|
||||
<button type="button" data-action="corridor">${escapeMarkup(t('⌖ LOWER GORGE'))}</button>
|
||||
</div>
|
||||
</section>
|
||||
<details class="bhote-event-evidence">
|
||||
<summary>FIELD REPORTS · ${event.fieldReports.length}</summary>
|
||||
<summary>${escapeMarkup(t('FIELD REPORTS · {count}', { count: event.fieldReports.length }))}</summary>
|
||||
<div class="bhote-event-report-list"></div>
|
||||
</details>
|
||||
<div class="bhote-event-credit">
|
||||
<span>GEOLOCATIONS · GEO GEORGE SHADRACH</span>
|
||||
<button type="button" data-action="geolocation-map">↗ OPEN PUBLIC MAP</button>
|
||||
<span>${escapeMarkup(t('GEOLOCATIONS · {credit}', { credit: 'GEO GEORGE SHADRACH' }))}</span>
|
||||
<button type="button" data-action="geolocation-map">${escapeMarkup(t('↗ OPEN PUBLIC MAP'))}</button>
|
||||
</div>
|
||||
<p class="bhote-event-caveat"><span data-role="caveat"></span><span data-role="imagery-cloud-note"> Clouds are preserved from the source imagery.</span></p>
|
||||
<p class="bhote-event-caveat"><span data-role="caveat"></span><span data-role="imagery-cloud-note"> ${escapeMarkup(t('Clouds are preserved from the source imagery.'))}</span></p>
|
||||
`;
|
||||
|
||||
const refs = collectPanelReferences(panel);
|
||||
@@ -1205,7 +1245,7 @@ export function createBhoteKoshiEventLayer({
|
||||
_embeddedMedia?.show?.({
|
||||
observation: {
|
||||
title: witness.title,
|
||||
shortTitle: 'Rasuwagadhi witness',
|
||||
shortTitle: t('Rasuwagadhi witness'),
|
||||
media: { sourceUrl: witness.sourceUrl },
|
||||
},
|
||||
anchor,
|
||||
@@ -2320,7 +2360,10 @@ export function createBhoteKoshiEventLayer({
|
||||
collisionGroup: 'ambient-card',
|
||||
zIndex: 60,
|
||||
interactive: true,
|
||||
accessibilityLabel: `Open original source for ${observation.title}; capture time unverified`,
|
||||
accessibilityLabel: t(
|
||||
'Open original source for {source}; capture time unverified',
|
||||
{ source: observation.title },
|
||||
),
|
||||
activate: () => openExternal(observation.media.sourceUrl),
|
||||
minDistance: 0,
|
||||
maxDistance: 220000,
|
||||
@@ -2561,9 +2604,12 @@ export function createBhoteKoshiEventLayer({
|
||||
cssProperty: '--bhote-koshi-split',
|
||||
beforeTitle: event.imagery.before.label,
|
||||
afterTitle: event.imagery.after.label,
|
||||
ariaLabel: 'Historical reference and post-event image divider',
|
||||
ariaLabel: t('Historical reference and post-event image divider'),
|
||||
formatValueText: (beforePercent, afterPercent) =>
|
||||
`A historical reference ${beforePercent} percent, B post-event ${afterPercent} percent`,
|
||||
t(
|
||||
'A historical reference {before} percent, B post-event {after} percent',
|
||||
{ before: beforePercent, after: afterPercent },
|
||||
),
|
||||
onChange: setSplit,
|
||||
});
|
||||
}
|
||||
@@ -2595,7 +2641,7 @@ export function createBhoteKoshiEventLayer({
|
||||
setElementProperty(
|
||||
button,
|
||||
'textContent',
|
||||
_cinematicActive ? '■ RELEASE CAMERA' : '◉ CINEMATIC',
|
||||
_cinematicActive ? t('■ RELEASE CAMERA') : t('◉ CINEMATIC'),
|
||||
);
|
||||
setElementAttribute(button, 'aria-pressed', String(_cinematicActive));
|
||||
}
|
||||
@@ -2961,13 +3007,13 @@ export function createBhoteKoshiEventLayer({
|
||||
_panelRefs.progressInput,
|
||||
'aria-label',
|
||||
sceneDirected
|
||||
? 'Seek Nepal scene clock'
|
||||
: 'Schematic downstream progression',
|
||||
? t('Seek Nepal scene clock')
|
||||
: t('Schematic downstream progression'),
|
||||
);
|
||||
setElementProperty(
|
||||
_panelRefs.timelineLabel,
|
||||
'textContent',
|
||||
sceneDirected ? 'NEPAL SCENE CLOCK' : 'RECONSTRUCTION CLOCK',
|
||||
sceneDirected ? t('NEPAL SCENE CLOCK') : t('RECONSTRUCTION CLOCK'),
|
||||
);
|
||||
const sceneDuration = Number(_sceneContext?.sceneDurationSec) || 0;
|
||||
setElementProperty(
|
||||
@@ -2980,22 +3026,22 @@ export function createBhoteKoshiEventLayer({
|
||||
);
|
||||
const playLabel = sceneDirected
|
||||
? _sceneActionPending && _sceneActionPending.type !== 'scene'
|
||||
? '… PLAYING SHOT'
|
||||
: '▶ PLAY SHOT'
|
||||
? t('… PLAYING SHOT')
|
||||
: t('▶ PLAY SHOT')
|
||||
: _playing
|
||||
? 'Ⅱ PAUSE'
|
||||
? t('Ⅱ PAUSE')
|
||||
: _progress >= 1
|
||||
? _cinematicReplayArmed
|
||||
? '↺ REPLAY CINEMATIC'
|
||||
: '↺ REPLAY'
|
||||
: '▶ PLAY';
|
||||
? t('↺ REPLAY CINEMATIC')
|
||||
: t('↺ REPLAY')
|
||||
: t('▶ PLAY');
|
||||
setElementProperty(_panelRefs.playButton, 'textContent', playLabel);
|
||||
setElementProperty(
|
||||
_panelRefs.playSceneButton,
|
||||
'textContent',
|
||||
_sceneActionPending?.type === 'scene'
|
||||
? '… PLAYING SCENE'
|
||||
: '▶ PLAY SCENE',
|
||||
? t('… PLAYING SCENE')
|
||||
: t('▶ PLAY SCENE'),
|
||||
);
|
||||
setElementProperty(
|
||||
_panelRefs.playSceneButton,
|
||||
@@ -3011,19 +3057,21 @@ export function createBhoteKoshiEventLayer({
|
||||
setElementAttribute(
|
||||
_panelRefs.playSceneButton,
|
||||
'aria-label',
|
||||
`Continue ${_sceneContext?.sceneTitle || 'current scene'} from next shot`,
|
||||
t('Continue {scene} from next shot', {
|
||||
scene: _sceneContext?.sceneTitle || t('current scene'),
|
||||
}),
|
||||
);
|
||||
setElementProperty(
|
||||
_panelRefs.storyReplayButton,
|
||||
'textContent',
|
||||
'↺ FULL STORY',
|
||||
t('↺ FULL STORY'),
|
||||
);
|
||||
if (!sceneDirected) {
|
||||
setElementProperty(_panelRefs.storyReplayButton, 'disabled', false);
|
||||
setElementAttribute(
|
||||
_panelRefs.storyReplayButton,
|
||||
'aria-label',
|
||||
'Replay the full reconstruction',
|
||||
t('Replay the full reconstruction'),
|
||||
);
|
||||
}
|
||||
const beatIndex = currentEvidenceIndex();
|
||||
@@ -3047,18 +3095,22 @@ export function createBhoteKoshiEventLayer({
|
||||
_panelRefs.beatTitle,
|
||||
'textContent',
|
||||
sceneDirected
|
||||
? _sceneContext?.shotTitle || 'SCENE SHOT'
|
||||
: observation?.shortTitle || observation?.title || 'EVIDENCE',
|
||||
? _sceneContext?.shotTitle || t('SCENE SHOT')
|
||||
: observation?.shortTitle || observation?.title || t('EVIDENCE'),
|
||||
);
|
||||
const sourceCount = Math.max(
|
||||
0,
|
||||
Math.floor(Number(observation?.corroboration?.sourceCount) || 0),
|
||||
);
|
||||
const beatMeta = [
|
||||
String(observation?.phase || 'EVIDENCE').toUpperCase(),
|
||||
'TIME UNVERIFIED',
|
||||
sourceCount > 1 ? `${sourceCount} SOURCES` : '',
|
||||
observation?.imageryCoverage === 'outside' ? 'OUTSIDE IMAGERY SWIPE' : '',
|
||||
evidencePhaseLabel(observation?.phase),
|
||||
t('TIME UNVERIFIED'),
|
||||
sourceCount > 1
|
||||
? tn(sourceCount, '{count} SOURCE', '{count} SOURCES')
|
||||
: '',
|
||||
observation?.imageryCoverage === 'outside'
|
||||
? t('OUTSIDE IMAGERY SWIPE')
|
||||
: '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' / ');
|
||||
@@ -3066,7 +3118,11 @@ export function createBhoteKoshiEventLayer({
|
||||
_panelRefs.beatMeta,
|
||||
'textContent',
|
||||
sceneDirected
|
||||
? `${String(_sceneContext?.sceneTitle || 'NEPAL FLOOD INCIDENT').toUpperCase()} / AUTHORED SHOT`
|
||||
? t('{scene} / AUTHORED SHOT', {
|
||||
scene: String(
|
||||
_sceneContext?.sceneTitle || t('NEPAL FLOOD INCIDENT'),
|
||||
).toUpperCase(),
|
||||
})
|
||||
: beatMeta,
|
||||
);
|
||||
setElementProperty(
|
||||
@@ -3093,18 +3149,20 @@ export function createBhoteKoshiEventLayer({
|
||||
_panelRefs.playButton,
|
||||
'aria-label',
|
||||
sceneDirected
|
||||
? `Play ${_sceneContext?.shotTitle || 'current scene shot'}`
|
||||
: 'Play reconstruction',
|
||||
? t('Play {shot}', {
|
||||
shot: _sceneContext?.shotTitle || t('current scene shot'),
|
||||
})
|
||||
: t('Play reconstruction'),
|
||||
);
|
||||
setElementAttribute(
|
||||
_panelRefs.previousButton,
|
||||
'aria-label',
|
||||
sceneDirected ? 'Load previous scene shot' : 'Previous story beat',
|
||||
sceneDirected ? t('Load previous scene shot') : t('Previous story beat'),
|
||||
);
|
||||
setElementAttribute(
|
||||
_panelRefs.nextButton,
|
||||
'aria-label',
|
||||
sceneDirected ? 'Load next scene shot' : 'Next story beat',
|
||||
sceneDirected ? t('Load next scene shot') : t('Next story beat'),
|
||||
);
|
||||
setElementProperty(
|
||||
_panelRefs.openSourceButton,
|
||||
@@ -3154,7 +3212,7 @@ export function createBhoteKoshiEventLayer({
|
||||
if (_panelRefs?.caveat) {
|
||||
_panelRefs.caveat.textContent = imageryAvailable
|
||||
? _event?.reconstruction?.caveat || ''
|
||||
: 'SCHEMATIC CORRIDOR, NOT MODELED ARRIVAL TIME.';
|
||||
: t('SCHEMATIC CORRIDOR, NOT MODELED ARRIVAL TIME.');
|
||||
}
|
||||
if (_panelRefs?.imageryCloudNote)
|
||||
_panelRefs.imageryCloudNote.hidden = !imageryAvailable;
|
||||
@@ -3717,7 +3775,9 @@ export function createBhoteKoshiEventLayer({
|
||||
count: _enabled ? 1 : 0,
|
||||
status: _enabled ? 'nominal' : 'idle',
|
||||
source: 'Vantor + GeoPera',
|
||||
coverage: _enabled ? 'Rasuwa · 26 Aug 2026' : 'Event reconstruction',
|
||||
coverage: _enabled
|
||||
? t('Rasuwa · 26 Aug 2026')
|
||||
: t('Event reconstruction'),
|
||||
};
|
||||
},
|
||||
getPlaybackState() {
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from './dataCredits.js';
|
||||
import { BHOTE_KOSHI_FLOOD_PATH } from './bhoteKoshiFloodPath.js';
|
||||
import { INCIDENT_OVERVIEW_PLACES } from './bhoteKoshiIncidentPlaces.js';
|
||||
import { t, msg } from '../i18n/index.js';
|
||||
|
||||
export const BHOTE_KOSHI_LOCATOR_LAYER_ID = 'bhote-koshi-locator';
|
||||
export const BHOTE_KOSHI_LOCATOR_OVERLAY_SOURCE_ID =
|
||||
@@ -55,19 +56,19 @@ const INCIDENT = Object.freeze({
|
||||
lat: 28.3632333,
|
||||
lon: 85.4423639,
|
||||
heightM: 5200,
|
||||
title: 'BHOTE KOSHI FLOOD INCIDENT',
|
||||
subtitle: 'NEPAL · 26 AUG 2026',
|
||||
title: msg('BHOTE KOSHI FLOOD INCIDENT'),
|
||||
subtitle: msg('NEPAL · 26 AUG 2026'),
|
||||
});
|
||||
const TRIGGER_RECORD = Object.freeze({
|
||||
lat: 28.271,
|
||||
lon: 85.515,
|
||||
heightM: 4200,
|
||||
title: 'USGS INITIAL REPORT',
|
||||
title: msg('USGS INITIAL REPORT'),
|
||||
details: Object.freeze([
|
||||
'26 AUG 2026 · 08:37 NPT',
|
||||
'28.2710° N · 85.5150° E',
|
||||
'INITIAL M4.4 · RECLASSIFIED M5.2 LANDSLIDE',
|
||||
'USGS EVENT us7000tbwb · OPEN SOURCE',
|
||||
msg('26 AUG 2026 · 08:37 NPT'),
|
||||
msg('28.2710° N · 85.5150° E'),
|
||||
msg('INITIAL M4.4 · RECLASSIFIED M5.2 LANDSLIDE'),
|
||||
msg('USGS EVENT us7000tbwb · OPEN SOURCE'),
|
||||
]),
|
||||
});
|
||||
const TRIGGER_APPROACH_CAMERA = Object.freeze({
|
||||
@@ -90,11 +91,13 @@ const NEPAL = Object.freeze({
|
||||
lat: 28.3949,
|
||||
lon: 84.124,
|
||||
heightM: 6800,
|
||||
// i18n-ignore: country name, shown as is
|
||||
title: 'NEPAL',
|
||||
});
|
||||
const NEARBY_CITIES = Object.freeze([
|
||||
Object.freeze({
|
||||
id: 'kathmandu',
|
||||
// i18n-ignore: city name
|
||||
title: 'KATHMANDU',
|
||||
lat: 27.7017,
|
||||
lon: 85.3206,
|
||||
@@ -103,6 +106,7 @@ const NEARBY_CITIES = Object.freeze([
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'bhaktapur',
|
||||
// i18n-ignore: city name
|
||||
title: 'BHAKTAPUR',
|
||||
lat: 27.673,
|
||||
lon: 85.43,
|
||||
@@ -111,6 +115,7 @@ const NEARBY_CITIES = Object.freeze([
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'dhulikhel',
|
||||
// i18n-ignore: city name
|
||||
title: 'DHULIKHEL',
|
||||
lat: 27.6221,
|
||||
lon: 85.5428,
|
||||
@@ -394,8 +399,8 @@ export function createBhoteKoshiLocatorLayer({
|
||||
position,
|
||||
variant: 'card',
|
||||
paintLane: 'thumbnail',
|
||||
title: subject.title,
|
||||
details: [subject.subtitle],
|
||||
title: t(subject.title),
|
||||
details: [t(subject.subtitle)],
|
||||
accent: INCIDENT_ACCENT,
|
||||
sourceAlpha: () => presentation().alpha,
|
||||
presentationScale: () => presentation().scale,
|
||||
@@ -536,8 +541,8 @@ export function createBhoteKoshiLocatorLayer({
|
||||
position,
|
||||
variant: 'card',
|
||||
paintLane: 'thumbnail',
|
||||
title: TRIGGER_RECORD.title,
|
||||
details: [...TRIGGER_RECORD.details],
|
||||
title: t(TRIGGER_RECORD.title),
|
||||
details: TRIGGER_RECORD.details.map((line) => t(line)),
|
||||
accent: INCIDENT_ACCENT,
|
||||
sourceAlpha: () => presentation().alpha,
|
||||
presentationScale: () => presentation().scale,
|
||||
@@ -1121,57 +1126,67 @@ export function createBhoteKoshiLocatorLayer({
|
||||
chips: [
|
||||
{
|
||||
id: 'nepal-context',
|
||||
// i18n-ignore: country name
|
||||
label: 'NEPAL',
|
||||
active: _presentation === NEPAL_CONTEXT_PRESENTATION,
|
||||
title:
|
||||
title: t(
|
||||
'Show the Nepal context callout without a border or country fill',
|
||||
),
|
||||
params: { presentation: NEPAL_CONTEXT_PRESENTATION },
|
||||
},
|
||||
{
|
||||
id: 'bhote-koshi-regional',
|
||||
// i18n-ignore: river name (Bhote Koshi)
|
||||
label: 'BHOTE',
|
||||
active: _presentation === BHOTE_KOSHI_REGIONAL_PRESENTATION,
|
||||
title:
|
||||
title: t(
|
||||
'Introduce Nepal, draw its border, then reveal the Bhote Koshi incident',
|
||||
),
|
||||
params: { presentation: BHOTE_KOSHI_REGIONAL_PRESENTATION },
|
||||
},
|
||||
{
|
||||
id: 'bhote-koshi-city-context',
|
||||
label: 'CITIES',
|
||||
label: t('CITIES'),
|
||||
active: _presentation === BHOTE_KOSHI_CITY_CONTEXT_PRESENTATION,
|
||||
title:
|
||||
title: t(
|
||||
'Keep the regional incident context and introduce nearby city labels',
|
||||
),
|
||||
params: { presentation: BHOTE_KOSHI_CITY_CONTEXT_PRESENTATION },
|
||||
},
|
||||
{
|
||||
id: 'bhote-koshi-incident-places',
|
||||
label: 'PLACES',
|
||||
label: t('PLACES'),
|
||||
active: _presentation === BHOTE_KOSHI_INCIDENT_PLACES_PRESENTATION,
|
||||
title:
|
||||
title: t(
|
||||
'Introduce the reconstruction scene key places from trigger to downstream',
|
||||
),
|
||||
params: { presentation: BHOTE_KOSHI_INCIDENT_PLACES_PRESENTATION },
|
||||
},
|
||||
{
|
||||
id: 'bhote-koshi-flood-path',
|
||||
label: 'PATH',
|
||||
label: t('PATH'),
|
||||
active: _presentation === BHOTE_KOSHI_FLOOD_PATH_PRESENTATION,
|
||||
title:
|
||||
title: t(
|
||||
'Animate the sourced cyan river-centerline route to Trishuli Bazaar',
|
||||
),
|
||||
params: { presentation: BHOTE_KOSHI_FLOOD_PATH_PRESENTATION },
|
||||
},
|
||||
{
|
||||
id: 'bhote-koshi-path-overview',
|
||||
label: 'OVERVIEW',
|
||||
label: t('OVERVIEW'),
|
||||
active: _presentation === BHOTE_KOSHI_PATH_OVERVIEW_PRESENTATION,
|
||||
title: 'Keep the completed flood path and location labels visible',
|
||||
title: t(
|
||||
'Keep the completed flood path and location labels visible',
|
||||
),
|
||||
params: { presentation: BHOTE_KOSHI_PATH_OVERVIEW_PRESENTATION },
|
||||
},
|
||||
{
|
||||
id: 'bhote-koshi-trigger-record',
|
||||
label: 'RECORD',
|
||||
label: t('RECORD'),
|
||||
active: _presentation === BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION,
|
||||
title:
|
||||
title: t(
|
||||
'Reveal the trigger record while making a slow camera approach',
|
||||
),
|
||||
params: {
|
||||
presentation: BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION,
|
||||
preview: true,
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Marks restore labels for i18n extraction; shareRestoration translates them.
|
||||
// Kept local so this module stays free of the i18n import.
|
||||
const msg = (text) => text;
|
||||
import { normalizeIdSwitches, patchRequestedIdSwitches } from './idSwitches.js';
|
||||
import reservationRows from './layerStateTokenReservations.json' with { type: 'json' };
|
||||
|
||||
@@ -458,19 +461,19 @@ export const SHARE_TRACKING_RESTORE_POLICIES = Object.freeze({
|
||||
optionOwner: 'flights',
|
||||
optionKey: 'selectedFlightsTrackingId',
|
||||
expiryWindowMs: 90_000,
|
||||
label: 'flight',
|
||||
label: msg('flight'),
|
||||
}),
|
||||
military: Object.freeze({
|
||||
optionOwner: 'flights',
|
||||
optionKey: 'selectedMilitaryTrackingId',
|
||||
expiryWindowMs: 45_000,
|
||||
label: 'military flight',
|
||||
label: msg('military flight'),
|
||||
}),
|
||||
satellites: Object.freeze({
|
||||
optionOwner: 'satellites',
|
||||
optionKey: 'selectedSatTrackingId',
|
||||
expiryWindowMs: 300_000,
|
||||
label: 'satellite',
|
||||
label: msg('satellite'),
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
+10
-10
@@ -26,47 +26,47 @@ import { t, msg } from '../i18n/index.js';
|
||||
*/
|
||||
export const SATELLITE_CLASSES = Object.freeze({
|
||||
station: Object.freeze({
|
||||
label: 'STATION',
|
||||
label: 'STATION', // i18n-ignore: English token; shown through CLASS_DISPLAY_KEYS
|
||||
// Warm white — "crewed", and the brightest class in the sky. Deliberately
|
||||
// NOT amber: 40-48deg amber is the app's known-military convention
|
||||
// (flights.js MIL_TINT / militaryFlights.js MIL_ICON_COLOR) and must not
|
||||
// leak into another domain. Slightly warm so it also separates from the
|
||||
// pure white the civilian air fleet draws in.
|
||||
color: '#fff6e5',
|
||||
blurb: 'Crewed stations and their visiting vehicles',
|
||||
blurb: 'Crewed stations and their visiting vehicles', // i18n-ignore: shown through CLASS_DISPLAY_KEYS
|
||||
}),
|
||||
nav: Object.freeze({
|
||||
label: 'NAV',
|
||||
label: 'NAV', // i18n-ignore: English token; shown through CLASS_DISPLAY_KEYS
|
||||
// Cyan — the strongest accent goes to the class users actually name
|
||||
// ("how many GPS satellites are up?"). Takes over the slot the catch-all
|
||||
// VISUAL group used to occupy, so this adds no new hue to the app.
|
||||
color: '#4fd8ff',
|
||||
blurb: 'GNSS navigation — GPS, GLONASS, Galileo',
|
||||
blurb: 'GNSS navigation — GPS, GLONASS, Galileo', // i18n-ignore: shown through CLASS_DISPLAY_KEYS
|
||||
}),
|
||||
geo: Object.freeze({
|
||||
label: 'GEO',
|
||||
label: 'GEO', // i18n-ignore: English token; shown through CLASS_DISPLAY_KEYS
|
||||
// Violet, unchanged. Already the app's "space" semantic (the detection
|
||||
// overlay paints the SAT tier #bda4ff), and the geostationary belt draws
|
||||
// as one clean equatorial ring at globe scale.
|
||||
color: '#c89bff',
|
||||
blurb: 'Geostationary belt — comms and weather, fixed over the equator',
|
||||
blurb: 'Geostationary belt — comms and weather, fixed over the equator', // i18n-ignore: shown through CLASS_DISPLAY_KEYS
|
||||
}),
|
||||
visual: Object.freeze({
|
||||
label: 'VISUAL',
|
||||
label: 'VISUAL', // i18n-ignore: English token; shown through CLASS_DISPLAY_KEYS
|
||||
// Muted blue-gray. This is the catch-all bucket, so it gives up the bright
|
||||
// cyan it used to hold and recedes behind the three classes that mean
|
||||
// something. Still well clear of the dense shell by luminance.
|
||||
color: '#9fb3c4',
|
||||
blurb: 'Brightest naked-eye objects — CelesTrak visual group',
|
||||
blurb: 'Brightest naked-eye objects — CelesTrak visual group', // i18n-ignore: shown through CLASS_DISPLAY_KEYS
|
||||
}),
|
||||
comms: Object.freeze({
|
||||
label: 'COMMS',
|
||||
label: 'COMMS', // i18n-ignore: English token; shown through CLASS_DISPLAY_KEYS
|
||||
// Dim slate — thousands of these appear in DENSE mode. They must read as
|
||||
// texture behind the core catalog, never compete with it. Rec.601 luma
|
||||
// ~0.40 against VISUAL's ~0.69 keeps the two separable even when the
|
||||
// NVG/FLIR shaders collapse the scene to a single channel.
|
||||
color: '#54697f',
|
||||
blurb: 'Broadband constellation shell — shown only in DENSE mode',
|
||||
blurb: 'Broadband constellation shell — shown only in DENSE mode', // i18n-ignore: shown through CLASS_DISPLAY_KEYS
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
+483
-144
@@ -52,13 +52,23 @@ test('dismissal is session-scoped; only the checkbox suppresses durably', () =>
|
||||
rememberFirstRunSessionDismissed(session);
|
||||
assert.equal(session.read(), 'dismissed');
|
||||
// Gone for THIS session...
|
||||
assert.equal(shouldShowFirstRun({ storage, sessionStorageRef: session, location: { search: '' } }), false);
|
||||
assert.equal(
|
||||
shouldShowFirstRun({
|
||||
storage,
|
||||
sessionStorageRef: session,
|
||||
location: { search: '' },
|
||||
}),
|
||||
false,
|
||||
);
|
||||
// ...and back in the next one, because sessionStorage did not survive it.
|
||||
assert.equal(shouldShowFirstRun({
|
||||
storage,
|
||||
sessionStorageRef: memoryStorage(FIRST_RUN_SESSION_KEY),
|
||||
location: { search: '' },
|
||||
}), true);
|
||||
assert.equal(
|
||||
shouldShowFirstRun({
|
||||
storage,
|
||||
sessionStorageRef: memoryStorage(FIRST_RUN_SESSION_KEY),
|
||||
location: { search: '' },
|
||||
}),
|
||||
true,
|
||||
);
|
||||
// Session dismissal must never have written the durable key.
|
||||
assert.equal(storage.read(), null);
|
||||
});
|
||||
@@ -67,43 +77,65 @@ test('the checkbox writes and clears durable suppression, and a storage reset un
|
||||
const storage = memoryStorage(FIRST_RUN_STORAGE_KEY);
|
||||
setFirstRunSuppressed(true, storage);
|
||||
assert.equal(storage.read(), 'suppressed');
|
||||
assert.equal(shouldShowFirstRun({
|
||||
storage,
|
||||
sessionStorageRef: memoryStorage(FIRST_RUN_SESSION_KEY),
|
||||
location: { search: '' },
|
||||
}), false);
|
||||
assert.equal(
|
||||
shouldShowFirstRun({
|
||||
storage,
|
||||
sessionStorageRef: memoryStorage(FIRST_RUN_SESSION_KEY),
|
||||
location: { search: '' },
|
||||
}),
|
||||
false,
|
||||
);
|
||||
|
||||
// Unticking before dismissing takes the suppression back.
|
||||
setFirstRunSuppressed(false, storage);
|
||||
assert.equal(storage.read(), null);
|
||||
assert.equal(shouldShowFirstRun({
|
||||
storage,
|
||||
sessionStorageRef: memoryStorage(FIRST_RUN_SESSION_KEY),
|
||||
location: { search: '' },
|
||||
}), true);
|
||||
assert.equal(
|
||||
shouldShowFirstRun({
|
||||
storage,
|
||||
sessionStorageRef: memoryStorage(FIRST_RUN_SESSION_KEY),
|
||||
location: { search: '' },
|
||||
}),
|
||||
true,
|
||||
);
|
||||
|
||||
// A cleared/hard-reset profile shows it again — an accepted, documented cost.
|
||||
setFirstRunSuppressed(true, storage);
|
||||
assert.equal(shouldShowFirstRun({
|
||||
storage: memoryStorage(FIRST_RUN_STORAGE_KEY),
|
||||
sessionStorageRef: memoryStorage(FIRST_RUN_SESSION_KEY),
|
||||
location: { search: '' },
|
||||
}), true);
|
||||
assert.equal(
|
||||
shouldShowFirstRun({
|
||||
storage: memoryStorage(FIRST_RUN_STORAGE_KEY),
|
||||
sessionStorageRef: memoryStorage(FIRST_RUN_SESSION_KEY),
|
||||
location: { search: '' },
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('welcome params work in both directions and outrank both suppressions', () => {
|
||||
const suppressed = memoryStorage(FIRST_RUN_STORAGE_KEY, 'suppressed');
|
||||
const dismissed = memoryStorage(FIRST_RUN_SESSION_KEY, 'dismissed');
|
||||
// ?welcome=1 replays past the checkbox AND past a session dismissal.
|
||||
assert.equal(shouldShowFirstRun({
|
||||
storage: suppressed, sessionStorageRef: dismissed, location: { search: '?welcome=1' },
|
||||
}), true);
|
||||
assert.equal(
|
||||
shouldShowFirstRun({
|
||||
storage: suppressed,
|
||||
sessionStorageRef: dismissed,
|
||||
location: { search: '?welcome=1' },
|
||||
}),
|
||||
true,
|
||||
);
|
||||
// ?welcome=0 suppresses a session that would otherwise see it.
|
||||
assert.equal(shouldShowFirstRun({ ...fresh(), location: { search: '?welcome=0' } }), false);
|
||||
assert.equal(
|
||||
shouldShowFirstRun({ ...fresh(), location: { search: '?welcome=0' } }),
|
||||
false,
|
||||
);
|
||||
// A share link outranks everything, including the replay hatch.
|
||||
assert.equal(shouldShowFirstRun({
|
||||
hasShareState: true, ...fresh(), location: { search: '?welcome=1' },
|
||||
}), false);
|
||||
assert.equal(
|
||||
shouldShowFirstRun({
|
||||
hasShareState: true,
|
||||
...fresh(),
|
||||
location: { search: '?welcome=1' },
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('a share link never sees the launcher — its author already chose the view', () => {
|
||||
@@ -112,11 +144,20 @@ test('a share link never sees the launcher — its author already chose the view
|
||||
|
||||
test('privacy-restricted storage fails open and every write stays best-effort', () => {
|
||||
const blocked = {
|
||||
getItem: () => { throw new Error('blocked'); },
|
||||
setItem: () => { throw new Error('blocked'); },
|
||||
removeItem: () => { throw new Error('blocked'); },
|
||||
getItem: () => {
|
||||
throw new Error('blocked');
|
||||
},
|
||||
setItem: () => {
|
||||
throw new Error('blocked');
|
||||
},
|
||||
removeItem: () => {
|
||||
throw new Error('blocked');
|
||||
},
|
||||
};
|
||||
assert.equal(shouldShowFirstRun({ storage: blocked, sessionStorageRef: blocked }), true);
|
||||
assert.equal(
|
||||
shouldShowFirstRun({ storage: blocked, sessionStorageRef: blocked }),
|
||||
true,
|
||||
);
|
||||
assert.doesNotThrow(() => setFirstRunSuppressed(true, blocked));
|
||||
assert.doesNotThrow(() => rememberFirstRunSessionDismissed(blocked));
|
||||
});
|
||||
@@ -128,10 +169,15 @@ test('a THROWING storage getter still fails open — Safari private mode', () =>
|
||||
// before the function body starts, so the SecurityError escapes every
|
||||
// try/catch in the module and the launcher silently never appears.
|
||||
const saved = Object.getOwnPropertyDescriptor(globalThis, 'localStorage');
|
||||
const savedSession = Object.getOwnPropertyDescriptor(globalThis, 'sessionStorage');
|
||||
const savedSession = Object.getOwnPropertyDescriptor(
|
||||
globalThis,
|
||||
'sessionStorage',
|
||||
);
|
||||
const hostile = {
|
||||
configurable: true,
|
||||
get() { throw new Error('SecurityError: The operation is insecure.'); },
|
||||
get() {
|
||||
throw new Error('SecurityError: The operation is insecure.');
|
||||
},
|
||||
};
|
||||
Object.defineProperty(globalThis, 'localStorage', hostile);
|
||||
Object.defineProperty(globalThis, 'sessionStorage', hostile);
|
||||
@@ -152,16 +198,22 @@ test('a THROWING storage getter still fails open — Safari private mode', () =>
|
||||
} finally {
|
||||
if (saved) Object.defineProperty(globalThis, 'localStorage', saved);
|
||||
else delete globalThis.localStorage;
|
||||
if (savedSession) Object.defineProperty(globalThis, 'sessionStorage', savedSession);
|
||||
if (savedSession)
|
||||
Object.defineProperty(globalThis, 'sessionStorage', savedSession);
|
||||
else delete globalThis.sessionStorage;
|
||||
}
|
||||
});
|
||||
|
||||
test('no storage is touched from a default parameter position', () => {
|
||||
const module = fs.readFileSync(new URL('./firstRunExperience.js', import.meta.url), 'utf8');
|
||||
const module = fs.readFileSync(
|
||||
new URL('./firstRunExperience.js', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
// Comments stripped first: the block explaining this very defect quotes the
|
||||
// bad pattern, and matching prose instead of code would make the pin a liar.
|
||||
const code = module.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '');
|
||||
const code = module
|
||||
.replace(/\/\*[\s\S]*?\*\//g, '')
|
||||
.replace(/^\s*\/\/.*$/gm, '');
|
||||
assert.doesNotMatch(
|
||||
code,
|
||||
/=\s*globalThis\.(local|session)Storage/,
|
||||
@@ -169,14 +221,24 @@ test('no storage is touched from a default parameter position', () => {
|
||||
);
|
||||
// The only reads of the global areas live inside the guarded resolver.
|
||||
const globalReads = [...code.matchAll(/globalThis\.(local|session)Storage/g)];
|
||||
assert.equal(globalReads.length, 2, 'exactly two global storage reads, both in resolveStore');
|
||||
const resolver = code.slice(code.indexOf('function resolveStore'), code.indexOf('function readStored'));
|
||||
assert.equal(
|
||||
globalReads.length,
|
||||
2,
|
||||
'exactly two global storage reads, both in resolveStore',
|
||||
);
|
||||
const resolver = code.slice(
|
||||
code.indexOf('function resolveStore'),
|
||||
code.indexOf('function readStored'),
|
||||
);
|
||||
assert.equal(
|
||||
[...resolver.matchAll(/globalThis\.(local|session)Storage/g)].length,
|
||||
2,
|
||||
'both global storage reads must be inside resolveStore, inside its try',
|
||||
);
|
||||
assert.match(resolver, /try \{[\s\S]*globalThis\.sessionStorage[\s\S]*\} catch/);
|
||||
assert.match(
|
||||
resolver,
|
||||
/try \{[\s\S]*globalThis\.sessionStorage[\s\S]*\} catch/,
|
||||
);
|
||||
assert.match(code, /function readStored\(kind, injected, key\)/);
|
||||
assert.match(code, /function writeStored\(kind, injected, key, value\)/);
|
||||
});
|
||||
@@ -184,33 +246,52 @@ test('no storage is touched from a default parameter position', () => {
|
||||
// ── ESC arbitration: one surface, one key, never an invisible handler ────────
|
||||
|
||||
test('the JS and CSS lists of screen-claiming surfaces stay in step', () => {
|
||||
const module = fs.readFileSync(new URL('./firstRunExperience.js', import.meta.url), 'utf8');
|
||||
const module = fs.readFileSync(
|
||||
new URL('./firstRunExperience.js', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
const css = readStylesheet(new URL('../style.css', import.meta.url));
|
||||
|
||||
assert.deepEqual(
|
||||
[...EXCLUSIVE_SURFACE_CLASSES].sort(),
|
||||
['cockpit-mode', 'recording-mode', 'scene-playback-mode', 'ui-clean-view'],
|
||||
);
|
||||
assert.deepEqual([...EXCLUSIVE_SURFACE_CLASSES].sort(), [
|
||||
'cockpit-mode',
|
||||
'recording-mode',
|
||||
'scene-playback-mode',
|
||||
'ui-clean-view',
|
||||
]);
|
||||
// A surface that hides the card in CSS but is missing from the JS list would
|
||||
// leave an invisible launcher holding the ESC handler — blocker 2 exactly.
|
||||
const hideRule = css.slice(
|
||||
css.indexOf('body.ui-clean-view #first-run-launcher'),
|
||||
css.indexOf('display: none', css.indexOf('body.ui-clean-view #first-run-launcher')),
|
||||
css.indexOf(
|
||||
'display: none',
|
||||
css.indexOf('body.ui-clean-view #first-run-launcher'),
|
||||
),
|
||||
);
|
||||
const inCss = [...hideRule.matchAll(/body\.([a-z-]+) #first-run-launcher/g)].map((m) => m[1]);
|
||||
const inCss = [
|
||||
...hideRule.matchAll(/body\.([a-z-]+) #first-run-launcher/g),
|
||||
].map((m) => m[1]);
|
||||
assert.deepEqual(
|
||||
inCss.sort(),
|
||||
[...EXCLUSIVE_SURFACE_CLASSES].sort(),
|
||||
'every screen-claiming surface must appear in BOTH lists',
|
||||
);
|
||||
assert.match(module, /export const EXCLUSIVE_SURFACE_CLASSES = Object\.freeze\(\[/);
|
||||
assert.match(
|
||||
module,
|
||||
/export const EXCLUSIVE_SURFACE_CLASSES = Object\.freeze\(\[/,
|
||||
);
|
||||
});
|
||||
|
||||
test('exclusiveSurfaceActive reads the live body classes', () => {
|
||||
const make = (classes) => ({ body: { classList: { contains: (name) => classes.includes(name) } } });
|
||||
const make = (classes) => ({
|
||||
body: { classList: { contains: (name) => classes.includes(name) } },
|
||||
});
|
||||
assert.equal(exclusiveSurfaceActive(make([])), false);
|
||||
for (const name of EXCLUSIVE_SURFACE_CLASSES) {
|
||||
assert.equal(exclusiveSurfaceActive(make([name])), true, `${name} must count as exclusive`);
|
||||
assert.equal(
|
||||
exclusiveSurfaceActive(make([name])),
|
||||
true,
|
||||
`${name} must count as exclusive`,
|
||||
);
|
||||
}
|
||||
assert.equal(exclusiveSurfaceActive(make(['some-other-class'])), false);
|
||||
assert.equal(exclusiveSurfaceActive(undefined), false);
|
||||
@@ -218,14 +299,25 @@ test('exclusiveSurfaceActive reads the live body classes', () => {
|
||||
});
|
||||
|
||||
test('the key handler refuses to act for a card that is not really on screen', () => {
|
||||
const module = fs.readFileSync(new URL('./firstRunExperience.js', import.meta.url), 'utf8');
|
||||
const module = fs.readFileSync(
|
||||
new URL('./firstRunExperience.js', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
assert.match(module, /isActive: \(\) => !closing && isTopmost\(\)/);
|
||||
// Real visibility, not just the class: the class survives while CSS hides the
|
||||
// card, which is precisely how a Scene left an invisible ESC handler armed.
|
||||
assert.match(module, /const isTopmost = \(\) =>\s*root\.isConnected/);
|
||||
assert.match(module, /&&\s*root\.getClientRects\(\)\.length > 0 &&\s*!coveredByOverlay\(\);/);
|
||||
const keyboard = fs.readFileSync(new URL('./ui/surfaceKeyboard.js', import.meta.url), 'utf8');
|
||||
const handler = keyboard.slice(keyboard.indexOf('const onKeyDown = (event) => {'));
|
||||
assert.match(
|
||||
module,
|
||||
/&&\s*root\.getClientRects\(\)\.length > 0 &&\s*!coveredByOverlay\(\);/,
|
||||
);
|
||||
const keyboard = fs.readFileSync(
|
||||
new URL('./ui/surfaceKeyboard.js', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
const handler = keyboard.slice(
|
||||
keyboard.indexOf('const onKeyDown = (event) => {'),
|
||||
);
|
||||
assert.match(
|
||||
handler.slice(0, handler.indexOf("if (event.key === 'Escape')")),
|
||||
/!isActive\(\)/,
|
||||
@@ -234,7 +326,10 @@ test('the key handler refuses to act for a card that is not really on screen', (
|
||||
});
|
||||
|
||||
test('an overlay with NO class to watch still disarms the launcher', () => {
|
||||
const module = fs.readFileSync(new URL('./firstRunExperience.js', import.meta.url), 'utf8');
|
||||
const module = fs.readFileSync(
|
||||
new URL('./firstRunExperience.js', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
const css = readStylesheet(new URL('../style.css', import.meta.url));
|
||||
|
||||
// The repro, pinned as the stacking it actually is: the attribution lightbox
|
||||
@@ -242,7 +337,10 @@ test('an overlay with NO class to watch still disarms the launcher', () => {
|
||||
// keeps its box, so getClientRects() alone called it visible, ESC dismissed a
|
||||
// buried launcher and wrote the session flag while the lightbox stayed open.
|
||||
const overlay = css.slice(css.indexOf('.cesium-credit-lightbox-overlay {'));
|
||||
assert.match(overlay.slice(0, overlay.indexOf('}')), /z-index: 200 !important/);
|
||||
assert.match(
|
||||
overlay.slice(0, overlay.indexOf('}')),
|
||||
/z-index: 200 !important/,
|
||||
);
|
||||
const launcher = css.slice(css.indexOf('#first-run-launcher {'));
|
||||
assert.match(launcher.slice(0, launcher.indexOf('}')), /z-index: 175/);
|
||||
|
||||
@@ -259,14 +357,26 @@ test('an overlay with NO class to watch still disarms the launcher', () => {
|
||||
|
||||
// ...and every inconclusive answer is UNCOVERED, so a guard added to stop the
|
||||
// launcher acting under an overlay can never become why ESC stopped working.
|
||||
assert.match(covered, /if \(typeof documentRef\.elementFromPoint !== 'function'\) return false;/);
|
||||
assert.match(covered, /if \(!\(rect\.width > 0 && rect\.height > 0\)\) return false;/);
|
||||
assert.match(
|
||||
covered,
|
||||
/if \(typeof documentRef\.elementFromPoint !== 'function'\) return false;/,
|
||||
);
|
||||
assert.match(
|
||||
covered,
|
||||
/if \(!\(rect\.width > 0 && rect\.height > 0\)\) return false;/,
|
||||
);
|
||||
assert.match(covered, /\} catch \{\s*\n\s*return false;\s*\n\s*\}/);
|
||||
});
|
||||
|
||||
test('one ESC does one thing — the radio disclosure stops the launcher outright', () => {
|
||||
const ui = fs.readFileSync(new URL('./ui/radioBindings.js', import.meta.url), 'utf8');
|
||||
const module = fs.readFileSync(new URL('./firstRunExperience.js', import.meta.url), 'utf8');
|
||||
const ui = fs.readFileSync(
|
||||
new URL('./ui/radioBindings.js', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
const module = fs.readFileSync(
|
||||
new URL('./firstRunExperience.js', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
// stopPropagation() does NOT stop later listeners on the SAME document, so the
|
||||
// disclosure's earlier capture handler closed the disclosure and the launcher
|
||||
@@ -276,30 +386,45 @@ test('one ESC does one thing — the radio disclosure stops the launcher outrigh
|
||||
const claim = radioEsc.slice(0, radioEsc.indexOf('setRadioDisclosure(false'));
|
||||
assert.match(claim, /event\.preventDefault\(\);/);
|
||||
assert.match(claim, /event\.stopImmediatePropagation\(\);/);
|
||||
assert.doesNotMatch(claim, /event\.stopPropagation\(\);/,
|
||||
'the plain form leaves the launcher listening and is the defect itself');
|
||||
assert.doesNotMatch(
|
||||
claim,
|
||||
/event\.stopPropagation\(\);/,
|
||||
'the plain form leaves the launcher listening and is the defect itself',
|
||||
);
|
||||
|
||||
// Belt on the launcher side: a key another surface already marked is not ours,
|
||||
// whether or not that surface remembered to silence us.
|
||||
const keyboard = fs.readFileSync(new URL('./ui/surfaceKeyboard.js', import.meta.url), 'utf8');
|
||||
const handler = keyboard.slice(keyboard.indexOf('const onKeyDown = (event) => {'));
|
||||
const keyboard = fs.readFileSync(
|
||||
new URL('./ui/surfaceKeyboard.js', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
const handler = keyboard.slice(
|
||||
keyboard.indexOf('const onKeyDown = (event) => {'),
|
||||
);
|
||||
assert.match(
|
||||
handler.slice(0, handler.indexOf("if (event.key === 'Escape')")),
|
||||
/event\.defaultPrevented\) return;/,
|
||||
'a marked key must be somebody else\'s key',
|
||||
"a marked key must be somebody else's key",
|
||||
);
|
||||
});
|
||||
|
||||
test('a refused write takes the tick back instead of promising "never again"', () => {
|
||||
const module = fs.readFileSync(new URL('./firstRunExperience.js', import.meta.url), 'utf8');
|
||||
const module = fs.readFileSync(
|
||||
new URL('./firstRunExperience.js', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
// The write stays best-effort; the OUTCOME is now reported, because a box left
|
||||
// ticked after a refused write tells the visitor the launcher is gone for good
|
||||
// while it is already guaranteed to return next session.
|
||||
const blocked = {
|
||||
getItem: () => null,
|
||||
setItem: () => { throw new Error('blocked'); },
|
||||
removeItem: () => { throw new Error('blocked'); },
|
||||
setItem: () => {
|
||||
throw new Error('blocked');
|
||||
},
|
||||
removeItem: () => {
|
||||
throw new Error('blocked');
|
||||
},
|
||||
};
|
||||
assert.equal(setFirstRunSuppressed(true, blocked), false);
|
||||
assert.equal(setFirstRunSuppressed(false, blocked), false);
|
||||
@@ -318,13 +443,26 @@ test('a refused write takes the tick back instead of promising "never again"', (
|
||||
module.indexOf('const onSuppressChange = (event) => {'),
|
||||
module.indexOf(' const keyboard = createSurfaceKeyboard({'),
|
||||
);
|
||||
assert.match(handler, /if \(setFirstRunSuppressed\(wanted, storage\)\) return;/);
|
||||
assert.match(handler, /box\.checked = !wanted;/, 'a refused write must revert the tick');
|
||||
assert.match(
|
||||
handler,
|
||||
/if \(setFirstRunSuppressed\(wanted, storage\)\) return;/,
|
||||
);
|
||||
assert.match(
|
||||
handler,
|
||||
/box\.checked = !wanted;/,
|
||||
'a refused write must revert the tick',
|
||||
);
|
||||
});
|
||||
|
||||
test('a surface class that never clears is an ACCEPTED no-show, not a timer', () => {
|
||||
const module = fs.readFileSync(new URL('./firstRunExperience.js', import.meta.url), 'utf8');
|
||||
const state = fs.readFileSync(new URL('../docs/CURRENT-STATE.md', import.meta.url), 'utf8');
|
||||
const module = fs.readFileSync(
|
||||
new URL('./firstRunExperience.js', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
const state = fs.readFileSync(
|
||||
new URL('../docs/CURRENT-STATE.md', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
// A "reveal anyway after N seconds" would trade a benign no-show for the card
|
||||
// punching through a recording in progress — recordings run long, and none of
|
||||
@@ -336,33 +474,65 @@ test('a surface class that never clears is an ACCEPTED no-show, not a timer', ()
|
||||
module.indexOf('ACCEPTED, DELIBERATELY NOT TIMED OUT'),
|
||||
module.indexOf(' const onViewportResize'),
|
||||
);
|
||||
assert.ok(accepted.length > 0, 'the acceptance must be written where the next editor will read it');
|
||||
assert.match(accepted, /const syncToExclusiveSurfaces = \(\) => \{/,
|
||||
'the pin must cover the function the note governs, not just the note');
|
||||
assert.doesNotMatch(accepted, /setTimeout|setInterval/,
|
||||
'the acceptance is the decision NOT to time this out');
|
||||
assert.ok(
|
||||
accepted.length > 0,
|
||||
'the acceptance must be written where the next editor will read it',
|
||||
);
|
||||
assert.match(
|
||||
accepted,
|
||||
/const syncToExclusiveSurfaces = \(\) => \{/,
|
||||
'the pin must cover the function the note governs, not just the note',
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
accepted,
|
||||
/setTimeout|setInterval/,
|
||||
'the acceptance is the decision NOT to time this out',
|
||||
);
|
||||
assert.match(accepted, /docs\/CURRENT-STATE\.md/);
|
||||
assert.match(state, /a surface class that never clears means no launcher for that page/i);
|
||||
assert.match(
|
||||
state,
|
||||
/a surface class that never clears means no launcher for that page/i,
|
||||
);
|
||||
});
|
||||
|
||||
test('the scroll fade only appears when the list really overflows', () => {
|
||||
const module = fs.readFileSync(new URL('./firstRunExperience.js', import.meta.url), 'utf8');
|
||||
const module = fs.readFileSync(
|
||||
new URL('./firstRunExperience.js', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
const css = readStylesheet(new URL('../style.css', import.meta.url));
|
||||
// A fade on a card where all five tiles fit promises a sixth mission that does
|
||||
// not exist, which is worse than no affordance at all.
|
||||
assert.match(module, /const overflows = choiceList\.scrollHeight > choiceList\.clientHeight \+ 1;/);
|
||||
assert.match(module, /choiceList\.dataset\.scrollable = String\(overflows\);/);
|
||||
assert.match(
|
||||
module,
|
||||
/const overflows = choiceList\.scrollHeight > choiceList\.clientHeight \+ 1;/,
|
||||
);
|
||||
assert.match(
|
||||
module,
|
||||
/choiceList\.dataset\.scrollable = String\(overflows\);/,
|
||||
);
|
||||
// ...and the CSS must be gated on that flag, never on the media query alone.
|
||||
assert.match(css, /\.first-run-choices\[data-scrollable='true'\] \{[\s\S]*?mask-image/);
|
||||
const bareFade = css.match(/^\s*\.first-run-choices \{[\s\S]*?\n\}/m)?.[0] || '';
|
||||
assert.doesNotMatch(bareFade, /mask-image/, 'the fade must never apply unconditionally');
|
||||
assert.match(
|
||||
css,
|
||||
/\.first-run-choices\[data-scrollable='true'\] \{[\s\S]*?mask-image/,
|
||||
);
|
||||
const bareFade =
|
||||
css.match(/^\s*\.first-run-choices \{[\s\S]*?\n\}/m)?.[0] || '';
|
||||
assert.doesNotMatch(
|
||||
bareFade,
|
||||
/mask-image/,
|
||||
'the fade must never apply unconditionally',
|
||||
);
|
||||
// Rotating a phone changes which tiles fit, so it re-measures.
|
||||
assert.match(module, /addEventListener\?\.\('resize', onViewportResize\)/);
|
||||
assert.match(module, /removeEventListener\?\.\('resize', onViewportResize\)/);
|
||||
});
|
||||
|
||||
test('the launcher yields on engage and waits when a surface is already up', () => {
|
||||
const module = fs.readFileSync(new URL('./firstRunExperience.js', import.meta.url), 'utf8');
|
||||
const module = fs.readFileSync(
|
||||
new URL('./firstRunExperience.js', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
// Both directions from one observer: yield if it is up and something takes
|
||||
// the screen; wait to reveal if something already has it.
|
||||
assert.match(
|
||||
@@ -375,19 +545,28 @@ test('the launcher yields on engage and waits when a surface is already up', ()
|
||||
// not see a new hold because of onboarding chrome.
|
||||
assert.match(module, /attributes: true,\s*attributeFilter: \['class'\]/);
|
||||
assert.match(module, /surfaceObserver\?\.disconnect\(\)/);
|
||||
assert.doesNotMatch(module, /setInterval|requestAnimationFrame\(function poll/);
|
||||
assert.doesNotMatch(
|
||||
module,
|
||||
/setInterval|requestAnimationFrame\(function poll/,
|
||||
);
|
||||
});
|
||||
|
||||
// ── Per-mission behavior ─────────────────────────────────────────────────────
|
||||
|
||||
function missionSpy({ contextOk = true, layerResult = () => true, globe = async () => ({ ok: true }) } = {}) {
|
||||
function missionSpy({
|
||||
contextOk = true,
|
||||
layerResult = () => true,
|
||||
globe = async () => ({ ok: true }),
|
||||
} = {}) {
|
||||
const calls = { contextModes: [], layerIds: [], globeFlights: 0 };
|
||||
return {
|
||||
calls,
|
||||
deps: {
|
||||
setContextMode: async (mode) => {
|
||||
calls.contextModes.push(mode);
|
||||
return contextOk ? { ok: true, mode } : { ok: false, failedLayerIds: ['rocket-launches'] };
|
||||
return contextOk
|
||||
? { ok: true, mode }
|
||||
: { ok: false, failedLayerIds: ['rocket-launches'] };
|
||||
},
|
||||
setLayerEnabled: async (layerId) => {
|
||||
calls.layerIds.push(layerId);
|
||||
@@ -408,14 +587,23 @@ test('the menu is the four owner-ordered missions', () => {
|
||||
// went is the one-click globe-scale dump. Restoring the tile needs the
|
||||
// globe-LOD declutter first.
|
||||
assert.deepEqual(Object.keys(FIRST_RUN_MISSIONS), [
|
||||
'contacts', 'space-missions', 'environmental', 'explore',
|
||||
'contacts',
|
||||
'space-missions',
|
||||
'environmental',
|
||||
'explore',
|
||||
]);
|
||||
assert.equal(FIRST_RUN_MISSIONS.infrastructure, undefined,
|
||||
'the infrastructure mission must be gone, not dormant');
|
||||
assert.equal(
|
||||
FIRST_RUN_MISSIONS.infrastructure,
|
||||
undefined,
|
||||
'the infrastructure mission must be gone, not dormant',
|
||||
);
|
||||
});
|
||||
|
||||
test('Live Contacts and Space Missions go through the one setContextMode facade', async () => {
|
||||
for (const [choice, mode] of [['contacts', 'contacts'], ['space-missions', 'space-missions']]) {
|
||||
for (const [choice, mode] of [
|
||||
['contacts', 'contacts'],
|
||||
['space-missions', 'space-missions'],
|
||||
]) {
|
||||
const spy = missionSpy();
|
||||
const outcome = await runFirstRunChoice(choice, spy.deps);
|
||||
assert.equal(outcome.ok, true);
|
||||
@@ -445,8 +633,14 @@ test('the tile is the FULLY CONFIGURED experience: quakes and fires together', (
|
||||
// that row into LOAD FAILED — a defect in the shared state machine, ledgered
|
||||
// post-launch, and the note must stay where the next editor will read it
|
||||
// rather than being re-discovered as a launcher bug.
|
||||
const module = fs.readFileSync(new URL('./firstRunExperience.js', import.meta.url), 'utf8');
|
||||
const table = module.slice(module.indexOf(' environmental: Object.freeze({'), module.indexOf(' explore:'));
|
||||
const module = fs.readFileSync(
|
||||
new URL('./firstRunExperience.js', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
const table = module.slice(
|
||||
module.indexOf(' environmental: Object.freeze({'),
|
||||
module.indexOf(' explore:'),
|
||||
);
|
||||
assert.match(table, /KEY REQUIRED/);
|
||||
assert.match(table, /src\/loadingFeedback\.js/);
|
||||
assert.match(table, /LEDGERED post-launch/);
|
||||
@@ -461,7 +655,10 @@ test('every visitor gets the same tile — there is no degraded keyless variant'
|
||||
assert.equal(outcome.ok, true);
|
||||
assert.deepEqual(outcome.failedLayerIds, []);
|
||||
assert.deepEqual(spy.calls.layerIds, ['earthquakes', 'local-firms']);
|
||||
const module = fs.readFileSync(new URL('./firstRunExperience.js', import.meta.url), 'utf8');
|
||||
const module = fs.readFileSync(
|
||||
new URL('./firstRunExperience.js', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
module.slice(module.indexOf('export async function runFirstRunChoice')),
|
||||
/FIRMS_MAP_KEY|hasKey|keyless\s*\?/,
|
||||
@@ -476,16 +673,31 @@ test('a refused layer fails the mission by name, and a stalled flight never does
|
||||
assert.deepEqual(outcome.failedLayerIds, ['earthquakes']);
|
||||
|
||||
// The globe flight is framing. A cancelled or throwing flight is not a failure.
|
||||
const flightDown = missionSpy({ globe: () => { throw new Error('cancelled'); } });
|
||||
assert.equal((await runFirstRunChoice('environmental', flightDown.deps)).ok, true);
|
||||
const flightDown = missionSpy({
|
||||
globe: () => {
|
||||
throw new Error('cancelled');
|
||||
},
|
||||
});
|
||||
assert.equal(
|
||||
(await runFirstRunChoice('environmental', flightDown.deps)).ok,
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('Explore manually touches nothing at all, and an unknown choice is inert', async () => {
|
||||
const spy = missionSpy();
|
||||
assert.equal((await runFirstRunChoice('explore', spy.deps)).ok, true);
|
||||
assert.deepEqual(spy.calls, { contextModes: [], layerIds: [], globeFlights: 0 });
|
||||
assert.deepEqual(spy.calls, {
|
||||
contextModes: [],
|
||||
layerIds: [],
|
||||
globeFlights: 0,
|
||||
});
|
||||
assert.equal((await runFirstRunChoice('nope', spy.deps)).ok, false);
|
||||
assert.deepEqual(spy.calls, { contextModes: [], layerIds: [], globeFlights: 0 });
|
||||
assert.deepEqual(spy.calls, {
|
||||
contextModes: [],
|
||||
layerIds: [],
|
||||
globeFlights: 0,
|
||||
});
|
||||
});
|
||||
|
||||
test('a failed Context mission reports the layers the facade named', async () => {
|
||||
@@ -500,14 +712,22 @@ test('the fires/quakes tile name is switchable from one constant', () => {
|
||||
assert.equal(environmentalLabel('EARTH_WATCH').title, 'EARTH WATCH');
|
||||
assert.equal(environmentalLabel('ACTIVE_EVENTS').title, 'ACTIVE EVENTS');
|
||||
assert.equal(environmentalLabel('nonsense').title, 'ENVIRONMENTAL');
|
||||
assert.equal(environmentalLabel().title, environmentalLabel(ENVIRONMENTAL_LABEL_CHOICE).title);
|
||||
assert.equal(
|
||||
environmentalLabel().title,
|
||||
environmentalLabel(ENVIRONMENTAL_LABEL_CHOICE).title,
|
||||
);
|
||||
});
|
||||
|
||||
// ── Defaults interplay: what a mission is allowed to persist ─────────────────
|
||||
|
||||
test('no mission writes a preference the visitor did not choose by picking it', () => {
|
||||
const module = fs.readFileSync(new URL('./firstRunExperience.js', import.meta.url), 'utf8');
|
||||
const code = module.slice(module.indexOf('export function shouldShowFirstRun'));
|
||||
const module = fs.readFileSync(
|
||||
new URL('./firstRunExperience.js', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
const code = module.slice(
|
||||
module.indexOf('export function shouldShowFirstRun'),
|
||||
);
|
||||
|
||||
// Layer enables ARE durable in this app and a mission tile IS that choice, so
|
||||
// they run at the same origin a click on those rows uses.
|
||||
@@ -528,22 +748,45 @@ test('no mission writes a preference the visitor did not choose by picking it',
|
||||
'_setModels3dParams',
|
||||
'setFeather',
|
||||
]) {
|
||||
assert.doesNotMatch(code, new RegExp(forbidden), `a mission must never touch ${forbidden}`);
|
||||
assert.doesNotMatch(
|
||||
code,
|
||||
new RegExp(forbidden),
|
||||
`a mission must never touch ${forbidden}`,
|
||||
);
|
||||
}
|
||||
|
||||
// The only durable panel write is the Context reveal, and only on the Context
|
||||
// missions — the globe missions open no panel at all.
|
||||
const panelWrites = code.match(/setPanelCollapsed/g) || [];
|
||||
assert.equal(panelWrites.length, 1, 'exactly one panel reveal, on the Context path');
|
||||
const contextPath = code.slice(code.indexOf('setContextMode: async (mode)'), code.indexOf('setLayerEnabled:'));
|
||||
assert.match(contextPath, /result\?\.ok[\s\S]*?setPanelCollapsed\?\.\('global-context-panel', false, \{\s*explicit: true,?\s*\}\)/);
|
||||
assert.equal(
|
||||
panelWrites.length,
|
||||
1,
|
||||
'exactly one panel reveal, on the Context path',
|
||||
);
|
||||
const contextPath = code.slice(
|
||||
code.indexOf('setContextMode: async (mode)'),
|
||||
code.indexOf('setLayerEnabled:'),
|
||||
);
|
||||
assert.match(
|
||||
contextPath,
|
||||
/result\?\.ok[\s\S]*?setPanelCollapsed\?\.\('global-context-panel', false, \{\s*explicit: true,?\s*\}\)/,
|
||||
);
|
||||
});
|
||||
|
||||
test('the decision table is written down where the next editor will read it', () => {
|
||||
const module = fs.readFileSync(new URL('./firstRunExperience.js', import.meta.url), 'utf8');
|
||||
assert.match(module, /MISSION → APP STATE, AND WHAT IT IS ALLOWED TO PERSIST/);
|
||||
const module = fs.readFileSync(
|
||||
new URL('./firstRunExperience.js', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
assert.match(
|
||||
module,
|
||||
/MISSION → APP STATE, AND WHAT IT IS ALLOWED TO PERSIST/,
|
||||
);
|
||||
for (const row of ['TOUCHED, DURABLE', 'TOUCHED, SESSION', 'NOT TOUCHED']) {
|
||||
assert.ok(module.includes(row), `decision table is missing its "${row}" rows`);
|
||||
assert.ok(
|
||||
module.includes(row),
|
||||
`decision table is missing its "${row}" rows`,
|
||||
);
|
||||
}
|
||||
assert.match(module, /SHOW POLICY/);
|
||||
});
|
||||
@@ -551,56 +794,109 @@ test('the decision table is written down where the next editor will read it', ()
|
||||
// ── Markup, startup ordering, accessibility ─────────────────────────────────
|
||||
|
||||
test('markup, startup ordering and accessibility remain pinned', () => {
|
||||
const html = expandApplicationHtml(fs.readFileSync(new URL('../index.html', import.meta.url), 'utf8'));
|
||||
const startup = fs.readFileSync(new URL('./app/startupChrome.js', import.meta.url), 'utf8');
|
||||
const html = expandApplicationHtml(
|
||||
fs.readFileSync(new URL('../index.html', import.meta.url), 'utf8'),
|
||||
);
|
||||
const startup = fs.readFileSync(
|
||||
new URL('./app/startupChrome.js', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
const css = readStylesheet(new URL('../style.css', import.meta.url));
|
||||
|
||||
assert.match(html, /id="first-run-launcher" role="dialog"[^>]*aria-labelledby="first-run-title"[^>]*hidden/);
|
||||
assert.match(
|
||||
html,
|
||||
/id="first-run-launcher" role="dialog"[^>]*aria-labelledby="first-run-title"[^>]*hidden/,
|
||||
);
|
||||
assert.equal((html.match(/data-first-run-choice=/g) || []).length, 4);
|
||||
assert.match(html, /data-first-run-status[^>]*role="status"[^>]*aria-live="polite"/);
|
||||
assert.match(
|
||||
html,
|
||||
/data-first-run-status[^>]*role="status"[^>]*aria-live="polite"/,
|
||||
);
|
||||
assert.match(html, /<input type="checkbox" data-first-run-suppress \/>/);
|
||||
assert.match(html, /<strong data-first-run-environmental-title>/);
|
||||
// Subcopy must name BOTH feeds the tile turns on — a tile that promised only
|
||||
// half of what it does is the defect this replaced. Only the VISIBLE <small>
|
||||
// text counts; the comment beside it naturally says the words too.
|
||||
const envTile = html.slice(html.indexOf('data-first-run-choice="environmental"'));
|
||||
const visible = envTile.slice(envTile.indexOf('<small>'), envTile.indexOf('</small>'));
|
||||
const envTile = html.slice(
|
||||
html.indexOf('data-first-run-choice="environmental"'),
|
||||
);
|
||||
const visible = envTile.slice(
|
||||
envTile.indexOf('<small>'),
|
||||
envTile.indexOf('</small>'),
|
||||
);
|
||||
assert.match(visible, /earthquakes/i);
|
||||
assert.match(visible, /fires?/i, 'the tile must promise the fires it enables');
|
||||
assert.match(
|
||||
visible,
|
||||
/fires?/i,
|
||||
'the tile must promise the fires it enables',
|
||||
);
|
||||
|
||||
// The card's one persuasive line is OWNER-AUTHORED and pinned verbatim,
|
||||
// unspaced em dash included. This is copy, not prose to be improved in a
|
||||
// passing edit — changing it needs the owner, not a nicer-sounding rewrite.
|
||||
assert.ok(
|
||||
html.includes('<p id="first-run-description">It feels like a forbidden cockpit'
|
||||
+ '—then you realize the sources are public and the data is real.</p>'),
|
||||
html.includes(
|
||||
'<p id="first-run-description" data-i18n-runtime>It feels like a forbidden cockpit' +
|
||||
'—then you realize the sources are public and the data is real.</p>',
|
||||
),
|
||||
'the owner-authored first-run line must ship exactly as written',
|
||||
);
|
||||
|
||||
// Menu order is the owner's, read straight off the markup.
|
||||
const order = [...html.matchAll(/data-first-run-choice="([a-z-]+)"/g)].map((match) => match[1]);
|
||||
assert.deepEqual(order, ['contacts', 'space-missions', 'environmental', 'explore']);
|
||||
assert.doesNotMatch(html, /data-first-run-choice="infrastructure"/,
|
||||
'the removed tile must leave no markup behind');
|
||||
const order = [...html.matchAll(/data-first-run-choice="([a-z-]+)"/g)].map(
|
||||
(match) => match[1],
|
||||
);
|
||||
assert.deepEqual(order, [
|
||||
'contacts',
|
||||
'space-missions',
|
||||
'environmental',
|
||||
'explore',
|
||||
]);
|
||||
assert.doesNotMatch(
|
||||
html,
|
||||
/data-first-run-choice="infrastructure"/,
|
||||
'the removed tile must leave no markup behind',
|
||||
);
|
||||
|
||||
assert.match(startup, /styleManager\.initialRestorePromise/);
|
||||
assert.ok(startup.indexOf("loadingScreen.classList.add('hidden')") < startup.indexOf("loadingScreen.addEventListener('transitionend', revealFirstRun"));
|
||||
assert.match(startup, /initializeWelcome\?\.\(\{ styleManager, dataManager \}\)/);
|
||||
assert.ok(
|
||||
startup.indexOf("loadingScreen.classList.add('hidden')") <
|
||||
startup.indexOf(
|
||||
"loadingScreen.addEventListener('transitionend', revealFirstRun",
|
||||
),
|
||||
);
|
||||
assert.match(
|
||||
startup,
|
||||
/initializeWelcome\?\.\(\{ styleManager, dataManager \}\)/,
|
||||
);
|
||||
|
||||
assert.match(css, /body\.ui-clean-view #first-run-launcher/);
|
||||
assert.match(css, /body\.recording-mode #first-run-launcher/);
|
||||
// Scoped, not a bare search: style.css has other reduced-motion blocks, and
|
||||
// matching one of THOSE would let the launcher's own opt-out be deleted.
|
||||
const reducedMotion = [...css.matchAll(/@media \(prefers-reduced-motion: reduce\) \{([\s\S]*?)\n\}/g)]
|
||||
.map((match) => match[1]);
|
||||
const reducedMotion = [
|
||||
...css.matchAll(
|
||||
/@media \(prefers-reduced-motion: reduce\) \{([\s\S]*?)\n\}/g,
|
||||
),
|
||||
].map((match) => match[1]);
|
||||
assert.ok(
|
||||
reducedMotion.some((block) => block.includes('#first-run-launcher') && block.includes('.first-run-choices')),
|
||||
reducedMotion.some(
|
||||
(block) =>
|
||||
block.includes('#first-run-launcher') &&
|
||||
block.includes('.first-run-choices'),
|
||||
),
|
||||
'the launcher must keep its OWN prefers-reduced-motion block, covering the card and the scrolling list',
|
||||
);
|
||||
// The card must be click-through until revealed and again while it fades out.
|
||||
const base = css.slice(css.indexOf('#first-run-launcher {'), css.indexOf('#first-run-launcher.visible'));
|
||||
const base = css.slice(
|
||||
css.indexOf('#first-run-launcher {'),
|
||||
css.indexOf('#first-run-launcher.visible'),
|
||||
);
|
||||
assert.match(base, /pointer-events: none/);
|
||||
assert.match(css, /#first-run-launcher\.visible \{[\s\S]*?pointer-events: auto/);
|
||||
assert.match(
|
||||
css,
|
||||
/#first-run-launcher\.visible \{[\s\S]*?pointer-events: auto/,
|
||||
);
|
||||
// The card is a flex column so its mission list can scroll on a short
|
||||
// viewport — and an AUTHOR `display` on this id outranks the UA's
|
||||
// `[hidden] { display: none }`, which would strand the card in the
|
||||
@@ -616,13 +912,22 @@ test('markup, startup ordering and accessibility remain pinned', () => {
|
||||
});
|
||||
|
||||
test('the launcher keeps focus, restores it, and never disables the focused button', () => {
|
||||
const module = fs.readFileSync(new URL('./firstRunExperience.js', import.meta.url), 'utf8');
|
||||
const module = fs.readFileSync(
|
||||
new URL('./firstRunExperience.js', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
// aria-disabled, never the `disabled` property: disabling a focused button
|
||||
// drops the keyboard to <body> and strands the visitor outside the launcher.
|
||||
assert.match(module, /button\.setAttribute\('aria-disabled', String\(next\)\)/);
|
||||
assert.match(
|
||||
module,
|
||||
/button\.setAttribute\('aria-disabled', String\(next\)\)/,
|
||||
);
|
||||
assert.doesNotMatch(module, /button\.disabled = /);
|
||||
// Tab is confined to the launcher, and ESC always releases it.
|
||||
const keyboard = fs.readFileSync(new URL('./ui/surfaceKeyboard.js', import.meta.url), 'utf8');
|
||||
const keyboard = fs.readFileSync(
|
||||
new URL('./ui/surfaceKeyboard.js', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
assert.match(module, /keyboard\.activate\(\)/);
|
||||
assert.match(module, /keyboard\.deactivate\(\{ restoreFocus \}\)/);
|
||||
assert.match(keyboard, /event\.key !== 'Tab'/);
|
||||
@@ -633,7 +938,10 @@ test('the launcher keeps focus, restores it, and never disables the focused butt
|
||||
});
|
||||
|
||||
test('the DISPLAY rail starts collapsed on a first run, and a stored choice wins', () => {
|
||||
const ui = fs.readFileSync(new URL('./ui/panelPositionControls.js', import.meta.url), 'utf8');
|
||||
const ui = fs.readFileSync(
|
||||
new URL('./ui/panelPositionControls.js', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
// The rail opened by default to advertise HUD / DETECT / 3D. Those default ON
|
||||
// now, so it was opening to offer controls for things already happening —
|
||||
// while competing with the mission card for the one first impression there is.
|
||||
@@ -644,7 +952,11 @@ test('the DISPLAY rail starts collapsed on a first run, and a stored choice wins
|
||||
);
|
||||
// The first-run default may only apply when NOTHING is stored: a visitor who
|
||||
// opened the rail keeps it open, and one who closed it keeps it closed.
|
||||
const block = ui.slice(ui.indexOf('stored = localStorage.getItem(this._panelCollapseStorageKey(panelId))'));
|
||||
const block = ui.slice(
|
||||
ui.indexOf(
|
||||
'stored = localStorage.getItem(this._panelCollapseStorageKey(panelId))',
|
||||
),
|
||||
);
|
||||
const guard = block.slice(0, block.indexOf('panelEl.classList.toggle'));
|
||||
assert.match(guard, /if \(stored === '1'\) collapsed = true;/);
|
||||
assert.match(guard, /if \(stored === '0'\) collapsed = false;/);
|
||||
@@ -661,9 +973,17 @@ test('the voice TOOL SCHEMA matches the pinned contract — the mission mapping
|
||||
// Canonical serialization pins every tool name, description, property and
|
||||
// ordering while allowing source formatting. Derived from the unchanged
|
||||
// release schema before formatting (the previous source-byte pin passed).
|
||||
const legacyTools = structuredClone(GEV_REALTIME_TOOLS).filter((tool) => tool.name !== 'set_cyber_sonar');
|
||||
const hudLayout = legacyTools.find((tool) => tool.name === 'set_hud').parameters.properties.layout;
|
||||
assert.deepEqual(hudLayout.enum, ['tactical', 'operator', 'minimal', 'cyber']);
|
||||
const legacyTools = structuredClone(GEV_REALTIME_TOOLS).filter(
|
||||
(tool) => tool.name !== 'set_cyber_sonar',
|
||||
);
|
||||
const hudLayout = legacyTools.find((tool) => tool.name === 'set_hud')
|
||||
.parameters.properties.layout;
|
||||
assert.deepEqual(hudLayout.enum, [
|
||||
'tactical',
|
||||
'operator',
|
||||
'minimal',
|
||||
'cyber',
|
||||
]);
|
||||
// Cyber deliberately adds one layout; first-run missions still change no tools.
|
||||
hudLayout.enum = hudLayout.enum.filter((layout) => layout !== 'cyber');
|
||||
const block = JSON.stringify(legacyTools);
|
||||
@@ -680,7 +1000,10 @@ test('the voice TOOL SCHEMA matches the pinned contract — the mission mapping
|
||||
'b316ca2e67848eb565b6847ac044a19d1465d8ef8252a5dfbd7d947a2a36d5ed',
|
||||
'the first-run missions must ride EXISTING tools: no schema edit, no cache bust',
|
||||
);
|
||||
const instructions = fs.readFileSync(new URL('../server/providers/openai/instructions.js', import.meta.url), 'utf8');
|
||||
const instructions = fs.readFileSync(
|
||||
new URL('../server/providers/openai/instructions.js', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
// ...and the mapping that makes them reachable by voice is one instruction
|
||||
// string, whose rollback is deleting that string. Anchored to a LIVE array
|
||||
@@ -691,23 +1014,39 @@ test('the voice TOOL SCHEMA matches the pinned contract — the mission mapping
|
||||
/\n\s+'NAMED VIEWS are shorthand/,
|
||||
'the mission mapping must be an active instruction entry, not commented out',
|
||||
);
|
||||
const mapping = instructions.slice(instructions.indexOf('NAMED VIEWS are shorthand'));
|
||||
const mapping = instructions.slice(
|
||||
instructions.indexOf('NAMED VIEWS are shorthand'),
|
||||
);
|
||||
const paragraph = mapping.slice(0, mapping.indexOf("',\n"));
|
||||
for (const layerId of [
|
||||
'local-datacenters', 'local-dams', 'telegeography-submarine-cables', 'local-firms', 'earthquakes',
|
||||
'local-datacenters',
|
||||
'local-dams',
|
||||
'telegeography-submarine-cables',
|
||||
'local-firms',
|
||||
'earthquakes',
|
||||
]) {
|
||||
assert.ok(paragraph.includes(layerId), `mapping must name the existing ${layerId} enum value`);
|
||||
assert.ok(
|
||||
paragraph.includes(layerId),
|
||||
`mapping must name the existing ${layerId} enum value`,
|
||||
);
|
||||
}
|
||||
assert.ok(paragraph.includes('zoom_to_globe'));
|
||||
assert.ok(paragraph.includes('set_layer_visibility'));
|
||||
});
|
||||
|
||||
test('every layer a mission drives is already in the shipped set_layer_visibility enum', () => {
|
||||
const tool = GEV_REALTIME_TOOLS.find(tool => tool.name === 'set_layer_visibility');
|
||||
const tool = GEV_REALTIME_TOOLS.find(
|
||||
(tool) => tool.name === 'set_layer_visibility',
|
||||
);
|
||||
const allowedLayers = tool.parameters.properties.layerId.enum;
|
||||
const missionLayerIds = Object.values(FIRST_RUN_MISSIONS).flatMap((mission) => mission.layerIds || []);
|
||||
const missionLayerIds = Object.values(FIRST_RUN_MISSIONS).flatMap(
|
||||
(mission) => mission.layerIds || [],
|
||||
);
|
||||
assert.ok(missionLayerIds.length > 0);
|
||||
for (const layerId of missionLayerIds) {
|
||||
assert.ok(allowedLayers.includes(layerId), `${layerId} must already be an allowed enum value`);
|
||||
assert.ok(
|
||||
allowedLayers.includes(layerId),
|
||||
`${layerId} must already be an allowed enum value`,
|
||||
);
|
||||
}
|
||||
});
|
||||
+9
-1
@@ -186,6 +186,7 @@ export class IntelHUD {
|
||||
this._el = document.getElementById('intel-hud');
|
||||
if (!this._el) return;
|
||||
|
||||
// i18n-ignore: sensor readout codes (MGRS, GSD, NIIRS, ONA, BAND…) and a classification banner; translated parts use t()
|
||||
this._el.innerHTML = `
|
||||
<div class="hud-sonar" aria-hidden="true"></div>
|
||||
|
||||
@@ -228,7 +229,7 @@ export class IntelHUD {
|
||||
<div class="hud-content" style="text-align:right">
|
||||
<div id="hud-gsd">GSD: --m NIIRS: --</div>
|
||||
<div id="hud-alt">ALT: --m SUN: --° EL</div>
|
||||
<div id="hud-ais-vessel" class="hud-ais-vessel">AIS: --</div>
|
||||
<div id="hud-ais-vessel" class="hud-ais-vessel">${t('AIS: --')}</div>
|
||||
</div>
|
||||
<div class="hud-bracket">┘</div>
|
||||
</div>
|
||||
@@ -356,9 +357,11 @@ export class IntelHUD {
|
||||
const formatted = this._formatMGRS(mgrsStr);
|
||||
mgrsLabel = formatted;
|
||||
const el = document.getElementById('hud-mgrs');
|
||||
// i18n-ignore: sensor readout codes, same in every language
|
||||
if (el) el.textContent = `MGRS: ${formatted}`;
|
||||
} catch {
|
||||
const el = document.getElementById('hud-mgrs');
|
||||
// i18n-ignore: sensor readout codes, same in every language
|
||||
if (el) el.textContent = 'MGRS: ---';
|
||||
}
|
||||
|
||||
@@ -367,6 +370,7 @@ export class IntelHUD {
|
||||
if (llEl) llEl.textContent = `${latDMS} ${lonDMS}`;
|
||||
const bottomEl = document.getElementById('hud-bottom-line');
|
||||
if (bottomEl) {
|
||||
// i18n-ignore: sensor readout codes, same in every language
|
||||
bottomEl.textContent = `MGRS: ${mgrsLabel} LAT: ${latDMS} LON: ${lonDMS}`;
|
||||
}
|
||||
|
||||
@@ -382,6 +386,7 @@ export class IntelHUD {
|
||||
);
|
||||
const gsdEl = document.getElementById('hud-gsd');
|
||||
if (gsdEl)
|
||||
// i18n-ignore: sensor readout codes, same in every language
|
||||
gsdEl.textContent = `GSD: ${gsd.toFixed(2)}m NIIRS: ${niirs.toFixed(1)}`;
|
||||
|
||||
// Altitude — reported as height above MEAN SEA LEVEL. `altM` is the raw
|
||||
@@ -394,6 +399,7 @@ export class IntelHUD {
|
||||
const altMslM = ellipsoidalToMslDisplayM(altM, geoidN);
|
||||
const sunEl = this._estimateSunElevation(latDeg, lonDeg);
|
||||
if (altEl)
|
||||
// i18n-ignore: sensor readout codes, same in every language
|
||||
altEl.textContent = `ALT: ${Math.round(altMslM)}m SUN: ${sunEl.toFixed(1)}° EL`;
|
||||
|
||||
// Collection timestamp
|
||||
@@ -403,6 +409,7 @@ export class IntelHUD {
|
||||
const h = String(now.getUTCHours()).padStart(2, '0');
|
||||
const m = String(now.getUTCMinutes()).padStart(2, '0');
|
||||
const s = String(now.getUTCSeconds()).padStart(2, '0');
|
||||
// i18n-ignore: sensor readout codes, same in every language
|
||||
collEl.textContent = `COLL: ${h}:${m}:${s}Z`;
|
||||
}
|
||||
|
||||
@@ -411,6 +418,7 @@ export class IntelHUD {
|
||||
const pitchDeg = Cesium.Math.toDegrees(camera.pitch);
|
||||
const ona = Math.max(0, 90 + pitchDeg);
|
||||
const onaEl = document.getElementById('hud-ona');
|
||||
// i18n-ignore: sensor readout codes, same in every language
|
||||
if (onaEl) onaEl.textContent = `ONA: ${ona.toFixed(1)}°`;
|
||||
|
||||
// `altM` stays the raw ellipsoidal camera height the sensor model reads
|
||||
|
||||
@@ -4,6 +4,7 @@ import assert from 'node:assert/strict';
|
||||
import {
|
||||
checkCatalog,
|
||||
extractMessages,
|
||||
findUnmarked,
|
||||
readCatalog,
|
||||
shippedLocales,
|
||||
unmarkedMarkup,
|
||||
@@ -34,6 +35,13 @@ for (const locale of shippedLocales()) {
|
||||
});
|
||||
}
|
||||
|
||||
test('every UI string is marked for translation or kept on purpose (npm run i18n)', () => {
|
||||
assert.deepEqual(
|
||||
findUnmarked().map(({ where, text }) => `${where} ${text}`),
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
test('checkCatalog reports missing, stale, placeholder and plural problems', () => {
|
||||
const fixture = new Map([
|
||||
['Saved {name}', { plural: false }],
|
||||
|
||||
+823
-1
@@ -979,5 +979,827 @@
|
||||
"ALL": "TODAS",
|
||||
"FLAT": "PLANAS",
|
||||
"SINCE": "DESDE",
|
||||
"Coverage colours": "Colores de cobertura"
|
||||
"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.",
|
||||
"{label} · Detailed outline unavailable": "{label} · Contorno detallado no disponible",
|
||||
"Witness source": "Fuente testigo",
|
||||
"{provider} · EMBEDDED SOURCE": "{provider} · FUENTE INSERTADA",
|
||||
"OPEN ORIGINAL ↗": "ABRIR ORIGINAL ↗",
|
||||
"{provider} media: {source}": "Contenido de {provider}: {source}",
|
||||
"{provider} · PROVIDER CONTROLS": "{provider} · CONTROLES DEL PROVEEDOR",
|
||||
"YOUTUBE {state} · OPEN ORIGINAL": "YOUTUBE {state} · ABRE EL ORIGINAL",
|
||||
"Facebook player API did not initialize.": "La API del reproductor de Facebook no se inicializó.",
|
||||
"Facebook player API could not load.": "No se pudo cargar la API del reproductor de Facebook.",
|
||||
"Facebook player API timed out.": "La API del reproductor de Facebook agotó el tiempo de espera.",
|
||||
"X embed API did not initialize.": "La API de inserción de X no se inicializó.",
|
||||
"X embed could not load.": "No se pudo cargar la inserción de X.",
|
||||
"X embed timed out.": "La inserción de X agotó el tiempo de espera.",
|
||||
"TIMEOUT": "TIEMPO AGOTADO",
|
||||
"BLOCKED": "BLOQUEADO",
|
||||
"Facebook embed unavailable. The post may be private, removed, region-restricted, or blocked.": "Inserción de Facebook no disponible. La publicación puede ser privada, haber sido eliminada, estar restringida por región o bloqueada.",
|
||||
"Preloading {provider} media": "Precargando contenido de {provider}",
|
||||
"LOADING X POST…": "CARGANDO PUBLICACIÓN DE X…",
|
||||
"The X post is unavailable or cannot be embedded.": "La publicación de X no está disponible o no se puede insertar.",
|
||||
"X POST · PROVIDER CONTROLS": "PUBLICACIÓN DE X · CONTROLES DEL PROVEEDOR",
|
||||
"{reason} OPEN ORIGINAL.": "{reason} ABRE EL ORIGINAL.",
|
||||
"X embed unavailable": "Inserción de X no disponible",
|
||||
"FACEBOOK · PROVIDER CONTROLS": "FACEBOOK · CONTROLES DEL PROVEEDOR",
|
||||
"Facebook embed unavailable": "Inserción de Facebook no disponible",
|
||||
"AUTOPLAY BLOCKED · USE PROVIDER CONTROLS": "REPRODUCCIÓN AUTOMÁTICA BLOQUEADA · USA LOS CONTROLES DEL PROVEEDOR",
|
||||
"Embedded {provider} media for {source}": "Contenido de {provider} insertado para {source}",
|
||||
"witness source": "fuente testigo",
|
||||
"Rasuwagadhi witness": "Testigo de Rasuwagadhi",
|
||||
"Open original source for {source}; capture time unverified": "Abrir la fuente original de {source}; hora de captura sin verificar",
|
||||
"Historical reference and post-event image divider": "Divisor entre la imagen de referencia histórica y la posterior al evento",
|
||||
"A historical reference {before} percent, B post-event {after} percent": "A, referencia histórica, {before} por ciento; B, posterior al evento, {after} por ciento",
|
||||
"■ RELEASE CAMERA": "■ LIBERAR CÁMARA",
|
||||
"Seek Nepal scene clock": "Desplazarse por el reloj de la escena de Nepal",
|
||||
"NEPAL SCENE CLOCK": "RELOJ DE LA ESCENA DE NEPAL",
|
||||
"… PLAYING SHOT": "… REPRODUCIENDO PLANO",
|
||||
"▶ PLAY SHOT": "▶ REPRODUCIR PLANO",
|
||||
"Ⅱ PAUSE": "Ⅱ PAUSA",
|
||||
"↺ REPLAY CINEMATIC": "↺ REPETIR CINEMÁTICA",
|
||||
"↺ REPLAY": "↺ REPETIR",
|
||||
"… PLAYING SCENE": "… REPRODUCIENDO ESCENA",
|
||||
"Continue {scene} from next shot": "Continuar {scene} desde el siguiente plano",
|
||||
"current scene": "la escena actual",
|
||||
"Replay the full reconstruction": "Repetir la reconstrucción completa",
|
||||
"SCENE SHOT": "PLANO DE ESCENA",
|
||||
"TIME UNVERIFIED": "HORA SIN VERIFICAR",
|
||||
"{count} SOURCES": {
|
||||
"one": "{count} FUENTE",
|
||||
"many": "{count} DE FUENTES",
|
||||
"other": "{count} FUENTES"
|
||||
},
|
||||
"OUTSIDE IMAGERY SWIPE": "FUERA DE LA COMPARACIÓN DE IMÁGENES",
|
||||
"{scene} / AUTHORED SHOT": "{scene} / PLANO DISEÑADO",
|
||||
"NEPAL FLOOD INCIDENT": "INCIDENTE DE INUNDACIÓN EN NEPAL",
|
||||
"Play {shot}": "Reproducir {shot}",
|
||||
"current scene shot": "el plano actual de la escena",
|
||||
"Play reconstruction": "Reproducir la reconstrucción",
|
||||
"Load previous scene shot": "Cargar el plano anterior de la escena",
|
||||
"Load next scene shot": "Cargar el siguiente plano de la escena",
|
||||
"SCHEMATIC CORRIDOR, NOT MODELED ARRIVAL TIME.": "CORREDOR ESQUEMÁTICO, NO TIEMPO DE LLEGADA MODELADO.",
|
||||
"Rasuwa · 26 Aug 2026": "Rasuwa · 26 ago 2026",
|
||||
"Event reconstruction": "Reconstrucción del evento",
|
||||
"INITIATION": "INICIO",
|
||||
"DAMMING": "REPRESAMIENTO",
|
||||
"SECONDARY FAILURE": "ROTURA SECUNDARIA",
|
||||
"BORDER ARRIVAL": "LLEGADA A LA FRONTERA",
|
||||
"PROPAGATION": "PROPAGACIÓN",
|
||||
"DOWNSTREAM PASSAGE": "PASO AGUAS ABAJO",
|
||||
"CONSEQUENCE": "CONSECUENCIA",
|
||||
"DOWNSTREAM CONSEQUENCE": "CONSECUENCIA AGUAS ABAJO",
|
||||
"EVIDENCE": "EVIDENCIA",
|
||||
"{count} GEOLOCATED SOURCE-MAP PLACEMENTS": {
|
||||
"one": "{count} UBICACIÓN GEOLOCALIZADA EN EL MAPA DE FUENTES",
|
||||
"many": "{count} DE UBICACIONES GEOLOCALIZADAS EN EL MAPA DE FUENTES",
|
||||
"other": "{count} UBICACIONES GEOLOCALIZADAS EN EL MAPA DE FUENTES"
|
||||
},
|
||||
"NO PREVIEW": "SIN VISTA PREVIA",
|
||||
"SOURCE LINK": "ENLACE DE LA FUENTE",
|
||||
"{publisher} / OPEN": "{publisher} / ABRIR",
|
||||
"Bhote Koshi flood reconstruction controls": "Controles de la reconstrucción de la inundación del Bhote Koshi",
|
||||
"EVENT RECONSTRUCTION · 26 AUG 2026": "RECONSTRUCCIÓN DEL EVENTO · 26 AGO 2026",
|
||||
"BHOTE KOSHI OUTBURST FLOOD": "INUNDACIÓN POR DESBORDE DEL BHOTE KOSHI",
|
||||
"Close event layer": "Cerrar la capa del evento",
|
||||
"OBSERVED IMAGERY": "IMÁGENES OBSERVADAS",
|
||||
"SCHEMATIC CORRIDOR": "CORREDOR ESQUEMÁTICO",
|
||||
"2021 HISTORICAL REFERENCE / 2026 POST-EVENT": "REFERENCIA HISTÓRICA 2021 / POSTERIOR AL EVENTO 2026",
|
||||
"Historical reference and post-event image split": "División entre la imagen de referencia histórica y la posterior al evento",
|
||||
"RECONSTRUCTION CLOCK": "RELOJ DE LA RECONSTRUCCIÓN",
|
||||
"Schematic downstream progression": "Avance esquemático aguas abajo",
|
||||
"Story beat navigation": "Navegación por momentos del relato",
|
||||
"Previous story beat": "Momento anterior del relato",
|
||||
"CAUSE": "CAUSA",
|
||||
"CAPTURE TIME UNVERIFIED": "HORA DE CAPTURA SIN VERIFICAR",
|
||||
"Next story beat": "Siguiente momento del relato",
|
||||
"▶ PLAY": "▶ REPRODUCIR",
|
||||
"Play the next shot and continue through this scene": "Reproducir el siguiente plano y continuar con esta escena",
|
||||
"▶ PLAY SCENE": "▶ REPRODUCIR ESCENA",
|
||||
"◉ CINEMATIC": "◉ CINEMÁTICA",
|
||||
"↺ FULL STORY": "↺ HISTORIA COMPLETA",
|
||||
"↗ OPEN ORIGINAL": "↗ ABRIR ORIGINAL",
|
||||
"⌖ LOWER GORGE": "⌖ GARGANTA INFERIOR",
|
||||
"FIELD REPORTS · {count}": "INFORMES DE CAMPO · {count}",
|
||||
"GEOLOCATIONS · {credit}": "GEOLOCALIZACIONES · {credit}",
|
||||
"↗ OPEN PUBLIC MAP": "↗ ABRIR MAPA PÚBLICO",
|
||||
"Clouds are preserved from the source imagery.": "Las nubes se conservan de las imágenes de origen.",
|
||||
"Show the Nepal context callout without a border or country fill": "Muestra la etiqueta de contexto de Nepal sin frontera ni relleno del país",
|
||||
"Introduce Nepal, draw its border, then reveal the Bhote Koshi incident": "Presenta Nepal, dibuja su frontera y luego revela el incidente del Bhote Koshi",
|
||||
"CITIES": "CIUDADES",
|
||||
"Keep the regional incident context and introduce nearby city labels": "Mantiene el contexto regional del incidente y presenta las etiquetas de las ciudades cercanas",
|
||||
"PLACES": "LUGARES",
|
||||
"Introduce the reconstruction scene key places from trigger to downstream": "Presenta los lugares clave de la escena de reconstrucción, desde el desencadenante hasta aguas abajo",
|
||||
"PATH": "RECORRIDO",
|
||||
"Animate the sourced cyan river-centerline route to Trishuli Bazaar": "Anima la ruta cian documentada por el eje del río hasta Trishuli Bazaar",
|
||||
"OVERVIEW": "VISTA GENERAL",
|
||||
"Keep the completed flood path and location labels visible": "Mantiene visibles el recorrido completo de la inundación y las etiquetas de lugares",
|
||||
"RECORD": "REGISTRO",
|
||||
"Reveal the trigger record while making a slow camera approach": "Revela el registro del desencadenante con una aproximación lenta de la cámara",
|
||||
"BHOTE KOSHI FLOOD INCIDENT": "INCIDENTE DE INUNDACIÓN DEL BHOTE KOSHI",
|
||||
"NEPAL · 26 AUG 2026": "NEPAL · 26 AGO 2026",
|
||||
"USGS INITIAL REPORT": "INFORME INICIAL DEL USGS",
|
||||
"26 AUG 2026 · 08:37 NPT": "26 AGO 2026 · 08:37 NPT",
|
||||
"28.2710° N · 85.5150° E": "28.2710° N · 85.5150° E",
|
||||
"INITIAL M4.4 · RECLASSIFIED M5.2 LANDSLIDE": "INICIAL M4.4 · RECLASIFICADO COMO DESLIZAMIENTO M5.2",
|
||||
"USGS EVENT us7000tbwb · OPEN SOURCE": "EVENTO USGS us7000tbwb · ABRIR FUENTE",
|
||||
"flight": "vuelo",
|
||||
"military flight": "vuelo militar",
|
||||
"satellite": "satélite",
|
||||
"VOICE AUTH · CHATGPT OAUTH": "AUTENTICACIÓN DE VOZ · OAUTH DE CHATGPT",
|
||||
"VOICE AUTH · API KEY": "AUTENTICACIÓN DE VOZ · CLAVE DE API",
|
||||
"USE API KEY": "USAR CLAVE DE API",
|
||||
"USE CHATGPT OAUTH": "USAR OAUTH DE CHATGPT",
|
||||
"Use OPENAI_API_KEY for the next cloud voice session": "Usar OPENAI_API_KEY en la próxima sesión de voz en la nube",
|
||||
"Use the signed-in local ChatGPT/Codex OAuth session for the next cloud voice session": "Usar la sesión OAuth local de ChatGPT/Codex ya iniciada en la próxima sesión de voz en la nube",
|
||||
"Metered — a billing-enabled account": "De pago por uso — una cuenta con facturación activada",
|
||||
"Free key — register, paste, done": "Clave gratuita — regístrate, pégala y listo",
|
||||
"browser-side": "en el navegador",
|
||||
"This key runs in the browser by design — restrict it at the provider (see SECURITY.md)": "Esta clave funciona en el navegador por diseño — restríngela en el proveedor (consulta SECURITY.md)",
|
||||
"configured externally": "configurada externamente",
|
||||
"Supplied by your environment, Keychain, or launcher — change it where it was set": "Proporcionada por tu entorno, Keychain o el lanzador — cámbiala donde se definió",
|
||||
"MANAGE ↗": "GESTIONAR ↗",
|
||||
"GET KEY ↗": "OBTENER CLAVE ↗",
|
||||
"{envVar} saved — paste to replace": "{envVar} guardada — pega otra para reemplazarla",
|
||||
"paste {envVar}": "pega {envVar}",
|
||||
"Project keys: {status}": "Claves del proyecto: {status}",
|
||||
"Mapped camera locations": "Ubicaciones de cámaras cartografiadas",
|
||||
"SHOW NEAREST": "MOSTRAR LA MÁS CERCANA",
|
||||
"Stop following the current object before navigating to a camera": "Deja de seguir el objeto actual antes de ir a una cámara",
|
||||
"Move to the nearest loaded camera and show its details": "Ir a la cámara cargada más cercana y mostrar sus detalles",
|
||||
"Camera badges": "Iconos de cámaras",
|
||||
"Cyan cameras turn coral when selected. Wedges illustrate mapped direction, not measured coverage. Nearby cameras may be outside the screen.": "Las cámaras cian se vuelven coral al seleccionarlas. Las cuñas ilustran la dirección cartografiada, no la cobertura medida. Las cámaras cercanas pueden estar fuera de la pantalla.",
|
||||
"{count} nearby · {onScreen} on screen": {
|
||||
"one": "{count} cercana · {onScreen} en pantalla",
|
||||
"many": "{count} de cámaras cercanas · {onScreen} en pantalla",
|
||||
"other": "{count} cercanas · {onScreen} en pantalla"
|
||||
},
|
||||
"{count} nearby": {
|
||||
"one": "{count} cercana",
|
||||
"many": "{count} de cámaras cercanas",
|
||||
"other": "{count} cercanas"
|
||||
},
|
||||
"retrying mapped ALPR cameras": "reintentando cámaras ALPR cartografiadas",
|
||||
"loading mapped ALPR cameras": "cargando cámaras ALPR cartografiadas",
|
||||
"No ALPR data for this area — US and Canada only": "Sin datos ALPR para esta zona — solo EE. UU. y Canadá",
|
||||
"Zoom in to load mapped cameras": "Haz zoom para cargar las cámaras cartografiadas",
|
||||
"Showing cached locations": "Mostrando ubicaciones en caché",
|
||||
"Coverage limited — zoom in": "Cobertura limitada — haz zoom",
|
||||
"No ALPR data for this area": "Sin datos ALPR para esta zona",
|
||||
"None on screen — nearby cameras are outside the view": "Ninguna en pantalla — las cámaras cercanas están fuera de la vista",
|
||||
"ALPR camera": "Cámara ALPR",
|
||||
"OpenStreetMap · community mapped": "OpenStreetMap · cartografiado por la comunidad",
|
||||
"Atlantic and eastern/central North Pacific; not worldwide cyclone coverage.": "Atlántico y Pacífico Norte oriental/central; no es una cobertura mundial de ciclones.",
|
||||
"Potential tropical cyclone": "Posible ciclón tropical",
|
||||
"Hurricane": "Huracán",
|
||||
"Tropical storm": "Tormenta tropical",
|
||||
"Tropical depression": "Depresión tropical",
|
||||
"Subtropical storm": "Tormenta subtropical",
|
||||
"Subtropical depression": "Depresión subtropical",
|
||||
"Track and cone match this advisory": "La trayectoria y el cono coinciden con este aviso",
|
||||
"Track/cone awaiting advisory {number}": "Trayectoria/cono a la espera del aviso {number}",
|
||||
"Track/cone unavailable": "Trayectoria/cono no disponible",
|
||||
"Cached advisory · stale source": "Aviso en caché · fuente desactualizada",
|
||||
"Loading advisories…": "Cargando avisos…",
|
||||
"{count} active storms": {
|
||||
"one": "{count} ciclón activo",
|
||||
"many": "{count} de ciclones activos",
|
||||
"other": "{count} ciclones activos"
|
||||
},
|
||||
"Position as of {time}": "Posición a fecha de {time}",
|
||||
"Maximum sustained wind: {wind} · Pressure: {pressure}": "Viento máximo sostenido: {wind} · Presión: {pressure}",
|
||||
"{name} · {classification} · Advisory {number} · issued {time}": "{name} · {classification} · Aviso {number} · emitido {time}",
|
||||
"No active NHC/CPHC systems": "Sin sistemas activos del NHC/CPHC",
|
||||
"Advisories unavailable": "Avisos no disponibles",
|
||||
"Cyclones · NHC / CPHC": "Ciclones · NHC / CPHC",
|
||||
"Atlantic · E/C Pacific": "Atlántico · Pacífico E/C",
|
||||
"{storms} · {name} selected": "{storms} · {name} seleccionado",
|
||||
"Official advisory ↗": "Aviso oficial ↗",
|
||||
"Active NHC and CPHC cyclone advisories": "Avisos de ciclones activos del NHC y el CPHC",
|
||||
"Wind unavailable": "Viento no disponible",
|
||||
"Advisory center / forecast track": "Centro del aviso / trayectoria pronosticada",
|
||||
"Center-track uncertainty cone": "Cono de incertidumbre de la trayectoria del centro",
|
||||
"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.": "Selecciona un ciclón en el mapa, o elige uno en la lista para seleccionarlo y mover la cámara. Haz clic en un espacio vacío del mapa para borrar la selección. Contexto de avisos NOAA NHC/CPHC. El cono describe la incertidumbre de la trayectoria pronosticada del centro, no el tamaño del ciclón ni toda el área de peligro. Las etiquetas de los puntos de pronóstico son horas de anticipación de la fuente, no horas calculadas a partir de la emisión del aviso. La geometría sigue la superficie; la altura no es altitud meteorológica. Consulta el aviso oficial.",
|
||||
"Directions": "Indicaciones",
|
||||
"{mode} — reroute for {modeLower}": "{mode} — recalcular la ruta {modeLower}",
|
||||
"CLICK MAP": "CLIC EN EL MAPA",
|
||||
"SET A": "FIJAR A",
|
||||
"Click a spot on the globe to place A (click again to cancel)": "Haz clic en un punto del globo para colocar A (vuelve a hacer clic para cancelar)",
|
||||
"Then click the globe to place the start": "Luego haz clic en el globo para colocar el inicio",
|
||||
"SET B": "FIJAR B",
|
||||
"Click a spot on the globe to place B (click again to cancel)": "Haz clic en un punto del globo para colocar B (vuelve a hacer clic para cancelar)",
|
||||
"Then click the globe to place the destination": "Luego haz clic en el globo para colocar el destino",
|
||||
"Swap A and B": "Intercambiar A y B",
|
||||
"FLY ···": "VOLAR ···",
|
||||
"FLYING": "VOLANDO",
|
||||
"FLY": "VOLAR",
|
||||
"Fly the camera along the route": "Volar con la cámara a lo largo de la ruta",
|
||||
"Place A and B first": "Coloca primero A y B",
|
||||
"Remove the route and both markers": "Eliminar la ruta y ambos marcadores",
|
||||
"press Escape twice to leave Draw": "pulsa Escape dos veces para salir de Dibujo",
|
||||
"{name} is active — turn {name} off, then set {endpoint}": "{name} está activo — desactiva {name} y luego fija {endpoint}",
|
||||
"{name} is active — {leave}, then set {endpoint}": "{name} está activo — {leave} y luego fija {endpoint}",
|
||||
"Only the first {count} turns of this route are shown": "Solo se muestran los primeros {count} giros de esta ruta",
|
||||
"Turn-by-turn directions": "Indicaciones paso a paso",
|
||||
"OSM routing": "Rutas de OSM",
|
||||
"DRIVE": "COCHE",
|
||||
"Drive": "En coche",
|
||||
"Another map tool is using clicks — close it, then SET A again": "Otra herramienta del mapa está usando los clics — ciérrala y vuelve a pulsar FIJAR A",
|
||||
"WALK": "A PIE",
|
||||
"Walk": "A pie",
|
||||
"Routing…": "Calculando ruta…",
|
||||
"BIKE": "BICI",
|
||||
"Bike": "En bici",
|
||||
"No route found": "No se encontró ninguna ruta",
|
||||
"{distance} · {duration} · {mode} · first {count} turns": "{distance} · {duration} · {mode} · primeros {count} giros",
|
||||
"{distance} · {duration} · {mode}": "{distance} · {duration} · {mode}",
|
||||
"Click the globe to place {endpoint}": "Haz clic en el globo para colocar {endpoint}",
|
||||
"SET B, then click the globe": "Pulsa FIJAR B y luego haz clic en el globo",
|
||||
"SET A, then click the globe": "Pulsa FIJAR A y luego haz clic en el globo",
|
||||
"Step {step} of {total} · then {leg}": "Paso {step} de {total} · luego {leg}",
|
||||
"Step {step} of {total}": "Paso {step} de {total}",
|
||||
"Then: {instruction}": "Luego: {instruction}",
|
||||
"No route found between A and B": "No se encontró ninguna ruta entre A y B",
|
||||
"Routing failed: {error}": "Falló el cálculo de la ruta: {error}",
|
||||
"Routing timed out": "El cálculo de la ruta agotó el tiempo de espera",
|
||||
"Routing unavailable — no response from the routing service": "Rutas no disponibles — sin respuesta del servicio de rutas",
|
||||
"Routing unavailable": "Rutas no disponibles",
|
||||
"Fire · FRP {frp} MW": "Incendio · FRP {frp} MW",
|
||||
"Not contained or unknown": "Sin contener o desconocido",
|
||||
"Under 50% contained": "Contenido por debajo del 50%",
|
||||
"50–99% contained": "Contenido al 50–99%",
|
||||
"Fully contained": "Totalmente contenido",
|
||||
"Colour shows reported containment. Perimeters are simplified to about 100 m.": "El color indica la contención notificada. Los perímetros se simplifican a unos 100 m.",
|
||||
"MORE DETAIL · 30 m": "MÁS DETALLE · 30 m",
|
||||
"Sentinel-2 and Landsat via HLS, 30 m, every 2–6 days": "Sentinel-2 y Landsat vía HLS, 30 m, cada 2–6 días",
|
||||
"DAILY OVERVIEW · 250 m": "VISTA GENERAL DIARIA · 250 m",
|
||||
"VIIRS daily mosaic, 250 m, follows broad changes; little detail under 25 km": "Mosaico diario de VIIRS, 250 m, sigue los cambios amplios; poco detalle por debajo de 25 km",
|
||||
"Catalog truncated · a newer clear day may exist": "Catálogo truncado · puede existir un día despejado más reciente",
|
||||
"{label} catalog unavailable · {detail}": "Catálogo de {label} no disponible · {detail}",
|
||||
"Daily overview for today may still be empty until the pass": "La vista general diaria de hoy puede seguir vacía hasta el paso del satélite",
|
||||
"Sentinel-2": "Sentinel-2",
|
||||
"Landsat 8/9": "Landsat 8/9",
|
||||
"Daily overview": "Vista general diaria",
|
||||
"No frame within {gap} min of {time}": "Ningún fotograma a menos de {gap} min de {time}",
|
||||
"No frame within {gap} h of {time}": "Ningún fotograma a menos de {gap} h de {time}",
|
||||
"{hours}h {minutes}m ago": "hace {hours} h {minutes} min",
|
||||
"{minutes}m ago": "hace {minutes} min",
|
||||
"{state} · {time} · {lag}": "{state} · {time} · {lag}",
|
||||
"Observed": "Observado",
|
||||
"History": "Historial",
|
||||
"Observation unavailable": "Observación no disponible",
|
||||
"Waiting for observation": "Esperando observación",
|
||||
"{detail} · synced": "{detail} · sincronizado",
|
||||
"{detail} · nearest": "{detail} · más cercano",
|
||||
"RADAR REFLECTIVITY · dBZ": "REFLECTIVIDAD DE RADAR · dBZ",
|
||||
"LIGHTNING DENSITY · 15 min accumulation": "DENSIDAD DE RAYOS · acumulación de 15 min",
|
||||
"GLOBAL INFRARED · hourly": "INFRARROJO GLOBAL · cada hora",
|
||||
"GOES INFRARED · ~5 min": "INFRARROJO GOES · ~5 min",
|
||||
"{state}: {time}": "{state}: {time}",
|
||||
"Latest observation": "Última observación",
|
||||
"frame {index}/{total}": "fotograma {index}/{total}",
|
||||
"loading": "cargando",
|
||||
"Observation: unavailable": "Observación: no disponible",
|
||||
"Contiguous US · gaps ≠ no rain": "EE. UU. contiguos · huecos ≠ sin lluvia",
|
||||
"Americas + Pacific · not individual strikes": "América + Pacífico · no son rayos individuales",
|
||||
"Color: strikes/km²/min ×10³": "Color: rayos/km²/min ×10³",
|
||||
"60°S–60°N · typically 2–3 h delayed": "60°S–60°N · normalmente con 2–3 h de retraso",
|
||||
"North America · infrared imagery": "Norteamérica · imágenes infrarrojas",
|
||||
"Reduced motion · history playback unavailable": "Movimiento reducido · reproducción del historial no disponible",
|
||||
"Reduced motion · manual history available": "Movimiento reducido · historial manual disponible",
|
||||
"Rain radar · US": "Radar de lluvia · EE. UU.",
|
||||
"Lightning density · 15 min": "Densidad de rayos · 15 min",
|
||||
"Satellite clouds": "Nubes por satélite",
|
||||
"Americas + Pacific": "América + Pacífico",
|
||||
"Global · 60°S–60°N": "Global · 60°S–60°N",
|
||||
"North America": "Norteamérica",
|
||||
"Source observations delayed": "Observaciones de la fuente con retraso",
|
||||
"Stale source": "Fuente desactualizada",
|
||||
"Loading next frame…": "Cargando el siguiente fotograma…",
|
||||
"Map center outside coverage": "Centro del mapa fuera de cobertura",
|
||||
"strikes/km²/min ×10³": "rayos/km²/min ×10³",
|
||||
"N. America": "Norteamérica",
|
||||
"Global": "Global",
|
||||
"Hourly global mosaic; usually 2–3 hours delayed": "Mosaico global horario; normalmente con 2–3 horas de retraso",
|
||||
"GOES regional clouds; approximately 5-minute updates": "Nubes regionales de GOES; actualizaciones cada 5 minutos aproximadamente",
|
||||
"Clouds only": "Solo nubes",
|
||||
"Dim everything but the bright, cold cloud tops; a brightness filter, not a cloud mask": "Atenúa todo excepto las cimas de nubes brillantes y frías; un filtro de brillo, no una máscara de nubes",
|
||||
"Full": "Completa",
|
||||
"The complete infrared image at the chosen opacity": "La imagen infrarroja completa con la opacidad elegida",
|
||||
"Soft": "Suave",
|
||||
"Vivid": "Intensa",
|
||||
"Image opacity; does not alter the observed values": "Opacidad de la imagen; no altera los valores observados",
|
||||
"View US radar": "Ver radar de EE. UU.",
|
||||
"View Americas & Pacific": "Ver América y Pacífico",
|
||||
"View coverage": "Ver cobertura",
|
||||
"{value} strikes/km²/min ×10³ (15-minute density)": "{value} rayos/km²/min ×10³ (densidad de 15 minutos)",
|
||||
"{value} dBZ radar reflectivity": "{value} dBZ de reflectividad de radar",
|
||||
"STALE · cached source metadata": "DESACTUALIZADO · metadatos de la fuente en caché",
|
||||
"Map center is outside source coverage": "El centro del mapa está fuera de la cobertura de la fuente",
|
||||
"NOAA/NWS 15-minute lightning density derived from Vaisala NLDN/GLD360. Coverage 110°E across the Pacific/Americas to 0°, 25°S–80°N. Not a live strike count, global coverage or a safety warning.": "Densidad de rayos de 15 minutos de NOAA/NWS derivada de Vaisala NLDN/GLD360. Cobertura desde 110°E, a través del Pacífico/América, hasta 0°, 25°S–80°N. No es un recuento de rayos en vivo, ni una cobertura global, ni un aviso de seguridad.",
|
||||
"NOAA MRMS radar echoes indicate precipitation patterns, not rain rate, a storm warning or a future forecast. Native source approximately 1 km; display is limited to level 6. Frames use exact advertised observation times.": "Los ecos de radar NOAA MRMS indican patrones de precipitación, no la intensidad de lluvia, ni un aviso de tormenta, ni un pronóstico futuro. Fuente nativa de aproximadamente 1 km; la visualización se limita al nivel 6. Los fotogramas usan las horas de observación exactas anunciadas.",
|
||||
"GOES-19/18 longwave infrared Band 14 regional; NESDIS global longwave mosaic. Clouds only dims everything but bright, cold cloud tops; a brightness filter, not a cloud mask. Coverage and freshness differ by region.": "Infrarrojo de onda larga GOES-19/18, banda 14, regional; mosaico global de onda larga de NESDIS. Solo nubes atenúa todo excepto las cimas de nubes brillantes y frías; un filtro de brillo, no una máscara de nubes. La cobertura y la actualidad varían según la región.",
|
||||
"REGION": "REGIÓN",
|
||||
"IMAGE": "IMAGEN",
|
||||
"OPACITY": "OPACIDAD",
|
||||
"Forecast · does not follow history": "Pronóstico · no sigue el historial",
|
||||
"Wind motion": "Movimiento del viento",
|
||||
"Wind speed": "Velocidad del viento",
|
||||
"Sea-level pressure": "Presión a nivel del mar",
|
||||
"Air temperature · 2 m": "Temperatura del aire · 2 m",
|
||||
"Global · 1° grid": "Global · cuadrícula de 1°",
|
||||
"{model} forecast · {time}": "Pronóstico {model} · {time}",
|
||||
"Loading forecast": "Cargando pronóstico",
|
||||
"Preparing flow": "Preparando flujo",
|
||||
"Selected field unavailable": "Campo seleccionado no disponible",
|
||||
"Cached forecast · stale": "Pronóstico en caché · desactualizado",
|
||||
"ECMWF IFS surface forecast": "Pronóstico de superficie ECMWF IFS",
|
||||
"NOAA GFS surface forecast": "Pronóstico de superficie NOAA GFS",
|
||||
"Reduced motion": "Movimiento reducido",
|
||||
"Pause visual flow; forecast time does not advance with animation": "Pausa el flujo visual; la hora del pronóstico no avanza con la animación",
|
||||
"None": "Ninguno",
|
||||
"Speed": "Velocidad",
|
||||
"Pressure": "Presión",
|
||||
"Temperature": "Temperatura",
|
||||
"Wind trails with no field shading": "Trazas de viento sin sombreado del campo",
|
||||
"How hard the surface wind is blowing": "Con qué fuerza sopla el viento en superficie",
|
||||
"Sea-level air pressure: broad highs and lows": "Presión del aire a nivel del mar: altas y bajas amplias",
|
||||
"Air temperature two meters above the surface": "Temperatura del aire a dos metros sobre la superficie",
|
||||
"Wind speed units": "Unidades de velocidad del viento",
|
||||
"Read wind at map center": "Leer el viento en el centro del mapa",
|
||||
"Read the forecast at the center of the map without changing selection": "Leer el pronóstico en el centro del mapa sin cambiar la selección",
|
||||
"{model} forecast · {field} ({unit})": "Pronóstico {model} · {field} ({unit})",
|
||||
"Valid: {time}": "Válido: {time}",
|
||||
"preparing": "preparando",
|
||||
"Issued: {time}": "Emitido: {time}",
|
||||
"Selected field unavailable · wind remains visible": "Campo seleccionado no disponible · el viento sigue visible",
|
||||
"{error} · wind remains visible": "{error} · el viento sigue visible",
|
||||
"Surface wind at 10 m. Approximately 1° global grid. Curves follow the 10 m wind field, lifted 12 km for visibility; display height is not weather altitude. View lighting is for readability. Animation shows flow through one fixed forecast; it does not advance time. Color fields drape the globe basemap or the active photorealistic 3D Tiles.": "Viento en superficie a 10 m. Cuadrícula global de aproximadamente 1°. Las curvas siguen el campo de viento a 10 m, elevadas 12 km para que sean visibles; la altura mostrada no es altitud meteorológica. La iluminación de la vista es para facilitar la lectura. La animación muestra el flujo de un único pronóstico fijo; no avanza el tiempo. Los campos de color cubren el mapa base del globo o las 3D Tiles fotorrealistas activas.",
|
||||
"MODEL": "MODELO",
|
||||
"FIELD": "CAMPO",
|
||||
"UNITS": "UNIDADES",
|
||||
"MOTION": "MOVIMIENTO",
|
||||
"Forecast": "Pronóstico",
|
||||
"Interpolated model forecast on an approximately 1° grid. Broad weather patterns, not a street-level measurement.": "Pronóstico de modelo interpolado en una cuadrícula de aproximadamente 1°. Patrones meteorológicos generales, no una medición a pie de calle.",
|
||||
"No surface reading": "Sin lectura en superficie",
|
||||
"Aim the map center at Earth": "Apunta el centro del mapa a la Tierra",
|
||||
" · calm": " · calma",
|
||||
" from {direction}": " del {direction}",
|
||||
"WIND AT {coordinates}": "VIENTO EN {coordinates}",
|
||||
"{model} · valid {time}": "{model} · válido {time}",
|
||||
"Focusing {label}": "Enfocando {label}",
|
||||
"Source": "Fuente",
|
||||
"Could not leave scene: {scene}": "No se pudo salir de la escena: {scene}",
|
||||
"Seeked: {scene} / {shot}": "Saltado a: {scene} / {shot}",
|
||||
"Scene transition limit reached — load a shot to reset": "Límite de transiciones de escena alcanzado — carga un plano para restablecer",
|
||||
"Export failed: {error}": "La exportación falló: {error}",
|
||||
"Imported {file}": "Importado {file}",
|
||||
"Import failed: {error}": "La importación falló: {error}",
|
||||
"Import failed (could not read JSON file)": "La importación falló (no se pudo leer el archivo JSON)",
|
||||
"Layers refused: {layers}": "Capas rechazadas: {layers}",
|
||||
"Scene {n}": "Escena {n}",
|
||||
"Cannot update {scene}: shot inventory changed": "No se puede actualizar {scene}: el inventario de planos cambió",
|
||||
"Cannot update {scene}: evidence beats changed": "No se puede actualizar {scene}: los momentos de evidencia cambiaron",
|
||||
"Cannot update {scene}: shot bindings are incomplete": "No se puede actualizar {scene}: los vínculos de los planos están incompletos",
|
||||
"Updated {count} shots: {scene}": {
|
||||
"one": "{count} plano actualizado: {scene}",
|
||||
"many": "{count} de planos actualizados: {scene}",
|
||||
"other": "{count} planos actualizados: {scene}"
|
||||
},
|
||||
"Appended {count} shots: {scene}": {
|
||||
"one": "{count} plano añadido: {scene}",
|
||||
"many": "{count} de planos añadidos: {scene}",
|
||||
"other": "{count} planos añadidos: {scene}"
|
||||
},
|
||||
"Select a feature or use Tab and Enter to choose an action.": "Selecciona un elemento o usa Tab y Enter para elegir una acción.",
|
||||
"Selected feature: {feature}. Choose an action.": "Elemento seleccionado: {feature}. Elige una acción.",
|
||||
"Action complete": "Acción completada",
|
||||
"Action unavailable or cancelled": "Acción no disponible o cancelada",
|
||||
"Scene actions": "Acciones de escena",
|
||||
"Bhote Koshi Evidence Sequence": "Secuencia de evidencia del Bhote Koshi",
|
||||
"Nepal Flood Incident": "Incidente de inundación en Nepal",
|
||||
"{count} scenes": {
|
||||
"one": "{count} escena",
|
||||
"many": "{count} de escenas",
|
||||
"other": "{count} escenas"
|
||||
},
|
||||
"{count} shots": {
|
||||
"one": "{count} plano",
|
||||
"many": "{count} de planos",
|
||||
"other": "{count} planos"
|
||||
},
|
||||
"{count} data packs": {
|
||||
"one": "{count} paquete de datos",
|
||||
"many": "{count} de paquetes de datos",
|
||||
"other": "{count} paquetes de datos"
|
||||
},
|
||||
"{scene} / {pack}: {status}. File: {path}. {attribution} · {license}": "{scene} / {pack}: {status}. Archivo: {path}. {attribution} · {license}",
|
||||
"Unavailable layers: {layers}": "Capas no disponibles: {layers}",
|
||||
"This scene uses registered content or linked media. Those external files are not included in a scene bundle. Their original notices still apply.": "Esta escena usa contenido registrado o medios vinculados. Esos archivos externos no se incluyen en un paquete de escena. Sus avisos originales siguen vigentes.",
|
||||
"{bytes} bundled bytes verified. Files stay in memory for this session. Reimport the bundle after reloading the app.": "{bytes} bytes empaquetados verificados. Los archivos permanecen en memoria durante esta sesión. Vuelve a importar el paquete después de recargar la app.",
|
||||
"Review scene import": "Revisar la importación de escena",
|
||||
"Nothing is loaded or changed until you apply this file.": "No se carga ni se cambia nada hasta que apliques este archivo.",
|
||||
"Ready to import": "Listo para importar",
|
||||
"Apply replaces the current project. Export your current project first if you want to keep both.": "Aplicar reemplaza el proyecto actual. Exporta primero tu proyecto actual si quieres conservar ambos.",
|
||||
"Apply import": "Aplicar importación",
|
||||
"Import was not applied. The current project may have changed.": "La importación no se aplicó. Es posible que el proyecto actual haya cambiado.",
|
||||
"Select a scene and shot first": "Selecciona primero una escena y un plano",
|
||||
"Edit scene details": "Editar los detalles de la escena",
|
||||
"Camera positions use degrees and meters above the ellipsoid. Drafts are validated before they replace your saved scene.": "Las posiciones de cámara usan grados y metros sobre el elipsoide. Los borradores se validan antes de reemplazar tu escena guardada.",
|
||||
"Anchors and data packs": "Anclas y paquetes de datos",
|
||||
"Shot camera, timing, packs and actions": "Cámara, tiempos, paquetes y acciones del plano",
|
||||
"New anchor ID": "ID del ancla nueva",
|
||||
"Capture camera as anchor": "Capturar la cámara como ancla",
|
||||
"Anchor added to draft": "Ancla añadida al borrador",
|
||||
"Set move start to current camera": "Fijar el inicio del movimiento en la cámara actual",
|
||||
"Move added to draft; destination remains the shot camera": "Movimiento añadido al borrador; el destino sigue siendo la cámara del plano",
|
||||
"Use ordinary flight": "Usar vuelo normal",
|
||||
"Invalid shot JSON": "JSON de plano no válido",
|
||||
"Apply details": "Aplicar detalles",
|
||||
"Select a scene first": "Selecciona primero una escena",
|
||||
"Share selected scene": "Compartir la escena seleccionada",
|
||||
"Scene JSON preserves authored settings and attribution. It does not include asset files. Bundles include only pack files you choose here or previously imported bundle files.": "El JSON de escena conserva los ajustes de autor y la atribución. No incluye archivos de recursos. Los paquetes solo incluyen los archivos de paquete que elijas aquí o los archivos de paquetes importados antes.",
|
||||
"Download scene JSON": "Descargar JSON de escena",
|
||||
"Choose data-pack files": "Elegir archivos de paquete de datos",
|
||||
"Or choose a data-pack folder": "O elige una carpeta de paquete de datos",
|
||||
"Download asset bundle": "Descargar paquete de recursos",
|
||||
"Asset bundle downloaded": "Paquete de recursos descargado",
|
||||
"Could not complete this action. Check the file, references and selected assets.": "No se pudo completar esta acción. Revisa el archivo, las referencias y los recursos seleccionados.",
|
||||
"RTL-SDR sample stream stopped": "Flujo de muestras RTL-SDR detenido",
|
||||
"Switching to {mode}…": "Cambiando a {mode}…",
|
||||
"Browser audio is suspended; select FM again to resume it": "El audio del navegador está suspendido; selecciona FM de nuevo para reanudarlo",
|
||||
"RTL-SDR gain change failed": "No se pudo cambiar la ganancia del RTL-SDR",
|
||||
"Tuned to {mhz} MHz FM": "Sintonizado en {mhz} MHz FM",
|
||||
"FM signal found at {mhz} MHz": "Señal FM encontrada en {mhz} MHz",
|
||||
"No FM signal found": "No se encontró ninguna señal FM",
|
||||
"Seek stopped": "Búsqueda detenida",
|
||||
"Browser location is unavailable": "La ubicación del navegador no está disponible",
|
||||
"Receiver location was not granted": "No se concedió la ubicación del receptor",
|
||||
"RTL-SDR disconnected": "RTL-SDR desconectado",
|
||||
"No RTL-SDR device selected": "No hay ningún dispositivo RTL-SDR seleccionado",
|
||||
"WebUSB requires desktop Chrome or Edge on localhost/HTTPS": "WebUSB requiere Chrome o Edge de escritorio en localhost/HTTPS",
|
||||
"RTL-SDR access failed; close other SDR apps and check USB permissions": "No se pudo acceder al RTL-SDR; cierra otras apps de SDR y revisa los permisos USB",
|
||||
"RTL-SDR connection failed": "La conexión con el RTL-SDR falló",
|
||||
"Receiving 1090 MHz ADS-B": "Recibiendo ADS-B en 1090 MHz",
|
||||
"Receiving broadcast FM": "Recibiendo FM de radiodifusión",
|
||||
"Connect an RTL-SDR to begin.": "Conecta un RTL-SDR para empezar.",
|
||||
"WebUSB is unavailable in this browser": "WebUSB no está disponible en este navegador",
|
||||
"SDR decoder worker failed": "El worker del decodificador SDR falló",
|
||||
"WebUSB requires desktop Chrome or Edge": "WebUSB requiere Chrome o Edge de escritorio",
|
||||
"Waiting for an RTL-SDR device…": "Esperando un dispositivo RTL-SDR…",
|
||||
"Browser audio is suspended; press Connect again to resume it": "El audio del navegador está suspendido; pulsa Conectar de nuevo para reanudarlo",
|
||||
"Return map to straight-down view": "Volver el mapa a la vista cenital",
|
||||
"Tilt map to oblique view": "Inclinar el mapa a una vista oblicua",
|
||||
"Reset map to north up. Current heading {heading} degrees": "Orientar el mapa al norte. Rumbo actual: {heading} grados",
|
||||
"HDG": "HDG",
|
||||
"PITCH": "PITCH",
|
||||
"FOV": "FOV",
|
||||
"RANGE": "RANGE",
|
||||
"HGT": "HGT",
|
||||
"{value} KM/H": "{value} KM/H",
|
||||
"NORMAL": "NORMAL",
|
||||
"ANIME": "ANIME",
|
||||
"NOIR": "NOIR",
|
||||
"Rendered globe frames per second · toggle with `": "Fotogramas por segundo renderizados del globo · alterna con `",
|
||||
"Utilities": "Utilidades",
|
||||
"Other layers": "Otras capas",
|
||||
"Movement": "Movimiento",
|
||||
"Infrastructure": "Infraestructura",
|
||||
"Events": "Eventos",
|
||||
"Weather": "Clima",
|
||||
"LOCATED": "UBICADO",
|
||||
"LOCATE": "UBICAR",
|
||||
"IDLE": "INACTIVO",
|
||||
"TUNING": "SINTONIZANDO",
|
||||
"STREAMING": "TRANSMITIENDO",
|
||||
"UNSUPPORTED": "NO COMPATIBLE",
|
||||
"waiting for IQ": "esperando IQ",
|
||||
"{count} messages": {
|
||||
"one": "{count} mensaje",
|
||||
"many": "{count} de mensajes",
|
||||
"other": "{count} mensajes"
|
||||
},
|
||||
"DSP {count} blocks": {
|
||||
"one": "DSP {count} bloque",
|
||||
"many": "DSP {count} de bloques",
|
||||
"other": "DSP {count} bloques"
|
||||
},
|
||||
"DSP waiting": "DSP en espera",
|
||||
"{level} dBFS audio": "audio {level} dBFS",
|
||||
"audio signal --": "señal de audio --",
|
||||
"audio {state}": "audio {state}",
|
||||
"{count} HEARD": {
|
||||
"one": "{count} OÍDA",
|
||||
"many": "{count} DE OÍDAS",
|
||||
"other": "{count} OÍDAS"
|
||||
},
|
||||
"DISCONNECT": "DESCONECTAR",
|
||||
"CONNECT": "CONECTAR",
|
||||
"LOCATING…": "UBICANDO…",
|
||||
"Drag to resize · double-click the header to snap back": "Arrastra para cambiar el tamaño · doble clic en el encabezado para restaurarlo",
|
||||
"CH {index} / {total}": "CANAL {index} / {total}",
|
||||
"Station unavailable after directory refresh": "Emisora no disponible tras actualizar el directorio",
|
||||
"Clear reading": "Borrar lectura",
|
||||
"Earlier observation": "Observación anterior",
|
||||
"Observed history": "Historial observado",
|
||||
"Later observation": "Observación posterior",
|
||||
"Latest": "Último",
|
||||
"Newest frame per product": "Fotograma más reciente por producto",
|
||||
"Replay observed history": "Reproducir el historial observado",
|
||||
"S on another day replaces it · × unpins": "S en otro día lo reemplaza · × desfija",
|
||||
"← → preview · S shows the focused day": "← → vista previa · S muestra el día enfocado",
|
||||
"Recent imagery": "Imágenes recientes",
|
||||
"ZOOM IN": "ACERCAR",
|
||||
"Fly in until the view fits the 1,000 km limit": "Acércate hasta que la vista quepa en el límite de 1000 km",
|
||||
"Imagery days, newest first · arrows move, S or A and B pin": "Días de imágenes, el más reciente primero · las flechas mueven, S o A y B fijan",
|
||||
"On the map": "En el mapa",
|
||||
"Mode": "Modo",
|
||||
"Imagery on Esri · Google 3D returns when cleared": "Imágenes sobre Esri · Google 3D vuelve al borrarlas",
|
||||
"Imagery opacity": "Opacidad de las imágenes",
|
||||
"SWAP": "INTERCAMBIAR",
|
||||
"VS BASEMAP": "VS MAPA BASE",
|
||||
"Trade the two sides of the divider": "Intercambiar los dos lados del divisor",
|
||||
"A / B": "A / B",
|
||||
"Newest low-cloud day for this box · scene cloud, not box cloud": "Día más reciente con pocas nubes para este recuadro · nubes de la escena, no del recuadro",
|
||||
"Newest day covering this box · cloudier than 20%": "Día más reciente que cubre este recuadro · más de un 20% de nubes",
|
||||
"SELECT BOX": "SELECCIONAR RECUADRO",
|
||||
"Drag a box on the map (Esc cancels)": "Arrastra un recuadro en el mapa (Esc cancela)",
|
||||
"Newest day with imagery for this box · partial coverage": "Día más reciente con imágenes para este recuadro · cobertura parcial",
|
||||
"USE VIEW": "USAR VISTA",
|
||||
"Use the current view as the box": "Usar la vista actual como recuadro",
|
||||
"Newest daily overview for this box": "Vista general diaria más reciente para este recuadro",
|
||||
"Forget the box and its images": "Olvidar el recuadro y sus imágenes",
|
||||
"newest clear day": "día despejado más reciente",
|
||||
"newest day (cloudy)": "día más reciente (nublado)",
|
||||
"newest day (partial coverage)": "día más reciente (cobertura parcial)",
|
||||
"START HERE": "EMPIEZA AQUÍ",
|
||||
"newest overview": "vista general más reciente",
|
||||
"No imagery": "Sin imágenes",
|
||||
"Checking": "Comprobando",
|
||||
"Loading": "Cargando",
|
||||
"PREVIEW": "VISTA PREVIA",
|
||||
"Newest day with imagery for this box": "Día más reciente con imágenes para este recuadro",
|
||||
"SHOW": "MOSTRAR",
|
||||
"Pin {day} as {slot}": "Fijar {day} como {slot}",
|
||||
"Show {day}": "Mostrar {day}",
|
||||
"pinned {slot}": "fijado en {slot}",
|
||||
"shown": "mostrado",
|
||||
"preview": "vista previa",
|
||||
"start here": "empieza aquí",
|
||||
"No box": "Sin recuadro",
|
||||
"Searching": "Buscando",
|
||||
"Every day is empty here": "Todos los días están vacíos aquí",
|
||||
"Sources off": "Fuentes desactivadas",
|
||||
"VS": "VS",
|
||||
"Not set": "Sin definir",
|
||||
"Basemap": "Mapa base",
|
||||
"Source off": "Fuente desactivada",
|
||||
"Unpin {slot}": "Desfijar {slot}",
|
||||
"Unpin the image": "Desfijar la imagen",
|
||||
"EXPORT {slot}": "EXPORTAR {slot}",
|
||||
"EXPORT": "EXPORTAR",
|
||||
"Download {slot} as a PNG": "Descargar {slot} como PNG",
|
||||
"Download the image as a PNG": "Descargar la imagen como PNG",
|
||||
"Box {width} × {height} km": "Recuadro de {width} × {height} km",
|
||||
"no imagery": "sin imágenes",
|
||||
"Acquired {time}": "Adquirida {time}",
|
||||
"cloud unknown": "nubosidad desconocida",
|
||||
"Coverage {coverage}": "Cobertura {coverage}",
|
||||
"unknown": "desconocida",
|
||||
"START HERE · {reason}": "EMPIEZA AQUÍ · {reason}",
|
||||
"Daily overview shows little detail in a box this small": "La vista general diaria muestra poco detalle en un recuadro tan pequeño",
|
||||
"{percent}% cloud": "{percent}% de nubes",
|
||||
"Imagery: NASA GIBS and Worldview · HLS (Sentinel-2, Landsat 8/9) and VIIRS": "Imágenes: NASA GIBS y Worldview · HLS (Sentinel-2, Landsat 8/9) y VIIRS",
|
||||
"{min}–{max}% cloud": "{min}–{max}% de nubes",
|
||||
"Details": "Detalles",
|
||||
"Times, coverage, sources": "Horas, cobertura, fuentes",
|
||||
"HIDE EMPTY DAYS": "OCULTAR DÍAS VACÍOS",
|
||||
"SHOW EMPTY DAYS · {count}": "MOSTRAR DÍAS VACÍOS · {count}",
|
||||
"Days the probe found empty in this box": "Días que el sondeo encontró vacíos en este recuadro",
|
||||
"NO BOX": "SIN RECUADRO",
|
||||
"image": "imagen",
|
||||
"BASEMAP": "MAPA BASE",
|
||||
"The basemap": "El mapa base",
|
||||
"basemap": "mapa base",
|
||||
"SEARCHING": "BUSCANDO",
|
||||
"{count} DAYS": {
|
||||
"one": "{count} DÍA",
|
||||
"many": "{count} DE DÍAS",
|
||||
"other": "{count} DÍAS"
|
||||
},
|
||||
"Recent imagery against the basemap divider": "Divisor de imágenes recientes frente al mapa base",
|
||||
"Recent imagery A and B divider": "Divisor A y B de imágenes recientes",
|
||||
"{before} {left} percent, {after} {right} percent": "{before} {left} por ciento, {after} {right} por ciento",
|
||||
"Zoom in or draw a smaller box": "Acércate o dibuja un recuadro más pequeño",
|
||||
"Select a box or use the view": "Selecciona un recuadro o usa la vista",
|
||||
"Searching the last 30 days": "Buscando en los últimos 30 días",
|
||||
"No days to show for this box": "No hay días que mostrar para este recuadro",
|
||||
"Drag the divider · SWAP trades sides": "Arrastra el divisor · INTERCAMBIAR cambia los lados",
|
||||
"Export failed · {error}": "La exportación falló · {error}",
|
||||
"← → preview · A or B pins the focused day": "← → vista previa · A o B fija el día enfocado",
|
||||
"← → preview the other side · A or B pins it": "← → vista previa del otro lado · A o B lo fija",
|
||||
"LOAD": "CARGAR",
|
||||
"No shots yet. Use CAPTURE SHOT to save current look.": "Aún no hay planos. Usa CAPTURAR PLANO para guardar el aspecto actual.",
|
||||
"Double-click to rename": "Doble clic para cambiar el nombre",
|
||||
"Shot name": "Nombre del plano",
|
||||
"RETRYING ALPR CAMERAS": "REINTENTANDO CÁMARAS ALPR",
|
||||
"FETCHING ALPR CAMERAS": "OBTENIENDO CÁMARAS ALPR",
|
||||
"OVERPASS TEMPORARILY UNAVAILABLE": "OVERPASS TEMPORALMENTE NO DISPONIBLE",
|
||||
"Weather data by Open-Meteo.com": "Datos meteorológicos de Open-Meteo.com",
|
||||
"ESC EXIT": "ESC SALIR",
|
||||
"C TOGGLE": "C ALTERNAR",
|
||||
"SIG": "SIG",
|
||||
"NEWS": "NEWS",
|
||||
"LOCAL": "LOCAL",
|
||||
"Cockpit briefing pages": "Páginas de información de la cabina",
|
||||
"MM": "MM",
|
||||
"KTS": "KN",
|
||||
"FT": "FT",
|
||||
"FLT": "FLT",
|
||||
"MIL": "MIL",
|
||||
"SITE": "SITE",
|
||||
"WX": "WX",
|
||||
"DIRECTORY: RADIO BROWSER": "DIRECTORIO: RADIO BROWSER",
|
||||
"Use your location for faster ADS-B position decoding": "Usa tu ubicación para decodificar más rápido las posiciones ADS-B",
|
||||
"CHANGE DEVICE": "CAMBIAR DISPOSITIVO",
|
||||
"Choose a different RTL-SDR or receiver channel": "Elige otro RTL-SDR u otro canal del receptor",
|
||||
"TUNE": "SINTONIZAR",
|
||||
"◀ SEEK": "◀ BUSCAR",
|
||||
"SEEK ▶": "BUSCAR ▶",
|
||||
"Local RTL-SDR receiver": "Receptor RTL-SDR local",
|
||||
"RTL-SDR receiver mode": "Modo del receptor RTL-SDR",
|
||||
"Seek broadcast FM stations": "Buscar emisoras de FM",
|
||||
"Tune available internet radio stations": "Sintonizar las emisoras de radio por Internet disponibles",
|
||||
"Local SDR volume": "Volumen del SDR local",
|
||||
"GAIN": "GANANCIA",
|
||||
"FM MHZ": "FM MHZ",
|
||||
"AUTO": "AUTO",
|
||||
"Chrome/Edge · WebUSB · one local tuner shared by FM and the Local ADS-B layer.": "Chrome/Edge · WebUSB · un sintonizador local compartido por FM y la capa ADS-B local.",
|
||||
"RTL-SDR tuner gain for the current mode": "Ganancia del sintonizador RTL-SDR para el modo actual",
|
||||
"WEATHER": "CLIMA",
|
||||
"Active weather products": "Productos meteorológicos activos",
|
||||
"RECENT IMAGERY": "IMÁGENES RECIENTES",
|
||||
"Imagery days in the box": "Días de imágenes en el recuadro",
|
||||
"MSG/S": "MSG/S",
|
||||
"HEARD": "OÍDAS",
|
||||
"POSITIONED": "POSICIONADAS",
|
||||
"SDR VOL": "VOL. SDR",
|
||||
"LOCAL RTL-SDR": "RTL-SDR LOCAL",
|
||||
"Cyber sonar contact scanner": "Escáner sonar de contactos Cyber",
|
||||
"Sonar ring count": "Número de anillos del sonar",
|
||||
"Sonar ring range": "Alcance de los anillos del sonar",
|
||||
"Sonar intensity": "Intensidad del sonar",
|
||||
"Cyber contact and label opacity": "Opacidad de contactos y etiquetas Cyber",
|
||||
"Minimum opacity for map dots, icons, models, brackets, and labels between sonar passes": "Opacidad mínima de puntos, iconos, modelos, corchetes y etiquetas del mapa entre pasadas del sonar",
|
||||
"Sonar sector width": "Ancho del sector del sonar",
|
||||
"Rings": "Anillos",
|
||||
"Range": "Alcance",
|
||||
"Power": "Potencia",
|
||||
"Opacity": "Opacidad",
|
||||
"Sector": "Sector",
|
||||
"Cyber": "Cyber",
|
||||
"Sonar": "Sonar",
|
||||
"Turn Street Level on or off": "Activar o desactivar A pie de calle",
|
||||
"Toggle straight-down and tilted map views": "Alternar entre la vista cenital y la vista inclinada del mapa",
|
||||
"Reset map to north up": "Orientar el mapa al norte",
|
||||
"Reset map bearing to north": "Restablecer la orientación del mapa al norte",
|
||||
"Rain radar": "Radar de lluvia",
|
||||
"Lightning density": "Densidad de rayos",
|
||||
"LATEST · newest per product": "ÚLTIMO · más reciente por producto",
|
||||
"Forecast · valid {valid} · issued {issued}": "Pronóstico · válido {valid} · emitido {issued}",
|
||||
"{detail} · Does not follow history": "{detail} · No sigue el historial",
|
||||
"Forecast · valid {valid}": "Pronóstico · válido {valid}",
|
||||
"{time} · {minutes} min ago": "{time} · hace {minutes} min",
|
||||
"Active weather": "Clima activo",
|
||||
"MINI": "MINI",
|
||||
"STD": "STD",
|
||||
"standard": "estándar",
|
||||
"mini": "mini",
|
||||
"COST UNKNOWN": "COSTO DESCONOCIDO",
|
||||
"ChatGPT OAuth session: {responses} response(s), {input} input and {output} output tokens, {captions} caption transcription(s) reported.": "Sesión OAuth de ChatGPT: {responses} respuesta(s), {input} tokens de entrada y {output} de salida, {captions} transcripción(es) de subtítulos informadas.",
|
||||
"USD cost and the API spend cap are unavailable for this auth mode.": "El costo en USD y el límite de gasto de la API no están disponibles con este modo de autenticación.",
|
||||
"Usage is incomplete because a response was still in flight when the session ended.": "El uso está incompleto porque una respuesta seguía en curso cuando terminó la sesión.",
|
||||
"Toggle voice control": "Alternar el control de voz",
|
||||
"Activate to toggle voice": "Activa para conectar o cortar la voz",
|
||||
"Voice active": "Voz activa",
|
||||
"Voice could not be started.": "No se pudo iniciar la voz.",
|
||||
"degraded": "degradado",
|
||||
"stale": "desactualizado",
|
||||
"partial": "parcial",
|
||||
"fallback": "respaldo",
|
||||
"unavailable": "no disponible",
|
||||
"{label} on": "{label}: activado",
|
||||
"{label} off": "{label}: desactivado",
|
||||
"Frame aircraft": "Encuadrar aeronaves",
|
||||
"Frame military aircraft": "Encuadrar aeronaves militares",
|
||||
"Frame ships": "Encuadrar buques",
|
||||
"Frame satellites": "Encuadrar satélites",
|
||||
"Frame contacts": "Encuadrar contactos",
|
||||
"{count} aircraft in frame": {
|
||||
"one": "{count} aeronave en el encuadre",
|
||||
"many": "{count} de aeronaves en el encuadre",
|
||||
"other": "{count} aeronaves en el encuadre"
|
||||
},
|
||||
"{count} military aircraft in frame": {
|
||||
"one": "{count} aeronave militar en el encuadre",
|
||||
"many": "{count} de aeronaves militares en el encuadre",
|
||||
"other": "{count} aeronaves militares en el encuadre"
|
||||
},
|
||||
"{count} ships in frame": {
|
||||
"one": "{count} buque en el encuadre",
|
||||
"many": "{count} de buques en el encuadre",
|
||||
"other": "{count} buques en el encuadre"
|
||||
},
|
||||
"{count} satellites in frame": {
|
||||
"one": "{count} satélite en el encuadre",
|
||||
"many": "{count} de satélites en el encuadre",
|
||||
"other": "{count} satélites en el encuadre"
|
||||
},
|
||||
"{count} contacts in frame": {
|
||||
"one": "{count} contacto en el encuadre",
|
||||
"many": "{count} de contactos en el encuadre",
|
||||
"other": "{count} contactos en el encuadre"
|
||||
},
|
||||
"Nearest military aircraft": "Aeronaves militares más cercanas",
|
||||
"Nearest ships": "Buques más cercanos",
|
||||
"Nearest satellites": "Satélites más cercanos",
|
||||
"Nearest aircraft": "Aeronaves más cercanas",
|
||||
"{first} and {second}": "{first} y {second}",
|
||||
"Camera range {range}": "Alcance de la cámara {range}",
|
||||
"Within {radius} of the view": "En un radio de {radius} desde la vista",
|
||||
"Labels on": "Etiquetas activadas",
|
||||
"Framing uses the nearest 80 loaded contacts": "El encuadre usa los 80 contactos cargados más cercanos",
|
||||
"{distance} from {location}": "A {distance} de {location}",
|
||||
"Altitude {altitude}": "Altitud {altitude}",
|
||||
"{origin} to {destination}": "{origin} a {destination}",
|
||||
"from {origin}": "desde {origin}",
|
||||
"to {destination}": "a {destination}",
|
||||
"Registration {registration}": "Matrícula {registration}",
|
||||
"Route {route}": "Ruta {route}",
|
||||
"View state": "Estado de la vista",
|
||||
"Marked {count} places": {
|
||||
"one": "{count} lugar marcado",
|
||||
"many": "{count} de lugares marcados",
|
||||
"other": "{count} lugares marcados"
|
||||
},
|
||||
"Nothing marked": "Nada marcado",
|
||||
"Not found: {places}": "No encontrado: {places}",
|
||||
"Tracing outlines": "Trazando contornos",
|
||||
"Mark the map": "Marcar el mapa",
|
||||
"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}",
|
||||
"Read the view": "Leer la vista",
|
||||
"Check view state": "Comprobar el estado de la vista",
|
||||
"Frame {target}": "Encuadrar {target}",
|
||||
"Finding places": "Buscando lugares",
|
||||
"Tracing outline": "Trazando contorno",
|
||||
"Looking up place": "Buscando lugar",
|
||||
"Flying": "Volando",
|
||||
"Turning on layer": "Activando capa",
|
||||
"Loading aircraft": "Cargando aeronaves",
|
||||
"Picking nearest": "Eligiendo la más cercana",
|
||||
"Working": "Trabajando",
|
||||
"{value} feet": "{value} pies",
|
||||
"nominal": "nominal",
|
||||
"Source: {label}": "Fuente: {label}",
|
||||
"N": "N",
|
||||
"NNE": "NNE",
|
||||
"NE": "NE",
|
||||
"ENE": "ENE",
|
||||
"E": "E",
|
||||
"ESE": "ESE",
|
||||
"SE": "SE",
|
||||
"SSE": "SSE",
|
||||
"S": "S",
|
||||
"SSW": "SSO",
|
||||
"SW": "SO",
|
||||
"WSW": "OSO",
|
||||
"W": "O",
|
||||
"WNW": "ONO",
|
||||
"NW": "NO",
|
||||
"NNW": "NNO",
|
||||
"Local ADS-B": "ADS-B local",
|
||||
"Transit": "Transporte público",
|
||||
"Recent Imagery": "Imágenes recientes",
|
||||
"Fire Perimeters": "Perímetros de incendios",
|
||||
"Cyclone advisories": "Avisos de ciclones",
|
||||
"EDIT DETAILS": "EDITAR DETALLES",
|
||||
"SHARE SCENE": "COMPARTIR ESCENA",
|
||||
"ON/OFF": "ON/OFF",
|
||||
"Voice assistant": "Asistente de voz",
|
||||
"VOICE": "VOZ",
|
||||
"Dismiss voice card": "Cerrar la tarjeta de voz",
|
||||
"YOU": "TÚ",
|
||||
"THIS": "ESTO",
|
||||
"HERE": "AQUÍ",
|
||||
"Plan": "Plan",
|
||||
"Referenced items": "Elementos citados",
|
||||
"NOTES & SOURCES": "NOTAS Y FUENTES",
|
||||
"ΔN": "ΔN",
|
||||
"ΔE": "ΔE"
|
||||
}
|
||||
+846
-4
@@ -372,7 +372,7 @@
|
||||
"REGIONAL NEWS": "РЕГИОНАЛЬНЫЕ НОВОСТИ",
|
||||
"CYCLE ON": "АВТОЛИСТАНИЕ ВКЛ",
|
||||
"CYCLE OFF": "АВТОЛИСТАНИЕ ВЫКЛ",
|
||||
"Stop automatic page cycling. Previous, Next, and the SIG/NEWS/LOCAL tabs stay available.": "Остановить автоматическое листание страниц. Кнопки ПРЕД./СЛЕД. и вкладки SIG/NEWS/LOCAL остаются доступны.",
|
||||
"Stop automatic page cycling. Previous, Next, and the SIG/NEWS/LOCAL tabs stay available.": "Остановить автоматическое листание страниц. Кнопки ПРЕД./СЛЕД. и вкладки СИГН./НОВОСТИ/МЕСТН. остаются доступны.",
|
||||
"Cycle briefing pages automatically every 9 seconds (Signals → News → Local). Pauses while you hover or focus the panel. Live signal data refreshes continuously either way.": "Автоматически листать страницы брифинга каждые 9 секунд (Сигналы → Новости → Местное). При наведении или фокусе на панели листание приостанавливается. Данные сигналов в реальном времени в любом случае обновляются непрерывно.",
|
||||
"{cohort}, Unavailable": "{cohort}, недоступно",
|
||||
"AHEAD": "ПО КУРСУ",
|
||||
@@ -618,8 +618,8 @@
|
||||
"Cockpit display and Radio controls": "Управление экраном и радио кабины",
|
||||
"Previous cockpit vision style": "Предыдущий стиль обзора кабины",
|
||||
"Previous vision style": "Предыдущий стиль обзора",
|
||||
"Current cockpit vision style: NORMAL. Activate for next style.": "Текущий стиль обзора кабины: NORMAL. Активируйте для следующего стиля.",
|
||||
"Current style: NORMAL — click for next": "Текущий стиль: NORMAL — нажмите для следующего",
|
||||
"Current cockpit vision style: NORMAL. Activate for next style.": "Текущий стиль обзора кабины: ОБЫЧНЫЙ. Активируйте для следующего стиля.",
|
||||
"Current style: NORMAL — click for next": "Текущий стиль: ОБЫЧНЫЙ — нажмите для следующего",
|
||||
"Next cockpit vision style": "Следующий стиль обзора кабины",
|
||||
"Next vision style": "Следующий стиль обзора",
|
||||
"Previous briefing page": "Предыдущая страница брифинга",
|
||||
@@ -990,5 +990,847 @@
|
||||
"ALL": "ВСЕ",
|
||||
"FLAT": "ПЛОСКИЕ",
|
||||
"SINCE": "С ДАТЫ",
|
||||
"Coverage colours": "Цвета покрытия"
|
||||
"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} — сначала закройте его.",
|
||||
"{label} · Detailed outline unavailable": "{label} · Подробный контур недоступен",
|
||||
"Witness source": "Свидетельство очевидца",
|
||||
"{provider} · EMBEDDED SOURCE": "{provider} · ВСТРОЕННЫЙ ИСТОЧНИК",
|
||||
"OPEN ORIGINAL ↗": "ОТКРЫТЬ ОРИГИНАЛ ↗",
|
||||
"{provider} media: {source}": "Медиа {provider}: {source}",
|
||||
"{provider} · PROVIDER CONTROLS": "{provider} · УПРАВЛЕНИЕ ПРОВАЙДЕРА",
|
||||
"YOUTUBE {state} · OPEN ORIGINAL": "YOUTUBE {state} · ОТКРЫТЬ ОРИГИНАЛ",
|
||||
"Facebook player API did not initialize.": "API плеера Facebook не инициализировался.",
|
||||
"Facebook player API could not load.": "Не удалось загрузить API плеера Facebook.",
|
||||
"Facebook player API timed out.": "Истекло время ожидания API плеера Facebook.",
|
||||
"X embed API did not initialize.": "API встраивания X не инициализировался.",
|
||||
"X embed could not load.": "Не удалось загрузить встраивание X.",
|
||||
"X embed timed out.": "Истекло время ожидания встраивания X.",
|
||||
"TIMEOUT": "ТАЙМАУТ",
|
||||
"BLOCKED": "ЗАБЛОКИРОВАНО",
|
||||
"Facebook embed unavailable. The post may be private, removed, region-restricted, or blocked.": "Встраивание Facebook недоступно. Публикация может быть закрытой, удалённой, ограниченной по региону или заблокированной.",
|
||||
"Preloading {provider} media": "Предзагрузка медиа {provider}",
|
||||
"LOADING X POST…": "ЗАГРУЗКА ПОСТА X…",
|
||||
"The X post is unavailable or cannot be embedded.": "Пост X недоступен или не может быть встроен.",
|
||||
"X POST · PROVIDER CONTROLS": "ПОСТ X · УПРАВЛЕНИЕ ПРОВАЙДЕРА",
|
||||
"{reason} OPEN ORIGINAL.": "{reason} ОТКРОЙТЕ ОРИГИНАЛ.",
|
||||
"X embed unavailable": "Встраивание X недоступно",
|
||||
"FACEBOOK · PROVIDER CONTROLS": "FACEBOOK · УПРАВЛЕНИЕ ПРОВАЙДЕРА",
|
||||
"Facebook embed unavailable": "Встраивание Facebook недоступно",
|
||||
"AUTOPLAY BLOCKED · USE PROVIDER CONTROLS": "АВТОВОСПРОИЗВЕДЕНИЕ ЗАБЛОКИРОВАНО · ИСПОЛЬЗУЙТЕ УПРАВЛЕНИЕ ПРОВАЙДЕРА",
|
||||
"Embedded {provider} media for {source}": "Встроенное медиа {provider}: {source}",
|
||||
"witness source": "свидетельство очевидца",
|
||||
"Rasuwagadhi witness": "Очевидец в Rasuwagadhi",
|
||||
"Open original source for {source}; capture time unverified": "Открыть исходный источник: {source}; время съёмки не подтверждено",
|
||||
"Historical reference and post-event image divider": "Разделитель исторического снимка и снимка после события",
|
||||
"A historical reference {before} percent, B post-event {after} percent": "A — исторический снимок: {before}%, B — после события: {after}%",
|
||||
"■ RELEASE CAMERA": "■ ОСВОБОДИТЬ КАМЕРУ",
|
||||
"Seek Nepal scene clock": "Перемотка часов непальской сцены",
|
||||
"NEPAL SCENE CLOCK": "ЧАСЫ НЕПАЛЬСКОЙ СЦЕНЫ",
|
||||
"… PLAYING SHOT": "… ИДЁТ КАДР",
|
||||
"▶ PLAY SHOT": "▶ ПУСК КАДРА",
|
||||
"Ⅱ PAUSE": "Ⅱ ПАУЗА",
|
||||
"↺ REPLAY CINEMATIC": "↺ ПОВТОРИТЬ КИНОРЕЖИМ",
|
||||
"↺ REPLAY": "↺ ПОВТОРИТЬ",
|
||||
"… PLAYING SCENE": "… ИДЁТ СЦЕНА",
|
||||
"Continue {scene} from next shot": "Продолжить со следующего кадра: {scene}",
|
||||
"current scene": "текущая сцена",
|
||||
"Replay the full reconstruction": "Повторить всю реконструкцию",
|
||||
"SCENE SHOT": "КАДР СЦЕНЫ",
|
||||
"TIME UNVERIFIED": "ВРЕМЯ НЕ ПОДТВЕРЖДЕНО",
|
||||
"{count} SOURCES": {
|
||||
"one": "{count} ИСТОЧНИК",
|
||||
"few": "{count} ИСТОЧНИКА",
|
||||
"many": "{count} ИСТОЧНИКОВ",
|
||||
"other": "{count} ИСТОЧНИКА"
|
||||
},
|
||||
"OUTSIDE IMAGERY SWIPE": "ВНЕ СРАВНЕНИЯ СНИМКОВ",
|
||||
"{scene} / AUTHORED SHOT": "{scene} / АВТОРСКИЙ КАДР",
|
||||
"NEPAL FLOOD INCIDENT": "НАВОДНЕНИЕ В НЕПАЛЕ",
|
||||
"Play {shot}": "Воспроизвести: {shot}",
|
||||
"current scene shot": "текущий кадр сцены",
|
||||
"Play reconstruction": "Воспроизвести реконструкцию",
|
||||
"Load previous scene shot": "Загрузить предыдущий кадр сцены",
|
||||
"Load next scene shot": "Загрузить следующий кадр сцены",
|
||||
"SCHEMATIC CORRIDOR, NOT MODELED ARRIVAL TIME.": "СХЕМАТИЧЕСКИЙ КОРИДОР, НЕ МОДЕЛЬ ВРЕМЕНИ ПРИХОДА.",
|
||||
"Rasuwa · 26 Aug 2026": "Rasuwa · 26 авг. 2026",
|
||||
"Event reconstruction": "Реконструкция события",
|
||||
"INITIATION": "ЗАРОЖДЕНИЕ",
|
||||
"DAMMING": "ЗАПРУДА",
|
||||
"SECONDARY FAILURE": "ВТОРИЧНЫЙ ПРОРЫВ",
|
||||
"BORDER ARRIVAL": "ДОСТИЖЕНИЕ ГРАНИЦЫ",
|
||||
"PROPAGATION": "РАСПРОСТРАНЕНИЕ",
|
||||
"DOWNSTREAM PASSAGE": "ПРОХОЖДЕНИЕ ВНИЗ ПО ТЕЧЕНИЮ",
|
||||
"CONSEQUENCE": "ПОСЛЕДСТВИЯ",
|
||||
"DOWNSTREAM CONSEQUENCE": "ПОСЛЕДСТВИЯ НИЖЕ ПО ТЕЧЕНИЮ",
|
||||
"EVIDENCE": "СВИДЕТЕЛЬСТВА",
|
||||
"{count} GEOLOCATED SOURCE-MAP PLACEMENTS": {
|
||||
"one": "{count} ГЕОПРИВЯЗАННАЯ МЕТКА НА КАРТЕ ИСТОЧНИКОВ",
|
||||
"few": "{count} ГЕОПРИВЯЗАННЫЕ МЕТКИ НА КАРТЕ ИСТОЧНИКОВ",
|
||||
"many": "{count} ГЕОПРИВЯЗАННЫХ МЕТОК НА КАРТЕ ИСТОЧНИКОВ",
|
||||
"other": "{count} ГЕОПРИВЯЗАННОЙ МЕТКИ НА КАРТЕ ИСТОЧНИКОВ"
|
||||
},
|
||||
"NO PREVIEW": "НЕТ ПРЕДПРОСМОТРА",
|
||||
"SOURCE LINK": "ССЫЛКА НА ИСТОЧНИК",
|
||||
"{publisher} / OPEN": "{publisher} / ОТКРЫТЬ",
|
||||
"Bhote Koshi flood reconstruction controls": "Управление реконструкцией наводнения на Bhote Koshi",
|
||||
"EVENT RECONSTRUCTION · 26 AUG 2026": "РЕКОНСТРУКЦИЯ СОБЫТИЯ · 26 АВГ. 2026",
|
||||
"BHOTE KOSHI OUTBURST FLOOD": "ПРОРЫВНОЙ ПАВОДОК НА BHOTE KOSHI",
|
||||
"Close event layer": "Закрыть слой события",
|
||||
"OBSERVED IMAGERY": "НАБЛЮДАЕМЫЕ СНИМКИ",
|
||||
"SCHEMATIC CORRIDOR": "СХЕМАТИЧЕСКИЙ КОРИДОР",
|
||||
"2021 HISTORICAL REFERENCE / 2026 POST-EVENT": "2021 ИСТОРИЧЕСКИЙ СНИМОК / 2026 ПОСЛЕ СОБЫТИЯ",
|
||||
"Historical reference and post-event image split": "Сравнение исторического снимка и снимка после события",
|
||||
"RECONSTRUCTION CLOCK": "ЧАСЫ РЕКОНСТРУКЦИИ",
|
||||
"Schematic downstream progression": "Схематическое продвижение вниз по течению",
|
||||
"Story beat navigation": "Навигация по этапам сюжета",
|
||||
"Previous story beat": "Предыдущий этап сюжета",
|
||||
"CAUSE": "ПРИЧИНА",
|
||||
"CAPTURE TIME UNVERIFIED": "ВРЕМЯ СЪЁМКИ НЕ ПОДТВЕРЖДЕНО",
|
||||
"Next story beat": "Следующий этап сюжета",
|
||||
"▶ PLAY": "▶ ПУСК",
|
||||
"Play the next shot and continue through this scene": "Воспроизвести следующий кадр и продолжить эту сцену",
|
||||
"▶ PLAY SCENE": "▶ ПУСК СЦЕНЫ",
|
||||
"◉ CINEMATIC": "◉ КИНОРЕЖИМ",
|
||||
"↺ FULL STORY": "↺ ВСЯ ИСТОРИЯ",
|
||||
"↗ OPEN ORIGINAL": "↗ ОТКРЫТЬ ОРИГИНАЛ",
|
||||
"⌖ LOWER GORGE": "⌖ НИЖНЕЕ УЩЕЛЬЕ",
|
||||
"FIELD REPORTS · {count}": "СООБЩЕНИЯ С МЕСТА · {count}",
|
||||
"GEOLOCATIONS · {credit}": "ГЕОЛОКАЦИИ · {credit}",
|
||||
"↗ OPEN PUBLIC MAP": "↗ ОТКРЫТЬ ПУБЛИЧНУЮ КАРТУ",
|
||||
"Clouds are preserved from the source imagery.": "Облака сохранены из исходных снимков.",
|
||||
"Show the Nepal context callout without a border or country fill": "Показать выноску с контекстом Непала без границы и заливки страны",
|
||||
"Introduce Nepal, draw its border, then reveal the Bhote Koshi incident": "Представить Непал, нарисовать его границу, затем показать инцидент на Bhote Koshi",
|
||||
"CITIES": "ГОРОДА",
|
||||
"Keep the regional incident context and introduce nearby city labels": "Сохранить региональный контекст инцидента и добавить подписи ближайших городов",
|
||||
"PLACES": "МЕСТА",
|
||||
"Introduce the reconstruction scene key places from trigger to downstream": "Показать ключевые места сцены реконструкции — от источника события до низовья",
|
||||
"PATH": "ПУТЬ",
|
||||
"Animate the sourced cyan river-centerline route to Trishuli Bazaar": "Анимировать голубой маршрут по оси реки до Trishuli Bazaar по данным источников",
|
||||
"OVERVIEW": "ОБЗОР",
|
||||
"Keep the completed flood path and location labels visible": "Оставить на экране весь путь паводка и подписи мест",
|
||||
"RECORD": "ЗАПИСЬ",
|
||||
"Reveal the trigger record while making a slow camera approach": "Показать запись о первопричине при медленном приближении камеры",
|
||||
"BHOTE KOSHI FLOOD INCIDENT": "НАВОДНЕНИЕ НА BHOTE KOSHI",
|
||||
"NEPAL · 26 AUG 2026": "НЕПАЛ · 26 АВГ. 2026",
|
||||
"USGS INITIAL REPORT": "ПЕРВИЧНОЕ СООБЩЕНИЕ USGS",
|
||||
"26 AUG 2026 · 08:37 NPT": "26 АВГ. 2026 · 08:37 NPT",
|
||||
"28.2710° N · 85.5150° E": "28,2710° С. Ш. · 85,5150° В. Д.",
|
||||
"INITIAL M4.4 · RECLASSIFIED M5.2 LANDSLIDE": "ПЕРВОНАЧАЛЬНО M4,4 · ПЕРЕКЛАССИФИЦИРОВАНО: ОПОЛЗЕНЬ M5,2",
|
||||
"USGS EVENT us7000tbwb · OPEN SOURCE": "СОБЫТИЕ USGS us7000tbwb · ОТКРЫТЬ ИСТОЧНИК",
|
||||
"flight": "рейс",
|
||||
"military flight": "военный рейс",
|
||||
"satellite": "спутник",
|
||||
"VOICE AUTH · CHATGPT OAUTH": "АВТОРИЗАЦИЯ ГОЛОСА · CHATGPT OAUTH",
|
||||
"VOICE AUTH · API KEY": "АВТОРИЗАЦИЯ ГОЛОСА · КЛЮЧ API",
|
||||
"USE API KEY": "ИСПОЛЬЗОВАТЬ КЛЮЧ API",
|
||||
"USE CHATGPT OAUTH": "ИСПОЛЬЗОВАТЬ CHATGPT OAUTH",
|
||||
"Use OPENAI_API_KEY for the next cloud voice session": "Использовать OPENAI_API_KEY для следующей облачной голосовой сессии",
|
||||
"Use the signed-in local ChatGPT/Codex OAuth session for the next cloud voice session": "Использовать локальную OAuth-сессию ChatGPT/Codex, в которую выполнен вход, для следующей облачной голосовой сессии",
|
||||
"Metered — a billing-enabled account": "Платный — нужен аккаунт с включённой оплатой",
|
||||
"Free key — register, paste, done": "Бесплатный ключ — зарегистрируйтесь, вставьте, готово",
|
||||
"browser-side": "в браузере",
|
||||
"This key runs in the browser by design — restrict it at the provider (see SECURITY.md)": "Этот ключ по задумке работает в браузере — ограничьте его у провайдера (см. SECURITY.md)",
|
||||
"configured externally": "задан извне",
|
||||
"Supplied by your environment, Keychain, or launcher — change it where it was set": "Задан окружением, Keychain или лаунчером — меняйте его там, где он задан",
|
||||
"MANAGE ↗": "УПРАВЛЯТЬ ↗",
|
||||
"GET KEY ↗": "ПОЛУЧИТЬ КЛЮЧ ↗",
|
||||
"{envVar} saved — paste to replace": "{envVar} сохранён — вставьте, чтобы заменить",
|
||||
"paste {envVar}": "вставьте {envVar}",
|
||||
"Project keys: {status}": "Ключи проекта: {status}",
|
||||
"Mapped camera locations": "Расположение камер на карте",
|
||||
"SHOW NEAREST": "ПОКАЗАТЬ БЛИЖАЙШУЮ",
|
||||
"Stop following the current object before navigating to a camera": "Прекратите следовать за текущим объектом, прежде чем переходить к камере",
|
||||
"Move to the nearest loaded camera and show its details": "Перейти к ближайшей загруженной камере и показать её данные",
|
||||
"Camera badges": "Значки камер",
|
||||
"Cyan cameras turn coral when selected. Wedges illustrate mapped direction, not measured coverage. Nearby cameras may be outside the screen.": "Голубые камеры при выборе становятся коралловыми. Секторы показывают направление по карте, а не измеренное покрытие. Ближайшие камеры могут быть за пределами экрана.",
|
||||
"{count} nearby · {onScreen} on screen": {
|
||||
"one": "рядом: {count} · на экране: {onScreen}",
|
||||
"few": "рядом: {count} · на экране: {onScreen}",
|
||||
"many": "рядом: {count} · на экране: {onScreen}",
|
||||
"other": "рядом: {count} · на экране: {onScreen}"
|
||||
},
|
||||
"{count} nearby": {
|
||||
"one": "рядом: {count}",
|
||||
"few": "рядом: {count}",
|
||||
"many": "рядом: {count}",
|
||||
"other": "рядом: {count}"
|
||||
},
|
||||
"retrying mapped ALPR cameras": "повторная загрузка камер ALPR",
|
||||
"loading mapped ALPR cameras": "загрузка камер ALPR",
|
||||
"No ALPR data for this area — US and Canada only": "Нет данных ALPR для этой области — только США и Канада",
|
||||
"Zoom in to load mapped cameras": "Приблизьте карту, чтобы загрузить камеры",
|
||||
"Showing cached locations": "Показаны места из кэша",
|
||||
"Coverage limited — zoom in": "Покрытие ограничено — приблизьте карту",
|
||||
"No ALPR data for this area": "Нет данных ALPR для этой области",
|
||||
"None on screen — nearby cameras are outside the view": "На экране нет — ближайшие камеры вне поля зрения",
|
||||
"ALPR camera": "Камера ALPR",
|
||||
"OpenStreetMap · community mapped": "OpenStreetMap · нанесено сообществом",
|
||||
"Atlantic and eastern/central North Pacific; not worldwide cyclone coverage.": "Атлантика и восточная/центральная часть северной Пацифики; покрытие циклонов не глобальное.",
|
||||
"Potential tropical cyclone": "Потенциальный тропический циклон",
|
||||
"Hurricane": "Ураган",
|
||||
"Tropical storm": "Тропический шторм",
|
||||
"Tropical depression": "Тропическая депрессия",
|
||||
"Subtropical storm": "Субтропический шторм",
|
||||
"Subtropical depression": "Субтропическая депрессия",
|
||||
"Track and cone match this advisory": "Трек и конус соответствуют этому бюллетеню",
|
||||
"Track/cone awaiting advisory {number}": "Трек/конус ожидают бюллетень {number}",
|
||||
"Track/cone unavailable": "Трек/конус недоступны",
|
||||
"Cached advisory · stale source": "Бюллетень из кэша · источник устарел",
|
||||
"Loading advisories…": "Загрузка бюллетеней…",
|
||||
"{count} active storms": {
|
||||
"one": "{count} активный шторм",
|
||||
"few": "{count} активных шторма",
|
||||
"many": "{count} активных штормов",
|
||||
"other": "{count} активного шторма"
|
||||
},
|
||||
"Position as of {time}": "Положение на {time}",
|
||||
"Maximum sustained wind: {wind} · Pressure: {pressure}": "Макс. устойчивый ветер: {wind} · Давление: {pressure}",
|
||||
"{name} · {classification} · Advisory {number} · issued {time}": "{name} · {classification} · Бюллетень {number} · выпущен {time}",
|
||||
"No active NHC/CPHC systems": "Нет активных систем NHC/CPHC",
|
||||
"Advisories unavailable": "Бюллетени недоступны",
|
||||
"Cyclones · NHC / CPHC": "Циклоны · NHC / CPHC",
|
||||
"Atlantic · E/C Pacific": "Атлантика · вост./центр. Пацифика",
|
||||
"{storms} · {name} selected": "{storms} · выбран {name}",
|
||||
"Official advisory ↗": "Официальный бюллетень ↗",
|
||||
"Active NHC and CPHC cyclone advisories": "Активные бюллетени NHC и CPHC о циклонах",
|
||||
"Wind unavailable": "Ветер: нет данных",
|
||||
"Advisory center / forecast track": "Центр по бюллетеню / прогнозный трек",
|
||||
"Center-track uncertainty cone": "Конус неопределённости трека центра",
|
||||
"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.": "Выберите шторм на карте или в списке, чтобы выделить его и переместить к нему камеру. Щёлкните по пустому месту карты, чтобы снять выделение. Контекст бюллетеней NOAA NHC/CPHC. Конус описывает неопределённость прогнозного трека центра, а не размер шторма или всю зону опасности. Подписи прогнозных точек — заблаговременность источника в часах, а не время, рассчитанное от выпуска бюллетеня. Геометрия следует поверхности; высота не отражает высоту погодных явлений. Сверяйтесь с официальным бюллетенем.",
|
||||
"Directions": "Маршруты",
|
||||
"{mode} — reroute for {modeLower}": "{mode} — перестроить маршрут: {modeLower}",
|
||||
"CLICK MAP": "ЩЁЛКНИТЕ ПО КАРТЕ",
|
||||
"SET A": "ЗАДАТЬ A",
|
||||
"Click a spot on the globe to place A (click again to cancel)": "Щёлкните точку на глобусе, чтобы поставить A (повторный щелчок — отмена)",
|
||||
"Then click the globe to place the start": "Затем щёлкните по глобусу, чтобы поставить начало",
|
||||
"SET B": "ЗАДАТЬ B",
|
||||
"Click a spot on the globe to place B (click again to cancel)": "Щёлкните точку на глобусе, чтобы поставить B (повторный щелчок — отмена)",
|
||||
"Then click the globe to place the destination": "Затем щёлкните по глобусу, чтобы поставить пункт назначения",
|
||||
"Swap A and B": "Поменять A и B местами",
|
||||
"FLY ···": "ПОЛЁТ ···",
|
||||
"FLYING": "В ПОЛЁТЕ",
|
||||
"FLY": "ПОЛЁТ",
|
||||
"Fly the camera along the route": "Пролететь камерой по маршруту",
|
||||
"Place A and B first": "Сначала поставьте A и B",
|
||||
"Remove the route and both markers": "Удалить маршрут и обе метки",
|
||||
"press Escape twice to leave Draw": "дважды нажмите Escape, чтобы выйти из режима «Рисование»",
|
||||
"{name} is active — turn {name} off, then set {endpoint}": "Активно: {name} — выключите {name}, затем задайте {endpoint}",
|
||||
"{name} is active — {leave}, then set {endpoint}": "Активно: {name} — {leave}, затем задайте {endpoint}",
|
||||
"Only the first {count} turns of this route are shown": "Показаны только первые повороты маршрута: {count}",
|
||||
"Turn-by-turn directions": "Пошаговые указания",
|
||||
"OSM routing": "Маршрутизация OSM",
|
||||
"DRIVE": "АВТО",
|
||||
"Drive": "На машине",
|
||||
"Another map tool is using clicks — close it, then SET A again": "Щелчки занимает другой инструмент карты — закройте его и снова нажмите ЗАДАТЬ A",
|
||||
"WALK": "ПЕШКОМ",
|
||||
"Walk": "Пешком",
|
||||
"Routing…": "Построение маршрута…",
|
||||
"BIKE": "ВЕЛО",
|
||||
"Bike": "На велосипеде",
|
||||
"No route found": "Маршрут не найден",
|
||||
"{distance} · {duration} · {mode} · first {count} turns": "{distance} · {duration} · {mode} · первые повороты: {count}",
|
||||
"{distance} · {duration} · {mode}": "{distance} · {duration} · {mode}",
|
||||
"Click the globe to place {endpoint}": "Щёлкните по глобусу, чтобы поставить {endpoint}",
|
||||
"SET B, then click the globe": "Нажмите ЗАДАТЬ B, затем щёлкните по глобусу",
|
||||
"SET A, then click the globe": "Нажмите ЗАДАТЬ A, затем щёлкните по глобусу",
|
||||
"Step {step} of {total} · then {leg}": "Шаг {step} из {total} · затем {leg}",
|
||||
"Step {step} of {total}": "Шаг {step} из {total}",
|
||||
"Then: {instruction}": "Затем: {instruction}",
|
||||
"No route found between A and B": "Маршрут между A и B не найден",
|
||||
"Routing failed: {error}": "Не удалось построить маршрут: {error}",
|
||||
"Routing timed out": "Истекло время построения маршрута",
|
||||
"Routing unavailable — no response from the routing service": "Построение маршрута недоступно — сервис маршрутизации не отвечает",
|
||||
"Routing unavailable": "Построение маршрута недоступно",
|
||||
"Fire · FRP {frp} MW": "Пожар · FRP {frp} МВт",
|
||||
"Not contained or unknown": "Не локализован или неизвестно",
|
||||
"Under 50% contained": "Локализован менее чем на 50%",
|
||||
"50–99% contained": "Локализован на 50–99%",
|
||||
"Fully contained": "Полностью локализован",
|
||||
"Colour shows reported containment. Perimeters are simplified to about 100 m.": "Цвет показывает заявленную степень локализации. Контуры упрощены примерно до 100 м.",
|
||||
"MORE DETAIL · 30 m": "БОЛЬШЕ ДЕТАЛЕЙ · 30 м",
|
||||
"Sentinel-2 and Landsat via HLS, 30 m, every 2–6 days": "Sentinel-2 и Landsat через HLS, 30 м, каждые 2–6 дней",
|
||||
"DAILY OVERVIEW · 250 m": "ЕЖЕДНЕВНЫЙ ОБЗОР · 250 м",
|
||||
"VIIRS daily mosaic, 250 m, follows broad changes; little detail under 25 km": "Ежедневная мозаика VIIRS, 250 м, показывает крупные изменения; мало деталей в масштабе менее 25 км",
|
||||
"Catalog truncated · a newer clear day may exist": "Каталог усечён · может быть более новый ясный день",
|
||||
"{label} catalog unavailable · {detail}": "Каталог {label} недоступен · {detail}",
|
||||
"Daily overview for today may still be empty until the pass": "Ежедневный обзор за сегодня может оставаться пустым до пролёта спутника",
|
||||
"Sentinel-2": "Sentinel-2",
|
||||
"Landsat 8/9": "Landsat 8/9",
|
||||
"Daily overview": "Ежедневный обзор",
|
||||
"No frame within {gap} min of {time}": "Нет кадра в пределах {gap} мин от {time}",
|
||||
"No frame within {gap} h of {time}": "Нет кадра в пределах {gap} ч от {time}",
|
||||
"{hours}h {minutes}m ago": "{hours} ч {minutes} мин назад",
|
||||
"{minutes}m ago": "{minutes} мин назад",
|
||||
"{state} · {time} · {lag}": "{state} · {time} · {lag}",
|
||||
"Observed": "Наблюдение",
|
||||
"History": "История",
|
||||
"Observation unavailable": "Наблюдение недоступно",
|
||||
"Waiting for observation": "Ожидание наблюдения",
|
||||
"{detail} · synced": "{detail} · синхронизировано",
|
||||
"{detail} · nearest": "{detail} · ближайший",
|
||||
"RADAR REFLECTIVITY · dBZ": "ОТРАЖАЕМОСТЬ РАДАРА · dBZ",
|
||||
"LIGHTNING DENSITY · 15 min accumulation": "ПЛОТНОСТЬ МОЛНИЙ · накопление за 15 мин",
|
||||
"GLOBAL INFRARED · hourly": "ГЛОБАЛЬНЫЙ ИК · ежечасно",
|
||||
"GOES INFRARED · ~5 min": "GOES ИК · ~5 мин",
|
||||
"{state}: {time}": "{state}: {time}",
|
||||
"Latest observation": "Последнее наблюдение",
|
||||
"frame {index}/{total}": "кадр {index}/{total}",
|
||||
"loading": "загрузка",
|
||||
"Observation: unavailable": "Наблюдение: недоступно",
|
||||
"Contiguous US · gaps ≠ no rain": "Континентальные США · пробелы ≠ нет дождя",
|
||||
"Americas + Pacific · not individual strikes": "Америка + Тихий океан · не отдельные разряды",
|
||||
"Color: strikes/km²/min ×10³": "Цвет: разрядов/км²/мин ×10³",
|
||||
"60°S–60°N · typically 2–3 h delayed": "60° ю. ш.–60° с. ш. · обычно задержка 2–3 ч",
|
||||
"North America · infrared imagery": "Северная Америка · ИК-снимки",
|
||||
"Reduced motion · history playback unavailable": "Уменьшение движения · воспроизведение истории недоступно",
|
||||
"Reduced motion · manual history available": "Уменьшение движения · доступна ручная история",
|
||||
"Rain radar · US": "Радар осадков · США",
|
||||
"Lightning density · 15 min": "Плотность молний · 15 мин",
|
||||
"Satellite clouds": "Спутниковая облачность",
|
||||
"Americas + Pacific": "Америка + Тихий океан",
|
||||
"Global · 60°S–60°N": "Глобально · 60° ю. ш.–60° с. ш.",
|
||||
"North America": "Северная Америка",
|
||||
"Source observations delayed": "Наблюдения источника задерживаются",
|
||||
"Stale source": "Источник устарел",
|
||||
"Loading next frame…": "Загрузка следующего кадра…",
|
||||
"Map center outside coverage": "Центр карты вне зоны покрытия",
|
||||
"strikes/km²/min ×10³": "разрядов/км²/мин ×10³",
|
||||
"N. America": "Сев. Америка",
|
||||
"Global": "Глобально",
|
||||
"Hourly global mosaic; usually 2–3 hours delayed": "Ежечасная глобальная мозаика; обычно с задержкой 2–3 ч",
|
||||
"GOES regional clouds; approximately 5-minute updates": "Региональная облачность GOES; обновление примерно каждые 5 мин",
|
||||
"Clouds only": "Только облака",
|
||||
"Dim everything but the bright, cold cloud tops; a brightness filter, not a cloud mask": "Затемнить всё, кроме ярких холодных вершин облаков; фильтр яркости, а не маска облачности",
|
||||
"Full": "Полное",
|
||||
"The complete infrared image at the chosen opacity": "Полное ИК-изображение с выбранной непрозрачностью",
|
||||
"Soft": "Мягко",
|
||||
"Vivid": "Ярко",
|
||||
"Image opacity; does not alter the observed values": "Непрозрачность изображения; не меняет наблюдаемые значения",
|
||||
"View US radar": "Показать радар США",
|
||||
"View Americas & Pacific": "Показать Америку и Тихий океан",
|
||||
"View coverage": "Показать зону покрытия",
|
||||
"{value} strikes/km²/min ×10³ (15-minute density)": "{value} разрядов/км²/мин ×10³ (плотность за 15 мин)",
|
||||
"{value} dBZ radar reflectivity": "Отражаемость радара: {value} dBZ",
|
||||
"STALE · cached source metadata": "УСТАРЕЛ · метаданные источника из кэша",
|
||||
"Map center is outside source coverage": "Центр карты вне зоны покрытия источника",
|
||||
"NOAA/NWS 15-minute lightning density derived from Vaisala NLDN/GLD360. Coverage 110°E across the Pacific/Americas to 0°, 25°S–80°N. Not a live strike count, global coverage or a safety warning.": "15-минутная плотность молний NOAA/NWS на основе Vaisala NLDN/GLD360. Покрытие — от 110° в. д. через Тихий океан и Америку до 0°, 25° ю. ш.–80° с. ш. Это не подсчёт разрядов в реальном времени, не глобальное покрытие и не предупреждение об опасности.",
|
||||
"NOAA MRMS radar echoes indicate precipitation patterns, not rain rate, a storm warning or a future forecast. Native source approximately 1 km; display is limited to level 6. Frames use exact advertised observation times.": "Радиолокационные эхосигналы NOAA MRMS показывают картину осадков, а не их интенсивность, штормовое предупреждение или прогноз. Исходное разрешение около 1 км; отображение ограничено уровнем 6. Кадры используют точное заявленное время наблюдений.",
|
||||
"GOES-19/18 longwave infrared Band 14 regional; NESDIS global longwave mosaic. Clouds only dims everything but bright, cold cloud tops; a brightness filter, not a cloud mask. Coverage and freshness differ by region.": "Региональный длинноволновый ИК-канал 14 GOES-19/18; глобальная длинноволновая мозаика NESDIS. Режим «Только облака» затемняет всё, кроме ярких холодных вершин облаков; это фильтр яркости, а не маска облачности. Покрытие и актуальность различаются по регионам.",
|
||||
"REGION": "РЕГИОН",
|
||||
"IMAGE": "ИЗОБРАЖЕНИЕ",
|
||||
"OPACITY": "НЕПРОЗРАЧНОСТЬ",
|
||||
"Forecast · does not follow history": "Прогноз · не следует за историей",
|
||||
"Wind motion": "Движение ветра",
|
||||
"Wind speed": "Скорость ветра",
|
||||
"Sea-level pressure": "Давление на уровне моря",
|
||||
"Air temperature · 2 m": "Температура воздуха · 2 м",
|
||||
"Global · 1° grid": "Глобально · сетка 1°",
|
||||
"{model} forecast · {time}": "Прогноз {model} · {time}",
|
||||
"Loading forecast": "Загрузка прогноза",
|
||||
"Preparing flow": "Подготовка потока",
|
||||
"Selected field unavailable": "Выбранное поле недоступно",
|
||||
"Cached forecast · stale": "Прогноз из кэша · устарел",
|
||||
"ECMWF IFS surface forecast": "Приземный прогноз ECMWF IFS",
|
||||
"NOAA GFS surface forecast": "Приземный прогноз NOAA GFS",
|
||||
"Reduced motion": "Уменьшение движения",
|
||||
"Pause visual flow; forecast time does not advance with animation": "Приостановить визуальный поток; время прогноза не сдвигается с анимацией",
|
||||
"None": "Нет",
|
||||
"Speed": "Скорость",
|
||||
"Pressure": "Давление",
|
||||
"Temperature": "Температура",
|
||||
"Wind trails with no field shading": "Следы ветра без заливки поля",
|
||||
"How hard the surface wind is blowing": "Насколько сильно дует приземный ветер",
|
||||
"Sea-level air pressure: broad highs and lows": "Давление на уровне моря: крупные области высокого и низкого давления",
|
||||
"Air temperature two meters above the surface": "Температура воздуха в двух метрах над поверхностью",
|
||||
"Wind speed units": "Единицы скорости ветра",
|
||||
"Read wind at map center": "Узнать ветер в центре карты",
|
||||
"Read the forecast at the center of the map without changing selection": "Получить прогноз в центре карты, не меняя выбор",
|
||||
"{model} forecast · {field} ({unit})": "Прогноз {model} · {field} ({unit})",
|
||||
"Valid: {time}": "Действует на: {time}",
|
||||
"preparing": "подготовка",
|
||||
"Issued: {time}": "Выпущен: {time}",
|
||||
"Selected field unavailable · wind remains visible": "Выбранное поле недоступно · ветер остаётся видимым",
|
||||
"{error} · wind remains visible": "{error} · ветер остаётся видимым",
|
||||
"Surface wind at 10 m. Approximately 1° global grid. Curves follow the 10 m wind field, lifted 12 km for visibility; display height is not weather altitude. View lighting is for readability. Animation shows flow through one fixed forecast; it does not advance time. Color fields drape the globe basemap or the active photorealistic 3D Tiles.": "Приземный ветер на высоте 10 м. Глобальная сетка около 1°. Линии следуют полю ветра на 10 м и подняты на 12 км для наглядности; высота отображения не является высотой погодных явлений. Освещение вида — для удобства чтения. Анимация показывает поток в одном фиксированном прогнозе и не сдвигает время. Цветовые поля накладываются на базовую карту глобуса или на активные фотореалистичные 3D Tiles.",
|
||||
"MODEL": "МОДЕЛЬ",
|
||||
"FIELD": "ПОЛЕ",
|
||||
"UNITS": "ЕДИНИЦЫ",
|
||||
"MOTION": "ДВИЖЕНИЕ",
|
||||
"Forecast": "Прогноз",
|
||||
"Interpolated model forecast on an approximately 1° grid. Broad weather patterns, not a street-level measurement.": "Интерполированный модельный прогноз на сетке около 1°. Крупные погодные закономерности, а не измерение на уровне улицы.",
|
||||
"No surface reading": "Нет приземных данных",
|
||||
"Aim the map center at Earth": "Наведите центр карты на Землю",
|
||||
" · calm": " · штиль",
|
||||
" from {direction}": " с {direction}",
|
||||
"WIND AT {coordinates}": "ВЕТЕР В ТОЧКЕ {coordinates}",
|
||||
"{model} · valid {time}": "{model} · на {time}",
|
||||
"Focusing {label}": "Фокусировка: {label}",
|
||||
"Source": "Источник",
|
||||
"Could not leave scene: {scene}": "Не удалось выйти из сцены: {scene}",
|
||||
"Seeked: {scene} / {shot}": "Перемотано: {scene} / {shot}",
|
||||
"Scene transition limit reached — load a shot to reset": "Достигнут лимит переходов сцены — загрузите кадр, чтобы сбросить его",
|
||||
"Export failed: {error}": "Ошибка экспорта: {error}",
|
||||
"Imported {file}": "Импортировано: {file}",
|
||||
"Import failed: {error}": "Ошибка импорта: {error}",
|
||||
"Import failed (could not read JSON file)": "Ошибка импорта (не удалось прочитать файл JSON)",
|
||||
"Layers refused: {layers}": "Слои отклонены: {layers}",
|
||||
"Scene {n}": "Сцена {n}",
|
||||
"Cannot update {scene}: shot inventory changed": "Не удалось обновить {scene}: изменился состав кадров",
|
||||
"Cannot update {scene}: evidence beats changed": "Не удалось обновить {scene}: изменились этапы доказательств",
|
||||
"Cannot update {scene}: shot bindings are incomplete": "Не удалось обновить {scene}: привязки кадров неполные",
|
||||
"Updated {count} shots: {scene}": {
|
||||
"one": "Обновлён {count} кадр: {scene}",
|
||||
"few": "Обновлено {count} кадра: {scene}",
|
||||
"many": "Обновлено {count} кадров: {scene}",
|
||||
"other": "Обновлено {count} кадра: {scene}"
|
||||
},
|
||||
"Appended {count} shots: {scene}": {
|
||||
"one": "Добавлен {count} кадр: {scene}",
|
||||
"few": "Добавлено {count} кадра: {scene}",
|
||||
"many": "Добавлено {count} кадров: {scene}",
|
||||
"other": "Добавлено {count} кадра: {scene}"
|
||||
},
|
||||
"Select a feature or use Tab and Enter to choose an action.": "Выберите объект на карте или используйте Tab и Enter, чтобы выбрать действие.",
|
||||
"Selected feature: {feature}. Choose an action.": "Выбран объект: {feature}. Выберите действие.",
|
||||
"Action complete": "Действие выполнено",
|
||||
"Action unavailable or cancelled": "Действие недоступно или отменено",
|
||||
"Scene actions": "Действия сцены",
|
||||
"Bhote Koshi Evidence Sequence": "Последовательность доказательств: Bhote Koshi",
|
||||
"Nepal Flood Incident": "Наводнение в Непале",
|
||||
"{count} scenes": {
|
||||
"one": "{count} сцена",
|
||||
"few": "{count} сцены",
|
||||
"many": "{count} сцен",
|
||||
"other": "{count} сцены"
|
||||
},
|
||||
"{count} shots": {
|
||||
"one": "{count} кадр",
|
||||
"few": "{count} кадра",
|
||||
"many": "{count} кадров",
|
||||
"other": "{count} кадра"
|
||||
},
|
||||
"{count} data packs": {
|
||||
"one": "{count} пакет данных",
|
||||
"few": "{count} пакета данных",
|
||||
"many": "{count} пакетов данных",
|
||||
"other": "{count} пакета данных"
|
||||
},
|
||||
"{scene} / {pack}: {status}. File: {path}. {attribution} · {license}": "{scene} / {pack}: {status}. Файл: {path}. {attribution} · {license}",
|
||||
"Unavailable layers: {layers}": "Недоступные слои: {layers}",
|
||||
"This scene uses registered content or linked media. Those external files are not included in a scene bundle. Their original notices still apply.": "Эта сцена использует зарегистрированный контент или связанные медиафайлы. Эти внешние файлы не входят в архив сцены. Их исходные уведомления по-прежнему действуют.",
|
||||
"{bytes} bundled bytes verified. Files stay in memory for this session. Reimport the bundle after reloading the app.": "Проверено байт в архиве: {bytes}. Файлы хранятся в памяти только в этом сеансе. После перезагрузки приложения импортируйте архив снова.",
|
||||
"Review scene import": "Проверка импорта сцены",
|
||||
"Nothing is loaded or changed until you apply this file.": "Ничего не загружается и не меняется, пока вы не примените этот файл.",
|
||||
"Ready to import": "Готово к импорту",
|
||||
"Apply replaces the current project. Export your current project first if you want to keep both.": "Применение заменит текущий проект. Чтобы сохранить оба, сначала экспортируйте текущий проект.",
|
||||
"Apply import": "Применить импорт",
|
||||
"Import was not applied. The current project may have changed.": "Импорт не применён. Возможно, текущий проект изменился.",
|
||||
"Select a scene and shot first": "Сначала выберите сцену и кадр",
|
||||
"Edit scene details": "Изменить параметры сцены",
|
||||
"Camera positions use degrees and meters above the ellipsoid. Drafts are validated before they replace your saved scene.": "Положения камеры задаются в градусах и метрах над эллипсоидом. Черновики проверяются, прежде чем заменить сохранённую сцену.",
|
||||
"Anchors and data packs": "Якоря и пакеты данных",
|
||||
"Shot camera, timing, packs and actions": "Камера кадра, тайминг, пакеты и действия",
|
||||
"New anchor ID": "ID нового якоря",
|
||||
"Capture camera as anchor": "Сохранить камеру как якорь",
|
||||
"Anchor added to draft": "Якорь добавлен в черновик",
|
||||
"Set move start to current camera": "Начать перелёт с текущей камеры",
|
||||
"Move added to draft; destination remains the shot camera": "Перелёт добавлен в черновик; конечной точкой остаётся камера кадра",
|
||||
"Use ordinary flight": "Использовать обычный перелёт",
|
||||
"Invalid shot JSON": "Некорректный JSON кадра",
|
||||
"Apply details": "Применить параметры",
|
||||
"Select a scene first": "Сначала выберите сцену",
|
||||
"Share selected scene": "Поделиться выбранной сценой",
|
||||
"Scene JSON preserves authored settings and attribution. It does not include asset files. Bundles include only pack files you choose here or previously imported bundle files.": "JSON сцены сохраняет авторские настройки и атрибуцию, но не содержит файлов ресурсов. Архив включает только файлы пакетов, выбранные здесь, или файлы ранее импортированных архивов.",
|
||||
"Download scene JSON": "Скачать JSON сцены",
|
||||
"Choose data-pack files": "Выбрать файлы пакетов данных",
|
||||
"Or choose a data-pack folder": "Или выберите папку пакета данных",
|
||||
"Download asset bundle": "Скачать архив ресурсов",
|
||||
"Asset bundle downloaded": "Архив ресурсов скачан",
|
||||
"Could not complete this action. Check the file, references and selected assets.": "Не удалось выполнить действие. Проверьте файл, ссылки и выбранные ресурсы.",
|
||||
"RTL-SDR sample stream stopped": "Поток выборок RTL-SDR остановлен",
|
||||
"Switching to {mode}…": "Переключение на {mode}…",
|
||||
"Browser audio is suspended; select FM again to resume it": "Звук в браузере приостановлен; выберите FM ещё раз, чтобы возобновить его",
|
||||
"RTL-SDR gain change failed": "Не удалось изменить усиление RTL-SDR",
|
||||
"Tuned to {mhz} MHz FM": "Настроено на FM {mhz} МГц",
|
||||
"FM signal found at {mhz} MHz": "Найден FM-сигнал на {mhz} МГц",
|
||||
"No FM signal found": "FM-сигнал не найден",
|
||||
"Seek stopped": "Поиск остановлен",
|
||||
"Browser location is unavailable": "Местоположение браузера недоступно",
|
||||
"Receiver location was not granted": "Доступ к местоположению приёмника не предоставлен",
|
||||
"RTL-SDR disconnected": "RTL-SDR отключён",
|
||||
"No RTL-SDR device selected": "Устройство RTL-SDR не выбрано",
|
||||
"WebUSB requires desktop Chrome or Edge on localhost/HTTPS": "Для WebUSB нужен настольный Chrome или Edge на localhost/HTTPS",
|
||||
"RTL-SDR access failed; close other SDR apps and check USB permissions": "Нет доступа к RTL-SDR; закройте другие SDR-приложения и проверьте разрешения USB",
|
||||
"RTL-SDR connection failed": "Не удалось подключить RTL-SDR",
|
||||
"Receiving 1090 MHz ADS-B": "Приём ADS-B на 1090 МГц",
|
||||
"Receiving broadcast FM": "Приём FM-вещания",
|
||||
"Connect an RTL-SDR to begin.": "Подключите RTL-SDR, чтобы начать.",
|
||||
"WebUSB is unavailable in this browser": "WebUSB недоступен в этом браузере",
|
||||
"SDR decoder worker failed": "Сбой обработчика декодера SDR",
|
||||
"WebUSB requires desktop Chrome or Edge": "Для WebUSB нужен настольный Chrome или Edge",
|
||||
"Waiting for an RTL-SDR device…": "Ожидание устройства RTL-SDR…",
|
||||
"Browser audio is suspended; press Connect again to resume it": "Звук в браузере приостановлен; нажмите «Подключить» ещё раз, чтобы возобновить его",
|
||||
"Return map to straight-down view": "Вернуть карту к виду сверху",
|
||||
"Tilt map to oblique view": "Наклонить карту в перспективный вид",
|
||||
"Reset map to north up. Current heading {heading} degrees": "Повернуть карту севером вверх. Текущий курс: {heading}°",
|
||||
"HDG": "КУРС",
|
||||
"PITCH": "ТАНГАЖ",
|
||||
"FOV": "ОБЗОР",
|
||||
"RANGE": "ДАЛЬН.",
|
||||
"HGT": "ВЫС.",
|
||||
"{value} KM/H": "{value} КМ/Ч",
|
||||
"NORMAL": "ОБЫЧНЫЙ",
|
||||
"ANIME": "АНИМЕ",
|
||||
"NOIR": "НУАР",
|
||||
"Rendered globe frames per second · toggle with `": "Кадров глобуса в секунду · переключение клавишей `",
|
||||
"Utilities": "Инструменты",
|
||||
"Other layers": "Другие слои",
|
||||
"Movement": "Движение",
|
||||
"Infrastructure": "Инфраструктура",
|
||||
"Events": "События",
|
||||
"Weather": "Погода",
|
||||
"LOCATED": "ОПРЕДЕЛЕНО",
|
||||
"LOCATE": "ОПРЕДЕЛИТЬ",
|
||||
"IDLE": "ОЖИДАНИЕ",
|
||||
"TUNING": "НАСТРОЙКА",
|
||||
"STREAMING": "ПРИЁМ",
|
||||
"UNSUPPORTED": "НЕ ПОДДЕРЖ.",
|
||||
"waiting for IQ": "ожидание IQ",
|
||||
"{count} messages": {
|
||||
"one": "{count} сообщение",
|
||||
"few": "{count} сообщения",
|
||||
"many": "{count} сообщений",
|
||||
"other": "{count} сообщения"
|
||||
},
|
||||
"DSP {count} blocks": {
|
||||
"one": "DSP {count} блок",
|
||||
"few": "DSP {count} блока",
|
||||
"many": "DSP {count} блоков",
|
||||
"other": "DSP {count} блока"
|
||||
},
|
||||
"DSP waiting": "DSP ожидает",
|
||||
"{level} dBFS audio": "аудио {level} dBFS",
|
||||
"audio signal --": "аудиосигнал --",
|
||||
"audio {state}": "аудио: {state}",
|
||||
"{count} HEARD": {
|
||||
"one": "СЛЫШНО: {count}",
|
||||
"few": "СЛЫШНО: {count}",
|
||||
"many": "СЛЫШНО: {count}",
|
||||
"other": "СЛЫШНО: {count}"
|
||||
},
|
||||
"DISCONNECT": "ОТКЛЮЧИТЬ",
|
||||
"CONNECT": "ПОДКЛЮЧИТЬ",
|
||||
"LOCATING…": "ОПРЕДЕЛЕНИЕ…",
|
||||
"Drag to resize · double-click the header to snap back": "Перетащите, чтобы изменить размер · двойной щелчок по заголовку — вернуть на место",
|
||||
"CH {index} / {total}": "КАН. {index} / {total}",
|
||||
"Station unavailable after directory refresh": "Станция недоступна после обновления каталога",
|
||||
"Clear reading": "Очистить показания",
|
||||
"Earlier observation": "Более раннее наблюдение",
|
||||
"Observed history": "История наблюдений",
|
||||
"Later observation": "Более позднее наблюдение",
|
||||
"Latest": "Последние",
|
||||
"Newest frame per product": "Самый свежий кадр каждого продукта",
|
||||
"Replay observed history": "Воспроизвести историю наблюдений",
|
||||
"S on another day replaces it · × unpins": "S на другом дне заменяет его · × открепляет",
|
||||
"← → preview · S shows the focused day": "← → просмотр · S показывает выбранный день",
|
||||
"Recent imagery": "Свежие снимки",
|
||||
"ZOOM IN": "ПРИБЛИЗИТЬ",
|
||||
"Fly in until the view fits the 1,000 km limit": "Приблизьтесь, пока вид не уложится в предел 1000 км",
|
||||
"Imagery days, newest first · arrows move, S or A and B pin": "Дни со снимками, сначала новые · стрелки — перемещение, S или A и B — закрепить",
|
||||
"On the map": "На карте",
|
||||
"Mode": "Режим",
|
||||
"Imagery on Esri · Google 3D returns when cleared": "Снимки поверх Esri · после очистки вернётся Google 3D",
|
||||
"Imagery opacity": "Непрозрачность снимков",
|
||||
"SWAP": "ПОМЕНЯТЬ",
|
||||
"VS BASEMAP": "VS ПОДЛОЖКА",
|
||||
"Trade the two sides of the divider": "Поменять местами стороны разделителя",
|
||||
"A / B": "A / B",
|
||||
"Newest low-cloud day for this box · scene cloud, not box cloud": "Самый свежий малооблачный день для этой области · облачность всей сцены, а не области",
|
||||
"Newest day covering this box · cloudier than 20%": "Самый свежий день с покрытием этой области · облачность выше 20%",
|
||||
"SELECT BOX": "ВЫБРАТЬ ОБЛАСТЬ",
|
||||
"Drag a box on the map (Esc cancels)": "Выделите область на карте (Esc — отмена)",
|
||||
"Newest day with imagery for this box · partial coverage": "Самый свежий день со снимками этой области · частичное покрытие",
|
||||
"USE VIEW": "ПО ВИДУ",
|
||||
"Use the current view as the box": "Использовать текущий вид как область",
|
||||
"Newest daily overview for this box": "Самый свежий ежедневный обзор для этой области",
|
||||
"Forget the box and its images": "Сбросить область и её снимки",
|
||||
"newest clear day": "самый свежий ясный день",
|
||||
"newest day (cloudy)": "самый свежий день (облачно)",
|
||||
"newest day (partial coverage)": "самый свежий день (частичное покрытие)",
|
||||
"START HERE": "НАЧНИТЕ ЗДЕСЬ",
|
||||
"newest overview": "самый свежий обзор",
|
||||
"No imagery": "Нет снимков",
|
||||
"Checking": "Проверка",
|
||||
"Loading": "Загрузка",
|
||||
"PREVIEW": "ПРОСМОТР",
|
||||
"Newest day with imagery for this box": "Самый свежий день со снимками этой области",
|
||||
"SHOW": "ПОКАЗАТЬ",
|
||||
"Pin {day} as {slot}": "Закрепить {day} как {slot}",
|
||||
"Show {day}": "Показать {day}",
|
||||
"pinned {slot}": "закреплено как {slot}",
|
||||
"shown": "показано",
|
||||
"preview": "просмотр",
|
||||
"start here": "начните здесь",
|
||||
"No box": "Нет области",
|
||||
"Searching": "Поиск",
|
||||
"Every day is empty here": "Здесь все дни пустые",
|
||||
"Sources off": "Источники выключены",
|
||||
"VS": "VS",
|
||||
"Not set": "Не задано",
|
||||
"Basemap": "Подложка",
|
||||
"Source off": "Источник выключен",
|
||||
"Unpin {slot}": "Открепить {slot}",
|
||||
"Unpin the image": "Открепить снимок",
|
||||
"EXPORT {slot}": "ЭКСПОРТ {slot}",
|
||||
"EXPORT": "ЭКСПОРТ",
|
||||
"Download {slot} as a PNG": "Скачать {slot} в PNG",
|
||||
"Download the image as a PNG": "Скачать снимок в PNG",
|
||||
"Box {width} × {height} km": "Область {width} × {height} км",
|
||||
"no imagery": "нет снимков",
|
||||
"Acquired {time}": "Снято {time}",
|
||||
"cloud unknown": "облачность неизвестна",
|
||||
"Coverage {coverage}": "Покрытие {coverage}",
|
||||
"unknown": "неизвестно",
|
||||
"START HERE · {reason}": "НАЧНИТЕ ЗДЕСЬ · {reason}",
|
||||
"Daily overview shows little detail in a box this small": "В такой маленькой области ежедневный обзор почти не показывает деталей",
|
||||
"{percent}% cloud": "облачность {percent}%",
|
||||
"Imagery: NASA GIBS and Worldview · HLS (Sentinel-2, Landsat 8/9) and VIIRS": "Снимки: NASA GIBS и Worldview · HLS (Sentinel-2, Landsat 8/9) и VIIRS",
|
||||
"{min}–{max}% cloud": "облачность {min}–{max}%",
|
||||
"Details": "Подробности",
|
||||
"Times, coverage, sources": "Время, покрытие, источники",
|
||||
"HIDE EMPTY DAYS": "СКРЫТЬ ПУСТЫЕ ДНИ",
|
||||
"SHOW EMPTY DAYS · {count}": "ПОКАЗАТЬ ПУСТЫЕ ДНИ · {count}",
|
||||
"Days the probe found empty in this box": "Дни, в которые проверка не нашла снимков в этой области",
|
||||
"NO BOX": "НЕТ ОБЛАСТИ",
|
||||
"image": "снимок",
|
||||
"BASEMAP": "ПОДЛОЖКА",
|
||||
"The basemap": "Подложка",
|
||||
"basemap": "подложка",
|
||||
"SEARCHING": "ПОИСК",
|
||||
"{count} DAYS": {
|
||||
"one": "{count} ДЕНЬ",
|
||||
"few": "{count} ДНЯ",
|
||||
"many": "{count} ДНЕЙ",
|
||||
"other": "{count} ДНЯ"
|
||||
},
|
||||
"Recent imagery against the basemap divider": "Разделитель: свежие снимки и подложка",
|
||||
"Recent imagery A and B divider": "Разделитель свежих снимков A и B",
|
||||
"{before} {left} percent, {after} {right} percent": "{before}: {left}%, {after}: {right}%",
|
||||
"Zoom in or draw a smaller box": "Приблизьте карту или выделите область поменьше",
|
||||
"Select a box or use the view": "Выделите область или используйте текущий вид",
|
||||
"Searching the last 30 days": "Поиск за последние 30 дней",
|
||||
"No days to show for this box": "Для этой области нет дней для показа",
|
||||
"Drag the divider · SWAP trades sides": "Перетащите разделитель · ПОМЕНЯТЬ меняет стороны",
|
||||
"Export failed · {error}": "Ошибка экспорта · {error}",
|
||||
"← → preview · A or B pins the focused day": "← → просмотр · A или B закрепляет выбранный день",
|
||||
"← → preview the other side · A or B pins it": "← → просмотр другой стороны · A или B закрепляет её",
|
||||
"LOAD": "ЗАГРУЗИТЬ",
|
||||
"No shots yet. Use CAPTURE SHOT to save current look.": "Кадров пока нет. Нажмите «СДЕЛАТЬ КАДР», чтобы сохранить текущий вид.",
|
||||
"Double-click to rename": "Дважды щёлкните, чтобы переименовать",
|
||||
"Shot name": "Название кадра",
|
||||
"RETRYING ALPR CAMERAS": "ПОВТОРНАЯ ЗАГРУЗКА КАМЕР ALPR",
|
||||
"FETCHING ALPR CAMERAS": "ЗАГРУЗКА КАМЕР ALPR",
|
||||
"OVERPASS TEMPORARILY UNAVAILABLE": "OVERPASS ВРЕМЕННО НЕДОСТУПЕН",
|
||||
"Weather data by Open-Meteo.com": "Данные о погоде: Open-Meteo.com",
|
||||
"ESC EXIT": "ESC ВЫХОД",
|
||||
"C TOGGLE": "C ПЕРЕКЛ.",
|
||||
"SIG": "СИГН.",
|
||||
"NEWS": "НОВОСТИ",
|
||||
"LOCAL": "МЕСТН.",
|
||||
"Cockpit briefing pages": "Страницы брифинга кабины",
|
||||
"MM": "ММ",
|
||||
"KTS": "УЗ",
|
||||
"FT": "ФТ",
|
||||
"FLT": "РЕЙСЫ",
|
||||
"MIL": "ВОЕН.",
|
||||
"SITE": "ОБЪЕКТЫ",
|
||||
"WX": "ПОГОДА",
|
||||
"DIRECTORY: RADIO BROWSER": "КАТАЛОГ: RADIO BROWSER",
|
||||
"Use your location for faster ADS-B position decoding": "Использовать ваше местоположение для ускорения декодирования позиций ADS-B",
|
||||
"CHANGE DEVICE": "СМЕНИТЬ УСТРОЙСТВО",
|
||||
"Choose a different RTL-SDR or receiver channel": "Выбрать другой RTL-SDR или канал приёмника",
|
||||
"TUNE": "НАСТРОИТЬ",
|
||||
"◀ SEEK": "◀ ПОИСК",
|
||||
"SEEK ▶": "ПОИСК ▶",
|
||||
"Local RTL-SDR receiver": "Локальный приёмник RTL-SDR",
|
||||
"RTL-SDR receiver mode": "Режим приёмника RTL-SDR",
|
||||
"Seek broadcast FM stations": "Поиск FM-радиостанций",
|
||||
"Tune available internet radio stations": "Настройка на доступные интернет-радиостанции",
|
||||
"Local SDR volume": "Громкость локального SDR",
|
||||
"GAIN": "УСИЛЕНИЕ",
|
||||
"FM MHZ": "FM МГЦ",
|
||||
"AUTO": "АВТО",
|
||||
"Chrome/Edge · WebUSB · one local tuner shared by FM and the Local ADS-B layer.": "Chrome/Edge · WebUSB · один локальный тюнер для FM и слоя Local ADS-B.",
|
||||
"RTL-SDR tuner gain for the current mode": "Усиление тюнера RTL-SDR для текущего режима",
|
||||
"WEATHER": "ПОГОДА",
|
||||
"Active weather products": "Активные погодные продукты",
|
||||
"RECENT IMAGERY": "СВЕЖИЕ СНИМКИ",
|
||||
"Imagery days in the box": "Дни со снимками в области",
|
||||
"MSG/S": "СООБЩ./С",
|
||||
"HEARD": "СЛЫШНО",
|
||||
"POSITIONED": "С КООРД.",
|
||||
"SDR VOL": "ГРОМК. SDR",
|
||||
"LOCAL RTL-SDR": "ЛОКАЛЬНЫЙ RTL-SDR",
|
||||
"Cyber sonar contact scanner": "Кибер-сонар: сканер контактов",
|
||||
"Sonar ring count": "Число колец сонара",
|
||||
"Sonar ring range": "Дальность колец сонара",
|
||||
"Sonar intensity": "Интенсивность сонара",
|
||||
"Cyber contact and label opacity": "Непрозрачность контактов и меток в режиме «Кибер»",
|
||||
"Minimum opacity for map dots, icons, models, brackets, and labels between sonar passes": "Минимальная непрозрачность точек, значков, моделей, рамок и меток на карте между проходами сонара",
|
||||
"Sonar sector width": "Ширина сектора сонара",
|
||||
"Rings": "Кольца",
|
||||
"Range": "Дальность",
|
||||
"Power": "Мощность",
|
||||
"Opacity": "Непрозрачность",
|
||||
"Sector": "Сектор",
|
||||
"Cyber": "Кибер",
|
||||
"Sonar": "Сонар",
|
||||
"Turn Street Level on or off": "Включить или выключить «Уличные снимки»",
|
||||
"Toggle straight-down and tilted map views": "Переключить вид карты: сверху или с наклоном",
|
||||
"Reset map to north up": "Повернуть карту севером вверх",
|
||||
"Reset map bearing to north": "Сбросить азимут карты на север",
|
||||
"Rain radar": "Радар осадков",
|
||||
"Lightning density": "Плотность молний",
|
||||
"LATEST · newest per product": "ПОСЛЕДНИЕ · самые свежие по каждому продукту",
|
||||
"Forecast · valid {valid} · issued {issued}": "Прогноз · действует на {valid} · выпущен {issued}",
|
||||
"{detail} · Does not follow history": "{detail} · Не следует истории",
|
||||
"Forecast · valid {valid}": "Прогноз · действует на {valid}",
|
||||
"{time} · {minutes} min ago": "{time} · {minutes} мин назад",
|
||||
"Active weather": "Активная погода",
|
||||
"MINI": "MINI",
|
||||
"STD": "СТД",
|
||||
"standard": "стандартную",
|
||||
"mini": "mini",
|
||||
"COST UNKNOWN": "СТОИМОСТЬ НЕИЗВЕСТНА",
|
||||
"ChatGPT OAuth session: {responses} response(s), {input} input and {output} output tokens, {captions} caption transcription(s) reported.": "Сессия ChatGPT OAuth: ответов — {responses}, входных токенов — {input}, выходных токенов — {output}, транскрипций субтитров — {captions}.",
|
||||
"USD cost and the API spend cap are unavailable for this auth mode.": "Стоимость в USD и лимит расходов API недоступны в этом режиме авторизации.",
|
||||
"Usage is incomplete because a response was still in flight when the session ended.": "Данные об использовании неполные: к моменту завершения сессии один из ответов ещё не был получен.",
|
||||
"Toggle voice control": "Включить или выключить голосовое управление",
|
||||
"Activate to toggle voice": "Активируйте, чтобы включить или выключить голос",
|
||||
"Voice active": "Голос активен",
|
||||
"Voice could not be started.": "Не удалось запустить голос.",
|
||||
"degraded": "с перебоями",
|
||||
"stale": "устарел",
|
||||
"partial": "частично",
|
||||
"fallback": "резерв",
|
||||
"unavailable": "недоступен",
|
||||
"{label} on": "{label}: вкл.",
|
||||
"{label} off": "{label}: выкл.",
|
||||
"Frame aircraft": "Кадр: самолёты",
|
||||
"Frame military aircraft": "Кадр: военные самолёты",
|
||||
"Frame ships": "Кадр: суда",
|
||||
"Frame satellites": "Кадр: спутники",
|
||||
"Frame contacts": "Кадр: контакты",
|
||||
"{count} aircraft in frame": {
|
||||
"one": "{count} самолёт в кадре",
|
||||
"few": "{count} самолёта в кадре",
|
||||
"many": "{count} самолётов в кадре",
|
||||
"other": "{count} самолёта в кадре"
|
||||
},
|
||||
"{count} military aircraft in frame": {
|
||||
"one": "{count} военный самолёт в кадре",
|
||||
"few": "{count} военных самолёта в кадре",
|
||||
"many": "{count} военных самолётов в кадре",
|
||||
"other": "{count} военного самолёта в кадре"
|
||||
},
|
||||
"{count} ships in frame": {
|
||||
"one": "{count} судно в кадре",
|
||||
"few": "{count} судна в кадре",
|
||||
"many": "{count} судов в кадре",
|
||||
"other": "{count} судна в кадре"
|
||||
},
|
||||
"{count} satellites in frame": {
|
||||
"one": "{count} спутник в кадре",
|
||||
"few": "{count} спутника в кадре",
|
||||
"many": "{count} спутников в кадре",
|
||||
"other": "{count} спутника в кадре"
|
||||
},
|
||||
"{count} contacts in frame": {
|
||||
"one": "{count} контакт в кадре",
|
||||
"few": "{count} контакта в кадре",
|
||||
"many": "{count} контактов в кадре",
|
||||
"other": "{count} контакта в кадре"
|
||||
},
|
||||
"Nearest military aircraft": "Ближайшие военные самолёты",
|
||||
"Nearest ships": "Ближайшие суда",
|
||||
"Nearest satellites": "Ближайшие спутники",
|
||||
"Nearest aircraft": "Ближайшие самолёты",
|
||||
"{first} and {second}": "{first} и {second}",
|
||||
"Camera range {range}": "Дальность камеры: {range}",
|
||||
"Within {radius} of the view": "В радиусе {radius} от вида",
|
||||
"Labels on": "Метки включены",
|
||||
"Framing uses the nearest 80 loaded contacts": "В кадр берутся 80 ближайших загруженных контактов",
|
||||
"{distance} from {location}": "{location}: {distance}",
|
||||
"Altitude {altitude}": "Высота {altitude}",
|
||||
"{origin} to {destination}": "{origin} → {destination}",
|
||||
"from {origin}": "откуда: {origin}",
|
||||
"to {destination}": "куда: {destination}",
|
||||
"Registration {registration}": "Регистрация {registration}",
|
||||
"Route {route}": "Маршрут {route}",
|
||||
"View state": "Состояние вида",
|
||||
"Marked {count} places": {
|
||||
"one": "Отмечено {count} место",
|
||||
"few": "Отмечено {count} места",
|
||||
"many": "Отмечено {count} мест",
|
||||
"other": "Отмечено {count} места"
|
||||
},
|
||||
"Nothing marked": "Ничего не отмечено",
|
||||
"Not found: {places}": "Не найдено: {places}",
|
||||
"Tracing outlines": "Построение контуров",
|
||||
"Mark the map": "Отметить на карте",
|
||||
"Mark {targets} +{more}": "Отметить {targets} +{more}",
|
||||
"Mark {targets}": "Отметить {targets}",
|
||||
"Fly to {place}": "Перелёт: {place}",
|
||||
"Nearest aircraft to {place}": "Ближайшие самолёты: {place}",
|
||||
"Read the view": "Прочитать вид",
|
||||
"Check view state": "Проверить состояние вида",
|
||||
"Frame {target}": "Кадр: {target}",
|
||||
"Finding places": "Поиск мест",
|
||||
"Tracing outline": "Построение контура",
|
||||
"Looking up place": "Поиск места",
|
||||
"Flying": "Перелёт",
|
||||
"Turning on layer": "Включение слоя",
|
||||
"Loading aircraft": "Загрузка самолётов",
|
||||
"Picking nearest": "Выбор ближайших",
|
||||
"Working": "Выполняется",
|
||||
"{value} feet": "{value} фт",
|
||||
"nominal": "в норме",
|
||||
"Source: {label}": "Источник: {label}",
|
||||
"N": "С",
|
||||
"NNE": "ССВ",
|
||||
"NE": "СВ",
|
||||
"ENE": "ВСВ",
|
||||
"E": "В",
|
||||
"ESE": "ВЮВ",
|
||||
"SE": "ЮВ",
|
||||
"SSE": "ЮЮВ",
|
||||
"S": "Ю",
|
||||
"SSW": "ЮЮЗ",
|
||||
"SW": "ЮЗ",
|
||||
"WSW": "ЗЮЗ",
|
||||
"W": "З",
|
||||
"WNW": "ЗСЗ",
|
||||
"NW": "СЗ",
|
||||
"NNW": "ССЗ",
|
||||
"Local ADS-B": "Локальный ADS-B",
|
||||
"Transit": "Общественный транспорт",
|
||||
"Recent Imagery": "Свежие снимки",
|
||||
"Fire Perimeters": "Контуры пожаров",
|
||||
"Cyclone advisories": "Сводки о циклонах",
|
||||
"EDIT DETAILS": "ИЗМЕНИТЬ ДАННЫЕ",
|
||||
"SHARE SCENE": "ПОДЕЛИТЬСЯ СЦЕНОЙ",
|
||||
"ON/OFF": "ВКЛ/ВЫКЛ",
|
||||
"Voice assistant": "Голосовой ассистент",
|
||||
"VOICE": "ГОЛОС",
|
||||
"Dismiss voice card": "Закрыть карточку голоса",
|
||||
"YOU": "ВЫ",
|
||||
"THIS": "ЭТО",
|
||||
"HERE": "ЗДЕСЬ",
|
||||
"Plan": "План",
|
||||
"Referenced items": "Упомянутые объекты",
|
||||
"NOTES & SOURCES": "ЗАМЕТКИ И ИСТОЧНИКИ",
|
||||
"ΔN": "ΔС",
|
||||
"ΔE": "ΔВ"
|
||||
}
|
||||
+26
-17
@@ -128,12 +128,17 @@ function updateCloudVoiceAuthControl(auth, busy = false) {
|
||||
const state = auth.querySelector('.key-setup-cloud-auth-state');
|
||||
const toggle = auth.querySelector('[data-cloud-voice-auth-toggle]');
|
||||
state.textContent =
|
||||
mode === 'oauth' ? 'VOICE AUTH · CHATGPT OAUTH' : 'VOICE AUTH · API KEY';
|
||||
toggle.textContent = mode === 'oauth' ? 'USE API KEY' : 'USE CHATGPT OAUTH';
|
||||
mode === 'oauth'
|
||||
? t('VOICE AUTH · CHATGPT OAUTH')
|
||||
: t('VOICE AUTH · API KEY');
|
||||
toggle.textContent =
|
||||
mode === 'oauth' ? t('USE API KEY') : t('USE CHATGPT OAUTH');
|
||||
toggle.title =
|
||||
mode === 'oauth'
|
||||
? 'Use OPENAI_API_KEY for the next cloud voice session'
|
||||
: 'Use the signed-in local ChatGPT/Codex OAuth session for the next cloud voice session';
|
||||
? t('Use OPENAI_API_KEY for the next cloud voice session')
|
||||
: t(
|
||||
'Use the signed-in local ChatGPT/Codex OAuth session for the next cloud voice session',
|
||||
);
|
||||
// Keep the control focusable while the delegated handler blocks repeats.
|
||||
toggle.setAttribute('aria-disabled', String(busy));
|
||||
}
|
||||
@@ -153,21 +158,22 @@ function buildRow(documentRef, key) {
|
||||
led.className = 'key-setup-led';
|
||||
led.setAttribute('aria-hidden', 'true');
|
||||
const title = documentRef.createElement('strong');
|
||||
title.textContent = key.title;
|
||||
title.textContent = key.title; // i18n-ignore: provider product name
|
||||
const tier = documentRef.createElement('span');
|
||||
tier.className = 'key-setup-tier';
|
||||
tier.textContent = TIER_DOTS[key.tier] || '';
|
||||
tier.title =
|
||||
key.tier === 'metered'
|
||||
? 'Metered — a billing-enabled account'
|
||||
: 'Free key — register, paste, done';
|
||||
? t('Metered — a billing-enabled account')
|
||||
: t('Free key — register, paste, done');
|
||||
head.append(led, title, tier);
|
||||
if (key.clientExposed) {
|
||||
const exposed = documentRef.createElement('span');
|
||||
exposed.className = 'key-setup-exposed';
|
||||
exposed.textContent = 'browser-side';
|
||||
exposed.title =
|
||||
'This key runs in the browser by design — restrict it at the provider (see SECURITY.md)';
|
||||
exposed.textContent = t('browser-side');
|
||||
exposed.title = t(
|
||||
'This key runs in the browser by design — restrict it at the provider (see SECURITY.md)',
|
||||
);
|
||||
head.append(exposed);
|
||||
}
|
||||
if (external) {
|
||||
@@ -175,9 +181,10 @@ function buildRow(documentRef, key) {
|
||||
// facts this panel reports, never values it rewrites or deletes.
|
||||
const badge = documentRef.createElement('span');
|
||||
badge.className = 'key-setup-external';
|
||||
badge.textContent = 'configured externally';
|
||||
badge.title =
|
||||
'Supplied by your environment, Keychain, or launcher — change it where it was set';
|
||||
badge.textContent = t('configured externally');
|
||||
badge.title = t(
|
||||
'Supplied by your environment, Keychain, or launcher — change it where it was set',
|
||||
);
|
||||
head.append(badge);
|
||||
}
|
||||
const get = documentRef.createElement('a');
|
||||
@@ -185,7 +192,7 @@ function buildRow(documentRef, key) {
|
||||
get.href = key.getUrl;
|
||||
get.target = '_blank';
|
||||
get.rel = 'noopener noreferrer';
|
||||
get.textContent = key.set ? 'MANAGE ↗' : 'GET KEY ↗';
|
||||
get.textContent = key.set ? t('MANAGE ↗') : t('GET KEY ↗');
|
||||
head.append(get);
|
||||
|
||||
const unlocks = documentRef.createElement('p');
|
||||
@@ -206,8 +213,8 @@ function buildRow(documentRef, key) {
|
||||
input.dataset.envVar = envVar;
|
||||
input.setAttribute('aria-label', envVar);
|
||||
input.placeholder = key.set
|
||||
? `${envVar} saved — paste to replace`
|
||||
: `paste ${envVar}`;
|
||||
? t('{envVar} saved — paste to replace', { envVar })
|
||||
: t('paste {envVar}', { envVar });
|
||||
fields.append(input);
|
||||
}
|
||||
if (key.managed === 'file') {
|
||||
@@ -328,7 +335,9 @@ export async function initKeySetup({
|
||||
if (disposed) return;
|
||||
status = nextStatus;
|
||||
chipLabel.textContent = keySetupChipLabel(status);
|
||||
chip.title = `Project keys: ${keySetupChipLabel(status)}`;
|
||||
chip.title = t('Project keys: {status}', {
|
||||
status: keySetupChipLabel(status),
|
||||
});
|
||||
chip.setAttribute('aria-label', chip.title);
|
||||
// Fully powered is the owner's clean screen: the chip retires. The dialog
|
||||
// stays reachable this session (and via ?setup=1) to swap or verify keys.
|
||||
|
||||
+10
-10
@@ -39,7 +39,7 @@ const msg = (text) => text;
|
||||
export const KEY_SETUP_KEYS = Object.freeze([
|
||||
Object.freeze({
|
||||
id: 'google-maps',
|
||||
title: 'GOOGLE MAPS',
|
||||
title: 'GOOGLE MAPS', // i18n-ignore: provider product name
|
||||
unlocks: msg('The photorealistic 3D planet + place search'),
|
||||
getUrl: 'https://developers.google.com/maps/documentation/tile/get-api-key',
|
||||
envVars: Object.freeze(['GOOGLE_MAPS_API_KEY']),
|
||||
@@ -48,7 +48,7 @@ export const KEY_SETUP_KEYS = Object.freeze([
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'google-maps-server',
|
||||
title: 'GOOGLE MAPS — SERVER',
|
||||
title: 'GOOGLE MAPS — SERVER', // i18n-ignore: provider product name; row is hidden
|
||||
unlocks: msg(
|
||||
'Places context + Street View fallback; optional separate key',
|
||||
),
|
||||
@@ -60,7 +60,7 @@ export const KEY_SETUP_KEYS = Object.freeze([
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'openai',
|
||||
title: 'OPENAI',
|
||||
title: 'OPENAI', // i18n-ignore: provider product name
|
||||
unlocks: msg('Voice control — API key or ChatGPT OAuth'),
|
||||
getUrl: 'https://platform.openai.com/api-keys',
|
||||
envVars: Object.freeze(['OPENAI_API_KEY']),
|
||||
@@ -68,7 +68,7 @@ export const KEY_SETUP_KEYS = Object.freeze([
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'aisstream',
|
||||
title: 'AISSTREAM',
|
||||
title: 'AISSTREAM', // i18n-ignore: provider product name
|
||||
unlocks: msg('Live ships, worldwide'),
|
||||
getUrl: 'https://aisstream.io',
|
||||
envVars: Object.freeze(['AISSTREAM_API_KEY']),
|
||||
@@ -76,7 +76,7 @@ export const KEY_SETUP_KEYS = Object.freeze([
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'firms',
|
||||
title: 'NASA FIRMS',
|
||||
title: 'NASA FIRMS', // i18n-ignore: provider product name
|
||||
unlocks: msg('Live active-fire detections'),
|
||||
getUrl: 'https://firms.modaps.eosdis.nasa.gov/api/map_key/',
|
||||
envVars: Object.freeze(['FIRMS_MAP_KEY']),
|
||||
@@ -84,7 +84,7 @@ export const KEY_SETUP_KEYS = Object.freeze([
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'tomtom',
|
||||
title: 'TOMTOM',
|
||||
title: 'TOMTOM', // i18n-ignore: provider product name
|
||||
unlocks: msg('Real live traffic (keyless runs a simulation)'),
|
||||
getUrl: 'https://my.tomtom.com/keys',
|
||||
envVars: Object.freeze(['TOMTOM_API_KEY']),
|
||||
@@ -92,7 +92,7 @@ export const KEY_SETUP_KEYS = Object.freeze([
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'cesium-ion',
|
||||
title: 'CESIUM ION',
|
||||
title: 'CESIUM ION', // i18n-ignore: provider product name
|
||||
unlocks: msg('Bing imagery map stacks + world terrain'),
|
||||
getUrl: 'https://ion.cesium.com/tokens',
|
||||
envVars: Object.freeze(['CESIUM_ION_TOKEN']),
|
||||
@@ -101,7 +101,7 @@ export const KEY_SETUP_KEYS = Object.freeze([
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'opensky',
|
||||
title: 'OPENSKY',
|
||||
title: 'OPENSKY', // i18n-ignore: provider product name
|
||||
unlocks: msg('More flight-polling credits (anonymous works without)'),
|
||||
getUrl: 'https://opensky-network.org',
|
||||
envVars: Object.freeze(['OPENSKY_CLIENT_ID', 'OPENSKY_CLIENT_SECRET']),
|
||||
@@ -109,7 +109,7 @@ export const KEY_SETUP_KEYS = Object.freeze([
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'mapillary',
|
||||
title: 'MAPILLARY',
|
||||
title: 'MAPILLARY', // i18n-ignore: provider product name
|
||||
unlocks: msg(
|
||||
'Street-level imagery and coverage in the Street Level layer. Free: register an app in the Mapillary developer dashboard and paste its Client Token',
|
||||
),
|
||||
@@ -120,7 +120,7 @@ export const KEY_SETUP_KEYS = Object.freeze([
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'launch-library',
|
||||
title: 'LAUNCH LIBRARY',
|
||||
title: 'LAUNCH LIBRARY', // i18n-ignore: provider product name
|
||||
unlocks: msg('Higher space-missions request allowance'),
|
||||
getUrl: 'https://thespacedevs.com',
|
||||
envVars: Object.freeze(['LL2_API_TOKEN']),
|
||||
|
||||
+32
-19
@@ -18,6 +18,7 @@ import {
|
||||
alprCreditMarkup,
|
||||
} from './model.js';
|
||||
import { createAlprPresentation } from './presentation.js';
|
||||
import { t, tn, msg } from '../../i18n/index.js';
|
||||
|
||||
/**
|
||||
* Own one layer's requests, records, display and viewer subscriptions.
|
||||
@@ -293,7 +294,7 @@ export function createAlprCamerasLayer({ source, services } = {}) {
|
||||
id: LAYER_ID,
|
||||
name: 'ALPR Cameras',
|
||||
icon: '📷',
|
||||
source: source.label || 'Mapped camera locations',
|
||||
source: t(source.label || msg('Mapped camera locations')),
|
||||
updateInterval: 0,
|
||||
statsRefreshInterval: 1000,
|
||||
init(viewer) {
|
||||
@@ -372,10 +373,12 @@ export function createAlprCamerasLayer({ source, services } = {}) {
|
||||
chips: [
|
||||
{
|
||||
id: 'find-camera',
|
||||
label: 'SHOW NEAREST',
|
||||
label: t('SHOW NEAREST'),
|
||||
title: state.viewer?.trackedEntity
|
||||
? 'Stop following the current object before navigating to a camera'
|
||||
: 'Move to the nearest loaded camera and show its details',
|
||||
? t(
|
||||
'Stop following the current object before navigating to a camera',
|
||||
)
|
||||
: t('Move to the nearest loaded camera and show its details'),
|
||||
disabled:
|
||||
!state.enabled || !count || Boolean(state.viewer?.trackedEntity),
|
||||
onClick: focusNearest,
|
||||
@@ -383,11 +386,12 @@ export function createAlprCamerasLayer({ source, services } = {}) {
|
||||
],
|
||||
legend: [
|
||||
{
|
||||
label: 'Camera badges',
|
||||
label: t('Camera badges'),
|
||||
color: ALPR_COLOR,
|
||||
count,
|
||||
blurb:
|
||||
blurb: t(
|
||||
'Cyan cameras turn coral when selected. Wedges illustrate mapped direction, not measured coverage. Nearby cameras may be outside the screen.',
|
||||
),
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -428,11 +432,18 @@ export function createAlprCamerasLayer({ source, services } = {}) {
|
||||
count: state.dataSource?.entities.values.length || 0,
|
||||
countLabel:
|
||||
state.enabled && !state.noCoverage && state.status !== 'zoom-in'
|
||||
? `${state.dataSource?.entities.values.length || 0} nearby${
|
||||
Number.isInteger(state.onScreen)
|
||||
? ` · ${state.onScreen} on screen`
|
||||
: ''
|
||||
}`
|
||||
? Number.isInteger(state.onScreen)
|
||||
? tn(
|
||||
state.dataSource?.entities.values.length || 0,
|
||||
'{count} nearby · {onScreen} on screen',
|
||||
'{count} nearby · {onScreen} on screen',
|
||||
{ onScreen: state.onScreen },
|
||||
)
|
||||
: tn(
|
||||
state.dataSource?.entities.values.length || 0,
|
||||
'{count} nearby',
|
||||
'{count} nearby',
|
||||
)
|
||||
: '',
|
||||
noCoverage: Boolean(state.noCoverage),
|
||||
lastUpdate: state.lastUpdate,
|
||||
@@ -451,20 +462,22 @@ export function createAlprCamerasLayer({ source, services } = {}) {
|
||||
: 0,
|
||||
loadingLabel: state.loading
|
||||
? state.retrying
|
||||
? 'retrying mapped ALPR cameras'
|
||||
: 'loading mapped ALPR cameras'
|
||||
? t('retrying mapped ALPR cameras')
|
||||
: t('loading mapped ALPR cameras')
|
||||
: state.noCoverage
|
||||
? 'No ALPR data for this area — US and Canada only'
|
||||
? t('No ALPR data for this area — US and Canada only')
|
||||
: state.status === 'zoom-in'
|
||||
? 'Zoom in to load mapped cameras'
|
||||
? t('Zoom in to load mapped cameras')
|
||||
: [
|
||||
state.stale ? 'Showing cached locations' : '',
|
||||
state.stale ? t('Showing cached locations') : '',
|
||||
state.saturated || state.renderSaturated
|
||||
? 'Coverage limited — zoom in'
|
||||
? t('Coverage limited — zoom in')
|
||||
: '',
|
||||
state.status === 'empty'
|
||||
? t('No ALPR data for this area')
|
||||
: '',
|
||||
state.status === 'empty' ? 'No ALPR data for this area' : '',
|
||||
state.status === 'ready' && state.onScreen === 0
|
||||
? 'None on screen — nearby cameras are outside the view'
|
||||
? t('None on screen — nearby cameras are outside the view')
|
||||
: '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
MARKER_SCALE_BY_DISTANCE,
|
||||
} from './policy.js';
|
||||
import { boxContains } from './model.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
import { createAlprFloorResolver } from './floors.js';
|
||||
import { createAlprOverlay } from './overlay.js';
|
||||
import {
|
||||
@@ -451,7 +452,7 @@ export function createAlprPresentation({ state, services, source }) {
|
||||
layerName: 'ALPR Cameras',
|
||||
source:
|
||||
source.attribution?.description || source.label || 'Camera source',
|
||||
label: 'ALPR camera',
|
||||
label: t('ALPR camera'),
|
||||
latitude: record.latitude,
|
||||
longitude: record.longitude,
|
||||
properties: {
|
||||
|
||||
@@ -16,6 +16,9 @@ import {
|
||||
} from '../../sources/vectorTiles.js';
|
||||
import { buildOverpassQuery, normalizeAlprNode } from './records.js';
|
||||
import { decodeAlprTile } from './tileRecords.js';
|
||||
// Marks text for i18n extraction; the UI translates it with t(). Kept local
|
||||
// because AI tool services import this module outside the browser.
|
||||
const msg = (text) => text;
|
||||
|
||||
/**
|
||||
* Finest attributed extract zoom whose tiles for `box` fit the per-view cap,
|
||||
@@ -184,7 +187,7 @@ export function createAlprTileSource({
|
||||
for (const source of sources) source.clear();
|
||||
precision.clear();
|
||||
},
|
||||
label: 'OpenStreetMap · community mapped',
|
||||
label: msg('OpenStreetMap · community mapped'),
|
||||
attribution: {
|
||||
name: 'OpenStreetMap',
|
||||
description: '© OpenStreetMap contributors, ODbL',
|
||||
@@ -260,7 +263,7 @@ export function createOverpassAlprSource({
|
||||
}
|
||||
return {
|
||||
fetch: fetchAlprNodes,
|
||||
label: 'OpenStreetMap · community mapped',
|
||||
label: msg('OpenStreetMap · community mapped'),
|
||||
attribution: {
|
||||
name: 'OpenStreetMap',
|
||||
description: 'OpenStreetMap contributors (ODbL 1.0; community mapped)',
|
||||
|
||||
+18
-18
@@ -179,7 +179,7 @@ export const CAMERA_SEEDS = [
|
||||
id: 'nyc-midtown-w',
|
||||
cityId: 'nyc',
|
||||
poiIndex: 1,
|
||||
label: 'Midtown West @ 34th',
|
||||
label: 'Midtown West @ 34th', // i18n-ignore: camera name
|
||||
offsetNorthM: 120,
|
||||
offsetEastM: -70,
|
||||
headingDeg: 206,
|
||||
@@ -191,7 +191,7 @@ export const CAMERA_SEEDS = [
|
||||
id: 'nyc-wtc-n',
|
||||
cityId: 'nyc',
|
||||
poiIndex: 2,
|
||||
label: 'WTC North Plaza',
|
||||
label: 'WTC North Plaza', // i18n-ignore: camera name
|
||||
offsetNorthM: 95,
|
||||
offsetEastM: 34,
|
||||
headingDeg: 164,
|
||||
@@ -203,7 +203,7 @@ export const CAMERA_SEEDS = [
|
||||
id: 'nyc-times-square-ne',
|
||||
cityId: 'nyc',
|
||||
poiIndex: 1,
|
||||
label: 'Times Sq Northeast',
|
||||
label: 'Times Sq Northeast', // i18n-ignore: camera name
|
||||
offsetNorthM: 230,
|
||||
offsetEastM: 120,
|
||||
headingDeg: 218,
|
||||
@@ -216,7 +216,7 @@ export const CAMERA_SEEDS = [
|
||||
id: 'sf-market-5th',
|
||||
cityId: 'sf',
|
||||
poiIndex: 2,
|
||||
label: 'Market & 5th',
|
||||
label: 'Market & 5th', // i18n-ignore: camera name
|
||||
offsetNorthM: -160,
|
||||
offsetEastM: 80,
|
||||
headingDeg: 320,
|
||||
@@ -228,7 +228,7 @@ export const CAMERA_SEEDS = [
|
||||
id: 'sf-financial-district',
|
||||
cityId: 'sf',
|
||||
poiIndex: 1,
|
||||
label: 'SF Financial Core',
|
||||
label: 'SF Financial Core', // i18n-ignore: camera name
|
||||
offsetNorthM: 110,
|
||||
offsetEastM: 52,
|
||||
headingDeg: 205,
|
||||
@@ -241,7 +241,7 @@ export const CAMERA_SEEDS = [
|
||||
id: 'tokyo-shibuya-scramble',
|
||||
cityId: 'tokyo',
|
||||
poiIndex: 4,
|
||||
label: 'Shibuya Crossing',
|
||||
label: 'Shibuya Crossing', // i18n-ignore: camera name
|
||||
offsetNorthM: 180,
|
||||
offsetEastM: 46,
|
||||
headingDeg: 18,
|
||||
@@ -253,7 +253,7 @@ export const CAMERA_SEEDS = [
|
||||
id: 'tokyo-ginza-core',
|
||||
cityId: 'tokyo',
|
||||
poiIndex: 0,
|
||||
label: 'Ginza Core',
|
||||
label: 'Ginza Core', // i18n-ignore: camera name
|
||||
offsetNorthM: -180,
|
||||
offsetEastM: 150,
|
||||
headingDeg: 245,
|
||||
@@ -265,7 +265,7 @@ export const CAMERA_SEEDS = [
|
||||
id: 'tokyo-asakusa-n',
|
||||
cityId: 'tokyo',
|
||||
poiIndex: 3,
|
||||
label: 'Asakusa North Gate',
|
||||
label: 'Asakusa North Gate', // i18n-ignore: camera name
|
||||
offsetNorthM: 110,
|
||||
offsetEastM: -65,
|
||||
headingDeg: 192,
|
||||
@@ -278,7 +278,7 @@ export const CAMERA_SEEDS = [
|
||||
id: 'london-city-a1',
|
||||
cityId: 'london',
|
||||
poiIndex: 4,
|
||||
label: 'City Cluster A1',
|
||||
label: 'City Cluster A1', // i18n-ignore: camera name
|
||||
offsetNorthM: 80,
|
||||
offsetEastM: 65,
|
||||
headingDeg: 220,
|
||||
@@ -290,7 +290,7 @@ export const CAMERA_SEEDS = [
|
||||
id: 'london-soho-core',
|
||||
cityId: 'london',
|
||||
poiIndex: 2,
|
||||
label: 'Soho Core',
|
||||
label: 'Soho Core', // i18n-ignore: camera name
|
||||
offsetNorthM: 210,
|
||||
offsetEastM: 120,
|
||||
headingDeg: 206,
|
||||
@@ -303,7 +303,7 @@ export const CAMERA_SEEDS = [
|
||||
id: 'paris-rivoli',
|
||||
cityId: 'paris',
|
||||
poiIndex: 4,
|
||||
label: 'Rue de Rivoli',
|
||||
label: 'Rue de Rivoli', // i18n-ignore: camera name
|
||||
offsetNorthM: 55,
|
||||
offsetEastM: 85,
|
||||
headingDeg: 248,
|
||||
@@ -315,7 +315,7 @@ export const CAMERA_SEEDS = [
|
||||
id: 'paris-champs-n',
|
||||
cityId: 'paris',
|
||||
poiIndex: 1,
|
||||
label: 'Champs-Élysées North',
|
||||
label: 'Champs-Élysées North', // i18n-ignore: camera name
|
||||
offsetNorthM: 130,
|
||||
offsetEastM: -38,
|
||||
headingDeg: 175,
|
||||
@@ -328,7 +328,7 @@ export const CAMERA_SEEDS = [
|
||||
id: 'dc-mall-center',
|
||||
cityId: 'dc',
|
||||
poiIndex: 1,
|
||||
label: 'National Mall Center',
|
||||
label: 'National Mall Center', // i18n-ignore: camera name
|
||||
offsetNorthM: 120,
|
||||
offsetEastM: 20,
|
||||
headingDeg: 258,
|
||||
@@ -340,7 +340,7 @@ export const CAMERA_SEEDS = [
|
||||
id: 'dc-pentagon-s',
|
||||
cityId: 'dc',
|
||||
poiIndex: 3,
|
||||
label: 'Pentagon South',
|
||||
label: 'Pentagon South', // i18n-ignore: camera name
|
||||
offsetNorthM: -100,
|
||||
offsetEastM: 92,
|
||||
headingDeg: 14,
|
||||
@@ -353,7 +353,7 @@ export const CAMERA_SEEDS = [
|
||||
id: 'dubai-difc-loop',
|
||||
cityId: 'dubai',
|
||||
poiIndex: 4,
|
||||
label: 'DIFC Loop',
|
||||
label: 'DIFC Loop', // i18n-ignore: camera name
|
||||
offsetNorthM: 92,
|
||||
offsetEastM: -45,
|
||||
headingDeg: 196,
|
||||
@@ -365,7 +365,7 @@ export const CAMERA_SEEDS = [
|
||||
id: 'dubai-downtown-east',
|
||||
cityId: 'dubai',
|
||||
poiIndex: 0,
|
||||
label: 'Downtown East',
|
||||
label: 'Downtown East', // i18n-ignore: camera name
|
||||
offsetNorthM: -130,
|
||||
offsetEastM: 190,
|
||||
headingDeg: 322,
|
||||
@@ -378,7 +378,7 @@ export const CAMERA_SEEDS = [
|
||||
id: 'austin-congress-s',
|
||||
cityId: 'austin',
|
||||
poiIndex: 0,
|
||||
label: 'Congress Southbound',
|
||||
label: 'Congress Southbound', // i18n-ignore: camera name
|
||||
offsetNorthM: -165,
|
||||
offsetEastM: 40,
|
||||
headingDeg: 12,
|
||||
@@ -390,7 +390,7 @@ export const CAMERA_SEEDS = [
|
||||
id: 'austin-downtown-west',
|
||||
cityId: 'austin',
|
||||
poiIndex: 1,
|
||||
label: 'Downtown West',
|
||||
label: 'Downtown West', // i18n-ignore: camera name
|
||||
offsetNorthM: -120,
|
||||
offsetEastM: -160,
|
||||
headingDeg: 120,
|
||||
|
||||
@@ -14,23 +14,27 @@ import {
|
||||
CYCLONE_OVERLAY_SOURCE_ID,
|
||||
cycloneStormIdFromEntryId,
|
||||
} from './labels.js';
|
||||
import { t, tn, msg } from '../../i18n/index.js';
|
||||
|
||||
const utc = (value) =>
|
||||
value ? `${value.slice(5, 16).replace('T', ' ')} UTC` : 'Unavailable';
|
||||
const COVERAGE =
|
||||
'Atlantic and eastern/central North Pacific; not worldwide cyclone coverage.';
|
||||
value ? `${value.slice(5, 16).replace('T', ' ')} UTC` : t('Unavailable');
|
||||
const COVERAGE = msg(
|
||||
'Atlantic and eastern/central North Pacific; not worldwide cyclone coverage.',
|
||||
);
|
||||
const CLASSIFICATION_NAMES = Object.freeze({
|
||||
PTC: 'Potential tropical cyclone',
|
||||
HU: 'Hurricane',
|
||||
TS: 'Tropical storm',
|
||||
TD: 'Tropical depression',
|
||||
SS: 'Subtropical storm',
|
||||
SD: 'Subtropical depression',
|
||||
PTC: msg('Potential tropical cyclone'),
|
||||
HU: msg('Hurricane'),
|
||||
TS: msg('Tropical storm'),
|
||||
TD: msg('Tropical depression'),
|
||||
SS: msg('Subtropical storm'),
|
||||
SD: msg('Subtropical depression'),
|
||||
});
|
||||
const classificationName = (code) =>
|
||||
Object.hasOwn(CLASSIFICATION_NAMES, code) ? CLASSIFICATION_NAMES[code] : code;
|
||||
Object.hasOwn(CLASSIFICATION_NAMES, code)
|
||||
? t(CLASSIFICATION_NAMES[code])
|
||||
: code;
|
||||
const number = (value, unit) =>
|
||||
value === null ? 'Unavailable' : `${value} ${unit}`;
|
||||
value === null ? t('Unavailable') : `${value} ${unit}`;
|
||||
|
||||
/** Advisory status and coherent forecast geometry; selected through the shared row list. */
|
||||
export function createCyclonesLayer({
|
||||
@@ -316,41 +320,68 @@ export function createCyclonesLayer({
|
||||
const geometry =
|
||||
storm &&
|
||||
(coherentCycloneGeometry(storm)
|
||||
? 'Track and cone match this advisory'
|
||||
? t('Track and cone match this advisory')
|
||||
: storm.geometryStatus === 'pending'
|
||||
? `Track/cone awaiting advisory ${storm.advisoryNumber}`
|
||||
: 'Track/cone unavailable');
|
||||
? t('Track/cone awaiting advisory {number}', {
|
||||
number: storm.advisoryNumber,
|
||||
})
|
||||
: t('Track/cone unavailable'));
|
||||
const status =
|
||||
error ||
|
||||
(snapshot?.stale
|
||||
? 'Cached advisory · stale source'
|
||||
? t('Cached advisory · stale source')
|
||||
: loading
|
||||
? 'Loading advisories…'
|
||||
? t('Loading advisories…')
|
||||
: storm && !coherentCycloneGeometry(storm)
|
||||
? geometry
|
||||
: null);
|
||||
const stormCount = snapshot?.storms.length || 0;
|
||||
const activeStorms = tn(
|
||||
stormCount,
|
||||
'{count} active storm',
|
||||
'{count} active storms',
|
||||
);
|
||||
const position = storm
|
||||
? t('Position as of {time}', { time: utc(storm.positionAt) })
|
||||
: '';
|
||||
const intensity = storm
|
||||
? t('Maximum sustained wind: {wind} · Pressure: {pressure}', {
|
||||
wind: number(storm.windKt, 'kt'),
|
||||
pressure: number(storm.pressureHpa, 'hPa'),
|
||||
})
|
||||
: '';
|
||||
const detail = storm
|
||||
? `${storm.name} · ${classificationName(storm.classification)} · Advisory ${storm.advisoryNumber} · issued ${utc(storm.issuedAt)}`
|
||||
? t('{name} · {classification} · Advisory {number} · issued {time}', {
|
||||
name: storm.name,
|
||||
classification: classificationName(storm.classification),
|
||||
number: storm.advisoryNumber,
|
||||
time: utc(storm.issuedAt),
|
||||
})
|
||||
: empty
|
||||
? 'No active NHC/CPHC systems'
|
||||
: snapshot?.storms.length
|
||||
? `${snapshot.storms.length} active storm${snapshot.storms.length === 1 ? '' : 's'}`
|
||||
? t('No active NHC/CPHC systems')
|
||||
: stormCount
|
||||
? activeStorms
|
||||
: loading
|
||||
? 'Loading advisories…'
|
||||
: 'Advisories unavailable';
|
||||
? t('Loading advisories…')
|
||||
: t('Advisories unavailable');
|
||||
const controls = {
|
||||
readout: true,
|
||||
summary: {
|
||||
label: 'Cyclones · NHC / CPHC',
|
||||
coverage: 'Atlantic · E/C Pacific',
|
||||
compact: snapshot?.storms.length
|
||||
? `${snapshot.storms.length} active storm${snapshot.storms.length === 1 ? '' : 's'}${storm ? ` · ${storm.name} selected` : ''}`
|
||||
label: t('Cyclones · NHC / CPHC'),
|
||||
coverage: t('Atlantic · E/C Pacific'),
|
||||
compact: stormCount
|
||||
? storm
|
||||
? t('{storms} · {name} selected', {
|
||||
storms: activeStorms,
|
||||
name: storm.name,
|
||||
})
|
||||
: activeStorms
|
||||
: detail,
|
||||
actions: storm?.advisoryUrl
|
||||
? [
|
||||
{
|
||||
id: 'advisory',
|
||||
label: 'Official advisory ↗',
|
||||
label: t('Official advisory ↗'),
|
||||
href: storm.advisoryUrl,
|
||||
},
|
||||
]
|
||||
@@ -360,12 +391,12 @@ export function createCyclonesLayer({
|
||||
? [
|
||||
{
|
||||
id: 'position',
|
||||
text: `Position as of ${utc(storm.positionAt)}`,
|
||||
text: position,
|
||||
muted: true,
|
||||
},
|
||||
{
|
||||
id: 'intensity',
|
||||
text: `Maximum sustained wind: ${number(storm.windKt, 'kt')} · Pressure: ${number(storm.pressureHpa, 'hPa')}`,
|
||||
text: intensity,
|
||||
},
|
||||
{ id: 'geometry', text: geometry, muted: true },
|
||||
]
|
||||
@@ -375,12 +406,12 @@ export function createCyclonesLayer({
|
||||
units: 'kt',
|
||||
},
|
||||
list: {
|
||||
ariaLabel: 'Active NHC and CPHC cyclone advisories',
|
||||
ariaLabel: t('Active NHC and CPHC cyclone advisories'),
|
||||
items: (snapshot?.storms || []).map((item, index) => ({
|
||||
id: item.id,
|
||||
ordinal: index + 1,
|
||||
lead: item.basin,
|
||||
text: `${item.name} · ${classificationName(item.classification)} · ${item.windKt === null ? 'Wind unavailable' : `${item.windKt} kt`}`,
|
||||
text: `${item.name} · ${classificationName(item.classification)} · ${item.windKt === null ? t('Wind unavailable') : `${item.windKt} kt`}`,
|
||||
active: item.id === selectedId,
|
||||
params: { stormId: item.id, focus: true },
|
||||
})),
|
||||
@@ -388,15 +419,22 @@ export function createCyclonesLayer({
|
||||
chips: [],
|
||||
legend: storm
|
||||
? [
|
||||
{ label: 'Advisory center / forecast track', color: '#7fe6ed' },
|
||||
{ label: 'Center-track uncertainty cone', color: '#7fe6ed44' },
|
||||
{
|
||||
label: t('Advisory center / forecast track'),
|
||||
color: '#7fe6ed',
|
||||
},
|
||||
{
|
||||
label: t('Center-track uncertainty cone'),
|
||||
color: '#7fe6ed44',
|
||||
},
|
||||
]
|
||||
: [],
|
||||
info: storm
|
||||
? `${detail}\nPosition as of ${utc(storm.positionAt)}\nMaximum sustained wind: ${number(storm.windKt, 'kt')} · Pressure: ${number(storm.pressureHpa, 'hPa')}\n${geometry}${status && status !== geometry ? '\n' + status : ''}\n${snapshot.coverage}`
|
||||
: `${detail}${status ? '\n' + status : ''}\n${snapshot?.coverage || COVERAGE}`,
|
||||
infoTitle:
|
||||
? `${detail}\n${position}\n${intensity}\n${geometry}${status && status !== geometry ? '\n' + status : ''}\n${snapshot.coverage}`
|
||||
: `${detail}${status ? '\n' + status : ''}\n${snapshot?.coverage || t(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.',
|
||||
),
|
||||
};
|
||||
return controls;
|
||||
},
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
formatRouteDistance,
|
||||
formatRouteDuration,
|
||||
} from '../../data/routeSteps.js';
|
||||
import { t, msg } from '../../i18n/index.js';
|
||||
|
||||
export const DIRECTIONS_STEP_OVERLAY_SOURCE_ID = 'directions-step';
|
||||
export const DIRECTIONS_STEP_OVERLAY_SOURCE_OPTIONS = Object.freeze({
|
||||
@@ -31,9 +32,9 @@ export const DIRECTIONS_STEP_OVERLAY_SOURCE_OPTIONS = Object.freeze({
|
||||
|
||||
/** Travel modes, keyed by the `/api/route` profile name. */
|
||||
export const DIRECTIONS_MODES = Object.freeze({
|
||||
car: Object.freeze({ chip: 'DRIVE', word: 'Drive', icon: '🚗' }),
|
||||
foot: Object.freeze({ chip: 'WALK', word: 'Walk', icon: '🚶' }),
|
||||
bike: Object.freeze({ chip: 'BIKE', word: 'Bike', icon: '🚲' }),
|
||||
car: Object.freeze({ chip: msg('DRIVE'), word: msg('Drive'), icon: '🚗' }),
|
||||
foot: Object.freeze({ chip: msg('WALK'), word: msg('Walk'), icon: '🚶' }),
|
||||
bike: Object.freeze({ chip: msg('BIKE'), word: msg('Bike'), icon: '🚲' }),
|
||||
});
|
||||
export const DEFAULT_DIRECTIONS_MODE = 'car';
|
||||
|
||||
@@ -107,32 +108,35 @@ export function directionsRowControls(state) {
|
||||
const routing = status === 'routing';
|
||||
const chips = Object.entries(DIRECTIONS_MODES).map(([id, spec]) => ({
|
||||
id: `mode-${id}`,
|
||||
label: spec.chip,
|
||||
label: t(spec.chip),
|
||||
active: mode === id,
|
||||
state: mode === id ? 'active' : 'idle',
|
||||
title: `${spec.word} — reroute for ${spec.word.toLowerCase()}`,
|
||||
title: t('{mode} — reroute for {modeLower}', {
|
||||
mode: t(spec.word),
|
||||
modeLower: t(spec.word).toLowerCase(),
|
||||
}),
|
||||
params: { mode: id },
|
||||
}));
|
||||
chips.push({
|
||||
id: 'set-a',
|
||||
label: armed === 'a' ? 'CLICK MAP' : a ? 'A ✓' : 'SET A',
|
||||
label: armed === 'a' ? t('CLICK MAP') : a ? 'A ✓' : t('SET A'),
|
||||
active: armed === 'a',
|
||||
state: armed === 'a' ? 'active' : 'idle',
|
||||
title:
|
||||
armed === 'a'
|
||||
? 'Click a spot on the globe to place A (click again to cancel)'
|
||||
: 'Then click the globe to place the start',
|
||||
? t('Click a spot on the globe to place A (click again to cancel)')
|
||||
: t('Then click the globe to place the start'),
|
||||
params: { arm: armed === 'a' ? null : 'a' },
|
||||
});
|
||||
chips.push({
|
||||
id: 'set-b',
|
||||
label: armed === 'b' ? 'CLICK MAP' : b ? 'B ✓' : 'SET B',
|
||||
label: armed === 'b' ? t('CLICK MAP') : b ? 'B ✓' : t('SET B'),
|
||||
active: armed === 'b',
|
||||
state: armed === 'b' ? 'active' : 'idle',
|
||||
title:
|
||||
armed === 'b'
|
||||
? 'Click a spot on the globe to place B (click again to cancel)'
|
||||
: 'Then click the globe to place the destination',
|
||||
? t('Click a spot on the globe to place B (click again to cancel)')
|
||||
: t('Then click the globe to place the destination'),
|
||||
params: { arm: armed === 'b' ? null : 'b' },
|
||||
});
|
||||
chips.push({
|
||||
@@ -140,26 +144,28 @@ export function directionsRowControls(state) {
|
||||
label: '⇄',
|
||||
disabled: !(a && b) || routing,
|
||||
state: 'idle',
|
||||
title: 'Swap A and B',
|
||||
title: t('Swap A and B'),
|
||||
params: { swap: true },
|
||||
});
|
||||
const flying = Number.isInteger(flightStep);
|
||||
chips.push({
|
||||
id: 'fly',
|
||||
label: routing ? 'FLY ···' : flying ? 'FLYING' : 'FLY',
|
||||
label: routing ? t('FLY ···') : flying ? t('FLYING') : t('FLY'),
|
||||
disabled: !route || routing,
|
||||
busy: routing || flying,
|
||||
active: flying,
|
||||
state: routing || flying ? 'loading' : 'idle',
|
||||
title: route ? 'Fly the camera along the route' : 'Place A and B first',
|
||||
title: route
|
||||
? t('Fly the camera along the route')
|
||||
: t('Place A and B first'),
|
||||
params: { fly: true },
|
||||
});
|
||||
chips.push({
|
||||
id: 'clear',
|
||||
label: 'CLEAR',
|
||||
label: t('CLEAR'),
|
||||
disabled: !a && !b && !route,
|
||||
state: 'idle',
|
||||
title: 'Remove the route and both markers',
|
||||
title: t('Remove the route and both markers'),
|
||||
params: { clear: true },
|
||||
});
|
||||
return {
|
||||
@@ -179,8 +185,8 @@ export function directionsRowControls(state) {
|
||||
*/
|
||||
export const POINTER_TOOL_EXITS = Object.freeze({
|
||||
draw: Object.freeze({
|
||||
name: 'Draw',
|
||||
leave: 'press Escape twice to leave Draw',
|
||||
name: msg('Draw'),
|
||||
leave: msg('press Escape twice to leave Draw'),
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -201,9 +207,16 @@ export function pointerBlockedMessage(owner, which) {
|
||||
if (!id || id === DIRECTIONS_POINTER_OWNER) return null;
|
||||
const endpoint = which === 'b' ? 'B' : 'A';
|
||||
const known = POINTER_TOOL_EXITS[id];
|
||||
const name = known?.name || id;
|
||||
const leave = known?.leave || `turn ${name} off`;
|
||||
return `${name} is active — ${leave}, then set ${endpoint}`;
|
||||
if (!known)
|
||||
return t('{name} is active — turn {name} off, then set {endpoint}', {
|
||||
name: id,
|
||||
endpoint,
|
||||
});
|
||||
return t('{name} is active — {leave}, then set {endpoint}', {
|
||||
name: t(known.name),
|
||||
leave: t(known.leave),
|
||||
endpoint,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -285,13 +298,15 @@ export function directionsStepList({
|
||||
id: 'step-truncated',
|
||||
ordinal: items.length + 1,
|
||||
lead: '',
|
||||
text: `Only the first ${steps.length} turns of this route are shown`,
|
||||
text: t('Only the first {count} turns of this route are shown', {
|
||||
count: steps.length,
|
||||
}),
|
||||
active: false,
|
||||
current: false,
|
||||
disabled: true,
|
||||
});
|
||||
}
|
||||
return { ariaLabel: 'Turn-by-turn directions', items };
|
||||
return { ariaLabel: t('Turn-by-turn directions'), items };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -331,12 +346,12 @@ export function directionsStats(state) {
|
||||
armed,
|
||||
pointerBlocked,
|
||||
} = state;
|
||||
const source = 'OSM routing';
|
||||
const source = t('OSM routing');
|
||||
if (pointerBlocked) {
|
||||
return {
|
||||
count: route?.steps.length || 0,
|
||||
lastUpdate,
|
||||
error: 'Another map tool is using clicks — close it, then SET A again',
|
||||
error: t('Another map tool is using clicks — close it, then SET A again'),
|
||||
status: 'empty',
|
||||
source,
|
||||
};
|
||||
@@ -347,7 +362,7 @@ export function directionsStats(state) {
|
||||
lastUpdate,
|
||||
error: null,
|
||||
loading: true,
|
||||
loadingLabel: 'Routing…',
|
||||
loadingLabel: t('Routing…'),
|
||||
source,
|
||||
};
|
||||
}
|
||||
@@ -355,7 +370,7 @@ export function directionsStats(state) {
|
||||
return {
|
||||
count: 0,
|
||||
lastUpdate,
|
||||
error: error || 'No route found',
|
||||
error: error || t('No route found'),
|
||||
status: 'empty',
|
||||
source,
|
||||
};
|
||||
@@ -364,10 +379,17 @@ export function directionsStats(state) {
|
||||
// set (not only while loading), so the route summary and the placement
|
||||
// guidance ride on it; `coverage` carries the same text for stats readers.
|
||||
if (route) {
|
||||
const word = DIRECTIONS_MODES[mode]?.word || mode;
|
||||
const summary =
|
||||
`${formatRouteDistance(route.distanceM)} · ${formatRouteDuration(route.durationS)} · ${word}` +
|
||||
(route.stepsTruncated ? ` · first ${route.steps.length} turns` : '');
|
||||
const spec = DIRECTIONS_MODES[mode];
|
||||
const word = spec ? t(spec.word) : mode;
|
||||
const values = {
|
||||
distance: formatRouteDistance(route.distanceM),
|
||||
duration: formatRouteDuration(route.durationS),
|
||||
mode: word,
|
||||
count: route.steps.length,
|
||||
};
|
||||
const summary = route.stepsTruncated
|
||||
? t('{distance} · {duration} · {mode} · first {count} turns', values)
|
||||
: t('{distance} · {duration} · {mode}', values);
|
||||
return {
|
||||
count: route.steps.length,
|
||||
lastUpdate,
|
||||
@@ -378,10 +400,13 @@ export function directionsStats(state) {
|
||||
};
|
||||
}
|
||||
let coverage;
|
||||
if (armed) coverage = `Click the globe to place ${armed.toUpperCase()}`;
|
||||
else if (a && !b) coverage = 'SET B, then click the globe';
|
||||
else if (!a && b) coverage = 'SET A, then click the globe';
|
||||
else coverage = 'SET A, then click the globe';
|
||||
if (armed)
|
||||
coverage = t('Click the globe to place {endpoint}', {
|
||||
endpoint: armed.toUpperCase(),
|
||||
});
|
||||
else if (a && !b) coverage = t('SET B, then click the globe');
|
||||
else if (!a && b) coverage = t('SET A, then click the globe');
|
||||
else coverage = t('SET A, then click the globe');
|
||||
return {
|
||||
count: 0,
|
||||
lastUpdate,
|
||||
@@ -405,11 +430,18 @@ export function directionsStepCopy(steps, index) {
|
||||
const leg = [];
|
||||
if (step.distanceM > 0) leg.push(formatRouteDistance(step.distanceM));
|
||||
if (step.durationS > 0) leg.push(formatRouteDuration(step.durationS));
|
||||
const position = { step: index + 1, total: steps.length };
|
||||
details.push(
|
||||
`Step ${index + 1} of ${steps.length}${leg.length ? ` · then ${leg.join(' · ')}` : ''}`,
|
||||
leg.length
|
||||
? t('Step {step} of {total} · then {leg}', {
|
||||
...position,
|
||||
leg: leg.join(' · '),
|
||||
})
|
||||
: t('Step {step} of {total}', position),
|
||||
);
|
||||
const next = steps[index + 1];
|
||||
if (next) details.push(`Then: ${next.instruction}`);
|
||||
if (next)
|
||||
details.push(t('Then: {instruction}', { instruction: next.instruction }));
|
||||
return { title: step.instruction, details };
|
||||
}
|
||||
|
||||
@@ -840,10 +872,10 @@ export function createDirectionsLayer({ services, source }) {
|
||||
_status = 'error';
|
||||
_error =
|
||||
payload?.error === 'no route found'
|
||||
? 'No route found between A and B'
|
||||
? t('No route found between A and B')
|
||||
: payload?.error
|
||||
? `Routing failed: ${payload.error}`
|
||||
: 'No route found between A and B';
|
||||
? t('Routing failed: {error}', { error: payload.error })
|
||||
: t('No route found between A and B');
|
||||
return;
|
||||
}
|
||||
_route = route;
|
||||
@@ -860,10 +892,10 @@ export function createDirectionsLayer({ services, source }) {
|
||||
// happened instead.
|
||||
_error =
|
||||
error?.name === 'AbortError'
|
||||
? 'Routing timed out'
|
||||
? t('Routing timed out')
|
||||
: error?.name === 'TypeError'
|
||||
? 'Routing unavailable — no response from the routing service'
|
||||
: error?.message || 'Routing unavailable';
|
||||
? t('Routing unavailable — no response from the routing service')
|
||||
: error?.message || t('Routing unavailable');
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
if (_routeAbort === controller) _routeAbort = null;
|
||||
@@ -1058,7 +1090,7 @@ export function createDirectionsLayer({ services, source }) {
|
||||
[
|
||||
{
|
||||
type: 'route',
|
||||
label: 'Directions',
|
||||
label: t('Directions'),
|
||||
path: _route.geometry.map(([lon, lat]) => ({ lon, lat, height: 0 })),
|
||||
},
|
||||
],
|
||||
|
||||
@@ -18,7 +18,9 @@ export function createQueries({
|
||||
if (!fire) return null;
|
||||
return {
|
||||
id: fireDetectionKey(fire),
|
||||
label: `Fire · FRP ${components.model.formatFrp(fire.frp)} MW`,
|
||||
label: t('Fire · FRP {frp} MW', {
|
||||
frp: components.model.formatFrp(fire.frp),
|
||||
}),
|
||||
latitude: fire.lat,
|
||||
longitude: fire.lon,
|
||||
frp: fire.frp,
|
||||
@@ -115,7 +117,9 @@ export function createQueries({
|
||||
latitude: strongest.lat,
|
||||
longitude: strongest.lon,
|
||||
frp: strongest.frp,
|
||||
label: `Fire · FRP ${components.model.formatFrp(strongest.frp)} MW`,
|
||||
label: t('Fire · FRP {frp} MW', {
|
||||
frp: components.model.formatFrp(strongest.frp),
|
||||
}),
|
||||
};
|
||||
},
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
import * as Cesium from 'cesium';
|
||||
import { isPointerFree } from '../../data/inputOwnership.js';
|
||||
import { CONTEXT_TOP_N } from './policy.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
||||
export function createSelection({
|
||||
layerState,
|
||||
@@ -80,7 +81,7 @@ export function createSelection({
|
||||
requestWorldFocus({
|
||||
kind: 'fire',
|
||||
id: fireDetectionKey(fire),
|
||||
label: 'FIRE',
|
||||
label: t('FIRE'),
|
||||
position: components.model.firePosition(fire),
|
||||
});
|
||||
}
|
||||
@@ -192,7 +193,9 @@ export function createSelection({
|
||||
layerName: name,
|
||||
source: 'NASA FIRMS',
|
||||
dataSource: layerState._dataSource,
|
||||
label: `Fire · FRP ${components.model.formatFrp(fire.frp)} MW`,
|
||||
label: t('Fire · FRP {frp} MW', {
|
||||
frp: components.model.formatFrp(fire.frp),
|
||||
}),
|
||||
latitude: fire.lat,
|
||||
longitude: fire.lon,
|
||||
properties: {
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
inciwebNodeId,
|
||||
isCurrentPublication,
|
||||
} from './inciweb.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
export { normalizeFirePerimeterSnapshot } from './records.js';
|
||||
export { createWfigsPerimeterSource } from './source.js';
|
||||
export * from './cards.js';
|
||||
@@ -414,10 +415,10 @@ export function createFirePerimetersLayer({
|
||||
|
||||
getRowControls() {
|
||||
const bands = [
|
||||
{ label: 'Not contained or unknown', color: containmentAccent(0) },
|
||||
{ label: 'Under 50% contained', color: containmentAccent(1) },
|
||||
{ label: '50–99% contained', color: containmentAccent(50) },
|
||||
{ label: 'Fully contained', color: containmentAccent(100) },
|
||||
{ label: t('Not contained or unknown'), color: containmentAccent(0) },
|
||||
{ label: t('Under 50% contained'), color: containmentAccent(1) },
|
||||
{ label: t('50–99% contained'), color: containmentAccent(50) },
|
||||
{ label: t('Fully contained'), color: containmentAccent(100) },
|
||||
];
|
||||
return {
|
||||
chips: [],
|
||||
@@ -428,8 +429,9 @@ export function createFirePerimetersLayer({
|
||||
).length,
|
||||
...(index === 0
|
||||
? {
|
||||
blurb:
|
||||
blurb: t(
|
||||
'Colour shows reported containment. Perimeters are simplified to about 100 m.',
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
})),
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
validateBox,
|
||||
viewTooLargeMessage,
|
||||
} from './model.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
||||
/**
|
||||
* Quiet time after the last arrow key before the focused day previews, so a
|
||||
@@ -748,15 +749,21 @@ export function createRecentImageryLayer({
|
||||
|
||||
function notes() {
|
||||
const out = [];
|
||||
if (_truncated) out.push('Catalog truncated · a newer clear day may exist');
|
||||
if (_truncated)
|
||||
out.push(t('Catalog truncated · a newer clear day may exist'));
|
||||
for (const error of _catalogErrors) {
|
||||
const label = PRODUCTS[error.product]?.label || error.product;
|
||||
out.push(`${label} catalog unavailable · ${error.message}`);
|
||||
out.push(
|
||||
t('{label} catalog unavailable · {detail}', {
|
||||
label,
|
||||
detail: error.message,
|
||||
}),
|
||||
);
|
||||
}
|
||||
const today = utcDay(now());
|
||||
const viirsToday = candidateFor(`VIIRS:${today}`);
|
||||
if (viirsToday?.availability === 'unknown' && _sources.viirs)
|
||||
out.push('Daily overview for today may still be empty until the pass');
|
||||
out.push(t('Daily overview for today may still be empty until the pass'));
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -767,7 +774,7 @@ export function createRecentImageryLayer({
|
||||
key: key || null,
|
||||
candidate: candidateFor(key),
|
||||
label: parsed
|
||||
? `${shortDay(parsed.day)} · ${PRODUCTS[parsed.product].name} · ${PRODUCTS[parsed.product].resolutionM} m`
|
||||
? `${shortDay(parsed.day)} · ${t(PRODUCTS[parsed.product].name)} · ${PRODUCTS[parsed.product].resolutionM} m`
|
||||
: null,
|
||||
sourceOff,
|
||||
drapable: drapable(candidateFor(key)),
|
||||
@@ -1329,17 +1336,18 @@ export function createRecentImageryLayer({
|
||||
chips: [
|
||||
{
|
||||
id: 'hls',
|
||||
label: 'MORE DETAIL · 30 m',
|
||||
label: t('MORE DETAIL · 30 m'),
|
||||
active: _sources.hls,
|
||||
title: 'Sentinel-2 and Landsat via HLS, 30 m, every 2–6 days',
|
||||
title: t('Sentinel-2 and Landsat via HLS, 30 m, every 2–6 days'),
|
||||
params: { hls: !_sources.hls },
|
||||
},
|
||||
{
|
||||
id: 'viirs',
|
||||
label: 'DAILY OVERVIEW · 250 m',
|
||||
label: t('DAILY OVERVIEW · 250 m'),
|
||||
active: _sources.viirs,
|
||||
title:
|
||||
title: t(
|
||||
'VIIRS daily mosaic, 250 m, follows broad changes; little detail under 25 km',
|
||||
),
|
||||
params: { viirs: !_sources.viirs },
|
||||
},
|
||||
],
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
// Marks text for i18n extraction; the UI translates it with t(). Kept local
|
||||
// because AI tool services import this module outside the browser.
|
||||
const msg = (text) => text;
|
||||
|
||||
/**
|
||||
* Pure domain model for the Recent Imagery layer: products, the selection box,
|
||||
* per-day candidates, ranking and the keyless URL builders. No DOM, no Cesium,
|
||||
@@ -29,8 +33,8 @@ const MONTHS = 'Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec'.split(' ');
|
||||
*/
|
||||
export const PRODUCTS = Object.freeze({
|
||||
S30: Object.freeze({
|
||||
label: 'HLS S30',
|
||||
name: 'Sentinel-2',
|
||||
label: 'HLS S30', // i18n-ignore: product code
|
||||
name: msg('Sentinel-2'),
|
||||
source: 'hls',
|
||||
sensor: 'Sentinel-2 via HLS',
|
||||
resolutionM: 30,
|
||||
@@ -41,8 +45,8 @@ export const PRODUCTS = Object.freeze({
|
||||
overview: false,
|
||||
}),
|
||||
L30: Object.freeze({
|
||||
label: 'HLS L30',
|
||||
name: 'Landsat 8/9',
|
||||
label: 'HLS L30', // i18n-ignore: product code
|
||||
name: msg('Landsat 8/9'),
|
||||
source: 'hls',
|
||||
sensor: 'Landsat 8/9 via HLS',
|
||||
resolutionM: 30,
|
||||
@@ -53,8 +57,8 @@ export const PRODUCTS = Object.freeze({
|
||||
overview: false,
|
||||
}),
|
||||
VIIRS: Object.freeze({
|
||||
label: 'VIIRS',
|
||||
name: 'Daily overview',
|
||||
label: 'VIIRS', // i18n-ignore: product code
|
||||
name: msg('Daily overview'),
|
||||
source: 'viirs',
|
||||
sensor: 'VIIRS NOAA-21',
|
||||
resolutionM: 250,
|
||||
|
||||
@@ -14,7 +14,7 @@ export function createLabels({ state: layerState, services, parts, source }) {
|
||||
id: String(ISS_NORAD),
|
||||
position,
|
||||
variant: 'label',
|
||||
title: 'ISS',
|
||||
title: 'ISS', // i18n-ignore: satellite name, as in the satellite catalog
|
||||
accent: '#ff4444',
|
||||
priority: 1000,
|
||||
collisionGroup: 'ambient-label',
|
||||
|
||||
@@ -35,7 +35,7 @@ export function createBundledCableSource({
|
||||
return json;
|
||||
}
|
||||
return {
|
||||
label: 'TeleGeography',
|
||||
label: 'TeleGeography', // i18n-ignore: data provider name
|
||||
async fetch(signal) {
|
||||
const [cables, landingPoints] = await Promise.all([
|
||||
read(cableUrl(), signal),
|
||||
|
||||
+112
-45
@@ -7,6 +7,7 @@ import {
|
||||
GLOBAL_INFRARED_MAX_GAP_MS,
|
||||
LIGHTNING_MAX_GAP_MS,
|
||||
} from './clock.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
||||
const STOPS = [
|
||||
[10, '#00ecec'],
|
||||
@@ -29,7 +30,7 @@ const LIGHTNING_STOPS = [
|
||||
['300+', '#00FF00'],
|
||||
];
|
||||
const utc = (value) =>
|
||||
value ? `${value.slice(5, 16).replace('T', ' ')} UTC` : 'Unavailable';
|
||||
value ? `${value.slice(5, 16).replace('T', ' ')} UTC` : t('Unavailable');
|
||||
|
||||
/** A per-application observation layer, using the existing layer lifecycle and
|
||||
* row controls. History is transient: shared links always open latest imagery. */
|
||||
@@ -487,15 +488,23 @@ export function createWeatherLayer({
|
||||
noFrame &&
|
||||
shared?.mode === 'history' &&
|
||||
shared.products.find((entry) => entry.id === id)?.selected === null
|
||||
? `No frame within ${maxGap() / 60_000 < 60 ? `${maxGap() / 60_000} min` : `${maxGap() / 3600_000} h`} of ${utc(shared.target)}`
|
||||
? maxGap() / 60_000 < 60
|
||||
? t('No frame within {gap} min of {time}', {
|
||||
gap: maxGap() / 60_000,
|
||||
time: utc(shared.target),
|
||||
})
|
||||
: t('No frame within {gap} h of {time}', {
|
||||
gap: maxGap() / 3600_000,
|
||||
time: utc(shared.target),
|
||||
})
|
||||
: null;
|
||||
const time = shownTime();
|
||||
const relation =
|
||||
shared?.mode === 'history' && time
|
||||
? Date.parse(time) === Date.parse(shared.target)
|
||||
? ' · synced'
|
||||
? 'synced'
|
||||
: Date.parse(time) < Date.parse(shared.target)
|
||||
? ' · nearest'
|
||||
? 'nearest'
|
||||
: ''
|
||||
: '';
|
||||
const times = manifest?.times || [];
|
||||
@@ -508,8 +517,11 @@ export function createWeatherLayer({
|
||||
age === null
|
||||
? ''
|
||||
: age >= 60
|
||||
? `${Math.floor(age / 60)}h ${age % 60}m ago`
|
||||
: `${age}m ago`;
|
||||
? t('{hours}h {minutes}m ago', {
|
||||
hours: Math.floor(age / 60),
|
||||
minutes: age % 60,
|
||||
})
|
||||
: t('{minutes}m ago', { minutes: age });
|
||||
const diagnostic = rendering?.getDiagnostics();
|
||||
const b = manifest?.bounds;
|
||||
const canvas = viewer?.scene?.canvas;
|
||||
@@ -535,49 +547,95 @@ export function createWeatherLayer({
|
||||
lat < b.south ||
|
||||
lat > b.north ||
|
||||
(lightning && lon > 0 && lon < 110));
|
||||
let detail = time
|
||||
? t('{state} · {time} · {lag}', {
|
||||
state: followLatest ? t('Observed') : t('History'),
|
||||
time: utc(time),
|
||||
lag,
|
||||
})
|
||||
: missing
|
||||
? t('Observation unavailable')
|
||||
: t('Waiting for observation');
|
||||
if (relation === 'synced') detail = t('{detail} · synced', { detail });
|
||||
else if (relation === 'nearest')
|
||||
detail = t('{detail} · nearest', { detail });
|
||||
const infoHeading = radar
|
||||
? t('RADAR REFLECTIVITY · dBZ')
|
||||
: lightning
|
||||
? t('LIGHTNING DENSITY · 15 min accumulation')
|
||||
: product === 'clouds'
|
||||
? t('GLOBAL INFRARED · hourly')
|
||||
: t('GOES INFRARED · ~5 min');
|
||||
const infoTime = time
|
||||
? `${t('{state}: {time}', {
|
||||
state: followLatest ? t('Latest observation') : t('History'),
|
||||
time: utc(time),
|
||||
})}\n${[
|
||||
lag,
|
||||
current && !followLatest
|
||||
? t('frame {index}/{total}', {
|
||||
index: index + 1,
|
||||
total: times.length,
|
||||
})
|
||||
: '',
|
||||
loading ? t('loading') : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ')}`
|
||||
: [t('Observation: unavailable'), loading ? t('loading') : '']
|
||||
.filter(Boolean)
|
||||
.join(' · ');
|
||||
const infoCoverage = radar
|
||||
? t('Contiguous US · gaps ≠ no rain')
|
||||
: lightning
|
||||
? `${t('Americas + Pacific · not individual strikes')}\n${t('Color: strikes/km²/min ×10³')}`
|
||||
: product === 'clouds'
|
||||
? t('60°S–60°N · typically 2–3 h delayed')
|
||||
: t('North America · infrared imagery');
|
||||
const infoMotion = motion?.matches
|
||||
? clock
|
||||
? t('Reduced motion · history playback unavailable')
|
||||
: t('Reduced motion · manual history available')
|
||||
: '';
|
||||
const controls = {
|
||||
readout: true,
|
||||
summary: {
|
||||
label: radar
|
||||
? 'Rain radar · US'
|
||||
? t('Rain radar · US')
|
||||
: lightning
|
||||
? 'Lightning density · 15 min'
|
||||
: 'Satellite clouds',
|
||||
? t('Lightning density · 15 min')
|
||||
: t('Satellite clouds'),
|
||||
coverage: radar
|
||||
? 'CONUS'
|
||||
: lightning
|
||||
? 'Americas + Pacific'
|
||||
? t('Americas + Pacific')
|
||||
: product === 'clouds'
|
||||
? 'Global · 60°S–60°N'
|
||||
: 'North America',
|
||||
? t('Global · 60°S–60°N')
|
||||
: t('North America'),
|
||||
shownTime: time,
|
||||
maxGapMinutes: maxGap() / 60_000,
|
||||
detail: time
|
||||
? `${followLatest ? 'Observed' : 'History'} · ${utc(time)} · ${lag}${relation}`
|
||||
: missing
|
||||
? 'Observation unavailable'
|
||||
: 'Waiting for observation',
|
||||
detail,
|
||||
status:
|
||||
missing ||
|
||||
hostStatus ||
|
||||
error ||
|
||||
diagnostic?.error ||
|
||||
(observationDelayed()
|
||||
? 'Source observations delayed'
|
||||
? t('Source observations delayed')
|
||||
: manifest?.stale
|
||||
? 'Stale source'
|
||||
? t('Stale source')
|
||||
: loading
|
||||
? 'Loading next frame…'
|
||||
? t('Loading next frame…')
|
||||
: outside
|
||||
? 'Map center outside coverage'
|
||||
? t('Map center outside coverage')
|
||||
: null),
|
||||
units: radar ? 'dBZ' : lightning ? 'strikes/km²/min ×10³' : '',
|
||||
units: radar ? 'dBZ' : lightning ? t('strikes/km²/min ×10³') : '',
|
||||
},
|
||||
chips: [
|
||||
...(satellite
|
||||
? [
|
||||
['clouds-regional', 'N. America'],
|
||||
['clouds', 'Global'],
|
||||
['clouds-regional', t('N. America')],
|
||||
['clouds', t('Global')],
|
||||
].map(([value, label]) => ({
|
||||
id: value,
|
||||
label,
|
||||
@@ -585,43 +643,44 @@ export function createWeatherLayer({
|
||||
params: { product: value },
|
||||
title:
|
||||
value === 'clouds'
|
||||
? 'Hourly global mosaic; usually 2–3 hours delayed'
|
||||
: 'GOES regional clouds; approximately 5-minute updates',
|
||||
? t('Hourly global mosaic; usually 2–3 hours delayed')
|
||||
: t('GOES regional clouds; approximately 5-minute updates'),
|
||||
}))
|
||||
: []),
|
||||
...(satellite
|
||||
? [
|
||||
{
|
||||
id: 'filtered',
|
||||
label: 'Clouds only',
|
||||
label: t('Clouds only'),
|
||||
active: infrared === 'filtered',
|
||||
params: { infrared: 'filtered' },
|
||||
title:
|
||||
title: t(
|
||||
'Dim everything but the bright, cold cloud tops; a brightness filter, not a cloud mask',
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'full',
|
||||
label: 'Full',
|
||||
label: t('Full'),
|
||||
active: infrared === 'full',
|
||||
params: { infrared: 'full' },
|
||||
title: 'The complete infrared image at the chosen opacity',
|
||||
title: t('The complete infrared image at the chosen opacity'),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...['light', 'strong'].map((value) => ({
|
||||
id: `opacity-${value}`,
|
||||
label: value === 'light' ? 'Soft' : 'Vivid',
|
||||
label: value === 'light' ? t('Soft') : t('Vivid'),
|
||||
active: opacity === value,
|
||||
params: { opacity: value },
|
||||
title: 'Image opacity; does not alter the observed values',
|
||||
title: t('Image opacity; does not alter the observed values'),
|
||||
})),
|
||||
{
|
||||
id: 'coverage',
|
||||
label: radar
|
||||
? 'View US radar'
|
||||
? t('View US radar')
|
||||
: lightning
|
||||
? 'View Americas & Pacific'
|
||||
: 'View coverage',
|
||||
? t('View Americas & Pacific')
|
||||
: t('View coverage'),
|
||||
disabled: !manifest || !runNavigation,
|
||||
params: { focus: true },
|
||||
},
|
||||
@@ -632,37 +691,45 @@ export function createWeatherLayer({
|
||||
label: String(label),
|
||||
color,
|
||||
blurb: lightning
|
||||
? `${label} strikes/km²/min ×10³ (15-minute density)`
|
||||
: `${label} dBZ radar reflectivity`,
|
||||
? t('{value} strikes/km²/min ×10³ (15-minute density)', {
|
||||
value: label,
|
||||
})
|
||||
: t('{value} dBZ radar reflectivity', { value: label }),
|
||||
}))
|
||||
: [],
|
||||
info: hostHidden
|
||||
? hostStatus
|
||||
: `${radar ? 'RADAR REFLECTIVITY · dBZ' : lightning ? 'LIGHTNING DENSITY · 15 min accumulation' : product === 'clouds' ? 'GLOBAL INFRARED · hourly' : 'GOES INFRARED · ~5 min'}\n${time ? `${followLatest ? 'Latest observation' : 'History'}: ${utc(time)}\n${lag}${current && !followLatest ? ` · frame ${index + 1}/${times.length}` : ''}${loading ? ' · loading' : ''}` : `Observation: unavailable${loading ? ' · loading' : ''}`}${missing ? `\n${missing}` : ''}${manifest?.stale ? '\nSTALE · cached source metadata' : ''}${error || diagnostic?.error ? '\n' + (error || diagnostic.error) : ''}\n${radar ? 'Contiguous US · gaps ≠ no rain' : lightning ? 'Americas + Pacific · not individual strikes\nColor: strikes/km²/min ×10³' : product === 'clouds' ? '60°S–60°N · typically 2–3 h delayed' : 'North America · infrared imagery'}${outside ? '\nMap center is outside source coverage' : ''}${motion?.matches ? (clock ? '\nReduced motion · history playback unavailable' : '\nReduced motion · manual history available') : ''}`,
|
||||
: `${infoHeading}\n${infoTime}${missing ? `\n${missing}` : ''}${manifest?.stale ? `\n${t('STALE · cached source metadata')}` : ''}${error || diagnostic?.error ? '\n' + (error || diagnostic.error) : ''}\n${infoCoverage}${outside ? `\n${t('Map center is outside source coverage')}` : ''}${infoMotion ? `\n${infoMotion}` : ''}`,
|
||||
infoTitle: lightning
|
||||
? 'NOAA/NWS 15-minute lightning density derived from Vaisala NLDN/GLD360. Coverage 110°E across the Pacific/Americas to 0°, 25°S–80°N. Not a live strike count, global coverage or a safety warning.'
|
||||
? t(
|
||||
'NOAA/NWS 15-minute lightning density derived from Vaisala NLDN/GLD360. Coverage 110°E across the Pacific/Americas to 0°, 25°S–80°N. Not a live strike count, global coverage or a safety warning.',
|
||||
)
|
||||
: radar
|
||||
? 'NOAA MRMS radar echoes indicate precipitation patterns, not rain rate, a storm warning or a future forecast. Native source approximately 1 km; display is limited to level 6. Frames use exact advertised observation times.'
|
||||
: 'GOES-19/18 longwave infrared Band 14 regional; NESDIS global longwave mosaic. Clouds only dims everything but bright, cold cloud tops; a brightness filter, not a cloud mask. Coverage and freshness differ by region.',
|
||||
? t(
|
||||
'NOAA MRMS radar echoes indicate precipitation patterns, not rain rate, a storm warning or a future forecast. Native source approximately 1 km; display is limited to level 6. Frames use exact advertised observation times.',
|
||||
)
|
||||
: t(
|
||||
'GOES-19/18 longwave infrared Band 14 regional; NESDIS global longwave mosaic. Clouds only dims everything but bright, cold cloud tops; a brightness filter, not a cloud mask. Coverage and freshness differ by region.',
|
||||
),
|
||||
};
|
||||
controls.summary.settings = [
|
||||
...(satellite
|
||||
? [
|
||||
{
|
||||
id: 'region',
|
||||
label: 'REGION',
|
||||
label: t('REGION'),
|
||||
chips: controls.chips.filter(({ params }) => params.product),
|
||||
},
|
||||
{
|
||||
id: 'image',
|
||||
label: 'IMAGE',
|
||||
label: t('IMAGE'),
|
||||
chips: controls.chips.filter(({ params }) => params.infrared),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
id: 'opacity',
|
||||
label: 'OPACITY',
|
||||
label: t('OPACITY'),
|
||||
chips: controls.chips.filter(({ params }) => params.opacity),
|
||||
},
|
||||
];
|
||||
@@ -677,7 +744,7 @@ export function createWeatherLayer({
|
||||
getStats() {
|
||||
return {
|
||||
count: shownTime() ? 1 : 0,
|
||||
countLabel: isLatest() ? 'Observed' : 'History',
|
||||
countLabel: isLatest() ? t('Observed') : t('History'),
|
||||
lastUpdate: shownTime() ? Date.parse(shownTime()) : null,
|
||||
loading,
|
||||
error: error || rendering?.getDiagnostics().error || null,
|
||||
|
||||
@@ -3,14 +3,14 @@ import { normalizeLongitude, sampleWind } from './model.js';
|
||||
/** Fixed physical scales shared by imagery and the field legend. */
|
||||
export const WIND_FIELDS = Object.freeze({
|
||||
speed: Object.freeze({
|
||||
label: 'Wind speed',
|
||||
label: 'Wind speed', // i18n-ignore: internal field name; the shown label comes from index.js
|
||||
units: 'm/s',
|
||||
min: 0,
|
||||
max: 30,
|
||||
stops: ['#133a67', '#2876a3', '#66c9d1', '#c8dfac', '#f1cf79', '#ee9761'],
|
||||
}),
|
||||
temperature: Object.freeze({
|
||||
label: '2 m air temperature',
|
||||
label: '2 m air temperature', // i18n-ignore: internal field name; the shown label comes from index.js
|
||||
units: '°C',
|
||||
min: -40,
|
||||
max: 50,
|
||||
@@ -30,7 +30,7 @@ export const WIND_FIELDS = Object.freeze({
|
||||
],
|
||||
}),
|
||||
pressure: Object.freeze({
|
||||
label: 'Mean sea-level pressure',
|
||||
label: 'Mean sea-level pressure', // i18n-ignore: internal field name; the shown label comes from index.js
|
||||
units: 'hPa',
|
||||
min: 960,
|
||||
max: 1050,
|
||||
|
||||
+48
-37
@@ -11,6 +11,7 @@ import {
|
||||
createWindInspectionMarker,
|
||||
WIND_UNITS,
|
||||
} from './inspection.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
||||
/** Format a forecast timestamp explicitly in UTC. */
|
||||
export function formatWindValidTime(value) {
|
||||
@@ -242,7 +243,7 @@ export function createWindLayer({
|
||||
getRowControls() {
|
||||
const historyStatus =
|
||||
clock?.getState().mode === 'history'
|
||||
? 'Forecast · does not follow history'
|
||||
? t('Forecast · does not follow history')
|
||||
: null;
|
||||
const valid = formatWindValidTime(manifest?.cycle?.validIso);
|
||||
const run = formatWindValidTime(manifest?.cycle?.runIso);
|
||||
@@ -261,10 +262,10 @@ export function createWindLayer({
|
||||
const imageryError =
|
||||
enabled && !loading ? diagnostic?.imageryError : null;
|
||||
const label = {
|
||||
none: 'Wind motion',
|
||||
speed: 'Wind speed',
|
||||
pressure: 'Sea-level pressure',
|
||||
temperature: 'Air temperature · 2 m',
|
||||
none: t('Wind motion'),
|
||||
speed: t('Wind speed'),
|
||||
pressure: t('Sea-level pressure'),
|
||||
temperature: t('Air temperature · 2 m'),
|
||||
}[overlay];
|
||||
const kind = ['temperature', 'pressure'].includes(overlay)
|
||||
? overlay
|
||||
@@ -290,60 +291,64 @@ export function createWindLayer({
|
||||
readout: true,
|
||||
summary: {
|
||||
label,
|
||||
coverage: 'Global · 1° grid',
|
||||
coverage: t('Global · 1° grid'),
|
||||
validTime: manifest?.cycle?.validIso,
|
||||
issuedTime: manifest?.cycle?.runIso,
|
||||
detail: `${model === 'ifs' ? 'ECMWF IFS' : 'GFS'} forecast · ${valid || 'Unavailable'}`,
|
||||
detail: t('{model} forecast · {time}', {
|
||||
model: model === 'ifs' ? 'ECMWF IFS' : 'GFS',
|
||||
time: valid || t('Unavailable'),
|
||||
}),
|
||||
status: loading
|
||||
? 'Loading forecast'
|
||||
? t('Loading forecast')
|
||||
: preparing
|
||||
? 'Preparing flow'
|
||||
? t('Preparing flow')
|
||||
: error ||
|
||||
manifest?.reason ||
|
||||
imageryError ||
|
||||
(scalarMissing ? 'Selected field unavailable' : null) ||
|
||||
(manifest?.stale ? 'Cached forecast · stale' : null),
|
||||
(scalarMissing ? t('Selected field unavailable') : null) ||
|
||||
(manifest?.stale ? t('Cached forecast · stale') : null),
|
||||
units: legendUnit,
|
||||
},
|
||||
chips: [
|
||||
...['gfs', 'ifs'].map((value) => ({
|
||||
id: `model-${value}`,
|
||||
label: value === 'ifs' ? 'ECMWF' : 'GFS',
|
||||
label: value === 'ifs' ? 'ECMWF' : 'GFS', // i18n-ignore: forecast model names
|
||||
active: model === value,
|
||||
params: { model: value },
|
||||
title:
|
||||
value === 'ifs'
|
||||
? 'ECMWF IFS surface forecast'
|
||||
: 'NOAA GFS surface forecast',
|
||||
? t('ECMWF IFS surface forecast')
|
||||
: t('NOAA GFS surface forecast'),
|
||||
})),
|
||||
{
|
||||
id: 'motion',
|
||||
label: diagnostic?.reducedMotion
|
||||
? 'Reduced motion'
|
||||
? t('Reduced motion')
|
||||
: paused
|
||||
? 'Resume'
|
||||
: 'Pause',
|
||||
? t('Resume')
|
||||
: t('Pause'),
|
||||
active: motionPaused,
|
||||
disabled: Boolean(diagnostic?.reducedMotion),
|
||||
params: { paused: !paused },
|
||||
title:
|
||||
title: t(
|
||||
'Pause visual flow; forecast time does not advance with animation',
|
||||
),
|
||||
},
|
||||
...['none', 'speed', 'pressure', 'temperature'].map((value) => ({
|
||||
id: `overlay-${value}`,
|
||||
label: {
|
||||
none: 'None',
|
||||
speed: 'Speed',
|
||||
pressure: 'Pressure',
|
||||
temperature: 'Temperature',
|
||||
none: t('None'),
|
||||
speed: t('Speed'),
|
||||
pressure: t('Pressure'),
|
||||
temperature: t('Temperature'),
|
||||
}[value],
|
||||
active: overlay === value,
|
||||
params: { overlay: value },
|
||||
title: {
|
||||
none: 'Wind trails with no field shading',
|
||||
speed: 'How hard the surface wind is blowing',
|
||||
pressure: 'Sea-level air pressure: broad highs and lows',
|
||||
temperature: 'Air temperature two meters above the surface',
|
||||
none: t('Wind trails with no field shading'),
|
||||
speed: t('How hard the surface wind is blowing'),
|
||||
pressure: t('Sea-level air pressure: broad highs and lows'),
|
||||
temperature: t('Air temperature two meters above the surface'),
|
||||
}[value],
|
||||
})),
|
||||
...(speedLegendVisible ||
|
||||
@@ -356,15 +361,16 @@ export function createWindLayer({
|
||||
label: value,
|
||||
active: units === value,
|
||||
params: { units: value },
|
||||
title: 'Wind speed units',
|
||||
title: t('Wind speed units'),
|
||||
})),
|
||||
{
|
||||
id: 'read-wind',
|
||||
label: 'Read wind at map center',
|
||||
label: t('Read wind at map center'),
|
||||
params: { inspect: true },
|
||||
disabled: loading || !manifest?.u || manifest?.unavailable,
|
||||
title:
|
||||
title: t(
|
||||
'Read the forecast at the center of the map without changing selection',
|
||||
),
|
||||
},
|
||||
],
|
||||
legend:
|
||||
@@ -373,9 +379,14 @@ export function createWindLayer({
|
||||
(overlay === 'none' && !speedLegendVisible)
|
||||
? []
|
||||
: legend,
|
||||
info: `${model === 'ifs' ? 'ECMWF IFS' : 'GFS'} forecast · ${label} (${legendUnit})${historyStatus ? `\n${historyStatus}` : ''}\nValid: ${valid || 'Unavailable'}${loading ? ' · loading' : preparing ? ' · preparing' : ''}\nIssued: ${run || 'Unavailable'}${manifest?.stale ? ' · STALE' : ''}${error ? '\n' + error : ''}${scalarMissing ? '\nSelected field unavailable · wind remains visible' : ''}${imageryError && !scalarMissing ? '\n' + imageryError + ' · wind remains visible' : ''}`,
|
||||
infoTitle:
|
||||
info: `${t('{model} forecast · {field} ({unit})', {
|
||||
model: model === 'ifs' ? 'ECMWF IFS' : 'GFS',
|
||||
field: label,
|
||||
unit: legendUnit,
|
||||
})}${historyStatus ? `\n${historyStatus}` : ''}\n${t('Valid: {time}', { time: valid || t('Unavailable') })}${loading ? ` · ${t('loading')}` : preparing ? ` · ${t('preparing')}` : ''}\n${t('Issued: {time}', { time: run || t('Unavailable') })}${manifest?.stale ? ' · STALE' : ''}${error ? '\n' + error : ''}${scalarMissing ? `\n${t('Selected field unavailable · wind remains visible')}` : ''}${imageryError && !scalarMissing ? `\n${t('{error} · wind remains visible', { error: imageryError })}` : ''}`,
|
||||
infoTitle: t(
|
||||
'Surface wind at 10 m. Approximately 1° global grid. Curves follow the 10 m wind field, lifted 12 km for visibility; display height is not weather altitude. View lighting is for readability. Animation shows flow through one fixed forecast; it does not advance time. Color fields drape the globe basemap or the active photorealistic 3D Tiles.',
|
||||
),
|
||||
};
|
||||
controls.summary.reading = reading;
|
||||
controls.summary.result = reading ? windReadingResult(reading) : null;
|
||||
@@ -385,12 +396,12 @@ export function createWindLayer({
|
||||
chips: controls.chips.filter((chip) => chip.id.startsWith(prefix)),
|
||||
});
|
||||
controls.summary.settings = [
|
||||
setting('model', 'MODEL', 'model-'),
|
||||
setting('field', 'FIELD', 'overlay-'),
|
||||
setting('model', t('MODEL'), 'model-'),
|
||||
setting('field', t('FIELD'), 'overlay-'),
|
||||
...(controls.chips.some(({ id }) => id.startsWith('units-'))
|
||||
? [{ id: 'units', label: 'UNITS', chips: windUnitChips(units) }]
|
||||
? [{ id: 'units', label: t('UNITS'), chips: windUnitChips(units) }]
|
||||
: []),
|
||||
setting('motion', 'MOTION', 'motion'),
|
||||
setting('motion', t('MOTION'), 'motion'),
|
||||
];
|
||||
controls.summary.actions = controls.chips.filter(
|
||||
({ id }) => id === 'read-wind',
|
||||
@@ -425,7 +436,7 @@ export function createWindLayer({
|
||||
: null;
|
||||
return {
|
||||
...windStats(manifest),
|
||||
countLabel: 'Forecast',
|
||||
countLabel: t('Forecast'),
|
||||
loading: loading || Boolean(preparing),
|
||||
model: model.toUpperCase(),
|
||||
overlay,
|
||||
@@ -433,7 +444,7 @@ export function createWindLayer({
|
||||
stale: Boolean(manifest?.stale),
|
||||
source: model === 'ifs' ? 'ECMWF IFS' : 'NOAA GFS',
|
||||
validTime:
|
||||
formatWindValidTime(manifest?.cycle?.validIso) || 'Unavailable',
|
||||
formatWindValidTime(manifest?.cycle?.validIso) || t('Unavailable'),
|
||||
error:
|
||||
error ||
|
||||
windStats(manifest).error ||
|
||||
|
||||
@@ -1,23 +1,27 @@
|
||||
import { sampleWind, windSpeed } from './model.js';
|
||||
import { sampleScalar } from './fields.js';
|
||||
|
||||
// The reading stays English: AI tools read it too. Text is marked for i18n
|
||||
// extraction and translated where the WEATHER card shows it (presentation.js).
|
||||
const msg = (text) => text;
|
||||
|
||||
const DIRECTIONS = [
|
||||
'N',
|
||||
'NNE',
|
||||
'NE',
|
||||
'ENE',
|
||||
'E',
|
||||
'ESE',
|
||||
'SE',
|
||||
'SSE',
|
||||
'S',
|
||||
'SSW',
|
||||
'SW',
|
||||
'WSW',
|
||||
'W',
|
||||
'WNW',
|
||||
'NW',
|
||||
'NNW',
|
||||
msg('N'),
|
||||
msg('NNE'),
|
||||
msg('NE'),
|
||||
msg('ENE'),
|
||||
msg('E'),
|
||||
msg('ESE'),
|
||||
msg('SE'),
|
||||
msg('SSE'),
|
||||
msg('S'),
|
||||
msg('SSW'),
|
||||
msg('SW'),
|
||||
msg('WSW'),
|
||||
msg('W'),
|
||||
msg('WNW'),
|
||||
msg('NW'),
|
||||
msg('NNW'),
|
||||
];
|
||||
export const WIND_UNITS = Object.freeze({
|
||||
'km/h': 3.6,
|
||||
@@ -56,8 +60,8 @@ export function inspectWindAtCenter(
|
||||
));
|
||||
if (!point || !snapshot?.u || !snapshot?.v)
|
||||
return {
|
||||
coordinates: 'No surface reading',
|
||||
wind: 'Aim the map center at Earth',
|
||||
coordinates: msg('No surface reading'),
|
||||
wind: msg('Aim the map center at Earth'),
|
||||
model,
|
||||
validTime,
|
||||
status,
|
||||
@@ -87,11 +91,11 @@ export function inspectWindAtCenter(
|
||||
scalarKind: overlay,
|
||||
scalarLabel:
|
||||
overlay === 'speed'
|
||||
? 'Wind speed'
|
||||
? msg('Wind speed')
|
||||
: overlay === 'temperature'
|
||||
? 'Air temperature · 2 m'
|
||||
? msg('Air temperature · 2 m')
|
||||
: overlay === 'pressure'
|
||||
? 'Sea-level pressure'
|
||||
? msg('Sea-level pressure')
|
||||
: null,
|
||||
scalarValue:
|
||||
overlay === 'speed'
|
||||
@@ -102,8 +106,9 @@ export function inspectWindAtCenter(
|
||||
model,
|
||||
validTime,
|
||||
status,
|
||||
explanation:
|
||||
explanation: msg(
|
||||
'Interpolated model forecast on an approximately 1° grid. Broad weather patterns, not a street-level measurement.',
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { formatWindSpeed, WIND_UNITS } from './inspection.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
||||
/** Reformat a captured sample; changing units never samples the map again. */
|
||||
export function formatWindReading(reading, units) {
|
||||
@@ -22,33 +23,52 @@ export function windUnitChips(units) {
|
||||
label: value,
|
||||
active: units === value,
|
||||
params: { units: value },
|
||||
title: 'Wind speed units',
|
||||
title: t('Wind speed units'),
|
||||
}));
|
||||
}
|
||||
|
||||
/** The wind line in the active language; the reading itself stays English. */
|
||||
function windText(reading) {
|
||||
if (!Number.isFinite(reading.speed))
|
||||
return reading.wind === 'Aim the map center at Earth'
|
||||
? t('Aim the map center at Earth')
|
||||
: reading.wind;
|
||||
const speed = formatWindSpeed(reading.speed, reading.units);
|
||||
return reading.from === 'Calm'
|
||||
? `${speed}${t(' · calm')}`
|
||||
: `${speed}${t(' from {direction}', { direction: t(reading.from) })}`;
|
||||
}
|
||||
|
||||
/** Portable result block consumed by the WEATHER card. */
|
||||
export function windReadingResult(reading) {
|
||||
const coordinates =
|
||||
reading.coordinates === 'No surface reading'
|
||||
? t('No surface reading')
|
||||
: reading.coordinates.replace(' · ', ' ');
|
||||
return {
|
||||
id: 'reading',
|
||||
label: `WIND AT ${reading.coordinates.replace(' · ', ' ')}`,
|
||||
label: t('WIND AT {coordinates}', { coordinates }),
|
||||
lines: [
|
||||
{
|
||||
id: 'wind',
|
||||
text: reading.wind,
|
||||
text: windText(reading),
|
||||
},
|
||||
{
|
||||
id: 'meta',
|
||||
text: `${reading.model} · valid ${reading.validTime?.replace(/^\d{4}-/, '')}`,
|
||||
text: t('{model} · valid {time}', {
|
||||
model: reading.model,
|
||||
time: reading.validTime?.replace(/^\d{4}-/, ''),
|
||||
}),
|
||||
},
|
||||
...(reading.scalarValue
|
||||
? [
|
||||
{
|
||||
id: 'scalar',
|
||||
text: `${reading.scalarLabel} · ${reading.scalarValue}`,
|
||||
text: `${t(reading.scalarLabel)} · ${reading.scalarValue}`,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{ id: 'explanation', text: reading.explanation },
|
||||
{ id: 'explanation', text: t(reading.explanation) },
|
||||
],
|
||||
clear: { params: { inspect: false } },
|
||||
};
|
||||
|
||||
@@ -244,6 +244,7 @@ export function reduceTrafficSyncFeedback(
|
||||
// Neutral default: the layer always supplies its own LIVE/SIMULATED
|
||||
// label, and a fallback string must never claim a live feed on a
|
||||
// keyless build.
|
||||
// i18n-ignore: portable node/browser status vocabulary; src/ui/shellFeedback.js translates it at the presentation boundary
|
||||
label: label || 'syncing road network',
|
||||
progressText: hasProgress ? `${progressPct}%` : '...',
|
||||
};
|
||||
@@ -392,6 +393,7 @@ export function presentLoadingFeedback(state, summary, nowMs) {
|
||||
);
|
||||
return {
|
||||
state: 'retry',
|
||||
// i18n-ignore: portable node/browser status vocabulary; src/ui/shellFeedback.js translates it at the presentation boundary
|
||||
label: (
|
||||
camera.cameraRetry.error || 'Overpass temporarily unavailable'
|
||||
).toUpperCase(),
|
||||
@@ -438,6 +440,7 @@ export function presentLoadingFeedback(state, summary, nowMs) {
|
||||
if (active.length === 1 && active[0].cameraRetry && !summary.disabling) {
|
||||
return {
|
||||
state: 'loading',
|
||||
// i18n-ignore: portable node/browser status vocabulary; src/ui/shellFeedback.js translates it at the presentation boundary
|
||||
label: active[0].cameraRetry.retrying
|
||||
? 'RETRYING ALPR CAMERAS'
|
||||
: 'FETCHING ALPR CAMERAS',
|
||||
@@ -452,6 +455,7 @@ export function presentLoadingFeedback(state, summary, nowMs) {
|
||||
) {
|
||||
return {
|
||||
state: 'loading',
|
||||
// i18n-ignore: portable node/browser status vocabulary; src/ui/shellFeedback.js translates it at the presentation boundary
|
||||
label: active[0].installationRetry.retrying
|
||||
? 'RETRYING MAPPED SITES'
|
||||
: 'FETCHING MAPPED SITES',
|
||||
|
||||
@@ -26,7 +26,7 @@ test('the ACTIVE STYLE indicator is written from the style name and nothing else
|
||||
assert.equal(writes.length, 1, 'the style indicator must have exactly one writer');
|
||||
assert.match(
|
||||
ui.slice(writes[0].index, writes[0].index + 160),
|
||||
/this\._styleIndicator\.textContent =\s*displayNames\[styleName\] \|\| styleName\.toUpperCase\(\);/,
|
||||
/this\._styleIndicator\.textContent = STYLE_STATUS_LABELS\[styleName\]\s*\?\s*t\(STYLE_STATUS_LABELS\[styleName\]\)\s*:\s*styleName\.toUpperCase\(\);/,
|
||||
);
|
||||
|
||||
const handler = locationSearchHandler();
|
||||
|
||||
+4
-1
@@ -1,5 +1,6 @@
|
||||
import { createStandaloneApplication } from './standalone/application.js';
|
||||
import { describeError } from './standalone/errors.js';
|
||||
import { t } from './i18n/index.js';
|
||||
|
||||
const application = createStandaloneApplication({
|
||||
googleApiKey: import.meta.env.GOOGLE_MAPS_API_KEY,
|
||||
@@ -10,7 +11,9 @@ const application = createStandaloneApplication({
|
||||
application.start().catch((error) => {
|
||||
console.error("God's Eye View initialization failed:", error);
|
||||
const loaderStatus = document.querySelector('#loading-screen .loader-status');
|
||||
loaderStatus.textContent = `Error: ${describeError(error)}`;
|
||||
loaderStatus.textContent = t('Error: {message}', {
|
||||
message: describeError(error),
|
||||
});
|
||||
loaderStatus.style.color = '#ff4444';
|
||||
});
|
||||
|
||||
|
||||
@@ -495,7 +495,7 @@ test('the Visual Presets tray owns Map Source and the retired left panel is abse
|
||||
);
|
||||
assert.match(
|
||||
html,
|
||||
/<section class="map-source-section"[\s\S]*?<div id="map-stack-chips" class="map-stack-chip-row" role="group" aria-label="Map source"><\/div>/,
|
||||
/<section class="map-source-section"[\s\S]*?<div id="map-stack-chips" class="map-stack-chip-row" role="group" aria-label="Map source" data-i18n-aria-label><\/div>/,
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
html,
|
||||
|
||||
+5
-5
@@ -2,14 +2,14 @@ import * as Cesium from 'cesium';
|
||||
export const MAP_STACKS = [
|
||||
{
|
||||
id: 'photoreal',
|
||||
label: 'Google 3D',
|
||||
label: 'Google 3D', // i18n-ignore: imagery product name
|
||||
shortLabel: '3D',
|
||||
kind: 'photoreal',
|
||||
requiresIon: false,
|
||||
},
|
||||
{
|
||||
id: 'bing-aerial',
|
||||
label: 'Bing Aerial',
|
||||
label: 'Bing Aerial', // i18n-ignore: imagery product name
|
||||
shortLabel: 'Aerial',
|
||||
kind: 'ion',
|
||||
style: Cesium.IonWorldImageryStyle.AERIAL,
|
||||
@@ -17,7 +17,7 @@ export const MAP_STACKS = [
|
||||
},
|
||||
{
|
||||
id: 'bing-labels',
|
||||
label: 'Bing Labels',
|
||||
label: 'Bing Labels', // i18n-ignore: imagery product name
|
||||
shortLabel: 'Labels',
|
||||
kind: 'ion',
|
||||
style: Cesium.IonWorldImageryStyle.AERIAL_WITH_LABELS,
|
||||
@@ -25,14 +25,14 @@ export const MAP_STACKS = [
|
||||
},
|
||||
{
|
||||
id: 'esri-imagery',
|
||||
label: 'Esri Satellite',
|
||||
label: 'Esri Satellite', // i18n-ignore: imagery product name
|
||||
shortLabel: 'SAT',
|
||||
kind: 'esri-imagery',
|
||||
requiresIon: false,
|
||||
},
|
||||
{
|
||||
id: 'osm',
|
||||
label: 'OSM',
|
||||
label: 'OSM', // i18n-ignore: imagery product name
|
||||
shortLabel: 'OSM',
|
||||
kind: 'osm',
|
||||
requiresIon: false,
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
placementVariants,
|
||||
} from './worldOverlayDraw.js';
|
||||
import { WORLD_OVERLAY_STYLE } from './worldOverlayTokens.js';
|
||||
import { t } from '../i18n/index.js';
|
||||
|
||||
/**
|
||||
* @module worldOverlay
|
||||
@@ -1269,7 +1270,7 @@ function ensureOverlayDom() {
|
||||
_accessibilityRoot.id = ACCESSIBILITY_ROOT_ID;
|
||||
_accessibilityRoot.className = 'world-overlay-accessibility';
|
||||
_accessibilityRoot.setAttribute('role', 'region');
|
||||
_accessibilityRoot.setAttribute('aria-label', 'Visible map targets');
|
||||
_accessibilityRoot.setAttribute('aria-label', t('Visible map targets'));
|
||||
document.body.appendChild(_accessibilityRoot);
|
||||
}
|
||||
_accessibilityList = document.getElementById(ACCESSIBILITY_LIST_ID);
|
||||
@@ -2419,7 +2420,9 @@ function syncAccessibleActions() {
|
||||
if (!activate) return;
|
||||
const accepted = activate();
|
||||
if (accepted !== false && _accessibilityStatus) {
|
||||
_accessibilityStatus.textContent = `Focusing ${item.label}`;
|
||||
_accessibilityStatus.textContent = t('Focusing {label}', {
|
||||
label: item.label,
|
||||
});
|
||||
}
|
||||
});
|
||||
_accessibilityList.appendChild(button);
|
||||
|
||||
@@ -351,6 +351,7 @@ function buildWorkload(count) {
|
||||
id: `mover-${index}`,
|
||||
position: () => position,
|
||||
variant: 'label',
|
||||
// i18n-ignore: synthetic allocation-benchmark fixture, not shown as UI
|
||||
title: `TRK ${1000 + index}`,
|
||||
details: [`ALT ${10_000 + index * 25}`],
|
||||
priority: index % 7,
|
||||
@@ -461,6 +462,7 @@ function buildPhase3FirmsWorkload(count) {
|
||||
position,
|
||||
gapPx: 10,
|
||||
accent: '224, 82, 82',
|
||||
// i18n-ignore: synthetic allocation-benchmark fixture, not shown as UI
|
||||
title: `▲ ${50 + fireIndex} MW`,
|
||||
details: ['high · 2h · N20'],
|
||||
selected: false,
|
||||
@@ -551,6 +553,7 @@ function buildPhase3VesselsWorkload(count) {
|
||||
position,
|
||||
gapPx: 10,
|
||||
accent: '224, 82, 82',
|
||||
// i18n-ignore: synthetic allocation-benchmark fixture, not shown as UI
|
||||
title: `▲ ${50 + fireIndex} MW`,
|
||||
details: ['high · 2h · N20'],
|
||||
selected: false,
|
||||
@@ -570,6 +573,7 @@ function buildPhase3VesselsWorkload(count) {
|
||||
position,
|
||||
gapPx: selected ? 12 : 10,
|
||||
accent: '57, 213, 255',
|
||||
// i18n-ignore: synthetic allocation-benchmark fixture, not shown as UI
|
||||
title: selected ? 'SELECTED VESSEL' : `VESSEL ${vesselIndex}`,
|
||||
details: selected
|
||||
? ['CARGO · 14.5KT · 231°', 'MMSI 353136000 · POS: LIVE']
|
||||
@@ -644,6 +648,7 @@ function buildPhase3TrackedWorkload(count) {
|
||||
gevTrackedId: 'flights:allocation-probe',
|
||||
gevDisplayPosition: selected.position,
|
||||
gevLabelModel: {
|
||||
// i18n-ignore: synthetic allocation-benchmark fixture, not shown as UI
|
||||
title: 'ALLOC01',
|
||||
details: ['FL350 · 451 kts', 'TEST AIR · A320'],
|
||||
accent: '#39d0ff',
|
||||
@@ -694,6 +699,7 @@ function buildPhase4CctvWorkload(count) {
|
||||
const entry = createCctvThumbnailOverlayEntry({
|
||||
id: active ? 'cctv:active' : `cctv:${index}`,
|
||||
position,
|
||||
// i18n-ignore: synthetic allocation-benchmark fixture, not shown as UI
|
||||
title: active ? 'ACTIVE CAMERA' : `CAMERA ${index}`,
|
||||
frameSlot: slot,
|
||||
rank: index,
|
||||
@@ -867,6 +873,7 @@ function buildPhase5CivilWorkload(count) {
|
||||
const entry = registration?.entries[0];
|
||||
if (!entry)
|
||||
throw new Error('phase5-civil requires the protected tracked entry');
|
||||
// i18n-ignore: synthetic allocation-benchmark fixture, not shown as UI
|
||||
entry.title = 'ALLOC01 · FL350 · 451 kts';
|
||||
entry.details = ['TEST AIR · A320', 'AUS → LAX'];
|
||||
return workload;
|
||||
@@ -881,6 +888,7 @@ function buildPhase5MilitaryWorkload(count) {
|
||||
if (!entry)
|
||||
throw new Error('phase5-military requires the protected tracked entry');
|
||||
entry.id = 'military:allocation-probe';
|
||||
// i18n-ignore: synthetic allocation-benchmark fixture, not shown as UI
|
||||
entry.title = 'RCH451';
|
||||
entry.details = ['C17 · 05-8152', 'USAF · 28000 ft · 400 kt'];
|
||||
entry.accent = '#ffd166';
|
||||
@@ -1067,6 +1075,7 @@ function appendSubmarineCableWorkload(workload, count) {
|
||||
id: `${kind}-reference-${index}-alloc`,
|
||||
kind,
|
||||
label:
|
||||
// i18n-ignore: synthetic allocation-benchmark fixture, not shown as UI
|
||||
kind === 'cable' ? `Cable System ${index}` : `Landing Point ${index}`,
|
||||
tip: position,
|
||||
distanceM: 200_000 + index * 40_000,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { renderPackGeometry } from './geometry.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
||||
function caption(viewer, pack) {
|
||||
const card = document.createElement('section');
|
||||
@@ -18,7 +19,7 @@ function caption(viewer, pack) {
|
||||
card.append(title);
|
||||
if (pack.attribution.url) {
|
||||
const link = document.createElement('a');
|
||||
link.textContent = 'Source';
|
||||
link.textContent = t('Source');
|
||||
link.href = pack.attribution.url;
|
||||
link.target = '_blank';
|
||||
link.rel = 'noopener noreferrer';
|
||||
|
||||
+49
-27
@@ -26,7 +26,7 @@ import {
|
||||
getSceneAppendRecipeById,
|
||||
} from './recipes.js';
|
||||
import { sceneLayerPlan, sceneRequiresContextModeExit } from './scenePolicy.js';
|
||||
import { t, msg } from '../i18n/index.js';
|
||||
import { t, tn } from '../i18n/index.js';
|
||||
import { createSceneDataPacks } from './dataPacks/controller.js';
|
||||
import { createSceneInteractions } from './interactions.js';
|
||||
import { createSceneSharing } from './sharing.js';
|
||||
@@ -66,13 +66,7 @@ import {
|
||||
// project titles, and voice-matching titles all stay English (keep-English
|
||||
// boundary + persisted data); only the presentation of an UNRENAMED built-in
|
||||
// localizes, so an operator's renamed scene is never masked by the catalog.
|
||||
const RECIPE_TITLE_KEYS = Object.freeze({
|
||||
'flights-radar': msg('Global Flights Radar'),
|
||||
'orbital-watch': msg('Orbital Watch'),
|
||||
'thermal-threats': msg('Thermal Threat Board'),
|
||||
'city-overload': msg('City Overload'),
|
||||
'omniscience-pullback': msg('Omniscience Pullback'),
|
||||
});
|
||||
// Recipe titles are marked with msg() in recipes.js.
|
||||
|
||||
/**
|
||||
* Display title for a scene: the localized recipe name while the stored title
|
||||
@@ -81,10 +75,9 @@ const RECIPE_TITLE_KEYS = Object.freeze({
|
||||
* @returns {string}
|
||||
*/
|
||||
function displaySceneTitle(scene) {
|
||||
const key = RECIPE_TITLE_KEYS[scene?.id];
|
||||
if (!key) return scene?.title || '';
|
||||
const builtin = SCENE_RECIPES.find((recipe) => recipe.id === scene.id);
|
||||
return scene.title === builtin?.title ? t(key) : scene.title;
|
||||
const builtin = SCENE_RECIPES.find((recipe) => recipe.id === scene?.id);
|
||||
if (!builtin?.title) return scene?.title || '';
|
||||
return scene.title === builtin.title ? t(builtin.title) : scene.title;
|
||||
}
|
||||
|
||||
/** @constant {string} localStorage key for the serialized project */
|
||||
@@ -519,7 +512,9 @@ export class SceneDirector {
|
||||
|
||||
const scene = {
|
||||
id: uid('scene'),
|
||||
title: sceneName.trim() || `Scene ${this._project.scenes.length + 1}`,
|
||||
title:
|
||||
sceneName.trim() ||
|
||||
t('Scene {n}', { n: this._project.scenes.length + 1 }),
|
||||
shots: [],
|
||||
};
|
||||
|
||||
@@ -797,7 +792,9 @@ export class SceneDirector {
|
||||
));
|
||||
if (!inventoryMatches) {
|
||||
this._updateStatus(
|
||||
`Cannot update ${recipe.title}: shot inventory changed`,
|
||||
t('Cannot update {scene}: shot inventory changed', {
|
||||
scene: t(recipe.title),
|
||||
}),
|
||||
);
|
||||
return {
|
||||
appended: false,
|
||||
@@ -828,7 +825,9 @@ export class SceneDirector {
|
||||
);
|
||||
if (!sourcePackMatches) {
|
||||
this._updateStatus(
|
||||
`Cannot update ${recipe.title}: evidence beats changed`,
|
||||
t('Cannot update {scene}: evidence beats changed', {
|
||||
scene: t(recipe.title),
|
||||
}),
|
||||
);
|
||||
return {
|
||||
appended: false,
|
||||
@@ -851,7 +850,9 @@ export class SceneDirector {
|
||||
}));
|
||||
if (resolvedPatches.some(({ shot }) => !shot)) {
|
||||
this._updateStatus(
|
||||
`Cannot update ${recipe.title}: shot bindings are incomplete`,
|
||||
t('Cannot update {scene}: shot bindings are incomplete', {
|
||||
scene: t(recipe.title),
|
||||
}),
|
||||
);
|
||||
return {
|
||||
appended: false,
|
||||
@@ -931,8 +932,18 @@ export class SceneDirector {
|
||||
if (announce) {
|
||||
this._updateStatus(
|
||||
marker
|
||||
? `Updated ${patchedShotCount} ${patchedShotCount === 1 ? 'shot' : 'shots'}: ${recipe.title}`
|
||||
: `Appended ${appendedShots.length} shots: ${recipe.title}`,
|
||||
? tn(
|
||||
patchedShotCount,
|
||||
'Updated {count} shot: {scene}',
|
||||
'Updated {count} shots: {scene}',
|
||||
{ scene: t(recipe.title) },
|
||||
)
|
||||
: tn(
|
||||
appendedShots.length,
|
||||
'Appended {count} shot: {scene}',
|
||||
'Appended {count} shots: {scene}',
|
||||
{ scene: t(recipe.title) },
|
||||
),
|
||||
);
|
||||
}
|
||||
return {
|
||||
@@ -1121,7 +1132,11 @@ export class SceneDirector {
|
||||
const released = await this._releaseSceneLayers(previousScene, token);
|
||||
if (!released || token.cancelled) {
|
||||
if (!token.cancelled)
|
||||
this._updateStatus(`Could not leave scene: ${previousScene.title}`);
|
||||
this._updateStatus(
|
||||
t('Could not leave scene: {scene}', {
|
||||
scene: previousScene.title,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
this._loadedSceneId = null;
|
||||
@@ -1171,7 +1186,9 @@ export class SceneDirector {
|
||||
seeking: true,
|
||||
});
|
||||
if (this._loadAbort === controller) this._loadAbort = null;
|
||||
this._updateStatus(`Seeked: ${scene.title} / ${shot.title}`);
|
||||
this._updateStatus(
|
||||
t('Seeked: {scene} / {shot}', { scene: scene.title, shot: shot.title }),
|
||||
);
|
||||
this._activateInteractions(scene, shot);
|
||||
return { started: true, shotId };
|
||||
}
|
||||
@@ -1533,7 +1550,9 @@ export class SceneDirector {
|
||||
running: false,
|
||||
seeking: true,
|
||||
});
|
||||
this._updateStatus(`Seeked: ${scene.title} / ${shot.title}`);
|
||||
this._updateStatus(
|
||||
t('Seeked: {scene} / {shot}', { scene: scene.title, shot: shot.title }),
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1807,6 +1826,7 @@ export class SceneDirector {
|
||||
// Initialize telemetry accumulator for this run
|
||||
this._activeRun = {
|
||||
recipeId: `project-${PROJECT_VERSION}`,
|
||||
// i18n-ignore: run telemetry record, downloaded as JSON
|
||||
title: 'Editable Scene Run',
|
||||
startedAt: new Date().toISOString(),
|
||||
estimatedDurationSec,
|
||||
@@ -1950,7 +1970,7 @@ export class SceneDirector {
|
||||
if (action.type === 'shot') {
|
||||
if (this._interactionTransitions >= 64) {
|
||||
this._updateStatus(
|
||||
'Scene transition limit reached — load a shot to reset',
|
||||
t('Scene transition limit reached — load a shot to reset'),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
@@ -1993,7 +2013,7 @@ export class SceneDirector {
|
||||
try {
|
||||
payload = stringifySceneDocument(this._project);
|
||||
} catch (error) {
|
||||
this._updateStatus(`Export failed: ${error.message}`);
|
||||
this._updateStatus(t('Export failed: {error}', { error: error.message }));
|
||||
return;
|
||||
}
|
||||
// Trigger a browser download via a temporary anchor element
|
||||
@@ -2066,7 +2086,7 @@ export class SceneDirector {
|
||||
this._loadedSceneId = null;
|
||||
this._saveProject();
|
||||
this._publish({ type: 'project-imported', project });
|
||||
this._updateStatus(`Imported ${file.name}`);
|
||||
this._updateStatus(t('Imported {file}', { file: file.name }));
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (
|
||||
@@ -2077,8 +2097,8 @@ export class SceneDirector {
|
||||
return false;
|
||||
this._updateStatus(
|
||||
error instanceof SceneDocumentError
|
||||
? `Import failed: ${error.message}`
|
||||
: 'Import failed (could not read JSON file)',
|
||||
? t('Import failed: {error}', { error: error.message })
|
||||
: t('Import failed (could not read JSON file)'),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2178,7 +2198,9 @@ export class SceneDirector {
|
||||
}
|
||||
|
||||
if (refused.length) {
|
||||
this._updateStatus(`Layers refused: ${refused.join(', ')}`);
|
||||
this._updateStatus(
|
||||
t('Layers refused: {layers}', { layers: refused.join(', ') }),
|
||||
);
|
||||
this._logEvent('shot_layers_refused', { layerIds: [...refused] });
|
||||
}
|
||||
return { applied, refused, cancelled: false };
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { isPointerFree } from '../data/inputOwnership.js';
|
||||
import { createInteractionSession } from '../director/interactions/session.js';
|
||||
import { t } from '../i18n/index.js';
|
||||
|
||||
/** Own the active shot's pick handler, accessible actions and plain-text feedback. */
|
||||
export function createSceneInteractions(
|
||||
@@ -24,7 +25,7 @@ export function createSceneInteractions(
|
||||
card.append(text);
|
||||
if (item.action.url) {
|
||||
const link = document.createElement('a');
|
||||
link.textContent = 'Source';
|
||||
link.textContent = t('Source');
|
||||
link.style.color = '#6eeaff';
|
||||
link.href = item.action.url;
|
||||
link.target = '_blank';
|
||||
@@ -60,8 +61,8 @@ export function createSceneInteractions(
|
||||
const ok = await session.dispatch(id);
|
||||
if (current && current === panel && status)
|
||||
status.textContent = ok
|
||||
? 'Action complete'
|
||||
: 'Action unavailable or cancelled';
|
||||
? t('Action complete')
|
||||
: t('Action unavailable or cancelled');
|
||||
return ok;
|
||||
}
|
||||
return {
|
||||
@@ -87,7 +88,7 @@ export function createSceneInteractions(
|
||||
}
|
||||
panel = document.createElement('section');
|
||||
panel.dataset.directorInteractions = '';
|
||||
panel.setAttribute('aria-label', 'Scene actions');
|
||||
panel.setAttribute('aria-label', t('Scene actions'));
|
||||
Object.assign(panel.style, {
|
||||
position: 'absolute',
|
||||
left: '12px',
|
||||
@@ -104,12 +105,13 @@ export function createSceneInteractions(
|
||||
});
|
||||
const owner = panel;
|
||||
const title = document.createElement('strong');
|
||||
title.textContent = 'Scene actions';
|
||||
title.textContent = t('Scene actions');
|
||||
panel.append(title);
|
||||
status = document.createElement('p');
|
||||
status.setAttribute('role', 'status');
|
||||
status.textContent =
|
||||
'Select a feature or use Tab and Enter to choose an action.';
|
||||
status.textContent = t(
|
||||
'Select a feature or use Tab and Enter to choose an action.',
|
||||
);
|
||||
panel.append(status);
|
||||
for (const item of items) {
|
||||
const button = document.createElement('button');
|
||||
@@ -157,7 +159,12 @@ export function createSceneInteractions(
|
||||
? '2px solid #6eeaff'
|
||||
: '';
|
||||
}
|
||||
status.textContent = `Selected feature: ${matches[0].target.featureId}. Choose an action.`;
|
||||
status.textContent = t(
|
||||
'Selected feature: {feature}. Choose an action.',
|
||||
{
|
||||
feature: matches[0].target.featureId,
|
||||
},
|
||||
);
|
||||
buttons.get(matches[0].id)?.focus({ preventScroll: true });
|
||||
}, Cesium.ScreenSpaceEventType.LEFT_CLICK);
|
||||
},
|
||||
|
||||
@@ -7,6 +7,7 @@ import { SOURCE_PATH_BEAT_IDS } from '../data/bhoteKoshiShotPaths.js';
|
||||
const ADDITIONAL_SHOTS = [
|
||||
{
|
||||
id: 'second-landslide',
|
||||
// i18n-ignore: shot title (mostly a place name), matched by title on upgrade
|
||||
title: 'Second landslide',
|
||||
lat: 28.399234,
|
||||
lon: 85.535806,
|
||||
@@ -17,6 +18,7 @@ const ADDITIONAL_SHOTS = [
|
||||
},
|
||||
{
|
||||
id: 'dhunche',
|
||||
// i18n-ignore: shot title (mostly a place name), matched by title on upgrade
|
||||
title: 'Dhunche',
|
||||
lat: 28.161431,
|
||||
lon: 85.324278,
|
||||
@@ -27,6 +29,7 @@ const ADDITIONAL_SHOTS = [
|
||||
},
|
||||
{
|
||||
id: 'mailung-upper-trishuli',
|
||||
// i18n-ignore: shot title (mostly a place name), matched by title on upgrade
|
||||
title: 'Mailung / Upper Trishuli bridge',
|
||||
lat: 28.140642,
|
||||
lon: 85.265996,
|
||||
@@ -37,6 +40,7 @@ const ADDITIONAL_SHOTS = [
|
||||
},
|
||||
{
|
||||
id: 'mailung-bazzar',
|
||||
// i18n-ignore: shot title (mostly a place name), matched by title on upgrade
|
||||
title: 'Mailung Bazzar, Dandaguan',
|
||||
lat: 28.126526,
|
||||
lon: 85.248027,
|
||||
@@ -47,6 +51,7 @@ const ADDITIONAL_SHOTS = [
|
||||
},
|
||||
{
|
||||
id: 'dandagaun',
|
||||
// i18n-ignore: shot title (mostly a place name), matched by title on upgrade
|
||||
title: 'Dandagaun, Rasuwa',
|
||||
lat: 28.124424,
|
||||
lon: 85.245154,
|
||||
@@ -57,6 +62,7 @@ const ADDITIONAL_SHOTS = [
|
||||
},
|
||||
{
|
||||
id: 'dandagaun-viewpoint',
|
||||
// i18n-ignore: shot title (mostly a place name), matched by title on upgrade
|
||||
title: 'Dandagaun viewpoint restaurant',
|
||||
lat: 28.086663,
|
||||
lon: 85.229914,
|
||||
@@ -67,6 +73,7 @@ const ADDITIONAL_SHOTS = [
|
||||
},
|
||||
{
|
||||
id: 'betrawati-bazaar',
|
||||
// i18n-ignore: shot title (mostly a place name), matched by title on upgrade
|
||||
title: 'Betrawati Bazaar',
|
||||
lat: 28.029827,
|
||||
lon: 85.227694,
|
||||
@@ -77,6 +84,7 @@ const ADDITIONAL_SHOTS = [
|
||||
},
|
||||
{
|
||||
id: 'bhainse',
|
||||
// i18n-ignore: shot title (mostly a place name), matched by title on upgrade
|
||||
title: 'Bhainse',
|
||||
lat: 28.023087,
|
||||
lon: 85.229449,
|
||||
@@ -87,6 +95,7 @@ const ADDITIONAL_SHOTS = [
|
||||
},
|
||||
{
|
||||
id: 'devighat-taadi-khola-bridge',
|
||||
// i18n-ignore: shot title (mostly a place name), matched by title on upgrade
|
||||
title: 'Devighat / Taadi Khola Bridge',
|
||||
lat: 27.921132,
|
||||
lon: 85.177113,
|
||||
@@ -97,6 +106,7 @@ const ADDITIONAL_SHOTS = [
|
||||
},
|
||||
{
|
||||
id: 'charaudi',
|
||||
// i18n-ignore: shot title (mostly a place name), matched by title on upgrade
|
||||
title: 'Charaudi',
|
||||
lat: 27.865095,
|
||||
lon: 84.838424,
|
||||
@@ -107,6 +117,7 @@ const ADDITIONAL_SHOTS = [
|
||||
},
|
||||
{
|
||||
id: 'final-view',
|
||||
// i18n-ignore: shot title (mostly a place name), matched by title on upgrade
|
||||
title: 'Final view',
|
||||
lat: 28.0834,
|
||||
lon: 85.206,
|
||||
@@ -340,6 +351,7 @@ export function expandNepalEvidencePack(base) {
|
||||
shotPatches: [
|
||||
...(base.shotPatches || []),
|
||||
{
|
||||
// i18n-ignore: shot title (mostly a place name), matched by title on upgrade
|
||||
title: 'Final view',
|
||||
layers: finalView.layers,
|
||||
},
|
||||
|
||||
@@ -122,6 +122,7 @@ export function recipeToScene(recipe) {
|
||||
const mapStack = keyframe.mapStack || post.mapStack;
|
||||
return {
|
||||
id: uid('shot'),
|
||||
// i18n-ignore: persisted fallback; legacy bootstrap matches 'Shot N'
|
||||
title: keyframe.title || `Shot ${idx + 1}`,
|
||||
durationSec: Math.max(
|
||||
0.2,
|
||||
@@ -171,6 +172,7 @@ export function recipeToScene(recipe) {
|
||||
|
||||
return {
|
||||
id: recipe.id || uid('scene'),
|
||||
// i18n-ignore: persisted fallback; built-in titles display through director
|
||||
title: recipe.title || 'Untitled Scene',
|
||||
releaseLayerIds: [
|
||||
...new Set(
|
||||
@@ -225,6 +227,7 @@ export function normalizeShot(
|
||||
|
||||
return {
|
||||
id: rawShot?.id || uid('shot'),
|
||||
// i18n-ignore: persisted fallback; legacy bootstrap matches 'Shot N'
|
||||
title: rawShot?.title || `Shot ${index + 1}`,
|
||||
durationSec: Math.max(
|
||||
0.2,
|
||||
@@ -349,6 +352,7 @@ export function normalizeProject(rawProject) {
|
||||
);
|
||||
return {
|
||||
id: scene?.id || uid('scene'),
|
||||
// i18n-ignore: persisted fallback for a saved scene without a title
|
||||
title: scene?.title || `Scene ${sceneIdx + 1}`,
|
||||
...(scene?.dataPacks ? { dataPacks: deepClone(scene.dataPacks) } : {}),
|
||||
...(scene?.anchors ? { anchors: deepClone(scene.anchors) } : {}),
|
||||
|
||||
+36
-7
@@ -4,11 +4,12 @@
|
||||
*/
|
||||
|
||||
import { expandNepalEvidencePack } from './nepalEvidencePack.js';
|
||||
import { msg } from '../i18n/index.js';
|
||||
|
||||
const BHOTE_KOSHI_NEPAL_BASE_RECIPE = Object.freeze({
|
||||
id: 'bhote-koshi-nepal-evidence-pack',
|
||||
version: 12,
|
||||
title: 'Bhote Koshi Evidence Sequence',
|
||||
title: msg('Bhote Koshi Evidence Sequence'),
|
||||
style: 'normal',
|
||||
ui: { hidePanels: false, hudMode: 'full', safeFrame: '16:9' },
|
||||
layers: {
|
||||
@@ -26,6 +27,7 @@ const BHOTE_KOSHI_NEPAL_BASE_RECIPE = Object.freeze({
|
||||
fromShotTitles: ['Shot 1', 'Shot 2', 'Shot 3'],
|
||||
cameraPath: [
|
||||
{
|
||||
// i18n-ignore: shot title, matched by title when the recipe upgrades a saved project
|
||||
title: 'Global Incident Context',
|
||||
lat: 27.5962,
|
||||
lon: 78.5718,
|
||||
@@ -43,6 +45,7 @@ const BHOTE_KOSHI_NEPAL_BASE_RECIPE = Object.freeze({
|
||||
},
|
||||
},
|
||||
{
|
||||
// i18n-ignore: shot title, matched by title when the recipe upgrades a saved project
|
||||
title: 'Nepal-Focused Globe Rotation',
|
||||
lat: 28.7248,
|
||||
lon: 81.3226,
|
||||
@@ -60,6 +63,7 @@ const BHOTE_KOSHI_NEPAL_BASE_RECIPE = Object.freeze({
|
||||
},
|
||||
},
|
||||
{
|
||||
// i18n-ignore: shot title, matched by title when the recipe upgrades a saved project
|
||||
title: 'Bhote Koshi Regional Approach',
|
||||
lat: 27.0354,
|
||||
lon: 84.6287,
|
||||
@@ -77,6 +81,7 @@ const BHOTE_KOSHI_NEPAL_BASE_RECIPE = Object.freeze({
|
||||
},
|
||||
},
|
||||
{
|
||||
// i18n-ignore: shot title, matched by title when the recipe upgrades a saved project
|
||||
title: 'Bhote Koshi Nearby Cities',
|
||||
lat: 27.891,
|
||||
lon: 85.428,
|
||||
@@ -94,6 +99,7 @@ const BHOTE_KOSHI_NEPAL_BASE_RECIPE = Object.freeze({
|
||||
},
|
||||
},
|
||||
{
|
||||
// i18n-ignore: shot title, matched by title when the recipe upgrades a saved project
|
||||
title: 'Bhote Koshi Incident Corridor',
|
||||
lat: 28.0529,
|
||||
lon: 85.2189,
|
||||
@@ -111,6 +117,7 @@ const BHOTE_KOSHI_NEPAL_BASE_RECIPE = Object.freeze({
|
||||
},
|
||||
},
|
||||
{
|
||||
// i18n-ignore: shot title, matched by title when the recipe upgrades a saved project
|
||||
title: 'Bhote Koshi Flood Path',
|
||||
lat: 28.1691,
|
||||
lon: 85.352,
|
||||
@@ -128,6 +135,7 @@ const BHOTE_KOSHI_NEPAL_BASE_RECIPE = Object.freeze({
|
||||
},
|
||||
},
|
||||
{
|
||||
// i18n-ignore: shot title, matched by title when the recipe upgrades a saved project
|
||||
title: 'Bhote Koshi Corridor Overview',
|
||||
lat: 27.8986,
|
||||
lon: 84.8723,
|
||||
@@ -145,6 +153,7 @@ const BHOTE_KOSHI_NEPAL_BASE_RECIPE = Object.freeze({
|
||||
},
|
||||
},
|
||||
{
|
||||
// i18n-ignore: shot title, matched by title when the recipe upgrades a saved project
|
||||
title: 'Bhote Koshi Upper Valley',
|
||||
lat: 28.4187,
|
||||
lon: 85.4341,
|
||||
@@ -221,6 +230,7 @@ const BHOTE_KOSHI_NEPAL_BASE_RECIPE = Object.freeze({
|
||||
},
|
||||
shotPatches: [
|
||||
{
|
||||
// i18n-ignore: shot title, matched by title when the recipe upgrades a saved project
|
||||
title: 'Global Incident Context',
|
||||
visual: { mapStack: 'photoreal' },
|
||||
layers: {
|
||||
@@ -236,6 +246,7 @@ const BHOTE_KOSHI_NEPAL_BASE_RECIPE = Object.freeze({
|
||||
},
|
||||
},
|
||||
{
|
||||
// i18n-ignore: shot title, matched by title when the recipe upgrades a saved project
|
||||
title: 'Nepal-Focused Globe Rotation',
|
||||
visual: { mapStack: 'photoreal' },
|
||||
layers: {
|
||||
@@ -251,6 +262,7 @@ const BHOTE_KOSHI_NEPAL_BASE_RECIPE = Object.freeze({
|
||||
},
|
||||
},
|
||||
{
|
||||
// i18n-ignore: shot title, matched by title when the recipe upgrades a saved project
|
||||
title: 'Bhote Koshi Regional Approach',
|
||||
visual: { mapStack: 'photoreal' },
|
||||
layers: {
|
||||
@@ -266,6 +278,7 @@ const BHOTE_KOSHI_NEPAL_BASE_RECIPE = Object.freeze({
|
||||
},
|
||||
},
|
||||
{
|
||||
// i18n-ignore: shot title, matched by title when the recipe upgrades a saved project
|
||||
title: 'Bhote Koshi Nearby Cities',
|
||||
visual: { mapStack: 'photoreal' },
|
||||
layers: {
|
||||
@@ -281,6 +294,7 @@ const BHOTE_KOSHI_NEPAL_BASE_RECIPE = Object.freeze({
|
||||
},
|
||||
},
|
||||
{
|
||||
// i18n-ignore: shot title, matched by title when the recipe upgrades a saved project
|
||||
title: 'Bhote Koshi Incident Corridor',
|
||||
camera: {
|
||||
lat: 28.0529,
|
||||
@@ -305,6 +319,7 @@ const BHOTE_KOSHI_NEPAL_BASE_RECIPE = Object.freeze({
|
||||
},
|
||||
},
|
||||
{
|
||||
// i18n-ignore: shot title, matched by title when the recipe upgrades a saved project
|
||||
title: 'Bhote Koshi Flood Path',
|
||||
visual: { mapStack: 'photoreal' },
|
||||
layers: {
|
||||
@@ -320,6 +335,7 @@ const BHOTE_KOSHI_NEPAL_BASE_RECIPE = Object.freeze({
|
||||
},
|
||||
},
|
||||
{
|
||||
// i18n-ignore: shot title, matched by title when the recipe upgrades a saved project
|
||||
title: 'Bhote Koshi Corridor Overview',
|
||||
visual: { mapStack: 'photoreal' },
|
||||
layers: {
|
||||
@@ -335,6 +351,7 @@ const BHOTE_KOSHI_NEPAL_BASE_RECIPE = Object.freeze({
|
||||
},
|
||||
},
|
||||
{
|
||||
// i18n-ignore: shot title, matched by title when the recipe upgrades a saved project
|
||||
title: 'Bhote Koshi Upper Valley',
|
||||
holdSec: 8,
|
||||
visual: { mapStack: 'photoreal' },
|
||||
@@ -357,6 +374,7 @@ const BHOTE_KOSHI_NEPAL_BASE_RECIPE = Object.freeze({
|
||||
},
|
||||
},
|
||||
{
|
||||
// i18n-ignore: shot title, matched by title when the recipe upgrades a saved project
|
||||
title: 'Upper Valley Collapse',
|
||||
camera: {
|
||||
lat: 28.417,
|
||||
@@ -380,24 +398,29 @@ const BHOTE_KOSHI_NEPAL_BASE_RECIPE = Object.freeze({
|
||||
},
|
||||
},
|
||||
{
|
||||
// i18n-ignore: shot title, matched by title when the recipe upgrades a saved project
|
||||
title: 'Nepal-China Border Gate',
|
||||
visual: { mapStack: 'photoreal' },
|
||||
},
|
||||
{
|
||||
// i18n-ignore: shot title, matched by title when the recipe upgrades a saved project
|
||||
title: 'Timure Evidence Cluster',
|
||||
visual: { mapStack: 'photoreal' },
|
||||
},
|
||||
{
|
||||
// i18n-ignore: shot title, matched by title when the recipe upgrades a saved project
|
||||
title: 'Syabru Besi Passage',
|
||||
visual: { mapStack: 'photoreal' },
|
||||
},
|
||||
{
|
||||
// i18n-ignore: shot title, matched by title when the recipe upgrades a saved project
|
||||
title: 'Bidur / Trishuli Consequence',
|
||||
visual: { mapStack: 'photoreal' },
|
||||
},
|
||||
],
|
||||
cameraPath: [
|
||||
{
|
||||
// i18n-ignore: shot title, matched by title when the recipe upgrades a saved project
|
||||
title: 'Upper Valley Collapse',
|
||||
lat: 28.417,
|
||||
lon: 85.3937,
|
||||
@@ -421,6 +444,7 @@ const BHOTE_KOSHI_NEPAL_BASE_RECIPE = Object.freeze({
|
||||
},
|
||||
},
|
||||
{
|
||||
// i18n-ignore: shot title, matched by title when the recipe upgrades a saved project
|
||||
title: 'Debris-Dammed Lake',
|
||||
lat: 28.392914,
|
||||
lon: 85.570921,
|
||||
@@ -444,6 +468,7 @@ const BHOTE_KOSHI_NEPAL_BASE_RECIPE = Object.freeze({
|
||||
},
|
||||
},
|
||||
{
|
||||
// i18n-ignore: shot title, matched by title when the recipe upgrades a saved project
|
||||
title: 'Nepal-China Border Gate',
|
||||
mapStack: 'photoreal',
|
||||
lat: 28.316789,
|
||||
@@ -474,6 +499,7 @@ const BHOTE_KOSHI_NEPAL_BASE_RECIPE = Object.freeze({
|
||||
},
|
||||
},
|
||||
{
|
||||
// i18n-ignore: shot title, matched by title when the recipe upgrades a saved project
|
||||
title: 'Timure Evidence Cluster',
|
||||
mapStack: 'photoreal',
|
||||
lat: 28.285968,
|
||||
@@ -502,6 +528,7 @@ const BHOTE_KOSHI_NEPAL_BASE_RECIPE = Object.freeze({
|
||||
},
|
||||
},
|
||||
{
|
||||
// i18n-ignore: shot title, matched by title when the recipe upgrades a saved project
|
||||
title: 'Syabru Besi Passage',
|
||||
mapStack: 'photoreal',
|
||||
lat: 28.210306,
|
||||
@@ -530,6 +557,7 @@ const BHOTE_KOSHI_NEPAL_BASE_RECIPE = Object.freeze({
|
||||
},
|
||||
},
|
||||
{
|
||||
// i18n-ignore: shot title, matched by title when the recipe upgrades a saved project
|
||||
title: 'Bidur / Trishuli Consequence',
|
||||
mapStack: 'photoreal',
|
||||
lat: 27.981737,
|
||||
@@ -569,7 +597,7 @@ const BHOTE_KOSHI_NEPAL_APPEND_RECIPE = BHOTE_KOSHI_NEPAL_BASE_RECIPE
|
||||
const BHOTE_KOSHI_NEPAL_BOOTSTRAP_RECIPE = BHOTE_KOSHI_NEPAL_APPEND_RECIPE
|
||||
? Object.freeze({
|
||||
id: 'bhote-koshi-nepal-scene',
|
||||
title: 'Nepal Flood Incident',
|
||||
title: msg('Nepal Flood Incident'),
|
||||
durationSec: 15,
|
||||
style: BHOTE_KOSHI_NEPAL_APPEND_RECIPE.style,
|
||||
ui: BHOTE_KOSHI_NEPAL_APPEND_RECIPE.ui,
|
||||
@@ -583,6 +611,7 @@ const BHOTE_KOSHI_NEPAL_BOOTSTRAP_RECIPE = BHOTE_KOSHI_NEPAL_APPEND_RECIPE
|
||||
.slice(0, 3)
|
||||
.map((keyframe, index) => ({
|
||||
...keyframe,
|
||||
// i18n-ignore: legacy shot titles the bootstrap matches verbatim
|
||||
title: `Shot ${index + 1}`,
|
||||
})),
|
||||
})
|
||||
@@ -593,7 +622,7 @@ const EVENT_RECIPES = [BHOTE_KOSHI_NEPAL_BOOTSTRAP_RECIPE].filter(Boolean);
|
||||
const PUBLIC_SCENE_RECIPES = [
|
||||
{
|
||||
id: 'flights-radar',
|
||||
title: 'Global Flights Radar',
|
||||
title: msg('Global Flights Radar'),
|
||||
durationSec: 30,
|
||||
style: 'retro',
|
||||
ui: { hidePanels: true, hudMode: 'minimal', safeFrame: '16:9' },
|
||||
@@ -663,7 +692,7 @@ const PUBLIC_SCENE_RECIPES = [
|
||||
},
|
||||
{
|
||||
id: 'orbital-watch',
|
||||
title: 'Orbital Watch',
|
||||
title: msg('Orbital Watch'),
|
||||
durationSec: 32,
|
||||
style: 'surveillance',
|
||||
ui: { hidePanels: true, hudMode: 'full', safeFrame: '16:9' },
|
||||
@@ -741,7 +770,7 @@ const PUBLIC_SCENE_RECIPES = [
|
||||
},
|
||||
{
|
||||
id: 'thermal-threats',
|
||||
title: 'Thermal Threat Board',
|
||||
title: msg('Thermal Threat Board'),
|
||||
durationSec: 26,
|
||||
style: 'thermal',
|
||||
ui: { hidePanels: true, hudMode: 'full', safeFrame: '16:9' },
|
||||
@@ -809,7 +838,7 @@ const PUBLIC_SCENE_RECIPES = [
|
||||
},
|
||||
{
|
||||
id: 'city-overload',
|
||||
title: 'City Overload',
|
||||
title: msg('City Overload'),
|
||||
durationSec: 30,
|
||||
style: 'surveillance',
|
||||
ui: { hidePanels: true, hudMode: 'minimal', safeFrame: '9:16' },
|
||||
@@ -887,7 +916,7 @@ const PUBLIC_SCENE_RECIPES = [
|
||||
},
|
||||
{
|
||||
id: 'omniscience-pullback',
|
||||
title: 'Omniscience Pullback',
|
||||
title: msg('Omniscience Pullback'),
|
||||
durationSec: 36,
|
||||
style: 'retro',
|
||||
ui: { hidePanels: true, hudMode: 'full', safeFrame: '16:9' },
|
||||
|
||||
+71
-35
@@ -12,6 +12,7 @@ import {
|
||||
} from '../director/authoring.js';
|
||||
import { createSceneDialog, mountSceneSharing } from '../ui/sceneSharing.js';
|
||||
import { PACK_LIMITS } from '../director/packs/manifest.js';
|
||||
import { t, tn } from '../i18n/index.js';
|
||||
|
||||
function download(text, name) {
|
||||
const url = URL.createObjectURL(
|
||||
@@ -91,7 +92,9 @@ export function createSceneSharing(director) {
|
||||
dialog.status.textContent =
|
||||
error?.name === 'SceneDocumentError'
|
||||
? error.message
|
||||
: 'Could not complete this action. Check the file, references and selected assets.';
|
||||
: t(
|
||||
'Could not complete this action. Check the file, references and selected assets.',
|
||||
);
|
||||
} finally {
|
||||
if (alive(owner)) busy = false;
|
||||
}
|
||||
@@ -103,39 +106,64 @@ export function createSceneSharing(director) {
|
||||
layerIds: director.dataManager.getAll().map((l) => l.id),
|
||||
});
|
||||
dialog.text(
|
||||
`${report.scenes} scenes · ${report.shots} shots · ${report.packs.length} data packs`,
|
||||
[
|
||||
tn(report.scenes, '{count} scene', '{count} scenes'),
|
||||
tn(report.shots, '{count} shot', '{count} shots'),
|
||||
tn(report.packs.length, '{count} data pack', '{count} data packs'),
|
||||
].join(' · '),
|
||||
);
|
||||
for (const pack of report.packs)
|
||||
dialog.text(
|
||||
`${pack.scene} / ${pack.id}: ${pack.status}. File: ${pack.path}. ${pack.attribution.text} · ${pack.attribution.license}`,
|
||||
t(
|
||||
'{scene} / {pack}: {status}. File: {path}. {attribution} · {license}',
|
||||
{
|
||||
scene: pack.scene,
|
||||
pack: pack.id,
|
||||
status: pack.status,
|
||||
path: pack.path,
|
||||
attribution: pack.attribution.text,
|
||||
license: pack.attribution.license,
|
||||
},
|
||||
),
|
||||
);
|
||||
if (report.missingLayers.length)
|
||||
dialog.text(`Unavailable layers: ${report.missingLayers.join(', ')}`);
|
||||
dialog.text(
|
||||
t('Unavailable layers: {layers}', {
|
||||
layers: report.missingLayers.join(', '),
|
||||
}),
|
||||
);
|
||||
if (report.externalContent)
|
||||
dialog.text(
|
||||
'This scene uses registered content or linked media. Those external files are not included in a scene bundle. Their original notices still apply.',
|
||||
t(
|
||||
'This scene uses registered content or linked media. Those external files are not included in a scene bundle. Their original notices still apply.',
|
||||
),
|
||||
);
|
||||
if (report.bundledBytes)
|
||||
dialog.text(
|
||||
`${report.bundledBytes} bundled bytes verified. Files stay in memory for this session. Reimport the bundle after reloading the app.`,
|
||||
t(
|
||||
'{bytes} bundled bytes verified. Files stay in memory for this session. Reimport the bundle after reloading the app.',
|
||||
{ bytes: report.bundledBytes },
|
||||
),
|
||||
);
|
||||
return report;
|
||||
}
|
||||
async function preview(file) {
|
||||
const owner = open('Review scene import');
|
||||
const owner = open(t('Review scene import'));
|
||||
if (!owner) return;
|
||||
const expected = currentProject();
|
||||
dialog.text('Nothing is loaded or changed until you apply this file.');
|
||||
dialog.text(t('Nothing is loaded or changed until you apply this file.'));
|
||||
await run(owner, async () => {
|
||||
const input = await readSceneShare(file, { signal: owner.signal });
|
||||
if (!alive(owner)) return;
|
||||
staged = input;
|
||||
inventory(input);
|
||||
dialog.status.textContent = 'Ready to import';
|
||||
dialog.status.textContent = t('Ready to import');
|
||||
dialog.text(
|
||||
'Apply replaces the current project. Export your current project first if you want to keep both.',
|
||||
t(
|
||||
'Apply replaces the current project. Export your current project first if you want to keep both.',
|
||||
),
|
||||
);
|
||||
const apply = dialog.button('Apply import', () =>
|
||||
const apply = dialog.button(t('Apply import'), () =>
|
||||
run(owner, async () => {
|
||||
if (expected !== currentProject()) throw new Error('Project changed');
|
||||
apply.disabled = true;
|
||||
@@ -148,8 +176,9 @@ export function createSceneSharing(director) {
|
||||
if (ok) close();
|
||||
else {
|
||||
apply.disabled = false;
|
||||
dialog.status.textContent =
|
||||
'Import was not applied. The current project may have changed.';
|
||||
dialog.status.textContent = t(
|
||||
'Import was not applied. The current project may have changed.',
|
||||
);
|
||||
}
|
||||
}
|
||||
}),
|
||||
@@ -161,29 +190,31 @@ export function createSceneSharing(director) {
|
||||
const scene = director._getSelectedScene(),
|
||||
shot = scene?.shots.find((s) => s.id === director._selectedShotId);
|
||||
if (!scene || !shot) {
|
||||
director._updateStatus('Select a scene and shot first');
|
||||
director._updateStatus(t('Select a scene and shot first'));
|
||||
return;
|
||||
}
|
||||
const owner = open('Edit scene details');
|
||||
const owner = open(t('Edit scene details'));
|
||||
if (!owner) return;
|
||||
const original = structuredClone(director._project),
|
||||
expected = currentProject();
|
||||
dialog.text(
|
||||
'Camera positions use degrees and meters above the ellipsoid. Drafts are validated before they replace your saved scene.',
|
||||
t(
|
||||
'Camera positions use degrees and meters above the ellipsoid. Drafts are validated before they replace your saved scene.',
|
||||
),
|
||||
);
|
||||
const sceneText = dialog.input(
|
||||
'Anchors and data packs',
|
||||
t('Anchors and data packs'),
|
||||
json(subset(scene, sceneKeys)),
|
||||
{ multiline: true },
|
||||
);
|
||||
const shotText = dialog.input(
|
||||
'Shot camera, timing, packs and actions',
|
||||
t('Shot camera, timing, packs and actions'),
|
||||
json(subset(shot, shotKeys)),
|
||||
{ multiline: true },
|
||||
);
|
||||
const anchorName = dialog.input('New anchor ID', 'anchor-1');
|
||||
const anchorName = dialog.input(t('New anchor ID'), 'anchor-1');
|
||||
dialog.button(
|
||||
'Capture camera as anchor',
|
||||
t('Capture camera as anchor'),
|
||||
() =>
|
||||
run(owner, async () => {
|
||||
const details = JSON.parse(sceneText.value),
|
||||
@@ -210,12 +241,12 @@ export function createSceneSharing(director) {
|
||||
JSON.parse(shotText.value),
|
||||
);
|
||||
sceneText.value = json(details);
|
||||
dialog.status.textContent = 'Anchor added to draft';
|
||||
dialog.status.textContent = t('Anchor added to draft');
|
||||
}),
|
||||
dialog.body,
|
||||
);
|
||||
dialog.button(
|
||||
'Set move start to current camera',
|
||||
t('Set move start to current camera'),
|
||||
() =>
|
||||
run(owner, async () => {
|
||||
const details = JSON.parse(shotText.value),
|
||||
@@ -246,13 +277,14 @@ export function createSceneSharing(director) {
|
||||
details,
|
||||
);
|
||||
shotText.value = json(details);
|
||||
dialog.status.textContent =
|
||||
'Move added to draft; destination remains the shot camera';
|
||||
dialog.status.textContent = t(
|
||||
'Move added to draft; destination remains the shot camera',
|
||||
);
|
||||
}),
|
||||
dialog.body,
|
||||
);
|
||||
dialog.button(
|
||||
'Use ordinary flight',
|
||||
t('Use ordinary flight'),
|
||||
() => {
|
||||
if (!alive(owner)) return;
|
||||
try {
|
||||
@@ -260,12 +292,12 @@ export function createSceneSharing(director) {
|
||||
delete details.move;
|
||||
shotText.value = json(details);
|
||||
} catch {
|
||||
dialog.status.textContent = 'Invalid shot JSON';
|
||||
dialog.status.textContent = t('Invalid shot JSON');
|
||||
}
|
||||
},
|
||||
dialog.body,
|
||||
);
|
||||
const apply = dialog.button('Apply details', () =>
|
||||
const apply = dialog.button(t('Apply details'), () =>
|
||||
run(owner, async () => {
|
||||
if (expected !== currentProject()) throw new Error('Project changed');
|
||||
const project = editSceneDetails(
|
||||
@@ -300,10 +332,10 @@ export function createSceneSharing(director) {
|
||||
director._selectedSceneId,
|
||||
);
|
||||
} catch {
|
||||
director._updateStatus('Select a scene first');
|
||||
director._updateStatus(t('Select a scene first'));
|
||||
return;
|
||||
}
|
||||
const owner = open('Share selected scene');
|
||||
const owner = open(t('Share selected scene'));
|
||||
if (!owner) return;
|
||||
const paths = new Set(
|
||||
project.scenes.flatMap((s) =>
|
||||
@@ -320,19 +352,23 @@ export function createSceneSharing(director) {
|
||||
staged = { project, assets: existing };
|
||||
inventory(staged);
|
||||
dialog.text(
|
||||
'Scene JSON preserves authored settings and attribution. It does not include asset files. Bundles include only pack files you choose here or previously imported bundle files.',
|
||||
t(
|
||||
'Scene JSON preserves authored settings and attribution. It does not include asset files. Bundles include only pack files you choose here or previously imported bundle files.',
|
||||
),
|
||||
);
|
||||
dialog.button('Download scene JSON', () => {
|
||||
dialog.button(t('Download scene JSON'), () => {
|
||||
if (alive(owner)) download(stringifySceneDocument(project), 'scene.json');
|
||||
});
|
||||
const files = dialog.input('Choose data-pack files', '', { type: 'file' });
|
||||
const files = dialog.input(t('Choose data-pack files'), '', {
|
||||
type: 'file',
|
||||
});
|
||||
files.multiple = true;
|
||||
const folder = dialog.input('Or choose a data-pack folder', '', {
|
||||
const folder = dialog.input(t('Or choose a data-pack folder'), '', {
|
||||
type: 'file',
|
||||
});
|
||||
folder.multiple = true;
|
||||
folder.setAttribute('webkitdirectory', '');
|
||||
dialog.button('Download asset bundle', () =>
|
||||
dialog.button(t('Download asset bundle'), () =>
|
||||
run(owner, async () => {
|
||||
const selected = [...files.files, ...folder.files];
|
||||
const packs = project.scenes.flatMap((s) => s.dataPacks || []);
|
||||
@@ -374,7 +410,7 @@ export function createSceneSharing(director) {
|
||||
);
|
||||
if (alive(owner)) {
|
||||
download(text, 'scene.gevbundle.json');
|
||||
dialog.status.textContent = 'Asset bundle downloaded';
|
||||
dialog.status.textContent = t('Asset bundle downloaded');
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
+38
-23
@@ -17,6 +17,7 @@ import {
|
||||
selectAuthorizedSdrDevice,
|
||||
} from './usbDevices.js';
|
||||
import { summarizeLocalAdsb } from '../sources/adsbRecords.js';
|
||||
import { t } from '../i18n/index.js';
|
||||
|
||||
export { createRememberingWebUsb } from './usbDevices.js';
|
||||
|
||||
@@ -204,23 +205,25 @@ function clampFmFrequency(value) {
|
||||
function describeConnectionError(error) {
|
||||
const message = String(error?.message || error || '').trim();
|
||||
if (/No device was selected|NotFoundError/i.test(message))
|
||||
return 'No RTL-SDR device selected';
|
||||
return t('No RTL-SDR device selected');
|
||||
if (
|
||||
/does not support the WebUSB|usb/i.test(message) &&
|
||||
/support/i.test(message)
|
||||
) {
|
||||
return 'WebUSB requires desktop Chrome or Edge on localhost/HTTPS';
|
||||
return t('WebUSB requires desktop Chrome or Edge on localhost/HTTPS');
|
||||
}
|
||||
if (/access|permission|claim|busy/i.test(message)) {
|
||||
return 'RTL-SDR access failed; close other SDR apps and check USB permissions';
|
||||
return t(
|
||||
'RTL-SDR access failed; close other SDR apps and check USB permissions',
|
||||
);
|
||||
}
|
||||
return message || 'RTL-SDR connection failed';
|
||||
return message || t('RTL-SDR connection failed');
|
||||
}
|
||||
|
||||
function streamingMessage(mode) {
|
||||
return mode === 'adsb'
|
||||
? 'Receiving 1090 MHz ADS-B'
|
||||
: 'Receiving broadcast FM';
|
||||
? t('Receiving 1090 MHz ADS-B')
|
||||
: t('Receiving broadcast FM');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -255,8 +258,8 @@ export class SdrController {
|
||||
connected: false,
|
||||
status: webUsbSupported ? 'idle' : 'unsupported',
|
||||
message: webUsbSupported
|
||||
? 'Connect an RTL-SDR to begin.'
|
||||
: 'WebUSB is unavailable in this browser',
|
||||
? t('Connect an RTL-SDR to begin.')
|
||||
: t('WebUSB is unavailable in this browser'),
|
||||
mode: 'fm',
|
||||
frequencyHz: FM_DEFAULT_HZ,
|
||||
sampleRate: FM_SAMPLE_RATE,
|
||||
@@ -402,7 +405,10 @@ export class SdrController {
|
||||
// A failed worker (e.g. its module did not load) never recovers: drop
|
||||
// it so the next session builds and configures a fresh one.
|
||||
this._discardWorker();
|
||||
this._setState({ status: 'error', message: 'SDR decoder worker failed' });
|
||||
this._setState({
|
||||
status: 'error',
|
||||
message: t('SDR decoder worker failed'),
|
||||
});
|
||||
if (this._device) void this.stop({ preserveMessage: true });
|
||||
};
|
||||
return worker;
|
||||
@@ -600,7 +606,7 @@ export class SdrController {
|
||||
if (!this.state.webUsbSupported) {
|
||||
this._setState({
|
||||
status: 'unsupported',
|
||||
message: 'WebUSB requires desktop Chrome or Edge',
|
||||
message: t('WebUSB requires desktop Chrome or Edge'),
|
||||
});
|
||||
return false;
|
||||
}
|
||||
@@ -609,7 +615,7 @@ export class SdrController {
|
||||
const superseded = () => this._destroyed || token !== this._sessionToken;
|
||||
this._setState({
|
||||
status: 'connecting',
|
||||
message: 'Waiting for an RTL-SDR device…',
|
||||
message: t('Waiting for an RTL-SDR device…'),
|
||||
});
|
||||
let acquisition = null;
|
||||
try {
|
||||
@@ -620,7 +626,7 @@ export class SdrController {
|
||||
// consumes the click activation.
|
||||
if (nextMode === 'fm' && !(await this._ensureAudio())) {
|
||||
throw new Error(
|
||||
'Browser audio is suspended; press Connect again to resume it',
|
||||
t('Browser audio is suspended; press Connect again to resume it'),
|
||||
);
|
||||
}
|
||||
if (superseded()) return false;
|
||||
@@ -1002,7 +1008,7 @@ export class SdrController {
|
||||
this._setState({
|
||||
connected: false,
|
||||
status: 'error',
|
||||
message: 'RTL-SDR sample stream stopped',
|
||||
message: t('RTL-SDR sample stream stopped'),
|
||||
});
|
||||
await this.stop({ preserveMessage: true });
|
||||
break;
|
||||
@@ -1040,12 +1046,14 @@ export class SdrController {
|
||||
status: 'tuning',
|
||||
mode: nextMode,
|
||||
gain: this._gainByMode[nextMode],
|
||||
message: `Switching to ${nextMode === 'adsb' ? 'ADS-B' : 'FM'}…`,
|
||||
message: t('Switching to {mode}…', {
|
||||
mode: nextMode === 'adsb' ? 'ADS-B' : 'FM',
|
||||
}),
|
||||
});
|
||||
try {
|
||||
if (nextMode === 'fm' && !(await this._ensureAudio())) {
|
||||
throw new Error(
|
||||
'Browser audio is suspended; select FM again to resume it',
|
||||
t('Browser audio is suspended; select FM again to resume it'),
|
||||
);
|
||||
}
|
||||
// A stall teardown, stop() or reconnect during the switch supersedes
|
||||
@@ -1116,7 +1124,7 @@ export class SdrController {
|
||||
console.warn('[SDR] Gain change failed:', error);
|
||||
this._setState({
|
||||
status: 'error',
|
||||
message: 'RTL-SDR gain change failed',
|
||||
message: t('RTL-SDR gain change failed'),
|
||||
});
|
||||
return null;
|
||||
}
|
||||
@@ -1161,7 +1169,9 @@ export class SdrController {
|
||||
...(quiet
|
||||
? {}
|
||||
: {
|
||||
message: `Tuned to ${(frequency / 1_000_000).toFixed(1)} MHz FM`,
|
||||
message: t('Tuned to {mhz} MHz FM', {
|
||||
mhz: (frequency / 1_000_000).toFixed(1),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
return true;
|
||||
@@ -1226,13 +1236,18 @@ export class SdrController {
|
||||
this._setState({
|
||||
seeking: false,
|
||||
seekMessage: `Found ${(peak.frequency / 1_000_000).toFixed(1)} MHz · ${peak.snr.toFixed(1)} dB`,
|
||||
message: `FM signal found at ${(peak.frequency / 1_000_000).toFixed(1)} MHz`,
|
||||
message: t('FM signal found at {mhz} MHz', {
|
||||
mhz: (peak.frequency / 1_000_000).toFixed(1),
|
||||
}),
|
||||
});
|
||||
return peak;
|
||||
}
|
||||
if (token === this._seekToken) {
|
||||
await this.tuneFm(origin, { quiet: true });
|
||||
this._setState({ seeking: false, seekMessage: 'No FM signal found' });
|
||||
this._setState({
|
||||
seeking: false,
|
||||
seekMessage: t('No FM signal found'),
|
||||
});
|
||||
}
|
||||
return null;
|
||||
} finally {
|
||||
@@ -1248,7 +1263,7 @@ export class SdrController {
|
||||
cancelSeek() {
|
||||
this._seekToken += 1;
|
||||
if (!this.state.seeking) return false;
|
||||
this._setState({ seeking: false, seekMessage: 'Seek stopped' });
|
||||
this._setState({ seeking: false, seekMessage: t('Seek stopped') });
|
||||
this._audioNode?.port.postMessage({
|
||||
type: 'volume',
|
||||
value: this.state.volume,
|
||||
@@ -1268,7 +1283,7 @@ export class SdrController {
|
||||
if (typeof navigator === 'undefined' || !navigator.geolocation) {
|
||||
this._setState({
|
||||
locationStatus: 'unavailable',
|
||||
message: 'Browser location is unavailable',
|
||||
message: t('Browser location is unavailable'),
|
||||
});
|
||||
return false;
|
||||
}
|
||||
@@ -1295,7 +1310,7 @@ export class SdrController {
|
||||
console.warn('[SDR] Receiver location failed:', error);
|
||||
this._setState({
|
||||
locationStatus: 'denied',
|
||||
message: 'Receiver location was not granted',
|
||||
message: t('Receiver location was not granted'),
|
||||
});
|
||||
return false;
|
||||
}
|
||||
@@ -1333,7 +1348,7 @@ export class SdrController {
|
||||
status: preserveMessage ? this.state.status : idleStatus,
|
||||
...(preserveMessage
|
||||
? {}
|
||||
: { message: message || 'RTL-SDR disconnected' }),
|
||||
: { message: message || t('RTL-SDR disconnected') }),
|
||||
snr: null,
|
||||
samplesPerSecond: 0,
|
||||
workerBlocks: 0,
|
||||
|
||||
@@ -66,6 +66,7 @@ export function createFlightSource({
|
||||
now = () => Date.now(),
|
||||
} = {}) {
|
||||
return {
|
||||
// i18n-ignore: feed source name, replaced by the provider from each snapshot and reported to AI tools
|
||||
label: 'Flights',
|
||||
async getSnapshot(query = {}, { signal } = {}) {
|
||||
const params = new URLSearchParams();
|
||||
@@ -127,6 +128,7 @@ export function createMilitarySource({
|
||||
now = () => Date.now(),
|
||||
} = {}) {
|
||||
return {
|
||||
// i18n-ignore: feed source name, replaced by the provider from each snapshot and reported to AI tools
|
||||
label: 'Military aircraft',
|
||||
async getIdentities(_query = {}, { signal } = {}) {
|
||||
const { response, payload } = await readResponse(
|
||||
@@ -186,6 +188,7 @@ export function createVesselSource({
|
||||
origin = () => globalThis.location?.origin || 'http://localhost',
|
||||
} = {}) {
|
||||
return {
|
||||
// i18n-ignore: feed source name, replaced by the provider from each snapshot and reported to AI tools
|
||||
label: 'Vessels',
|
||||
async getSnapshot({ maxRows = 12000, area } = {}, { signal } = {}) {
|
||||
const url = new URL(apiUrl, origin());
|
||||
|
||||
@@ -246,7 +246,7 @@ export const nightVisionShader = {
|
||||
nvgColor += phosphor * lbl * 0.3 * intensity;
|
||||
}
|
||||
|
||||
// Gain indicator below label: "AUTO" marker
|
||||
// Gain indicator below label: "AUTO" marker (i18n-ignore: shader comment, not UI)
|
||||
vec2 gainArea = (uv - vec2(0.03, 0.88)) / vec2(0.05, 0.025);
|
||||
if (gainArea.x >= 0.0 && gainArea.x <= 1.0 && gainArea.y >= 0.0 && gainArea.y <= 1.0) {
|
||||
float gLbl = step(0.1, gainArea.x) * step(gainArea.x, 0.9) *
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { isPickedWorldPosition } from '../data/scenePick.js';
|
||||
import { t } from '../i18n/index.js';
|
||||
|
||||
function trackedTarget(viewer) {
|
||||
const entity = viewer.trackedEntity;
|
||||
@@ -288,8 +289,8 @@ export function bindCameraOrientationControls({
|
||||
tiltButton?.setAttribute(
|
||||
'aria-label',
|
||||
next.tilted
|
||||
? 'Return map to straight-down view'
|
||||
: 'Tilt map to oblique view',
|
||||
? t('Return map to straight-down view')
|
||||
: t('Tilt map to oblique view'),
|
||||
);
|
||||
}
|
||||
if (!applied || applied.heading !== next.heading) {
|
||||
@@ -297,7 +298,9 @@ export function bindCameraOrientationControls({
|
||||
northButton?.style?.setProperty('--camera-heading', `${next.heading}deg`);
|
||||
northButton?.setAttribute(
|
||||
'aria-label',
|
||||
`Reset map to north up. Current heading ${next.heading} degrees`,
|
||||
t('Reset map to north up. Current heading {heading} degrees', {
|
||||
heading: next.heading,
|
||||
}),
|
||||
);
|
||||
}
|
||||
applied = next;
|
||||
|
||||
+11
-11
@@ -1,4 +1,4 @@
|
||||
import { t } from '../i18n/index.js';
|
||||
import { t, msg } from '../i18n/index.js';
|
||||
|
||||
/** Shortest-wrap signed degrees, for heading offsets typed as absolute values. */
|
||||
const signedNormalizeDeg = (deg) => ((((deg + 180) % 360) + 360) % 360) - 180;
|
||||
@@ -12,7 +12,7 @@ const signedNormalizeDeg = (deg) => ((((deg + 180) % 360) + 360) % 360) - 180;
|
||||
*/
|
||||
const CCTV_CAL_FIELDS = {
|
||||
heading: {
|
||||
label: 'HDG',
|
||||
label: msg('HDG'),
|
||||
unit: '°',
|
||||
decimals: 1,
|
||||
get: (cam) => cam.headingDeg,
|
||||
@@ -21,21 +21,21 @@ const CCTV_CAL_FIELDS = {
|
||||
}),
|
||||
},
|
||||
pitch: {
|
||||
label: 'PITCH',
|
||||
label: msg('PITCH'),
|
||||
unit: '°',
|
||||
decimals: 1,
|
||||
get: (cam) => cam.pitchDeg,
|
||||
toPatch: (value, base) => ({ pitchDeg: value - base.pitchDeg }),
|
||||
},
|
||||
fov: {
|
||||
label: 'FOV',
|
||||
label: msg('FOV'),
|
||||
unit: '°',
|
||||
decimals: 0,
|
||||
get: (cam) => cam.fovDeg,
|
||||
toPatch: (value, base) => ({ fovDeg: value - base.fovDeg }),
|
||||
},
|
||||
range: {
|
||||
label: 'RANGE',
|
||||
label: msg('RANGE'),
|
||||
unit: 'm',
|
||||
decimals: 0,
|
||||
get: (cam) => cam.rangeM,
|
||||
@@ -44,21 +44,21 @@ const CCTV_CAL_FIELDS = {
|
||||
}),
|
||||
},
|
||||
height: {
|
||||
label: 'HGT',
|
||||
label: msg('HGT'),
|
||||
unit: 'm',
|
||||
decimals: 0,
|
||||
get: (cam) => cam.mountHeightM,
|
||||
toPatch: (value, base) => ({ heightM: value - base.mountHeightM }),
|
||||
},
|
||||
north: {
|
||||
label: 'ΔN',
|
||||
label: msg('ΔN'),
|
||||
unit: 'm',
|
||||
decimals: 1,
|
||||
get: (cam) => cam.calibration?.offsetNorthM || 0,
|
||||
toPatch: (value) => ({ offsetNorthM: value }),
|
||||
},
|
||||
east: {
|
||||
label: 'ΔE',
|
||||
label: msg('ΔE'),
|
||||
unit: 'm',
|
||||
decimals: 1,
|
||||
get: (cam) => cam.calibration?.offsetEastM || 0,
|
||||
@@ -101,7 +101,7 @@ export function _beginCctvCalValueEdit(chip) {
|
||||
input.step = field.decimals > 0 ? '0.1' : '1';
|
||||
input.value = Number(startValue).toFixed(field.decimals);
|
||||
input.className = 'cctv-cal-input';
|
||||
chip.textContent = `${field.label} `;
|
||||
chip.textContent = `${t(field.label)} `;
|
||||
chip.appendChild(input);
|
||||
input.focus();
|
||||
input.select();
|
||||
@@ -176,8 +176,8 @@ export function _syncCctvCalReadout(enabled, activeCamera) {
|
||||
if (!field) continue;
|
||||
const value = canCalibrate ? field.get(activeCamera) : null;
|
||||
chip.textContent = Number.isFinite(value)
|
||||
? `${field.label} ${Number(value).toFixed(field.decimals)}${field.unit}`
|
||||
: `${field.label} --`;
|
||||
? `${t(field.label)} ${Number(value).toFixed(field.decimals)}${field.unit}`
|
||||
: `${t(field.label)} --`;
|
||||
chip.disabled = !canCalibrate;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -267,7 +267,7 @@ export function renderRegionalBrief(payload, info) {
|
||||
}
|
||||
if (this.localWind) {
|
||||
this.localWind.textContent = Number.isFinite(weather?.windKph)
|
||||
? `${Math.round(weather.windKph)} KM/H`
|
||||
? t('{value} KM/H', { value: Math.round(weather.windKph) })
|
||||
: '—';
|
||||
}
|
||||
if (this.localWindDirection) {
|
||||
|
||||
@@ -53,11 +53,6 @@ export function updateContext(info, heading) {
|
||||
const enteringLost = this.context.dataset.state !== 'lost';
|
||||
this.context.dataset.state = 'lost';
|
||||
if (this.contextUncertainty) {
|
||||
// The English literal write is pinned verbatim by cockpitMarkup.test.mjs
|
||||
// ('CONTACT LOST' must appear in this method body); the t() re-write
|
||||
// below is a same-value override that localizes it under other locales.
|
||||
this.contextUncertainty.textContent =
|
||||
'CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR';
|
||||
this.contextUncertainty.textContent = t(
|
||||
'CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR',
|
||||
);
|
||||
@@ -109,13 +104,18 @@ export function updateContext(info, heading) {
|
||||
if (this.contextDistance) {
|
||||
const distanceM = closest?.distanceM;
|
||||
this.contextDistance.textContent = Number.isFinite(distanceM)
|
||||
? `${distanceM < 10000 ? (distanceM / 1000).toFixed(1) : Math.round(distanceM / 1000)} KM`
|
||||
? t('{value} KM', {
|
||||
value:
|
||||
distanceM < 10000
|
||||
? (distanceM / 1000).toFixed(1)
|
||||
: Math.round(distanceM / 1000),
|
||||
})
|
||||
: '—';
|
||||
this.contextDistance.setAttribute(
|
||||
'aria-label',
|
||||
Number.isFinite(distanceM)
|
||||
? this.contextDistance.textContent
|
||||
: 'Unavailable',
|
||||
: t('Unavailable'),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -147,12 +147,6 @@ export function updateContext(info, heading) {
|
||||
this.contextDirection.classList.toggle('unknown', relative === null);
|
||||
}
|
||||
if (this.contextBearing) {
|
||||
// The 'BRG —' literal is pinned verbatim by cockpitMarkup.test.mjs; the
|
||||
// t() re-write below is a same-value override that localizes it.
|
||||
if (relative === null) this.contextBearing.textContent = 'BRG —';
|
||||
else if (Math.abs(relative) < 8) this.contextBearing.textContent = 'AHEAD';
|
||||
else
|
||||
this.contextBearing.textContent = `${relative < 0 ? 'L' : 'R'} ${String(Math.round(Math.abs(relative))).padStart(3, '0')}°`;
|
||||
if (relative === null) this.contextBearing.textContent = t('BRG —');
|
||||
else if (Math.abs(relative) < 8)
|
||||
this.contextBearing.textContent = t('AHEAD');
|
||||
|
||||
@@ -232,17 +232,17 @@ export function setVisionMode(mode, { revealParameters = false } = {}) {
|
||||
const next = normalizeCockpitVisionMode(mode);
|
||||
this.visionMode = next;
|
||||
const labels = {
|
||||
optical: 'NORMAL',
|
||||
crt: 'CRT',
|
||||
nvg: 'NVG',
|
||||
thermal: 'FLIR',
|
||||
anime: 'ANIME',
|
||||
noir: 'NOIR',
|
||||
snow: 'SNOW',
|
||||
optical: t('NORMAL'),
|
||||
crt: t('CRT'),
|
||||
nvg: t('NVG'),
|
||||
thermal: t('FLIR'),
|
||||
anime: t('ANIME'),
|
||||
noir: t('NOIR'),
|
||||
snow: t('SNOW'),
|
||||
};
|
||||
const names = {
|
||||
optical: 'Normal',
|
||||
crt: 'CRT',
|
||||
optical: t('Normal'),
|
||||
crt: t('CRT'),
|
||||
nvg: t('Night vision'),
|
||||
thermal: t('Thermal'),
|
||||
anime: t('Anime'),
|
||||
|
||||
@@ -36,19 +36,12 @@ export function setContextCollapsed(collapsed) {
|
||||
if (this.contextToggle) {
|
||||
const expanded = !this.contextCollapsed;
|
||||
this.contextToggle.setAttribute('aria-expanded', String(expanded));
|
||||
// The English aria-label literal is pinned verbatim by
|
||||
// cockpitMarkup.test.mjs; the t() re-write below is a same-value
|
||||
// override that localizes it (byte-identical under 'en').
|
||||
this.contextToggle.setAttribute(
|
||||
'aria-label',
|
||||
`${expanded ? 'Collapse' : 'Expand'} Contact panel`,
|
||||
);
|
||||
this.contextToggle.setAttribute(
|
||||
'aria-label',
|
||||
t(expanded ? msg('Expand Contact panel') : msg('Collapse Contact panel')),
|
||||
expanded ? t('Collapse Contact panel') : t('Expand Contact panel'),
|
||||
);
|
||||
this.contextToggle.title = t(
|
||||
expanded ? msg('Expand contact panel') : msg('Collapse contact panel'),
|
||||
expanded ? msg('Collapse contact panel') : msg('Expand contact panel'),
|
||||
);
|
||||
const icon = this.contextToggle.querySelector('.material-symbols-outlined');
|
||||
if (icon) icon.textContent = expanded ? 'chevron_left' : 'chevron_right';
|
||||
|
||||
@@ -617,7 +617,7 @@ test('the Display selector, voice schema and final stylesheet expose Cyber', ()
|
||||
const militaryRendering = read('../layers/military/rendering.js');
|
||||
const setHud = GEV_ACTION_SCHEMAS.find((schema) => schema.name === 'set_hud');
|
||||
|
||||
assert.match(display, /<option value="cyber">Cyber<\/option>/);
|
||||
assert.match(display, /<option value="cyber" data-i18n>Cyber<\/option>/);
|
||||
assert.match(display, /id="cyber-sonar-toggle"/);
|
||||
assert.match(
|
||||
display,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { t } from '../i18n/index.js';
|
||||
/** Count rendered globe frames while the optional readout is visible. */
|
||||
export function createFrameRateMonitor({ viewer, documentRef = document }) {
|
||||
const host = documentRef.getElementById('title-bar');
|
||||
@@ -7,8 +8,9 @@ export function createFrameRateMonitor({ viewer, documentRef = document }) {
|
||||
const readout = documentRef.createElement('div');
|
||||
readout.className = 'frame-rate-readout';
|
||||
readout.hidden = true;
|
||||
// i18n-ignore: FPS is a unit code, the same in every language
|
||||
readout.textContent = 'FPS —';
|
||||
readout.title = 'Rendered globe frames per second · toggle with `';
|
||||
readout.title = t('Rendered globe frames per second · toggle with `');
|
||||
host.appendChild(readout);
|
||||
let removeFrameListener = null;
|
||||
let timer = null;
|
||||
@@ -27,6 +29,7 @@ export function createFrameRateMonitor({ viewer, documentRef = document }) {
|
||||
function show() {
|
||||
frames = 0;
|
||||
startedAt = performance.now();
|
||||
// i18n-ignore: FPS is a unit code, the same in every language
|
||||
readout.textContent = 'FPS —';
|
||||
readout.hidden = false;
|
||||
removeFrameListener = frameEvent.addEventListener(() => {
|
||||
@@ -36,6 +39,7 @@ export function createFrameRateMonitor({ viewer, documentRef = document }) {
|
||||
const now = performance.now();
|
||||
const elapsed = now - startedAt;
|
||||
readout.textContent =
|
||||
// i18n-ignore: FPS is a unit code, the same in every language
|
||||
documentRef.hidden || elapsed <= 0
|
||||
? 'FPS —'
|
||||
: `FPS ${Math.round((frames * 1000) / elapsed)}`;
|
||||
|
||||
+19
-8
@@ -40,6 +40,16 @@ const LAYER_NAME_KEYS = Object.freeze({
|
||||
'local-dams': msg('Dams'),
|
||||
'telegeography-submarine-cables': msg('Submarine Cables'),
|
||||
'local-firms': msg('FIRMS Active Fires'),
|
||||
'local-adsb': msg('Local ADS-B'),
|
||||
transit: msg('Transit'),
|
||||
'recent-imagery': msg('Recent Imagery'),
|
||||
'fire-perimeters': msg('Fire Perimeters'),
|
||||
wind: msg('Wind'),
|
||||
'weather-radar': msg('Rain radar'),
|
||||
'weather-satellite': msg('Satellite clouds'),
|
||||
'weather-lightning': msg('Lightning density'),
|
||||
'weather-cyclones': msg('Cyclone advisories'),
|
||||
directions: msg('Directions'),
|
||||
});
|
||||
|
||||
function lifecycleStateLabel(lifecycleState) {
|
||||
@@ -58,7 +68,7 @@ const FEED_STATE_LABELS = Object.freeze({
|
||||
// Presentation order is independent of catalog registration and startup order.
|
||||
const PANEL_GROUPS = [
|
||||
{
|
||||
label: 'Movement',
|
||||
label: msg('Movement'),
|
||||
ids: [
|
||||
'satellites',
|
||||
'flights',
|
||||
@@ -71,11 +81,11 @@ const PANEL_GROUPS = [
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Cameras',
|
||||
label: msg('Cameras'),
|
||||
ids: ['cctv', 'recent-imagery', 'street-level'],
|
||||
},
|
||||
{
|
||||
label: 'Infrastructure',
|
||||
label: msg('Infrastructure'),
|
||||
ids: [
|
||||
'alpr-cameras',
|
||||
'military-installations',
|
||||
@@ -85,11 +95,11 @@ const PANEL_GROUPS = [
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Events',
|
||||
label: msg('Events'),
|
||||
ids: ['rocket-launches', 'earthquakes', 'local-firms', 'fire-perimeters'],
|
||||
},
|
||||
{
|
||||
label: 'Weather',
|
||||
label: msg('Weather'),
|
||||
ids: [
|
||||
'wind',
|
||||
'weather-radar',
|
||||
@@ -99,7 +109,7 @@ const PANEL_GROUPS = [
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Utilities',
|
||||
label: msg('Utilities'),
|
||||
ids: ['directions', 'radio'],
|
||||
},
|
||||
];
|
||||
@@ -251,11 +261,12 @@ export class LayerPanel {
|
||||
for (const layer of layers) {
|
||||
if (!layer.showInTogglePanel) continue;
|
||||
const group =
|
||||
PANEL_ORDER[PANEL_POSITIONS.get(layer.id)]?.label ?? 'Other layers';
|
||||
PANEL_ORDER[PANEL_POSITIONS.get(layer.id)]?.label ??
|
||||
msg('Other layers');
|
||||
if (group && group !== previousGroup) {
|
||||
const heading = document.createElement('h3');
|
||||
heading.className = 'data-layer-group-heading';
|
||||
heading.textContent = group;
|
||||
heading.textContent = t(group);
|
||||
this._toggleContainer.appendChild(heading);
|
||||
}
|
||||
previousGroup = group;
|
||||
|
||||
@@ -13,7 +13,9 @@ test('panel presentation places Transit between Street Traffic and Bike Share in
|
||||
source.indexOf('const PANEL_POSITIONS ='),
|
||||
);
|
||||
const order = JSON.parse(
|
||||
runInNewContext(`${declarations}\nJSON.stringify(PANEL_ORDER)`),
|
||||
runInNewContext(
|
||||
`const msg = (text) => text;\n${declarations}\nJSON.stringify(PANEL_ORDER)`,
|
||||
),
|
||||
);
|
||||
assert.deepEqual(
|
||||
order.filter(({ label }) => label === 'Movement').map(({ id }) => id),
|
||||
|
||||
@@ -1,10 +1,26 @@
|
||||
import { localReceiverFeedName } from '../layers/localAdsb/feedNames.js';
|
||||
import { msg, t, tn } from '../i18n/index.js';
|
||||
|
||||
/**
|
||||
* Pure presentation of the Local RTL-SDR card in the Radio panel. Receives the
|
||||
* receiver session snapshot and returns what each control should show.
|
||||
*/
|
||||
|
||||
// Receiver status codes shown on the connection chip and the connect button.
|
||||
const STATUS_LABELS = Object.freeze({
|
||||
idle: msg('IDLE'),
|
||||
connecting: msg('CONNECTING'),
|
||||
tuning: msg('TUNING'),
|
||||
streaming: msg('STREAMING'),
|
||||
error: msg('ERROR'),
|
||||
unsupported: msg('UNSUPPORTED'),
|
||||
});
|
||||
|
||||
function statusLabel(status) {
|
||||
const key = String(status || 'idle');
|
||||
return STATUS_LABELS[key] ? t(STATUS_LABELS[key]) : key.toUpperCase();
|
||||
}
|
||||
|
||||
function formatGain(gain) {
|
||||
return gain === 'auto' || !Number.isFinite(gain) ? 'auto' : gain.toFixed(1);
|
||||
}
|
||||
@@ -20,10 +36,15 @@ function statusText(state) {
|
||||
const iqStatus =
|
||||
state.samplesPerSecond > 0
|
||||
? `${(state.samplesPerSecond / 1_000_000).toFixed(2)} MS/s IQ`
|
||||
: 'waiting for IQ';
|
||||
: t('waiting for IQ');
|
||||
const device = state.deviceLabel ? ` · ${state.deviceLabel}` : '';
|
||||
if (state.connected && !fmActive) {
|
||||
return `${state.message}${device} · ${iqStatus} · ${state.decodedMessages} messages`;
|
||||
const decoded = tn(
|
||||
state.decodedMessages,
|
||||
'{count} message',
|
||||
'{count} messages',
|
||||
);
|
||||
return `${state.message}${device} · ${iqStatus} · ${decoded}`;
|
||||
}
|
||||
const detail = state.seeking
|
||||
? state.seekMessage
|
||||
@@ -31,13 +52,14 @@ function statusText(state) {
|
||||
if (state.connected && fmActive) {
|
||||
const workerStatus =
|
||||
state.workerBlocks > 0
|
||||
? `DSP ${state.workerBlocks} blocks`
|
||||
: 'DSP waiting';
|
||||
? tn(state.workerBlocks, 'DSP {count} block', 'DSP {count} blocks')
|
||||
: t('DSP waiting');
|
||||
const rfStatus = `RF ${formatIq(state)}`;
|
||||
const audioSignal = Number.isFinite(state.audioLevelDbfs)
|
||||
? `${state.audioLevelDbfs.toFixed(1)} dBFS audio`
|
||||
: 'audio signal --';
|
||||
return `${detail}${device} · ${iqStatus} · ${workerStatus} · ${rfStatus} · ${audioSignal} · audio ${state.audioState}`;
|
||||
? t('{level} dBFS audio', { level: state.audioLevelDbfs.toFixed(1) })
|
||||
: t('audio signal --');
|
||||
const audio = t('audio {state}', { state: state.audioState });
|
||||
return `${detail}${device} · ${iqStatus} · ${workerStatus} · ${rfStatus} · ${audioSignal} · ${audio}`;
|
||||
}
|
||||
return detail;
|
||||
}
|
||||
@@ -57,15 +79,15 @@ export function localSdrCardView(state) {
|
||||
return {
|
||||
connectionLabel: state.connected
|
||||
? adsbActive
|
||||
? `${state.aircraftHeard || 0} HEARD`
|
||||
: 'STREAMING'
|
||||
: String(state.status || 'idle').toUpperCase(),
|
||||
? tn(state.aircraftHeard || 0, '{count} HEARD', '{count} HEARD')
|
||||
: t('STREAMING')
|
||||
: statusLabel(state.status),
|
||||
connectionActive: Boolean(state.connected),
|
||||
connectLabel: transitional
|
||||
? String(state.status).toUpperCase()
|
||||
? statusLabel(state.status)
|
||||
: state.connected
|
||||
? 'DISCONNECT'
|
||||
: 'CONNECT',
|
||||
? t('DISCONNECT')
|
||||
: t('CONNECT'),
|
||||
connectDisabled: transitional || !state.webUsbSupported,
|
||||
connectPressed: Boolean(state.connected),
|
||||
fmActive,
|
||||
@@ -73,10 +95,10 @@ export function localSdrCardView(state) {
|
||||
modeDisabled: transitional,
|
||||
locateLabel:
|
||||
state.locationStatus === 'requesting'
|
||||
? 'LOCATING…'
|
||||
? t('LOCATING…')
|
||||
: state.locationStatus === 'ready'
|
||||
? 'LOCATED'
|
||||
: 'LOCATE',
|
||||
? t('LOCATED')
|
||||
: t('LOCATE'),
|
||||
locateDisabled: state.locationStatus === 'requesting',
|
||||
locateActive: state.locationStatus === 'ready',
|
||||
changeDeviceDisabled: transitional || !state.webUsbSupported,
|
||||
|
||||
@@ -761,7 +761,9 @@ export class PanelPositionControls {
|
||||
handle.dataset.dir = dir;
|
||||
handle.setAttribute('aria-hidden', 'true');
|
||||
if (dir === 'se') {
|
||||
handle.title = 'Drag to resize · double-click the header to snap back';
|
||||
handle.title = t(
|
||||
'Drag to resize · double-click the header to snap back',
|
||||
);
|
||||
}
|
||||
panelEl.appendChild(handle);
|
||||
this._resizeHandles.push(handle);
|
||||
|
||||
@@ -154,7 +154,10 @@ export function bindRadioControls() {
|
||||
syncTunerTape(resolvedCoordinate);
|
||||
if (this._radioTunerValue) {
|
||||
this._radioTunerValue.textContent = station
|
||||
? `CH ${String(slot.stationIndex + 1).padStart(2, '0')} / ${String(this._radioTunerStations.length).padStart(2, '0')}`
|
||||
? t('CH {index} / {total}', {
|
||||
index: String(slot.stationIndex + 1).padStart(2, '0'),
|
||||
total: String(this._radioTunerStations.length).padStart(2, '0'),
|
||||
})
|
||||
: t('NO STATIONS');
|
||||
}
|
||||
if (this._radioTunerStation)
|
||||
@@ -325,7 +328,7 @@ export function bindRadioControls() {
|
||||
this._radioTunerStation.textContent = t('STATION UNAVAILABLE');
|
||||
this._radioTunerSlider?.setAttribute(
|
||||
'aria-valuetext',
|
||||
'Station unavailable after directory refresh',
|
||||
t('Station unavailable after directory refresh'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { t } from '../i18n/index.js';
|
||||
import { syncChipGroup } from './chipGroup.js';
|
||||
import { syncRowList } from './rowList.js';
|
||||
|
||||
@@ -98,8 +99,8 @@ export function createRailCardBlocks({ container, cardId, onParams }) {
|
||||
block.clear.type = 'button';
|
||||
block.clear.dataset.actionId = 'clear';
|
||||
block.clear.textContent = '×';
|
||||
block.clear.setAttribute('aria-label', 'Clear reading');
|
||||
block.clear.title = 'Clear reading';
|
||||
block.clear.setAttribute('aria-label', t('Clear reading'));
|
||||
block.clear.title = t('Clear reading');
|
||||
bind((event) => {
|
||||
if (event.target === block.clear) dispatch(block.props.clear);
|
||||
});
|
||||
|
||||
+12
-7
@@ -1,3 +1,4 @@
|
||||
import { t } from '../i18n/index.js';
|
||||
const set = (node, key, value) => {
|
||||
if (node[key] !== value) node[key] = value;
|
||||
};
|
||||
@@ -32,7 +33,7 @@ export function createRailTimeline({
|
||||
parent.appendChild(node);
|
||||
return node;
|
||||
};
|
||||
const previous = makeButton('‹', 'Earlier observation', row);
|
||||
const previous = makeButton('‹', t('Earlier observation'), row);
|
||||
const slider = document.createElement('input');
|
||||
slider.type = 'range';
|
||||
slider.className = `gev-quantitative-slider${sliderClassName ? ` ${sliderClassName}` : ''}`;
|
||||
@@ -40,19 +41,23 @@ export function createRailTimeline({
|
||||
slider.max = '0';
|
||||
slider.step = '1';
|
||||
slider.value = '0';
|
||||
slider.setAttribute('aria-label', 'Observed history');
|
||||
slider.setAttribute('aria-label', t('Observed history'));
|
||||
row.appendChild(slider);
|
||||
const next = makeButton('›', 'Later observation', row);
|
||||
const next = makeButton('›', t('Later observation'), row);
|
||||
const controls = document.createElement('div');
|
||||
controls.className = 'rail-timeline-controls';
|
||||
const latest = makeButton('Latest', 'Newest frame per product', controls);
|
||||
const play = makeButton('Play', 'Replay observed history', controls);
|
||||
const latest = makeButton(
|
||||
t('Latest'),
|
||||
t('Newest frame per product'),
|
||||
controls,
|
||||
);
|
||||
const play = makeButton(t('Play'), t('Replay observed history'), controls);
|
||||
const readout = document.createElement('span');
|
||||
readout.className = 'rail-timeline-readout';
|
||||
controls.appendChild(readout);
|
||||
const label = document.createElement('div');
|
||||
label.className = 'panel-title';
|
||||
label.textContent = 'Observed history';
|
||||
label.textContent = t('Observed history');
|
||||
const endpoints = document.createElement('div');
|
||||
endpoints.className = 'rail-timeline-endpoints';
|
||||
const oldest = document.createElement('span');
|
||||
@@ -128,7 +133,7 @@ export function createRailTimeline({
|
||||
`data-toggle-chip${props.mode === 'latest' ? ' active' : ''}`,
|
||||
);
|
||||
set(play, 'className', `data-toggle-chip${props.playing ? ' active' : ''}`);
|
||||
set(play, 'textContent', props.playing ? 'Pause' : 'Play');
|
||||
set(play, 'textContent', props.playing ? t('Pause') : t('Play'));
|
||||
for (const [node, active] of [
|
||||
[latest, props.mode === 'latest'],
|
||||
[play, props.playing],
|
||||
|
||||
+157
-97
@@ -8,6 +8,7 @@
|
||||
* strip and selection bar are this adapter's own keyed DOM. Source toggles
|
||||
* live on the data-panel row.
|
||||
*/
|
||||
import { t, tn, msg } from '../i18n/index.js';
|
||||
import { createRailCardBlocks } from './railCardBlocks.js';
|
||||
import { createRailCards } from './railCards.js';
|
||||
import { createImagerySplit } from './imagerySplit.js';
|
||||
@@ -20,28 +21,28 @@ import {
|
||||
|
||||
const PANEL_ID = 'recent-imagery-panel';
|
||||
const OVERVIEW_MIN_BOX_KM = 25;
|
||||
const ESRI_NOTE = 'Imagery on Esri · Google 3D returns when cleared';
|
||||
const ESRI_NOTE = msg('Imagery on Esri · Google 3D returns when cleared');
|
||||
const MODE_LABELS = [
|
||||
['image', 'IMAGE'],
|
||||
['basemap', 'VS BASEMAP'],
|
||||
['ab', 'A / B'],
|
||||
['image', msg('IMAGE')],
|
||||
['basemap', msg('VS BASEMAP')],
|
||||
['ab', msg('A / B')],
|
||||
];
|
||||
const START_HERE_TITLES = {
|
||||
clear: 'Newest low-cloud day for this box · scene cloud, not box cloud',
|
||||
cloudy: 'Newest day covering this box · cloudier than 20%',
|
||||
partial: 'Newest day with imagery for this box · partial coverage',
|
||||
overview: 'Newest daily overview for this box',
|
||||
clear: msg('Newest low-cloud day for this box · scene cloud, not box cloud'),
|
||||
cloudy: msg('Newest day covering this box · cloudier than 20%'),
|
||||
partial: msg('Newest day with imagery for this box · partial coverage'),
|
||||
overview: msg('Newest daily overview for this box'),
|
||||
};
|
||||
const START_HERE_REASONS = {
|
||||
clear: 'newest clear day',
|
||||
cloudy: 'newest day (cloudy)',
|
||||
partial: 'newest day (partial coverage)',
|
||||
overview: 'newest overview',
|
||||
clear: msg('newest clear day'),
|
||||
cloudy: msg('newest day (cloudy)'),
|
||||
partial: msg('newest day (partial coverage)'),
|
||||
overview: msg('newest overview'),
|
||||
};
|
||||
const PLACEHOLDERS = {
|
||||
empty: 'No imagery',
|
||||
error: 'Unavailable',
|
||||
present: 'Loading',
|
||||
empty: msg('No imagery'),
|
||||
error: msg('Unavailable'),
|
||||
present: msg('Loading'),
|
||||
};
|
||||
const KEY_STEPS = {
|
||||
ArrowLeft: (index) => index - 1,
|
||||
@@ -55,7 +56,7 @@ const appearedDocuments = new WeakSet();
|
||||
|
||||
const sensorLine = (product) =>
|
||||
PRODUCTS[product]
|
||||
? `${PRODUCTS[product].name} · ${PRODUCTS[product].resolutionM} m`
|
||||
? `${t(PRODUCTS[product].name)} · ${PRODUCTS[product].resolutionM} m`
|
||||
: product || '';
|
||||
const utcTime = (iso) => (hhmm(iso) ? `${hhmm(iso)}Z` : '');
|
||||
const longerSideKm = (size) =>
|
||||
@@ -68,35 +69,36 @@ const kmText = (km) =>
|
||||
: km.toFixed(1);
|
||||
|
||||
function cloudText(candidate) {
|
||||
if (candidate.thumbnail?.status === 'empty') return 'no imagery';
|
||||
if (!candidate.cloud) return 'cloud unknown';
|
||||
if (candidate.thumbnail?.status === 'empty') return t('no imagery');
|
||||
if (!candidate.cloud) return t('cloud unknown');
|
||||
const min = Math.round(candidate.cloud.min);
|
||||
const max = Math.round(candidate.cloud.max);
|
||||
return min === max ? `${min}% cloud` : `${min}–${max}% cloud`;
|
||||
return min === max
|
||||
? t('{percent}% cloud', { percent: min })
|
||||
: t('{min}–{max}% cloud', { min, max });
|
||||
}
|
||||
|
||||
function countText(snapshot) {
|
||||
if (!snapshot.box) return 'NO BOX';
|
||||
if (snapshot.searching) return 'SEARCHING';
|
||||
const count = snapshot.candidates.length;
|
||||
return `${count} DAY${count === 1 ? '' : 'S'}`;
|
||||
if (!snapshot.box) return t('NO BOX');
|
||||
if (snapshot.searching) return t('SEARCHING');
|
||||
return tn(snapshot.candidates.length, '{count} DAY', '{count} DAYS');
|
||||
}
|
||||
|
||||
/** The one-line hint under the strip: the next thing to do, per state. */
|
||||
function hintText(snapshot) {
|
||||
if (snapshot.zoomToFit) return 'Zoom in or draw a smaller box';
|
||||
if (!snapshot.box) return 'Select a box or use the view';
|
||||
if (snapshot.searching) return 'Searching the last 30 days';
|
||||
if (!snapshot.candidates.length) return 'No days to show for this box';
|
||||
if (snapshot.zoomToFit) return t('Zoom in or draw a smaller box');
|
||||
if (!snapshot.box) return t('Select a box or use the view');
|
||||
if (snapshot.searching) return t('Searching the last 30 days');
|
||||
if (!snapshot.candidates.length) return t('No days to show for this box');
|
||||
const { mode, pins, shown } = snapshot;
|
||||
if (shown.swipe !== 'none') return 'Drag the divider · SWAP trades sides';
|
||||
if (shown.swipe !== 'none') return t('Drag the divider · SWAP trades sides');
|
||||
if (mode === 'ab') {
|
||||
if (!pins.a.key && !pins.b.key)
|
||||
return '← → preview · A or B pins the focused day';
|
||||
return '← → preview the other side · A or B pins it';
|
||||
return t('← → preview · A or B pins the focused day');
|
||||
return t('← → preview the other side · A or B pins it');
|
||||
}
|
||||
if (pins.a.key) return 'S on another day replaces it · × unpins';
|
||||
return '← → preview · S shows the focused day';
|
||||
if (pins.a.key) return t('S on another day replaces it · × unpins');
|
||||
return t('← → preview · S shows the focused day');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -173,7 +175,7 @@ export function createRecentImageryPanel({
|
||||
|
||||
// ---- build: every block exists from the start, in its final order ----
|
||||
const root = el('section', 'recent-imagery-readout');
|
||||
root.setAttribute('aria-label', 'Recent imagery');
|
||||
root.setAttribute('aria-label', t('Recent imagery'));
|
||||
|
||||
const actionsHost = el('div', 'ri-block ri-actions-row', root);
|
||||
actionsHost.id = 'ri-actions';
|
||||
@@ -191,10 +193,15 @@ export function createRecentImageryPanel({
|
||||
noticeText.id = 'ri-notice-text';
|
||||
noticeText.setAttribute('role', 'status');
|
||||
noticeText.setAttribute('aria-live', 'polite');
|
||||
const zoomIn = el('button', 'data-toggle-chip ri-zoom-in', notice, 'ZOOM IN');
|
||||
const zoomIn = el(
|
||||
'button',
|
||||
'data-toggle-chip ri-zoom-in',
|
||||
notice,
|
||||
t('ZOOM IN'),
|
||||
);
|
||||
zoomIn.id = 'ri-zoom-in';
|
||||
zoomIn.type = 'button';
|
||||
zoomIn.title = 'Fly in until the view fits the 1,000 km limit';
|
||||
zoomIn.title = t('Fly in until the view fits the 1,000 km limit');
|
||||
|
||||
const strip = el('div', 'ri-block ri-strip', root);
|
||||
strip.id = 'ri-strip';
|
||||
@@ -203,7 +210,7 @@ export function createRecentImageryPanel({
|
||||
strip.setAttribute('aria-orientation', 'horizontal');
|
||||
strip.setAttribute(
|
||||
'aria-label',
|
||||
'Imagery days, newest first · arrows move, S or A and B pin',
|
||||
t('Imagery days, newest first · arrows move, S or A and B pin'),
|
||||
);
|
||||
strip.setAttribute('aria-keyshortcuts', 'A B S');
|
||||
const stripEmpty = el('div', 'ri-strip-empty', strip);
|
||||
@@ -213,16 +220,16 @@ export function createRecentImageryPanel({
|
||||
|
||||
const selection = el('section', 'ri-block ri-selection', root);
|
||||
selection.id = 'ri-selection';
|
||||
selection.setAttribute('aria-label', 'On the map');
|
||||
selection.setAttribute('aria-label', t('On the map'));
|
||||
const modeGroup = el('div', 'ri-mode', selection);
|
||||
modeGroup.id = 'ri-mode';
|
||||
modeGroup.setAttribute('role', 'radiogroup');
|
||||
modeGroup.setAttribute('aria-label', 'Mode');
|
||||
modeGroup.setAttribute('aria-label', t('Mode'));
|
||||
const modeButtons = new Map(
|
||||
MODE_LABELS.map(([mode, label]) => {
|
||||
const node = el('button', 'data-toggle-chip ri-mode-btn', modeGroup);
|
||||
node.type = 'button';
|
||||
node.textContent = label;
|
||||
node.textContent = t(label);
|
||||
node.dataset.mode = mode;
|
||||
node.setAttribute('role', 'radio');
|
||||
return [mode, node];
|
||||
@@ -244,7 +251,7 @@ export function createRecentImageryPanel({
|
||||
const controls = el('div', 'ri-block ri-controls', root);
|
||||
controls.id = 'ri-controls';
|
||||
const opacityLabel = el('label', 'ri-opacity-label', controls);
|
||||
el('span', '', opacityLabel, 'OPACITY');
|
||||
el('span', '', opacityLabel, t('OPACITY'));
|
||||
const opacityValue = el('span', 'ri-opacity-value', opacityLabel, '100%');
|
||||
const opacity = el('input', 'ri-opacity', controls);
|
||||
opacity.id = 'ri-opacity';
|
||||
@@ -255,13 +262,13 @@ export function createRecentImageryPanel({
|
||||
step: '1',
|
||||
value: '100',
|
||||
});
|
||||
opacity.setAttribute('aria-label', 'Imagery opacity');
|
||||
opacity.setAttribute('aria-label', t('Imagery opacity'));
|
||||
opacityLabel.htmlFor = 'ri-opacity';
|
||||
// Its slot is always there; it is only usable while a swipe is live.
|
||||
const swap = el('button', 'data-toggle-chip ri-swap', controls, 'SWAP');
|
||||
const swap = el('button', 'data-toggle-chip ri-swap', controls, t('SWAP'));
|
||||
swap.id = 'ri-swap';
|
||||
swap.type = 'button';
|
||||
swap.title = 'Trade the two sides of the divider';
|
||||
swap.title = t('Trade the two sides of the divider');
|
||||
const exportHost = el('div', 'ri-exports', controls);
|
||||
const exports = createRailCardBlocks({
|
||||
container: exportHost,
|
||||
@@ -318,8 +325,8 @@ export function createRecentImageryPanel({
|
||||
actions: [
|
||||
{
|
||||
id: 'select-box',
|
||||
label: 'SELECT BOX',
|
||||
title: 'Drag a box on the map (Esc cancels)',
|
||||
label: t('SELECT BOX'),
|
||||
title: t('Drag a box on the map (Esc cancels)'),
|
||||
onClick: () => {
|
||||
exportError = null;
|
||||
if (tool?.isActive()) tool.cancel('toggle');
|
||||
@@ -328,8 +335,8 @@ export function createRecentImageryPanel({
|
||||
},
|
||||
{
|
||||
id: 'use-view',
|
||||
label: 'USE VIEW',
|
||||
title: 'Use the current view as the box',
|
||||
label: t('USE VIEW'),
|
||||
title: t('Use the current view as the box'),
|
||||
onClick: () => {
|
||||
exportError = null;
|
||||
layer.useCurrentView(viewer);
|
||||
@@ -337,8 +344,8 @@ export function createRecentImageryPanel({
|
||||
},
|
||||
{
|
||||
id: 'clear',
|
||||
label: 'CLEAR',
|
||||
title: 'Forget the box and its images',
|
||||
label: t('CLEAR'),
|
||||
title: t('Forget the box and its images'),
|
||||
disabled: !snapshot.box && !snapshot.boxError,
|
||||
onClick: () => {
|
||||
exportError = null;
|
||||
@@ -366,7 +373,7 @@ export function createRecentImageryPanel({
|
||||
function renderNotice() {
|
||||
const warning = snapshot.boxError || snapshot.error || exportError;
|
||||
const text =
|
||||
warning || snapshot.notice || (snapshot.borrowedEsri ? ESRI_NOTE : '');
|
||||
warning || snapshot.notice || (snapshot.borrowedEsri ? t(ESRI_NOTE) : '');
|
||||
set(noticeText, 'textContent', text);
|
||||
attribute(noticeText, 'title', text);
|
||||
toggle(notice, 'warn', Boolean(warning));
|
||||
@@ -388,7 +395,7 @@ export function createRecentImageryPanel({
|
||||
entry.thumb = el('div', 'ri-thumb', card);
|
||||
entry.placeholder = el('span', 'ri-thumb-text', entry.thumb);
|
||||
entry.flag = el('span', 'ri-card-flag', entry.thumb);
|
||||
entry.start = el('span', 'ri-card-start', entry.thumb, 'START HERE');
|
||||
entry.start = el('span', 'ri-card-start', entry.thumb, t('START HERE'));
|
||||
entry.date = el('div', 'ri-card-date', card);
|
||||
entry.sensor = el('div', 'ri-card-sensor', card);
|
||||
entry.cloud = el('div', 'ri-card-cloud', card);
|
||||
@@ -432,13 +439,17 @@ export function createRecentImageryPanel({
|
||||
}
|
||||
if (entry.image) set(entry.image, 'hidden', !shown);
|
||||
set(entry.placeholder, 'hidden', shown);
|
||||
set(entry.placeholder, 'textContent', PLACEHOLDERS[status] || 'Checking');
|
||||
set(
|
||||
entry.placeholder,
|
||||
'textContent',
|
||||
PLACEHOLDERS[status] ? t(PLACEHOLDERS[status]) : t('Checking'),
|
||||
);
|
||||
if (entry.thumb.dataset.status !== status)
|
||||
entry.thumb.dataset.status = status;
|
||||
const flag = candidate.preview
|
||||
? 'PREVIEW'
|
||||
? t('PREVIEW')
|
||||
: candidate.pending
|
||||
? 'LOADING'
|
||||
? t('LOADING')
|
||||
: '';
|
||||
set(entry.flag, 'textContent', flag);
|
||||
set(entry.flag, 'hidden', !flag);
|
||||
@@ -448,8 +459,10 @@ export function createRecentImageryPanel({
|
||||
set(
|
||||
entry.start,
|
||||
'title',
|
||||
START_HERE_TITLES[snapshot.recommended.reason] ||
|
||||
'Newest day with imagery for this box',
|
||||
t(
|
||||
START_HERE_TITLES[snapshot.recommended.reason] ||
|
||||
msg('Newest day with imagery for this box'),
|
||||
),
|
||||
);
|
||||
set(entry.date, 'textContent', day);
|
||||
set(entry.sensor, 'textContent', sensorLine(candidate.product));
|
||||
@@ -459,7 +472,7 @@ export function createRecentImageryPanel({
|
||||
const chip = entry.chips[slotId];
|
||||
const live = slotId === 'a' || ab;
|
||||
const on = candidate.pinned === slotId && live;
|
||||
const label = slotId === 'a' ? (ab ? 'A' : 'SHOW') : 'B';
|
||||
const label = slotId === 'a' ? (ab ? 'A' : t('SHOW')) : 'B';
|
||||
set(chip, 'textContent', label);
|
||||
set(chip, 'disabled', empty || !live);
|
||||
toggle(chip, 'active', on);
|
||||
@@ -469,22 +482,29 @@ export function createRecentImageryPanel({
|
||||
attribute(
|
||||
chip,
|
||||
'aria-label',
|
||||
ab ? `Pin ${day} as ${slotId.toUpperCase()}` : `Show ${day}`,
|
||||
ab
|
||||
? t('Pin {day} as {slot}', { day, slot: slotId.toUpperCase() })
|
||||
: t('Show {day}', { day }),
|
||||
);
|
||||
}
|
||||
const state = candidate.pinned
|
||||
? ab
|
||||
? ` · pinned ${candidate.pinned.toUpperCase()}`
|
||||
? t('pinned {slot}', { slot: candidate.pinned.toUpperCase() })
|
||||
: candidate.pinned === 'a'
|
||||
? ' · shown'
|
||||
? t('shown')
|
||||
: ''
|
||||
: candidate.preview
|
||||
? ' · preview'
|
||||
? t('preview')
|
||||
: '';
|
||||
attribute(
|
||||
card,
|
||||
'aria-label',
|
||||
`${day} · ${sensorLine(candidate.product)} · ${cloudText(candidate)}${recommended ? ' · start here' : ''}${state}`,
|
||||
[
|
||||
day,
|
||||
sensorLine(candidate.product),
|
||||
cloudText(candidate),
|
||||
...[recommended ? t('start here') : '', state].filter(Boolean),
|
||||
].join(' · '),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -512,14 +532,14 @@ export function createRecentImageryPanel({
|
||||
candidates.length
|
||||
? ''
|
||||
: !snapshot.box
|
||||
? 'No box'
|
||||
? t('No box')
|
||||
: snapshot.searching
|
||||
? 'Searching'
|
||||
? t('Searching')
|
||||
: snapshot.hiddenCount
|
||||
? 'Every day is empty here'
|
||||
? t('Every day is empty here')
|
||||
: !snapshot.sources.hls && !snapshot.sources.viirs
|
||||
? 'Sources off'
|
||||
: 'No imagery',
|
||||
? t('Sources off')
|
||||
: t('No imagery'),
|
||||
);
|
||||
set(stripEmpty, 'hidden', candidates.length > 0);
|
||||
if (candidates[snapshot.focusIndex])
|
||||
@@ -596,18 +616,22 @@ export function createRecentImageryPanel({
|
||||
set(
|
||||
tag,
|
||||
'textContent',
|
||||
ab ? slotId.toUpperCase() : slotId === 'a' ? 'IMAGE' : 'VS',
|
||||
ab ? slotId.toUpperCase() : slotId === 'a' ? t('IMAGE') : t('VS'),
|
||||
);
|
||||
let text = 'Not set';
|
||||
if (basemapRow) text = 'Basemap';
|
||||
let text = t('Not set');
|
||||
if (basemapRow) text = t('Basemap');
|
||||
else if (image?.label)
|
||||
text = `${image.sourceOff ? 'Source off · ' : ''}${image.label}${
|
||||
text = [
|
||||
image.sourceOff ? t('Source off') : '',
|
||||
image.label,
|
||||
image.preview
|
||||
? ' · preview'
|
||||
? t('preview')
|
||||
: image.candidate && !image.drapable && !image.sourceOff
|
||||
? ' · loading'
|
||||
: ''
|
||||
}`;
|
||||
? t('loading')
|
||||
: '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ');
|
||||
set(value, 'textContent', text);
|
||||
attribute(value, 'title', text);
|
||||
const lit = basemapRow
|
||||
@@ -625,7 +649,9 @@ export function createRecentImageryPanel({
|
||||
attribute(
|
||||
unpin,
|
||||
'aria-label',
|
||||
ab ? `Unpin ${slotId.toUpperCase()}` : 'Unpin the image',
|
||||
ab
|
||||
? t('Unpin {slot}', { slot: slotId.toUpperCase() })
|
||||
: t('Unpin the image'),
|
||||
);
|
||||
set(unpin, 'title', unpin.getAttribute('aria-label'));
|
||||
}
|
||||
@@ -658,8 +684,12 @@ export function createRecentImageryPanel({
|
||||
type: 'actions',
|
||||
actions: ['a', 'b'].map((slotId) => ({
|
||||
id: `export-${slotId}`,
|
||||
label: ab ? `EXPORT ${slotId.toUpperCase()}` : 'EXPORT',
|
||||
title: `Download ${ab ? slotId.toUpperCase() : 'the image'} as a PNG`,
|
||||
label: ab
|
||||
? t('EXPORT {slot}', { slot: slotId.toUpperCase() })
|
||||
: t('EXPORT'),
|
||||
title: ab
|
||||
? t('Download {slot} as a PNG', { slot: slotId.toUpperCase() })
|
||||
: t('Download the image as a PNG'),
|
||||
disabled: !exportTarget(slotId) || exporting.has(slotId),
|
||||
onClick: () => void exportImage(slotId),
|
||||
})),
|
||||
@@ -682,7 +712,10 @@ export function createRecentImageryPanel({
|
||||
if (snapshot.box)
|
||||
lines.push({
|
||||
id: 'box',
|
||||
text: `Box ${kmText(snapshot.boxSizeKm?.width)} × ${kmText(snapshot.boxSizeKm?.height)} km`,
|
||||
text: t('Box {width} × {height} km', {
|
||||
width: kmText(snapshot.boxSizeKm?.width),
|
||||
height: kmText(snapshot.boxSizeKm?.height),
|
||||
}),
|
||||
});
|
||||
if (focus) {
|
||||
lines.push({ id: 'readout', text: snapshot.readout || '' });
|
||||
@@ -690,14 +723,24 @@ export function createRecentImageryPanel({
|
||||
if (focus.timeRange?.start) {
|
||||
const start = utcTime(focus.timeRange.start);
|
||||
const end = utcTime(focus.timeRange.end || focus.timeRange.start);
|
||||
times.push(`Acquired ${start === end ? start : `${start}–${end}`}`);
|
||||
times.push(
|
||||
t('Acquired {time}', {
|
||||
time: start === end ? start : `${start}–${end}`,
|
||||
}),
|
||||
);
|
||||
} else if (focus.thumbnail?.acquisitionTime)
|
||||
times.push(`Acquired ${utcTime(focus.thumbnail.acquisitionTime)}`);
|
||||
times.push(`Coverage ${focus.coverage || 'unknown'}`);
|
||||
times.push(
|
||||
t('Acquired {time}', {
|
||||
time: utcTime(focus.thumbnail.acquisitionTime),
|
||||
}),
|
||||
);
|
||||
times.push(
|
||||
t('Coverage {coverage}', { coverage: focus.coverage || t('unknown') }),
|
||||
);
|
||||
lines.push({ id: 'acquired', text: times.join(' · ') });
|
||||
(focus.granules || []).forEach((granule, index) => {
|
||||
const cloud = Number.isFinite(granule.cloud)
|
||||
? ` · ${Math.round(granule.cloud)}% cloud`
|
||||
? ` · ${t('{percent}% cloud', { percent: Math.round(granule.cloud) })}`
|
||||
: '';
|
||||
lines.push({
|
||||
id: `granule-${index}`,
|
||||
@@ -707,7 +750,11 @@ export function createRecentImageryPanel({
|
||||
});
|
||||
}
|
||||
const reason = START_HERE_REASONS[snapshot.recommended?.reason];
|
||||
if (reason) lines.push({ id: 'start', text: `START HERE · ${reason}` });
|
||||
if (reason)
|
||||
lines.push({
|
||||
id: 'start',
|
||||
text: t('START HERE · {reason}', { reason: t(reason) }),
|
||||
});
|
||||
for (const [index, note] of snapshot.notes.entries())
|
||||
lines.push({ id: `note-${index}`, text: note, muted: true });
|
||||
if (
|
||||
@@ -718,12 +765,14 @@ export function createRecentImageryPanel({
|
||||
)
|
||||
lines.push({
|
||||
id: 'overview-scale',
|
||||
text: 'Daily overview shows little detail in a box this small',
|
||||
text: t('Daily overview shows little detail in a box this small'),
|
||||
muted: true,
|
||||
});
|
||||
lines.push({
|
||||
id: 'credit',
|
||||
text: 'Imagery: NASA GIBS and Worldview · HLS (Sentinel-2, Landsat 8/9) and VIIRS',
|
||||
text: t(
|
||||
'Imagery: NASA GIBS and Worldview · HLS (Sentinel-2, Landsat 8/9) and VIIRS',
|
||||
),
|
||||
muted: true,
|
||||
});
|
||||
return lines;
|
||||
@@ -735,9 +784,11 @@ export function createRecentImageryPanel({
|
||||
details.update([
|
||||
{
|
||||
id: 'details',
|
||||
title: 'Details',
|
||||
title: t('Details'),
|
||||
open: detailsOpen,
|
||||
compact: snapshot.focus ? snapshot.readout : 'Times, coverage, sources',
|
||||
compact: snapshot.focus
|
||||
? snapshot.readout
|
||||
: t('Times, coverage, sources'),
|
||||
blocks: [
|
||||
{ id: 'lines', type: 'lines', lines: detailLines() },
|
||||
{
|
||||
@@ -747,9 +798,9 @@ export function createRecentImageryPanel({
|
||||
{
|
||||
id: 'toggle-empty',
|
||||
label: showing
|
||||
? 'HIDE EMPTY DAYS'
|
||||
: `SHOW EMPTY DAYS · ${hidden}`,
|
||||
title: 'Days the probe found empty in this box',
|
||||
? t('HIDE EMPTY DAYS')
|
||||
: t('SHOW EMPTY DAYS · {count}', { count: hidden }),
|
||||
title: t('Days the probe found empty in this box'),
|
||||
disabled: !showing && !hidden,
|
||||
onClick: () => layer.setShowUnavailable(!showing),
|
||||
},
|
||||
@@ -773,8 +824,10 @@ export function createRecentImageryPanel({
|
||||
const describe = (c) => (c ? `${sensorLine(c.product)} · ${c.day}` : '');
|
||||
// The two sides as [label, title, spoken name]; SWAP trades them.
|
||||
const sides = [
|
||||
basemap ? ['IMAGE', describe(a), 'image'] : ['A', describe(a), 'A'],
|
||||
basemap ? ['BASEMAP', 'The basemap', 'basemap'] : ['B', describe(b), 'B'],
|
||||
basemap ? [t('IMAGE'), describe(a), t('image')] : ['A', describe(a), 'A'],
|
||||
basemap
|
||||
? [t('BASEMAP'), t('The basemap'), t('basemap')]
|
||||
: ['B', describe(b), 'B'],
|
||||
];
|
||||
const [before, after] = swapped ? [sides[1], sides[0]] : sides;
|
||||
const signature = live
|
||||
@@ -799,10 +852,15 @@ export function createRecentImageryPanel({
|
||||
beforeTitle: before[1],
|
||||
afterTitle: after[1],
|
||||
ariaLabel: basemap
|
||||
? 'Recent imagery against the basemap divider'
|
||||
: 'Recent imagery A and B divider',
|
||||
? t('Recent imagery against the basemap divider')
|
||||
: t('Recent imagery A and B divider'),
|
||||
formatValueText: (leftPercent, rightPercent) =>
|
||||
`${before[2][0].toUpperCase()}${before[2].slice(1)} ${leftPercent} percent, ${after[2]} ${rightPercent} percent`,
|
||||
t('{before} {left} percent, {after} {right} percent', {
|
||||
before: `${before[2][0].toUpperCase()}${before[2].slice(1)}`,
|
||||
left: leftPercent,
|
||||
after: after[2],
|
||||
right: rightPercent,
|
||||
}),
|
||||
getViewportWidth: () =>
|
||||
Number(viewer?.scene?.canvas?.clientWidth) ||
|
||||
Number(document.documentElement?.clientWidth) ||
|
||||
@@ -895,7 +953,9 @@ export function createRecentImageryPanel({
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (!destroyed)
|
||||
exportError = `Export failed · ${error?.message || error}`;
|
||||
exportError = t('Export failed · {error}', {
|
||||
error: error?.message || error,
|
||||
});
|
||||
return false;
|
||||
} finally {
|
||||
exporting.delete(slotId);
|
||||
|
||||
@@ -1,4 +1,26 @@
|
||||
/** Scene panel elements and text-only project presentation. */
|
||||
import { msg, t } from '../i18n/index.js';
|
||||
|
||||
// Labels for what a shot stores; anything else shows as stored. Style names
|
||||
// match STYLE_STATUS_LABELS in visualPresets.js, which this package cannot
|
||||
// import (it pulls in the shaders).
|
||||
const STYLE_LABELS = Object.freeze({
|
||||
normal: msg('NORMAL'),
|
||||
retro: msg('CRT'),
|
||||
surveillance: msg('NVG'),
|
||||
thermal: msg('FLIR'),
|
||||
anime: msg('ANIME'),
|
||||
noir: msg('NOIR'),
|
||||
snow: msg('SNOW'),
|
||||
});
|
||||
|
||||
const DETECTION_MODE_LABELS = Object.freeze({
|
||||
OFF: msg('OFF'),
|
||||
SPARSE: msg('SPARSE'),
|
||||
BALANCED: msg('BALANCED'),
|
||||
DENSE: msg('DENSE'),
|
||||
});
|
||||
|
||||
export function sceneElements(root = document) {
|
||||
const ids = {
|
||||
panel: 'scene-panel',
|
||||
@@ -47,7 +69,9 @@ export function renderSceneShots(
|
||||
if (!scene || scene.shots.length === 0) {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'scene-shot-empty';
|
||||
empty.textContent = 'No shots yet. Use CAPTURE SHOT to save current look.';
|
||||
empty.textContent = t(
|
||||
'No shots yet. Use CAPTURE SHOT to save current look.',
|
||||
);
|
||||
element.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
@@ -61,14 +85,14 @@ export function renderSceneShots(
|
||||
const label = document.createElement('div');
|
||||
label.className = 'scene-shot-label';
|
||||
label.textContent = shot.title;
|
||||
label.title = 'Double-click to rename';
|
||||
label.title = t('Double-click to rename');
|
||||
listen(label, 'click', () => select(shot.id));
|
||||
listen(label, 'dblclick', () => {
|
||||
if (label.children.length) return;
|
||||
const input = document.createElement('input');
|
||||
input.type = 'text';
|
||||
input.className = 'scene-shot-rename';
|
||||
input.setAttribute('aria-label', 'Shot name');
|
||||
input.setAttribute('aria-label', t('Shot name'));
|
||||
input.value = shot.title;
|
||||
let finished = false;
|
||||
const finish = (save) => {
|
||||
@@ -96,11 +120,11 @@ export function renderSceneShots(
|
||||
actions.className = 'scene-shot-actions';
|
||||
const loadButton = document.createElement('button');
|
||||
loadButton.className = 'scene-shot-btn';
|
||||
loadButton.textContent = 'LOAD';
|
||||
loadButton.textContent = t('LOAD');
|
||||
listen(loadButton, 'click', () => load(scene.id, shot.id));
|
||||
const deleteButton = document.createElement('button');
|
||||
deleteButton.className = 'scene-shot-btn scene-shot-danger';
|
||||
deleteButton.textContent = 'DEL';
|
||||
deleteButton.textContent = t('DEL');
|
||||
listen(deleteButton, 'click', () => remove(scene.id, shot.id));
|
||||
actions.appendChild(loadButton);
|
||||
actions.appendChild(deleteButton);
|
||||
@@ -110,7 +134,13 @@ export function renderSceneShots(
|
||||
meta.className = 'scene-shot-meta';
|
||||
const mode = shot.visual?.detection?.mode || 'OFF';
|
||||
const style = shot.visual?.style || 'normal';
|
||||
meta.textContent = `${style.toUpperCase()} · ${mode} · ${shot.durationSec.toFixed(1)}s + ${shot.holdSec.toFixed(1)}s`;
|
||||
const styleLabel = STYLE_LABELS[style]
|
||||
? t(STYLE_LABELS[style])
|
||||
: style.toUpperCase();
|
||||
const modeLabel = DETECTION_MODE_LABELS[mode]
|
||||
? t(DETECTION_MODE_LABELS[mode])
|
||||
: mode;
|
||||
meta.textContent = `${styleLabel} · ${modeLabel} · ${shot.durationSec.toFixed(1)}s + ${shot.holdSec.toFixed(1)}s`;
|
||||
row.appendChild(top);
|
||||
row.appendChild(meta);
|
||||
element.appendChild(row);
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { t } from '../i18n/index.js';
|
||||
|
||||
/** Small owned modal for scene authoring and import review; text never becomes markup. */
|
||||
export function createSceneDialog(title, onClose) {
|
||||
const dialog = document.createElement('dialog');
|
||||
@@ -116,8 +118,8 @@ export function mountSceneSharing(
|
||||
bar.dataset.directorAuthoring = '';
|
||||
bar.className = 'scene-controls';
|
||||
const entries = [
|
||||
['EDIT DETAILS', edit],
|
||||
['SHARE SCENE', share],
|
||||
[t('EDIT DETAILS'), edit],
|
||||
[t('SHARE SCENE'), share],
|
||||
];
|
||||
const removers = [];
|
||||
for (const [text, fn] of entries) {
|
||||
|
||||
@@ -192,7 +192,9 @@ export class ShareRestoration {
|
||||
this.showStatus(t('ACQUIRING'), {
|
||||
state: 'acquiring',
|
||||
detail: t('SHARED {subject}', {
|
||||
subject: String(result.label || t('entity')).toUpperCase(),
|
||||
subject: String(
|
||||
result.label ? t(result.label) : t('entity'),
|
||||
).toUpperCase(),
|
||||
}),
|
||||
persistent: true,
|
||||
});
|
||||
@@ -217,7 +219,7 @@ export class ShareRestoration {
|
||||
const noticeGeneration = ownsAcquiringNotice
|
||||
? this._shareTrackingNoticeGeneration
|
||||
: ++this._shareTrackingNoticeGeneration;
|
||||
const subject = result.label || t('entity');
|
||||
const subject = result.label ? t(result.label) : t('entity');
|
||||
const message =
|
||||
result.classification === 'expired'
|
||||
? t('Shared {subject} follow expired', { subject })
|
||||
|
||||
@@ -25,6 +25,9 @@ const STATUS_LABEL_KEYS = Object.freeze({
|
||||
'MAPPED SITES LOADED': msg('MAPPED SITES LOADED'),
|
||||
'RETRYING MAPPED SITES': msg('RETRYING MAPPED SITES'),
|
||||
'FETCHING MAPPED SITES': msg('FETCHING MAPPED SITES'),
|
||||
'RETRYING ALPR CAMERAS': msg('RETRYING ALPR CAMERAS'),
|
||||
'FETCHING ALPR CAMERAS': msg('FETCHING ALPR CAMERAS'),
|
||||
'OVERPASS TEMPORARILY UNAVAILABLE': msg('OVERPASS TEMPORARILY UNAVAILABLE'),
|
||||
'TURNING OFF LIVE DATA': msg('TURNING OFF LIVE DATA'),
|
||||
'REFRESHING LIVE DATA': msg('REFRESHING LIVE DATA'),
|
||||
'syncing road network': msg('syncing road network'),
|
||||
|
||||
@@ -123,7 +123,7 @@ export class StreetLevelControls {
|
||||
const sinceDays = () =>
|
||||
SINCE_STOPS[Number(el.sinceRange?.value) || 0]?.days ?? 0;
|
||||
this.listen(el.sinceRange, 'input', () => {
|
||||
const label = SINCE_STOPS[Number(el.sinceRange.value) || 0].label;
|
||||
const label = t(SINCE_STOPS[Number(el.sinceRange.value) || 0].label);
|
||||
if (el.sinceLabel) el.sinceLabel.textContent = label;
|
||||
el.sinceRange.setAttribute('aria-valuetext', label);
|
||||
});
|
||||
|
||||
@@ -113,7 +113,7 @@
|
||||
<div class="cockpit-topline">
|
||||
<div>
|
||||
<span class="cockpit-kicker" data-i18n>FIRST PERSON</span>
|
||||
<strong id="cockpit-callsign">AIRCRAFT</strong>
|
||||
<strong id="cockpit-callsign" data-i18n>AIRCRAFT</strong>
|
||||
<span id="cockpit-aircraft-meta" class="cockpit-aircraft-meta" data-i18n>LIVE TRACK · COURSE ALIGNED</span>
|
||||
</div>
|
||||
<div class="cockpit-vision-controls" role="group" aria-label="Cockpit vision style" data-i18n-aria-label>
|
||||
@@ -123,7 +123,7 @@
|
||||
</button>
|
||||
<button id="cockpit-vision-current" class="cockpit-vision-current" type="button" data-cockpit-vision="optical" aria-label="Current cockpit vision style: NORMAL. Activate for next style." title="Current style: NORMAL — click for next" data-i18n-aria-label data-i18n-title>
|
||||
<small data-i18n>CURRENT</small>
|
||||
<strong id="cockpit-vision-current-label" aria-live="polite">NORMAL</strong>
|
||||
<strong id="cockpit-vision-current-label" aria-live="polite" data-i18n>NORMAL</strong>
|
||||
</button>
|
||||
<button id="cockpit-vision-next" class="cockpit-vision-nav" type="button" aria-label="Next cockpit vision style" title="Next vision style" data-i18n-aria-label data-i18n-title>
|
||||
<small data-i18n>NEXT</small>
|
||||
@@ -137,13 +137,13 @@
|
||||
<div class="cockpit-readout cockpit-speed">
|
||||
<span class="cockpit-readout-label" data-i18n>GROUND SPEED</span>
|
||||
<strong id="cockpit-speed-value">---</strong>
|
||||
<span class="cockpit-readout-unit">KTS</span>
|
||||
<span class="cockpit-readout-unit" data-i18n>KTS</span>
|
||||
</div>
|
||||
<div class="cockpit-heading-value"><span id="cockpit-heading-value">---</span><small>°</small></div>
|
||||
<div class="cockpit-readout cockpit-altitude">
|
||||
<span class="cockpit-readout-label" data-i18n>ALTITUDE</span>
|
||||
<strong id="cockpit-altitude-value">-----</strong>
|
||||
<span class="cockpit-readout-unit">FT</span>
|
||||
<span class="cockpit-readout-unit" data-i18n>FT</span>
|
||||
</div>
|
||||
</div>
|
||||
<span class="cockpit-heading-caret material-symbols-outlined" aria-hidden="true">arrow_drop_down</span>
|
||||
@@ -156,7 +156,7 @@
|
||||
<aside id="cockpit-context" class="cockpit-context-window" aria-label="Contact cockpit summary" hidden data-i18n-aria-label>
|
||||
<header class="cockpit-context-header">
|
||||
<div>
|
||||
<span class="cockpit-context-kicker">CONTACT</span>
|
||||
<span data-i18n class="cockpit-context-kicker">CONTACT</span>
|
||||
<strong id="cockpit-context-subject" data-i18n>CONTACTS · 250 KM</strong>
|
||||
</div>
|
||||
<nav class="cockpit-context-actions" aria-label="Contact navigation" data-i18n-aria-label>
|
||||
@@ -173,10 +173,10 @@
|
||||
<span class="cockpit-context-qualifier" data-i18n>CONTEXT ONLY</span>
|
||||
</header>
|
||||
<div class="cockpit-context-cohorts" role="list" aria-label="Nearby cohort counts" data-i18n-aria-label>
|
||||
<div role="listitem" data-context-cohort="flights"><span>FLT</span><strong>—</strong><small>OpenSky</small></div>
|
||||
<div role="listitem" data-context-cohort="military"><span>MIL</span><strong>—</strong><small>adsb.lol</small></div>
|
||||
<div role="listitem" data-context-cohort="ais-live-vessels"><span>AIS</span><strong>—</strong><small>AISStream</small></div>
|
||||
<div role="listitem" data-context-cohort="military-installations"><span>SITE</span><strong>—</strong><small>OpenStreetMap</small></div>
|
||||
<div role="listitem" data-context-cohort="flights"><span data-i18n>FLT</span><strong>—</strong><small translate="no">OpenSky</small></div>
|
||||
<div role="listitem" data-context-cohort="military"><span data-i18n>MIL</span><strong>—</strong><small translate="no">adsb.lol</small></div>
|
||||
<div role="listitem" data-context-cohort="ais-live-vessels"><span translate="no">AIS</span><strong>—</strong><small translate="no">AISStream</small></div>
|
||||
<div role="listitem" data-context-cohort="military-installations"><span data-i18n>SITE</span><strong>—</strong><small translate="no">OpenStreetMap</small></div>
|
||||
</div>
|
||||
<div class="cockpit-context-nearest">
|
||||
<span id="cockpit-context-direction" class="material-symbols-outlined" aria-hidden="true">navigation</span>
|
||||
@@ -197,8 +197,8 @@
|
||||
aria-pressed="false"
|
||||
title="Enable cockpit weather effects" data-i18n-aria-label data-i18n-title
|
||||
>
|
||||
<span>WX</span>
|
||||
<strong id="cockpit-weather-state">OFF</strong>
|
||||
<span data-i18n>WX</span>
|
||||
<strong data-i18n id="cockpit-weather-state">OFF</strong>
|
||||
</button>
|
||||
<time id="cockpit-context-updated">--:--:--Z</time>
|
||||
</footer>
|
||||
@@ -223,7 +223,7 @@
|
||||
</section>
|
||||
<header class="cockpit-signal-header">
|
||||
<div class="cockpit-brief-heading">
|
||||
<span id="cockpit-brief-kicker" class="cockpit-signal-kicker"><i aria-hidden="true"></i> LIVE SIGNALS</span>
|
||||
<span id="cockpit-brief-kicker" class="cockpit-signal-kicker" data-i18n-runtime><i aria-hidden="true"></i> LIVE SIGNALS</span>
|
||||
<strong id="cockpit-brief-subtitle" data-i18n>OBSERVED / MAPPED PINGS</strong>
|
||||
</div>
|
||||
<nav class="cockpit-brief-actions" aria-label="Cockpit briefing controls" data-i18n-aria-label>
|
||||
@@ -239,7 +239,7 @@
|
||||
type="button"
|
||||
aria-label="CYCLE OFF"
|
||||
aria-pressed="false"
|
||||
title="Cycle briefing pages automatically every 9 seconds (Signals → News → Local). Pauses while you hover or focus the panel. Live signal data refreshes continuously either way." data-i18n-title
|
||||
title="Cycle briefing pages automatically every 9 seconds (Signals → News → Local). Pauses while you hover or focus the panel. Live signal data refreshes continuously either way." data-i18n-title data-i18n-aria-label data-i18n
|
||||
>CYCLE OFF</button>
|
||||
<button id="cockpit-signal-toggle" type="button" aria-label="Collapse cockpit briefing panel" aria-expanded="true" title="Collapse briefing panel" data-i18n-aria-label data-i18n-title>
|
||||
<span class="material-symbols-outlined" aria-hidden="true">right_panel_close</span>
|
||||
@@ -260,20 +260,20 @@
|
||||
<small id="cockpit-local-coordinates">--</small>
|
||||
</div>
|
||||
<div class="cockpit-local-grid">
|
||||
<div><span data-i18n>TEMP</span><strong id="cockpit-local-temperature">—</strong><small>OPEN-METEO</small></div>
|
||||
<div><span data-i18n>TEMP</span><strong id="cockpit-local-temperature">—</strong><small translate="no">OPEN-METEO</small></div>
|
||||
<div><span data-i18n>WIND</span><strong id="cockpit-local-wind">—</strong><small id="cockpit-local-wind-direction">—</small></div>
|
||||
<div><span data-i18n>SKY</span><strong id="cockpit-local-condition">UNKNOWN</strong><small id="cockpit-local-cloud">—</small></div>
|
||||
<div><span data-i18n>PRECIP</span><strong id="cockpit-local-precipitation">—</strong><small>MM</small></div>
|
||||
<div><span data-i18n>SKY</span><strong id="cockpit-local-condition" data-i18n>UNKNOWN</strong><small id="cockpit-local-cloud">—</small></div>
|
||||
<div><span data-i18n>PRECIP</span><strong id="cockpit-local-precipitation">—</strong><small data-i18n>MM</small></div>
|
||||
</div>
|
||||
<a class="cockpit-weather-credit" href="https://open-meteo.com/" target="_blank" rel="noopener">Weather data by Open-Meteo.com</a>
|
||||
<a class="cockpit-weather-credit" href="https://open-meteo.com/" target="_blank" rel="noopener" data-i18n>Weather data by Open-Meteo.com</a>
|
||||
</section>
|
||||
</div>
|
||||
<footer class="cockpit-signal-footer">
|
||||
<span id="cockpit-brief-source" data-i18n>SOURCE-BACKED EVENTS · NO SYNTHETIC NEWS</span>
|
||||
<nav class="cockpit-brief-tabs" aria-label="Cockpit briefing pages">
|
||||
<button type="button" data-cockpit-brief-index="0" aria-label="Show Live Signals" aria-current="true" data-i18n-aria-label>SIG</button>
|
||||
<button type="button" data-cockpit-brief-index="1" aria-label="Show Regional News" data-i18n-aria-label>NEWS</button>
|
||||
<button type="button" data-cockpit-brief-index="2" aria-label="Show Local Info" data-i18n-aria-label>LOCAL</button>
|
||||
<nav class="cockpit-brief-tabs" aria-label="Cockpit briefing pages" data-i18n-aria-label>
|
||||
<button type="button" data-cockpit-brief-index="0" aria-label="Show Live Signals" aria-current="true" data-i18n-aria-label data-i18n>SIG</button>
|
||||
<button type="button" data-cockpit-brief-index="1" aria-label="Show Regional News" data-i18n-aria-label data-i18n>NEWS</button>
|
||||
<button type="button" data-cockpit-brief-index="2" aria-label="Show Local Info" data-i18n-aria-label data-i18n>LOCAL</button>
|
||||
</nav>
|
||||
</footer>
|
||||
</aside>
|
||||
@@ -313,7 +313,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
<div class="cockpit-help">ESC EXIT <span>·</span> C TOGGLE</div>
|
||||
<div class="cockpit-help"><bdi data-i18n>ESC EXIT</bdi> <span>·</span> <bdi data-i18n>C TOGGLE</bdi></div>
|
||||
</section>
|
||||
|
||||
<nav id="view-switcher" aria-label="View switcher" data-i18n-aria-label>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<button id="control-panel-toggle" class="panel-header dock-tray-toggle" type="button"
|
||||
data-dock-toggle-target="control-panel" aria-expanded="false"
|
||||
aria-controls="control-panel-popover" aria-label="Expand Visual Presets" data-i18n-aria-label>
|
||||
<span class="panel-title"><span class="dock-label-icon dock-label-icon-presets" aria-hidden="true"></span>VISUAL PRESETS</span>
|
||||
<span class="panel-title" data-i18n-runtime><span class="dock-label-icon dock-label-icon-presets" aria-hidden="true"></span>VISUAL PRESETS</span>
|
||||
<span class="panel-divider"></span>
|
||||
</button>
|
||||
<!-- Language selector: buttons are rendered by applicationShell _initLocaleSelector(), one per shipped locale. -->
|
||||
@@ -53,15 +53,15 @@
|
||||
</div>
|
||||
<section class="map-source-section" aria-labelledby="map-source-label">
|
||||
<div class="map-source-heading">
|
||||
<span id="map-source-label">MAP SOURCE</span>
|
||||
<span data-i18n id="map-source-label">MAP SOURCE</span>
|
||||
<span id="map-stack-status" class="pp-stack-status" aria-live="polite">3D</span>
|
||||
</div>
|
||||
<div id="map-stack-chips" class="map-stack-chip-row" role="group" aria-label="Map source"></div>
|
||||
<div id="map-stack-chips" class="map-stack-chip-row" role="group" aria-label="Map source" data-i18n-aria-label></div>
|
||||
</section>
|
||||
</div>
|
||||
<div id="style-mini-status" class="panel-mini-status" aria-live="polite">
|
||||
<span class="panel-mini-label" data-i18n>Style</span>
|
||||
<span id="style-mini-value" class="panel-mini-value">NORMAL</span>
|
||||
<span id="style-mini-value" class="panel-mini-value" data-i18n>NORMAL</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -72,7 +72,7 @@
|
||||
<div class="location-inner">
|
||||
<div class="location-toolbar">
|
||||
<button id="location-bar-toggle" class="dock-tray-toggle" type="button" data-dock-toggle-target="location-bar" aria-controls="location-bar-popover" aria-expanded="false" aria-label="Expand LOCATION" title="Expand LOCATION" data-i18n-aria-label data-i18n-title>
|
||||
<span class="location-toolbar-label"><span class="dock-label-icon dock-label-icon-location" aria-hidden="true"></span>LOCATION</span>
|
||||
<span class="location-toolbar-label" data-i18n-runtime><span class="dock-label-icon dock-label-icon-location" aria-hidden="true"></span>LOCATION</span>
|
||||
</button>
|
||||
<button class="panel-collapse-btn" data-collapse-target="location-bar" title="Collapse panel" data-i18n-title>+</button>
|
||||
</div>
|
||||
|
||||
@@ -4,10 +4,10 @@
|
||||
<div class="panel-glow"></div>
|
||||
<div class="weather-panel-inner" data-rail-scroller>
|
||||
<div class="panel-header">
|
||||
<span class="panel-title">WEATHER</span>
|
||||
<span id="weather-panel-count" aria-label="Active weather products">0</span>
|
||||
<span class="panel-title" data-i18n>WEATHER</span>
|
||||
<span id="weather-panel-count" aria-label="Active weather products" data-i18n-aria-label>0</span>
|
||||
<span class="panel-divider"></span>
|
||||
<button class="panel-collapse-btn" data-collapse-target="weather-panel" type="button" title="Collapse panel">▼</button>
|
||||
<button class="panel-collapse-btn" data-collapse-target="weather-panel" type="button" title="Collapse panel" data-i18n-title>▼</button>
|
||||
</div>
|
||||
<div id="weather-panel-body" data-rail-scroller></div>
|
||||
</div>
|
||||
@@ -16,10 +16,10 @@
|
||||
<div class="panel-glow"></div>
|
||||
<div class="recent-imagery-panel-inner" data-rail-scroller>
|
||||
<div class="panel-header">
|
||||
<span class="panel-title">RECENT IMAGERY</span>
|
||||
<span id="recent-imagery-panel-count" aria-label="Imagery days in the box"></span>
|
||||
<span class="panel-title" data-i18n>RECENT IMAGERY</span>
|
||||
<span id="recent-imagery-panel-count" aria-label="Imagery days in the box" data-i18n-aria-label></span>
|
||||
<span class="panel-divider"></span>
|
||||
<button class="panel-collapse-btn" data-collapse-target="recent-imagery-panel" type="button" title="Collapse panel">▼</button>
|
||||
<button class="panel-collapse-btn" data-collapse-target="recent-imagery-panel" type="button" title="Collapse panel" data-i18n-title>▼</button>
|
||||
</div>
|
||||
<div id="recent-imagery-panel-body" data-rail-scroller></div>
|
||||
</div>
|
||||
@@ -75,7 +75,7 @@
|
||||
</div>
|
||||
<div id="context-mode-standby" class="context-mode-standby">
|
||||
<strong data-i18n>SELECT CONTEXT</strong>
|
||||
<span>CONTACTS — nearest planes · vessels · sites<br>SPACE MISSIONS — launches & orbital assets</span>
|
||||
<span data-i18n-runtime>CONTACTS — nearest planes · vessels · sites<br>SPACE MISSIONS — launches & orbital assets</span>
|
||||
</div>
|
||||
<section id="context-flights-view" class="context-mode-view" role="tabpanel" aria-labelledby="global-context-flights-btn" hidden>
|
||||
<div class="global-context-actions" aria-label="Contact Context actions" data-i18n-aria-label>
|
||||
@@ -114,7 +114,7 @@
|
||||
<div class="radio-panel-inner">
|
||||
<div class="panel-header">
|
||||
<span class="panel-title" data-i18n>RADIO</span>
|
||||
<span id="radio-layer-state" class="radio-layer-state">OFF</span>
|
||||
<span id="radio-layer-state" class="radio-layer-state" data-i18n>OFF</span>
|
||||
<span class="panel-divider"></span>
|
||||
<button class="panel-collapse-btn" data-collapse-target="radio-panel" title="Expand Radio" aria-label="Expand Radio section" data-i18n-title data-i18n-aria-label>+</button>
|
||||
</div>
|
||||
@@ -132,14 +132,14 @@
|
||||
</div>
|
||||
<div id="radio-tuner" class="radio-tuner" hidden>
|
||||
<div class="radio-tuner-header">
|
||||
<span id="radio-tuner-band-label">DIRECTORY BAND</span>
|
||||
<span data-i18n id="radio-tuner-band-label">DIRECTORY BAND</span>
|
||||
<output id="radio-tuner-value" for="radio-tuner-slider" data-i18n>DRAG TO TUNE</output>
|
||||
</div>
|
||||
<div class="radio-tuner-dial">
|
||||
<div class="radio-tuner-scale" aria-hidden="true"></div>
|
||||
<div id="radio-tuner-needle" class="radio-tuner-needle" aria-hidden="true"></div>
|
||||
<input id="radio-tuner-slider" type="range" min="0" max="0" step="1" value="0"
|
||||
aria-label="Tune available internet radio stations" aria-valuetext="No station available" disabled />
|
||||
aria-label="Tune available internet radio stations" aria-valuetext="No station available" disabled data-i18n-aria-label />
|
||||
</div>
|
||||
<div class="radio-tuner-readout">
|
||||
<span id="radio-tuner-station" data-i18n>ALL · DRAG THE NEEDLE</span>
|
||||
@@ -160,81 +160,80 @@
|
||||
<div id="radio-playback-state" class="radio-playback-state" role="status" aria-live="polite" data-i18n>Radio off</div>
|
||||
<div class="radio-links">
|
||||
<a id="radio-station-homepage" href="#" target="_blank" rel="noopener noreferrer" hidden data-i18n>STATION SITE</a>
|
||||
<a href="https://www.radio-browser.info/" target="_blank" rel="noopener noreferrer">DIRECTORY: RADIO BROWSER</a>
|
||||
<a href="https://www.radio-browser.info/" target="_blank" rel="noopener noreferrer" data-i18n>DIRECTORY: RADIO BROWSER</a>
|
||||
</div>
|
||||
<p class="radio-privacy" data-i18n>Audio connects directly to the broadcaster after you press play. Your IP is visible to that broadcaster.</p>
|
||||
<p class="radio-privacy">Audio connects directly to the broadcaster after you press play. Your IP is visible to that broadcaster.</p>
|
||||
<div id="sdr-radio-card" class="sdr-radio-card" role="group" aria-label="Local RTL-SDR receiver">
|
||||
<div id="sdr-radio-card" class="sdr-radio-card" role="group" aria-label="Local RTL-SDR receiver" data-i18n-aria-label>
|
||||
<div class="sdr-radio-heading">
|
||||
<strong>LOCAL RTL-SDR</strong>
|
||||
<span id="sdr-connection-state">NO USB</span>
|
||||
<strong data-i18n>LOCAL RTL-SDR</strong>
|
||||
<span id="sdr-connection-state" data-i18n-runtime>NO USB</span>
|
||||
</div>
|
||||
<p>Chrome/Edge · WebUSB · one local tuner shared by FM and the Local ADS-B layer.</p>
|
||||
<p data-i18n>Chrome/Edge · WebUSB · one local tuner shared by FM and the Local ADS-B layer.</p>
|
||||
<div class="sdr-radio-actions">
|
||||
<button id="sdr-connect-btn" class="panel-layer-toggle" type="button" aria-pressed="false">CONNECT</button>
|
||||
<button id="sdr-locate-btn" type="button" title="Use your location for faster ADS-B position decoding">LOCATE</button>
|
||||
<button id="sdr-change-device-btn" type="button" title="Choose a different RTL-SDR or receiver channel">CHANGE DEVICE</button>
|
||||
<button id="sdr-connect-btn" class="panel-layer-toggle" type="button" aria-pressed="false" data-i18n>CONNECT</button>
|
||||
<button id="sdr-locate-btn" type="button" title="Use your location for faster ADS-B position decoding" data-i18n-title data-i18n>LOCATE</button>
|
||||
<button id="sdr-change-device-btn" type="button" title="Choose a different RTL-SDR or receiver channel" data-i18n-title data-i18n>CHANGE DEVICE</button>
|
||||
</div>
|
||||
<div class="sdr-mode-row" role="group" aria-label="RTL-SDR receiver mode">
|
||||
<button id="sdr-mode-fm-btn" class="active" type="button" aria-pressed="true">FM</button>
|
||||
<button id="sdr-mode-adsb-btn" type="button" aria-pressed="false">ADS-B · 1090</button>
|
||||
<div class="sdr-mode-row" role="group" aria-label="RTL-SDR receiver mode" data-i18n-aria-label>
|
||||
<button id="sdr-mode-fm-btn" class="active" type="button" aria-pressed="true" translate="no">FM</button>
|
||||
<button id="sdr-mode-adsb-btn" type="button" aria-pressed="false" translate="no">ADS-B · 1090</button>
|
||||
</div>
|
||||
<div class="sdr-gain-row">
|
||||
<label for="sdr-gain-select">GAIN</label>
|
||||
<select id="sdr-gain-select" aria-label="RTL-SDR tuner gain for the current mode">
|
||||
<option value="auto">AUTO</option>
|
||||
<option value="0.0">0.0 dB</option>
|
||||
<option value="0.9">0.9 dB</option>
|
||||
<option value="1.4">1.4 dB</option>
|
||||
<option value="2.7">2.7 dB</option>
|
||||
<option value="3.7">3.7 dB</option>
|
||||
<option value="7.7">7.7 dB</option>
|
||||
<option value="8.7">8.7 dB</option>
|
||||
<option value="12.5">12.5 dB</option>
|
||||
<option value="14.4">14.4 dB</option>
|
||||
<option value="15.7">15.7 dB</option>
|
||||
<option value="16.6">16.6 dB</option>
|
||||
<option value="19.7">19.7 dB</option>
|
||||
<option value="20.7">20.7 dB</option>
|
||||
<option value="22.9">22.9 dB</option>
|
||||
<option value="25.4">25.4 dB</option>
|
||||
<option value="28.0">28.0 dB</option>
|
||||
<option value="29.7">29.7 dB</option>
|
||||
<option value="32.8">32.8 dB</option>
|
||||
<option value="33.8">33.8 dB</option>
|
||||
<option value="36.4">36.4 dB</option>
|
||||
<option value="37.2">37.2 dB</option>
|
||||
<option value="38.6">38.6 dB</option>
|
||||
<option value="40.2">40.2 dB</option>
|
||||
<option value="42.1">42.1 dB</option>
|
||||
<option value="43.4">43.4 dB</option>
|
||||
<option value="43.9">43.9 dB</option>
|
||||
<option value="44.5">44.5 dB</option>
|
||||
<option value="48.0">48.0 dB</option>
|
||||
<option value="49.6">49.6 dB</option>
|
||||
<label for="sdr-gain-select" data-i18n>GAIN</label>
|
||||
<select id="sdr-gain-select" aria-label="RTL-SDR tuner gain for the current mode" data-i18n-aria-label>
|
||||
<option value="auto" data-i18n>AUTO</option>
|
||||
<option value="0.0" translate="no">0.0 dB</option>
|
||||
<option value="0.9" translate="no">0.9 dB</option>
|
||||
<option value="1.4" translate="no">1.4 dB</option>
|
||||
<option value="2.7" translate="no">2.7 dB</option>
|
||||
<option value="3.7" translate="no">3.7 dB</option>
|
||||
<option value="7.7" translate="no">7.7 dB</option>
|
||||
<option value="8.7" translate="no">8.7 dB</option>
|
||||
<option value="12.5" translate="no">12.5 dB</option>
|
||||
<option value="14.4" translate="no">14.4 dB</option>
|
||||
<option value="15.7" translate="no">15.7 dB</option>
|
||||
<option value="16.6" translate="no">16.6 dB</option>
|
||||
<option value="19.7" translate="no">19.7 dB</option>
|
||||
<option value="20.7" translate="no">20.7 dB</option>
|
||||
<option value="22.9" translate="no">22.9 dB</option>
|
||||
<option value="25.4" translate="no">25.4 dB</option>
|
||||
<option value="28.0" translate="no">28.0 dB</option>
|
||||
<option value="29.7" translate="no">29.7 dB</option>
|
||||
<option value="32.8" translate="no">32.8 dB</option>
|
||||
<option value="33.8" translate="no">33.8 dB</option>
|
||||
<option value="36.4" translate="no">36.4 dB</option>
|
||||
<option value="37.2" translate="no">37.2 dB</option>
|
||||
<option value="38.6" translate="no">38.6 dB</option>
|
||||
<option value="40.2" translate="no">40.2 dB</option>
|
||||
<option value="42.1" translate="no">42.1 dB</option>
|
||||
<option value="43.4" translate="no">43.4 dB</option>
|
||||
<option value="43.9" translate="no">43.9 dB</option>
|
||||
<option value="44.5" translate="no">44.5 dB</option>
|
||||
<option value="48.0" translate="no">48.0 dB</option>
|
||||
<option value="49.6" translate="no">49.6 dB</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="sdr-adsb-stats" class="sdr-adsb-stats" role="status" aria-live="off" hidden>
|
||||
<span><output id="sdr-stat-rate">--</output> MSG/S</span>
|
||||
<span><output id="sdr-stat-heard">0</output> HEARD</span>
|
||||
<span><output id="sdr-stat-positioned">0</output> POSITIONED</span>
|
||||
<span>IQ <output id="sdr-stat-iq">--</output></span>
|
||||
<span><output id="sdr-stat-rate">--</output> <span data-i18n>MSG/S</span></span>
|
||||
<span><output id="sdr-stat-heard">0</output> <span data-i18n>HEARD</span></span>
|
||||
<span><output id="sdr-stat-positioned">0</output> <span data-i18n>POSITIONED</span></span>
|
||||
<span translate="no">IQ <output id="sdr-stat-iq">--</output></span>
|
||||
</div>
|
||||
<div class="sdr-frequency-row">
|
||||
<label for="sdr-frequency-input">FM MHZ</label>
|
||||
<label for="sdr-frequency-input" data-i18n>FM MHZ</label>
|
||||
<input id="sdr-frequency-input" type="number" min="87.5" max="108" step="0.1" value="98.5" inputmode="decimal" />
|
||||
<button id="sdr-tune-btn" type="button">TUNE</button>
|
||||
<button id="sdr-tune-btn" type="button" data-i18n>TUNE</button>
|
||||
</div>
|
||||
<div class="sdr-seek-row" role="group" aria-label="Seek broadcast FM stations">
|
||||
<button id="sdr-seek-back-btn" type="button" disabled>◀ SEEK</button>
|
||||
<button id="sdr-seek-forward-btn" type="button" disabled>SEEK ▶</button>
|
||||
<div class="sdr-seek-row" role="group" aria-label="Seek broadcast FM stations" data-i18n-aria-label>
|
||||
<button id="sdr-seek-back-btn" type="button" disabled data-i18n>◀ SEEK</button>
|
||||
<button id="sdr-seek-forward-btn" type="button" disabled data-i18n>SEEK ▶</button>
|
||||
</div>
|
||||
<label class="radio-volume-row" for="sdr-volume">
|
||||
<span>SDR VOL</span>
|
||||
<input id="sdr-volume" class="gev-quantitative-slider" type="range" min="0" max="100" value="80" aria-label="Local SDR volume" />
|
||||
<span data-i18n>SDR VOL</span>
|
||||
<input id="sdr-volume" class="gev-quantitative-slider" type="range" min="0" max="100" value="80" aria-label="Local SDR volume" data-i18n-aria-label />
|
||||
<output id="sdr-volume-value" class="gev-slider-value" for="sdr-volume">80%</output>
|
||||
</label>
|
||||
<div id="sdr-status" class="radio-playback-state" role="status" aria-live="polite">Connect an RTL-SDR to begin.</div>
|
||||
<div id="sdr-status" class="radio-playback-state" role="status" aria-live="polite" data-i18n>Connect an RTL-SDR to begin.</div>
|
||||
<div id="sdr-feed-status" class="sdr-feed-status" role="status" aria-live="off" hidden></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<!-- Post-processing toggles (top-right) -->
|
||||
<div id="pp-toggles" class="panel-collapsible collapsed" data-panel-id="pp-toggles">
|
||||
<div class="compact pp-header-row">
|
||||
<span class="pp-header-label">DISPLAY</span>
|
||||
<span data-i18n class="pp-header-label">DISPLAY</span>
|
||||
<span class="panel-divider" aria-hidden="true"></span>
|
||||
<button class="panel-collapse-btn pp-collapse-btn" data-collapse-target="pp-toggles" title="Collapse panel" data-i18n-title>◀</button>
|
||||
</div>
|
||||
@@ -9,7 +9,7 @@
|
||||
<div class="pp-toggle-group">
|
||||
<button class="pp-toggle-btn" id="hud-toggle" title="Intelligence HUD (H)" data-i18n-title>
|
||||
<span class="pp-icon">◎</span>
|
||||
<span class="pp-label">HUD</span>
|
||||
<span class="pp-label" translate="no">HUD</span>
|
||||
</button>
|
||||
<div class="pp-slider-row" id="hud-layout-row">
|
||||
<span class="pp-slider-mini-label" data-i18n>Layout</span>
|
||||
@@ -17,37 +17,37 @@
|
||||
<option value="tactical" data-i18n>Tactical</option>
|
||||
<option value="operator" data-i18n>Operator</option>
|
||||
<option value="minimal" data-i18n>Minimal</option>
|
||||
<option value="cyber">Cyber</option>
|
||||
<option value="cyber" data-i18n>Cyber</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="pp-slider-row cyber-sonar-control cyber-sonar-toggle-row" id="cyber-sonar-row">
|
||||
<span class="pp-slider-mini-label">Sonar</span>
|
||||
<button class="pp-mode-btn active" id="cyber-sonar-toggle" title="Cyber sonar contact scanner" aria-label="Cyber sonar contact scanner" aria-pressed="true">ON</button>
|
||||
<span class="pp-slider-mini-label" data-i18n>Sonar</span>
|
||||
<button class="pp-mode-btn active" id="cyber-sonar-toggle" title="Cyber sonar contact scanner" aria-label="Cyber sonar contact scanner" aria-pressed="true" data-i18n-title data-i18n-aria-label data-i18n>ON</button>
|
||||
</div>
|
||||
<output id="cyber-sonar-status" class="cyber-sonar-control" role="status"></output>
|
||||
<div class="pp-slider-row cyber-sonar-control cyber-sonar-setting" id="cyber-sonar-rings-row">
|
||||
<label class="pp-slider-mini-label" for="cyber-sonar-rings">Rings</label>
|
||||
<input type="range" class="pp-slider" id="cyber-sonar-rings" min="3" max="12" step="1" value="8" aria-label="Sonar ring count" />
|
||||
<label class="pp-slider-mini-label" for="cyber-sonar-rings" data-i18n>Rings</label>
|
||||
<input type="range" class="pp-slider" id="cyber-sonar-rings" min="3" max="12" step="1" value="8" aria-label="Sonar ring count" data-i18n-aria-label />
|
||||
<span class="pp-slider-value" id="cyber-sonar-rings-value">8</span>
|
||||
</div>
|
||||
<div class="pp-slider-row cyber-sonar-control cyber-sonar-setting" id="cyber-sonar-range-row">
|
||||
<label class="pp-slider-mini-label" for="cyber-sonar-range">Range</label>
|
||||
<input type="range" class="pp-slider" id="cyber-sonar-range" min="60" max="120" step="1" value="100" aria-label="Sonar ring range" />
|
||||
<label class="pp-slider-mini-label" for="cyber-sonar-range" data-i18n>Range</label>
|
||||
<input type="range" class="pp-slider" id="cyber-sonar-range" min="60" max="120" step="1" value="100" aria-label="Sonar ring range" data-i18n-aria-label />
|
||||
<span class="pp-slider-value" id="cyber-sonar-range-value">100%</span>
|
||||
</div>
|
||||
<div class="pp-slider-row cyber-sonar-control cyber-sonar-setting" id="cyber-sonar-intensity-row">
|
||||
<label class="pp-slider-mini-label" for="cyber-sonar-intensity">Power</label>
|
||||
<input type="range" class="pp-slider" id="cyber-sonar-intensity" min="0" max="100" step="1" value="70" aria-label="Sonar intensity" />
|
||||
<label class="pp-slider-mini-label" for="cyber-sonar-intensity" data-i18n>Power</label>
|
||||
<input type="range" class="pp-slider" id="cyber-sonar-intensity" min="0" max="100" step="1" value="70" aria-label="Sonar intensity" data-i18n-aria-label />
|
||||
<span class="pp-slider-value" id="cyber-sonar-intensity-value">70%</span>
|
||||
</div>
|
||||
<div class="pp-slider-row cyber-sonar-control cyber-sonar-setting" id="cyber-sonar-opacity-row">
|
||||
<label class="pp-slider-mini-label" for="cyber-sonar-opacity">Opacity</label>
|
||||
<input type="range" class="pp-slider" id="cyber-sonar-opacity" min="35" max="100" step="1" value="84" aria-label="Cyber contact and label opacity" title="Minimum opacity for map dots, icons, models, brackets, and labels between sonar passes" />
|
||||
<label class="pp-slider-mini-label" for="cyber-sonar-opacity" data-i18n>Opacity</label>
|
||||
<input type="range" class="pp-slider" id="cyber-sonar-opacity" min="35" max="100" step="1" value="84" aria-label="Cyber contact and label opacity" title="Minimum opacity for map dots, icons, models, brackets, and labels between sonar passes" data-i18n-aria-label data-i18n-title />
|
||||
<span class="pp-slider-value" id="cyber-sonar-opacity-value">84%</span>
|
||||
</div>
|
||||
<div class="pp-slider-row cyber-sonar-control cyber-sonar-setting" id="cyber-sonar-sector-row">
|
||||
<label class="pp-slider-mini-label" for="cyber-sonar-sector">Sector</label>
|
||||
<input type="range" class="pp-slider" id="cyber-sonar-sector" min="8" max="60" step="1" value="24" aria-label="Sonar sector width" />
|
||||
<label class="pp-slider-mini-label" for="cyber-sonar-sector" data-i18n>Sector</label>
|
||||
<input type="range" class="pp-slider" id="cyber-sonar-sector" min="8" max="60" step="1" value="24" aria-label="Sonar sector width" data-i18n-aria-label />
|
||||
<span class="pp-slider-value" id="cyber-sonar-sector-value">24°</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<div id="loading-screen">
|
||||
<div class="loader-content">
|
||||
<span class="loader-logo brand-logo" data-logo-gaze data-logo-src="/logo.svg" aria-hidden="true"><img src="/logo.svg" alt="" /></span>
|
||||
<h2>GOD'S EYE <span class="title-accent">VIEW</span></h2>
|
||||
<h2 translate="no">GOD'S EYE <span class="title-accent">VIEW</span></h2>
|
||||
<p class="loader-status" data-i18n>Initializing photorealistic world...</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -44,7 +44,7 @@
|
||||
</div>
|
||||
<div class="cctv-controls">
|
||||
<button id="cctv-projection-btn" class="scene-btn" data-i18n>PROJECTION ON</button>
|
||||
<span id="cctv-quality-chip">CAL · --</span>
|
||||
<span id="cctv-quality-chip" data-i18n-runtime>CAL · --</span>
|
||||
</div>
|
||||
<div class="cctv-calibration-block">
|
||||
<div class="cctv-calibration-header">
|
||||
@@ -52,11 +52,11 @@
|
||||
<button id="cctv-adjust-btn" class="scene-btn" title="Drag the camera in the world: rings rotate, arrows move, handles set range/FOV" data-i18n-title data-i18n>ADJUST</button>
|
||||
</div>
|
||||
<div id="cctv-cal-readout" class="cctv-cal-readout" aria-label="Camera pose — click a value to type" data-i18n-aria-label>
|
||||
<button class="cctv-cal-value" data-cal-field="heading" title="Heading (compass °) — click to type" data-i18n-title>HDG --</button>
|
||||
<button class="cctv-cal-value" data-cal-field="pitch" title="Pitch (° up/down) — click to type" data-i18n-title>PITCH --</button>
|
||||
<button class="cctv-cal-value" data-cal-field="fov" title="Horizontal FOV (°) — click to type" data-i18n-title>FOV --</button>
|
||||
<button class="cctv-cal-value" data-cal-field="range" title="Range / monitor-plane distance (m) — click to type" data-i18n-title>RANGE --</button>
|
||||
<button class="cctv-cal-value" data-cal-field="height" title="Mount height above ground (m) — click to type" data-i18n-title>HGT --</button>
|
||||
<button class="cctv-cal-value" data-cal-field="heading" title="Heading (compass °) — click to type" data-i18n-title data-i18n-runtime>HDG --</button>
|
||||
<button class="cctv-cal-value" data-cal-field="pitch" title="Pitch (° up/down) — click to type" data-i18n-title data-i18n-runtime>PITCH --</button>
|
||||
<button class="cctv-cal-value" data-cal-field="fov" title="Horizontal FOV (°) — click to type" data-i18n-title data-i18n-runtime>FOV --</button>
|
||||
<button class="cctv-cal-value" data-cal-field="range" title="Range / monitor-plane distance (m) — click to type" data-i18n-title data-i18n-runtime>RANGE --</button>
|
||||
<button class="cctv-cal-value" data-cal-field="height" title="Mount height above ground (m) — click to type" data-i18n-title data-i18n-runtime>HGT --</button>
|
||||
<button class="cctv-cal-value" data-cal-field="north" title="North offset from catalog position (m) — click to type" data-i18n-title>ΔN --</button>
|
||||
<button class="cctv-cal-value" data-cal-field="east" title="East offset from catalog position (m) — click to type" data-i18n-title>ΔE --</button>
|
||||
</div>
|
||||
@@ -78,8 +78,8 @@
|
||||
<div class="panel-header">
|
||||
<span class="panel-title" data-i18n>STREET LEVEL</span>
|
||||
<span class="panel-divider"></span>
|
||||
<button id="sl-status" class="sl-status" type="button" aria-pressed="false" title="Turn Street Level on or off">OFF</button>
|
||||
<button class="panel-collapse-btn" data-collapse-target="street-level-panel" title="Expand panel">◀</button>
|
||||
<button id="sl-status" class="sl-status" type="button" aria-pressed="false" title="Turn Street Level on or off" data-i18n-title data-i18n>OFF</button>
|
||||
<button class="panel-collapse-btn" data-collapse-target="street-level-panel" title="Expand panel" data-i18n-title>◀</button>
|
||||
</div>
|
||||
<div id="sl-body" class="sl-body">
|
||||
<div class="sl-main">
|
||||
@@ -129,7 +129,7 @@
|
||||
<div class="sl-since">
|
||||
<label class="cctv-summary-label" for="sl-since" data-i18n>SINCE</label>
|
||||
<input id="sl-since" class="sl-range" type="range" min="0" max="8" step="1" value="0" aria-valuetext="ANY DATE" aria-describedby="sl-since-label" />
|
||||
<output id="sl-since-label" class="sl-since-label" for="sl-since">ANY DATE</output>
|
||||
<output id="sl-since-label" class="sl-since-label" for="sl-since" data-i18n>ANY DATE</output>
|
||||
</div>
|
||||
</fieldset>
|
||||
<ul id="sl-legend" class="sl-legend" aria-label="Coverage colours" data-i18n-aria-label></ul>
|
||||
|
||||
@@ -10,14 +10,14 @@
|
||||
<!-- Title -->
|
||||
<div id="title-bar">
|
||||
<div class="title-glow"></div>
|
||||
<h1><span class="title-logo brand-logo" data-logo-gaze data-logo-src="/logo.svg" aria-hidden="true"><img src="/logo.svg" alt="" /></span> <span>GOD'S EYE <span class="title-accent">VIEW</span></span></h1>
|
||||
<h1><span class="title-logo brand-logo" data-logo-gaze data-logo-src="/logo.svg" aria-hidden="true"><img src="/logo.svg" alt="" /></span> <span translate="no">GOD'S EYE <span class="title-accent">VIEW</span></span></h1>
|
||||
<p class="subtitle" data-i18n>NO PLACE LEFT BEHIND</p>
|
||||
</div>
|
||||
|
||||
<!-- Style indicator -->
|
||||
<div id="style-indicator">
|
||||
<span class="indicator-label" data-i18n>ACTIVE STYLE</span>
|
||||
<span class="indicator-value" id="active-style-name">NORMAL</span>
|
||||
<span class="indicator-value" id="active-style-name" data-i18n>NORMAL</span>
|
||||
</div>
|
||||
|
||||
<!-- Top-center camera and share actions -->
|
||||
@@ -28,10 +28,10 @@
|
||||
<button id="share-btn" type="button" aria-label="Copy share link" title="Copy share link" data-i18n-aria-label data-i18n-title>
|
||||
<span class="share-icon" aria-hidden="true">🔗</span>
|
||||
</button>
|
||||
<button id="tilt-map-view" type="button" aria-label="Tilt map to oblique view" aria-pressed="false" title="Toggle straight-down and tilted map views">
|
||||
<button id="tilt-map-view" type="button" aria-label="Tilt map to oblique view" aria-pressed="false" title="Toggle straight-down and tilted map views" data-i18n-aria-label data-i18n-title>
|
||||
<span class="material-symbols-outlined" aria-hidden="true">view_in_ar</span>
|
||||
</button>
|
||||
<button id="north-up-view" type="button" aria-label="Reset map to north up" title="Reset map bearing to north">
|
||||
<button id="north-up-view" type="button" aria-label="Reset map to north up" title="Reset map bearing to north" data-i18n-aria-label data-i18n-title>
|
||||
<span class="camera-compass-n" aria-hidden="true">N</span>
|
||||
<span class="camera-compass-needle material-symbols-outlined" aria-hidden="true">navigation</span>
|
||||
</button>
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
— including the unspaced em dash. The arc it has to carry: this looks
|
||||
like something you should not have access to → the sources are all
|
||||
public → the data is real. Tile subcopy stays purely functional. -->
|
||||
<p id="first-run-description">It feels like a forbidden cockpit—then you realize the sources are public and the data is real.</p>
|
||||
<p id="first-run-description" data-i18n-runtime>It feels like a forbidden cockpit—then you realize the sources are public and the data is real.</p>
|
||||
<div class="first-run-choices">
|
||||
<button type="button" data-first-run-choice="contacts">
|
||||
<span class="material-symbols-outlined" aria-hidden="true">radar</span>
|
||||
@@ -30,7 +30,7 @@
|
||||
<!-- Subcopy names BOTH feeds this tile turns on, because the launcher
|
||||
optimizes for the fully configured experience (owner ruling,
|
||||
2026-08-23) — see the mission table in src/firstRunExperience.js. -->
|
||||
<span><strong data-first-run-environmental-title>ENVIRONMENTAL</strong><small>Live earthquakes and active fires, from USGS and NASA</small></span>
|
||||
<span data-i18n-runtime><strong data-first-run-environmental-title>ENVIRONMENTAL</strong><small>Live earthquakes and active fires, from USGS and NASA</small></span>
|
||||
<span class="material-symbols-outlined first-run-arrow" aria-hidden="true">arrow_forward</span>
|
||||
</button>
|
||||
<button type="button" class="first-run-explore" data-first-run-choice="explore">
|
||||
|
||||
+12
-8
@@ -5,6 +5,7 @@ import { snowShader } from '../styles/snow.js';
|
||||
import { nightVisionShader } from '../styles/surveillance.js';
|
||||
import { thermalShader } from '../styles/thermal.js';
|
||||
import { BLOOM_INTENSITY_DEFAULT } from '../bloom.js';
|
||||
import { msg } from '../i18n/index.js';
|
||||
|
||||
/** Duration (ms) for shader intensity crossfade between style presets. */
|
||||
export const TRANSITION_DURATION_MS = 500;
|
||||
@@ -144,13 +145,16 @@ export const SHARPEN_SHADER = /* glsl */ `
|
||||
}
|
||||
`;
|
||||
|
||||
/** Stable display labels for the active style and inherited Cockpit vision. */
|
||||
/**
|
||||
* Stable display labels for the active style and inherited Cockpit vision.
|
||||
* English source text: render through t().
|
||||
*/
|
||||
export const STYLE_STATUS_LABELS = {
|
||||
normal: 'NORMAL',
|
||||
retro: 'CRT',
|
||||
surveillance: 'NVG',
|
||||
thermal: 'FLIR',
|
||||
anime: 'ANIME',
|
||||
noir: 'NOIR',
|
||||
snow: 'SNOW',
|
||||
normal: msg('NORMAL'),
|
||||
retro: msg('CRT'),
|
||||
surveillance: msg('NVG'),
|
||||
thermal: msg('FLIR'),
|
||||
anime: msg('ANIME'),
|
||||
noir: msg('NOIR'),
|
||||
snow: msg('SNOW'),
|
||||
};
|
||||
@@ -672,7 +672,7 @@ export class VisualSettings {
|
||||
const settings = applyCyberSonarSettings(readCyberSonarSettings());
|
||||
this._cyberSonarBtn.classList.toggle('active', enabled);
|
||||
this._cyberSonarBtn.setAttribute('aria-pressed', String(enabled));
|
||||
this._cyberSonarBtn.textContent = enabled ? 'ON' : 'OFF';
|
||||
this._cyberSonarBtn.textContent = enabled ? t('ON') : t('OFF');
|
||||
for (const [input, output, value, suffix] of [
|
||||
[this._cyberSonarRings, this._cyberSonarRingsValue, settings.rings, ''],
|
||||
[this._cyberSonarRange, this._cyberSonarRangeValue, settings.range, '%'],
|
||||
@@ -1595,9 +1595,9 @@ export class VisualSettings {
|
||||
});
|
||||
|
||||
// Update style indicator
|
||||
const displayNames = { surveillance: 'NVG', thermal: 'FLIR', retro: 'CRT' };
|
||||
this._styleIndicator.textContent =
|
||||
displayNames[styleName] || styleName.toUpperCase();
|
||||
this._styleIndicator.textContent = STYLE_STATUS_LABELS[styleName]
|
||||
? t(STYLE_STATUS_LABELS[styleName])
|
||||
: styleName.toUpperCase();
|
||||
this._updateStyleMiniStatus(styleName);
|
||||
|
||||
// Update parameter sliders
|
||||
@@ -1629,9 +1629,9 @@ export class VisualSettings {
|
||||
|
||||
_updateStyleMiniStatus(styleName = this.activeStyle) {
|
||||
if (!this._styleMiniValue) return;
|
||||
this._styleMiniValue.textContent =
|
||||
STYLE_STATUS_LABELS[styleName] ||
|
||||
String(styleName || 'normal').toUpperCase();
|
||||
this._styleMiniValue.textContent = STYLE_STATUS_LABELS[styleName]
|
||||
? t(STYLE_STATUS_LABELS[styleName])
|
||||
: String(styleName || 'normal').toUpperCase();
|
||||
}
|
||||
|
||||
_updateHudButtonState() {
|
||||
|
||||
+33
-18
@@ -1,3 +1,4 @@
|
||||
import { t } from '../i18n/index.js';
|
||||
import { createRailCards } from './railCards.js';
|
||||
import { createRailTimeline } from './railTimeline.js';
|
||||
|
||||
@@ -15,18 +16,24 @@ const OBSERVED = new Set(ORDER.slice(2));
|
||||
const utc = (time) =>
|
||||
Number.isFinite(Date.parse(time))
|
||||
? `${new Date(time).toISOString().slice(5, 16).replace('T', ' ')} UTC`
|
||||
: 'Unavailable';
|
||||
: t('Unavailable');
|
||||
const age = (time) => {
|
||||
const minutes = Math.max(
|
||||
0,
|
||||
Math.floor((Date.now() - Date.parse(time)) / 60_000),
|
||||
);
|
||||
return minutes >= 60
|
||||
? `${Math.floor(minutes / 60)}h ${minutes % 60}m ago`
|
||||
: `${minutes}m ago`;
|
||||
? t('{hours}h {minutes}m ago', {
|
||||
hours: Math.floor(minutes / 60),
|
||||
minutes: minutes % 60,
|
||||
})
|
||||
: t('{minutes}m ago', { minutes });
|
||||
};
|
||||
const historyTime = (time) =>
|
||||
`${utc(time).slice(6)} · ${Math.max(0, Math.floor((Date.now() - Date.parse(time)) / 60_000))} min ago`;
|
||||
t('{time} · {minutes} min ago', {
|
||||
time: utc(time).slice(6),
|
||||
minutes: Math.max(0, Math.floor((Date.now() - Date.parse(time)) / 60_000)),
|
||||
});
|
||||
const dated = (time) => `${utc(time)} · ${age(time)}`;
|
||||
const set = (node, key, value) => {
|
||||
if (node[key] !== value) node[key] = value;
|
||||
@@ -63,7 +70,7 @@ export function createWeatherPanel({
|
||||
const root = document.createElement('section');
|
||||
root.className = 'weather-readout';
|
||||
root.hidden = true;
|
||||
root.setAttribute('aria-label', 'Active weather');
|
||||
root.setAttribute('aria-label', t('Active weather'));
|
||||
const timelineHost = document.createElement('div');
|
||||
timelineHost.className = 'weather-timeline-block';
|
||||
timelineHost.hidden = true;
|
||||
@@ -71,10 +78,10 @@ export function createWeatherPanel({
|
||||
cardsHost.className = 'weather-cards';
|
||||
const observedGroup = document.createElement('section');
|
||||
observedGroup.className = 'weather-observed-group';
|
||||
observedGroup.setAttribute('aria-label', 'Observed history');
|
||||
observedGroup.setAttribute('aria-label', t('Observed history'));
|
||||
const heading = document.createElement('h3');
|
||||
heading.className = 'panel-title';
|
||||
heading.textContent = 'Observed history';
|
||||
heading.textContent = t('Observed history');
|
||||
const scope = document.createElement('div');
|
||||
scope.className = 'weather-observed-scope';
|
||||
const observedCardsHost = document.createElement('div');
|
||||
@@ -132,9 +139,9 @@ export function createWeatherPanel({
|
||||
const showTimeline = observed.length > 0;
|
||||
set(observedGroup, 'hidden', !showTimeline);
|
||||
const names = {
|
||||
'weather-radar': 'Rain radar',
|
||||
'weather-satellite': 'Satellite clouds',
|
||||
'weather-lightning': 'Lightning density',
|
||||
'weather-radar': t('Rain radar'),
|
||||
'weather-satellite': t('Satellite clouds'),
|
||||
'weather-lightning': t('Lightning density'),
|
||||
};
|
||||
set(scope, 'textContent', observed.map(({ id }) => names[id]).join(' · '));
|
||||
set(timelineHost, 'hidden', !showTimeline);
|
||||
@@ -155,7 +162,7 @@ export function createWeatherPanel({
|
||||
disabled: !showTimeline || state.timeline.length < 2,
|
||||
readout:
|
||||
state.mode === 'latest'
|
||||
? 'LATEST · newest per product'
|
||||
? t('LATEST · newest per product')
|
||||
: historyTime(state.target),
|
||||
});
|
||||
const models = active.map(({ id, icon, summary, legend = [], list }) => {
|
||||
@@ -165,18 +172,26 @@ export function createWeatherPanel({
|
||||
const shown = product?.shown ?? summary.shownTime;
|
||||
if (state.mode === 'history' && product?.selected === null) {
|
||||
const gap = summary.maxGapMinutes || 30;
|
||||
detail = `No frame within ${gap < 60 ? `${gap} min` : `${gap / 60} h`} of ${utc(state.target).slice(6)}`;
|
||||
const time = utc(state.target).slice(6);
|
||||
detail =
|
||||
gap < 60
|
||||
? t('No frame within {gap} min of {time}', { gap, time })
|
||||
: t('No frame within {gap} h of {time}', { gap: gap / 60, time });
|
||||
} else if (shown) {
|
||||
detail = dated(shown);
|
||||
if (state.mode === 'history')
|
||||
detail +=
|
||||
detail =
|
||||
Date.parse(shown) === Date.parse(state.target)
|
||||
? ' · synced'
|
||||
: ' · nearest';
|
||||
? t('{detail} · synced', { detail })
|
||||
: t('{detail} · nearest', { detail });
|
||||
}
|
||||
} else if (id === 'wind') {
|
||||
detail = `Forecast · valid ${utc(summary.validTime)} · issued ${utc(summary.issuedTime)}`;
|
||||
if (state.mode === 'history') detail += ' · Does not follow history';
|
||||
detail = t('Forecast · valid {valid} · issued {issued}', {
|
||||
valid: utc(summary.validTime),
|
||||
issued: utc(summary.issuedTime),
|
||||
});
|
||||
if (state.mode === 'history')
|
||||
detail = t('{detail} · Does not follow history', { detail });
|
||||
}
|
||||
const lines = [
|
||||
{ id: 'time', text: detail, muted: true },
|
||||
@@ -222,7 +237,7 @@ export function createWeatherPanel({
|
||||
summary.status ||
|
||||
summary.compact ||
|
||||
(id === 'wind'
|
||||
? `Forecast · valid ${utc(summary.validTime)}`
|
||||
? t('Forecast · valid {valid}', { valid: utc(summary.validTime) })
|
||||
: detail),
|
||||
compactStatus: Boolean(summary.status),
|
||||
blocks,
|
||||
|
||||
+14
-11
@@ -1,6 +1,9 @@
|
||||
import { t } from '../i18n/index.js';
|
||||
|
||||
/** Build the voice control independently of its connection backend. */
|
||||
// The assistant's name on its card. i18n-ignore: product name
|
||||
const ASSISTANT_NAME = 'GEV';
|
||||
|
||||
export function createVoiceControl({ reset = false } = {}) {
|
||||
let root = document.getElementById('gev-voice-control');
|
||||
if (root && reset) {
|
||||
@@ -17,13 +20,13 @@ export function createVoiceControl({ reset = false } = {}) {
|
||||
<div class="gev-voice-kicker">${t('AI AGENT')}</div>
|
||||
<div id="gev-voice-status">${t('OFF')}</div>
|
||||
<div class="gev-voice-cost">
|
||||
<button id="gev-voice-tier" class="gev-voice-tier-btn" type="button" aria-pressed="false" title="${t('Voice model tier — applies next session')}">STD</button>
|
||||
<button id="gev-voice-tier" class="gev-voice-tier-btn" type="button" aria-pressed="false" title="${t('Voice model tier — applies next session')}">${t('STD')}</button>
|
||||
<span id="gev-voice-cost-value" class="gev-voice-cost-value" data-level="ok" title="${t('Estimated session cost')}">~$0.00</span>
|
||||
</div>
|
||||
</div>
|
||||
<button id="gev-voice-button" type="button" aria-label="${t('Voice control — activate to toggle voice; hold Space to speak')}" aria-describedby="gev-voice-help">
|
||||
<span class="gev-mic-orbit"><img src="/mic.svg" alt="" /></span>
|
||||
<span class="gev-mic-label">ON/OFF</span>
|
||||
<span class="gev-mic-label">${t('ON/OFF')}</span>
|
||||
</button>
|
||||
<div class="gev-voice-visualizer" aria-hidden="true">
|
||||
${Array.from({ length: 15 }, (_, index) => `<span style="--bar:${index}"></span>`).join('')}
|
||||
@@ -35,23 +38,23 @@ export function createVoiceControl({ reset = false } = {}) {
|
||||
<span class="gev-voice-help-kicker">${t('VOICE CONTROL')}</span>
|
||||
<span class="gev-voice-help-detail">${t('Hold Space to speak · tap Space to activate focused controls')}</span>
|
||||
</div>
|
||||
<section id="gev-voice-card" class="gev-voice-card" hidden aria-label="Voice assistant">
|
||||
<section id="gev-voice-card" class="gev-voice-card" hidden aria-label="${t('Voice assistant')}">
|
||||
<div class="gev-voice-card-header">
|
||||
<span class="gev-voice-card-kicker">VOICE</span>
|
||||
<span class="gev-voice-card-kicker">${t('VOICE')}</span>
|
||||
<span id="gev-voice-card-phase" class="gev-voice-card-phase"></span>
|
||||
<button id="gev-voice-card-dismiss" class="gev-voice-card-dismiss" type="button" aria-label="Dismiss voice card">×</button>
|
||||
<button id="gev-voice-card-dismiss" class="gev-voice-card-dismiss" type="button" aria-label="${t('Dismiss voice card')}">×</button>
|
||||
</div>
|
||||
<p id="gev-voice-card-user" class="gev-voice-card-caption" data-role="user" hidden><span class="gev-voice-card-who">YOU</span><span id="gev-voice-card-user-text" class="gev-voice-card-text"></span></p>
|
||||
<p id="gev-voice-card-pointer" class="gev-voice-card-pointer" hidden><span id="gev-voice-card-pointer-kind" class="gev-voice-card-who">THIS</span><span class="gev-voice-card-sr">: </span><span id="gev-voice-card-pointer-text" class="gev-voice-card-pointer-label"></span></p>
|
||||
<p id="gev-voice-card-assistant" class="gev-voice-card-caption" data-role="assistant" hidden><span class="gev-voice-card-who">GEV</span><span id="gev-voice-card-assistant-text" class="gev-voice-card-text"></span></p>
|
||||
<ol id="gev-voice-card-plan" class="gev-voice-card-plan" aria-label="Plan" hidden></ol>
|
||||
<p id="gev-voice-card-user" class="gev-voice-card-caption" data-role="user" hidden><span class="gev-voice-card-who">${t('YOU')}</span><span id="gev-voice-card-user-text" class="gev-voice-card-text"></span></p>
|
||||
<p id="gev-voice-card-pointer" class="gev-voice-card-pointer" hidden><span id="gev-voice-card-pointer-kind" class="gev-voice-card-who">${t('THIS')}</span><span class="gev-voice-card-sr">: </span><span id="gev-voice-card-pointer-text" class="gev-voice-card-pointer-label"></span></p>
|
||||
<p id="gev-voice-card-assistant" class="gev-voice-card-caption" data-role="assistant" hidden><span class="gev-voice-card-who">${ASSISTANT_NAME}</span><span id="gev-voice-card-assistant-text" class="gev-voice-card-text"></span></p>
|
||||
<ol id="gev-voice-card-plan" class="gev-voice-card-plan" aria-label="${t('Plan')}" hidden></ol>
|
||||
<div id="gev-voice-card-result" class="gev-voice-card-result" hidden>
|
||||
<div id="gev-voice-card-result-title" class="gev-voice-card-result-title"></div>
|
||||
<div id="gev-voice-card-chips" class="gev-voice-card-chips" hidden></div>
|
||||
<ul id="gev-voice-card-lines" class="gev-voice-card-lines"></ul>
|
||||
<ol id="gev-voice-card-referents" class="gev-voice-card-referents" aria-label="Referenced items" hidden></ol>
|
||||
<ol id="gev-voice-card-referents" class="gev-voice-card-referents" aria-label="${t('Referenced items')}" hidden></ol>
|
||||
<details id="gev-voice-card-notes" class="gev-voice-card-notes" hidden>
|
||||
<summary>NOTES & SOURCES</summary>
|
||||
<summary>${t('NOTES & SOURCES')}</summary>
|
||||
<ul id="gev-voice-card-note-list" class="gev-voice-card-lines"></ul>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
@@ -1642,15 +1642,20 @@ export async function controlCctv(dataManager, args = {}, styleManager = null) {
|
||||
throw new Error(`Unknown CCTV action: ${args.action || 'missing'}`);
|
||||
}
|
||||
|
||||
// Labels are place names returned to the voice model as tool data.
|
||||
const RADIO_COUNTRY_CENTERS = new Map([
|
||||
// i18n-ignore: place name in tool data
|
||||
['us', { lat: 39.8, lon: -98.6, country: 'US', label: 'United States' }],
|
||||
// i18n-ignore: place name in tool data
|
||||
['usa', { lat: 39.8, lon: -98.6, country: 'US', label: 'United States' }],
|
||||
[
|
||||
'united states',
|
||||
// i18n-ignore: place name in tool data
|
||||
{ lat: 39.8, lon: -98.6, country: 'US', label: 'United States' },
|
||||
],
|
||||
[
|
||||
'united states of america',
|
||||
// i18n-ignore: place name in tool data
|
||||
{ lat: 39.8, lon: -98.6, country: 'US', label: 'United States' },
|
||||
],
|
||||
]);
|
||||
@@ -2251,6 +2256,7 @@ async function trackEntity(
|
||||
action: 'track_entity',
|
||||
kind: 'fire',
|
||||
layerId: 'local-firms',
|
||||
// i18n-ignore: entity name in a tool result for the voice model
|
||||
label: strongest.label || 'Strongest fire',
|
||||
latitude: strongest.latitude,
|
||||
longitude: strongest.longitude,
|
||||
|
||||
@@ -49,7 +49,7 @@ export class RealtimeCost {
|
||||
const pendingTier = resolveVoiceModel(this.voiceTier).tier;
|
||||
const isMini = pendingTier === 'mini';
|
||||
if (this.ui?.tierButton) {
|
||||
this.ui.tierButton.textContent = isMini ? 'MINI' : 'STD';
|
||||
this.ui.tierButton.textContent = isMini ? t('MINI') : t('STD');
|
||||
this.ui.tierButton.setAttribute(
|
||||
'aria-pressed',
|
||||
isMini ? 'true' : 'false',
|
||||
@@ -65,7 +65,7 @@ export class RealtimeCost {
|
||||
'Voice model: {pendingId} — click to switch to {target}; applies next session',
|
||||
{
|
||||
pendingId,
|
||||
target: isMini ? 'standard' : 'mini',
|
||||
target: isMini ? t('standard') : t('mini'),
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -74,13 +74,22 @@ export class RealtimeCost {
|
||||
if (liveAuth === 'oauth') {
|
||||
const { responses, input, output, captions } = this.oauthUsage;
|
||||
this.ui.costValue.hidden = false;
|
||||
this.ui.costValue.textContent = 'COST UNKNOWN';
|
||||
this.ui.costValue.textContent = t('COST UNKNOWN');
|
||||
this.ui.costValue.dataset.level = 'unknown';
|
||||
this.ui.costValue.title =
|
||||
`ChatGPT OAuth session: ${responses} response(s), ${input} input and ${output} output tokens, ${captions} caption transcription(s) reported. ` +
|
||||
'USD cost and the API spend cap are unavailable for this auth mode.' +
|
||||
t(
|
||||
'ChatGPT OAuth session: {responses} response(s), {input} input and {output} output tokens, {captions} caption transcription(s) reported.',
|
||||
{ responses, input, output, captions },
|
||||
) +
|
||||
' ' +
|
||||
t(
|
||||
'USD cost and the API spend cap are unavailable for this auth mode.',
|
||||
) +
|
||||
(state.incomplete
|
||||
? ' Usage is incomplete because a response was still in flight when the session ended.'
|
||||
? ' ' +
|
||||
t(
|
||||
'Usage is incomplete because a response was still in flight when the session ended.',
|
||||
)
|
||||
: '');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -364,6 +364,7 @@ export class RealtimeInput {
|
||||
*/
|
||||
updateVoiceButtonLabel() {
|
||||
if (!this.ui.buttonLabel) return;
|
||||
// i18n-ignore: the GEV MIC button's name; translated tips refer to it as MIC
|
||||
this.ui.buttonLabel.textContent = 'MIC';
|
||||
if (this.ui.helpDetail) {
|
||||
this.ui.helpDetail.textContent = resolveVoiceControlHint(
|
||||
|
||||
@@ -2,6 +2,17 @@ import { createVoiceControl } from './control.js';
|
||||
import { createVoiceSession } from './session.js';
|
||||
import { VoiceCardControls } from './voiceCard.js';
|
||||
import { PointerReticle } from './pointerReticle.js';
|
||||
import { t, msg } from '../i18n/index.js';
|
||||
|
||||
// Session states are machine values (dataset.status); only the label shown for
|
||||
// them is translated. Unknown states show as they are, upper-cased.
|
||||
const SESSION_STATE_LABELS = Object.freeze({
|
||||
idle: msg('OFF'),
|
||||
connecting: msg('CONNECTING'),
|
||||
listening: msg('LISTENING'),
|
||||
executing: msg('EXECUTING'),
|
||||
error: msg('ERROR'),
|
||||
});
|
||||
|
||||
function createDefaultReticle(pointer) {
|
||||
const element = pointer?.element;
|
||||
@@ -52,8 +63,9 @@ export function createVoiceCommands({
|
||||
if (ui.tierButton) ui.tierButton.hidden = !capabilities.costControls;
|
||||
if (ui.costValue) ui.costValue.hidden = !capabilities.costControls;
|
||||
if (!capabilities.pushToTalk) {
|
||||
ui.button.setAttribute('aria-label', 'Toggle voice control');
|
||||
if (ui.helpDetail) ui.helpDetail.textContent = 'Activate to toggle voice';
|
||||
ui.button.setAttribute('aria-label', t('Toggle voice control'));
|
||||
if (ui.helpDetail)
|
||||
ui.helpDetail.textContent = t('Activate to toggle voice');
|
||||
}
|
||||
// Retain the existing controller's inspection surface for browser tools.
|
||||
const controls = adapter.controller || session;
|
||||
@@ -61,15 +73,21 @@ export function createVoiceCommands({
|
||||
const updateStatus = session.subscribe((event) => {
|
||||
if (event.type !== 'state') return;
|
||||
ui.root.dataset.status = event.state;
|
||||
ui.status.textContent =
|
||||
event.state === 'idle' ? 'OFF' : event.state.toUpperCase();
|
||||
ui.status.textContent = SESSION_STATE_LABELS[event.state]
|
||||
? t(SESSION_STATE_LABELS[event.state])
|
||||
: event.state.toUpperCase();
|
||||
// The session reports 'Voice off' in English: it is portable and has no
|
||||
// locale, so that detail shows as the idle label. Other details are
|
||||
// provider messages and show as they arrive.
|
||||
const idle = event.state === 'idle';
|
||||
const detail = event.detail === 'Voice off' ? '' : event.detail;
|
||||
ui.detail.textContent =
|
||||
event.detail || (event.state === 'idle' ? 'Voice off' : 'Voice active');
|
||||
detail || (idle ? t('Voice off') : t('Voice active'));
|
||||
ui.button.setAttribute('aria-pressed', String(session.isActive()));
|
||||
if (ui.errorDetail)
|
||||
ui.errorDetail.textContent =
|
||||
event.state === 'error'
|
||||
? event.detail || 'Voice could not be started.'
|
||||
? event.detail || t('Voice could not be started.')
|
||||
: '';
|
||||
if (event.state === 'error') ui.root.classList?.remove('error-dismissed');
|
||||
});
|
||||
|
||||
+202
-44
@@ -16,9 +16,14 @@
|
||||
*
|
||||
* Builders are small pure functions keyed by tool name. Tools without one are
|
||||
* returned unchanged.
|
||||
*
|
||||
* Only `display` and the plan/progress labels are interface text, translated
|
||||
* with t(). `say`, `identityLine` and the progress lines are not shown: they go
|
||||
* to the voice model to be spoken, so they stay as written.
|
||||
*/
|
||||
|
||||
import { analystHeadline } from './resultDisplay.js';
|
||||
import { formatNumber, msg, t, tn } from '../i18n/index.js';
|
||||
|
||||
const LAYER_NOUNS = Object.freeze({
|
||||
flights: 'aircraft',
|
||||
@@ -53,13 +58,26 @@ function listPhrase(items) {
|
||||
return `${list.slice(0, -1).join(', ')} and ${list.at(-1)}`;
|
||||
}
|
||||
|
||||
function feet(meters) {
|
||||
function feetRounded(meters) {
|
||||
const value = Number(meters);
|
||||
if (!Number.isFinite(value)) return null;
|
||||
const rounded = Math.round((value * 3.28084) / 100) * 100;
|
||||
return Number.isFinite(value)
|
||||
? Math.round((value * 3.28084) / 100) * 100
|
||||
: null;
|
||||
}
|
||||
|
||||
function feet(meters) {
|
||||
const rounded = feetRounded(meters);
|
||||
if (rounded === null) return null;
|
||||
return `${rounded.toLocaleString('en-US')} feet`;
|
||||
}
|
||||
|
||||
/** The card's altitude, in the active language. */
|
||||
function shownFeet(meters) {
|
||||
const rounded = feetRounded(meters);
|
||||
if (rounded === null) return null;
|
||||
return t('{value} feet', { value: formatNumber(rounded) });
|
||||
}
|
||||
|
||||
function kilometers(km) {
|
||||
const value = Number(km);
|
||||
if (!Number.isFinite(value)) return null;
|
||||
@@ -76,9 +94,98 @@ function feedTag(state) {
|
||||
return MATERIAL_FEED_TAGS[state] || null;
|
||||
}
|
||||
|
||||
/** Feed states as the card shows them; the state values stay English. */
|
||||
const FEED_STATE_LABELS = Object.freeze({
|
||||
nominal: msg('nominal'),
|
||||
loading: msg('loading'),
|
||||
degraded: msg('degraded'),
|
||||
stale: msg('stale'),
|
||||
partial: msg('partial'),
|
||||
fallback: msg('fallback'),
|
||||
unavailable: msg('unavailable'),
|
||||
});
|
||||
|
||||
function feedStateLabel(state) {
|
||||
const key = FEED_STATE_LABELS[state];
|
||||
return key ? t(key) : state;
|
||||
}
|
||||
|
||||
/** A feed chip only when the state is worth seeing; nominal is the default. */
|
||||
function feedChips(state) {
|
||||
return state && state !== 'nominal' ? [{ label: state }] : [];
|
||||
return state && state !== 'nominal' ? [{ label: feedStateLabel(state) }] : [];
|
||||
}
|
||||
|
||||
/** '{label} on' / '{label} off' as the card shows it. */
|
||||
function switchedTitle(label, on) {
|
||||
return on ? t('{label} on', { label }) : t('{label} off', { label });
|
||||
}
|
||||
|
||||
// Card titles name the layer's things in whole phrases, so each language can
|
||||
// inflect the noun; the English matches LAYER_NOUNS.
|
||||
function frameTitle(layerId) {
|
||||
switch (layerId) {
|
||||
case 'flights':
|
||||
return t('Frame aircraft');
|
||||
case 'military':
|
||||
return t('Frame military aircraft');
|
||||
case 'ais-live-vessels':
|
||||
return t('Frame ships');
|
||||
case 'satellites':
|
||||
return t('Frame satellites');
|
||||
default:
|
||||
return t('Frame contacts');
|
||||
}
|
||||
}
|
||||
|
||||
function inFrameTitle(layerId, count) {
|
||||
switch (layerId) {
|
||||
case 'flights':
|
||||
return tn(
|
||||
count,
|
||||
'{count} aircraft in frame',
|
||||
'{count} aircraft in frame',
|
||||
);
|
||||
case 'military':
|
||||
return tn(
|
||||
count,
|
||||
'{count} military aircraft in frame',
|
||||
'{count} military aircraft in frame',
|
||||
);
|
||||
case 'ais-live-vessels':
|
||||
return tn(count, '{count} ships in frame', '{count} ships in frame');
|
||||
case 'satellites':
|
||||
return tn(
|
||||
count,
|
||||
'{count} satellites in frame',
|
||||
'{count} satellites in frame',
|
||||
);
|
||||
default:
|
||||
return tn(
|
||||
count,
|
||||
'{count} contacts in frame',
|
||||
'{count} contacts in frame',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function nearestTitle(layerId) {
|
||||
switch (layerId) {
|
||||
case 'military':
|
||||
return t('Nearest military aircraft');
|
||||
case 'ais-live-vessels':
|
||||
return t('Nearest ships');
|
||||
case 'satellites':
|
||||
return t('Nearest satellites');
|
||||
default:
|
||||
return t('Nearest aircraft');
|
||||
}
|
||||
}
|
||||
|
||||
/** Two shown items joined, or the one item. */
|
||||
function shownPair(items) {
|
||||
return items.length === 2
|
||||
? t('{first} and {second}', { first: items[0], second: items[1] })
|
||||
: items[0] || '';
|
||||
}
|
||||
|
||||
/** Ids such as `austin` or `local-firms` read as names on screen. */
|
||||
@@ -117,7 +224,7 @@ function setLayerVisibility(result, args = {}) {
|
||||
return {
|
||||
say,
|
||||
display: {
|
||||
title: `${label} ${on ? 'on' : 'off'}`,
|
||||
title: switchedTitle(label, on),
|
||||
chips: on ? feedChips(result.feedState) : [],
|
||||
sources: result.source ? [{ label: result.source }] : [],
|
||||
},
|
||||
@@ -138,7 +245,11 @@ function flyToLocation(result) {
|
||||
display: {
|
||||
title: label,
|
||||
lines: result.rangeM
|
||||
? [`Camera range ${kilometers(result.rangeM / 1000)}`]
|
||||
? [
|
||||
t('Camera range {range}', {
|
||||
range: kilometers(result.rangeM / 1000),
|
||||
}),
|
||||
]
|
||||
: [],
|
||||
},
|
||||
};
|
||||
@@ -162,7 +273,7 @@ function frameOverhead(result) {
|
||||
return {
|
||||
say,
|
||||
display: {
|
||||
title: `Frame ${noun}`,
|
||||
title: frameTitle(result?.layerId),
|
||||
lines: [result?.error].filter(Boolean),
|
||||
},
|
||||
};
|
||||
@@ -181,12 +292,13 @@ function frameOverhead(result) {
|
||||
return {
|
||||
say: sentence(`Framed ${countText} ${noun} within ${radius}`),
|
||||
display: {
|
||||
title: `${count} ${noun} in frame`,
|
||||
title: inFrameTitle(result.layerId, count),
|
||||
lines: [
|
||||
`Within ${radius} of the view`,
|
||||
result.detectionEnabled ? 'Labels on' : null,
|
||||
t('Within {radius} of the view', { radius }),
|
||||
result.detectionEnabled ? t('Labels on') : null,
|
||||
].filter(Boolean),
|
||||
notes: count >= 80 ? ['Framing uses the nearest 80 loaded contacts'] : [],
|
||||
notes:
|
||||
count >= 80 ? [t('Framing uses the nearest 80 loaded contacts')] : [],
|
||||
},
|
||||
referents,
|
||||
};
|
||||
@@ -212,7 +324,7 @@ function selectNearestAircraft(result) {
|
||||
return {
|
||||
say,
|
||||
display: {
|
||||
title: `Nearest ${noun}`,
|
||||
title: nearestTitle(result?.layerId),
|
||||
lines: [result?.error].filter(Boolean),
|
||||
chips: feedChips(feed.state),
|
||||
sources,
|
||||
@@ -239,8 +351,14 @@ function selectNearestAircraft(result) {
|
||||
display: {
|
||||
title: name,
|
||||
lines: [
|
||||
distance ? `${distance} from ${location}` : null,
|
||||
altitude ? `Altitude ${altitude}` : null,
|
||||
distance
|
||||
? t('{distance} from {location}', { distance, location })
|
||||
: null,
|
||||
altitude
|
||||
? t('Altitude {altitude}', {
|
||||
altitude: shownFeet(aircraft.altitudeM),
|
||||
})
|
||||
: null,
|
||||
].filter(Boolean),
|
||||
chips: feedChips(feed.state),
|
||||
sources,
|
||||
@@ -251,14 +369,30 @@ function selectNearestAircraft(result) {
|
||||
};
|
||||
}
|
||||
|
||||
function routeText(properties) {
|
||||
const SPOKEN_ROUTE = Object.freeze({
|
||||
between: (origin, destination) => `${origin} to ${destination}`,
|
||||
from: (origin) => `from ${origin}`,
|
||||
to: (destination) => `to ${destination}`,
|
||||
});
|
||||
|
||||
const SHOWN_ROUTE = Object.freeze({
|
||||
between: (origin, destination) =>
|
||||
t('{origin} to {destination}', { origin, destination }),
|
||||
from: (origin) => t('from {origin}', { origin }),
|
||||
to: (destination) => t('to {destination}', { destination }),
|
||||
});
|
||||
|
||||
function routeText(properties, words = SPOKEN_ROUTE) {
|
||||
const route = spokenLabel(properties.route || '', 40);
|
||||
if (route) return route.replace(/\s*(?:-|→|>)\s*/g, ' to ');
|
||||
if (route)
|
||||
return route
|
||||
.split(/\s*(?:-|→|>)\s*/)
|
||||
.reduce((origin, destination) => words.between(origin, destination));
|
||||
const origin = spokenLabel(properties.routeOrigin || '', 12);
|
||||
const destination = spokenLabel(properties.routeDestination || '', 12);
|
||||
if (origin && destination) return `${origin} to ${destination}`;
|
||||
if (origin) return `from ${origin}`;
|
||||
if (destination) return `to ${destination}`;
|
||||
if (origin && destination) return words.between(origin, destination);
|
||||
if (origin) return words.from(origin);
|
||||
if (destination) return words.to(destination);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -301,9 +435,13 @@ function getEntityContext(result) {
|
||||
title,
|
||||
lines: [
|
||||
properties.registration
|
||||
? `Registration ${spokenLabel(properties.registration, 16)}`
|
||||
? t('Registration {registration}', {
|
||||
registration: spokenLabel(properties.registration, 16),
|
||||
})
|
||||
: null,
|
||||
route
|
||||
? t('Route {route}', { route: routeText(properties, SHOWN_ROUTE) })
|
||||
: null,
|
||||
route ? `Route ${route}` : null,
|
||||
].filter(Boolean),
|
||||
sources: selected.source ? [{ label: selected.source }] : [],
|
||||
},
|
||||
@@ -350,10 +488,10 @@ function getCurrentViewState(result) {
|
||||
.map((part) => sentence(part[0].toUpperCase() + part.slice(1)))
|
||||
.join(' '),
|
||||
display: {
|
||||
title: 'View state',
|
||||
title: t('View state'),
|
||||
lines: enabled.map(
|
||||
(layer) =>
|
||||
`${spokenLabel(layer.name || layer.id, 32)} · ${layer.count ?? 0} · ${layer.feedState || 'nominal'}`,
|
||||
`${spokenLabel(layer.name || layer.id, 32)} · ${layer.count ?? 0} · ${feedStateLabel(layer.feedState || 'nominal')}`,
|
||||
),
|
||||
},
|
||||
schedule: 'silent',
|
||||
@@ -387,13 +525,15 @@ function annotateMap(result) {
|
||||
say: parts.length ? parts.join(' ') : null,
|
||||
display: {
|
||||
title: result?.ok
|
||||
? `Marked ${drawn.length} ${drawn.length === 1 ? 'place' : 'places'}`
|
||||
: 'Nothing marked',
|
||||
? tn(drawn.length, 'Marked {count} place', 'Marked {count} places')
|
||||
: t('Nothing marked'),
|
||||
// The numbered referents list the marks; lines stay for extra facts.
|
||||
lines: [],
|
||||
notes: [
|
||||
...(failed.length ? [`Not found: ${failed.join(', ')}`] : []),
|
||||
...(result?.outlinePending ? ['Tracing outlines'] : []),
|
||||
...(failed.length
|
||||
? [t('Not found: {places}', { places: failed.join(', ') })]
|
||||
: []),
|
||||
...(result?.outlinePending ? [t('Tracing outlines')] : []),
|
||||
],
|
||||
},
|
||||
referents,
|
||||
@@ -492,22 +632,36 @@ export function planStepLabel(name, args = {}) {
|
||||
switch (name) {
|
||||
case 'annotate_map': {
|
||||
const targets = annotationTargets(args);
|
||||
return targets.length
|
||||
? `Mark ${listPhrase(targets.slice(0, 2))}${targets.length > 2 ? ` +${targets.length - 2}` : ''}`
|
||||
: 'Mark the map';
|
||||
if (!targets.length) return t('Mark the map');
|
||||
const shown = shownPair(targets.slice(0, 2));
|
||||
return targets.length > 2
|
||||
? t('Mark {targets} +{more}', {
|
||||
targets: shown,
|
||||
more: targets.length - 2,
|
||||
})
|
||||
: t('Mark {targets}', { targets: shown });
|
||||
}
|
||||
case 'fly_to_location':
|
||||
return `Fly to ${displayName(args.query || args.locationId || 'location', 40)}`;
|
||||
return t('Fly to {place}', {
|
||||
place: displayName(args.query || args.locationId || 'location', 40),
|
||||
});
|
||||
case 'select_nearest_aircraft':
|
||||
return `Nearest aircraft to ${displayName(args.locationQuery || args.locationId || 'here', 32)}`;
|
||||
return t('Nearest aircraft to {place}', {
|
||||
place: displayName(args.locationQuery || args.locationId || 'here', 32),
|
||||
});
|
||||
case 'set_layer_visibility':
|
||||
return `${displayName(args.layerId || 'Layer', 32)} ${args.enabled === false ? 'off' : 'on'}`;
|
||||
return switchedTitle(
|
||||
displayName(args.layerId || 'Layer', 32),
|
||||
args.enabled !== false,
|
||||
);
|
||||
case 'get_entity_context':
|
||||
return 'Read the view';
|
||||
return t('Read the view');
|
||||
case 'get_current_view_state':
|
||||
return 'Check view state';
|
||||
return t('Check view state');
|
||||
case 'frame_overhead':
|
||||
return `Frame ${spokenLabel(args.target || 'flights', 24)}`;
|
||||
return t('Frame {target}', {
|
||||
target: spokenLabel(args.target || 'flights', 24),
|
||||
});
|
||||
default:
|
||||
return spokenLabel(String(name || 'Action').replace(/_/g, ' '), 40);
|
||||
}
|
||||
@@ -532,18 +686,22 @@ export function narrationLabel(name, args = {}) {
|
||||
|
||||
/** On-screen labels for progress steps. */
|
||||
const STEP_LABELS = Object.freeze({
|
||||
resolve: 'Finding places',
|
||||
outline: 'Tracing outline',
|
||||
search: 'Looking up place',
|
||||
fly: 'Flying',
|
||||
layer: 'Turning on layer',
|
||||
refresh: 'Loading aircraft',
|
||||
nearest: 'Picking nearest',
|
||||
resolve: msg('Finding places'),
|
||||
outline: msg('Tracing outline'),
|
||||
search: msg('Looking up place'),
|
||||
fly: msg('Flying'),
|
||||
layer: msg('Turning on layer'),
|
||||
refresh: msg('Loading aircraft'),
|
||||
nearest: msg('Picking nearest'),
|
||||
});
|
||||
|
||||
/** On-screen label for a progress step. */
|
||||
export function progressStepLabel(step, label) {
|
||||
const base = STEP_LABELS[step] || spokenLabel(step || 'Working', 24);
|
||||
const base = STEP_LABELS[step]
|
||||
? t(STEP_LABELS[step])
|
||||
: step
|
||||
? spokenLabel(step, 24)
|
||||
: t('Working');
|
||||
return label ? `${base}: ${spokenLabel(label, 32)}` : base;
|
||||
}
|
||||
|
||||
|
||||
@@ -93,7 +93,7 @@ test('markup: the card nests inside the voice control with every element and a p
|
||||
assert.match(controlSource, new RegExp(`id="${id}"`), `${id} is missing`);
|
||||
}
|
||||
assert.match(controlSource, /id="gev-voice-card-live"[^>]*aria-live="polite"/);
|
||||
assert.match(controlSource, /aria-label="Dismiss voice card"/);
|
||||
assert.match(controlSource, /aria-label="\$\{t\('Dismiss voice card'\)\}"/);
|
||||
assert.match(css, /#gev-voice-control \.gev-voice-card \{/);
|
||||
assert.match(css, /body\.recording-mode #gev-voice-control/, 'recording mode hides the control, card included');
|
||||
assert.match(css, /body\.ui-clean-view #command-dock/, 'Clean-UI hides the dock, card included');
|
||||
|
||||
@@ -2,6 +2,7 @@ import { planStepLabel, progressStepLabel, spokenLabel } from './speech.js';
|
||||
import { presentResult } from './resultDisplay.js';
|
||||
import { MAX_DISPLAYED_REFERENTS, normalizeReferents } from './referents.js';
|
||||
|
||||
import { t } from '../i18n/index.js';
|
||||
/**
|
||||
* Pure presentation of the voice card nested in the voice control: captions,
|
||||
* the current plan, the latest result and its numbered referents.
|
||||
@@ -244,7 +245,9 @@ export function voiceCardView(state) {
|
||||
const display = state.result?.display || null;
|
||||
const allNotes = [
|
||||
...(display?.notes || []),
|
||||
...(display?.sources || []).map((source) => `Source: ${source.label}`),
|
||||
...(display?.sources || []).map((source) =>
|
||||
t('Source: {label}', { label: source.label }),
|
||||
),
|
||||
];
|
||||
// Analyst notes carry count/scope/provenance qualifications. Preserve their
|
||||
// full sanitized text in at most six existing rows, joining overflow into
|
||||
@@ -274,7 +277,7 @@ export function voiceCardView(state) {
|
||||
},
|
||||
pointer: {
|
||||
visible: Boolean(state.pointer),
|
||||
kind: state.pointer?.kind === 'here' ? 'HERE' : 'THIS',
|
||||
kind: state.pointer?.kind === 'here' ? t('HERE') : t('THIS'),
|
||||
text: spokenLabel(state.pointer?.label || '', 32),
|
||||
},
|
||||
assistant: {
|
||||
|
||||
@@ -50,7 +50,7 @@ export const VOICE_MODELS = Object.freeze({
|
||||
standard: Object.freeze({
|
||||
tier: 'standard',
|
||||
id: 'gpt-realtime-2',
|
||||
label: 'STANDARD',
|
||||
label: 'STANDARD', // i18n-ignore: tier name for reports and scripts; the app's tier button sets its own text
|
||||
/** USD per 1M tokens — gpt-realtime-2. */
|
||||
rates: Object.freeze({
|
||||
textInput: 4,
|
||||
@@ -66,7 +66,7 @@ export const VOICE_MODELS = Object.freeze({
|
||||
mini: Object.freeze({
|
||||
tier: 'mini',
|
||||
id: 'gpt-realtime-2.1-mini',
|
||||
label: 'MINI',
|
||||
label: 'MINI', // i18n-ignore: tier name for reports and scripts; the app's tier button sets its own text
|
||||
/** USD per 1M tokens — gpt-realtime-2.1-mini (~3.2× cheaper on audio). */
|
||||
rates: Object.freeze({
|
||||
textInput: 0.6,
|
||||
|
||||
Reference in new issue
Block a user