i18n: translate the application views with t() as they render

Every data-i18n, data-i18n-title, data-i18n-aria-label and
data-i18n-placeholder marker in the Vue templates becomes a t() call with
the key the boot pass used to look up, contexts included. The three labels
the shell rewrote at boot (Visual Presets, Location and the standby
description) render through t() too, and that rewrite is gone. With the
locale in vue-i18n, view text now follows a locale change, and markup
under v-if or v-for no longer comes back in English when it re-renders.

Rendered text and attributes are unchanged in every locale. The boot pass
still translates static page markup such as the loading screen and a host's
own markup, and npm run i18n rejects data-i18n* in a Vue template.
This commit is contained in:
Sameh Khamis committed 2026-11-01 15:38:23 -08:00
1 parent 2c72a038d5
commit ba20bc13d8
33 files changed
+377 -344

No files matched your search

+1 -1
View File
@@ -126,7 +126,7 @@ The highest-leverage places to jump in:
- ES modules, **2-space indent, single quotes, semicolons.**
- JSDoc on exported/public functions.
- Match the surrounding code — comment density, naming, and idiom.
- User-visible text goes through `t()` or `tn()`, with `msg()` marking English that is stored and translated later. Static page markup that predates the views keeps its `data-i18n*` attributes; components use `t()`. `npm run i18n` must pass. See [docs/I18N.md](docs/I18N.md).
- User-visible text goes through `t()` or `tn()`, with `msg()` marking English that is stored and translated later. Components call `t()` in their templates, so their text follows the locale; `data-i18n*` attributes are only for static page markup such as the loading screen, and `npm run i18n` rejects them in a template. `npm run i18n` must pass. See [docs/I18N.md](docs/I18N.md).
- Prefer small, reviewable commits. Conventional-commit-style prefixes (`feat:`, `fix:`, `perf:`, `docs:`) are appreciated but not required.
## Building UI
+6
View File
@@ -1,3 +1,9 @@
- The application views translate their text with `t()` as they render,
so it follows the active locale; they no longer carry `data-i18n*`
attributes, and `npm run i18n` rejects those in a Vue template. The boot
pass (`applyDocumentTranslations`) still translates static page markup
such as the loading screen and a host's own markup (samehkhamis, #0000).
- The page's locale lives in a vue-i18n instance installed in every
application view, and `t()`, `tn()` and the Intl formatters read it from
there, so a view that translates while rendering renders again when the
+17 -7
View File
@@ -31,13 +31,23 @@ Vue views call the same `t()` and `tn()`. The page's vue-i18n instance (`src/ui/
## Markup
Vue views translate in the template, so their text renders in the active locale:
```vue
<span>{{ t('Normal') }}</span>
<button :title="t('Pin visual presets')">…</button>
<option value="all">{{ t('All', { context: 'radio tag' }) }}</option>
```
Static page markup that no view renders (the loading screen in `index.html`, a host's own markup) opts in with attributes instead, and `npm run i18n` rejects them in a Vue template:
```html
<span data-i18n>Normal</span>
<button title="Pin visual presets" data-i18n-title>…</button>
<input placeholder="Search any location..." data-i18n-placeholder />
```
`data-i18n` replaces the element's text, so put it on the element that directly holds the text, never on a parent with child elements. `data-i18n-title`, `data-i18n-aria-label` and `data-i18n-placeholder` translate that attribute. `data-i18n-context="radio tag"` gives the element's text a context, the catalog entry `radio tag|All`, as `t('All', { context: 'radio tag' })` does in code. Markup is translated once at boot, and switching language reloads the page. A module that holds work a reload would lose (an unsaved edit, an open session) registers it with `registerUnsavedWork(() => busy ? msg('unsaved …') : null)` from `src/i18n/index.js`, and the language picker asks before reloading.
`data-i18n` replaces the element's text, so put it on the element that directly holds the text, never on a parent with child elements. `data-i18n-title`, `data-i18n-aria-label` and `data-i18n-placeholder` translate that attribute. `data-i18n-context="radio tag"` gives the element's text a context, the catalog entry `radio tag|All`, as `t('All', { context: 'radio tag' })` does in code. Attribute markup is translated once at boot. Switching language still reloads the page: views would follow a new locale, but much of their text is published already translated by code that does not. A module that holds work a reload would lose (an unsaved edit, an open session) registers it with `registerUnsavedWork(() => busy ? msg('unsaved …') : null)` from `src/i18n/index.js`, and the language picker asks before reloading.
## Rules
@@ -68,16 +78,16 @@ An empty translation is reported, and so is one left as the English, plural form
`npm run i18n` and `npm test` fail on UI text that is not marked:
- **Markup:** element text without `data-i18n`, and `title`, `aria-label` or `placeholder` without the matching `data-i18n-*`.
- **Markup:** element text without `data-i18n`, and `title`, `aria-label` or `placeholder` without the matching `data-i18n-*`. In a Vue template, static text or a static `title`, `aria-label` or `placeholder` (rather than `t()`), and any `data-i18n*` attribute.
- **Code:** string literals that reach the page without `t()`, `tn()` or `msg()`: assigned to `textContent`, `title` and the like; passed to `setAttribute('title' | 'aria-label' | …)`, `createTextNode`, `showToast` and other notice helpers; assigned to a layer's `error`, `lastError`, `loadingLabel`, `statusMessage` or `status` (a plain `error = …` too); or kept in `label:`, `title:`, `status:` and similar properties. Not reported: declarations (`let status = …`), default values, ternary branches and lower-case codes (`'auth-failed'`). The full list is `JS_SINKS` in `scripts/i18n.mjs`.
Fix it by marking the text. When the text must stay as it is, say so where it lives:
| Case | How |
| --------------------------------------------------------------------------- | ------------------------------------------------------------------------ |
| Codes, units, product names in markup (`0.9 dB`, `ADS-B · 1090`, `OpenSky`) | `translate="no"` on the element; it covers everything inside, as in HTML |
| Markup whose text code sets through `t()` (a status that starts as `OFF`) | `data-i18n-runtime` on the element |
| A literal in code that is not UI copy (a product name, sample data) | `// i18n-ignore: <reason>` on the line or the line above |
| Case | How |
| ------------------------------------------------------------------------------ | ------------------------------------------------------------------------ |
| Codes, units, product names in markup (`0.9 dB`, `ADS-B · 1090`, `OpenSky`) | `translate="no"` on the element; it covers everything inside, as in HTML |
| Page markup whose text code sets through `t()` (a status that starts as `OFF`) | `data-i18n-runtime` on the element (not in a Vue template) |
| A literal in code that is not UI copy (a product name, sample data) | `// i18n-ignore: <reason>` on the line or the line above |
Icons (`aria-hidden="true"`, Material Symbols), comments, CSS values and lowercase identifiers are skipped automatically, and so is `src/tools/`, whose text is written for AI clients rather than the page.
+7 -6
View File
@@ -153,10 +153,9 @@ a feature supplying only its accent.
Next each view takes over its wiring: markup an owner builds with
`createElement` becomes component markup that renders from a Pinia store
the owner publishes to, and user actions come back to the owner through
the store, so the owner keeps every decision. Static markup that the
translation pass rewrites (`data-i18n*`) stays outside `v-if` and
`v-for`, because re-rendering it would bring back the English; component
text uses `t()` instead. Shared builders become components first and
the store, so the owner keeps every decision. Component text uses
`t()`, which follows the locale as the view renders; the views carry no
`data-i18n*` markers, which `npm run i18n` rejects in a template. Shared builders become components first and
keep their imperative entry points until their owners are components:
the rail cards, card blocks and timeline (`src/ui/rail/`) render through
Vue's synchronous `render()` behind `createRailCards()`,
@@ -344,8 +343,10 @@ a feature supplying only its accent.
now requires the pattern for new UI. The page-level markup tests read
`renderApplicationHtml()`, the real views rendered on the server, and new
tests mount components; three tests still read a `.vue` file's source
(`cyberTheme`, `panelStorageDocs`, `geoFileImport`). The attribute translation pass goes with the move to
vue-i18n. An explicit API for hosts to register panels and rail items is
(`cyberTheme`, `panelStorageDocs`, `geoFileImport`). The views translate with `t()`
while rendering, and vue-i18n holds the locale they read; the attribute
translation pass is left for static page markup (the loading screen and a
host's own markup). An explicit API for hosts to register panels and rail items is
deferred until a host adds one: no host does today (the hosted
application's account interface is its own markup), and the first such
panel, the hosted timeline, will shape it.
+39 -3
View File
@@ -156,7 +156,7 @@ const SKIP_TAGS = new Set(['script', 'style', 'svg', 'code', 'pre', 'title']);
/**
* User-visible markup text without data-i18n*. Skipped, with everything
* inside: translate="no" (text that stays as is, as in HTML),
* data-i18n-runtime (code sets this text through t()), and icons
* data-i18n-runtime (page markup whose text code sets through t()), and icons
* (aria-hidden, Material Symbols).
*/
export function unmarkedMarkup(file, html) {
@@ -404,8 +404,13 @@ export function unmarkedScript(file, original) {
*/
export function findUnmarked() {
const found = [];
for (const file of markupFiles())
for (const file of markupFiles()) {
found.push(...unmarkedMarkup(file, readMarkup(file)));
}
for (const file of trackedFiles('src/**/*.vue'))
found.push(
...markedTemplate(file, readFileSync(path.join(ROOT, file), 'utf8')),
);
for (const file of sourceFiles().filter(
(name) => !NOT_UI.some((pattern) => pattern.test(name)),
))
@@ -421,7 +426,38 @@ export function findUnmarked() {
});
}
/** Static markup opted in with data-i18n*: the page and its views. */
/**
* data-i18n* in a Vue template: views translate with t() as they render, so
* their text follows the locale; the attributes are for static page markup.
* Reads the whole single-file component, so lines are the file's own.
*/
export function markedTemplate(file, source) {
const start = source.indexOf('<template>');
if (start === -1) return [];
const end = source.lastIndexOf('</template>');
// Comments and attribute values are blanked, keeping every offset.
const blank = (text) => text.replace(/[^\n]/g, ' ');
const template = source
.slice(0, end === -1 ? undefined : end)
.replace(/<!--[\s\S]*?-->/g, blank);
const found = [];
const TAG = /<[a-zA-Z][\w-]*((?:"[^"]*"|'[^']*'|[^>"'])*)>/g;
TAG.lastIndex = start + '<template>'.length;
for (let tag; (tag = TAG.exec(template));) {
const attrs = tag[1].replace(/"[^"]*"|'[^']*'/g, blank);
const offset = tag.index + tag[0].length - 1 - tag[1].length;
for (const match of attrs.matchAll(
/(?<=\s)(?::|v-bind:)?(data-i18n[\w-]*)(?=[\s=/]|$)/g,
))
found.push({
where: `${file}:${lineAt(source, offset + match.index)}`,
text: `${match[1]}: use t() in a Vue template`,
});
}
return found;
}
/** Markup checked for unmarked text: the page and its views. */
function markupFiles() {
return trackedFiles('index.html', 'src/ui/views/*.vue');
}
+3 -3
View File
@@ -814,7 +814,7 @@ test('markup, startup ordering and accessibility remain pinned', async () => {
// passing edit — changing it needs the owner, not a nicer-sounding rewrite.
assert.ok(
html.includes(
'<p id="first-run-description" data-i18n-runtime>It feels like a forbidden cockpit' +
'<p id="first-run-description">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',
@@ -903,8 +903,8 @@ test('the card closes without a keyboard: the ESC hint is a button, kept on phon
footer,
/<button type="button" class="first-run-dismiss" data-first-run-dismiss>/,
);
assert.match(footer, /data-i18n>ESC to dismiss</);
assert.match(footer, /data-i18n>DISMISS</);
assert.match(footer, />ESC to dismiss</);
assert.match(footer, />DISMISS</);
// It closes the way ESC does, through the one dismiss path, so the session
// flag and focus handling are the same: firstRunLauncherView.test.mjs.
// Phones used to hide the hint, leaving no way out but picking a mission.
+27
View File
@@ -5,6 +5,7 @@ import {
checkCatalog,
extractMessages,
findUnmarked,
markedTemplate,
readCatalog,
shippedLocales,
unmarkedMarkup,
@@ -111,6 +112,32 @@ test('unmarkedMarkup finds text and attributes without data-i18n*', () => {
assert.deepEqual(texts('<!-- Note --><span>— 42 —</span>'), []);
});
test('markedTemplate rejects data-i18n* attributes in a Vue template', () => {
const view = [
'<script setup>',
"const note = 'data-i18n';",
'</script>',
'<template>',
' <!-- no data-i18n here -->',
' <span data-i18n>Normal</span>',
' <b title="Explains data-i18n" data-i18n-title>{{ t(\'Go\') }}</b>',
' <i :data-i18n="true" />',
'</template>',
].join('\n');
assert.deepEqual(markedTemplate('View.vue', view), [
{ where: 'View.vue:6', text: 'data-i18n: use t() in a Vue template' },
{ where: 'View.vue:7', text: 'data-i18n-title: use t() in a Vue template' },
{ where: 'View.vue:8', text: 'data-i18n: use t() in a Vue template' },
]);
assert.deepEqual(
markedTemplate(
'View.vue',
'<template><b :title="t(\'Pin\')">x</b></template>',
),
[],
);
});
test('unmarkedScript finds literals that reach the page without t/tn/msg', () => {
const texts = (js) => unmarkedScript('f.js', js).map((f) => f.text);
assert.deepEqual(texts("el.textContent = 'Saved';"), ['Saved']);
+1 -1
View File
@@ -336,7 +336,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" data-i18n-aria-label><\/div>/,
/<section class="map-source-section"[\s\S]*?<div id="map-stack-chips" class="map-stack-chip-row" role="group" aria-label="Map source"><\/div>/,
);
assert.doesNotMatch(
html,
+4 -3
View File
@@ -29,12 +29,13 @@ export function createStandaloneApplication({
constructed = true;
// Locale is resolved before any UI initialization so <html lang>/<html dir>
// and every catalog lookup agree from the first painted frame. Module
// scripts execute after parsing, so the DOM is ready here and every
// data-i18n* element honors the resolved locale without a reload.
// scripts execute after parsing, so the DOM is ready here: the views
// translate as they render, and the page's static data-i18n* markup (the
// loading screen) is translated once.
const activeLocale = resolveLocale();
setLocale(activeLocale);
applyDocumentLanguage(document, activeLocale);
// Views render the page's markup; translation and wiring read it.
// Views render the page's markup; wiring reads it.
mountApplicationViews(document);
applyDocumentTranslations(document);
const loadingScreen = document.getElementById('loading-screen');
-51
View File
@@ -575,8 +575,6 @@ export class StyleManager extends ShellFacade {
});
this._initUI();
this._initMapStackControl();
// Headers first: panel chrome builds its toggle labels from them.
this._applyRuntimeStaticHeaderText();
this._initPanelChrome();
this._panelNavigation = new PanelNavigation({
ui: this._ui,
@@ -802,55 +800,6 @@ export class StyleManager extends ShellFacade {
}
}
/**
* Rewrites a header's last text node, keeping the leading icon span that a
* textContent write would destroy.
* @param {Element|null} element
* @param {string} text
* @returns {void}
*/
_setHeaderText(element, text) {
if (!element) return;
const textNode = Array.from(element.childNodes)
.reverse()
.find(
(node) => node.nodeType === Node.TEXT_NODE && node.nodeValue?.trim(),
);
if (textNode) textNode.nodeValue = text;
else element.textContent = text;
}
/**
* Translates at boot the header texts that share their element with an
* icon or a <br> (data-i18n-runtime in the templates).
* @returns {void}
*/
_applyRuntimeStaticHeaderText() {
this._setHeaderText(
document.querySelector('#control-panel .panel-title'),
t('VISUAL PRESETS'),
);
this._setHeaderText(
document.querySelector('#location-bar .location-toolbar-label'),
t('LOCATION'),
);
// One span holds both mode descriptions around a <br>; keep the <br>.
const standbyDescription = document.querySelector(
'#context-mode-standby span',
);
if (standbyDescription) {
standbyDescription.replaceChildren(
document.createTextNode(
t('CONTACTS — nearest planes · vessels · sites'),
),
document.createElement('br'),
document.createTextNode(
t('SPACE MISSIONS — launches & orbital assets'),
),
);
}
}
/**
* Phone layout: a bottom tab bar presenting one panel sheet at a time
* (src/ui/mobileShell.js). Inactive above 720px.
+2 -2
View File
@@ -654,11 +654,11 @@ 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" data-i18n>Cyber<\/option>/);
assert.match(display, /<option value="cyber">\{\{ t\('Cyber'\) \}\}<\/option>/);
assert.match(display, /id="cyber-sonar-toggle"/);
assert.match(
display,
/id="cyber-sonar-opacity"[^>]*:model-value="sonar\?\.opacity \?\? 84" aria-label="Cyber contact and label opacity"/,
/id="cyber-sonar-opacity"[^>]*:model-value="sonar\?\.opacity \?\? 84" :aria-label="t\('Cyber contact and label opacity'\)"/,
);
assert.match(display, /id="cyber-sonar-sector"/);
assert.deepEqual(setHud.parameters.properties.layout.enum, HUD_LAYOUTS);
+25 -25
View File
@@ -55,7 +55,7 @@ const tickStyle = (tick) =>
<template>
<!-- Aircraft first-person view. It can be entered from the visible view switcher. -->
<section id="cockpit-hud" aria-label="Aircraft cockpit view" :hidden="!active" :style="utilityStyle" v-class-toggle="{ 'signals-active': active, 'context-active': Boolean(presence?.context) }" data-i18n-aria-label :data-layer="hud.layer ?? undefined">
<section id="cockpit-hud" :aria-label="t('Aircraft cockpit view')" :hidden="!active" :style="utilityStyle" v-class-toggle="{ 'signals-active': active, 'context-active': Boolean(presence?.context) }" :data-layer="hud.layer ?? undefined">
<!-- One optical coordinate system: every targeting mark shares this center.
Decorative only; telemetry and interactive panels remain separate. -->
<svg class="cockpit-frame-lines" viewBox="0 0 1200 720" preserveAspectRatio="none" aria-hidden="true" focusable="false">
@@ -117,19 +117,19 @@ const tickStyle = (tick) =>
<span>+20</span><span>+10</span><span class="active">00</span><span>−10</span><span>−20</span>
</div>
<div class="cockpit-horizon-guide">
<span></span><small data-i18n>LEVEL</small><span></span>
<span></span><small>{{ t('LEVEL') }}</small><span></span>
</div>
<div id="cockpit-route-direction" class="cockpit-route-direction" :hidden="!direction.visible" aria-label="Estimated destination direction" data-i18n-aria-label :style="direction.angle === null ? undefined : { '--route-angle': direction.angle }">
<div id="cockpit-route-direction" class="cockpit-route-direction" :hidden="!direction.visible" :aria-label="t('Estimated destination direction')" :style="direction.angle === null ? undefined : { '--route-angle': direction.angle }">
<svg class="cockpit-route-chevron" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path d="M12 2 4.58 19.93c-.34.83.51 1.63 1.31 1.27L12 18.47l6.11 2.73c.8.36 1.65-.44 1.31-1.27L12 2Z" />
</svg>
<small id="cockpit-route-direction-label">{{ direction.label }}</small>
</div>
<div class="cockpit-visor-status cockpit-visor-status-left" data-i18n>OPTICAL PLANE · 01</div>
<div class="cockpit-visor-status cockpit-visor-status-right" data-i18n>VISOR LOCK · ACTIVE</div>
<div class="cockpit-visor-status cockpit-visor-status-left">{{ t('OPTICAL PLANE · 01') }}</div>
<div class="cockpit-visor-status cockpit-visor-status-right">{{ t('VISOR LOCK · ACTIVE') }}</div>
</div>
<div id="cockpit-speed-rim" class="cockpit-altitude-rim cockpit-speed-rim" v-class-toggle="{ unavailable: hud.speedRim.unavailable }">
<span class="cockpit-altitude-rim-label" data-i18n>GROUND SPEED · KTS</span>
<span class="cockpit-altitude-rim-label">{{ t('GROUND SPEED · KTS') }}</span>
<div class="cockpit-altitude-rim-rail"></div>
<div class="cockpit-altitude-rim-scale">
<span v-for="(tick, index) in hud.speedRim.ticks" :key="index" data-speed-rim-tick :hidden="tick.hidden" :class="{ major: tick.major }" :style="tickStyle(tick)"><b>{{ tick.label }}</b><i></i></span>
@@ -140,7 +140,7 @@ const tickStyle = (tick) =>
</div>
</div>
<div id="cockpit-altitude-rim" class="cockpit-altitude-rim" v-class-toggle="{ unavailable: hud.altitudeRim.unavailable }">
<span class="cockpit-altitude-rim-label" data-i18n>ALTITUDE · FT</span>
<span class="cockpit-altitude-rim-label">{{ t('ALTITUDE · FT') }}</span>
<div class="cockpit-altitude-rim-rail"></div>
<div class="cockpit-altitude-rim-scale">
<span v-for="(tick, index) in hud.altitudeRim.ticks" :key="index" data-altitude-rim-tick :hidden="tick.hidden" :class="{ major: tick.major }" :style="tickStyle(tick)"><b>{{ tick.label }}</b><i></i></span>
@@ -152,38 +152,38 @@ const tickStyle = (tick) =>
</div>
<div class="cockpit-topline">
<div>
<span class="cockpit-kicker" data-i18n>FIRST PERSON</span>
<span class="cockpit-kicker">{{ t('FIRST PERSON') }}</span>
<strong id="cockpit-callsign">{{ hud.callsign }}</strong>
<span id="cockpit-aircraft-meta" v-class-toggle="{ emergency: hud.meta.emergency }" class="cockpit-aircraft-meta">{{ hud.meta.text }}</span>
</div>
<div class="cockpit-vision-controls" role="group" aria-label="Cockpit vision style" data-i18n-aria-label>
<button id="cockpit-vision-previous" class="cockpit-vision-nav" type="button" aria-label="Previous cockpit vision style" title="Previous vision style" data-i18n-aria-label data-i18n-title>
<div class="cockpit-vision-controls" role="group" :aria-label="t('Cockpit vision style')">
<button id="cockpit-vision-previous" class="cockpit-vision-nav" type="button" :aria-label="t('Previous cockpit vision style')" :title="t('Previous vision style')">
<span class="material-symbols-outlined" aria-hidden="true">chevron_left</span>
<small data-i18n>PREV</small>
<small>{{ t('PREV') }}</small>
</button>
<button id="cockpit-vision-current" class="cockpit-vision-current" type="button" :data-cockpit-vision="v.mode" :aria-label="visionLabel" :title="visionTitle">
<small data-i18n>CURRENT</small>
<small>{{ t('CURRENT') }}</small>
<strong id="cockpit-vision-current-label" aria-live="polite">{{ v.label }}</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>
<button id="cockpit-vision-next" class="cockpit-vision-nav" type="button" :aria-label="t('Next cockpit vision style')" :title="t('Next vision style')">
<small>{{ t('NEXT') }}</small>
<span class="material-symbols-outlined" aria-hidden="true">chevron_right</span>
</button>
</div>
</div>
<div class="cockpit-reticle material-symbols-outlined" aria-hidden="true">my_location</div>
<div class="cockpit-compass" aria-label="Current aircraft heading" data-i18n-aria-label>
<div class="cockpit-compass" :aria-label="t('Current aircraft heading')">
<div class="cockpit-instrument-row">
<div class="cockpit-readout cockpit-speed">
<span class="cockpit-readout-label" data-i18n>GROUND SPEED</span>
<span class="cockpit-readout-label">{{ t('GROUND SPEED') }}</span>
<CockpitRollingValue id="cockpit-speed-value" tag="strong" :reading="hud.speed" placeholder="---" />
<span class="cockpit-readout-unit" data-i18n>KTS</span>
<span class="cockpit-readout-unit">{{ t('KTS') }}</span>
</div>
<div class="cockpit-heading-value"><CockpitRollingValue id="cockpit-heading-value" :reading="hud.heading" placeholder="---" :circular-range="360" /><small>°</small></div>
<div class="cockpit-readout cockpit-altitude">
<span class="cockpit-readout-label" data-i18n>ALTITUDE</span>
<span class="cockpit-readout-label">{{ t('ALTITUDE') }}</span>
<CockpitRollingValue id="cockpit-altitude-value" tag="strong" :reading="hud.altitude" placeholder="-----" />
<span class="cockpit-readout-unit" data-i18n>FT</span>
<span class="cockpit-readout-unit">{{ t('FT') }}</span>
</div>
</div>
<span class="cockpit-heading-caret material-symbols-outlined" aria-hidden="true">arrow_drop_down</span>
@@ -196,17 +196,17 @@ const tickStyle = (tick) =>
<CockpitContext />
<CockpitBriefing />
<CockpitUtilityControls />
<div class="cockpit-help"><bdi data-i18n>ESC EXIT</bdi> <span>·</span> <bdi data-i18n>C TOGGLE</bdi></div>
<div class="cockpit-help"><bdi>{{ t('ESC EXIT') }}</bdi> <span>·</span> <bdi>{{ t('C TOGGLE') }}</bdi></div>
</section>
<nav id="view-switcher" aria-label="View switcher" data-i18n-aria-label>
<button id="cockpit-reset-globe" type="button" :aria-label="resetBusy ? t('Resetting cockpit to full globe view') : t('Reset cockpit to full globe view')" title="Exit cockpit and return to full globe view" :hidden="!active" data-i18n-title @click="globe.act('reset')">
<nav id="view-switcher" :aria-label="t('View switcher')">
<button id="cockpit-reset-globe" type="button" :aria-label="resetBusy ? t('Resetting cockpit to full globe view') : t('Reset cockpit to full globe view')" :title="t('Exit cockpit and return to full globe view')" :hidden="!active" @click="globe.act('reset')">
<span class="material-symbols-outlined" aria-hidden="true">public</span>
<span data-i18n>RESET</span>
<span>{{ t('RESET') }}</span>
</button>
<button id="map-view-switch" type="button" aria-label="Exit cockpit view" aria-keyshortcuts="Escape C" title="Exit cockpit view" :hidden="!active" data-i18n-aria-label data-i18n-title>
<button id="map-view-switch" type="button" :aria-label="t('Exit cockpit view')" aria-keyshortcuts="Escape C" :title="t('Exit cockpit view')" :hidden="!active">
<span class="material-symbols-outlined" aria-hidden="true">close_fullscreen</span>
<span data-i18n>EXIT COCKPIT</span>
<span>{{ t('EXIT COCKPIT') }}</span>
</button>
</nav>
</template>
+21 -21
View File
@@ -66,7 +66,7 @@ function selectMapSource(chip) {
<template>
<!-- Unified bottom command dock: locations, voice, visual presets -->
<div id="command-dock" class="ui-scroll" aria-label="Navigation, voice, and visual preset controls" data-i18n-aria-label>
<div id="command-dock" class="ui-scroll" :aria-label="t('Navigation, voice, and visual preset controls')">
<template v-for="slot in dockOrder" :key="slot">
<!-- Control panel -->
<div v-if="slot === 'control'" id="control-panel" v-panel-state="'control-panel'" class="panel-collapsible collapsed">
@@ -75,53 +75,53 @@ function selectMapSource(chip) {
<button id="control-panel-toggle" class="panel-header dock-tray-toggle" type="button"
data-dock-toggle-target="control-panel" :aria-expanded="String(panels['control-panel']?.collapsed === false)"
aria-controls="control-panel-popover" :aria-label="trayWords('control-panel', t('VISUAL PRESETS'), t('Expand Visual Presets'))" :title="trayWords('control-panel', t('VISUAL PRESETS'), undefined)">
<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-title"><span class="dock-label-icon dock-label-icon-presets" aria-hidden="true"></span>{{ t('VISUAL PRESETS') }}</span>
<span class="panel-divider"></span>
</button>
<div id="control-panel-popover" class="dock-popover-content">
<UiButton variant="icon" class="dock-pin-btn" data-pin-target="control-panel" :label="t('Pin visual presets')" :title="t('Keep visual presets open')" :pressed="!!panels['control-panel']?.pinned" @click="pin($event, 'control-panel')"><img src="/pin.svg" alt="" /></UiButton>
<div id="style-buttons" class="button-grid">
<button class="style-btn active" data-style="normal" @click="display.act('setStyle', 'normal')" v-class-toggle="{ ...(style && { active: style.name === 'normal' }) }" title="Show the globe without a visual filter." data-i18n-title>
<button class="style-btn active" data-style="normal" @click="display.act('setStyle', 'normal')" v-class-toggle="{ ...(style && { active: style.name === 'normal' }) }" :title="t('Show the globe without a visual filter.')">
<span class="btn-icon">◯</span>
<span class="btn-label" data-i18n>Normal</span>
<span class="btn-label">{{ t('Normal') }}</span>
<span class="btn-key">1</span>
</button>
<button class="style-btn" data-style="retro" @click="display.act('setStyle', 'retro')" v-class-toggle="{ ...(style && { active: style.name === 'retro' }) }" title="Emulate a green phosphor CRT with scanlines and screen curvature." data-i18n-title>
<button class="style-btn" data-style="retro" @click="display.act('setStyle', 'retro')" v-class-toggle="{ ...(style && { active: style.name === 'retro' }) }" :title="t('Emulate a green phosphor CRT with scanlines and screen curvature.')">
<span class="btn-icon">▦</span>
<span class="btn-label" data-i18n>CRT</span>
<span class="btn-label">{{ t('CRT') }}</span>
<span class="btn-key">2</span>
</button>
<button class="style-btn" data-style="surveillance" @click="display.act('setStyle', 'surveillance')" v-class-toggle="{ ...(style && { active: style.name === 'surveillance' }) }" title="Simulate night-vision goggles with green intensification and a tube vignette." data-i18n-title>
<button class="style-btn" data-style="surveillance" @click="display.act('setStyle', 'surveillance')" v-class-toggle="{ ...(style && { active: style.name === 'surveillance' }) }" :title="t('Simulate night-vision goggles with green intensification and a tube vignette.')">
<span class="btn-icon">🌙</span>
<span class="btn-label" data-i18n>NVG</span>
<span class="btn-label">{{ t('NVG') }}</span>
<span class="btn-key">3</span>
</button>
<button class="style-btn" data-style="thermal" @click="display.act('setStyle', 'thermal')" v-class-toggle="{ ...(style && { active: style.name === 'thermal' }) }" title="Simulate FLIR-style thermal contrast. Turn up Ironbow for color." data-i18n-title>
<button class="style-btn" data-style="thermal" @click="display.act('setStyle', 'thermal')" v-class-toggle="{ ...(style && { active: style.name === 'thermal' }) }" :title="t('Simulate FLIR-style thermal contrast. Turn up Ironbow for color.')">
<span class="btn-icon">🌡️</span>
<span class="btn-label" data-i18n>FLIR</span>
<span class="btn-label">{{ t('FLIR') }}</span>
<span class="btn-key">4</span>
</button>
<button class="style-btn" data-style="anime" @click="display.act('setStyle', 'anime')" v-class-toggle="{ ...(style && { active: style.name === 'anime' }) }" title="Apply bright cel-shaded color and illustrated outlines." data-i18n-title>
<button class="style-btn" data-style="anime" @click="display.act('setStyle', 'anime')" v-class-toggle="{ ...(style && { active: style.name === 'anime' }) }" :title="t('Apply bright cel-shaded color and illustrated outlines.')">
<span class="btn-icon">✦</span>
<span class="btn-label" data-i18n>Anime</span>
<span class="btn-label">{{ t('Anime') }}</span>
<span class="btn-key">5</span>
</button>
<button class="style-btn" data-style="noir" @click="display.act('setStyle', 'noir')" v-class-toggle="{ ...(style && { active: style.name === 'noir' }) }" title="Apply high-contrast monochrome film-noir grading." data-i18n-title>
<button class="style-btn" data-style="noir" @click="display.act('setStyle', 'noir')" v-class-toggle="{ ...(style && { active: style.name === 'noir' }) }" :title="t('Apply high-contrast monochrome film-noir grading.')">
<span class="btn-icon">◐</span>
<span class="btn-label" data-i18n>Noir</span>
<span class="btn-label">{{ t('Noir') }}</span>
<span class="btn-key">6</span>
</button>
</div>
<section class="map-source-section" aria-labelledby="map-source-label">
<div class="map-source-heading">
<span data-i18n id="map-source-label">MAP SOURCE</span>
<span id="map-source-label">{{ t('MAP SOURCE') }}</span>
<span id="map-stack-status" class="pp-stack-status" v-class-toggle="{ warn: !!mapSource?.status.warn }" aria-live="polite">{{ mapSource ? mapSource.status.text : '3D' }}</span>
</div>
<div id="map-stack-chips" class="map-stack-chip-row" role="group" aria-label="Map source" data-i18n-aria-label><UiButton v-for="chip in mapSource?.chips" :key="chip.id" class="map-stack-chip" :pressed="Boolean(chip.active)" pressed-class="active" v-class-toggle="{ unavailable: !chip.available }" :data-stack-id="chip.id" :title="chip.title" :aria-disabled="String(!chip.available)" :aria-label="chip.ariaLabel" @click="selectMapSource(chip)"><span class="map-stack-chip-label">{{ chip.label }}</span><span v-if="chip.requirement" class="map-stack-chip-req">{{ chip.requirement }}</span></UiButton></div>
<div id="map-stack-chips" class="map-stack-chip-row" role="group" :aria-label="t('Map source')"><UiButton v-for="chip in mapSource?.chips" :key="chip.id" class="map-stack-chip" :pressed="Boolean(chip.active)" pressed-class="active" v-class-toggle="{ unavailable: !chip.available }" :data-stack-id="chip.id" :title="chip.title" :aria-disabled="String(!chip.available)" :aria-label="chip.ariaLabel" @click="selectMapSource(chip)"><span class="map-stack-chip-label">{{ chip.label }}</span><span v-if="chip.requirement" class="map-stack-chip-req">{{ chip.requirement }}</span></UiButton></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 class="panel-mini-label">{{ t('Style') }}</span>
<span id="style-mini-value" class="panel-mini-value">{{ style ? style.label : t('NORMAL') }}</span>
</div>
</div>
@@ -135,9 +135,9 @@ function selectMapSource(chip) {
<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="String(panels['location-bar']?.collapsed === false)" :aria-label="trayWords('location-bar', t('LOCATION'), t('Expand LOCATION'))" :title="trayWords('location-bar', t('LOCATION'), t('Expand LOCATION'))">
<span class="location-toolbar-label" data-i18n-runtime><span class="dock-label-icon dock-label-icon-location" aria-hidden="true"></span>LOCATION</span>
<span class="location-toolbar-label"><span class="dock-label-icon dock-label-icon-location" aria-hidden="true"></span>{{ t('LOCATION') }}</span>
</button>
<button class="panel-collapse-btn" data-collapse-target="location-bar" title="Collapse panel" data-i18n-title @click="chrome.act('toggle', 'location-bar')">+</button>
<button class="panel-collapse-btn" data-collapse-target="location-bar" :title="t('Collapse panel')" @click="chrome.act('toggle', 'location-bar')">+</button>
</div>
<div id="location-mini-status" class="location-mini-status" aria-live="polite">
<div id="location-mini-city" class="location-mini-city">{{ loc ? loc.status.city : t('📍 Location: --') }}</div>
@@ -150,10 +150,10 @@ function selectMapSource(chip) {
<div class="location-city-row">
<div id="location-pills" class="location-pills"><UiButton v-for="city in loc?.cities" :key="city.id" class="location-pill" :pressed="Boolean(city.active)" pressed-class="active" :data-location-id="city.id" @click="locationControls?.chooseCity(city.id)">{{ city.label }}</UiButton></div>
<div class="location-search-wrap">
<button id="search-toggle" class="search-toggle-btn" title="Search any location" data-i18n-title @click="locationControls?.toggleSearch()">
<button id="search-toggle" class="search-toggle-btn" :title="t('Search any location')" @click="locationControls?.toggleSearch()">
<span>&#x1F50E;</span>
</button>
<input type="text" id="location-search" v-class-toggle="{ expanded: !!loc?.searchExpanded, searching: !!loc?.searching }" placeholder="Search any location..." autocomplete="off" spellcheck="false" aria-label="Search location by name or coordinates" :aria-invalid="loc?.searchError ? 'true' : null" :aria-describedby="loc?.searchError ? 'location-search-error' : null" data-i18n-aria-label data-i18n-placeholder />
<input type="text" id="location-search" v-class-toggle="{ expanded: !!loc?.searchExpanded, searching: !!loc?.searching }" :placeholder="t('Search any location...')" autocomplete="off" spellcheck="false" :aria-label="t('Search location by name or coordinates')" :aria-invalid="loc?.searchError ? 'true' : null" :aria-describedby="loc?.searchError ? 'location-search-error' : null" />
</div>
</div>
<p id="location-search-error" class="location-search-error" role="alert">{{ loc?.searchError }}</p>
+12 -12
View File
@@ -30,37 +30,37 @@ const sceneRuntime = computed(() => scenePanel.value?.runtime ?? '');
<!-- Global Context is deliberately independent from the left layer accordion. -->
<aside id="right-context-rail" class="ui-scroll" v-class-toggle="{ 'bhote-event-active': !!bhotePanel }">
<RailPanel id="weather-panel" hidden :title="t('WEATHER')" sideways rail-scroller>
<template #badge><span id="weather-panel-count" aria-label="Active weather products" data-i18n-aria-label>0</span></template>
<template #badge><span id="weather-panel-count" :aria-label="t('Active weather products')">0</span></template>
<div id="weather-panel-body" class="ui-scroll" data-rail-scroller></div>
</RailPanel>
<RailPanel id="recent-imagery-panel" hidden :title="t('RECENT IMAGERY')" sideways rail-scroller>
<template #badge><span id="recent-imagery-panel-count" aria-label="Imagery days in the box" data-i18n-aria-label></span></template>
<template #badge><span id="recent-imagery-panel-count" :aria-label="t('Imagery days in the box')"></span></template>
<div id="recent-imagery-panel-body" class="ui-scroll" data-rail-scroller></div>
</RailPanel>
<RailPanel id="global-context-panel" start-collapsed v-class-toggle="{ ...(ctx && { 'context-enabled': ctx.enabled }) }" :data-context-mode="ctx?.mode" :title="t('CONTEXT')" sideways scroll rail-scroller>
<div class="cyber-panel-body ui-scroll" data-rail-scroller>
<div class="global-context-modes" role="tablist" aria-label="Context mode" data-i18n-aria-label>
<button id="global-context-flights-btn" class="context-mode-button" v-class-toggle="{ active: ctx?.flightsActive }" type="button" role="tab" tabindex="0" :aria-selected="String(ctx?.flightsActive ?? false)" :aria-disabled="ctx && String(ctx.busy)" :aria-busy="ctx && String(ctx.busy)" aria-controls="context-flights-view" aria-label="CONTACTS" title="Cycles the nearest contacts of whatever type you select — planes, vessels, installations. Satellites track independently." data-i18n-aria-label data-i18n-title>
<div class="global-context-modes" role="tablist" :aria-label="t('Context mode')">
<button id="global-context-flights-btn" class="context-mode-button" v-class-toggle="{ active: ctx?.flightsActive }" type="button" role="tab" tabindex="0" :aria-selected="String(ctx?.flightsActive ?? false)" :aria-disabled="ctx && String(ctx.busy)" :aria-busy="ctx && String(ctx.busy)" aria-controls="context-flights-view" :aria-label="t('CONTACTS')" :title="t('Cycles the nearest contacts of whatever type you select — planes, vessels, installations. Satellites track independently.')">
<span class="material-symbols-outlined" aria-hidden="true">radar</span>
<span data-i18n>CONTACTS</span>
<span>{{ t('CONTACTS') }}</span>
</button>
<button id="global-context-missions-btn" class="context-mode-button" v-class-toggle="{ active: ctx?.missionsActive }" type="button" role="tab" tabindex="0" :aria-selected="String(ctx?.missionsActive ?? false)" :aria-disabled="ctx && String(ctx.busy)" :aria-busy="ctx && String(ctx.busy)" aria-controls="context-missions-view">
<span class="material-symbols-outlined" aria-hidden="true">rocket_launch</span>
<span data-i18n>SPACE MISSIONS</span>
<span>{{ t('SPACE MISSIONS') }}</span>
</button>
</div>
<div id="context-mode-standby" class="context-mode-standby" :hidden="ctx?.enabled">
<strong data-i18n>SELECT CONTEXT</strong>
<span data-i18n-runtime>CONTACTS — nearest planes · vessels · sites<br>SPACE MISSIONS — launches &amp; orbital assets</span>
<strong>{{ t('SELECT CONTEXT') }}</strong>
<span>{{ t('CONTACTS — nearest planes · vessels · sites') }}<br>{{ t('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="!ctx?.flightsActive">
<div class="global-context-actions" aria-label="Contact Context actions" data-i18n-aria-label>
<div class="global-context-actions" :aria-label="t('Contact Context actions')">
<button id="cockpit-entry" type="button" :hidden="!cockpit?.entryAvailable">
<span class="material-symbols-outlined" aria-hidden="true">flight</span>
<span data-i18n>COCKPIT</span>
<span>{{ t('COCKPIT') }}</span>
</button>
<button id="installations-search-btn" class="panel-layer-toggle" type="button" :aria-disabled="ctx && String(ctx.installationsBusy)" :aria-busy="ctx && String(ctx.installationsBusy)" data-i18n>SEARCH NEARBY SITES</button>
<UiButton id="tr3b-toggle" class="tr3b-toggle" :pressed="!!cockpit?.tr3b?.converted" :hidden="!cockpit?.tr3b" aria-label="Reclassify tracked contact as TR-3B" :title="cockpit?.tr3b?.converted ? t('Restore real aircraft') : t('Reclassify as TR-3B')" data-i18n-aria-label>🛸</UiButton>
<button id="installations-search-btn" class="panel-layer-toggle" type="button" :aria-disabled="ctx && String(ctx.installationsBusy)" :aria-busy="ctx && String(ctx.installationsBusy)">{{ t('SEARCH NEARBY SITES') }}</button>
<UiButton id="tr3b-toggle" class="tr3b-toggle" :pressed="!!cockpit?.tr3b?.converted" :hidden="!cockpit?.tr3b" :aria-label="t('Reclassify tracked contact as TR-3B')" :title="cockpit?.tr3b?.converted ? t('Restore real aircraft') : t('Reclassify as TR-3B')">🛸</UiButton>
</div>
<AwarenessPanel />
</section>
+2 -1
View File
@@ -1,4 +1,5 @@
<script setup>
import { t } from '../../i18n/index.js';
import { useDisplayStore } from '../stores/display.js';
import DisplayPanel from './panels/DisplayPanel.vue';
@@ -7,5 +8,5 @@ const display = useDisplayStore();
<template>
<DisplayPanel />
<button id="clean-view-exit" @click="display.act('exitCleanView')" title="Return UI controls" data-i18n-title data-i18n>EXIT CLEAN VIEW</button>
<button id="clean-view-exit" @click="display.act('exitCleanView')" :title="t('Return UI controls')">{{ t('EXIT CLEAN VIEW') }}</button>
</template>
+16 -16
View File
@@ -51,30 +51,30 @@ async function importChosen(event) {
<!-- Scene Director Panel -->
<RailPanel id="scene-panel" start-collapsed v-class-toggle="{ running: scene?.running, 'has-shots': scene?.hasShots }" :title="t('AUTHORING')" :hint="t('Create and edit scenes')" scroll>
<p class="scene-step-heading" data-i18n>SCENE</p>
<p class="scene-step-heading">{{ t('SCENE') }}</p>
<div class="scene-controls">
<UiSelect class="gev-field" id="scene-select" aria-label="Scene" data-i18n-aria-label :disabled="off('select')" @update:model-value="scenes.act('selectScene', $event)"><SceneOptions /></UiSelect>
<UiButton id="scene-new-btn" type="button" :disabled="off('new')" @click="scenes.act('create')" class="gev-action-button scene-btn" title="Start a new, empty scene" data-i18n data-i18n-title>NEW SCENE</UiButton>
<UiButton tone="alert" id="scene-delete-btn" type="button" :disabled="off('delete')" @click="scenes.act('deleteScene')" class="gev-action-button gev-action-button--danger scene-btn scene-btn-danger" title="Delete this scene and all of its shots" data-i18n data-i18n-title>DELETE</UiButton>
<UiSelect class="gev-field" id="scene-select" :aria-label="t('Scene')" :disabled="off('select')" @update:model-value="scenes.act('selectScene', $event)"><SceneOptions /></UiSelect>
<UiButton id="scene-new-btn" type="button" :disabled="off('new')" @click="scenes.act('create')" class="gev-action-button scene-btn" :title="t('Start a new, empty scene')">{{ t('NEW SCENE') }}</UiButton>
<UiButton tone="alert" id="scene-delete-btn" type="button" :disabled="off('delete')" @click="scenes.act('deleteScene')" class="gev-action-button gev-action-button--danger scene-btn scene-btn-danger" :title="t('Delete this scene and all of its shots')">{{ t('DELETE') }}</UiButton>
</div>
<p class="scene-step-heading" data-i18n>SHOTS</p>
<p class="scene-step-heading">{{ t('SHOTS') }}</p>
<div class="scene-controls">
<UiButton variant="primary" id="scene-capture-btn" type="button" :disabled="off('capture')" @click="scenes.act('capture')" class="gev-action-button scene-btn scene-btn-primary" title="Add the current view, style and layers as the next shot" data-i18n data-i18n-title>+ CAPTURE SHOT</UiButton>
<UiButton id="scene-update-shot-btn" type="button" :disabled="off('update')" @click="scenes.act('update')" class="gev-action-button scene-btn" title="Replace the selected shot with the current view" data-i18n data-i18n-title>UPDATE SHOT</UiButton>
<UiButton variant="primary" id="scene-capture-btn" type="button" :disabled="off('capture')" @click="scenes.act('capture')" class="gev-action-button scene-btn scene-btn-primary" :title="t('Add the current view, style and layers as the next shot')">{{ t('+ CAPTURE SHOT') }}</UiButton>
<UiButton id="scene-update-shot-btn" type="button" :disabled="off('update')" @click="scenes.act('update')" class="gev-action-button scene-btn" :title="t('Replace the selected shot with the current view')">{{ t('UPDATE SHOT') }}</UiButton>
</div>
<SceneShotList />
<p class="scene-shot-hint" data-i18n>Click a shot to select it, double-click to rename.</p>
<p class="scene-step-heading" data-i18n>PREVIEW</p>
<p class="scene-shot-hint">{{ t('Click a shot to select it, double-click to rename.') }}</p>
<p class="scene-step-heading">{{ t('PREVIEW') }}</p>
<div class="scene-controls">
<UiButton variant="primary" id="scene-start-btn" type="button" class="gev-action-button scene-btn scene-btn-primary" title="Play from this scene through the rest of the project" data-i18n-title :disabled="off('start')" @click="scenes.act('start')">{{ scene?.playLabel ?? t('PLAY', { context: 'scene' }) }}</UiButton>
<UiButton tone="alert" id="scene-stop-btn" type="button" :disabled="off('stop')" @click="scenes.act('stop')" class="gev-action-button gev-action-button--danger scene-btn scene-btn-danger" title="Stop playback (Esc)" data-i18n data-i18n-title>STOP</UiButton>
<UiButton id="scene-next-btn" type="button" :disabled="off('next')" @click="scenes.act('next')" class="gev-action-button scene-btn" title="Play the shot after the selected one, moving on to the next scene when needed" data-i18n data-i18n-title>NEXT</UiButton>
<UiButton variant="primary" id="scene-start-btn" type="button" class="gev-action-button scene-btn scene-btn-primary" :title="t('Play from this scene through the rest of the project')" :disabled="off('start')" @click="scenes.act('start')">{{ scene?.playLabel ?? t('PLAY', { context: 'scene' }) }}</UiButton>
<UiButton tone="alert" id="scene-stop-btn" type="button" :disabled="off('stop')" @click="scenes.act('stop')" class="gev-action-button gev-action-button--danger scene-btn scene-btn-danger" :title="t('Stop playback (Esc)')">{{ t('STOP') }}</UiButton>
<UiButton id="scene-next-btn" type="button" :disabled="off('next')" @click="scenes.act('next')" class="gev-action-button scene-btn" :title="t('Play the shot after the selected one, moving on to the next scene when needed')">{{ t('NEXT') }}</UiButton>
</div>
<p class="scene-step-heading" data-i18n>SAVE &amp; SHARE</p>
<p class="scene-step-heading">{{ t('SAVE & SHARE') }}</p>
<div class="scene-controls scene-file-controls">
<UiButton id="scene-export-btn" type="button" :disabled="off('export')" @click="scenes.act('export')" class="gev-action-button scene-btn" title="Download every scene as a JSON file you can import later" data-i18n data-i18n-title>EXPORT</UiButton>
<UiButton id="scene-import-btn" type="button" :disabled="off('import')" @click="chooseImport($refs.sceneFile)" class="gev-action-button scene-btn" title="Load scenes from an exported JSON file" data-i18n data-i18n-title>IMPORT</UiButton>
<UiButton id="scene-download-btn" type="button" :disabled="off('download')" @click="scenes.act('download')" class="gev-action-button scene-btn" title="Download the timing log of the last play" data-i18n data-i18n-title>RUN LOG</UiButton>
<UiButton id="scene-export-btn" type="button" :disabled="off('export')" @click="scenes.act('export')" class="gev-action-button scene-btn" :title="t('Download every scene as a JSON file you can import later')">{{ t('EXPORT') }}</UiButton>
<UiButton id="scene-import-btn" type="button" :disabled="off('import')" @click="chooseImport($refs.sceneFile)" class="gev-action-button scene-btn" :title="t('Load scenes from an exported JSON file')">{{ t('IMPORT') }}</UiButton>
<UiButton id="scene-download-btn" type="button" :disabled="off('download')" @click="scenes.act('download')" class="gev-action-button scene-btn" :title="t('Download the timing log of the last play')">{{ t('RUN LOG') }}</UiButton>
<input ref="sceneFile" type="file" id="scene-import-file" accept="application/json,.json" hidden @change="importChosen" />
</div>
<div v-if="toolbar" data-director-authoring class="scene-controls">
+6 -6
View File
@@ -66,13 +66,13 @@ const removeTitle = (title) =>
<aside v-if="available" id="key-setup" role="dialog" aria-labelledby="key-setup-title" aria-describedby="key-setup-description" :hidden="dialog.hidden" v-class-toggle="{ visible: dialog.visible }" :data-ready="view?.ready ? 'true' : undefined" @transitionend="controls?.transitionEnd()">
<div class="key-setup-scanline" aria-hidden="true"></div>
<header class="key-setup-header">
<span class="key-setup-kicker" data-i18n>GROUND STATION · PROVIDER SETTINGS</span>
<button type="button" class="key-setup-close" data-key-setup-close aria-label="Close key setup" data-i18n-aria-label @click="controls?.close()">
<span class="key-setup-kicker">{{ t('GROUND STATION · PROVIDER SETTINGS') }}</span>
<button type="button" class="key-setup-close" data-key-setup-close :aria-label="t('Close key setup')" @click="controls?.close()">
<span class="material-symbols-outlined" aria-hidden="true">close</span>
</button>
</header>
<h2 id="key-setup-title" data-i18n>Power up the globe</h2>
<p id="key-setup-description" data-i18n>The globe already flies keyless. Every key below switches on another real feed — paste one and it's saved into this app's local configuration, then the server restarts itself. Server-side keys stay on this machine; Google Maps and Cesium ion run in the browser and must be provider-restricted. Keys you configured elsewhere are shown but never touched.</p>
<h2 id="key-setup-title">{{ t('Power up the globe') }}</h2>
<p id="key-setup-description">{{ t('The globe already flies keyless. Every key below switches on another real feed — paste one and it\'s saved into this app\'s local configuration, then the server restarts itself. Server-side keys stay on this machine; Google Maps and Cesium ion run in the browser and must be provider-restricted. Keys you configured elsewhere are shown but never touched.') }}</p>
<div class="key-setup-rows ui-scroll" data-key-setup-rows>
<section v-for="key in view?.rows ?? []" :key="`${key.id}:${view.generation}`" class="key-setup-row" :data-key-id="key.id" :data-set="String(key.set)" :data-managed="key.managed || undefined">
<div class="key-setup-row-head">
@@ -95,8 +95,8 @@ const removeTitle = (title) =>
</section>
</div>
<div class="key-setup-footer">
<button type="button" class="key-setup-apply" data-key-setup-apply :aria-disabled="view?.saving == null ? undefined : String(view.saving)" data-i18n @click="controls?.apply()">SAVE KEYS</button>
<span class="key-setup-hint" data-i18n>ESC to close</span>
<button type="button" class="key-setup-apply" data-key-setup-apply :aria-disabled="view?.saving == null ? undefined : String(view.saving)" @click="controls?.apply()">{{ t('SAVE KEYS') }}</button>
<span class="key-setup-hint">{{ t('ESC to close') }}</span>
</div>
<p class="key-setup-note" data-key-setup-status role="status" aria-live="polite" v-status-text="statusText"></p>
</aside>
+3 -3
View File
@@ -63,7 +63,7 @@ const vToastText = {
<div id="world-overlay-root" aria-hidden="true">
<canvas id="world-overlay-canvas"></canvas>
</div>
<div id="world-overlay-actions" class="world-overlay-accessibility" role="region" aria-label="Visible map targets" data-i18n-aria-label>
<div id="world-overlay-actions" class="world-overlay-accessibility" role="region" :aria-label="t('Visible map targets')">
<div id="world-overlay-action-list"><UiButton v-for="item in targets?.items ?? []" :key="item.key" :aria-label="item.label" :pressed="Boolean(item.selected)" :data-overlay-action-key="item.key" :onClick="onOverlayAction(targets.owner, item.key)">{{ item.label }}</UiButton></div>
<div id="world-overlay-status" v-overlay-status="targets" role="status" aria-live="polite" aria-atomic="true"></div>
</div>
@@ -72,13 +72,13 @@ const vToastText = {
<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 translate="no">GOD'S EYE <span class="title-accent">VIEW</span></span></h1>
<p class="subtitle scene-text-holdout" data-i18n>NO PLACE LEFT BEHIND</p>
<p class="subtitle scene-text-holdout">{{ t('NO PLACE LEFT BEHIND') }}</p>
<div v-if="fps" class="frame-rate-readout scene-text-holdout" :hidden="!fps.visible" :title="t('Rendered globe frames per second · toggle with `')">{{ fps.text }}</div>
</div>
<!-- Style indicator -->
<div id="style-indicator">
<span class="indicator-label scene-text-holdout" data-i18n>ACTIVE STYLE</span>
<span class="indicator-label scene-text-holdout">{{ t('ACTIVE STYLE') }}</span>
<span class="indicator-value scene-text-holdout" id="active-style-name">{{ style ? style.label : t('NORMAL') }}</span>
</div>
+11 -11
View File
@@ -1,6 +1,6 @@
<script setup>
import { storeToRefs } from 'pinia';
import { msg } from '../../i18n/index.js';
import { msg, t } from '../../i18n/index.js';
import {
FIRST_RUN_TIP,
environmentalLabel,
@@ -32,23 +32,23 @@ function onSuppress(event) {
<aside v-if="!launcher?.removed" id="first-run-launcher" role="dialog" aria-labelledby="first-run-title" aria-describedby="first-run-description" :class="{ visible: launcher?.visible }" :hidden="!launcher?.shown" :aria-hidden="launcher?.closing ? 'true' : undefined" :aria-busy="launcher?.busy ?? undefined" :data-state="launcher?.state ?? undefined">
<div class="first-run-scanline" aria-hidden="true"></div>
<header class="first-run-header">
<span class="first-run-kicker" data-i18n>MISSION CONTROL · FIRST LAUNCH</span>
<span class="first-run-kicker">{{ t('MISSION CONTROL · FIRST LAUNCH') }}</span>
</header>
<h2 id="first-run-title" data-i18n>Choose your first view</h2>
<h2 id="first-run-title">{{ t('Choose your first view') }}</h2>
<!-- The card's one persuasive line, owner-authored and VERBATIM (2026-08-23)
— 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" data-i18n-runtime>{{ launcher?.description ?? DESCRIPTION }}</p>
<p id="first-run-description">{{ launcher?.description ?? DESCRIPTION }}</p>
<div class="first-run-choices ui-scroll" :data-scrollable="launcher?.scrollable ?? undefined">
<button type="button" data-first-run-choice="contacts" :aria-disabled="launcher?.busy ?? undefined" @click="controls?.choose('contacts')">
<span class="material-symbols-outlined" aria-hidden="true">radar</span>
<span><strong data-i18n>LIVE CONTACTS</strong><small data-i18n>Aircraft, vessels and nearby intelligence</small></span>
<span><strong>{{ t('LIVE CONTACTS') }}</strong><small>{{ t('Aircraft, vessels and nearby intelligence') }}</small></span>
<span class="material-symbols-outlined first-run-arrow" aria-hidden="true">arrow_forward</span>
</button>
<button type="button" data-first-run-choice="space-missions" :aria-disabled="launcher?.busy ?? undefined" @click="controls?.choose('space-missions')">
<span class="material-symbols-outlined" aria-hidden="true">rocket_launch</span>
<span><strong data-i18n>SPACE MISSIONS</strong><small data-i18n>Launches, spacecraft and orbital context</small></span>
<span><strong>{{ t('SPACE MISSIONS') }}</strong><small>{{ t('Launches, spacecraft and orbital context') }}</small></span>
<span class="material-symbols-outlined first-run-arrow" aria-hidden="true">arrow_forward</span>
</button>
<button type="button" data-first-run-choice="environmental" :aria-disabled="launcher?.busy ?? undefined" @click="controls?.choose('environmental')">
@@ -57,26 +57,26 @@ function onSuppress(event) {
<!-- 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 data-i18n-runtime><strong data-first-run-environmental-title>{{ launcher?.environmentalTitle ?? environmentalLabel().title }}</strong><small>{{ launcher?.environmentalSub ?? ENVIRONMENTAL_SUB }}</small></span>
<span><strong data-first-run-environmental-title>{{ launcher?.environmentalTitle ?? environmentalLabel().title }}</strong><small>{{ launcher?.environmentalSub ?? ENVIRONMENTAL_SUB }}</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" :aria-disabled="launcher?.busy ?? undefined" @click="controls?.choose('explore')">
<span class="material-symbols-outlined" aria-hidden="true">public</span>
<span><strong data-i18n>EXPLORE MANUALLY</strong><small data-i18n>Begin with a clean globe</small></span>
<span><strong>{{ t('EXPLORE MANUALLY') }}</strong><small>{{ t('Begin with a clean globe') }}</small></span>
<span class="material-symbols-outlined first-run-arrow" aria-hidden="true">arrow_forward</span>
</button>
</div>
<div class="first-run-footer">
<label class="first-run-suppress">
<input type="checkbox" data-first-run-suppress @change="onSuppress" />
<span data-i18n>Don't show this again</span>
<span>{{ t('Don\'t show this again') }}</span>
</label>
<!-- A button, not just a hint: phones and tablets have no ESC key. It
closes exactly as ESC does, for this session only. Touch screens
see DISMISS; a keyboard keeps the ESC hint (first-run.css). -->
<button type="button" class="first-run-dismiss" data-first-run-dismiss @click="controls?.dismiss()">
<span class="first-run-dismiss-key" data-i18n>ESC to dismiss</span>
<span class="first-run-dismiss-touch" data-i18n>DISMISS</span>
<span class="first-run-dismiss-key">{{ t('ESC to dismiss') }}</span>
<span class="first-run-dismiss-touch">{{ t('DISMISS') }}</span>
</button>
</div>
<!-- Kept out of the footer row: it swaps to mission progress and errors, and
+19 -19
View File
@@ -32,7 +32,7 @@ const frameData = computed(() =>
<RailPanel id="cctv-panel" start-collapsed :title="t('CCTV')" sideways scroll rail-scroller>
<div class="cyber-panel-body ui-scroll" data-rail-scroller>
<div id="cctv-frame-wrap" :class="['cctv-frame-wrap', { loading: frame?.wrapLoading, 'has-frame': frame?.hasFrame }]">
<span class="cctv-frame-caption" aria-hidden="true" data-i18n>ACQUIRING FRAME</span>
<span class="cctv-frame-caption" aria-hidden="true">{{ t('ACQUIRING FRAME') }}</span>
<img id="cctv-frame" alt="CCTV feed frame" :class="{ active: frame?.hasFrame }" :src="frame?.src || undefined" :hidden="live" v-bind="frameData" />
<canvas id="cctv-video" class="cctv-frame" :hidden="!live"></canvas>
</div>
@@ -40,15 +40,15 @@ const frameData = computed(() =>
<div id="cctv-meta">{{ view?.meta ?? t('Enable CCTV to load camera intersections') }}</div>
<div class="cctv-controls">
<UiButton class="scene-btn" :pressed="Boolean(view?.enabled)" pressed-class="active" id="cctv-enable-btn">{{ view?.enabled ? t('CCTV ON') : t('CCTV OFF') }}</UiButton>
<UiButton id="cctv-nearest-btn" type="button" class="gev-action-button scene-btn" :disabled="view?.navDisabled" data-i18n>NEAREST</UiButton>
<UiButton id="cctv-nearest-btn" type="button" class="gev-action-button scene-btn" :disabled="view?.navDisabled">{{ t('NEAREST') }}</UiButton>
</div>
<div class="cctv-controls">
<UiButton id="cctv-prev-btn" type="button" class="gev-action-button scene-btn" :disabled="view?.navDisabled" data-i18n>PREV</UiButton>
<CctvPicker class="gev-field" id="cctv-camera-select" aria-label="CCTV camera" data-i18n-aria-label />
<UiButton id="cctv-next-btn" type="button" class="gev-action-button scene-btn" :disabled="view?.navDisabled" data-i18n>NEXT</UiButton>
<UiButton id="cctv-prev-btn" type="button" class="gev-action-button scene-btn" :disabled="view?.navDisabled">{{ t('PREV') }}</UiButton>
<CctvPicker class="gev-field" id="cctv-camera-select" :aria-label="t('CCTV camera')" />
<UiButton id="cctv-next-btn" type="button" class="gev-action-button scene-btn" :disabled="view?.navDisabled">{{ t('NEXT') }}</UiButton>
</div>
<div class="cctv-controls">
<UiButton id="cctv-focus-btn" type="button" class="gev-action-button scene-btn" :disabled="view?.focusDisabled" data-i18n>FOCUS</UiButton>
<UiButton id="cctv-focus-btn" type="button" class="gev-action-button scene-btn" :disabled="view?.focusDisabled">{{ t('FOCUS') }}</UiButton>
<UiButton class="scene-btn" :pressed="Boolean(view?.coverage.active)" pressed-class="active" id="cctv-coverage-btn" :disabled="view?.toggleDisabled">{{ view?.coverage.text ?? t('COVERAGE OFF') }}</UiButton>
<UiButton class="scene-btn" :pressed="Boolean(view?.autoHop.active)" pressed-class="active" id="cctv-auto-hop-btn" :disabled="view?.toggleDisabled">{{ view?.autoHop.text ?? t('AUTO HOP OFF') }}</UiButton>
</div>
@@ -58,24 +58,24 @@ const frameData = computed(() =>
</div>
<div class="cctv-calibration-block">
<div class="cctv-calibration-header">
<div class="cctv-summary-label" data-i18n>CALIBRATION</div>
<UiButton class="scene-btn" :pressed="Boolean(view?.calibration.adjust.active)" pressed-class="active" id="cctv-adjust-btn" title="Drag the camera in the world: rings rotate, arrows move, handles set range/FOV" data-i18n-title :disabled="view?.calibration.disabled">{{ view?.calibration.adjust.text ?? t('ADJUST') }}</UiButton>
<div class="cctv-summary-label">{{ t('CALIBRATION') }}</div>
<UiButton class="scene-btn" :pressed="Boolean(view?.calibration.adjust.active)" pressed-class="active" id="cctv-adjust-btn" :title="t('Drag the camera in the world: rings rotate, arrows move, handles set range/FOV')" :disabled="view?.calibration.disabled">{{ view?.calibration.adjust.text ?? t('ADJUST') }}</UiButton>
</div>
<div id="cctv-cal-readout" class="cctv-cal-readout" aria-label="Camera pose — click a value to type" data-i18n-aria-label>
<CctvCalValue field="heading" label="HDG" title="Heading (compass °) — click to type" data-i18n-title />
<CctvCalValue field="pitch" label="PITCH" title="Pitch (° up/down) — click to type" data-i18n-title />
<CctvCalValue field="fov" label="FOV" title="Horizontal FOV (°) — click to type" data-i18n-title />
<CctvCalValue field="range" label="RANGE" title="Range / monitor-plane distance (m) — click to type" data-i18n-title />
<CctvCalValue field="height" label="HGT" title="Mount height above ground (m) — click to type" data-i18n-title />
<CctvCalValue field="north" label="ΔN" title="North offset from catalog position (m) — click to type" data-i18n-title />
<CctvCalValue field="east" label="ΔE" title="East offset from catalog position (m) — click to type" data-i18n-title />
<div id="cctv-cal-readout" class="cctv-cal-readout" :aria-label="t('Camera pose — click a value to type')">
<CctvCalValue field="heading" label="HDG" :title="t('Heading (compass °) — click to type')" />
<CctvCalValue field="pitch" label="PITCH" :title="t('Pitch (° up/down) — click to type')" />
<CctvCalValue field="fov" label="FOV" :title="t('Horizontal FOV (°) — click to type')" />
<CctvCalValue field="range" label="RANGE" :title="t('Range / monitor-plane distance (m) — click to type')" />
<CctvCalValue field="height" label="HGT" :title="t('Mount height above ground (m) — click to type')" />
<CctvCalValue field="north" label="ΔN" :title="t('North offset from catalog position (m) — click to type')" />
<CctvCalValue field="east" label="ΔE" :title="t('East offset from catalog position (m) — click to type')" />
</div>
<div class="cctv-controls cctv-calibration-actions">
<UiButton id="cctv-calib-save-btn" type="button" class="gev-action-button scene-btn" :disabled="view?.calibration.disabled" data-i18n>SAVE CAL</UiButton>
<UiButton id="cctv-calib-reset-btn" type="button" class="gev-action-button scene-btn" :disabled="view?.calibration.disabled" data-i18n>RESET CAL</UiButton>
<UiButton id="cctv-calib-save-btn" type="button" class="gev-action-button scene-btn" :disabled="view?.calibration.disabled">{{ t('SAVE CAL') }}</UiButton>
<UiButton id="cctv-calib-reset-btn" type="button" class="gev-action-button scene-btn" :disabled="view?.calibration.disabled">{{ t('RESET CAL') }}</UiButton>
</div>
</div>
<div class="cctv-summary-label" data-i18n>SCENE SUMMARY</div>
<div class="cctv-summary-label">{{ t('SCENE SUMMARY') }}</div>
<div id="cctv-summary">{{ summary ?? t('Enable CCTV to start camera-linked intelligence summaries.') }}</div>
</div>
</RailPanel>
+16 -16
View File
@@ -37,19 +37,19 @@ function leaveSignals() {
</script>
<template>
<aside id="cockpit-signal-stream" class="cockpit-signal-window" aria-label="Cockpit briefing carousel" :hidden="!presence?.active" data-i18n-aria-label v-bind="streamState" ref="stream">
<aside id="cockpit-signal-stream" class="cockpit-signal-window" :aria-label="t('Cockpit briefing carousel')" :hidden="!presence?.active" v-bind="streamState" ref="stream">
<CockpitRoute />
<header class="cockpit-card-header cockpit-signal-header">
<div class="cockpit-brief-heading">
<span id="cockpit-brief-kicker" class="cockpit-card-kicker cockpit-signal-kicker"><i aria-hidden="true"></i> {{ b.kicker }}</span>
<strong id="cockpit-brief-subtitle">{{ b.subtitle }}</strong>
</div>
<nav class="cockpit-card-actions cockpit-brief-actions" aria-label="Cockpit briefing controls" data-i18n-aria-label>
<button id="cockpit-brief-previous" type="button" aria-label="Previous briefing page" title="Previous briefing page" data-i18n-aria-label data-i18n-title>
<nav class="cockpit-card-actions cockpit-brief-actions" :aria-label="t('Cockpit briefing controls')">
<button id="cockpit-brief-previous" type="button" :aria-label="t('Previous briefing page')" :title="t('Previous briefing page')">
<span class="material-symbols-outlined" aria-hidden="true">chevron_left</span>
</button>
<span id="cockpit-brief-position" aria-live="polite">{{ b.position }}</span>
<button id="cockpit-brief-next" type="button" aria-label="Next briefing page" title="Next briefing page" data-i18n-aria-label data-i18n-title>
<button id="cockpit-brief-next" type="button" :aria-label="t('Next briefing page')" :title="t('Next briefing page')">
<span class="material-symbols-outlined" aria-hidden="true">chevron_right</span>
</button>
<UiButton id="cockpit-brief-auto" :aria-label="b.autoLabel" :pressed="Boolean(b.autoRotate)" :title="b.autoHelp">{{ b.autoLabel }}</UiButton>
@@ -59,33 +59,33 @@ function leaveSignals() {
</nav>
</header>
<div class="cockpit-brief-stage">
<section data-cockpit-brief-page="signals" aria-label="Live signals" data-i18n-aria-label :hidden="b.pageId !== 'signals'">
<section data-cockpit-brief-page="signals" :aria-label="t('Live signals')" :hidden="b.pageId !== 'signals'">
<CockpitSignalList :leave="leaveSignals" />
</section>
<section data-cockpit-brief-page="news" aria-label="Latest regional news" data-i18n-aria-label :hidden="b.pageId !== 'news'">
<section data-cockpit-brief-page="news" :aria-label="t('Latest regional news')" :hidden="b.pageId !== 'news'">
<div id="cockpit-news-status" class="cockpit-brief-status" :hidden="b.news.statusHidden" :data-state="b.news.state ?? undefined">{{ b.news.status }}</div>
<ol id="cockpit-news-list" class="cockpit-news-list" aria-live="polite"><li v-for="article in b.news.articles" :key="article.key"><a :href="article.url" target="_blank" rel="noopener noreferrer"><strong>{{ article.title }}</strong><span>{{ article.meta }}</span></a></li></ol>
</section>
<section data-cockpit-brief-page="local" aria-label="Location-based information" data-i18n-aria-label :hidden="b.pageId !== 'local'">
<section data-cockpit-brief-page="local" :aria-label="t('Location-based information')" :hidden="b.pageId !== 'local'">
<div class="cockpit-local-place">
<strong id="cockpit-local-place">{{ b.local.place }}</strong>
<small id="cockpit-local-coordinates">{{ b.local.coordinates }}</small>
</div>
<div class="cockpit-local-grid">
<div><span data-i18n>TEMP</span><strong id="cockpit-local-temperature">{{ b.local.temperature }}</strong><small translate="no">OPEN-METEO</small></div>
<div><span data-i18n>WIND</span><strong id="cockpit-local-wind">{{ b.local.wind }}</strong><small id="cockpit-local-wind-direction">{{ b.local.windDirection }}</small></div>
<div><span data-i18n>SKY</span><strong id="cockpit-local-condition">{{ b.local.condition }}</strong><small id="cockpit-local-cloud">{{ b.local.cloud }}</small></div>
<div><span data-i18n>PRECIP</span><strong id="cockpit-local-precipitation">{{ b.local.precipitation }}</strong><small data-i18n>MM</small></div>
<div><span>{{ t('TEMP') }}</span><strong id="cockpit-local-temperature">{{ b.local.temperature }}</strong><small translate="no">OPEN-METEO</small></div>
<div><span>{{ t('WIND') }}</span><strong id="cockpit-local-wind">{{ b.local.wind }}</strong><small id="cockpit-local-wind-direction">{{ b.local.windDirection }}</small></div>
<div><span>{{ t('SKY') }}</span><strong id="cockpit-local-condition">{{ b.local.condition }}</strong><small id="cockpit-local-cloud">{{ b.local.cloud }}</small></div>
<div><span>{{ t('PRECIP') }}</span><strong id="cockpit-local-precipitation">{{ b.local.precipitation }}</strong><small>{{ t('MM') }}</small></div>
</div>
<a class="cockpit-weather-credit" href="https://open-meteo.com/" target="_blank" rel="noopener" data-i18n>Weather data by Open-Meteo.com</a>
<a class="cockpit-weather-credit" href="https://open-meteo.com/" target="_blank" rel="noopener">{{ t('Weather data by Open-Meteo.com') }}</a>
</section>
</div>
<footer class="cockpit-signal-footer">
<span id="cockpit-brief-source">{{ b.source }}</span>
<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="String(b.pageIndex === 0)" data-i18n-aria-label data-i18n>SIG</button>
<button type="button" data-cockpit-brief-index="1" aria-label="Show Regional News" :aria-current="brief ? String(b.pageIndex === 1) : undefined" data-i18n-aria-label data-i18n>NEWS</button>
<button type="button" data-cockpit-brief-index="2" aria-label="Show Local Info" :aria-current="brief ? String(b.pageIndex === 2) : undefined" data-i18n-aria-label data-i18n>LOCAL</button>
<nav class="cockpit-brief-tabs" :aria-label="t('Cockpit briefing pages')">
<button type="button" data-cockpit-brief-index="0" :aria-label="t('Show Live Signals')" :aria-current="String(b.pageIndex === 0)">{{ t('SIG') }}</button>
<button type="button" data-cockpit-brief-index="1" :aria-label="t('Show Regional News')" :aria-current="brief ? String(b.pageIndex === 1) : undefined">{{ t('NEWS') }}</button>
<button type="button" data-cockpit-brief-index="2" :aria-label="t('Show Local Info')" :aria-current="brief ? String(b.pageIndex === 2) : undefined">{{ t('LOCAL') }}</button>
</nav>
</footer>
</aside>
+12 -12
View File
@@ -29,35 +29,35 @@ const weatherLabel = computed(() =>
</script>
<template>
<aside id="cockpit-context" class="cockpit-context-window" aria-label="Contact cockpit summary" :hidden="!presence?.context" data-i18n-aria-label :data-state="c.state ?? undefined" :data-collapsed="presence ? String(collapsed) : undefined">
<aside id="cockpit-context" class="cockpit-context-window" :aria-label="t('Contact cockpit summary')" :hidden="!presence?.context" :data-state="c.state ?? undefined" :data-collapsed="presence ? String(collapsed) : undefined">
<header class="cockpit-card-header cockpit-context-header">
<div>
<span data-i18n class="cockpit-card-kicker cockpit-context-kicker">CONTACT</span>
<span class="cockpit-card-kicker cockpit-context-kicker">{{ t('CONTACT') }}</span>
<strong id="cockpit-context-subject">{{ c.subject }}</strong>
</div>
<nav class="cockpit-card-actions cockpit-context-actions" aria-label="Contact navigation" data-i18n-aria-label>
<button id="cockpit-context-previous" type="button" :disabled="!c.canPrevious" aria-label="Previous — prior visited contact in the 250 km window" title="Previous — prior visited contact in the 250 km window" data-i18n-aria-label data-i18n-title>
<nav class="cockpit-card-actions cockpit-context-actions" :aria-label="t('Contact navigation')">
<button id="cockpit-context-previous" type="button" :disabled="!c.canPrevious" :aria-label="t('Previous — prior visited contact in the 250 km window')" :title="t('Previous — prior visited contact in the 250 km window')">
<span class="material-symbols-outlined" aria-hidden="true">skip_previous</span>
</button>
<button id="cockpit-context-next" type="button" :disabled="!c.canNext" aria-label="Next — nearest unvisited contact in the 250 km window" title="Next — nearest unvisited contact in the 250 km window" data-i18n-aria-label data-i18n-title>
<button id="cockpit-context-next" type="button" :disabled="!c.canNext" :aria-label="t('Next — nearest unvisited contact in the 250 km window')" :title="t('Next — nearest unvisited contact in the 250 km window')">
<span class="material-symbols-outlined" aria-hidden="true">skip_next</span>
</button>
<button id="cockpit-context-toggle" type="button" :aria-label="collapsed ? t('Expand Contact panel') : t('Collapse Contact panel')" :aria-expanded="String(!collapsed)" :title="collapsed ? t('Expand contact panel') : t('Collapse contact panel')">
<span class="material-symbols-outlined" aria-hidden="true">{{ collapsed ? 'chevron_right' : 'chevron_left' }}</span>
</button>
</nav>
<span class="cockpit-context-qualifier" data-i18n>CONTEXT ONLY</span>
<span class="cockpit-context-qualifier">{{ t('CONTEXT ONLY') }}</span>
</header>
<div class="cockpit-context-cohorts" role="list" aria-label="Nearby cohort counts" data-i18n-aria-label>
<div v-class-toggle="{ unknown: c.cohorts.flights.unknown }" role="listitem" data-context-cohort="flights"><span data-i18n>FLT</span><strong>{{ c.cohorts.flights.count }}</strong><small translate="no">OpenSky</small></div>
<div v-class-toggle="{ unknown: c.cohorts.military.unknown }" role="listitem" data-context-cohort="military"><span data-i18n>MIL</span><strong>{{ c.cohorts.military.count }}</strong><small translate="no">adsb.lol</small></div>
<div class="cockpit-context-cohorts" role="list" :aria-label="t('Nearby cohort counts')">
<div v-class-toggle="{ unknown: c.cohorts.flights.unknown }" role="listitem" data-context-cohort="flights"><span>{{ t('FLT') }}</span><strong>{{ c.cohorts.flights.count }}</strong><small translate="no">OpenSky</small></div>
<div v-class-toggle="{ unknown: c.cohorts.military.unknown }" role="listitem" data-context-cohort="military"><span>{{ t('MIL') }}</span><strong>{{ c.cohorts.military.count }}</strong><small translate="no">adsb.lol</small></div>
<div v-class-toggle="{ unknown: c.cohorts['ais-live-vessels'].unknown }" role="listitem" data-context-cohort="ais-live-vessels"><span translate="no">AIS</span><strong>{{ c.cohorts['ais-live-vessels'].count }}</strong><small translate="no">AISStream</small></div>
<div v-class-toggle="{ unknown: c.cohorts['military-installations'].unknown }" role="listitem" data-context-cohort="military-installations"><span data-i18n>SITE</span><strong>{{ c.cohorts['military-installations'].count }}</strong><small translate="no">OpenStreetMap</small></div>
<div v-class-toggle="{ unknown: c.cohorts['military-installations'].unknown }" role="listitem" data-context-cohort="military-installations"><span>{{ t('SITE') }}</span><strong>{{ c.cohorts['military-installations'].count }}</strong><small translate="no">OpenStreetMap</small></div>
</div>
<div class="cockpit-context-nearest">
<span id="cockpit-context-direction" v-class-toggle="{ unknown: c.direction.unknown }" class="material-symbols-outlined" :style="directionStyle" aria-hidden="true">navigation</span>
<div>
<small data-i18n>NEAREST OBSERVED / MAPPED</small>
<small>{{ t('NEAREST OBSERVED / MAPPED') }}</small>
<strong id="cockpit-context-nearest-label" :aria-label="c.nearest.ariaLabel ?? undefined">{{ c.nearest.label }}</strong>
</div>
<span id="cockpit-context-bearing">{{ c.bearing }}</span>
@@ -66,7 +66,7 @@ const weatherLabel = computed(() =>
<footer class="cockpit-context-footer">
<span id="cockpit-context-uncertainty">{{ c.uncertainty }}</span>
<UiButton id="cockpit-weather-toggle" class="cockpit-context-weather" :aria-label="weatherLabel" :pressed="Boolean(c.weather)" :title="weatherLabel">
<span data-i18n>WX</span>
<span>{{ t('WX') }}</span>
<strong id="cockpit-weather-state">{{ c.weather ? t('ON') : t('OFF') }}</strong>
</UiButton>
<time id="cockpit-context-updated">{{ c.updated }}</time>
+5 -4
View File
@@ -1,6 +1,7 @@
<script setup>
import { storeToRefs } from 'pinia';
import { computed } from 'vue';
import { t } from '../../../i18n/index.js';
import { createRouteState } from '../../cockpitInstrumentState.js';
import { useCockpitStore } from '../../stores/cockpit.js';
@@ -13,19 +14,19 @@ const r = computed(() => route.value ?? initial);
</script>
<template>
<section id="cockpit-route" class="cockpit-route-card" aria-label="Estimated flight plan" data-i18n-aria-label :hidden="!r.visible">
<section id="cockpit-route" class="cockpit-route-card" :aria-label="t('Estimated flight plan')" :hidden="!r.visible">
<div class="cockpit-route-heading">
<span class="cockpit-route-kicker" data-i18n>ESTIMATED FLIGHT PLAN</span>
<span class="cockpit-route-kicker">{{ t('ESTIMATED FLIGHT PLAN') }}</span>
<small id="cockpit-route-status">{{ r.status }}</small>
</div>
<div class="cockpit-route-endpoints">
<div class="cockpit-route-airport">
<span data-i18n>FROM</span>
<span>{{ t('FROM') }}</span>
<strong id="cockpit-route-from">{{ r.from }}</strong>
</div>
<span class="cockpit-route-arrow material-symbols-outlined" aria-hidden="true">east</span>
<div class="cockpit-route-airport">
<span data-i18n>TO</span>
<span>{{ t('TO') }}</span>
<strong id="cockpit-route-to">{{ r.to }}</strong>
</div>
</div>
@@ -47,14 +47,14 @@ const radioLabel = computed(() =>
</script>
<template>
<aside id="cockpit-utility-controls" class="cockpit-utility-controls" v-class-toggle="{ 'layout-primary-only': !!layout?.primaryOnly }" aria-label="Cockpit display and Radio controls" data-i18n-aria-label>
<aside id="cockpit-utility-controls" class="cockpit-utility-controls" v-class-toggle="{ 'layout-primary-only': !!layout?.primaryOnly }" :aria-label="t('Cockpit display and Radio controls')">
<div class="cockpit-utility-control ui-scroll" v-class-toggle="{ 'is-expanded': open.display }" :aria-hidden="ariaHidden(open.display)">
<div class="cockpit-utility-launcher" data-cockpit-launcher="display">
<span data-i18n>DISPLAY</span>
<span>{{ t('DISPLAY') }}</span>
<span class="cockpit-utility-divider" aria-hidden="true"></span>
<button id="cockpit-display-toggle-btn" class="cockpit-utility-glyph" type="button" :aria-expanded="String(open.display)" aria-controls="cockpit-display-panel" :aria-label="displayLabel" :title="displayLabel">{{ open.display ? '▶' : '◀' }}</button>
</div>
<div id="cockpit-display-panel" class="cockpit-utility-popover ui-scroll" v-class-toggle="{ 'uses-shared-display-controls': !!cockpitPortal }" role="group" aria-label="Cockpit display options" :hidden="!open.display" data-i18n-aria-label>
<div id="cockpit-display-panel" class="cockpit-utility-popover ui-scroll" v-class-toggle="{ 'uses-shared-display-controls': !!cockpitPortal }" role="group" :aria-label="t('Cockpit display options')" :hidden="!open.display">
<div class="cockpit-display-slot" data-cockpit-display-slot="hud"></div>
<div class="cockpit-display-slot" data-cockpit-display-slot="detection"></div>
<div class="cockpit-display-slot" data-cockpit-display-slot="parameters"></div>
@@ -63,21 +63,21 @@ const radioLabel = computed(() =>
</div>
<div class="cockpit-utility-control ui-scroll" v-class-toggle="{ 'is-expanded': open.radio }" :aria-hidden="ariaHidden(open.radio)">
<div class="cockpit-utility-launcher" data-cockpit-launcher="radio">
<span data-i18n>RADIO</span>
<span>{{ t('RADIO') }}</span>
<span class="cockpit-utility-divider" aria-hidden="true"></span>
<button id="cockpit-radio-toggle-btn" @click="act('toggleCockpitRadio')" class="cockpit-utility-glyph" type="button" :aria-expanded="String(open.radio)" aria-controls="cockpit-radio-panel" :aria-label="radioLabel" :title="radioLabel">{{ open.radio ? '▶' : '◀' }}</button>
</div>
<div id="cockpit-radio-panel" class="cockpit-utility-popover cockpit-radio-compact" role="group" aria-label="Cockpit compact Radio controls" :hidden="!open.radio" data-i18n-aria-label>
<div id="cockpit-radio-panel" class="cockpit-utility-popover cockpit-radio-compact" role="group" :aria-label="t('Cockpit compact Radio controls')" :hidden="!open.radio">
<strong id="cockpit-radio-station" class="cockpit-radio-station">{{ cockpit ? cockpit.station : t('READY') }}</strong>
<UiButton id="cockpit-radio-enable-btn" @click="act('toggleRadio', 'cockpit')" class="panel-layer-toggle" :pressed="cockpit?.enable.active ?? false" pressed-class="active" :aria-label="cockpit?.enable.label" :aria-disabled="cockpit ? String(cockpit.enable.busy) : undefined" :aria-busy="cockpit ? String(cockpit.enable.busy) : undefined">{{ cockpit ? cockpit.enable.text : t('ENABLE') }}</UiButton>
<div class="cockpit-radio-transport">
<button id="cockpit-radio-prev-btn" @click="act('cycleRadio', -1, { rotate: false })" type="button" aria-label="Previous filtered radio station" :disabled="radio ? radio.transport.stepDisabled : true" data-i18n-aria-label>◀◀</button>
<button id="cockpit-radio-prev-btn" @click="act('cycleRadio', -1, { rotate: false })" type="button" :aria-label="t('Previous filtered radio station')" :disabled="radio ? radio.transport.stepDisabled : true">◀◀</button>
<button id="cockpit-radio-play-btn" @click="act('toggleRadioPlayback')" type="button" v-class-toggle="{ active: radio?.transport.play.active }" :aria-label="radio ? radio.transport.play.label : t('Play selected radio station')" :title="cockpit?.play.title" :disabled="radio ? radio.transport.play.disabled : true">{{ cockpit ? cockpit.play.glyph : '▶' }}</button>
<button id="cockpit-radio-next-btn" @click="act('cycleRadio', 1, { rotate: false })" type="button" aria-label="Next filtered radio station" :disabled="radio ? radio.transport.stepDisabled : true" data-i18n-aria-label>▶▶</button>
<button id="cockpit-radio-next-btn" @click="act('cycleRadio', 1, { rotate: false })" type="button" :aria-label="t('Next filtered radio station')" :disabled="radio ? radio.transport.stepDisabled : true">▶▶</button>
</div>
<label class="cockpit-radio-volume" for="cockpit-radio-volume">
<span data-i18n>VOLUME</span>
<UiSlider id="cockpit-radio-volume" hold @update:model-value="act('setRadioVolume', $event)" min="0" max="100" aria-label="Cockpit Radio volume" :disabled="cockpit ? cockpit.volume.disabled : true" :model-value="cockpit ? cockpit.volume.value : 80" data-i18n-aria-label />
<span>{{ t('VOLUME') }}</span>
<UiSlider id="cockpit-radio-volume" hold @update:model-value="act('setRadioVolume', $event)" min="0" max="100" :aria-label="t('Cockpit Radio volume')" :disabled="cockpit ? cockpit.volume.disabled : true" :model-value="cockpit ? cockpit.volume.value : 80" />
<output id="cockpit-radio-volume-value" class="gev-slider-value" for="cockpit-radio-volume">{{ cockpit ? `${cockpit.volume.value}%` : '80%' }}</output>
</label>
</div>
+54 -54
View File
@@ -59,7 +59,7 @@ const allocationOptions = [
<!-- Post-processing toggles (top-right) -->
<div id="pp-toggles" v-panel-state="'pp-toggles'" class="panel-collapsible collapsed ui-scroll" data-panel-id="pp-toggles">
<div class="compact pp-header-row">
<span data-i18n class="pp-header-label">DISPLAY</span>
<span class="pp-header-label">{{ t('DISPLAY') }}</span>
<span class="panel-divider" aria-hidden="true"></span>
<PanelCollapseButton class="pp-collapse-btn" target="pp-toggles" :name="t('DISPLAY')" sideways start-collapsed />
</div>
@@ -67,73 +67,73 @@ const allocationOptions = [
<!-- Cockpit shows these four groups in its Display popover: each teleports to the slot the Cockpit display portal publishes. While disabled a Teleport stays in place; its 'body' target only keeps server rendering from dropping it. -->
<Teleport :to="cockpitPortal?.hud ?? 'body'" :disabled="!cockpitPortal?.hud">
<div class="pp-toggle-group">
<button class="pp-toggle-btn" :class="{ active: hud?.visible }" id="hud-toggle" @click="act('toggleHud')" title="Intelligence HUD (H)" data-i18n-title>
<button class="pp-toggle-btn" :class="{ active: hud?.visible }" id="hud-toggle" @click="act('toggleHud')" :title="t('Intelligence HUD (H)')">
<span class="pp-icon">&#x25CE;</span>
<span class="pp-label" translate="no">HUD</span>
</button>
<div class="pp-slider-row" :class="{ visible: hud?.visible }" id="hud-layout-row">
<span class="pp-slider-mini-label" data-i18n>Layout</span>
<UiSelect id="hud-layout-select" class="pp-select gev-field gev-field--compact" :model-value="hud?.variant ?? 'tactical'" @update:model-value="act('setHudLayout', $event)" aria-label="HUD layout" data-i18n-aria-label>
<option value="tactical" data-i18n>Tactical</option>
<option value="minimal" data-i18n>Minimal</option>
<option value="cyber" data-i18n>Cyber</option>
<span class="pp-slider-mini-label">{{ t('Layout') }}</span>
<UiSelect id="hud-layout-select" class="pp-select gev-field gev-field--compact" :model-value="hud?.variant ?? 'tactical'" @update:model-value="act('setHudLayout', $event)" :aria-label="t('HUD layout')">
<option value="tactical">{{ t('Tactical') }}</option>
<option value="minimal">{{ t('Minimal') }}</option>
<option value="cyber">{{ t('Cyber') }}</option>
</UiSelect>
</div>
<div class="pp-slider-row cyber-sonar-control cyber-sonar-toggle-row" id="cyber-sonar-row">
<span class="pp-slider-mini-label" data-i18n>Sonar</span>
<UiButton class="pp-mode-btn" :pressed="sonarOn" pressed-class="active" id="cyber-sonar-toggle" @click="act('toggleSonar')" title="Cyber sonar contact scanner" aria-label="Cyber sonar contact scanner" data-i18n-title data-i18n-aria-label>{{ sonarOn ? t('ON') : t('OFF') }}</UiButton>
<span class="pp-slider-mini-label">{{ t('Sonar') }}</span>
<UiButton class="pp-mode-btn" :pressed="sonarOn" pressed-class="active" id="cyber-sonar-toggle" @click="act('toggleSonar')" :title="t('Cyber sonar contact scanner')" :aria-label="t('Cyber sonar contact scanner')">{{ sonarOn ? t('ON') : t('OFF') }}</UiButton>
</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" data-i18n>Rings</label>
<UiSlider class="pp-slider" id="cyber-sonar-rings" @update:model-value="act('setSonarRings', $event)" min="3" max="12" step="1" :model-value="sonar?.rings ?? 8" aria-label="Sonar ring count" data-i18n-aria-label />
<label class="pp-slider-mini-label" for="cyber-sonar-rings">{{ t('Rings') }}</label>
<UiSlider class="pp-slider" id="cyber-sonar-rings" @update:model-value="act('setSonarRings', $event)" min="3" max="12" step="1" :model-value="sonar?.rings ?? 8" :aria-label="t('Sonar ring count')" />
<span class="pp-slider-value" id="cyber-sonar-rings-value">{{ sonar?.rings ?? 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" data-i18n>Range</label>
<UiSlider class="pp-slider" id="cyber-sonar-range" @update:model-value="act('setSonarRange', $event)" min="60" max="120" step="1" :model-value="sonar?.range ?? 100" aria-label="Sonar ring range" data-i18n-aria-label />
<label class="pp-slider-mini-label" for="cyber-sonar-range">{{ t('Range') }}</label>
<UiSlider class="pp-slider" id="cyber-sonar-range" @update:model-value="act('setSonarRange', $event)" min="60" max="120" step="1" :model-value="sonar?.range ?? 100" :aria-label="t('Sonar ring range')" />
<span class="pp-slider-value" id="cyber-sonar-range-value">{{ pct(sonar?.range ?? 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" data-i18n>Power</label>
<UiSlider class="pp-slider" id="cyber-sonar-intensity" @update:model-value="act('setSonarIntensity', $event)" min="0" max="100" step="1" :model-value="sonar?.intensity ?? 70" aria-label="Sonar intensity" data-i18n-aria-label />
<label class="pp-slider-mini-label" for="cyber-sonar-intensity">{{ t('Power') }}</label>
<UiSlider class="pp-slider" id="cyber-sonar-intensity" @update:model-value="act('setSonarIntensity', $event)" min="0" max="100" step="1" :model-value="sonar?.intensity ?? 70" :aria-label="t('Sonar intensity')" />
<span class="pp-slider-value" id="cyber-sonar-intensity-value">{{ pct(sonar?.intensity ?? 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" data-i18n>Opacity</label>
<UiSlider class="pp-slider" id="cyber-sonar-opacity" @update:model-value="act('setSonarOpacity', $event)" min="35" max="100" step="1" :model-value="sonar?.opacity ?? 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 />
<label class="pp-slider-mini-label" for="cyber-sonar-opacity">{{ t('Opacity') }}</label>
<UiSlider class="pp-slider" id="cyber-sonar-opacity" @update:model-value="act('setSonarOpacity', $event)" min="35" max="100" step="1" :model-value="sonar?.opacity ?? 84" :aria-label="t('Cyber contact and label opacity')" :title="t('Minimum opacity for map dots, icons, models, brackets, and labels between sonar passes')" />
<span class="pp-slider-value" id="cyber-sonar-opacity-value">{{ pct(sonar?.opacity ?? 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" data-i18n>Sector</label>
<UiSlider class="pp-slider" id="cyber-sonar-sector" @update:model-value="act('setSonarSector', $event)" min="8" max="60" step="1" :model-value="sonar?.sector ?? 24" aria-label="Sonar sector width" data-i18n-aria-label />
<label class="pp-slider-mini-label" for="cyber-sonar-sector">{{ t('Sector') }}</label>
<UiSlider class="pp-slider" id="cyber-sonar-sector" @update:model-value="act('setSonarSector', $event)" min="8" max="60" step="1" :model-value="sonar?.sector ?? 24" :aria-label="t('Sonar sector width')" />
<span class="pp-slider-value" id="cyber-sonar-sector-value">{{ `${sonar?.sector ?? 24}°` }}</span>
</div>
</div>
</Teleport>
<Teleport :to="cockpitPortal?.detection ?? 'body'" :disabled="!cockpitPortal?.detection">
<div class="pp-toggle-group">
<UiButton class="pp-toggle-btn" :class="{ active: detectButton?.active, panoptic: detectButton?.panoptic }" :pressed="detectButton?.pressed ?? false" id="detection-toggle" @click="act('cycleDetection')" title="Detection Overlay (D)" :aria-label="detectButton?.ariaLabel ?? t('Detection overlay')" data-i18n-title>
<UiButton class="pp-toggle-btn" :class="{ active: detectButton?.active, panoptic: detectButton?.panoptic }" :pressed="detectButton?.pressed ?? false" id="detection-toggle" @click="act('cycleDetection')" :title="t('Detection Overlay (D)')" :aria-label="detectButton?.ariaLabel ?? t('Detection overlay')">
<span class="pp-icon">&#x25A3;</span>
<span class="pp-label">{{ detectButton?.label ?? t('DETECT') }}</span>
</UiButton>
<div class="pp-slider-row detection-slider-row" :class="{ visible: detection?.rowsVisible }" id="detection-slider-row">
<label class="pp-slider-mini-label" for="detection-density-slider" data-i18n>Density</label>
<UiSlider class="pp-slider" id="detection-density-slider" @update:model-value="act('setDensity', $event)" min="0" max="100" step="25" :model-value="detection?.densityPct ?? 50" aria-label="Detection label density" data-i18n-aria-label />
<label class="pp-slider-mini-label" for="detection-density-slider">{{ t('Density') }}</label>
<UiSlider class="pp-slider" id="detection-density-slider" @update:model-value="act('setDensity', $event)" min="0" max="100" step="25" :model-value="detection?.densityPct ?? 50" :aria-label="t('Detection label density')" />
<span class="pp-slider-value" id="detection-density-value">{{ pct(detection?.densityPct ?? 50) }}</span>
</div>
<div class="pp-slider-row detection-allocation-row" :class="{ visible: detection?.rowsVisible }" id="detection-allocation-row">
<span class="pp-slider-mini-label" data-i18n>Allocation</span>
<span class="pp-slider-mini-label">{{ t('Allocation') }}</span>
<UiSegmented class="pp-mode-seg detection-allocation-seg" item-class="pp-mode-btn" keyboard="none" :label="t('Detection label allocation')" :options="allocationOptions" :model-value="allocation" @update:model-value="act('setAllocation', $event)" />
</div>
<div class="pp-slider-row detection-fade-row" :class="{ visible: detection?.rowsVisible }" id="detection-fade-row">
<label class="pp-slider-mini-label" for="detection-fade-slider" data-i18n>Fade</label>
<UiSlider class="pp-slider" id="detection-fade-slider" @update:model-value="act('setFade', $event)" min="0" max="40" step="1" :model-value="detection?.fadePct ?? 7" aria-label="Detection fade distance" title="World-overlay fade distance outside the keyhole as a percentage of its radius" data-i18n-aria-label data-i18n-title />
<label class="pp-slider-mini-label" for="detection-fade-slider">{{ t('Fade') }}</label>
<UiSlider class="pp-slider" id="detection-fade-slider" @update:model-value="act('setFade', $event)" min="0" max="40" step="1" :model-value="detection?.fadePct ?? 7" :aria-label="t('Detection fade distance')" :title="t('World-overlay fade distance outside the keyhole as a percentage of its radius')" />
<span class="pp-slider-value" id="detection-fade-value">{{ pct(detection?.fadePct ?? 7) }}</span>
</div>
<div class="pp-slider-row detection-opacity-row" :class="{ visible: detection?.rowsVisible }" id="detection-opacity-row">
<label class="pp-slider-mini-label" for="detection-opacity-slider" data-i18n>Outside</label>
<UiSlider class="pp-slider" id="detection-opacity-slider" @update:model-value="act('setOutsideOpacity', $event)" min="0" max="100" step="1" :model-value="detection?.outsideOpacityPct ?? 1" aria-label="Detection opacity outside the keyhole" title="World-overlay label and card opacity beyond the fade distance" data-i18n-aria-label data-i18n-title />
<label class="pp-slider-mini-label" for="detection-opacity-slider">{{ t('Outside') }}</label>
<UiSlider class="pp-slider" id="detection-opacity-slider" @update:model-value="act('setOutsideOpacity', $event)" min="0" max="100" step="1" :model-value="detection?.outsideOpacityPct ?? 1" :aria-label="t('Detection opacity outside the keyhole')" :title="t('World-overlay label and card opacity beyond the fade distance')" />
<span class="pp-slider-value" id="detection-opacity-value">{{ pct(detection?.outsideOpacityPct ?? 1) }}</span>
</div>
</div>
@@ -142,7 +142,7 @@ const allocationOptions = [
<Teleport :to="cockpitPortal?.parameters ?? 'body'" :disabled="!cockpitPortal?.parameters">
<div id="param-slider-panel" v-panel-state="'param-slider-panel'" v-class-toggle="{ active: !!parameters }">
<div class="param-panel-header">
<span class="param-panel-title" data-i18n>PARAMETERS</span>
<span class="param-panel-title">{{ t('PARAMETERS') }}</span>
<span class="param-panel-divider"></span>
<PanelCollapseButton id="param-panel-collapse-btn" target="param-slider-panel" />
</div>
@@ -155,47 +155,47 @@ const allocationOptions = [
button ships lit and the mode row ships open — the same markup-carries-the-default
convention as #scope-toggle. ui.js re-syncs both from durable layer state, which
is what flips them back for a share link or a stored session carrying 3D off. -->
<UiButton class="pp-toggle-btn" :pressed="models3dOn" pressed-class="active" id="models3d-toggle" @click="act('toggleModels')" title="3D aircraft — flat icons zoomed out, 3D models up close" data-i18n-title>
<UiButton class="pp-toggle-btn" :pressed="models3dOn" pressed-class="active" id="models3d-toggle" @click="act('toggleModels')" :title="t('3D aircraft — flat icons zoomed out, 3D models up close')">
<span class="pp-icon">&#x2708;</span>
<span class="pp-label">3D</span>
</UiButton>
<div class="pp-slider-row" :class="{ visible: models3d?.rowVisible ?? true }" id="models3d-mode-row">
<span class="pp-slider-mini-label" data-i18n>Models</span>
<span class="pp-slider-mini-label">{{ t('Models') }}</span>
<UiSegmented class="pp-mode-seg" item-class="pp-mode-btn" keyboard="none" :label="t('3D model coverage')" :options="modelsOptions" :model-value="modelsMode" @update:model-value="act('setModelsMode', $event)" />
</div>
</div>
</Teleport>
<div class="pp-toggle-group">
<label class="pp-toggle-btn pp-scope-toggle" title="Scope — the circular viewport mask" data-i18n-title>
<label class="pp-toggle-btn pp-scope-toggle" :title="t('Scope — the circular viewport mask')">
<span class="pp-icon material-symbols-outlined" aria-hidden="true">adjust</span>
<span class="pp-label" data-i18n>Scope</span>
<span class="pp-label">{{ t('Scope') }}</span>
<input type="checkbox" id="scope-toggle" @change="act('setScopeEnabled', $event.target.checked)" :checked="scopeOn" />
</label>
<div class="pp-slider-row visible" id="scope-slider-row">
<span class="pp-slider-mini-label" data-i18n>Feather</span>
<UiSlider class="pp-slider" id="scope-feather-slider" @update:model-value="act('setScopeFeather', $event)" min="0" max="200" :model-value="scopeFeather" :disabled="!scopeOn" aria-label="Scope edge feather" title="Scope edge feather as a percentage of the keyhole radius" data-i18n-aria-label data-i18n-title />
<span class="pp-slider-mini-label">{{ t('Feather') }}</span>
<UiSlider class="pp-slider" id="scope-feather-slider" @update:model-value="act('setScopeFeather', $event)" min="0" max="200" :model-value="scopeFeather" :disabled="!scopeOn" :aria-label="t('Scope edge feather')" :title="t('Scope edge feather as a percentage of the keyhole radius')" />
<span class="pp-slider-value" id="scope-feather-value">{{ pct(scopeFeather) }}</span>
</div>
</div>
<div class="pp-toggle-group">
<UiButton class="pp-toggle-btn" :pressed="draw?.active ?? false" pressed-class="active" id="draw-toggle" @click="act('toggleDraw')" title="Draw on the world — click vertices, double-click or Enter to finish, Esc to cancel" data-i18n-title>
<UiButton class="pp-toggle-btn" :pressed="draw?.active ?? false" pressed-class="active" id="draw-toggle" @click="act('toggleDraw')" :title="t('Draw on the world — click vertices, double-click or Enter to finish, Esc to cancel')">
<span class="pp-icon material-symbols-outlined" aria-hidden="true">draw</span>
<span class="pp-label" data-i18n>Draw</span>
<span class="pp-label">{{ t('Draw') }}</span>
</UiButton>
<div class="pp-slider-row" :class="{ visible: draw?.active }" id="draw-mode-row">
<span class="pp-slider-mini-label" data-i18n>Shape</span>
<span class="pp-slider-mini-label">{{ t('Shape') }}</span>
<UiSegmented class="pp-mode-seg" item-class="pp-mode-btn" keyboard="none" :label="t('Shape to draw')" :options="drawShapeOptions" :model-value="drawShape" @update:model-value="act('setDrawShape', $event)" />
</div>
<div class="pp-slider-row" :class="{ visible: draw?.active }" id="draw-label-row">
<input type="text" class="pp-text-input" id="draw-label-input" v-range-value="draw?.label ?? ''" @input="act('setDrawLabel', $event.target.value)" maxlength="120" placeholder="Label (optional)" aria-label="Label for the drawn shape" data-i18n-placeholder data-i18n-aria-label autocomplete="off" spellcheck="false">
<UiSelect class="pp-select draw-color-select gev-field gev-field--compact" id="draw-color-select" :model-value="draw?.color ?? 'primary'" @update:model-value="act('setDrawColor', $event)" aria-label="Colour of the drawn shape" data-i18n-aria-label>
<option value="primary" data-i18n>Primary</option>
<option value="amber" data-i18n>Amber</option>
<option value="cyan" data-i18n>Cyan</option>
<option value="green" data-i18n>Green</option>
<option value="red" data-i18n>Red</option>
<input type="text" class="pp-text-input" id="draw-label-input" v-range-value="draw?.label ?? ''" @input="act('setDrawLabel', $event.target.value)" maxlength="120" :placeholder="t('Label (optional)')" :aria-label="t('Label for the drawn shape')" autocomplete="off" spellcheck="false">
<UiSelect class="pp-select draw-color-select gev-field gev-field--compact" id="draw-color-select" :model-value="draw?.color ?? 'primary'" @update:model-value="act('setDrawColor', $event)" :aria-label="t('Colour of the drawn shape')">
<option value="primary">{{ t('Primary') }}</option>
<option value="amber">{{ t('Amber') }}</option>
<option value="cyan">{{ t('Cyan') }}</option>
<option value="green">{{ t('Green') }}</option>
<option value="red">{{ t('Red') }}</option>
</UiSelect>
<button class="pp-mode-btn draw-clear-btn" id="draw-clear" type="button" @click="act('clearDraw')" title="Remove every mark from the board" data-i18n-title data-i18n>Clear</button>
<button class="pp-mode-btn draw-clear-btn" id="draw-clear" type="button" @click="act('clearDraw')" :title="t('Remove every mark from the board')">{{ t('Clear') }}</button>
<span class="draw-hint" id="draw-hint" aria-live="polite">{{ draw?.hint ?? '' }}</span>
</div>
</div>
@@ -203,30 +203,30 @@ const allocationOptions = [
<div class="pp-toggle-group">
<UiButton class="pp-toggle-btn" :pressed="celestial?.active ?? false" pressed-class="active" id="celestial-toggle" @click="act('toggleCelestial')" :title="celestialTitle" :disabled="celestial ? !celestial.supported : undefined" :aria-disabled="celestial ? String(!celestial.supported) : undefined">
<span class="pp-icon material-symbols-outlined" aria-hidden="true">flare</span>
<span class="pp-label" data-i18n>Celestial</span>
<span class="pp-label">{{ t('Celestial') }}</span>
</UiButton>
</div>
<button class="pp-toggle-btn" id="clean-view-toggle" @click="act('toggleCleanView')" title="Hide UI chrome" data-i18n-title>
<button class="pp-toggle-btn" id="clean-view-toggle" @click="act('toggleCleanView')" :title="t('Hide UI chrome')">
<span class="pp-icon">&#x25A1;</span>
<span class="pp-label" data-i18n>Clean UI</span>
<span class="pp-label">{{ t('Clean UI') }}</span>
</button>
<div class="pp-toggle-group">
<button class="pp-toggle-btn" :class="{ active: view?.bloom.active }" id="bloom-toggle" @click="act('toggleBloom')" title="Bloom / Glow" data-i18n-title>
<button class="pp-toggle-btn" :class="{ active: view?.bloom.active }" id="bloom-toggle" @click="act('toggleBloom')" :title="t('Bloom / Glow')">
<span class="pp-icon">&#x2728;</span>
<span class="pp-label" data-i18n>Bloom</span>
<span class="pp-label">{{ t('Bloom') }}</span>
</button>
<div class="pp-slider-row" :class="{ visible: view?.bloom.active }" id="bloom-slider-row">
<UiSlider class="pp-slider" id="bloom-intensity-slider" @update:model-value="act('setBloomIntensity', $event)" min="0" max="200" :model-value="view?.bloom.intensity ?? 0" aria-label="Bloom intensity" data-i18n-aria-label />
<UiSlider class="pp-slider" id="bloom-intensity-slider" @update:model-value="act('setBloomIntensity', $event)" min="0" max="200" :model-value="view?.bloom.intensity ?? 0" :aria-label="t('Bloom intensity')" />
<span class="pp-slider-value" id="bloom-intensity-value">{{ pct(view?.bloom.intensity ?? 0) }}</span>
</div>
</div>
<div class="pp-toggle-group">
<button class="pp-toggle-btn" :class="{ active: view?.sharpen.active }" id="sharpen-toggle" @click="act('toggleSharpen')" title="Sharpening" data-i18n-title>
<button class="pp-toggle-btn" :class="{ active: view?.sharpen.active }" id="sharpen-toggle" @click="act('toggleSharpen')" :title="t('Sharpening')">
<span class="pp-icon">&#x1F50D;</span>
<span class="pp-label" data-i18n>Sharpen</span>
<span class="pp-label">{{ t('Sharpen') }}</span>
</button>
<div class="pp-slider-row" :class="{ visible: view?.sharpen.active }" id="sharpen-slider-row">
<UiSlider class="pp-slider" id="sharpen-intensity-slider" @update:model-value="act('setSharpenIntensity', $event)" min="0" max="100" :model-value="view?.sharpen.intensity ?? 49" aria-label="Sharpen intensity" data-i18n-aria-label />
<UiSlider class="pp-slider" id="sharpen-intensity-slider" @update:model-value="act('setSharpenIntensity', $event)" min="0" max="100" :model-value="view?.sharpen.intensity ?? 49" :aria-label="t('Sharpen intensity')" />
<span class="pp-slider-value" id="sharpen-intensity-value">{{ pct(view?.sharpen.intensity ?? 49) }}</span>
</div>
</div>
+5 -4
View File
@@ -1,6 +1,7 @@
<script setup>
import { storeToRefs } from 'pinia';
import { computed, ref } from 'vue';
import { t } from '../../../i18n/index.js';
import { vFocusSafeChildren } from '../../rail/focusSafeChildren.js';
import { useDisplayStore } from '../../stores/display.js';
@@ -57,12 +58,12 @@ function leave() {
<template>
<div class="pp-toggle-group" id="geo-import-group">
<button ref="importButton" class="pp-toggle-btn" id="geo-import-button" type="button" @click="openPicker" title="Show your own GeoJSON, KML, KMZ or GPX files on the globe (or drop them onto it). File bytes stay on this device; overlays can appear in map screenshots shared with voice." data-i18n-title>
<button ref="importButton" class="pp-toggle-btn" id="geo-import-button" type="button" @click="openPicker" :title="t('Show your own GeoJSON, KML, KMZ or GPX files on the globe (or drop them onto it). File bytes stay on this device; overlays can appear in map screenshots shared with voice.')">
<span class="pp-icon material-symbols-outlined" aria-hidden="true">upload_file</span>
<span class="pp-label" data-i18n>Import</span>
<span class="pp-label">{{ t('Import') }}</span>
</button>
<input ref="picker" type="file" id="geo-import-input" accept=".geojson,.json,.kml,.kmz,.gpx" multiple hidden @change="pick" aria-label="Choose GeoJSON, KML, KMZ or GPX files" data-i18n-aria-label>
<ul class="geo-import-list" :class="{ visible: listed }" id="geo-import-list" v-focus-safe-children="leave" aria-label="Imported files" data-i18n-aria-label><li v-for="row in rows" :key="row.id" class="geo-import-row" :data-import-id="row.id"><span :class="['geo-import-swatch', { 'own-styles': row.ownStyles }]" :style="row.ownStyles ? null : { background: row.color }"></span><span class="geo-import-name" :title="row.title">{{ row.name }}</span><button v-for="item in row.actions" :key="item.action" type="button" class="geo-import-action" :data-action="item.action" :title="item.label" :aria-label="item.label" :aria-pressed="item.pressed" @click="display.act(ROW_ACTIONS[item.action], row.id)"><span class="material-symbols-outlined" aria-hidden="true">{{ item.icon }}</span></button></li></ul>
<input ref="picker" type="file" id="geo-import-input" accept=".geojson,.json,.kml,.kmz,.gpx" multiple hidden @change="pick" :aria-label="t('Choose GeoJSON, KML, KMZ or GPX files')">
<ul class="geo-import-list" :class="{ visible: listed }" id="geo-import-list" v-focus-safe-children="leave" :aria-label="t('Imported files')"><li v-for="row in rows" :key="row.id" class="geo-import-row" :data-import-id="row.id"><span :class="['geo-import-swatch', { 'own-styles': row.ownStyles }]" :style="row.ownStyles ? null : { background: row.color }"></span><span class="geo-import-name" :title="row.title">{{ row.name }}</span><button v-for="item in row.actions" :key="item.action" type="button" class="geo-import-action" :data-action="item.action" :title="item.label" :aria-label="item.label" :aria-pressed="item.pressed" @click="display.act(ROW_ACTIONS[item.action], row.id)"><span class="material-symbols-outlined" aria-hidden="true">{{ item.icon }}</span></button></li></ul>
<span class="draw-hint geo-import-hint" id="geo-import-hint" aria-live="polite">{{ geoImport?.hint ?? '' }}</span>
</div>
</template>
+5 -5
View File
@@ -17,18 +17,18 @@ const tilted = computed(() => Boolean(camera.value?.tilted));
</script>
<template>
<nav id="top-center-actions" aria-label="Globe actions" data-i18n-aria-label>
<button id="clear-selected-layers" type="button" :aria-label="clearBusy ? t('Clearing selected data layers') : t('Clear selected data layers')" :aria-disabled="clearBusy == null ? undefined : String(clearBusy)" :aria-busy="clearBusy == null ? undefined : String(clearBusy)" title="Turn off all selected data layers" data-i18n-title @click="act('clear')">
<nav id="top-center-actions" :aria-label="t('Globe actions')">
<button id="clear-selected-layers" type="button" :aria-label="clearBusy ? t('Clearing selected data layers') : t('Clear selected data layers')" :aria-disabled="clearBusy == null ? undefined : String(clearBusy)" :aria-busy="clearBusy == null ? undefined : String(clearBusy)" :title="t('Turn off all selected data layers')" @click="act('clear')">
<span class="material-symbols-outlined" aria-hidden="true">layers_clear</span>
</button>
<button id="share-btn" type="button" aria-label="Copy share link" title="Copy share link" data-i18n-aria-label data-i18n-title @click="act('share')">
<button id="share-btn" type="button" :aria-label="t('Copy share link')" :title="t('Copy share link')" @click="act('share')">
<span class="share-icon" aria-hidden="true">&#x1F517;</span>
</button>
<button id="tilt-map-view" type="button" :aria-label="tilted ? t('Return map to straight-down view') : t('Tilt map to oblique view')" :aria-pressed="String(tilted)" title="Toggle straight-down and tilted map views" data-i18n-title @click="act('tilt')">
<button id="tilt-map-view" type="button" :aria-label="tilted ? t('Return map to straight-down view') : t('Tilt map to oblique view')" :aria-pressed="String(tilted)" :title="t('Toggle straight-down and tilted map views')" @click="act('tilt')">
<span class="material-symbols-outlined" aria-hidden="true">view_in_ar</span>
</button>
<GlobeCompass @click="act('northUp')" />
<button id="reset-globe-view" type="button" :aria-label="resetBusy ? t('Resetting to full globe view') : t('Reset to full globe view')" title="Reset camera and return to full globe view" data-i18n-title @click="act('reset')">
<button id="reset-globe-view" type="button" :aria-label="resetBusy ? t('Resetting to full globe view') : t('Reset to full globe view')" :title="t('Reset camera and return to full globe view')" @click="act('reset')">
<span class="material-symbols-outlined" aria-hidden="true">public</span>
</button>
</nav>
+1 -1
View File
@@ -20,7 +20,7 @@ const label = computed(() =>
</script>
<template>
<button id="north-up-view" type="button" :aria-label="label" :data-north-up="heading == null ? undefined : String(heading === 0)" :style="heading == null ? undefined : { '--camera-heading': `${heading}deg` }" title="Reset map bearing to north" data-i18n-title @click="$emit('click')">
<button id="north-up-view" type="button" :aria-label="label" :data-north-up="heading == null ? undefined : String(heading === 0)" :style="heading == null ? undefined : { '--camera-heading': `${heading}deg` }" :title="t('Reset map bearing to north')" @click="$emit('click')">
<span class="camera-compass-n" aria-hidden="true">N</span>
<span class="camera-compass-needle material-symbols-outlined" aria-hidden="true">navigation</span>
</button>
+4 -4
View File
@@ -61,11 +61,11 @@ onUpdated(() => {
</script>
<template>
<div id="space-mission-roster" ref="root" class="space-mission-roster" aria-label="Available Space Missions" data-i18n-aria-label>
<div id="space-mission-roster" ref="root" class="space-mission-roster" :aria-label="t('Available Space Missions')">
<div class="space-mission-roster-header">
<div>
<strong data-i18n>AVAILABLE MISSIONS</strong>
<span data-i18n>SELECT A MISSION TO INSPECT</span>
<strong>{{ t('AVAILABLE MISSIONS') }}</strong>
<span>{{ t('SELECT A MISSION TO INSPECT') }}</span>
</div>
<output data-mission-roster-count>{{ roster ? roster.count : '—' }}</output>
</div>
@@ -74,6 +74,6 @@ onUpdated(() => {
<UiEmpty v-else-if="!roster.entries.length" class="space-mission-roster-empty" :title="roster.empty" />
<button v-for="entry in roster?.entries" v-else :key="entry.id" type="button" class="space-mission-roster-item" :data-mission-roster-index="entry.index" :data-mission-roster-id="entry.id" :aria-label="entry.ariaLabel" @click="layer?.selectMissionAt(entry.index)" @mouseenter="enter(entry.index)" @mousemove="move(entry.index)" @mouseleave="layer?.rosterPointerLeave()" @focus="layer?.rosterFocus(entry.index)" @blur="blur"><span class="space-mission-roster-marker" :style="{ '--mission-roster-color': entry.color }" aria-hidden="true"></span><span class="space-mission-roster-copy"><strong>{{ entry.label }}</strong><small>{{ entry.provider }} · {{ entry.date }}</small></span><span class="space-mission-roster-chevron" aria-hidden="true">›</span></button>
</div>
<p class="space-mission-roster-focus-continuation" tabindex="-1" data-mission-roster-focus-continuation data-i18n>TAB PREVIEWS · ENTER / SPACE SELECTS</p>
<p class="space-mission-roster-focus-continuation" tabindex="-1" data-mission-roster-focus-continuation>{{ t('TAB PREVIEWS · ENTER / SPACE SELECTS') }}</p>
</div>
</template>
+12 -12
View File
@@ -36,12 +36,12 @@ const radioWords = {
</script>
<template>
<RailPanel id="radio-panel" tag="section" start-collapsed v-class-toggle="{ ...(radio && { 'radio-enabled': radio.enabled, 'lifecycle-uncertain': radio.uncertain }), ...(sdrConnected !== null && { 'sdr-connected': sdrConnected }) }" aria-label="Internet radio and local SDR" data-i18n-aria-label :title="t('RADIO')" :toggle-words="radioWords" :glow="false" scroll>
<RailPanel id="radio-panel" tag="section" start-collapsed v-class-toggle="{ ...(radio && { 'radio-enabled': radio.enabled, 'lifecycle-uncertain': radio.uncertain }), ...(sdrConnected !== null && { 'sdr-connected': sdrConnected }) }" :aria-label="t('Internet radio and local SDR')" :title="t('RADIO')" :toggle-words="radioWords" :glow="false" scroll>
<template #badge><span id="radio-layer-state" class="radio-layer-state" :class="{ active: radio?.layerState.active }">{{ radio ? radio.layerState.text : t('OFF') }}</span></template>
<div class="radio-directory-row">
<UiButton id="radio-enable-btn" @click="act('toggleRadio', 'panel')" class="panel-layer-toggle" :pressed="radio?.enable.active ?? false" pressed-class="active" :aria-label="radio?.enable.label" :aria-disabled="radio ? String(radio.enable.busy) : undefined" :aria-busy="radio ? String(radio.enable.busy) : undefined">{{ radio ? radio.enable.text : t('ENABLE') }}</UiButton>
<label class="radio-filter-label" for="radio-filter" data-i18n>STATION TAG</label>
<UiSelect class="gev-field" id="radio-filter" @change="setFilter($event.target)" aria-label="Filter stations by station tag" :disabled="radio ? radio.filter.disabled : true" v-bind="radio?.filter.color ? { style: { color: radio.filter.color } } : {}" :model-value="radio ? radio.filter.value : 'all'" data-i18n-aria-label>
<label class="radio-filter-label" for="radio-filter">{{ t('STATION TAG') }}</label>
<UiSelect class="gev-field" id="radio-filter" @change="setFilter($event.target)" :aria-label="t('Filter stations by station tag')" :disabled="radio ? radio.filter.disabled : true" v-bind="radio?.filter.color ? { style: { color: radio.filter.color } } : {}" :model-value="radio ? radio.filter.value : 'all'">
<option v-if="!radio?.filter.options" value="all">{{ t('All', { context: 'radio tag' }) }}</option><template v-else><option v-for="option in radio.filter.options" :key="option.id" :value="option.id" :data-radio-color="option.color" :style="{ color: option.color }" :aria-label="option.label">● {{ option.label }}</option></template>
</UiSelect>
</div>
@@ -51,23 +51,23 @@ const radioWords = {
<span id="radio-station-tags">{{ radio?.station.tags ?? '' }}</span>
</div>
<RadioTuner />
<div class="radio-transport" role="group" aria-label="Radio playback" data-i18n-aria-label>
<button id="radio-prev-btn" @click="act('cycleRadio', -1)" type="button" aria-label="Previous filtered station" :disabled="radio ? radio.transport.stepDisabled : true" data-i18n-aria-label data-i18n>PREV</button>
<div class="radio-transport" role="group" :aria-label="t('Radio playback')">
<button id="radio-prev-btn" @click="act('cycleRadio', -1)" type="button" :aria-label="t('Previous filtered station')" :disabled="radio ? radio.transport.stepDisabled : true">{{ t('PREV') }}</button>
<button id="radio-play-btn" @click="act('toggleRadioPlayback')" type="button" v-class-toggle="{ active: radio?.transport.play.active }" :aria-label="radio ? radio.transport.play.label : t('Play selected station')" :disabled="radio ? radio.transport.play.disabled : true">{{ radio ? radio.transport.play.text : t('PLAY') }}</button>
<button id="radio-next-btn" @click="act('cycleRadio', 1)" type="button" aria-label="Next filtered station" :disabled="radio ? radio.transport.stepDisabled : true" data-i18n-aria-label data-i18n>NEXT</button>
<button id="radio-stop-btn" @click="act('stopRadio')" type="button" aria-label="Stop radio playback" :disabled="radio ? radio.transport.stopDisabled : true" data-i18n-aria-label data-i18n>STOP</button>
<button id="radio-next-btn" @click="act('cycleRadio', 1)" type="button" :aria-label="t('Next filtered station')" :disabled="radio ? radio.transport.stepDisabled : true">{{ t('NEXT') }}</button>
<button id="radio-stop-btn" @click="act('stopRadio')" type="button" :aria-label="t('Stop radio playback')" :disabled="radio ? radio.transport.stopDisabled : true">{{ t('STOP') }}</button>
</div>
<label class="radio-volume-row" for="radio-volume">
<span data-i18n>VOLUME</span>
<UiSlider id="radio-volume" hold @update:model-value="act('setRadioVolume', $event)" min="0" max="100" aria-label="Radio volume" :disabled="radio?.volume.disabled" :model-value="radio ? radio.volume.value : 80" data-i18n-aria-label />
<span>{{ t('VOLUME') }}</span>
<UiSlider id="radio-volume" hold @update:model-value="act('setRadioVolume', $event)" min="0" max="100" :aria-label="t('Radio volume')" :disabled="radio?.volume.disabled" :model-value="radio ? radio.volume.value : 80" />
<output id="radio-volume-value" class="gev-slider-value" for="radio-volume">{{ radio ? `${radio.volume.value}%` : '80%' }}</output>
</label>
<div id="radio-playback-state" class="radio-playback-state" :class="{ error: radio?.playback.error }" role="status" aria-live="polite">{{ radio ? radio.playback.text : t('Radio off') }}</div>
<div class="radio-links">
<a id="radio-station-homepage" :href="radio ? radio.station.homepage || undefined : '#'" target="_blank" rel="noopener noreferrer" :hidden="radio ? !radio.station.homepage : true" data-i18n>STATION SITE</a>
<a href="https://www.radio-browser.info/" target="_blank" rel="noopener noreferrer" data-i18n>DIRECTORY: RADIO BROWSER</a>
<a id="radio-station-homepage" :href="radio ? radio.station.homepage || undefined : '#'" target="_blank" rel="noopener noreferrer" :hidden="radio ? !radio.station.homepage : true">{{ t('STATION SITE') }}</a>
<a href="https://www.radio-browser.info/" target="_blank" rel="noopener noreferrer">{{ t('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">{{ t('Audio connects directly to the broadcaster after you press play. Your IP is visible to that broadcaster.') }}</p>
<SdrCard />
</RailPanel>
</template>
+2 -2
View File
@@ -74,11 +74,11 @@ const vTunerDragging = {
<input id="radio-tuner-slider" type="range" min="0" :max="tuner ? tuner.max : 0" step="1" value="0" v-range-value="tuner ? tuner.slot : 0"
@pointerdown="act('tunerPointerDown', $event)" @pointermove="act('tunerPointerMove', $event)" @pointerup="act('tunerPointerUp', $event)" @pointercancel="act('tunerPointerCancel', $event)" @lostpointercapture="act('tunerLostPointerCapture', $event)"
@keydown="act('tunerKeyDown', $event)" @keyup="act('tunerKeyUp', $event)" @input="act('tunerInput')" @change="act('tunerChange')" @blur="act('tunerBlur')"
aria-label="Tune available internet radio stations" :aria-valuetext="tuner?.valuetext ?? t('No station available')" :disabled="tuner ? !tuner.available : true" data-i18n-aria-label />
:aria-label="t('Tune available internet radio stations')" :aria-valuetext="tuner?.valuetext ?? t('No station available')" :disabled="tuner ? !tuner.available : true" />
</div>
<div class="radio-tuner-readout">
<span id="radio-tuner-station">{{ tuner?.station ?? t('ALL · DRAG THE NEEDLE') }}</span>
<span data-i18n>SNAPS TO AVAILABLE STATIONS</span>
<span>{{ t('SNAPS TO AVAILABLE STATIONS') }}</span>
</div>
</div>
</template>
+20 -20
View File
@@ -24,46 +24,46 @@ const tune = () => act('tuneSdr', frequency.value.value);
</script>
<template>
<div id="sdr-radio-card" class="sdr-radio-card" role="group" aria-label="Local RTL-SDR receiver" data-i18n-aria-label>
<div id="sdr-radio-card" class="sdr-radio-card" role="group" :aria-label="t('Local RTL-SDR receiver')">
<div class="sdr-radio-heading">
<strong data-i18n>LOCAL RTL-SDR</strong>
<span id="sdr-connection-state" v-class-toggle="{ active: sdr?.connectionActive }" data-i18n-runtime>{{ sdr ? sdr.connectionLabel : 'NO USB' }}</span>
<strong>{{ t('LOCAL RTL-SDR') }}</strong>
<span id="sdr-connection-state" v-class-toggle="{ active: sdr?.connectionActive }">{{ sdr ? sdr.connectionLabel : 'NO USB' }}</span>
</div>
<p data-i18n>Chrome/Edge · WebUSB · one local tuner shared by FM and the Local ADS-B layer.</p>
<p>{{ t('Chrome/Edge · WebUSB · one local tuner shared by FM and the Local ADS-B layer.') }}</p>
<div class="sdr-radio-actions">
<UiButton id="sdr-connect-btn" @click="act('toggleSdrConnection')" class="panel-layer-toggle" :pressed="sdr?.connectPressed ?? false" pressed-class="active" :disabled="sdr?.connectDisabled">{{ sdr ? sdr.connectLabel : t('CONNECT') }}</UiButton>
<button id="sdr-locate-btn" type="button" @click="act('locateSdr')" v-class-toggle="{ active: sdr?.locateActive }" title="Use your location for faster ADS-B position decoding" :disabled="sdr?.locateDisabled" data-i18n-title>{{ sdr ? sdr.locateLabel : t('LOCATE') }}</button>
<button id="sdr-change-device-btn" type="button" @click="act('changeSdrDevice')" title="Choose a different RTL-SDR or receiver channel" :disabled="sdr?.changeDeviceDisabled" data-i18n-title data-i18n>CHANGE DEVICE</button>
<button id="sdr-locate-btn" type="button" @click="act('locateSdr')" v-class-toggle="{ active: sdr?.locateActive }" :title="t('Use your location for faster ADS-B position decoding')" :disabled="sdr?.locateDisabled">{{ sdr ? sdr.locateLabel : t('LOCATE') }}</button>
<button id="sdr-change-device-btn" type="button" @click="act('changeSdrDevice')" :title="t('Choose a different RTL-SDR or receiver channel')" :disabled="sdr?.changeDeviceDisabled">{{ t('CHANGE DEVICE') }}</button>
</div>
<div class="sdr-mode-row" role="group" aria-label="RTL-SDR receiver mode" data-i18n-aria-label>
<div class="sdr-mode-row" role="group" :aria-label="t('RTL-SDR receiver mode')">
<UiButton id="sdr-mode-fm-btn" @click="act('setSdrMode', 'fm')" :pressed="sdr ? sdr.fmActive : true" pressed-class="active" :disabled="sdr?.modeDisabled" translate="no">FM</UiButton>
<UiButton id="sdr-mode-adsb-btn" @click="act('setSdrMode', 'adsb')" :pressed="sdr?.adsbActive ?? false" pressed-class="active" :disabled="sdr?.modeDisabled" translate="no">ADS-B · 1090</UiButton>
</div>
<div class="sdr-gain-row">
<label for="sdr-gain-select" data-i18n>GAIN</label>
<UiSelect class="gev-field" id="sdr-gain-select" hold :model-value="sdr ? sdr.gain : 'auto'" @update:model-value="act('setSdrGain', $event)" aria-label="RTL-SDR tuner gain for the current mode" :disabled="sdr?.gainDisabled" data-i18n-aria-label>
<option value="auto" data-i18n>AUTO</option>
<label for="sdr-gain-select">{{ t('GAIN') }}</label>
<UiSelect class="gev-field" id="sdr-gain-select" hold :model-value="sdr ? sdr.gain : 'auto'" @update:model-value="act('setSdrGain', $event)" :aria-label="t('RTL-SDR tuner gain for the current mode')" :disabled="sdr?.gainDisabled">
<option value="auto">{{ t('AUTO') }}</option>
<option v-for="gain in GAINS" :key="gain" :value="gain" translate="no">{{ localSdrGainOptionLabel(gain) }}</option>
</UiSelect>
</div>
<div id="sdr-adsb-stats" class="sdr-adsb-stats" role="status" aria-live="off" :hidden="sdr ? sdr.statsHidden : true">
<span><output id="sdr-stat-rate">{{ sdr ? sdr.stats.rate : '--' }}</output> <span data-i18n>MSG/S</span></span>
<span><output id="sdr-stat-heard">{{ sdr ? sdr.stats.heard : '0' }}</output> <span data-i18n>HEARD</span></span>
<span><output id="sdr-stat-positioned">{{ sdr ? sdr.stats.positioned : '0' }}</output> <span data-i18n>POSITIONED</span></span>
<span><output id="sdr-stat-rate">{{ sdr ? sdr.stats.rate : '--' }}</output> <span>{{ t('MSG/S') }}</span></span>
<span><output id="sdr-stat-heard">{{ sdr ? sdr.stats.heard : '0' }}</output> <span>{{ t('HEARD') }}</span></span>
<span><output id="sdr-stat-positioned">{{ sdr ? sdr.stats.positioned : '0' }}</output> <span>{{ t('POSITIONED') }}</span></span>
<span translate="no">IQ <output id="sdr-stat-iq">{{ sdr ? sdr.stats.iq : '--' }}</output></span>
</div>
<div class="sdr-frequency-row">
<label for="sdr-frequency-input" data-i18n>FM MHZ</label>
<label for="sdr-frequency-input">{{ t('FM MHZ') }}</label>
<input ref="frequency" class="gev-field" id="sdr-frequency-input" @keydown.enter.prevent="tune" type="number" min="87.5" max="108" step="0.1" value="98.5" inputmode="decimal" :disabled="sdr?.frequencyDisabled" v-field-value="sdr ? sdr.frequency : '98.5'" />
<button id="sdr-tune-btn" type="button" @click="tune" :disabled="sdr?.tuneDisabled" data-i18n>TUNE</button>
<button id="sdr-tune-btn" type="button" @click="tune" :disabled="sdr?.tuneDisabled">{{ t('TUNE') }}</button>
</div>
<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" @click="act('seekSdr', -1)" :disabled="sdr ? sdr.seekDisabled : true" data-i18n>◀ SEEK</button>
<button id="sdr-seek-forward-btn" type="button" @click="act('seekSdr', 1)" :disabled="sdr ? sdr.seekDisabled : true" data-i18n>SEEK ▶</button>
<div class="sdr-seek-row" role="group" :aria-label="t('Seek broadcast FM stations')">
<button id="sdr-seek-back-btn" type="button" @click="act('seekSdr', -1)" :disabled="sdr ? sdr.seekDisabled : true">{{ t('◀ SEEK') }}</button>
<button id="sdr-seek-forward-btn" type="button" @click="act('seekSdr', 1)" :disabled="sdr ? sdr.seekDisabled : true">{{ t('SEEK ▶') }}</button>
</div>
<label class="radio-volume-row" for="sdr-volume">
<span data-i18n>SDR VOL</span>
<UiSlider id="sdr-volume" hold @update:model-value="act('setSdrVolume', $event)" min="0" max="100" aria-label="Local SDR volume" :disabled="sdr?.volumeDisabled" :model-value="sdr ? sdr.volume : 80" data-i18n-aria-label />
<span>{{ t('SDR VOL') }}</span>
<UiSlider id="sdr-volume" hold @update:model-value="act('setSdrVolume', $event)" min="0" max="100" :aria-label="t('Local SDR volume')" :disabled="sdr?.volumeDisabled" :model-value="sdr ? sdr.volume : 80" />
<output id="sdr-volume-value" class="gev-slider-value" for="sdr-volume">{{ sdr ? sdr.volumeText : '80%' }}</output>
</label>
<div id="sdr-status" class="radio-playback-state" :class="{ error: sdr?.statusError }" role="status" aria-live="polite">{{ sdr ? sdr.statusText : t('Connect an RTL-SDR to begin.') }}</div>
+10 -10
View File
@@ -135,39 +135,39 @@ const vThumb = {
<div id="sl-viewer-wrap" class="sl-viewer-wrap" :hidden="!viewer.open">
<div class="sl-viewer-bar">
<div class="sl-viewer-tools">
<UiButton id="sl-viewer-expand" class="scene-btn" :hidden="!expandAvailable" :pressed="Boolean(expanded)" :aria-label="expanded === null ? undefined : expanded ? t('Shrink') : t('Expand')" title="Show the street-level view full screen (Esc shrinks it)" data-i18n-title><span class="sl-btn-icon" aria-hidden="true">{{ expanded ? '⤡' : '⤢' }}</span><span class="sl-btn-text">{{ expanded ? t('SHRINK') : t('EXPAND') }}</span></UiButton>
<UiButton id="sl-viewer-expand" class="scene-btn" :hidden="!expandAvailable" :pressed="Boolean(expanded)" :aria-label="expanded === null ? undefined : expanded ? t('Shrink') : t('Expand')" :title="t('Show the street-level view full screen (Esc shrinks it)')"><span class="sl-btn-icon" aria-hidden="true">{{ expanded ? '⤡' : '⤢' }}</span><span class="sl-btn-text">{{ expanded ? t('SHRINK') : t('EXPAND') }}</span></UiButton>
<UiSegmented class="sl-seg" item-class="sl-seg-btn" active-class="is-active" keyboard="radio" :hidden="!viewer.renderModes" :label="t('Image fit')" :options="renderModeOptions" :model-value="viewer.renderMode" @update:model-value="store.act('setRenderMode', $event)" />
</div>
<div class="sl-viewer-tools sl-viewer-tools-right">
<button id="sl-viewer-close" class="sl-viewer-x" type="button" title="Close the image and deselect it on the map" aria-label="Close the street-level image" data-i18n-title data-i18n-aria-label>×</button>
<button id="sl-viewer-close" class="sl-viewer-x" type="button" :title="t('Close the image and deselect it on the map')" :aria-label="t('Close the street-level image')">×</button>
</div>
</div>
<div class="sl-viewer-stage">
<div id="sl-viewer-placeholder" class="sl-viewer-placeholder" :hidden="!viewer.loading"></div>
<div id="sl-viewer" class="sl-viewer" aria-label="Street-level viewer" data-i18n-aria-label></div>
<div id="sl-viewer" class="sl-viewer" :aria-label="t('Street-level viewer')"></div>
</div>
<div id="sl-image-meta" class="sl-image-meta">
<span id="sl-image-by" class="sl-image-by">{{ image.captionLeft }}</span>
<span class="sl-image-right">
<span id="sl-image-when" class="sl-image-when">{{ image.captionRight }}</span>
<a id="sl-image-link" class="sl-image-link" :href="image.href" target="_blank" rel="noopener noreferrer" title="Open this image on the provider's site" data-i18n-title :hidden="!image.link">{{ image.linkLabel }}</a>
<a id="sl-image-link" class="sl-image-link" :href="image.href" target="_blank" rel="noopener noreferrer" :title="t('Open this image on the provider\'s site')" :hidden="!image.link">{{ image.linkLabel }}</a>
</span>
</div>
</div>
<div class="sl-providers" role="group" aria-label="Imagery providers" data-i18n-aria-label>
<span class="cctv-summary-label" data-i18n>PROVIDERS</span>
<div class="sl-providers" role="group" :aria-label="t('Imagery providers')">
<span class="cctv-summary-label">{{ t('PROVIDERS') }}</span>
<div id="sl-provider-chips" class="sl-chips"><RailChips :chips="view ? view.providers : []" /></div>
</div>
<div id="sl-error" class="sl-error" role="alert" :hidden="!view?.error"><span aria-hidden="true">⚠</span> <span id="sl-error-text">{{ view?.error || '' }}</span></div>
<!-- A missing key gates the filters only: the chips above stay
focusable so their tooltip can say how to add the key. -->
<fieldset id="sl-controls" class="sl-controls" :disabled="Boolean(view?.controlsDisabled)" :hidden="!shows.pano && !shows.since">
<div id="sl-pano-row" class="sl-filters" :hidden="!shows.pano" role="group" aria-label="Imagery filters" data-i18n-aria-label>
<span class="cctv-summary-label" data-i18n>IMAGERY</span>
<div id="sl-pano-row" class="sl-filters" :hidden="!shows.pano" role="group" :aria-label="t('Imagery filters')">
<span class="cctv-summary-label">{{ t('IMAGERY') }}</span>
<UiSegmented class="sl-seg" item-class="sl-seg-btn" active-class="is-active" keyboard="radio" :disabled="Boolean(view?.controlsDisabled)" :label="t('Panorama filter')" :options="panoOptions" :model-value="pano" @update:model-value="store.act('setPano', $event)" />
</div>
<div id="sl-since-row" class="sl-since" :hidden="!shows.since">
<label class="cctv-summary-label" for="sl-since" data-i18n>SINCE</label>
<label class="cctv-summary-label" for="sl-since">{{ t('SINCE') }}</label>
<UiSlider id="sl-since" v-thumb="view?.since" class="sl-range" min="0" max="8" step="1" :aria-valuetext="sinceLabel" aria-describedby="sl-since-label sl-since-note" @input="onSinceInput" @change="onSinceChange" />
<span class="sl-since-value">
<output id="sl-since-label" class="sl-since-label" for="sl-since">{{ sinceLabel }}</output>
@@ -178,7 +178,7 @@ const vThumb = {
<!-- The hint says how to open imagery; the button does it without a pointer. -->
<div class="sl-meta-row">
<div id="sl-coverage-meta" class="sl-meta">{{ view?.meta ?? '' }}</div>
<UiButton id="sl-nearest-btn" class="scene-btn sl-nearest-btn" type="button" :hidden="!view?.nearest.visible" :title="nearest.title || undefined" :disabled="nearest.disabled" data-i18n-runtime>{{ nearest.label }}</UiButton>
<UiButton id="sl-nearest-btn" class="scene-btn sl-nearest-btn" type="button" :hidden="!view?.nearest.visible" :title="nearest.title || undefined" :disabled="nearest.disabled">{{ nearest.label }}</UiButton>
</div>
</div>
</RailPanel>