mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
feat(i18n): presentation extraction — t() call sites, data-i18n templates, locale selector
Route every application-owned presentation string through the i18n core: t() call sites across the app/layers/ui/data/voice surfaces, data-i18n* attributes in the src/ui/templates/*.html markup (applied at boot by applyDocumentTranslations from src/standalone/application.js), and the dock locale selector (runtime-rendered from the offered pair — a single EN button while only English ships). Rebased onto current main, where the extraction follows the module splits: the shell wiring now lands in the owner modules (panelChrome, visualSettings, locationNavigation, shareRestoration, shellFeedback, applicationShell) and the voice status corpus in the realtime* modules; scene status lives in director.js + playbackAdapter.js. Modules shared with the node runtime (loadingFeedback, installationFeedback, regionalModel, keySetupCore, vessels ingestion) stay environment-neutral English and localize at their browser render sites (chip maps in shellFeedback/installations controls, the weather table in cockpitBriefing, keySetupRequirementEnvVars compositions), so the package-boundary graph stays clean; the browser boundary groups now own the i18n modules they reach (scripts/package-boundaries.json).
This commit is contained in:
1 parent
bde1e430ef
commit
284c4d4601
98 files changed
+3474
-1625
No files matched your search
+1295
-729
File diff suppressed because it is too large.
Load diff
Vendored
+3
-2
@@ -2,6 +2,7 @@ import { catalogControlServices } from './catalog.js';
|
||||
import { StyleManager } from '../ui/composition.js';
|
||||
import { flyToAustin } from '../camera.js';
|
||||
import { initCockpitCloudEffects } from '../cockpitCloudEffects.js';
|
||||
import { t } from '../i18n/index.js';
|
||||
|
||||
/** Construct the existing controls and camera presentation. */
|
||||
export function createApplicationControls({
|
||||
@@ -41,10 +42,10 @@ export function createApplicationControls({
|
||||
|
||||
// If no share link state, do default fly-to Austin
|
||||
if (!styleManager.hasShareState) {
|
||||
loaderStatus.textContent = 'Flying to Austin, TX...';
|
||||
loaderStatus.textContent = t('shell.loading.status.flying');
|
||||
defer(flyToAustin(viewer));
|
||||
} else {
|
||||
loaderStatus.textContent = 'Restoring shared view...';
|
||||
loaderStatus.textContent = t('shell.loading.status.restoring');
|
||||
}
|
||||
|
||||
return { styleManager, weatherEffects, cockpitCloudEffects };
|
||||
|
||||
+8
-5
@@ -14,6 +14,7 @@ import {
|
||||
governorRequestRender,
|
||||
} from '../renderGovernor.js';
|
||||
import { describeError } from './errors.js';
|
||||
import { t } from '../i18n/index.js';
|
||||
|
||||
/** Construct the application globe using the caller's local configuration. */
|
||||
export async function createApplicationScene({
|
||||
@@ -41,7 +42,7 @@ export async function createApplicationScene({
|
||||
else window.__GOOGLE_MAPS_API_KEY__ = previousKey;
|
||||
});
|
||||
}
|
||||
loaderStatus.textContent = 'Configuring viewer...';
|
||||
loaderStatus.textContent = t('shell.loading.status.configuring');
|
||||
// Provider attribution stays visible, including clean-view and recording.
|
||||
const creditContainer = document.createElement('div');
|
||||
creditContainer.id = 'cesium-credits';
|
||||
@@ -60,8 +61,8 @@ export async function createApplicationScene({
|
||||
configureCreditKeyboardAccess(document);
|
||||
loaderStatus.textContent =
|
||||
googleApiKey || cesiumToken
|
||||
? 'Loading Google 3D Tiles...'
|
||||
: 'Loading the keyless globe...';
|
||||
? t('shell.loading.status.tilesGoogle')
|
||||
: t('shell.loading.status.tilesKeyless');
|
||||
const photoreal = await loadPhotorealisticTileset(Cesium, {
|
||||
googleApiKey,
|
||||
cesiumToken,
|
||||
@@ -88,12 +89,14 @@ export async function createApplicationScene({
|
||||
tileError,
|
||||
);
|
||||
const tileErrorDetail = describeError(tileError);
|
||||
loaderStatus.textContent = `Google 3D Tiles unavailable (${tileErrorDetail}). Loading the keyless globe...`;
|
||||
loaderStatus.textContent = t('shell.loading.status.tilesUnavailable', {
|
||||
detail: tileErrorDetail,
|
||||
});
|
||||
}
|
||||
viewer.scene.globe.show = true;
|
||||
}
|
||||
|
||||
loaderStatus.textContent = 'Initializing systems...';
|
||||
loaderStatus.textContent = t('shell.loading.status.systems');
|
||||
|
||||
const mapStackController = new MapController(viewer, {
|
||||
requestRender: governorRequestRender,
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
* Adds the keyboard semantics omitted by Cesium's href-less attribution links.
|
||||
* Cesium retains ownership of showing and hiding its required credit lightbox.
|
||||
*/
|
||||
import { t } from './i18n/index.js';
|
||||
|
||||
export function configureCreditKeyboardAccess(root = document) {
|
||||
const expand = root?.querySelector?.(
|
||||
'#cesium-credits .cesium-credit-expand-link',
|
||||
@@ -19,7 +21,7 @@ export function configureCreditKeyboardAccess(root = document) {
|
||||
expand.setAttribute('aria-expanded', 'false');
|
||||
close.setAttribute('role', 'button');
|
||||
close.setAttribute('tabindex', '0');
|
||||
close.setAttribute('aria-label', 'Close data attribution');
|
||||
close.setAttribute('aria-label', t('shell.credits.closeAria'));
|
||||
|
||||
if (expand.dataset.gevKeyboardReady === 'true') return true;
|
||||
expand.dataset.gevKeyboardReady = 'true';
|
||||
|
||||
@@ -301,7 +301,7 @@ test('Contacts continuation target is the visible explanatory note', () => {
|
||||
const source = readLayerSource(new URL('./militaryAwareness.js', import.meta.url));
|
||||
assert.match(
|
||||
source,
|
||||
/<p class="military-awareness-note" tabindex="-1" data-awareness-focus-continuation>Open-source mapped\/observed context\./,
|
||||
/<p class="military-awareness-note" tabindex="-1" data-awareness-focus-continuation>\$\{t\('layers\.awareness\.note'\)\}<\/p>/,
|
||||
);
|
||||
assert.doesNotMatch(source, /<span[^>]*data-awareness-focus-continuation/);
|
||||
});
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
/** Pure proximity helpers for Global Context. No capability modeling. */
|
||||
|
||||
import { t } from '../i18n/index.js';
|
||||
|
||||
export const AWARENESS_RADIUS_M = 250000;
|
||||
export const AWARENESS_MAX_FLIGHT_SEARCH_RADIUS_M = 16000000;
|
||||
export const AWARENESS_RELATIONSHIP = Object.freeze({
|
||||
@@ -82,14 +84,14 @@ export function summarizeAwarenessCohort(
|
||||
relationship: AWARENESS_RELATIONSHIP.UNKNOWN,
|
||||
count: null,
|
||||
nearest: [],
|
||||
reason: 'feed unavailable',
|
||||
reason: t('layers.awareness.reason.feedUnavailable'),
|
||||
};
|
||||
if (stale)
|
||||
return {
|
||||
relationship: AWARENESS_RELATIONSHIP.UNKNOWN,
|
||||
count: null,
|
||||
nearest: [],
|
||||
reason: 'feed stale',
|
||||
reason: t('layers.awareness.reason.feedStale'),
|
||||
};
|
||||
const normalized = (Array.isArray(items) ? items : [])
|
||||
.filter(
|
||||
@@ -106,8 +108,8 @@ export function summarizeAwarenessCohort(
|
||||
count: normalized.length,
|
||||
nearest: normalized.slice(0, limit),
|
||||
reason: normalized.length
|
||||
? 'observed or mapped nearby context'
|
||||
: 'no observed or mapped objects in current feeds',
|
||||
? t('layers.awareness.reason.nearby')
|
||||
: t('layers.awareness.reason.none'),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
* for), then the constellations users can actually name, then the grab-bag.
|
||||
*/
|
||||
|
||||
import { t } from '../i18n/index.js';
|
||||
|
||||
/**
|
||||
* Class registry. `label` is the card/legend token, `color` the point color,
|
||||
* `blurb` the plain-language gloss for the legend tooltip.
|
||||
@@ -77,6 +79,32 @@ export const SATELLITE_CLASS_ORDER = Object.freeze([
|
||||
'comms',
|
||||
]);
|
||||
|
||||
// Display keys for the class table above: `label`/`blurb` stay the English
|
||||
// source-of-truth tokens (tests read them), while the card and legend accessors
|
||||
// translate through these keys at the presentation boundary.
|
||||
const CLASS_DISPLAY_KEYS = Object.freeze({
|
||||
station: Object.freeze({
|
||||
label: 'layers.satellites.class.station',
|
||||
blurb: 'layers.satellites.class.stationBlurb',
|
||||
}),
|
||||
nav: Object.freeze({
|
||||
label: 'layers.satellites.class.nav',
|
||||
blurb: 'layers.satellites.class.navBlurb',
|
||||
}),
|
||||
geo: Object.freeze({
|
||||
label: 'layers.satellites.class.geo',
|
||||
blurb: 'layers.satellites.class.geoBlurb',
|
||||
}),
|
||||
visual: Object.freeze({
|
||||
label: 'layers.satellites.class.visual',
|
||||
blurb: 'layers.satellites.class.visualBlurb',
|
||||
}),
|
||||
comms: Object.freeze({
|
||||
label: 'layers.satellites.class.comms',
|
||||
blurb: 'layers.satellites.class.commsBlurb',
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
* CelesTrak group tag → { class, subtype }. Subtype names the specific
|
||||
* constellation so the card can read "NAV · GPS" rather than a bare "NAV".
|
||||
@@ -139,7 +167,7 @@ export function satelliteClassColor(group) {
|
||||
*/
|
||||
export function satelliteClassLabel(group, { isIss = false } = {}) {
|
||||
const { klass, subtype } = satelliteClassOf(group, { isIss });
|
||||
const base = SATELLITE_CLASSES[klass].label;
|
||||
const base = t(CLASS_DISPLAY_KEYS[klass].label);
|
||||
return subtype ? `${base} · ${subtype}` : base;
|
||||
}
|
||||
|
||||
@@ -180,11 +208,12 @@ export function satelliteClassLegend(counts) {
|
||||
const count = counts?.[klass];
|
||||
if (!(count > 0)) continue;
|
||||
const spec = SATELLITE_CLASSES[klass];
|
||||
const display = CLASS_DISPLAY_KEYS[klass];
|
||||
result.push({
|
||||
klass,
|
||||
label: spec.label,
|
||||
label: t(display.label),
|
||||
color: spec.color,
|
||||
blurb: spec.blurb,
|
||||
blurb: t(display.blurb),
|
||||
count,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
* after the vessel cards moved out of their dedicated canvas renderer.
|
||||
*/
|
||||
|
||||
import { t } from '../i18n/index.js';
|
||||
|
||||
export const VESSEL_OVERLAY_SOURCE_ID = 'ais-live-vessels';
|
||||
/** Existing selector grid size; one ambient winner is retained per cell. */
|
||||
export const VESSEL_LABEL_GRID_PX = 118;
|
||||
@@ -64,6 +66,43 @@ const NUMERIC_TYPE_FAMILIES = {
|
||||
9: 'OTHER',
|
||||
};
|
||||
|
||||
// Display keys for the normalized English type tokens above. The tokens stay
|
||||
// machine values — TYPE_STYLES matches on them and tests pin them — so card
|
||||
// text translates only through this map at the presentation boundary.
|
||||
const TYPE_DISPLAY_KEYS = Object.freeze({
|
||||
FISHING: 'layers.vessel.type.fishing',
|
||||
TOWING: 'layers.vessel.type.towing',
|
||||
DREDGER: 'layers.vessel.type.dredger',
|
||||
'DIVE OPS': 'layers.vessel.type.diveOps',
|
||||
MILITARY: 'layers.vessel.type.military',
|
||||
SAILING: 'layers.vessel.type.sailing',
|
||||
PLEASURE: 'layers.vessel.type.pleasure',
|
||||
PILOT: 'layers.vessel.type.pilot',
|
||||
SAR: 'layers.vessel.type.sar',
|
||||
TUG: 'layers.vessel.type.tug',
|
||||
'PORT TENDER': 'layers.vessel.type.portTender',
|
||||
'ANTI-POLLUTION': 'layers.vessel.type.antiPollution',
|
||||
'LAW ENFORCE': 'layers.vessel.type.lawEnforce',
|
||||
MEDICAL: 'layers.vessel.type.medical',
|
||||
'HIGH-SPEED': 'layers.vessel.type.highSpeed',
|
||||
PASSENGER: 'layers.vessel.type.passenger',
|
||||
CARGO: 'layers.vessel.type.cargo',
|
||||
TANKER: 'layers.vessel.type.tanker',
|
||||
OTHER: 'layers.vessel.type.other',
|
||||
});
|
||||
|
||||
/**
|
||||
* Card-display form of a normalized AIS type: known tokens translate through
|
||||
* the catalog; free-text types and unknown codes render unchanged.
|
||||
* @param {string} type Raw AIS type.
|
||||
* @returns {string}
|
||||
*/
|
||||
export function displayVesselType(type) {
|
||||
const normalized = normalizeVesselType(type);
|
||||
const key = TYPE_DISPLAY_KEYS[normalized.toUpperCase()];
|
||||
return key ? t(key) : normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve an AIS type to display text: bare numeric ship-type codes map to
|
||||
* family names ("71" → "CARGO"); text types pass through unchanged.
|
||||
|
||||
+31
-13
@@ -21,6 +21,8 @@ import { createSurfaceKeyboard } from './ui/surfaceKeyboard.js';
|
||||
// Choosing a mission is deliberately NOT durable suppression: picking a mission
|
||||
// is enthusiasm, not "never show me this again".
|
||||
|
||||
import { t } from './i18n/index.js';
|
||||
|
||||
/** Durable suppression. Written ONLY by the "Don't show this again" checkbox. */
|
||||
export const FIRST_RUN_STORAGE_KEY = 'gev:first-run-mission:v1';
|
||||
/** Per-session dismissal. Written by every close path; scoped to sessionStorage. */
|
||||
@@ -34,10 +36,12 @@ export const FIRST_RUN_SESSION_KEY = 'gev:first-run-mission-session:v1';
|
||||
*/
|
||||
export const ENVIRONMENTAL_LABEL_CHOICE = 'ENVIRONMENTAL';
|
||||
|
||||
const ENVIRONMENTAL_LABELS = Object.freeze({
|
||||
ENVIRONMENTAL: Object.freeze({ title: 'ENVIRONMENTAL' }),
|
||||
EARTH_WATCH: Object.freeze({ title: 'EARTH WATCH' }),
|
||||
ACTIVE_EVENTS: Object.freeze({ title: 'ACTIVE EVENTS' }),
|
||||
// Catalog keys per label choice. Resolved through t() at CALL time (init),
|
||||
// never at module load — the locale is not resolved until main.js's body runs.
|
||||
const ENVIRONMENTAL_LABEL_KEYS = Object.freeze({
|
||||
ENVIRONMENTAL: 'setup.firstRun.environmentalTitle.environmental',
|
||||
EARTH_WATCH: 'setup.firstRun.environmentalTitle.earthWatch',
|
||||
ACTIVE_EVENTS: 'setup.firstRun.environmentalTitle.activeEvents',
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -45,7 +49,9 @@ const ENVIRONMENTAL_LABELS = Object.freeze({
|
||||
* @returns {{title: string}} The label set the constant above selects.
|
||||
*/
|
||||
export function environmentalLabel(choice = ENVIRONMENTAL_LABEL_CHOICE) {
|
||||
return ENVIRONMENTAL_LABELS[choice] || ENVIRONMENTAL_LABELS.ENVIRONMENTAL;
|
||||
const key =
|
||||
ENVIRONMENTAL_LABEL_KEYS[choice] || ENVIRONMENTAL_LABEL_KEYS.ENVIRONMENTAL;
|
||||
return { title: t(key) };
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -92,12 +98,12 @@ export const FIRST_RUN_MISSIONS = Object.freeze({
|
||||
contacts: Object.freeze({
|
||||
kind: 'context',
|
||||
contextMode: 'contacts',
|
||||
busyText: 'Starting live contacts…',
|
||||
busyKey: 'setup.firstRun.busy.contacts',
|
||||
}),
|
||||
'space-missions': Object.freeze({
|
||||
kind: 'context',
|
||||
contextMode: 'space-missions',
|
||||
busyText: 'Opening space missions…',
|
||||
busyKey: 'setup.firstRun.busy.spaceMissions',
|
||||
}),
|
||||
environmental: Object.freeze({
|
||||
kind: 'globe',
|
||||
@@ -115,7 +121,7 @@ export const FIRST_RUN_MISSIONS = Object.freeze({
|
||||
// before a launch. LEDGERED post-launch. Until it lands, keyless visitors
|
||||
// are judged on the layer row, which tells them the truth.
|
||||
layerIds: Object.freeze(['earthquakes', 'local-firms']),
|
||||
busyText: 'Scanning active events…',
|
||||
busyKey: 'setup.firstRun.busy.environmental',
|
||||
}),
|
||||
explore: Object.freeze({ kind: 'none' }),
|
||||
});
|
||||
@@ -352,12 +358,23 @@ export function initFirstRunExperience({
|
||||
}
|
||||
|
||||
// The tile name is owner-switchable from one constant, so paint it from the
|
||||
// module rather than trusting the markup to have been edited to match.
|
||||
// module rather than trusting the markup to have been edited to match. The
|
||||
// persuasive description line and the tile's feed subcopy are the two
|
||||
// phase-2 exceptions whose static sites are pinned verbatim by
|
||||
// firstRunExperience.test.mjs — the runtime write is their only legal
|
||||
// localization point (docs/TRANSLATORS.md, runtime-only static sites).
|
||||
const environmentalTitle = root.querySelector(
|
||||
'[data-first-run-environmental-title]',
|
||||
);
|
||||
if (environmentalTitle)
|
||||
environmentalTitle.textContent = environmentalLabel().title;
|
||||
const environmentalSub = root.querySelector(
|
||||
'[data-first-run-environmental-title] + small',
|
||||
);
|
||||
if (environmentalSub)
|
||||
environmentalSub.textContent = t('setup.firstRun.choice.environmentalSub');
|
||||
const description = root.querySelector('#first-run-description');
|
||||
if (description) description.textContent = t('setup.firstRun.description');
|
||||
|
||||
const status = root.querySelector('[data-first-run-status]');
|
||||
const suppressBox = root.querySelector('[data-first-run-suppress]');
|
||||
@@ -440,7 +457,9 @@ export function initFirstRunExperience({
|
||||
button.setAttribute('aria-disabled', String(next));
|
||||
if (!status) return;
|
||||
if (next)
|
||||
status.textContent = FIRST_RUN_MISSIONS[choice]?.busyText || 'Working…';
|
||||
status.textContent = t(
|
||||
FIRST_RUN_MISSIONS[choice]?.busyKey || 'setup.firstRun.busy.working',
|
||||
);
|
||||
else if (status.dataset.sticky !== 'true')
|
||||
status.textContent = defaultStatus;
|
||||
};
|
||||
@@ -490,7 +509,7 @@ export function initFirstRunExperience({
|
||||
Array.isArray(failed) && failed.length ? ` (${failed.join(', ')})` : '';
|
||||
if (status) {
|
||||
status.dataset.sticky = 'true';
|
||||
status.textContent = `Could not open that mission${detail}. Retry or explore manually.`;
|
||||
status.textContent = t('setup.firstRun.status.failed', { detail });
|
||||
}
|
||||
setBusy(false);
|
||||
};
|
||||
@@ -506,8 +525,7 @@ export function initFirstRunExperience({
|
||||
if (box) box.checked = !wanted;
|
||||
if (!status) return;
|
||||
status.dataset.sticky = 'true';
|
||||
status.textContent =
|
||||
'This browser is blocking storage, so that could not be saved.';
|
||||
status.textContent = t('setup.firstRun.status.storageBlocked');
|
||||
};
|
||||
|
||||
const keyboard = createSurfaceKeyboard({
|
||||
|
||||
+5
-4
@@ -35,6 +35,7 @@ import {
|
||||
DEFAULT_HUD_LAYOUT,
|
||||
normalizeHudLayout,
|
||||
} from './hudLayouts.js';
|
||||
import { t } from './i18n/index.js';
|
||||
|
||||
/** Color palettes keyed by shader mode; applied as CSS custom properties. */
|
||||
const HUD_COLORS = {
|
||||
@@ -201,15 +202,15 @@ export class IntelHUD {
|
||||
<div class="hud-system">${this._missionId} ${this._sensorId}</div>
|
||||
<div class="hud-mode" id="hud-mode">NORMAL</div>
|
||||
<div class="hud-summary-wrap">
|
||||
<div class="hud-summary-label">SUMMARY</div>
|
||||
<div class="hud-summary" id="hud-summary">Awaiting telemetry...</div>
|
||||
<div class="hud-summary-label">${t('cockpit.hud.summaryLabel')}</div>
|
||||
<div class="hud-summary" id="hud-summary">${t('cockpit.hud.summaryAwaiting')}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hud-corner hud-top-right">
|
||||
<div class="hud-content" style="text-align:right">
|
||||
<div class="hud-rec"><span id="hud-rec-dot">●</span> REC <span id="hud-timestamp">2026-01-01 00:00:00Z</span></div>
|
||||
<div class="hud-rec"><span id="hud-rec-dot">●</span> ${t('cockpit.hud.recLabel')} <span id="hud-timestamp">2026-01-01 00:00:00Z</span></div>
|
||||
<div class="hud-orbital">ORB: ${this._orbitNum} PASS: DESC-${this._passNum}</div>
|
||||
</div>
|
||||
<div class="hud-bracket">┐</div>
|
||||
@@ -634,7 +635,7 @@ export class IntelHUD {
|
||||
*/
|
||||
_composeSummary() {
|
||||
const m = this._latestMetrics;
|
||||
if (!m) return 'Awaiting telemetry...';
|
||||
if (!m) return t('cockpit.hud.summaryAwaiting');
|
||||
|
||||
const modeEl = document.getElementById('hud-mode');
|
||||
const modeLabel = modeEl?.textContent || 'NORMAL';
|
||||
|
||||
@@ -22,7 +22,9 @@ test('HUD sliders and location search have descriptive explicit names', () => {
|
||||
});
|
||||
|
||||
test('the first-run checkbox keeps its native visible label', () => {
|
||||
assert.match(html, /<label\b[^>]*class="first-run-suppress"[^>]*>\s*<input type="checkbox" data-first-run-suppress \/>\s*<span>Don't show this again<\/span>\s*<\/label>/);
|
||||
// The span may carry a data-i18n extraction attribute; the native wrapping
|
||||
// label contract is what this guards.
|
||||
assert.match(html, /<label\b[^>]*class="first-run-suppress"[^>]*>\s*<input type="checkbox" data-first-run-suppress \/>\s*<span\b[^>]*>Don't show this again<\/span>\s*<\/label>/);
|
||||
});
|
||||
|
||||
test('generated style sliders use the visible parameter label as their name', () => {
|
||||
|
||||
@@ -210,6 +210,7 @@ export default {
|
||||
'cctv.summary.empty': 'No cameras available in catalog.',
|
||||
'cctv.summary.city': '{city} CCTV',
|
||||
'cctv.summary.hdg': 'HDG {value}°',
|
||||
'cctv.summary.hdgEstimated': 'HDG {value}° (ESTIMATED)',
|
||||
'cctv.summary.fov': 'FOV {value}°',
|
||||
'cctv.summary.coverage': 'COVERAGE {value}km²',
|
||||
'cctv.summary.overlap': 'OVERLAP {count} cams',
|
||||
@@ -415,8 +416,21 @@ export default {
|
||||
'firms.card.focusAria': 'Focus fire detection {title}, {details}',
|
||||
|
||||
// src/data/traffic.js feed presentation (manager meta loadingLabel values).
|
||||
'traffic.loadingSyncing': 'syncing LIVE traffic flow',
|
||||
'traffic.liveCoverage': 'LIVE · TomTom flow · {percent}% cov',
|
||||
'traffic.loadingSyncing':
|
||||
'Syncing flow · Roads: OpenStreetMap · Flow: TomTom · Unmatched: simulated',
|
||||
'traffic.liveCoverage':
|
||||
'LIVE · Roads: OpenStreetMap · Flow: TomTom · {percent}% cov',
|
||||
'traffic.liveCoverageUnmatched':
|
||||
'LIVE · Roads: OpenStreetMap · Flow: TomTom · {percent}% cov · Unmatched: simulated',
|
||||
'traffic.simulatedNoMatches':
|
||||
'SIMULATED · Roads: OpenStreetMap · Flow: TomTom (no matches)',
|
||||
'traffic.syncingRoads': 'Syncing flow · Roads: {source}',
|
||||
'traffic.liveRoadsFlowHidden':
|
||||
'LIVE · Roads: {source} · Roads without flow hidden',
|
||||
'traffic.liveRoadsNoFlowInView':
|
||||
'LIVE · Roads: {source} · No flow roads in view',
|
||||
'traffic.roadsFlowLive': 'LIVE · Roads: {source} · Flow {percent}%',
|
||||
'traffic.roadsFlowSimulated': 'SIMULATED · Roads: {source} · Flow {percent}%',
|
||||
'traffic.simUnavailable': 'SIMULATED — traffic service unreachable',
|
||||
'traffic.simKeyless': 'SIMULATED — add TomTom key for live',
|
||||
|
||||
@@ -470,6 +484,7 @@ export default {
|
||||
'awareness.cohort.vessels': 'AIS vessels',
|
||||
'awareness.cohort.installations': 'Mapped installations',
|
||||
'awareness.coverage.viewport': 'CURRENT VIEWPORT ONLY',
|
||||
'awareness.coverage.within': 'WITHIN {km} KM',
|
||||
'awareness.hdg': 'HDG {value}°',
|
||||
'awareness.brg': 'BRG {value}°',
|
||||
'awareness.brgUnknown': 'BRG —',
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
// Markup coverage gate (phase 2): every data-i18n* attribute in the static
|
||||
// application markup must name a key that resolves in EVERY shipped catalog
|
||||
// via the real catalog builder — a renamed or dropped catalog key can no
|
||||
// longer strand a static extraction silently behind the key-itself fallback.
|
||||
// index.html is now assembled from src/ui/templates/*.html by
|
||||
// build/application-html.js before Vite processes it, so the gate expands the
|
||||
// same templates the build serves and scans that assembled markup. Regex
|
||||
// parsing is deliberate: no DOM dependency in unit tests.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { getCatalog } from './index.js';
|
||||
import { CATALOG_LOCALES, availableLocales } from './locale.js';
|
||||
import { expandApplicationHtml } from '../../build/application-html.js';
|
||||
|
||||
const INDEX_HTML = new URL('../../index.html', import.meta.url);
|
||||
const UI_JS = new URL('../../src/ui/applicationShell.js', import.meta.url);
|
||||
|
||||
// The four spellings applyDocumentTranslations() knows about. Anything else
|
||||
// (a typo like data-i18n-lable) must fail here, not silently never apply.
|
||||
const I18N_ATTRIBUTE_PATTERN = /data-i18n(?:-title|-aria-label|-placeholder)?="([^"]+)"/g;
|
||||
const KNOWN_ATTRIBUTE_NAMES = new Set([
|
||||
'data-i18n',
|
||||
'data-i18n-title',
|
||||
'data-i18n-aria-label',
|
||||
'data-i18n-placeholder',
|
||||
]);
|
||||
|
||||
const html = expandApplicationHtml(readFileSync(INDEX_HTML, 'utf8'));
|
||||
|
||||
const references = [...html.matchAll(I18N_ATTRIBUTE_PATTERN)].map((match) => match[1]);
|
||||
|
||||
test('index.html carries static i18n references', () => {
|
||||
assert.ok(references.length > 0, 'no data-i18n* attributes found — extraction missing?');
|
||||
});
|
||||
|
||||
test('index.html sources its body from the application templates', () => {
|
||||
const raw = readFileSync(INDEX_HTML, 'utf8');
|
||||
assert.ok(raw.includes('<!-- gev:template '),
|
||||
'index.html must keep the gev:template markers the build expands');
|
||||
assert.equal((raw.match(/data-i18n/g) || []).length, 0,
|
||||
'data-i18n markup belongs in src/ui/templates/*.html, not the marker shell');
|
||||
});
|
||||
|
||||
test('every data-i18n* attribute value resolves in every shipped catalog', () => {
|
||||
const catalogs = Object.fromEntries(CATALOG_LOCALES.map((locale) => [locale, getCatalog(locale)]));
|
||||
for (const locale of CATALOG_LOCALES) {
|
||||
assert.ok(catalogs[locale], `catalog builder produced the ${locale} locale`);
|
||||
}
|
||||
const missing = references.filter((key) => (
|
||||
CATALOG_LOCALES.some((locale) => !(key in catalogs[locale]))
|
||||
));
|
||||
assert.deepEqual(
|
||||
[...new Set(missing)],
|
||||
[],
|
||||
'static markup references keys absent from a catalog (translation would fall back or render the key)',
|
||||
);
|
||||
});
|
||||
|
||||
test('no unknown data-i18n* attribute spelling sneaks past the application pass', () => {
|
||||
const stray = [...html.matchAll(/(data-i18n[a-z-]*)="/g)]
|
||||
.map((match) => match[1])
|
||||
.filter((name) => !KNOWN_ATTRIBUTE_NAMES.has(name));
|
||||
assert.deepEqual(
|
||||
[...new Set(stray)],
|
||||
[],
|
||||
'applyDocumentTranslations() only applies the four known attributes; a fifth spelling is dead markup',
|
||||
);
|
||||
});
|
||||
|
||||
test('attribute values are well-formed catalog keys', () => {
|
||||
for (const key of references) {
|
||||
assert.match(key, /^(shell|cockpit|layers|setup)\.[a-z][a-zA-Z0-9]*(\.[a-zA-Z0-9]+)*$/, `malformed key: ${key}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('the dock language switch keeps a static group container with runtime-rendered buttons', () => {
|
||||
const selector = html.match(/<div class="dock-locale-switch"[\s\S]*?<\/div>/);
|
||||
assert.ok(selector, 'the locale switch group is missing from the control-panel tray');
|
||||
assert.match(selector[0], /data-i18n-aria-label="shell\.locale\.groupAriaLabel"/);
|
||||
// Buttons are no longer static: ui.js _initLocaleSelector() renders one per
|
||||
// locale in the configured pair (GEV_DEFAULT_LOCALE / GEV_SECONDARY_LOCALE).
|
||||
assert.doesNotMatch(
|
||||
selector[0],
|
||||
/dock-locale-btn/,
|
||||
'static selector buttons must move to the runtime renderer so the configured pair shapes the switch',
|
||||
);
|
||||
// Markup-only contract: no listener may attach from markup.
|
||||
assert.doesNotMatch(selector[0], / onclick| onsubmit| javascript:/i);
|
||||
});
|
||||
|
||||
test('every offered locale has a shell.locale.<code>.ariaLabel in every catalog; the replaced family stays deleted', () => {
|
||||
// The runtime renderer reads shell.locale.<code>.ariaLabel for every locale
|
||||
// availableLocales() can yield under any configuration — i.e. every shipped
|
||||
// catalog locale — and it must exist in every catalog.
|
||||
for (const catalogLocale of CATALOG_LOCALES) {
|
||||
const catalog = getCatalog(catalogLocale);
|
||||
for (const code of CATALOG_LOCALES) {
|
||||
assert.ok(
|
||||
catalog[`shell.locale.${code}.ariaLabel`],
|
||||
`${catalogLocale} catalog is missing shell.locale.${code}.ariaLabel for the runtime selector`,
|
||||
);
|
||||
}
|
||||
assert.equal(catalog['shell.locale.english.ariaLabel'], undefined,
|
||||
'the replaced phase-2 english.ariaLabel key stays deleted');
|
||||
assert.equal(catalog['shell.locale.spanish.ariaLabel'], undefined,
|
||||
'the replaced phase-2 spanish.ariaLabel key stays deleted');
|
||||
}
|
||||
// Default configuration pins the pair the shipped build offers: en-only
|
||||
// while English is the only shipped catalog (the selector renders a single
|
||||
// EN button; each secondary-locale PR widens this pin).
|
||||
assert.deepEqual([...availableLocales()], ['en']);
|
||||
});
|
||||
|
||||
test('ui.js _initLocaleSelector renders the configured pair (runtime contract)', () => {
|
||||
const source = readFileSync(UI_JS, 'utf8');
|
||||
const start = source.indexOf('_initLocaleSelector() {');
|
||||
assert.ok(start >= 0, '_initLocaleSelector method not found in src/ui.js');
|
||||
const body = source.slice(start, source.indexOf('\n }\n', start));
|
||||
assert.ok(body.length > 0, '_initLocaleSelector body not extracted');
|
||||
assert.match(body, /availableLocales\(\)/, 'iterates the resolved pair, not a hardcoded EN|ES');
|
||||
assert.match(body, /shell\.locale\.\$\{locale\}\.ariaLabel/, 'aria-labels come from the locale key family');
|
||||
assert.match(body, /toUpperCase\(\)/, 'labels are uppercase locale codes');
|
||||
assert.match(body, /aria-pressed/, 'pressed state is synced');
|
||||
assert.match(body, /persistLocaleAndReload/, 'clicks persist and reload');
|
||||
assert.match(body, /dock-locale-switch/, 'renders into the static group container');
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
// Repair-pass anchors (extraction correctness).
|
||||
//
|
||||
// en behavior must stay byte-identical: the values added for the director
|
||||
// status corpus, the keySetup dev surface, and the map-source wiring are
|
||||
// pinned here to the exact literals the code rendered before extraction.
|
||||
// Cross-module anchors (keySetupCore registry) catch catalog/registry drift
|
||||
// in either direction. The locale-scoped CCTV wrap rules and per-locale value
|
||||
// pins live in their locale PRs (es first); this file pins the English
|
||||
// contract plus the pixel-identical English layout.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
import { getCatalog } from './index.js';
|
||||
import { KEY_SETUP_KEYS, keySetupRequirement } from '../keySetupCore.mjs';
|
||||
|
||||
const en = getCatalog('en');
|
||||
|
||||
test('director status corpus keeps the pre-extraction English literals byte-identical', () => {
|
||||
const expected = {
|
||||
'setup.scenes.status.captureCameraNotReady': 'Cannot capture shot: camera not ready',
|
||||
'setup.scenes.status.shotTitleDefault': 'Shot {n}',
|
||||
'setup.scenes.status.captured': 'Captured: {scene} / {shot}',
|
||||
'setup.scenes.status.selectShotFirst': 'Select a shot first',
|
||||
'setup.scenes.status.updated': 'Updated: {scene} / {shot}',
|
||||
'setup.scenes.status.deleteShotConfirm': 'Delete shot "{shot}"?',
|
||||
'setup.scenes.status.loaded': 'Loaded: {scene} / {shot}',
|
||||
'setup.scenes.status.cameraUnavailable': 'Camera unavailable — exit cockpit first',
|
||||
'setup.scenes.status.noShotsToRun': 'No shots to run',
|
||||
'setup.scenes.status.runningShot': 'Running {index}/{total}: {scene} / {shot}',
|
||||
'setup.scenes.status.runComplete': 'Scene run complete',
|
||||
'setup.scenes.status.runError': 'Error: {message}',
|
||||
'setup.scenes.status.contextExitFailed': 'Could not exit {mode} — scene layers may be refused',
|
||||
};
|
||||
for (const [key, value] of Object.entries(expected)) {
|
||||
assert.equal(en[key], value, `${key} drifted from the removed English literal`);
|
||||
}
|
||||
});
|
||||
|
||||
test('setup.keySetup.unlocks.* en values equal the keySetupCore registry copy', () => {
|
||||
for (const entry of KEY_SETUP_KEYS) {
|
||||
const key = `setup.keySetup.unlocks.${entry.id}`;
|
||||
assert.equal(en[key], entry.unlocks, `${key} drifted from KEY_SETUP_KEYS unlocks`);
|
||||
}
|
||||
assert.equal(en['setup.keySetup.requirement'], 'Needs {envVars} — add it in Provider Settings');
|
||||
assert.equal(
|
||||
keySetupRequirement('cesium-ion'),
|
||||
'Needs CESIUM_ION_TOKEN — add it in Provider Settings',
|
||||
'en requirement sentence must stay byte-identical (pinned by keySetupCore.test.mjs)',
|
||||
);
|
||||
});
|
||||
|
||||
test('map-source keys match the static markup pinned by mapStackChips.test.mjs', () => {
|
||||
assert.equal(en['cockpit.presets.mapSourceLabel'], 'MAP SOURCE');
|
||||
assert.equal(en['cockpit.presets.mapSourceChipsAriaLabel'], 'Map source');
|
||||
});
|
||||
|
||||
test('the seven redundant layers.radio transport seeds stay deleted', () => {
|
||||
const deleted = [
|
||||
'layers.radio.pause',
|
||||
'layers.radio.resume',
|
||||
'layers.radio.playNearestAria',
|
||||
'layers.radio.pauseSelectedAria',
|
||||
'layers.radio.pauseNearestAria',
|
||||
'layers.radio.resumeSelectedAria',
|
||||
'layers.radio.resumeNearestAria',
|
||||
];
|
||||
for (const key of deleted) {
|
||||
assert.equal(en[key], undefined, `${key} was removed by the repair pass`);
|
||||
}
|
||||
});
|
||||
|
||||
test('the English CCTV control rows stay unwrapped: no locale-scoped wrap rule ships ahead of its locale', () => {
|
||||
// Locale wrap rules (html[lang='…'] .cctv-controls { flex-wrap: wrap }) are
|
||||
// added one per locale PR, each with a measured-width justification — never
|
||||
// speculatively. Until then the base rule must stay unwrapped so English
|
||||
// pixels are unchanged, and no html[lang] override may exist.
|
||||
const css = readFileSync(new URL('../../src/ui/styles/cctv.css', import.meta.url), 'utf8');
|
||||
const baseStart = css.indexOf('.cctv-controls {');
|
||||
const baseBody = css.slice(baseStart, css.indexOf('}', baseStart));
|
||||
assert.ok(!baseBody.includes('flex-wrap'), 'base .cctv-controls must stay unwrapped (EN pixel-identical)');
|
||||
assert.ok(!/html\[lang=/i.test(css), 'no locale-scoped CCTV rule may ship before its locale does');
|
||||
});
|
||||
+38
-19
@@ -16,12 +16,20 @@ import { createSurfaceKeyboard } from './ui/surfaceKeyboard.js';
|
||||
* the chip and the dialog are removed outright.
|
||||
*/
|
||||
|
||||
/** Chip label — pure, exported for tests. */
|
||||
import { t, hasMessage } from './i18n/index.js';
|
||||
|
||||
/**
|
||||
* Chip label — pure, exported for tests. The exact English values are pinned
|
||||
* by keySetup.test.mjs; t() resolves them from the catalog, so the default
|
||||
* locale renders byte-identical text and 'es' localizes at the same site.
|
||||
* @param {{total?: number, setCount?: number}|null} status
|
||||
* @returns {string}
|
||||
*/
|
||||
export function keySetupChipLabel(status) {
|
||||
const missing = Math.max(0, (status?.total || 0) - (status?.setCount || 0));
|
||||
return missing > 0
|
||||
? `POWER UP · ${missing} ${missing === 1 ? 'KEY' : 'KEYS'} WAITING`
|
||||
: 'POWERED UP';
|
||||
? t('setup.keySetup.chipWaiting', { count: missing })
|
||||
: t('setup.keySetup.chipReady');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -113,7 +121,10 @@ function buildRow(documentRef, key) {
|
||||
|
||||
const unlocks = documentRef.createElement('p');
|
||||
unlocks.className = 'key-setup-unlocks';
|
||||
unlocks.textContent = key.unlocks;
|
||||
// The registry `unlocks` text localizes per registry id; an id missing from
|
||||
// the catalog falls back to the server-provided English copy.
|
||||
const unlocksKey = `setup.keySetup.unlocks.${key.id}`;
|
||||
unlocks.textContent = hasMessage(unlocksKey) ? t(unlocksKey) : key.unlocks;
|
||||
|
||||
row.append(head, unlocks);
|
||||
if (!external) {
|
||||
@@ -138,8 +149,8 @@ function buildRow(documentRef, key) {
|
||||
remove.type = 'button';
|
||||
remove.className = 'key-setup-remove';
|
||||
remove.dataset.keySetupRemove = JSON.stringify(key.envVars);
|
||||
remove.textContent = 'REMOVE';
|
||||
remove.title = `Remove ${key.title} from this app's saved keys`;
|
||||
remove.textContent = t('setup.keySetup.row.remove');
|
||||
remove.title = t('setup.keySetup.row.removeTitle', { title: key.title });
|
||||
fields.append(remove);
|
||||
}
|
||||
row.append(fields);
|
||||
@@ -284,17 +295,19 @@ export async function initKeySetup({
|
||||
|
||||
const storeLabel = () =>
|
||||
status?.store === 'pinokio-environment'
|
||||
? 'your app configuration'
|
||||
: 'your local .env';
|
||||
? t('setup.keySetup.store.pinokio')
|
||||
: t('setup.keySetup.store.env');
|
||||
|
||||
const submitUpdates = async (updates, doneVerb) => {
|
||||
// doneMessageKey names the whole saved/removed sentence; the storage
|
||||
// destination is interpolated so the {store} phrase stays one spelling.
|
||||
const submitUpdates = async (updates, doneMessageKey) => {
|
||||
if (disposed || busy) return;
|
||||
const googleWasUnset = !status?.keys?.find(
|
||||
(key) => key.id === 'google-maps',
|
||||
)?.set;
|
||||
busy = true;
|
||||
applyButton?.setAttribute('aria-disabled', 'true');
|
||||
say('Saving…');
|
||||
say(t('setup.keySetup.status.saving'));
|
||||
try {
|
||||
const response = await doFetch('/api/setup/keys', {
|
||||
method: 'POST',
|
||||
@@ -305,7 +318,11 @@ export async function initKeySetup({
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (disposed) return;
|
||||
if (!response.ok || !payload.ok) {
|
||||
say(payload.error || `Save failed (${response.status}).`);
|
||||
// payload.error is a server-authored message (API value) — shown as-is.
|
||||
say(
|
||||
payload.error ||
|
||||
t('setup.keySetup.status.saveFailed', { status: response.status }),
|
||||
);
|
||||
return;
|
||||
}
|
||||
for (const input of root.querySelectorAll('input[data-env-var]'))
|
||||
@@ -331,11 +348,13 @@ export async function initKeySetup({
|
||||
signal: lifetime.signal,
|
||||
});
|
||||
}
|
||||
say(
|
||||
`${doneVerb} ${storeLabel()}. Restarting — this page reloads itself.`,
|
||||
);
|
||||
say(t(doneMessageKey, { store: storeLabel() }));
|
||||
} catch (error) {
|
||||
say(`Save failed: ${error?.message || error}`);
|
||||
say(
|
||||
t('setup.keySetup.status.saveFailedDetail', {
|
||||
detail: error?.message || error,
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
busy = false;
|
||||
applyButton?.setAttribute('aria-disabled', 'false');
|
||||
@@ -352,10 +371,10 @@ export async function initKeySetup({
|
||||
})),
|
||||
);
|
||||
if (!Object.keys(updates).length) {
|
||||
say('Paste at least one key first.');
|
||||
say(t('setup.keySetup.status.pasteFirst'));
|
||||
return;
|
||||
}
|
||||
await submitUpdates(updates, 'Saved to');
|
||||
await submitUpdates(updates, 'setup.keySetup.status.saved');
|
||||
};
|
||||
|
||||
chip.addEventListener('click', openDialog);
|
||||
@@ -377,11 +396,11 @@ export async function initKeySetup({
|
||||
// a deliberate two-step the lure cannot pre-satisfy.
|
||||
const ok =
|
||||
typeof globalThis.confirm !== 'function' ||
|
||||
globalThis.confirm('Remove this key from your saved configuration?');
|
||||
globalThis.confirm(t('setup.keySetup.confirm.remove'));
|
||||
if (!ok) return;
|
||||
void submitUpdates(
|
||||
Object.fromEntries(envVars.map((name) => [name, null])),
|
||||
'Removed from',
|
||||
'setup.keySetup.status.removed',
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -334,6 +334,19 @@ export function knownKeySetupEnvVars() {
|
||||
}
|
||||
|
||||
/** Tooltip guidance for a control gated by one registry entry. */
|
||||
/**
|
||||
* Env-var requirements for a registry entry, as machine values for
|
||||
* presentation layers that localize the guidance sentence themselves
|
||||
* (this module also runs in node-side key-setup builds, which stay English).
|
||||
* @param {string} id Registry entry id.
|
||||
* @returns {string|null} Joined env-var names, or null when nothing is required.
|
||||
*/
|
||||
export function keySetupRequirementEnvVars(id) {
|
||||
const entry = KEY_SETUP_KEYS.find((candidate) => candidate.id === id);
|
||||
if (!entry || entry.hidden) return null;
|
||||
return entry.envVars.join(' + ');
|
||||
}
|
||||
|
||||
export function keySetupRequirement(id) {
|
||||
const entry = KEY_SETUP_KEYS.find((candidate) => candidate.id === id);
|
||||
if (!entry || entry.hidden) return '';
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
formatAwarenessDistance,
|
||||
AWARENESS_RADIUS_M,
|
||||
} from '../../data/militaryAwarenessEngine.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
import { AWARENESS_PAGE_SIZE, AWARENESS_PAGE_ROTATE_MS } from './policy.js';
|
||||
|
||||
export function createPanel({ state: layerState, services, parts, source }) {
|
||||
@@ -47,8 +48,8 @@ export function createPanel({ state: layerState, services, parts, source }) {
|
||||
function hidePanel() {
|
||||
if (layerState.panel) {
|
||||
const markup = `<div class="military-awareness-standby">
|
||||
<strong>${layerState.enabled ? 'CONTEXT READY' : 'GLOBAL CONTEXT OFF'}</strong>
|
||||
<span>${layerState.enabled ? 'SELECT A FLIGHT, VESSEL, OR MAPPED INSTALLATION' : 'ENABLE TO LOAD OBSERVED / MAPPED PROXIMITY'}</span>
|
||||
<strong>${layerState.enabled ? t('layers.awareness.standbyReady') : t('layers.awareness.standbyOff')}</strong>
|
||||
<span>${layerState.enabled ? t('layers.awareness.standbySelect') : t('layers.awareness.standbyEnable')}</span>
|
||||
</div>`;
|
||||
layerState.panel.hidden = false;
|
||||
if (layerState.panelMarkup !== markup) {
|
||||
@@ -69,10 +70,11 @@ export function createPanel({ state: layerState, services, parts, source }) {
|
||||
const label = formatAwarenessLabel(item);
|
||||
const targetId = item.icao24 || item.mmsi || item.id;
|
||||
if (!targetId) {
|
||||
return `<li><span class="military-awareness-target unavailable" aria-label="Unavailable">${escapeHtml(label)} <span>${formatAwarenessDistance(item.distanceM)}</span></span></li>`;
|
||||
return `<li><span class="military-awareness-target unavailable" aria-label="${t('layers.awareness.unavailableAria')}">${escapeHtml(label)} <span>${formatAwarenessDistance(item.distanceM)}</span></span></li>`;
|
||||
}
|
||||
const accessibleLabel = label === '—' ? 'Unavailable' : label;
|
||||
return `<li><button type="button" class="military-awareness-target" data-awareness-layer="${escapeHtml(cohort.id)}" data-awareness-id="${escapeHtml(targetId)}" aria-label="Focus ${escapeHtml(accessibleLabel)}">${escapeHtml(label)} <span>${formatAwarenessDistance(item.distanceM)}</span></button>${namedAreasHtml(item.memberNames)}</li>`;
|
||||
const accessibleLabel =
|
||||
label === '—' ? t('layers.awareness.unavailableAria') : label;
|
||||
return `<li><button type="button" class="military-awareness-target" data-awareness-layer="${escapeHtml(cohort.id)}" data-awareness-id="${escapeHtml(targetId)}" aria-label="${escapeHtml(t('layers.awareness.focusAria', { label: accessibleLabel }))}">${escapeHtml(label)} <span>${formatAwarenessDistance(item.distanceM)}</span></button>${namedAreasHtml(item.memberNames)}</li>`;
|
||||
})
|
||||
.join('');
|
||||
const pageCount = Math.max(
|
||||
@@ -93,10 +95,10 @@ export function createPanel({ state: layerState, services, parts, source }) {
|
||||
|
||||
function navigationControlsHtml() {
|
||||
const canPrevious = layerState.navigationIndex > 0;
|
||||
return `<div class="military-awareness-controls" role="group" aria-label="Global Context navigation">
|
||||
<button type="button" data-awareness-action="previous" title="Previous — prior visited contact in the 250 km window"${canPrevious ? '' : ' disabled'}>PREVIOUS</button>
|
||||
<button type="button" data-awareness-action="focus">FOCUS</button>
|
||||
<button type="button" data-awareness-action="next" title="Next — nearest unvisited contact in the 250 km window"${parts.navigation.canNavigateNext() ? '' : ' disabled'}>NEXT</button>
|
||||
return `<div class="military-awareness-controls" role="group" aria-label="${t('layers.awareness.controlsAria')}">
|
||||
<button type="button" data-awareness-action="previous" title="${t('layers.awareness.previousTitle')}"${canPrevious ? '' : ' disabled'}>${t('layers.awareness.previous')}</button>
|
||||
<button type="button" data-awareness-action="focus">${t('layers.awareness.focus')}</button>
|
||||
<button type="button" data-awareness-action="next" title="${t('layers.awareness.nextTitle')}"${parts.navigation.canNavigateNext() ? '' : ' disabled'}>${t('layers.awareness.next')}</button>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
@@ -178,7 +180,7 @@ export function createPanel({ state: layerState, services, parts, source }) {
|
||||
${namedAreasHtml(subject.memberNames)}
|
||||
${navigationControlsHtml()}
|
||||
${cohorts.map(rowHtml).join('')}
|
||||
<p class="military-awareness-note" tabindex="-1" data-awareness-focus-continuation>Open-source mapped/observed context. Missing broadcasts, unloaded map areas, or unmapped sites are not evidence of absence.</p>`;
|
||||
<p class="military-awareness-note" tabindex="-1" data-awareness-focus-continuation>${t('layers.awareness.note')}</p>`;
|
||||
panel.hidden = false;
|
||||
if (layerState.panelMarkup !== markup) {
|
||||
const focusSnapshot = captureAwarenessPanelFocus(panel);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { t } from '../../i18n/index.js';
|
||||
import {
|
||||
createCyberSonarSampler,
|
||||
isCyberContactSonarActive as isCyberSonarActive,
|
||||
@@ -137,7 +138,9 @@ export function createRendering({
|
||||
'--compass-rotation',
|
||||
`${-headingDeg}deg`,
|
||||
);
|
||||
layerState.compassHeading.textContent = `HDG ${String(headingDeg).padStart(3, '0')}°`;
|
||||
layerState.compassHeading.textContent = t('layers.awareness.hdg', {
|
||||
value: String(headingDeg).padStart(3, '0'),
|
||||
});
|
||||
layerState.compassHeading.style.left = `${geometry.centerX}px`;
|
||||
layerState.compassHeading.style.top = `${geometry.centerY - compassRadius + 39}px`;
|
||||
for (const label of layerState.compassLabels) {
|
||||
@@ -221,10 +224,14 @@ export function createRendering({
|
||||
)
|
||||
: null;
|
||||
const bearingText = Number.isFinite(bearing)
|
||||
? `BRG ${String(Math.round(bearing)).padStart(3, '0')}°`
|
||||
: 'BRG —';
|
||||
? t('layers.awareness.brg', {
|
||||
value: String(Math.round(bearing)).padStart(3, '0'),
|
||||
})
|
||||
: t('layers.awareness.brgUnknown');
|
||||
const courseText = Number.isFinite(item.track)
|
||||
? ` · CRS ${String(Math.round(item.track)).padStart(3, '0')}°`
|
||||
? ` · ${t('layers.awareness.crs', {
|
||||
value: String(Math.round(item.track)).padStart(3, '0'),
|
||||
})}`
|
||||
: '';
|
||||
marker._label.textContent = `${label} · ${formatAwarenessDistance(item.distanceM)}\n${bearingText}${courseText}`;
|
||||
marker.hidden = false;
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
AWARENESS_RELATIONSHIP,
|
||||
} from '../../data/militaryAwarenessEngine.js';
|
||||
import * as Cesium from 'cesium';
|
||||
import { t } from '../../i18n/index.js';
|
||||
import {
|
||||
AWARENESS_QUERY_LIMIT,
|
||||
SOURCE_LABEL,
|
||||
@@ -10,6 +11,21 @@ import {
|
||||
AWARENESS_PAGE_SIZE,
|
||||
} from './policy.js';
|
||||
|
||||
// installations/controls.js getStats composes coverageLabel inside the layer
|
||||
// model (machine-composed English, pinned by militaryInstallations.test.mjs).
|
||||
// This is the presentation boundary: map the closed vocabulary to the
|
||||
// catalog, passing anything unknown through verbatim (docs/TRANSLATORS.md).
|
||||
const WITHIN_KM_RE = /^WITHIN (\d+) KM$/;
|
||||
|
||||
function localizeCoverageLabel(label) {
|
||||
if (typeof label !== 'string' || !label) return label;
|
||||
if (label === 'CURRENT VIEWPORT ONLY')
|
||||
return t('layers.awareness.coverage.viewport');
|
||||
const within = WITHIN_KM_RE.exec(label);
|
||||
if (within) return t('layers.awareness.coverage.within', { km: within[1] });
|
||||
return label;
|
||||
}
|
||||
|
||||
export function createSubject({ state: layerState, services, parts, source }) {
|
||||
const aisLiveVesselsLayer = services.vessels;
|
||||
const militaryInstallationsLayer = services.installations;
|
||||
@@ -66,7 +82,7 @@ export function createSubject({ state: layerState, services, parts, source }) {
|
||||
cohorts: [
|
||||
{
|
||||
id: 'flights',
|
||||
label: 'Flights',
|
||||
label: t('layers.awareness.cohort.flights'),
|
||||
source: flightsState.stats.source || SOURCE_LABEL.flights,
|
||||
summary: parts.navigation.summarizeAwarenessCohortForNavigation(
|
||||
flights,
|
||||
@@ -75,7 +91,7 @@ export function createSubject({ state: layerState, services, parts, source }) {
|
||||
},
|
||||
{
|
||||
id: 'military',
|
||||
label: 'Military flights',
|
||||
label: t('layers.awareness.cohort.military'),
|
||||
source: militaryState.stats.source || SOURCE_LABEL.military,
|
||||
summary: parts.navigation.summarizeAwarenessCohortForNavigation(
|
||||
military,
|
||||
@@ -84,7 +100,7 @@ export function createSubject({ state: layerState, services, parts, source }) {
|
||||
},
|
||||
{
|
||||
id: 'ais-live-vessels',
|
||||
label: 'AIS vessels',
|
||||
label: t('layers.awareness.cohort.vessels'),
|
||||
source: vesselsState.stats.source || SOURCE_LABEL['ais-live-vessels'],
|
||||
summary: parts.navigation.summarizeAwarenessCohortForNavigation(
|
||||
vessels,
|
||||
@@ -93,12 +109,13 @@ export function createSubject({ state: layerState, services, parts, source }) {
|
||||
},
|
||||
{
|
||||
id: 'military-installations',
|
||||
label: 'Mapped installations',
|
||||
label: t('layers.awareness.cohort.installations'),
|
||||
source:
|
||||
installationsState.stats.source ||
|
||||
SOURCE_LABEL['military-installations'],
|
||||
coverage:
|
||||
installationsState.stats.coverageLabel || 'CURRENT VIEWPORT ONLY',
|
||||
localizeCoverageLabel(installationsState.stats.coverageLabel) ||
|
||||
t('layers.awareness.coverage.viewport'),
|
||||
summary: parts.queries.summarizeInstallationViewport(
|
||||
installations,
|
||||
installationsState,
|
||||
|
||||
Vendored
+5
-2
@@ -1,3 +1,4 @@
|
||||
import { t } from '../../i18n/index.js';
|
||||
import { STATUS_POLL_MS } from './policy.js';
|
||||
|
||||
export function createControls({ state: layerState, services, parts, source }) {
|
||||
@@ -34,8 +35,10 @@ export function createControls({ state: layerState, services, parts, source }) {
|
||||
if (layerState._loading) {
|
||||
stats.loadingLabel =
|
||||
layerState._activeCityIds.size > 0
|
||||
? `syncing ${layerState._activeCityIds.size} city feeds...`
|
||||
: 'scanning nearby systems...';
|
||||
? t('layers.bike.loadingSyncing', {
|
||||
count: layerState._activeCityIds.size,
|
||||
})
|
||||
: t('layers.bike.loadingScanning');
|
||||
}
|
||||
if (layerState._error) stats.error = layerState._error;
|
||||
return stats;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { t } from '../../i18n/index.js';
|
||||
import { isPointerFree } from '../../data/inputOwnership.js';
|
||||
import {
|
||||
BIKESHARE_SELECTED_OVERLAY_SOURCE_ID,
|
||||
@@ -22,7 +23,9 @@ export function createSelection({
|
||||
const stationName = String(record?.stationName || '').trim();
|
||||
const stationLabel =
|
||||
stationName ||
|
||||
(record?.stationId ? `Station ${record.stationId}` : 'Station');
|
||||
(record?.stationId
|
||||
? t('layers.bike.stationWithId', { id: record.stationId })
|
||||
: t('layers.bike.stationFallback'));
|
||||
const bikes = Number.isFinite(record?.bikesAvailable)
|
||||
? record.bikesAvailable
|
||||
: '?';
|
||||
@@ -33,14 +36,17 @@ export function createSelection({
|
||||
|
||||
const lines = [
|
||||
stationLabel,
|
||||
`🚲 ${bikes} avail · ${docks} docks · ${capacity} cap`,
|
||||
t('layers.bike.card.capacity', { bikes, docks, capacity }),
|
||||
];
|
||||
|
||||
// Append warnings for stations that are offline or partially non-operational
|
||||
const abnormal = [];
|
||||
if (record?.isInstalled === false) abnormal.push('⚠️ Not installed');
|
||||
if (record?.isRenting === false) abnormal.push('⚠️ Not renting');
|
||||
if (record?.isReturning === false) abnormal.push('⚠️ Not returning');
|
||||
if (record?.isInstalled === false)
|
||||
abnormal.push(t('layers.bike.warning.notInstalled'));
|
||||
if (record?.isRenting === false)
|
||||
abnormal.push(t('layers.bike.warning.notRenting'));
|
||||
if (record?.isReturning === false)
|
||||
abnormal.push(t('layers.bike.warning.notReturning'));
|
||||
if (abnormal.length > 0) {
|
||||
lines.push(abnormal.join(' · '));
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
||||
/**
|
||||
* @module headingConfidence
|
||||
*
|
||||
@@ -43,5 +45,9 @@ export function isHeadingEstimated(camera) {
|
||||
*/
|
||||
export function headingHudToken(camera) {
|
||||
const hdg = Math.round(Number(camera?.headingDeg) || 0);
|
||||
return `HDG ${hdg}°${isHeadingEstimated(camera) ? ' (ESTIMATED)' : ''}`;
|
||||
// The token copy lives in the layers catalog (en is byte-identical to the
|
||||
// literals these replace); presentation stays here, translation at read.
|
||||
return isHeadingEstimated(camera)
|
||||
? t('layers.cctv.summary.hdgEstimated', { value: hdg })
|
||||
: t('layers.cctv.summary.hdg', { value: hdg });
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { CCTV_AMBIENT_CARD_MAX } from '../../data/cctvLod.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
import { ACTIVE_FRAME_REFRESH_MS, IDLE_FRAME_REFRESH_MS } from './policy.js';
|
||||
import { headingHudToken, isHeadingEstimated } from './headingConfidence.js';
|
||||
|
||||
@@ -19,8 +20,10 @@ export function createPresentation({
|
||||
const active = parts.selection.getActiveRecord();
|
||||
if (!active) {
|
||||
return layerState._records.length
|
||||
? `${layerState._records.length} CAMERAS STANDING BY · NO CAMERA SELECTED · CLICK A CAMERA TO ACTIVATE`
|
||||
: 'No cameras available in catalog.';
|
||||
? t('layers.cctv.summary.standingBy', {
|
||||
count: layerState._records.length,
|
||||
})
|
||||
: t('layers.cctv.summary.empty');
|
||||
}
|
||||
|
||||
const area = parts.model.sectorAreaKm2(
|
||||
@@ -34,21 +37,37 @@ export function createPresentation({
|
||||
const calBadge = parts.calibration.deriveCalBadge(active.camera);
|
||||
|
||||
return [
|
||||
`${active.camera.city.toUpperCase()} CCTV`,
|
||||
t('layers.cctv.summary.city', {
|
||||
city: active.camera.city.toUpperCase(),
|
||||
}),
|
||||
`${active.camera.name.toUpperCase()}`,
|
||||
// A synthetic bearing (headingConfidence 'low', no human calibration)
|
||||
// is tagged so a hashed guess never reads as a surveyed facing (#639).
|
||||
headingHudToken(active.camera),
|
||||
`FOV ${Math.round(active.camera.fovDeg)}°`,
|
||||
`COVERAGE ${area.toFixed(2)}km²`,
|
||||
overlapCount > 0 ? `OVERLAP ${overlapCount} cams` : 'ISOLATED VIEW',
|
||||
`PROJ ${layerState._showProjection ? 'MONITOR' : 'OFF'}`,
|
||||
layerState._coverageMode === 'viewshed' ? 'VIEWSHED' : null,
|
||||
`CAL ${calBadge.replace('-', ' ').toUpperCase()}`,
|
||||
t('layers.cctv.summary.fov', {
|
||||
value: Math.round(active.camera.fovDeg),
|
||||
}),
|
||||
t('layers.cctv.summary.coverage', { value: area.toFixed(2) }),
|
||||
overlapCount > 0
|
||||
? t('layers.cctv.summary.overlap', { count: overlapCount })
|
||||
: t('layers.cctv.summary.isolated'),
|
||||
layerState._showProjection
|
||||
? t('layers.cctv.summary.projMonitor')
|
||||
: t('layers.cctv.summary.projOff'),
|
||||
layerState._coverageMode === 'viewshed'
|
||||
? t('layers.cctv.summary.viewshed')
|
||||
: null,
|
||||
t('layers.cctv.summary.cal', {
|
||||
value: calBadge.replace('-', ' ').toUpperCase(),
|
||||
}),
|
||||
health?.sourceKind
|
||||
? `SRC ${String(health.sourceKind).toUpperCase()}`
|
||||
: `SRC ${String(active.camera.feedType || 'image').toUpperCase()}`,
|
||||
`${viewBand.toUpperCase()} CONTEXT`,
|
||||
? t('layers.cctv.summary.src', {
|
||||
value: String(health.sourceKind).toUpperCase(),
|
||||
})
|
||||
: t('layers.cctv.summary.src', {
|
||||
value: String(active.camera.feedType || 'image').toUpperCase(),
|
||||
}),
|
||||
t('layers.cctv.summary.context', { value: viewBand.toUpperCase() }),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ');
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { t } from '../../i18n/index.js';
|
||||
import {
|
||||
FIRMS_OVERLAY_SOURCE_ID,
|
||||
FIRMS_AMBIENT_COHORT_LIMIT,
|
||||
@@ -115,7 +116,10 @@ export function createCards({
|
||||
if (!card.interactive) return card;
|
||||
return {
|
||||
...card,
|
||||
accessibilityLabel: `Focus fire detection ${card.title}, ${card.details.join(', ')}`,
|
||||
accessibilityLabel: t('layers.firms.card.focusAria', {
|
||||
title: card.title,
|
||||
details: card.details.join(', '),
|
||||
}),
|
||||
activate: () => {
|
||||
const fire = layerState._fireByCardId.get(card.id);
|
||||
if (!fire) return false;
|
||||
|
||||
+27
-13
@@ -1,4 +1,5 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { t } from '../../i18n/index.js';
|
||||
import {
|
||||
satelliteShortName,
|
||||
fireDetectionKey,
|
||||
@@ -327,14 +328,20 @@ export function createModel({
|
||||
*/
|
||||
|
||||
function buildSelectedFireCard(fire, nowMs) {
|
||||
const meta = [`${confidenceBucket(fire.confidence)} conf`];
|
||||
const meta = [
|
||||
t('layers.firms.card.confSuffix', {
|
||||
value: confidenceBucket(fire.confidence),
|
||||
}),
|
||||
];
|
||||
if (fire.acqMs > 0) {
|
||||
const age = formatAge(nowMs - fire.acqMs);
|
||||
if (age) meta.push(`${age} ago`);
|
||||
if (age) meta.push(t('layers.firms.card.ageSuffix', { value: age }));
|
||||
}
|
||||
const sat = satelliteShortName(fire.satellite);
|
||||
meta.push(
|
||||
sat ? `${fire.sensor || 'VIIRS'} ${sat}` : fire.sensor || 'sensor n/a',
|
||||
sat
|
||||
? `${fire.sensor || 'VIIRS'} ${sat}`
|
||||
: fire.sensor || t('layers.firms.card.sensorUnavailable'),
|
||||
);
|
||||
return {
|
||||
id: `selected-fire:${fireDetectionKey(fire)}`,
|
||||
@@ -344,10 +351,11 @@ export function createModel({
|
||||
cullPosition: fireCullPosition(fire),
|
||||
gapPx: frpPixelSize(fire.frp),
|
||||
accent: accentForSeverity(detectionColorStop(fire).name),
|
||||
title: `FIRE · ${formatFrp(fire.frp)} MW`,
|
||||
title: t('layers.firms.card.fire', { frp: formatFrp(fire.frp) }),
|
||||
details: [
|
||||
meta.join(' · '),
|
||||
formatLatLon(fire.lat, fire.lon) + (fire.night ? ' · NIGHT' : ''),
|
||||
formatLatLon(fire.lat, fire.lon) +
|
||||
(fire.night ? ` · ${t('layers.firms.card.night')}` : ''),
|
||||
],
|
||||
selected: true,
|
||||
priority: Number.MAX_SAFE_INTEGER,
|
||||
@@ -380,7 +388,7 @@ export function createModel({
|
||||
cullPosition: candidate.cullPosition || candidate.position,
|
||||
gapPx: frpPixelSize(fire.frp),
|
||||
accent: accentForSeverity(detectionColorStop(fire).name),
|
||||
title: `▲ ${formatFrp(fire.frp)} MW`,
|
||||
title: t('layers.firms.card.ambient', { frp: formatFrp(fire.frp) }),
|
||||
details: [meta.join(' · ')],
|
||||
selected: false,
|
||||
priority: Number(fire.frp) || 0,
|
||||
@@ -398,11 +406,16 @@ export function createModel({
|
||||
|
||||
function buildCellCard(candidate, nowMs) {
|
||||
const cell = candidate.cell;
|
||||
const noun = cell.count === 1 ? 'FIRE' : 'FIRES';
|
||||
const parts = [`max ${formatFrp(cell.maxFrp)} MW`];
|
||||
const noun =
|
||||
cell.count === 1
|
||||
? t('layers.firms.card.fireNoun')
|
||||
: t('layers.firms.card.firesNoun');
|
||||
const parts = [
|
||||
t('layers.firms.card.maxFrp', { value: formatFrp(cell.maxFrp) }),
|
||||
];
|
||||
if (cell.newestAcqMs > 0) {
|
||||
const age = formatAge(nowMs - cell.newestAcqMs);
|
||||
if (age) parts.push(`new ${age}`);
|
||||
if (age) parts.push(t('layers.firms.card.newAge', { value: age }));
|
||||
}
|
||||
return {
|
||||
id: `cell:${cell.latCell ?? 'x'}:${cell.lonCell ?? 'x'}`,
|
||||
@@ -488,11 +501,12 @@ export function createModel({
|
||||
/** Millisecond delta → "<1m ago" / "Xm ago" / "Xh ago" (fresh-feed readout). */
|
||||
|
||||
function formatAgoMinutes(deltaMs) {
|
||||
if (!Number.isFinite(deltaMs) || deltaMs < 0) return 'just now';
|
||||
if (!Number.isFinite(deltaMs) || deltaMs < 0)
|
||||
return t('layers.meta.justNow');
|
||||
const minutes = Math.floor(deltaMs / 60000);
|
||||
if (minutes < 1) return '<1m ago';
|
||||
if (minutes < 90) return `${minutes}m ago`;
|
||||
return `${Math.round(minutes / 60)}h ago`;
|
||||
if (minutes < 1) return t('layers.firms.underMinuteAgo');
|
||||
if (minutes < 90) return t('layers.meta.minutesAgo', { count: minutes });
|
||||
return t('layers.meta.hoursAgo', { count: Math.round(minutes / 60) });
|
||||
}
|
||||
|
||||
/** Normalized 0..1 confidence → low/nominal/high display bucket. */
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { fireDetectionKey } from '../../data/firmsLabels.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
import { REFRESH_INTERVAL_MS } from './policy.js';
|
||||
|
||||
export function createQueries({
|
||||
@@ -59,21 +60,26 @@ export function createQueries({
|
||||
getStats() {
|
||||
const now = Date.now();
|
||||
const staleText = layerState._lastUpdate
|
||||
? `STALE · cached ${components.model.formatAge(now - layerState._lastUpdate) || '<1h'}`
|
||||
: 'STALE';
|
||||
? t('layers.firms.staleCached', {
|
||||
age:
|
||||
components.model.formatAge(now - layerState._lastUpdate) || '<1h',
|
||||
})
|
||||
: t('layers.status.stale');
|
||||
let loadingLabel = '';
|
||||
if (layerState._loading) {
|
||||
loadingLabel = layerState._fires.length
|
||||
? 'refreshing...'
|
||||
: 'loading...';
|
||||
? t('layers.meta.refreshing')
|
||||
: t('layers.meta.loading');
|
||||
} else if (layerState._keyRequired) {
|
||||
loadingLabel = 'KEY REQUIRED';
|
||||
loadingLabel = t('layers.firms.keyRequired');
|
||||
} else if (layerState._stale) {
|
||||
loadingLabel = staleText;
|
||||
} else if (layerState._error) {
|
||||
loadingLabel = layerState._error;
|
||||
} else if (layerState._lastUpdate) {
|
||||
loadingLabel = `LIVE · updated ${components.model.formatAgoMinutes(now - layerState._lastUpdate)}`;
|
||||
loadingLabel = t('layers.firms.liveUpdated', {
|
||||
age: components.model.formatAgoMinutes(now - layerState._lastUpdate),
|
||||
});
|
||||
}
|
||||
return {
|
||||
count: layerState._count,
|
||||
@@ -87,7 +93,7 @@ export function createQueries({
|
||||
// the operator can take.
|
||||
keyRequired: layerState._keyRequired,
|
||||
error: layerState._keyRequired
|
||||
? 'KEY REQUIRED'
|
||||
? t('layers.firms.keyRequired')
|
||||
: layerState._stale
|
||||
? staleText
|
||||
: layerState._error,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { t } from '../../i18n/index.js';
|
||||
import { nextCockpitNearContacts } from '../../data/cockpitAirLod.js';
|
||||
import { trackedModelZoomActive } from '../../data/trackedModelRegime.js';
|
||||
import {
|
||||
@@ -755,7 +756,7 @@ export function createTracking({
|
||||
const spd = info.velocity ? `${Math.round(info.velocity * 1.944)} kts` : '';
|
||||
const stale =
|
||||
flightState.records.missingPolls.get(icao24) || flightState.feed._backoff
|
||||
? 'STALE'
|
||||
? t('layers.status.stale')
|
||||
: '';
|
||||
const lines = [[cs, fl, spd, stale].filter(Boolean).join(' · ')];
|
||||
// Converted contacts report their class as TR-3B and nothing else — the
|
||||
|
||||
Vendored
+55
-6
@@ -5,6 +5,53 @@ import {
|
||||
DISTANCE_PREFILTER_MARGIN_M,
|
||||
ANCHOR_REFRESH_M,
|
||||
} from './policy.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
||||
// installationFeedback.js composes its guidance inside the portable
|
||||
// node/browser graph (the server-side HUD summary reads the same module), so
|
||||
// its copy stays English there. getStats is the browser presentation
|
||||
// boundary: map the closed message vocabulary to the catalog, passing any
|
||||
// live provider error through verbatim (docs/TRANSLATORS.md).
|
||||
const INSTALLATION_FEEDBACK_KEYS = Object.freeze({
|
||||
'Retrying mapped sites…': 'layers.installations.feedback.retrying',
|
||||
'Fetching mapped sites…': 'layers.installations.feedback.fetching',
|
||||
'Zoom in to search mapped installations':
|
||||
'layers.installations.feedback.zoomIn',
|
||||
'Showing cached mapped sites': 'layers.installations.feedback.cached',
|
||||
'Mapped sites not loaded': 'layers.installations.feedback.notLoaded',
|
||||
'Mapped sites loaded': 'layers.installations.feedback.loaded',
|
||||
'Overpass rate-limited': 'layers.installations.feedback.reason.rateLimited',
|
||||
'Overpass timed out': 'layers.installations.feedback.reason.timeout',
|
||||
'Overpass could not complete the query':
|
||||
'layers.installations.feedback.reason.queryFailed',
|
||||
'Overpass temporarily unavailable':
|
||||
'layers.installations.feedback.reason.unavailable',
|
||||
});
|
||||
|
||||
const INSTALLATION_RETRY_IN_RE = /^(.*) — retrying in (\d+)s$/;
|
||||
const INSTALLATION_RETRY_PENDING_RE = /^(.*) — retry pending$/;
|
||||
|
||||
function localizeInstallationFeedback(message) {
|
||||
if (typeof message !== 'string' || !message) return message;
|
||||
const key = INSTALLATION_FEEDBACK_KEYS[message];
|
||||
if (key) return t(key);
|
||||
const retryIn = message.match(INSTALLATION_RETRY_IN_RE);
|
||||
if (retryIn) {
|
||||
const reasonKey = INSTALLATION_FEEDBACK_KEYS[retryIn[1]];
|
||||
return t('layers.installations.feedback.retryIn', {
|
||||
reason: reasonKey ? t(reasonKey) : retryIn[1],
|
||||
seconds: retryIn[2],
|
||||
});
|
||||
}
|
||||
const pending = message.match(INSTALLATION_RETRY_PENDING_RE);
|
||||
if (pending) {
|
||||
const reasonKey = INSTALLATION_FEEDBACK_KEYS[pending[1]];
|
||||
return t('layers.installations.feedback.retryPending', {
|
||||
reason: reasonKey ? t(reasonKey) : pending[1],
|
||||
});
|
||||
}
|
||||
return message;
|
||||
}
|
||||
|
||||
export function createControls({ state: layerState, services, parts, source }) {
|
||||
const methods = {
|
||||
@@ -196,13 +243,15 @@ export function createControls({ state: layerState, services, parts, source }) {
|
||||
retryAt: layerState.retryAt,
|
||||
retrying: layerState.loading && Boolean(layerState.failureReason),
|
||||
failureReason: layerState.failureReason,
|
||||
statusMessage: installationFeedback({
|
||||
...layerState,
|
||||
count: layerState.lastUpdate ? mappedCount : undefined,
|
||||
retrying: layerState.loading && Boolean(layerState.failureReason),
|
||||
}),
|
||||
statusMessage: localizeInstallationFeedback(
|
||||
installationFeedback({
|
||||
...layerState,
|
||||
count: layerState.lastUpdate ? mappedCount : undefined,
|
||||
retrying: layerState.loading && Boolean(layerState.failureReason),
|
||||
}),
|
||||
),
|
||||
loadingLabel: layerState.loading
|
||||
? 'loading mapped installation context'
|
||||
? t('layers.installations.loading')
|
||||
: '',
|
||||
coverage: layerState.coverage,
|
||||
coverageLabel:
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { t } from '../../i18n/index.js';
|
||||
import { LAYER_ID, MAX_RENDERED, COLOR_BY_CLASS } from './policy.js';
|
||||
|
||||
export function createRendering({
|
||||
@@ -270,7 +271,7 @@ export function createRendering({
|
||||
entity.gevTrackedId = `installations:${record.id}`;
|
||||
entity.gevDisplayPosition = () => displayPosition;
|
||||
entity.gevLabelModel = {
|
||||
title: record.name || 'MAPPED INSTALLATION',
|
||||
title: record.name || t('layers.installations.fallbackTitle'),
|
||||
details: [
|
||||
String(record.class || 'installation')
|
||||
.replaceAll('_', ' ')
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { t } from '../../i18n/index.js';
|
||||
import {
|
||||
createCyberSonarSampler,
|
||||
isCyberContactSonarActive as isCyberSonarActive,
|
||||
@@ -119,8 +120,10 @@ export function createOverlays({ state: layerState, services, parts, source }) {
|
||||
const details = selected
|
||||
? [
|
||||
siteName
|
||||
? `LAUNCH SITE · ${shortMissionLabel(siteName, 20).toUpperCase()}`
|
||||
: 'LAUNCH SITE',
|
||||
? t('layers.missions.launchSiteValue', {
|
||||
site: shortMissionLabel(siteName, 20).toUpperCase(),
|
||||
})
|
||||
: t('layers.missions.launchSite'),
|
||||
]
|
||||
: [];
|
||||
return {
|
||||
@@ -586,27 +589,38 @@ export function createOverlays({ state: layerState, services, parts, source }) {
|
||||
const mission = shortMissionLabel(launch.name, 22).toUpperCase();
|
||||
const siteName = compactLaunchSiteName(launch.launchSite);
|
||||
const siteCallout = siteName
|
||||
? `LAUNCH SITE · ${shortMissionLabel(siteName, 20).toUpperCase()}`
|
||||
: 'LAUNCH SITE';
|
||||
? t('layers.missions.launchSiteValue', {
|
||||
site: shortMissionLabel(siteName, 20).toUpperCase(),
|
||||
})
|
||||
: t('layers.missions.launchSite');
|
||||
let title = mission;
|
||||
let detail = siteCallout;
|
||||
if (mode === 'countdown') {
|
||||
title = `T−${String(state.countdownSeconds).padStart(2, '0')} · ${mission}`;
|
||||
detail = `LAUNCH STANDBY\n${siteCallout}`;
|
||||
title = t('layers.missions.replay.countdown', {
|
||||
time: String(state.countdownSeconds).padStart(2, '0'),
|
||||
mission,
|
||||
});
|
||||
detail = `${t('layers.missions.replay.standbyDetail')}\n${siteCallout}`;
|
||||
} else if (mode === 'ascent') {
|
||||
title =
|
||||
state.elapsedSinceStart < 1
|
||||
? `LIFTOFF · ${mission}`
|
||||
: `${launch.trajectory.length > 1 ? 'ASCENT REPLAY' : 'ASCENT ESTIMATE'} · ${mission}`;
|
||||
? t('layers.missions.replay.liftoff', { mission })
|
||||
: t(
|
||||
launch.trajectory.length > 1
|
||||
? 'layers.missions.replay.ascentReplay'
|
||||
: 'layers.missions.replay.ascentEstimate',
|
||||
{ mission },
|
||||
);
|
||||
detail = parts.policyHelpers.formatMissionEventTime(state.eventTime);
|
||||
} else if (mode === 'recovery') {
|
||||
title = `STAGE RE-ENTRY / RECOVERY · ${mission}`;
|
||||
title = t('layers.missions.replay.recovery', { mission });
|
||||
detail = parts.policyHelpers.formatMissionEventTime(state.eventTime);
|
||||
} else if (mode === 'orbit') {
|
||||
title = `ORBIT REPLAY · ${mission}`;
|
||||
title = t('layers.missions.replay.orbit', { mission });
|
||||
detail = parts.policyHelpers.formatMissionEventTime(state.eventTime);
|
||||
}
|
||||
if (layerState._replayPaused) title = `PAUSED · ${title}`;
|
||||
if (layerState._replayPaused)
|
||||
title = t('layers.missions.replay.pausedPrefix', { title });
|
||||
const nextText = `${title}\n${detail}`;
|
||||
if (nextText !== layerState._replayVehicleOverlayText) {
|
||||
layerState._replayVehicleOverlayText = nextText;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { t } from '../../i18n/index.js';
|
||||
import {
|
||||
MISSION_CLOSE_VIEW_RANGE_M,
|
||||
MISSION_GLOBE_VIEW_RANGE_M,
|
||||
@@ -219,34 +220,42 @@ export function createPanel({ state: layerState, services, parts, source }) {
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ');
|
||||
return `<tr><td>${escapeMissionText(payload.name)}${payload.amount > 1 ? ` ×${payload.amount}` : ''}${detail ? `<small>${escapeMissionText(detail)}</small>` : ''}</td><td>${escapeMissionText(payload.type || 'UNSPECIFIED')}</td><td>${escapeMissionText(payload.destination || launch.orbit?.name || 'UNAVAILABLE')}</td></tr>`;
|
||||
return `<tr><td>${escapeMissionText(payload.name)}${payload.amount > 1 ? ` ×${payload.amount}` : ''}${detail ? `<small>${escapeMissionText(detail)}</small>` : ''}</td><td>${escapeMissionText(payload.type || t('layers.missions.rows.unspecified'))}</td><td>${escapeMissionText(payload.destination || launch.orbit?.name || t('layers.missions.rows.unavailable'))}</td></tr>`;
|
||||
})
|
||||
: [];
|
||||
if (launch.payloads.length > 5) {
|
||||
payloadRows.push(
|
||||
`<tr><td colspan="3" class="mission-table-empty">+${launch.payloads.length - 5} additional payload records</td></tr>`,
|
||||
`<tr><td colspan="3" class="mission-table-empty">${t('layers.missions.rows.additionalPayloads', { count: launch.payloads.length - 5 })}</td></tr>`,
|
||||
);
|
||||
}
|
||||
layerState._missionPanel.querySelector(
|
||||
'[data-mission-payloads]',
|
||||
).innerHTML = missionTableRows(payloadRows, 3, 'PAYLOAD DATA UNAVAILABLE');
|
||||
).innerHTML = missionTableRows(
|
||||
payloadRows,
|
||||
3,
|
||||
t('layers.missions.rows.classified'),
|
||||
);
|
||||
const stageRows = launch.recoveryStages.map((stage) => {
|
||||
const endpoint = stage.endpoint;
|
||||
const destination =
|
||||
stage.destination ||
|
||||
(endpoint?.accuracy === 'PAD / RTLS'
|
||||
? launch.launchSite
|
||||
: 'UNAVAILABLE');
|
||||
: t('layers.missions.rows.unavailable'));
|
||||
const position = endpoint
|
||||
? `${endpoint.lat.toFixed(2)}, ${endpoint.lon.toFixed(2)} · ${endpoint.accuracy}`
|
||||
: stage.downrangeKm > 0
|
||||
? `${stage.downrangeKm.toLocaleString()} KM DOWNRANGE`
|
||||
: 'POSITION UNAVAILABLE';
|
||||
? t('layers.missions.rows.kmDownrange', {
|
||||
value: stage.downrangeKm.toLocaleString(),
|
||||
})
|
||||
: t('layers.missions.rows.positionUnavailable');
|
||||
const stageDetail = [
|
||||
Number.isFinite(stage.flightNumber)
|
||||
? `FLIGHT ${stage.flightNumber}`
|
||||
? t('layers.missions.rows.flightNumber', {
|
||||
count: stage.flightNumber,
|
||||
})
|
||||
: null,
|
||||
stage.reused ? 'REUSED' : null,
|
||||
stage.reused ? t('layers.missions.rows.reused') : null,
|
||||
stage.recoveryType,
|
||||
]
|
||||
.filter(Boolean)
|
||||
@@ -254,7 +263,7 @@ export function createPanel({ state: layerState, services, parts, source }) {
|
||||
return `<tr><td>${escapeMissionText(stage.name)}${stageDetail ? `<small>${escapeMissionText(stageDetail)}</small>` : ''}</td><td>${escapeMissionText(stage.status)}</td><td>${escapeMissionText(destination)}<small>${escapeMissionText(position)}</small></td></tr>`;
|
||||
});
|
||||
layerState._missionPanel.querySelector('[data-mission-stages]').innerHTML =
|
||||
missionTableRows(stageRows, 3, 'NO STAGE RE-ENTRY / RECOVERY DATA');
|
||||
missionTableRows(stageRows, 3, t('layers.missions.rows.noStageData'));
|
||||
const stageSection = layerState._missionPanel.querySelector(
|
||||
'[data-mission-stages-section]',
|
||||
);
|
||||
@@ -281,7 +290,10 @@ export function createPanel({ state: layerState, services, parts, source }) {
|
||||
const count = layerState._missionRoster.querySelector(
|
||||
'[data-mission-roster-count]',
|
||||
);
|
||||
if (count) count.textContent = `${layerState._launches.length} / 30D`;
|
||||
if (count)
|
||||
count.textContent = t('layers.missions.roster.count', {
|
||||
count: layerState._launches.length,
|
||||
});
|
||||
if (!list) return;
|
||||
const focusSnapshot = captureMissionRosterFocus(list);
|
||||
layerState._missionRosterPreviewOwnership?.reset();
|
||||
@@ -289,8 +301,7 @@ export function createPanel({ state: layerState, services, parts, source }) {
|
||||
layerState._launches,
|
||||
);
|
||||
if (!entries.length) {
|
||||
list.innerHTML =
|
||||
'<div class="space-mission-roster-empty">NO MISSIONS AVAILABLE IN THE CURRENT 30-DAY WINDOW</div>';
|
||||
list.innerHTML = `<div class="space-mission-roster-empty">${t('layers.missions.roster.empty')}</div>`;
|
||||
restoreMissionRosterFocus(
|
||||
list,
|
||||
focusSnapshot,
|
||||
@@ -303,12 +314,15 @@ export function createPanel({ state: layerState, services, parts, source }) {
|
||||
list.innerHTML = entries
|
||||
.map(({ launch, index }) => {
|
||||
const color = parts.model.missionMarkerColor(launch).toCssColorString();
|
||||
const date = launch.launchTime?.slice(0, 10) || 'DATE UNAVAILABLE';
|
||||
const provider = launch.provider || 'UNSPECIFIED OPERATOR';
|
||||
const date =
|
||||
launch.launchTime?.slice(0, 10) ||
|
||||
t('layers.missions.roster.dateUnavailable');
|
||||
const provider =
|
||||
launch.provider || t('layers.missions.roster.unspecifiedOperator');
|
||||
const label = parts.overlays
|
||||
.shortMissionLabel(launch.name, 27)
|
||||
.toUpperCase();
|
||||
return `<button type="button" class="space-mission-roster-item" data-mission-roster-index="${index}" data-mission-roster-id="${escapeMissionText(launch.id)}" aria-label="Select ${escapeMissionText(label)}"><span class="space-mission-roster-marker" style="--mission-roster-color:${color}" aria-hidden="true"></span><span class="space-mission-roster-copy"><strong>${escapeMissionText(label)}</strong><small>${escapeMissionText(provider)} · ${escapeMissionText(date)}</small></span><span class="space-mission-roster-chevron" aria-hidden="true">›</span></button>`;
|
||||
return `<button type="button" class="space-mission-roster-item" data-mission-roster-index="${index}" data-mission-roster-id="${escapeMissionText(launch.id)}" aria-label="${escapeMissionText(t('layers.missions.roster.selectAria', { mission: label }))}"><span class="space-mission-roster-marker" style="--mission-roster-color:${color}" aria-hidden="true"></span><span class="space-mission-roster-copy"><strong>${escapeMissionText(label)}</strong><small>${escapeMissionText(provider)} · ${escapeMissionText(date)}</small></span><span class="space-mission-roster-chevron" aria-hidden="true">›</span></button>`;
|
||||
})
|
||||
.join('');
|
||||
list.querySelectorAll('[data-mission-roster-index]').forEach((button) => {
|
||||
@@ -365,7 +379,11 @@ export function createPanel({ state: layerState, services, parts, source }) {
|
||||
setMissionPanelField(
|
||||
'[data-mission-distance]',
|
||||
Number.isFinite(altitudeM)
|
||||
? `${Math.max(0, altitudeM / 1000).toLocaleString(undefined, { maximumFractionDigits: 0 })} KM`
|
||||
? t('layers.missions.telemetry.km', {
|
||||
value: Math.max(0, altitudeM / 1000).toLocaleString(undefined, {
|
||||
maximumFractionDigits: 0,
|
||||
}),
|
||||
})
|
||||
: null,
|
||||
);
|
||||
const speedMps = layerState._satelliteTelemetry.get(
|
||||
@@ -374,7 +392,12 @@ export function createPanel({ state: layerState, services, parts, source }) {
|
||||
setMissionPanelField(
|
||||
'[data-mission-speed]',
|
||||
Number.isFinite(speedMps)
|
||||
? `${(speedMps / 1000).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })} KM/S`
|
||||
? t('layers.missions.telemetry.kmPerSecond', {
|
||||
value: (speedMps / 1000).toLocaleString(undefined, {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
}),
|
||||
})
|
||||
: null,
|
||||
Number.isFinite(speedMps)
|
||||
? `${(speedMps * 3.6).toLocaleString(undefined, { maximumFractionDigits: 0 })} km/h`
|
||||
@@ -450,16 +473,16 @@ export function createPanel({ state: layerState, services, parts, source }) {
|
||||
layerState._missionPanel.className = 'context-space-mission-detail';
|
||||
layerState._missionPanel.setAttribute(
|
||||
'aria-label',
|
||||
'Selected Space Mission',
|
||||
t('layers.missions.panel.selectedHeader'),
|
||||
);
|
||||
layerState._missionPanel.innerHTML = `<div class="space-mission-view-header"><span>SELECTED SPACE MISSION</span><button type="button" data-mission-close title="Show all missions" aria-label="Deselect mission">×</button></div><div class="space-mission-detail"><strong data-mission-title>MISSION</strong><span data-mission-field data-mission-provider></span><span data-mission-field>STATUS · <b data-mission-status></b></span><span data-mission-field>LAUNCH SITE · <b data-mission-site></b></span><span data-mission-field>LAUNCH TIME · <b data-mission-time></b></span><span data-mission-field>ORBIT · <b data-mission-orbit></b></span><span>ASCENT PATH · <b data-mission-ascent-source></b></span><span data-mission-field>CURRENT DISTANCE FROM EARTH · <b data-mission-distance></b></span><span data-mission-field>SATELLITE SPEED · <b data-mission-speed></b></span></div><section class="mission-data-section"><h4>PAYLOAD</h4><div class="mission-table-scroll"><table class="mission-data-table"><thead><tr><th>NAME</th><th>TYPE</th><th>DESTINATION</th></tr></thead><tbody data-mission-payloads></tbody></table></div></section><section class="mission-data-section" data-mission-stages-section><h4>STAGE / RE-ENTRY / RECOVERY</h4><div class="mission-table-scroll"><table class="mission-data-table"><thead><tr><th>STAGE</th><th>STATUS</th><th>FINAL POSITION</th></tr></thead><tbody data-mission-stages></tbody></table></div></section><div class="mission-replay-speed-control"><div class="mission-replay-speed-header"><label for="space-mission-replay-speed">REPLAY SPEED</label><output class="gev-slider-value" for="space-mission-replay-speed" data-mission-replay-speed-output>1×</output></div><input id="space-mission-replay-speed" class="gev-quantitative-slider" type="range" min="0.25" max="4" step="0.25" value="1" data-mission-replay-speed aria-label="Replay speed multiplier"><div class="mission-replay-speed-scale" aria-hidden="true"><span>0.25×</span><span>1×</span><span>4×</span></div></div><div class="mission-action-row"><button type="button" class="mission-focus-button" data-mission-focus>FOCUS</button><button type="button" class="mission-replay-button" data-mission-replay aria-pressed="false">REPLAY ASCENT</button></div><div class="space-mission-nav"><button type="button" class="mission-nav-button" data-mission-prev title="Previous mission"><span aria-hidden="true">‹</span> PREV</button><span class="mission-nav-index" data-mission-index>—</span><button type="button" class="mission-nav-button" data-mission-next title="Next mission">NEXT <span aria-hidden="true">›</span></button></div><button type="button" class="panel-layer-toggle" data-mission-show-all>SHOW ALL / DESELECT</button>`;
|
||||
layerState._missionPanel.innerHTML = `<div class="space-mission-view-header"><span>${t('layers.missions.panel.selectedHeader')}</span><button type="button" data-mission-close title="${t('layers.missions.panel.showAllTitle')}" aria-label="${t('layers.missions.panel.deselectAria')}">×</button></div><div class="space-mission-detail"><strong data-mission-title>${t('layers.missions.panel.mission')}</strong><span data-mission-field data-mission-provider></span><span data-mission-field>${t('layers.missions.panel.status')}<b data-mission-status></b></span><span data-mission-field>${t('layers.missions.panel.launchSite')}<b data-mission-site></b></span><span data-mission-field>${t('layers.missions.panel.launchTime')}<b data-mission-time></b></span><span data-mission-field>${t('layers.missions.panel.orbit')}<b data-mission-orbit></b></span><span>${t('layers.missions.panel.ascentPath')}<b data-mission-ascent-source></b></span><span data-mission-field>${t('layers.missions.panel.distance')}<b data-mission-distance></b></span><span data-mission-field>${t('layers.missions.panel.speed')}<b data-mission-speed></b></span></div><section class="mission-data-section"><h4>${t('layers.missions.panel.payload')}</h4><div class="mission-table-scroll"><table class="mission-data-table"><thead><tr><th>${t('layers.missions.panel.colName')}</th><th>${t('layers.missions.panel.colType')}</th><th>${t('layers.missions.panel.colDestination')}</th></tr></thead><tbody data-mission-payloads></tbody></table></div></section><section class="mission-data-section" data-mission-stages-section><h4>${t('layers.missions.panel.stagesSection')}</h4><div class="mission-table-scroll"><table class="mission-data-table"><thead><tr><th>${t('layers.missions.panel.colStage')}</th><th>${t('layers.missions.panel.colStatus')}</th><th>${t('layers.missions.panel.colFinalPosition')}</th></tr></thead><tbody data-mission-stages></tbody></table></div></section><div class="mission-replay-speed-control"><div class="mission-replay-speed-header"><label for="space-mission-replay-speed">${t('layers.missions.replay.speedLabel')}</label><output class="gev-slider-value" for="space-mission-replay-speed" data-mission-replay-speed-output>1×</output></div><input id="space-mission-replay-speed" class="gev-quantitative-slider" type="range" min="0.25" max="4" step="0.25" value="1" data-mission-replay-speed aria-label="${t('layers.missions.replay.speedAria')}"><div class="mission-replay-speed-scale" aria-hidden="true"><span>0.25×</span><span>1×</span><span>4×</span></div></div><div class="mission-action-row"><button type="button" class="mission-focus-button" data-mission-focus>${t('layers.missions.panel.focus')}</button><button type="button" class="mission-replay-button" data-mission-replay aria-pressed="false">${t('layers.missions.replay.start')}</button></div><div class="space-mission-nav"><button type="button" class="mission-nav-button" data-mission-prev title="${t('layers.missions.panel.prevTitle')}"><span aria-hidden="true">‹</span> ${t('layers.missions.panel.prev')}</button><span class="mission-nav-index" data-mission-index>—</span><button type="button" class="mission-nav-button" data-mission-next title="${t('layers.missions.panel.nextTitle')}">${t('layers.missions.panel.next')} <span aria-hidden="true">›</span></button></div><button type="button" class="panel-layer-toggle" data-mission-show-all>${t('layers.missions.panel.showAll')}</button>`;
|
||||
layerState._missionPanel
|
||||
.querySelector('.mission-action-row')
|
||||
.insertAdjacentHTML(
|
||||
'beforeend',
|
||||
`<div class="mission-replay-transport" data-mission-replay-transport hidden>
|
||||
<button type="button" data-mission-replay-toggle title="Pause replay" aria-label="Pause replay">Ⅱ</button>
|
||||
<button type="button" class="cancel" data-mission-replay-cancel title="Cancel replay" aria-label="Cancel replay"><span aria-hidden="true">×</span></button>
|
||||
<button type="button" data-mission-replay-toggle title="${t('layers.missions.replay.pause')}" aria-label="${t('layers.missions.replay.pause')}">Ⅱ</button>
|
||||
<button type="button" class="cancel" data-mission-replay-cancel title="${t('layers.missions.replay.cancel')}" aria-label="${t('layers.missions.replay.cancel')}"><span aria-hidden="true">×</span></button>
|
||||
</div>`,
|
||||
);
|
||||
host.appendChild(layerState._missionPanel);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { t } from '../../i18n/index.js';
|
||||
import {
|
||||
EARTH_ROTATION_RAD_PER_SEC,
|
||||
PROJECTED_ASCENT_ROTATION_SEC,
|
||||
@@ -521,7 +522,7 @@ export function createPaths({ state: layerState, services, parts, source }) {
|
||||
return {
|
||||
lat: Cesium.Math.toDegrees(lat),
|
||||
lon: Cesium.Math.toDegrees(Cesium.Math.negativePiToPi(lon)),
|
||||
accuracy: 'EST. DOWNRANGE',
|
||||
accuracy: t('layers.missions.estDownrange'),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { t } from '../../i18n/index.js';
|
||||
import {
|
||||
SATELLITE_STANDALONE_DEFAULTS,
|
||||
LAUNCH_PAD_ZONE_MAX_CAMERA_HEIGHT_M,
|
||||
@@ -76,14 +77,14 @@ export function createPolicyHelpers({
|
||||
: 0;
|
||||
return {
|
||||
orbit: orbitName
|
||||
? `${orbitAllowed ? '' : 'PLANNED · '}${orbitName}`
|
||||
? `${orbitAllowed ? '' : t('layers.missions.rows.plannedPrefix')}${orbitName}`
|
||||
: null,
|
||||
ascent:
|
||||
suppliedTrajectoryPoints > 1
|
||||
? 'SUPPLIED TRAJECTORY POINTS'
|
||||
? t('layers.missions.rows.suppliedTrajectory')
|
||||
: replayAvailable
|
||||
? 'RECONSTRUCTED ESTIMATE'
|
||||
: 'UNAVAILABLE',
|
||||
? t('layers.missions.rows.reconstructedEstimate')
|
||||
: t('layers.missions.rows.unavailable'),
|
||||
replayAvailable: Boolean(replayAvailable),
|
||||
};
|
||||
}
|
||||
@@ -196,7 +197,8 @@ export function createPolicyHelpers({
|
||||
|
||||
function formatMissionEventTime(launchTime) {
|
||||
const date = new Date(launchTime);
|
||||
if (!launchTime || !Number.isFinite(date.getTime())) return 'UNAVAILABLE';
|
||||
if (!launchTime || !Number.isFinite(date.getTime()))
|
||||
return t('layers.missions.rows.unavailable');
|
||||
return `${date.toISOString().slice(0, 10)}\n${date.toISOString().slice(11, 19)} UTC`;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { t } from '../../i18n/index.js';
|
||||
import {
|
||||
REPLAY_ASCENT_FALLBACK_SEC,
|
||||
REPLAY_ASCENT_MIN_SEC,
|
||||
@@ -200,10 +201,10 @@ export function createReplay({ state: layerState, services, parts, source }) {
|
||||
if (speedControl) speedControl.hidden = !replayAvailable;
|
||||
button.hidden = active || !replayAvailable;
|
||||
button.disabled = !replayAvailable;
|
||||
button.textContent = 'REPLAY ASCENT';
|
||||
button.textContent = t('layers.missions.replay.start');
|
||||
button.classList.remove('active');
|
||||
button.setAttribute('aria-pressed', String(active));
|
||||
button.title = 'Replay the estimated ascent with a following camera';
|
||||
button.title = t('layers.missions.replay.startTitle');
|
||||
if (transport) {
|
||||
transport.hidden = !active;
|
||||
transport.classList.toggle(
|
||||
@@ -218,11 +219,13 @@ export function createReplay({ state: layerState, services, parts, source }) {
|
||||
toggleButton.textContent = layerState._replayPaused ? '▶' : 'Ⅱ';
|
||||
toggleButton.setAttribute(
|
||||
'aria-label',
|
||||
layerState._replayPaused ? 'Resume replay' : 'Pause replay',
|
||||
layerState._replayPaused
|
||||
? t('layers.missions.replay.resume')
|
||||
: t('layers.missions.replay.pause'),
|
||||
);
|
||||
toggleButton.title = layerState._replayPaused
|
||||
? 'Resume replay'
|
||||
: 'Pause replay';
|
||||
? t('layers.missions.replay.resume')
|
||||
: t('layers.missions.replay.pause');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -233,17 +236,19 @@ export function createReplay({ state: layerState, services, parts, source }) {
|
||||
);
|
||||
if (!transport || !layerState._replayCameraLaunchId) return;
|
||||
const phase = state.countdownActive
|
||||
? `T minus ${state.countdownSeconds}`
|
||||
? t('layers.missions.replay.phaseCountdown', {
|
||||
seconds: state.countdownSeconds,
|
||||
})
|
||||
: state.preCountdownActive
|
||||
? 'Preparing launch site'
|
||||
? t('layers.missions.replay.phasePreparing')
|
||||
: state.elapsedSinceStart < 1
|
||||
? 'Liftoff'
|
||||
? t('layers.missions.replay.phaseLiftoff')
|
||||
: state.ascending
|
||||
? 'Ascent replay'
|
||||
: 'Orbit replay';
|
||||
? t('layers.missions.replay.phaseAscent')
|
||||
: t('layers.missions.replay.phaseOrbit');
|
||||
transport.setAttribute(
|
||||
'aria-label',
|
||||
`${phase}${layerState._replayPaused ? ', paused' : ''}`,
|
||||
`${phase}${layerState._replayPaused ? t('layers.missions.replay.pausedSuffix') : ''}`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { displayedKinematics } from '../../data/motionModel.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
import * as Cesium from 'cesium';
|
||||
import { isExplicitLayerStateOrigin } from '../../data/layerState.js';
|
||||
import { aircraftIncludedInNearby } from '../../data/aircraftNearbyPolicy.js';
|
||||
@@ -54,7 +55,7 @@ export function createQueries({
|
||||
*/
|
||||
|
||||
function _formatAltitude(altitudeFt) {
|
||||
if (!Number.isFinite(altitudeFt)) return 'Alt unknown';
|
||||
if (!Number.isFinite(altitudeFt)) return t('layers.readout.altUnknown');
|
||||
return `${Math.round(altitudeFt)} ft`;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { t } from '../../i18n/index.js';
|
||||
import { nextCockpitNearContacts } from '../../data/cockpitAirLod.js';
|
||||
import { aircraftIcon, TRACKED_ICON_PX } from '../../data/aircraftIcons.js';
|
||||
import { trackedModelZoomActive } from '../../data/trackedModelRegime.js';
|
||||
@@ -219,7 +220,7 @@ export function createTracking({
|
||||
function _buildTrackedLabel(info, icao24) {
|
||||
const stale =
|
||||
flightState.records.missingPolls.get(icao24) || flightState.feed._backoff
|
||||
? ' · STALE'
|
||||
? ` · ${t('layers.status.stale')}`
|
||||
: '';
|
||||
const callsign =
|
||||
(parts.queries._toCleanText(info?.callsign) ||
|
||||
@@ -229,12 +230,14 @@ export function createTracking({
|
||||
// what the Easter egg replaces the real type with.
|
||||
const type = tr3bTypeLabel(
|
||||
icao24,
|
||||
parts.queries._toCleanText(info?.type) || 'Type unknown',
|
||||
parts.queries._toCleanText(info?.type) || t('layers.readout.typeUnknown'),
|
||||
);
|
||||
const registration =
|
||||
parts.queries._toCleanText(info?.registration) || 'Reg unknown';
|
||||
parts.queries._toCleanText(info?.registration) ||
|
||||
t('layers.readout.regUnknown');
|
||||
const operator =
|
||||
parts.queries._toCleanText(info?.operator) || 'Operator unknown';
|
||||
parts.queries._toCleanText(info?.operator) ||
|
||||
t('layers.readout.operatorUnknown');
|
||||
const altitude = parts.queries._formatAltitude(info?.altitudeFt);
|
||||
const speedKt = info?.speedMps ? Math.round(info.speedMps * 1.944) : null;
|
||||
const tail = speedKt ? `${altitude} · ${speedKt} kt` : altitude;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { MUSIC_GENRES, CATEGORY_MATCHERS } from './policy.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
||||
export function createCategories({
|
||||
state: layerState,
|
||||
@@ -69,14 +70,20 @@ export function createCategories({
|
||||
function buildRadioCategories(stations) {
|
||||
const rows = Array.isArray(stations) ? stations : [];
|
||||
const categories = [
|
||||
{ id: 'all', label: 'All' },
|
||||
{ id: 'news', label: 'News' },
|
||||
{ id: 'talk', label: 'Talk' },
|
||||
{ id: 'weather', label: 'Weather / Emergency' },
|
||||
{ id: 'public-safety', label: 'Public Safety' },
|
||||
{ id: 'aviation-marine', label: 'Aviation / Marine' },
|
||||
{ id: 'traffic-transit', label: 'Traffic / Transit' },
|
||||
{ id: 'music', label: 'Music' },
|
||||
{ id: 'all', label: t('layers.radio.filterAll') },
|
||||
{ id: 'news', label: t('layers.radio.category.news') },
|
||||
{ id: 'talk', label: t('layers.radio.category.talk') },
|
||||
{ id: 'weather', label: t('layers.radio.category.weather') },
|
||||
{ id: 'public-safety', label: t('layers.radio.category.publicSafety') },
|
||||
{
|
||||
id: 'aviation-marine',
|
||||
label: t('layers.radio.category.aviationMarine'),
|
||||
},
|
||||
{
|
||||
id: 'traffic-transit',
|
||||
label: t('layers.radio.category.trafficTransit'),
|
||||
},
|
||||
{ id: 'music', label: t('layers.radio.category.music') },
|
||||
];
|
||||
|
||||
for (const [genre, label] of MUSIC_GENRES) {
|
||||
@@ -85,7 +92,7 @@ export function createCategories({
|
||||
categories.push({ id, label });
|
||||
}
|
||||
}
|
||||
categories.push({ id: 'other', label: 'Other' });
|
||||
categories.push({ id: 'other', label: t('layers.radio.category.other') });
|
||||
return categories.map((category) => ({
|
||||
...category,
|
||||
color: parts.model.radioCategoryColor(category.id),
|
||||
|
||||
Vendored
+12
-8
@@ -4,6 +4,7 @@ import {
|
||||
satelliteClassLegend,
|
||||
} from '../../data/satelliteClass.js';
|
||||
import * as Cesium from 'cesium';
|
||||
import { t } from '../../i18n/index.js';
|
||||
import { ISS_NORAD, POINT_STYLES } from './policy.js';
|
||||
|
||||
export function createControls({ state: layerState, services, parts, source }) {
|
||||
@@ -493,19 +494,22 @@ export function createControls({ state: layerState, services, parts, source }) {
|
||||
const active =
|
||||
layerState._params.catalog === 'dense' &&
|
||||
layerState._denseStatus === 'ready';
|
||||
let title =
|
||||
'Add the full Starlink broadband shell (thousands of extra points)';
|
||||
if (loading) title = 'Loading the Starlink shell…';
|
||||
let title = t('layers.satellites.denseTitleAdd');
|
||||
if (loading) title = t('layers.satellites.denseTitleLoading');
|
||||
else if (failed)
|
||||
title = `Starlink ${layerState._denseError || 'load failed'} — click to retry`;
|
||||
else if (active)
|
||||
title =
|
||||
'Showing the full Starlink shell — click for the core catalog only';
|
||||
title = t('layers.satellites.denseTitleFailed', {
|
||||
detail: layerState._denseError || t('layers.satellites.loadFailed'),
|
||||
});
|
||||
else if (active) title = t('layers.satellites.denseTitleActive');
|
||||
return {
|
||||
chips: [
|
||||
{
|
||||
id: 'catalog',
|
||||
label: loading ? 'DENSE ···' : failed ? 'DENSE ✕' : 'DENSE',
|
||||
label: loading
|
||||
? t('layers.satellites.denseChipLoading')
|
||||
: failed
|
||||
? t('layers.satellites.denseChipFailed')
|
||||
: t('layers.satellites.denseChip'),
|
||||
active,
|
||||
busy: loading,
|
||||
disabled: loading,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { t } from '../../i18n/index.js';
|
||||
import {
|
||||
featureLabel,
|
||||
featureReference,
|
||||
@@ -11,7 +12,7 @@ export function createIngestion({ state, parts, source }) {
|
||||
|
||||
state._loading = true;
|
||||
state._error = null;
|
||||
state._loadingLabel = 'loading...';
|
||||
state._loadingLabel = t('layers.meta.loading');
|
||||
// Ownership token (the militaryAwareness activationId pattern): a
|
||||
// disable/abort followed by a fresh enable starts a NEWER load while this
|
||||
// one is still settling. The stale load must bail after every await and
|
||||
|
||||
+29
-10
@@ -1,5 +1,6 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { roadSurfaceChunks } from './surface.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
import {
|
||||
flowDensityMult,
|
||||
flowBucket,
|
||||
@@ -260,10 +261,14 @@ export function createModel({ state: layerState, services, parts, source }) {
|
||||
mode,
|
||||
error: null,
|
||||
loadingLabel: fetching
|
||||
? 'Syncing flow · Roads: TomTom'
|
||||
? t('layers.traffic.syncingRoads', { source: roadSource })
|
||||
: coveragePct > 0
|
||||
? 'LIVE · Roads: TomTom · Roads without flow hidden'
|
||||
: 'LIVE · Roads: TomTom · No flow roads in view',
|
||||
? t('layers.traffic.liveRoadsFlowHidden', {
|
||||
source: roadSource,
|
||||
})
|
||||
: t('layers.traffic.liveRoadsNoFlowInView', {
|
||||
source: roadSource,
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (liveMode && roadSource === 'TomTom + OpenStreetMap') {
|
||||
@@ -271,8 +276,16 @@ export function createModel({ state: layerState, services, parts, source }) {
|
||||
mode,
|
||||
error: null,
|
||||
loadingLabel: fetching
|
||||
? `Syncing flow · Roads: ${roadSource}`
|
||||
: `${coveragePct > 0 ? 'LIVE' : 'SIMULATED'} · Roads: ${roadSource} · Flow ${coveragePct}%`,
|
||||
? t('layers.traffic.syncingRoads', { source: roadSource })
|
||||
: coveragePct > 0
|
||||
? t('layers.traffic.roadsFlowLive', {
|
||||
source: roadSource,
|
||||
percent: coveragePct,
|
||||
})
|
||||
: t('layers.traffic.roadsFlowSimulated', {
|
||||
source: roadSource,
|
||||
percent: coveragePct,
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (liveMode) {
|
||||
@@ -280,10 +293,16 @@ export function createModel({ state: layerState, services, parts, source }) {
|
||||
mode,
|
||||
error: null,
|
||||
loadingLabel: fetching
|
||||
? 'Syncing flow · Roads: OpenStreetMap · Flow: TomTom · Unmatched: simulated'
|
||||
? t('layers.traffic.loadingSyncing')
|
||||
: coveragePct > 0
|
||||
? `LIVE · Roads: OpenStreetMap · Flow: TomTom · ${coveragePct}% cov${coveragePct < 100 ? ' · Unmatched: simulated' : ''}`
|
||||
: 'SIMULATED · Roads: OpenStreetMap · Flow: TomTom (no matches)',
|
||||
? coveragePct < 100
|
||||
? t('layers.traffic.liveCoverageUnmatched', {
|
||||
percent: coveragePct,
|
||||
})
|
||||
: t('layers.traffic.liveCoverage', {
|
||||
percent: coveragePct,
|
||||
})
|
||||
: t('layers.traffic.simulatedNoMatches'),
|
||||
};
|
||||
}
|
||||
// Keyless simulation — one terse line that names the mode and the remedy
|
||||
@@ -293,8 +312,8 @@ export function createModel({ state: layerState, services, parts, source }) {
|
||||
mode,
|
||||
error: null,
|
||||
loadingLabel: statusUnavailable
|
||||
? 'SIMULATED — traffic service unreachable'
|
||||
: 'SIMULATED — add TomTom key for live',
|
||||
? t('layers.traffic.simUnavailable')
|
||||
: t('layers.traffic.simKeyless'),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+15
-10
@@ -2,6 +2,7 @@ import {
|
||||
accentForVesselType,
|
||||
normalizeVesselType,
|
||||
} from '../../data/vesselLabels.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
||||
export function createCards({
|
||||
vesselState,
|
||||
@@ -20,9 +21,13 @@ export function createCards({
|
||||
const stale = (record.missedRefreshes || 0) > 0;
|
||||
el.classList.add('active');
|
||||
el.textContent = [
|
||||
`AIS: ${trimHudValue(record.name, 32)}`,
|
||||
`${trimHudValue(record.type || 'VESSEL', 24)} SPD: ${formatSpeed(record.speed)} HDG: ${formatHeading(record.heading ?? record.course)}`,
|
||||
`MMSI: ${record.mmsi || '--'} ${formatPositionTime(record)}${stale ? ' · STALE' : ''}`,
|
||||
t('layers.vessel.hudName', { name: trimHudValue(record.name, 32) }),
|
||||
t('layers.vessel.hudTypeLine', {
|
||||
type: trimHudValue(record.type || t('layers.vessel.fallback'), 24),
|
||||
speed: formatSpeed(record.speed),
|
||||
heading: formatHeading(record.heading ?? record.course),
|
||||
}),
|
||||
`MMSI: ${record.mmsi || '--'} ${formatPositionTime(record)}${stale ? ` · ${t('layers.status.stale')}` : ''}`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
@@ -30,7 +35,7 @@ export function createCards({
|
||||
const el = document.getElementById('hud-ais-vessel');
|
||||
if (!el) return;
|
||||
el.classList.remove('active');
|
||||
el.textContent = 'AIS: --';
|
||||
el.textContent = t('layers.vessel.hudIdle');
|
||||
}
|
||||
|
||||
function trimHudValue(value, maxLength) {
|
||||
@@ -85,7 +90,7 @@ export function createCards({
|
||||
const direction = record.heading ?? record.course;
|
||||
const details = [
|
||||
[
|
||||
vesselTypeShort(record) || 'VESSEL',
|
||||
vesselTypeShort(record) || t('layers.vessel.fallback'),
|
||||
formatSpeed(record.speed),
|
||||
Number.isFinite(direction) ? `${Math.round(direction)}°` : '--°',
|
||||
].join(' · '),
|
||||
@@ -94,7 +99,7 @@ export function createCards({
|
||||
if (destination) details.push(`→ ${trimHudValue(destination, 24)}`);
|
||||
const stale = (record.missedRefreshes || 0) > 0;
|
||||
details.push(
|
||||
`MMSI ${record.mmsi || '--'} · ${formatPositionTime(record)}${stale ? ' · STALE' : ''}`,
|
||||
`MMSI ${record.mmsi || '--'} · ${formatPositionTime(record)}${stale ? ` · ${t('layers.status.stale')}` : ''}`,
|
||||
);
|
||||
return {
|
||||
id: vesselOverlayEntryId(record),
|
||||
@@ -151,7 +156,7 @@ export function createCards({
|
||||
function displayVesselName(record) {
|
||||
const name = String(record.name || '').trim();
|
||||
if (name && name !== 'VESSEL' && name !== record.mmsi) return name;
|
||||
return record.mmsi ? `MMSI ${record.mmsi}` : 'VESSEL';
|
||||
return record.mmsi ? `MMSI ${record.mmsi}` : t('layers.vessel.fallback');
|
||||
}
|
||||
|
||||
function formatSpeed(speed) {
|
||||
@@ -163,10 +168,10 @@ export function createCards({
|
||||
}
|
||||
|
||||
function formatPositionTime(record) {
|
||||
if (!record.lastPositionUtc) return 'POS: LIVE';
|
||||
if (!record.lastPositionUtc) return t('layers.vessel.posLive');
|
||||
const date = new Date(record.lastPositionUtc);
|
||||
if (Number.isNaN(date.getTime())) return 'POS: LIVE';
|
||||
return `POS: ${date.toISOString().slice(11, 19)}Z`;
|
||||
if (Number.isNaN(date.getTime())) return t('layers.vessel.posLive');
|
||||
return t('layers.vessel.posAt', { time: date.toISOString().slice(11, 19) });
|
||||
}
|
||||
return {
|
||||
updateSelectedVesselHud,
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import { VESSEL_OVERLAY_SOURCE_ID } from '../../data/vesselLabels.js';
|
||||
import {
|
||||
AIS_FIRST_CONNECT_GRACE_MS,
|
||||
AIS_FIRST_CONNECT_LABEL,
|
||||
AIS_HEALTHY_STATUSES,
|
||||
} from './policy.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
import { AIS_FIRST_CONNECT_GRACE_MS, AIS_HEALTHY_STATUSES } from './policy.js';
|
||||
|
||||
export function createLifecycle({
|
||||
vesselState,
|
||||
@@ -41,7 +38,7 @@ export function createLifecycle({
|
||||
state.feed.firstConnectStartedAt = startedAt;
|
||||
state.feed.firstConnectDeadline = startedAt + AIS_FIRST_CONNECT_GRACE_MS;
|
||||
state.feed.error = null;
|
||||
state.feed.loadingLabel = AIS_FIRST_CONNECT_LABEL;
|
||||
state.feed.loadingLabel = t('layers.vessel.awaitingFirstPosition');
|
||||
scheduleFirstConnectExpiry(sessionId, AIS_FIRST_CONNECT_GRACE_MS);
|
||||
}
|
||||
|
||||
@@ -63,8 +60,8 @@ export function createLifecycle({
|
||||
state.feed.firstConnectPhase = 'unavailable';
|
||||
state.feed.loadingLabel = '';
|
||||
state.feed.error = state.feed.lastMessageAt
|
||||
? 'awaiting usable AIS positions…'
|
||||
: 'awaiting first AIS message…';
|
||||
? t('layers.vessel.awaitingPositions')
|
||||
: t('layers.vessel.awaitingFirstMessage');
|
||||
state.feed.stale = state.feed.count > 0;
|
||||
}, delayMs);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,27 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
||||
// The vessels ingestion owner is a portable module graph (it also runs in the
|
||||
// server-side source builds), so it writes stable English feed labels into
|
||||
// state. Localize those known labels here, at the presentation read; anything
|
||||
// else (a bespoke manager label) passes through verbatim.
|
||||
const FEED_STATE_LABEL_KEYS = Object.freeze({
|
||||
'refreshing...': 'layers.meta.refreshing',
|
||||
'loading...': 'layers.meta.loading',
|
||||
'awaiting first AIS position…': 'layers.vessel.awaitingFirstPosition',
|
||||
});
|
||||
|
||||
function localizeFeedLabel(raw) {
|
||||
if (typeof raw !== 'string' || !raw.trim()) return raw;
|
||||
const key = FEED_STATE_LABEL_KEYS[raw.trim()];
|
||||
return key ? t(key) : raw;
|
||||
}
|
||||
import {
|
||||
AIS_DEGRADED_STATUSES,
|
||||
AIS_HEALTHY_STATUSES,
|
||||
AIS_STATUS_REASON,
|
||||
REFRESH_MS,
|
||||
FOCUS_EVIDENCE_DEV,
|
||||
AIS_FIRST_CONNECT_LABEL,
|
||||
} from './policy.js';
|
||||
|
||||
export function createQueries({
|
||||
@@ -82,8 +98,8 @@ export function createQueries({
|
||||
if (acceptedRowCount > 0) return null; // accepted rows may be stale while reconnecting, but remain usable
|
||||
if (AIS_HEALTHY_STATUSES.has(status)) {
|
||||
return payload?.lastMessageAt
|
||||
? 'awaiting usable AIS positions…'
|
||||
: 'awaiting first AIS message…';
|
||||
? t('layers.vessel.awaitingPositions')
|
||||
: t('layers.vessel.awaitingFirstMessage');
|
||||
}
|
||||
if (!status) return null;
|
||||
const detail =
|
||||
@@ -132,7 +148,7 @@ export function createQueries({
|
||||
acceptedRowCount,
|
||||
error:
|
||||
deriveAisFeedError(payload, acceptedRowCount) ||
|
||||
(acceptedRowCount === 0 ? 'awaiting usable AIS positions…' : null),
|
||||
(acceptedRowCount === 0 ? t('layers.vessel.awaitingPositions') : null),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -504,8 +520,8 @@ export function createQueries({
|
||||
lastUpdate: state.feed.lastUpdate,
|
||||
loading: state.feed.loading || waitingForFirstPosition,
|
||||
loadingLabel: waitingForFirstPosition
|
||||
? AIS_FIRST_CONNECT_LABEL
|
||||
: state.feed.loadingLabel,
|
||||
? t('layers.vessel.awaitingFirstPosition')
|
||||
: localizeFeedLabel(state.feed.loadingLabel),
|
||||
error: state.feed.error,
|
||||
stale: state.feed.stale,
|
||||
partial: state.feed.partial,
|
||||
|
||||
@@ -5,6 +5,7 @@ import { createHoverDisclosure, collapsePanelOnEscape } from './ui/panelDisclosu
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { t } from './i18n/index.js';
|
||||
|
||||
// Exercise the installed event routes and central close method, without WebGL.
|
||||
const source = readShellSource();
|
||||
@@ -107,9 +108,9 @@ function harness({ hidden = false, selected = true, noChips = false } = {}) {
|
||||
window.clearTimeout = (id) => timers.delete(id);
|
||||
window.performance = { now: () => now };
|
||||
document.defaultView = window;
|
||||
const methods = new Function('createHoverDisclosure', 'collapsePanelOnEscape', 'document', 'window', 'clearTimeout', 'performance', 'requestAnimationFrame',
|
||||
const methods = new Function('createHoverDisclosure', 'collapsePanelOnEscape', 'document', 'window', 'clearTimeout', 'performance', 'requestAnimationFrame', 't',
|
||||
`return ({${shellMethod('_initAutoHoverPanel').toString()},\n${shellMethod('_collapsePanelOnEscape').toString()},\n${shellMethod('setPanelCollapsed').toString()},\n${shellMethod('_syncPanelCollapseButton').toString()}});`)(
|
||||
createHoverDisclosure, collapsePanelOnEscape, document, window, (id) => timers.delete(id), { now: () => now }, () => {},
|
||||
createHoverDisclosure, collapsePanelOnEscape, document, window, (id) => timers.delete(id), { now: () => now }, () => {}, t,
|
||||
);
|
||||
const saves = [];
|
||||
const claims = [];
|
||||
|
||||
+14
-4
@@ -10,7 +10,8 @@
|
||||
// state is re-synced from controller state (never optimistically), so a failed
|
||||
// or superseded switch still leaves the truly-active stack lit.
|
||||
|
||||
import { keySetupRequirement } from './keySetupCore.mjs';
|
||||
import { keySetupRequirementEnvVars } from './keySetupCore.mjs';
|
||||
import { t } from './i18n/index.js';
|
||||
|
||||
export const MAP_STACK_CHIP_CLASS = 'map-stack-chip';
|
||||
export const PRESENTED_MAP_STACK_IDS = Object.freeze([
|
||||
@@ -37,9 +38,15 @@ export function mapStackChipModel(stack, activeId) {
|
||||
const available = stack?.available !== false;
|
||||
const label = String(stack?.label ?? stack?.id ?? '');
|
||||
const requiresIon = stack?.requiresIon === true;
|
||||
// Stack labels are provider/dataset names (keep-English); the availability
|
||||
// sentence around them is copy and localizes here at the model boundary.
|
||||
const fallbackReason = requiresIon
|
||||
? keySetupRequirement('cesium-ion')
|
||||
: `${label || 'This map stack'} is unavailable`;
|
||||
? t('setup.keySetup.requirement', {
|
||||
envVars: keySetupRequirementEnvVars('cesium-ion'),
|
||||
})
|
||||
: t('setup.mapStack.unavailableReason', {
|
||||
label: label || t('setup.mapStack.fallbackName'),
|
||||
});
|
||||
const unavailableHint = available
|
||||
? ''
|
||||
: String(stack?.unavailableReason || fallbackReason);
|
||||
@@ -119,7 +126,10 @@ export function renderMapStackChips(
|
||||
if (!model.available) {
|
||||
chip.setAttribute(
|
||||
'aria-label',
|
||||
`${model.label} unavailable: ${model.unavailableHint}`,
|
||||
t('setup.mapStack.unavailableAriaLabel', {
|
||||
label: model.label,
|
||||
hint: model.unavailableHint,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { keySetupRequirement } from '../keySetupCore.mjs';
|
||||
import {
|
||||
keySetupRequirement,
|
||||
keySetupRequirementEnvVars,
|
||||
} from '../keySetupCore.mjs';
|
||||
import { t } from '../i18n/index.js';
|
||||
/**
|
||||
* Why Google 3D is unavailable, phrased so the tooltip and toast recommend the
|
||||
* RIGHT fix. With no credentials the fix is a key (or the ion route); with a
|
||||
@@ -11,5 +15,6 @@ import { keySetupRequirement } from '../keySetupCore.mjs';
|
||||
export function photorealUnavailableReason(hasCredentials) {
|
||||
if (hasCredentials)
|
||||
return "Google 3D tiles unavailable — check the key's API restrictions, quota, or network";
|
||||
return `${keySetupRequirement('google-maps')} — or a Cesium ion token for the ion-hosted route`;
|
||||
const envVars = keySetupRequirementEnvVars('google-maps');
|
||||
return `${t('setup.keySetup.requirement', { envVars })} — or a Cesium ion token for the ion-hosted route`;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { MAP_STACKS } from './catalog.js';
|
||||
import { photorealUnavailableReason } from './availability.js';
|
||||
import { keySetupRequirement } from '../keySetupCore.mjs';
|
||||
import { keySetupRequirementEnvVars } from '../keySetupCore.mjs';
|
||||
import { t } from '../i18n/index.js';
|
||||
import {
|
||||
createOsmImagery,
|
||||
createEsriImagery,
|
||||
@@ -34,7 +35,9 @@ export function createDefaultMapSources({
|
||||
descriptor,
|
||||
available: !descriptor.requiresIon || hasIon,
|
||||
unavailableReason: descriptor.requiresIon
|
||||
? keySetupRequirement('cesium-ion')
|
||||
? t('setup.keySetup.requirement', {
|
||||
envVars: keySetupRequirementEnvVars('cesium-ion'),
|
||||
})
|
||||
: null,
|
||||
};
|
||||
if (descriptor.kind === 'photoreal')
|
||||
|
||||
+63
-15
@@ -26,6 +26,7 @@ import {
|
||||
getSceneAppendRecipeById,
|
||||
} from './recipes.js';
|
||||
import { sceneLayerPlan, sceneRequiresContextModeExit } from './scenePolicy.js';
|
||||
import { t } from '../i18n/index.js';
|
||||
import { createSceneDataPacks } from './dataPacks/controller.js';
|
||||
import { createSceneInteractions } from './interactions.js';
|
||||
import { createSceneSharing } from './sharing.js';
|
||||
@@ -61,6 +62,31 @@ import {
|
||||
SceneDocumentError,
|
||||
} from '../director/document.js';
|
||||
|
||||
// Built-in recipe DISPLAY names. Recipe ids, the URL 'scene' param, stored
|
||||
// project titles, and voice-matching titles all stay English (keep-English
|
||||
// boundary + persisted data); only the presentation of an UNRENAMED built-in
|
||||
// localizes, so an operator's renamed scene is never masked by the catalog.
|
||||
const RECIPE_TITLE_KEYS = Object.freeze({
|
||||
'flights-radar': 'setup.scenes.recipe.flightsRadar',
|
||||
'orbital-watch': 'setup.scenes.recipe.orbitalWatch',
|
||||
'thermal-threats': 'setup.scenes.recipe.thermalThreats',
|
||||
'city-overload': 'setup.scenes.recipe.cityOverload',
|
||||
'omniscience-pullback': 'setup.scenes.recipe.omnisciencePullback',
|
||||
});
|
||||
|
||||
/**
|
||||
* Display title for a scene: the localized recipe name while the stored title
|
||||
* still matches the built-in verbatim, the stored title otherwise.
|
||||
* @param {{id?: string, title?: string}} scene
|
||||
* @returns {string}
|
||||
*/
|
||||
function displaySceneTitle(scene) {
|
||||
const key = RECIPE_TITLE_KEYS[scene?.id];
|
||||
if (!key) return scene?.title || '';
|
||||
const builtin = SCENE_RECIPES.find((recipe) => recipe.id === scene.id);
|
||||
return scene.title === builtin?.title ? t(key) : scene.title;
|
||||
}
|
||||
|
||||
/** @constant {string} localStorage key for the serialized project */
|
||||
const STORAGE_KEY = 'godsEyeView.sceneProject.v2';
|
||||
const STORAGE_CHECKPOINT_KEY = 'godsEyeView.sceneProject.checkpoint.v1';
|
||||
@@ -172,7 +198,7 @@ export class SceneDirector {
|
||||
this._presentation = {
|
||||
status: this._storageReadError
|
||||
? 'Saved project could not be read; storage preserved. Import a valid file to resume saving.'
|
||||
: 'Ready',
|
||||
: t('setup.scenes.statusReady'),
|
||||
progress: 0,
|
||||
runtime: '',
|
||||
playbackActive: false,
|
||||
@@ -370,7 +396,10 @@ export class SceneDirector {
|
||||
|
||||
_shotOutcome(type, scene, shot, index = scene.shots.indexOf(shot)) {
|
||||
if (type === 'shot-loaded')
|
||||
this._presentation.status = `Loaded: ${scene.title} / ${shot.title}`;
|
||||
this._presentation.status = t('setup.scenes.status.loaded', {
|
||||
scene: scene.title,
|
||||
shot: shot.title,
|
||||
});
|
||||
this._publish({
|
||||
type,
|
||||
sceneId: scene.id,
|
||||
@@ -389,7 +418,12 @@ export class SceneDirector {
|
||||
this._controls = new SceneControls({
|
||||
subscribe: (listener) => this.subscribe(listener),
|
||||
read: () => ({
|
||||
scenes: this._project.scenes,
|
||||
// Scene titles surface in the panel through SceneControls; built-in
|
||||
// recipe titles display their localized name until renamed by the user.
|
||||
scenes: this._project.scenes.map((scene) => ({
|
||||
...scene,
|
||||
title: displaySceneTitle(scene),
|
||||
})),
|
||||
selectedSceneId: this._selectedSceneId,
|
||||
selectedShotId: this._selectedShotId,
|
||||
running: this._running,
|
||||
@@ -920,14 +954,16 @@ export class SceneDirector {
|
||||
|
||||
const camera = this.styleManager.getCameraState();
|
||||
if (!camera) {
|
||||
this._updateStatus('Cannot capture shot: camera not ready');
|
||||
this._updateStatus(t('setup.scenes.status.captureCameraNotReady'));
|
||||
return;
|
||||
}
|
||||
|
||||
const shot = normalizeShot(
|
||||
{
|
||||
id: uid('shot'),
|
||||
title: `Shot ${scene.shots.length + 1}`,
|
||||
title: t('setup.scenes.status.shotTitleDefault', {
|
||||
n: scene.shots.length + 1,
|
||||
}),
|
||||
durationSec: DEFAULT_SHOT_DURATION_SEC,
|
||||
holdSec: DEFAULT_HOLD_SEC,
|
||||
camera,
|
||||
@@ -941,7 +977,12 @@ export class SceneDirector {
|
||||
this._selectedShotId = shot.id;
|
||||
this._saveProject();
|
||||
this._shotOutcome('shot-captured', scene, shot);
|
||||
this._updateStatus(`Captured: ${scene.title} / ${shot.title}`);
|
||||
this._updateStatus(
|
||||
t('setup.scenes.status.captured', {
|
||||
scene: scene.title,
|
||||
shot: shot.title,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -954,7 +995,7 @@ export class SceneDirector {
|
||||
|
||||
const shot = scene.shots.find((item) => item.id === this._selectedShotId);
|
||||
if (!shot) {
|
||||
this._updateStatus('Select a shot first');
|
||||
this._updateStatus(t('setup.scenes.status.selectShotFirst'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -969,7 +1010,12 @@ export class SceneDirector {
|
||||
|
||||
this._saveProject();
|
||||
this._shotOutcome('shot-updated', scene, shot);
|
||||
this._updateStatus(`Updated: ${scene.title} / ${shot.title}`);
|
||||
this._updateStatus(
|
||||
t('setup.scenes.status.updated', {
|
||||
scene: scene.title,
|
||||
shot: shot.title,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1564,7 +1610,7 @@ export class SceneDirector {
|
||||
this._claimingCamera = false;
|
||||
}
|
||||
if (claimed === false) {
|
||||
this._updateStatus('Camera unavailable — exit cockpit first');
|
||||
this._updateStatus(t('setup.scenes.status.cameraUnavailable'));
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
@@ -1699,7 +1745,7 @@ export class SceneDirector {
|
||||
if (!queue.length) return { started: false, reason: 'scene-complete' };
|
||||
}
|
||||
if (!queue.length) {
|
||||
this._updateStatus('No shots to run');
|
||||
this._updateStatus(t('setup.scenes.status.noShotsToRun'));
|
||||
return { started: false, reason: 'no-shots' };
|
||||
}
|
||||
|
||||
@@ -1782,7 +1828,11 @@ export class SceneDirector {
|
||||
releaseOnFinish: preview,
|
||||
});
|
||||
} catch (error) {
|
||||
this._updateStatus(`Error: ${error.message || 'run failed'}`);
|
||||
this._updateStatus(
|
||||
t('setup.scenes.status.runError', {
|
||||
message: error.message || 'run failed',
|
||||
}),
|
||||
);
|
||||
this._logEvent('scene_run_error', {
|
||||
message: error.message || 'unknown error',
|
||||
});
|
||||
@@ -1956,7 +2006,7 @@ export class SceneDirector {
|
||||
link.click();
|
||||
link.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
this._presentation.status = 'Project exported';
|
||||
this._presentation.status = t('setup.scenes.status.projectExported');
|
||||
this._publish({ type: 'project-exported', project: this._project });
|
||||
}
|
||||
|
||||
@@ -2218,9 +2268,7 @@ export class SceneDirector {
|
||||
`[Scenes] Could not exit ${mode}:`,
|
||||
result.error || 'unknown reason',
|
||||
);
|
||||
this._updateStatus(
|
||||
`Could not exit ${mode} — scene layers may be refused`,
|
||||
);
|
||||
this._updateStatus(t('setup.scenes.status.contextExitFailed', { mode }));
|
||||
this._logEvent('context_mode_exit_failed', {
|
||||
mode,
|
||||
error: result.error || null,
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
/** Connect the portable Director runner to the existing scene presentation. */
|
||||
import { t } from '../i18n/index.js';
|
||||
|
||||
export function createScenePlaybackAdapter(director, defaultShotDurationSec) {
|
||||
let timing;
|
||||
let travel;
|
||||
@@ -14,7 +16,12 @@ export function createScenePlaybackAdapter(director, defaultShotDurationSec) {
|
||||
director._renderSceneSelect();
|
||||
director._renderShotList();
|
||||
director._updateStatus(
|
||||
`Running ${index + 1}/${total}: ${scene.title} / ${shot.title}`,
|
||||
t('setup.scenes.status.runningShot', {
|
||||
index: index + 1,
|
||||
total,
|
||||
scene: scene.title,
|
||||
shot: shot.title,
|
||||
}),
|
||||
);
|
||||
director._logEvent('shot_start', {
|
||||
sceneId: scene.id,
|
||||
@@ -71,7 +78,7 @@ export function createScenePlaybackAdapter(director, defaultShotDurationSec) {
|
||||
},
|
||||
complete() {
|
||||
director._setProgress(1);
|
||||
director._updateStatus('Scene run complete');
|
||||
director._updateStatus(t('setup.scenes.status.runComplete'));
|
||||
director._logEvent('scene_run_complete', {});
|
||||
},
|
||||
};
|
||||
|
||||
@@ -6,6 +6,8 @@ import { createStandaloneScene } from './scene.js';
|
||||
import { createStandaloneControls } from './controls.js';
|
||||
import { createStandaloneData } from './data.js';
|
||||
import { createStandaloneTools } from './tools.js';
|
||||
import { resolveLocale, applyDocumentLanguage } from '../i18n/locale.js';
|
||||
import { applyDocumentTranslations, setLocale } from '../i18n/index.js';
|
||||
|
||||
// The existing controls and layer catalog contain page-scoped state.
|
||||
let constructed = false;
|
||||
@@ -21,6 +23,14 @@ export function createStandaloneApplication({
|
||||
if (constructed)
|
||||
throw new Error('The standalone application already owns this page');
|
||||
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.
|
||||
const activeLocale = resolveLocale();
|
||||
setLocale(activeLocale);
|
||||
applyDocumentLanguage(document, activeLocale);
|
||||
applyDocumentTranslations(document);
|
||||
const loadingScreen = document.getElementById('loading-screen');
|
||||
const loaderStatus = loadingScreen.querySelector('.loader-status');
|
||||
let placeSearch;
|
||||
|
||||
+126
-2
@@ -36,6 +36,9 @@ import { ShellFeedback } from './shellFeedback.js';
|
||||
|
||||
import { runCctvLayerEnableTransition } from '../cctvFocusPolicy.js';
|
||||
|
||||
import { getLocale, persistLocaleAndReload, t } from '../i18n/index.js';
|
||||
import { availableLocales } from '../i18n/locale.js';
|
||||
|
||||
/**
|
||||
* Central UI orchestrator for the God's Eye View application.
|
||||
*
|
||||
@@ -565,6 +568,8 @@ export class StyleManager extends ShellFacade {
|
||||
this._initUI();
|
||||
this._initMapStackControl();
|
||||
this._initPanelChrome();
|
||||
this._initLocaleSelector();
|
||||
this._applyRuntimeStaticHeaderText();
|
||||
this._initLeftPanelAdaptiveLayout();
|
||||
this._initRightPanelAdaptiveLayout();
|
||||
this._initRadioPanel();
|
||||
@@ -697,6 +702,109 @@ export class StyleManager extends ShellFacade {
|
||||
this._displayBindings._initUI();
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the command-dock locale switch (runtime-built since the
|
||||
* configurable-pair stage): one button per locale availableLocales()
|
||||
* yields — the configured GEV_DEFAULT_LOCALE / GEV_SECONDARY_LOCALE pair,
|
||||
* already deduped against the always-shipped English fallback. Labels are
|
||||
* uppercase locale codes (EN/ES/FR); aria-labels come from
|
||||
* shell.locale.<code>.ariaLabel in the active catalog. Pressed state is
|
||||
* synced from the resolved locale at boot; a click persists the choice and
|
||||
* reloads the page, so no live re-apply of document translations is needed
|
||||
* here (persistLocaleAndReload strips ?lang and keeps the hash).
|
||||
* @returns {void}
|
||||
*/
|
||||
_initLocaleSelector() {
|
||||
const container = document.querySelector(
|
||||
'#control-panel .dock-locale-switch',
|
||||
);
|
||||
if (!container) return;
|
||||
const active = getLocale();
|
||||
for (const locale of availableLocales()) {
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = 'dock-locale-btn';
|
||||
button.dataset.locale = locale;
|
||||
button.textContent = locale.toUpperCase();
|
||||
button.setAttribute('aria-label', t(`shell.locale.${locale}.ariaLabel`));
|
||||
const isActive = locale === active;
|
||||
button.classList.toggle('active', isActive);
|
||||
button.setAttribute('aria-pressed', String(isActive));
|
||||
button.addEventListener('click', () => {
|
||||
persistLocaleAndReload(button.dataset.locale);
|
||||
});
|
||||
container.appendChild(button);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrites a header's trailing text node in place, preserving the leading
|
||||
* dock-label-icon span that a textContent write would destroy (the phase-2
|
||||
* "key-only" headers: .panel-title / .pp-header-label share their element
|
||||
* with the icon, so no data-i18n attribute could target them — see
|
||||
* docs/TRANSLATORS.md, runtime-only static sites).
|
||||
* @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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Localizes the key-only dock/panel headers at boot: VISUAL PRESETS,
|
||||
* LOCATION, and the DISPLAY rail label. English output is byte-identical to
|
||||
* the static markup these replace.
|
||||
* @returns {void}
|
||||
*/
|
||||
_applyRuntimeStaticHeaderText() {
|
||||
this._setHeaderText(
|
||||
document.querySelector('#control-panel .panel-title'),
|
||||
t('cockpit.presets.title'),
|
||||
);
|
||||
this._setHeaderText(
|
||||
document.querySelector('#location-bar .location-toolbar-label'),
|
||||
t('cockpit.location.toolbarLabel'),
|
||||
);
|
||||
this._setHeaderText(
|
||||
document.querySelector('#pp-toggles .pp-header-label'),
|
||||
t('cockpit.display.title'),
|
||||
);
|
||||
// The cockpit context kicker ('CONTACT') is visible on the map itself, and
|
||||
// the briefing kicker shares its element with the live-dot <i>; both are
|
||||
// phase-2 key-only sites (see docs/TRANSLATORS.md). The briefing
|
||||
// text node keeps renderBriefPage's leading-space convention.
|
||||
this._setHeaderText(
|
||||
document.querySelector('.cockpit-context-kicker'),
|
||||
t('cockpit.context.kicker'),
|
||||
);
|
||||
// The phase-2 key-only standby description: one span holds BOTH mode
|
||||
// descriptions split by a literal <br>, so no attribute write was possible
|
||||
// without destroying the markup (docs/TRANSLATORS.md). Split into
|
||||
// two keys and write them at boot, preserving the <br>.
|
||||
const standbyDescription = document.querySelector(
|
||||
'#context-mode-standby span',
|
||||
);
|
||||
if (standbyDescription) {
|
||||
standbyDescription.replaceChildren(
|
||||
document.createTextNode(t('cockpit.context.standbyContactsDesc')),
|
||||
document.createElement('br'),
|
||||
document.createTextNode(t('cockpit.context.standbyMissionsDesc')),
|
||||
);
|
||||
}
|
||||
const briefKicker = document.querySelector('#cockpit-brief-kicker');
|
||||
if (briefKicker) {
|
||||
this._setHeaderText(briefKicker, ` ${t('cockpit.brief.kicker')}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the owner-approved map stack chip row from the matching controller
|
||||
* entries. Cesium ion/Bing chips remain keyboard-focusable but unavailable,
|
||||
@@ -705,6 +813,18 @@ export class StyleManager extends ShellFacade {
|
||||
*/
|
||||
_initMapStackControl() {
|
||||
if (!this.mapStackController) return;
|
||||
|
||||
// Key-only i18n sites (mapStackChips.test.mjs pins the static markup
|
||||
// verbatim, so no data-i18n* attribute can be added): localize at this
|
||||
// runtime write instead. The locale resolves before UI init and never
|
||||
// changes without a reload, so an init-time write is sufficient.
|
||||
if (this._mapSourceLabel)
|
||||
this._mapSourceLabel.textContent = t('cockpit.presets.mapSourceLabel');
|
||||
this._mapStackChips.setAttribute(
|
||||
'aria-label',
|
||||
t('cockpit.presets.mapSourceChipsAriaLabel'),
|
||||
);
|
||||
|
||||
this._mapSourceControls?.destroy();
|
||||
this._mapSourceControls = createMapSourceControls({
|
||||
container: this._mapStackChips,
|
||||
@@ -986,7 +1106,7 @@ export class StyleManager extends ShellFacade {
|
||||
const { cctvLayer } = this.services;
|
||||
if (this._disposed) return false;
|
||||
if (!this._dataManager || !this._dataManager.layers?.has('cctv')) {
|
||||
this._showToast('CCTV layer unavailable');
|
||||
this._showToast(t('cockpit.cctv.toastLayerUnavailable'));
|
||||
return false;
|
||||
}
|
||||
const enabled = this._dataManager.isEnabled('cctv');
|
||||
@@ -1433,7 +1553,11 @@ export class StyleManager extends ShellFacade {
|
||||
this._lifetime.listen(this._shareBtn, 'click', async () => {
|
||||
const success = await this.shareLinkManager.copyLink();
|
||||
if (!this._disposed)
|
||||
this._showToast(success ? 'Link copied!' : 'Copy failed');
|
||||
this._showToast(
|
||||
success
|
||||
? t('cockpit.share.toastCopied')
|
||||
: t('cockpit.share.toastCopyFailed'),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { t } from '../i18n/index.js';
|
||||
|
||||
export function _initCctvPanel() {
|
||||
if (!this._cctvPanel) return;
|
||||
|
||||
@@ -143,7 +145,7 @@ export function _initCctvPanel() {
|
||||
},
|
||||
{ origin: 'user' },
|
||||
);
|
||||
this.actions.showToast('CCTV calibration saved');
|
||||
this.actions.showToast(t('cockpit.cctv.toastCalibrationSaved'));
|
||||
});
|
||||
|
||||
this.listen(this._cctvCalibResetBtn, 'click', () => {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { t } from '../i18n/index.js';
|
||||
|
||||
/** Shortest-wrap signed degrees, for heading offsets typed as absolute values. */
|
||||
const signedNormalizeDeg = (deg) => ((((deg + 180) % 360) + 360) % 360) - 180;
|
||||
|
||||
@@ -81,7 +83,7 @@ export function _resetCctvCalibration() {
|
||||
},
|
||||
{ origin: 'user' },
|
||||
);
|
||||
this.actions.showToast('CCTV calibration reset');
|
||||
this.actions.showToast(t('cockpit.cctv.toastCalibrationReset'));
|
||||
}
|
||||
|
||||
export function _beginCctvCalValueEdit(chip) {
|
||||
@@ -162,7 +164,9 @@ export function _syncCctvCalReadout(enabled, activeCamera) {
|
||||
if (this._cctvAdjustBtn) {
|
||||
const adjustOn = !!this._cctvState?.calibrationMode;
|
||||
this._cctvAdjustBtn.classList.toggle('active', adjustOn && canCalibrate);
|
||||
this._cctvAdjustBtn.textContent = adjustOn ? 'ADJUST ON' : 'ADJUST';
|
||||
this._cctvAdjustBtn.textContent = adjustOn
|
||||
? t('layers.cctv.adjustOn')
|
||||
: t('layers.cctv.adjustLabel');
|
||||
this._cctvAdjustBtn.disabled = !canCalibrate;
|
||||
}
|
||||
if (this._cctvCalReadout) {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { t } from '../i18n/index.js';
|
||||
|
||||
export function _clearCctvFrame() {
|
||||
this._cctvFrameRequestToken += 1;
|
||||
if (this._cctvFramePreloader) {
|
||||
@@ -86,19 +88,19 @@ export function _settleCctvFrame(token, src, ok) {
|
||||
export function _syncCctvSourceBadge(activeCamera, enabled) {
|
||||
if (!this._cctvSourceBadge) return;
|
||||
if (!enabled || !activeCamera) {
|
||||
this._cctvSourceBadge.textContent = 'SOURCE · UNKNOWN';
|
||||
this._cctvSourceBadge.textContent = t('layers.cctv.sourceUnknown');
|
||||
this._cctvSourceBadge.dataset.frameState = 'idle';
|
||||
return;
|
||||
}
|
||||
const hasDisplayedFrame =
|
||||
this._cctvFrameWrap?.classList.contains('has-frame');
|
||||
if (this._cctvFrame?.dataset.loading === 'true' && !hasDisplayedFrame) {
|
||||
this._cctvSourceBadge.textContent = 'FRAME · LOADING';
|
||||
this._cctvSourceBadge.textContent = t('cockpit.cctv.frameLoading');
|
||||
this._cctvSourceBadge.dataset.frameState = 'loading';
|
||||
return;
|
||||
}
|
||||
if (this._cctvFrame?.dataset.error === 'true' && !hasDisplayedFrame) {
|
||||
this._cctvSourceBadge.textContent = 'FRAME · UNAVAILABLE';
|
||||
this._cctvSourceBadge.textContent = t('cockpit.cctv.frameUnavailable');
|
||||
this._cctvSourceBadge.dataset.frameState = 'error';
|
||||
return;
|
||||
}
|
||||
|
||||
+48
-24
@@ -1,12 +1,14 @@
|
||||
import { t } from '../i18n/index.js';
|
||||
|
||||
import { createCctvVideoSurface } from './cctvVideo.js';
|
||||
export function _calBadgeLabel(badge) {
|
||||
switch (badge) {
|
||||
case 'calibrated':
|
||||
return 'CALIBRATED';
|
||||
return t('cockpit.cctv.calBadgeCalibrated');
|
||||
case 'curated':
|
||||
return 'CURATED';
|
||||
return t('cockpit.cctv.calBadgeCurated');
|
||||
case 'raw-prior':
|
||||
return 'RAW PRIOR';
|
||||
return t('cockpit.cctv.calBadgeRawPrior');
|
||||
default:
|
||||
return '--';
|
||||
}
|
||||
@@ -51,7 +53,9 @@ export function _renderCctvState(state) {
|
||||
|
||||
if (this._cctvEnableBtn) {
|
||||
this._cctvEnableBtn.classList.toggle('active', enabled);
|
||||
this._cctvEnableBtn.textContent = enabled ? 'CCTV ON' : 'CCTV OFF';
|
||||
this._cctvEnableBtn.textContent = enabled
|
||||
? t('layers.cctv.toggleOn')
|
||||
: t('layers.cctv.toggleOff');
|
||||
}
|
||||
|
||||
if (this._cctvSelect) {
|
||||
@@ -99,17 +103,19 @@ export function _renderCctvState(state) {
|
||||
this._cctvCoverageBtn.classList.toggle('active', mode !== 'off');
|
||||
this._cctvCoverageBtn.textContent =
|
||||
mode === 'viewshed'
|
||||
? 'VIEWSHED ON'
|
||||
? t('cockpit.cctv.coverageViewshedOn')
|
||||
: mode === 'on'
|
||||
? 'COVERAGE ON'
|
||||
: 'COVERAGE OFF';
|
||||
? t('layers.cctv.coverageOn')
|
||||
: t('layers.cctv.coverageOff');
|
||||
this._cctvCoverageBtn.disabled = !enabled;
|
||||
}
|
||||
|
||||
if (this._cctvAutoHopBtn) {
|
||||
const autoHop = !!state?.autoHop;
|
||||
this._cctvAutoHopBtn.classList.toggle('active', autoHop);
|
||||
this._cctvAutoHopBtn.textContent = autoHop ? 'AUTO HOP ON' : 'AUTO HOP OFF';
|
||||
this._cctvAutoHopBtn.textContent = autoHop
|
||||
? t('layers.cctv.autoHopOn')
|
||||
: t('layers.cctv.autoHopOff');
|
||||
this._cctvAutoHopBtn.disabled = !enabled;
|
||||
}
|
||||
|
||||
@@ -117,8 +123,8 @@ export function _renderCctvState(state) {
|
||||
const showProjection = state?.showProjection !== false;
|
||||
this._cctvProjectionBtn.classList.toggle('active', showProjection);
|
||||
this._cctvProjectionBtn.textContent = showProjection
|
||||
? 'PROJECTION ON'
|
||||
: 'PROJECTION OFF';
|
||||
? t('layers.cctv.projectionOn')
|
||||
: t('layers.cctv.projectionOff');
|
||||
this._cctvProjectionBtn.disabled = !enabled;
|
||||
}
|
||||
|
||||
@@ -133,8 +139,10 @@ export function _renderCctvState(state) {
|
||||
const badge = activeCamera?.calBadge || null;
|
||||
const dirty = !!activeCamera?.calDirty;
|
||||
this._cctvQualityChip.textContent = dirty
|
||||
? 'CAL · EDITED (UNSAVED)'
|
||||
: `CAL · ${this._calBadgeLabel(badge)}`;
|
||||
? t('cockpit.cctv.calChipEdited')
|
||||
: t('cockpit.cctv.calChipTemplate', {
|
||||
badge: this._calBadgeLabel(badge),
|
||||
});
|
||||
this._cctvQualityChip.dataset.calBadge = dirty ? 'edited' : badge || '';
|
||||
}
|
||||
|
||||
@@ -154,14 +162,26 @@ export function _renderCctvState(state) {
|
||||
const calBadge = activeCamera.calBadge
|
||||
? this._calBadgeLabel(activeCamera.calBadge)
|
||||
: '';
|
||||
const projLabel = state?.showProjection !== false ? 'MONITOR' : 'OFF';
|
||||
this._cctvMeta.textContent = `${activeCamera.city} · HDG ${Math.round(activeCamera.headingDeg)}° · FOV ${Math.round(activeCamera.fovDeg)}° · RANGE ${Math.round(activeCamera.rangeM)}m · ${projLabel}${calBadge ? ` · ${calBadge}` : ''} · ${provider}${credit}${statusMsg}`;
|
||||
const projLabel =
|
||||
state?.showProjection !== false
|
||||
? t('cockpit.cctv.metaProjectionMonitor')
|
||||
: t('cockpit.cctv.metaProjectionOff');
|
||||
this._cctvMeta.textContent = t('cockpit.cctv.metaTemplate', {
|
||||
city: activeCamera.city,
|
||||
heading: `${Math.round(activeCamera.headingDeg)}°`,
|
||||
fov: `${Math.round(activeCamera.fovDeg)}°`,
|
||||
range: Math.round(activeCamera.rangeM),
|
||||
projection: projLabel,
|
||||
calBadge: calBadge ? ` · ${calBadge}` : '',
|
||||
provider,
|
||||
status: `${credit}${statusMsg}`,
|
||||
});
|
||||
} else if (cameras.length > 0) {
|
||||
this._cctvMeta.textContent = enabled
|
||||
? `${cameras.length} cameras loaded · click a camera to activate`
|
||||
: `${cameras.length} cameras loaded · enable CCTV to activate`;
|
||||
? t('cockpit.cctv.metaCamerasClick', { count: cameras.length })
|
||||
: t('cockpit.cctv.metaCamerasEnable', { count: cameras.length });
|
||||
} else {
|
||||
this._cctvMeta.textContent = 'Enable CCTV to load camera intersections';
|
||||
this._cctvMeta.textContent = t('layers.cctv.metaIdle');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -209,15 +229,13 @@ export function _renderCctvState(state) {
|
||||
}
|
||||
|
||||
this._syncCctvSourceBadge(activeCamera, enabled);
|
||||
this._typeCctvSummary(
|
||||
state?.summary ||
|
||||
'Enable CCTV to start camera-linked intelligence summaries.',
|
||||
);
|
||||
this._typeCctvSummary(state?.summary || t('layers.cctv.summaryIdle'));
|
||||
}
|
||||
|
||||
export function _typeCctvSummary(text) {
|
||||
if (this.destroyed || !this._cctvSummary) return;
|
||||
const nextText = String(text || '').trim() || 'No summary available.';
|
||||
const nextText =
|
||||
String(text || '').trim() || t('cockpit.cctv.summaryNoneAvailable');
|
||||
if (nextText === this._lastCctvSummaryText) return;
|
||||
this._lastCctvSummaryText = nextText;
|
||||
|
||||
@@ -249,7 +267,10 @@ export function _updateCctvSyncChip(loading, enabled) {
|
||||
clearTimeout(this._cctvChipHideTimer);
|
||||
this._cctvChipHideTimer = null;
|
||||
this._cctvChipWasBusy = true;
|
||||
this.actions.setSplitFlapText(this._cctvSyncLabel, 'loading frames');
|
||||
this.actions.setSplitFlapText(
|
||||
this._cctvSyncLabel,
|
||||
t('cockpit.cctv.syncLoadingFrames'),
|
||||
);
|
||||
// The counter is left plain on purpose: it ticks every few frames
|
||||
// during a grid load, and flapping it would read as a slot machine.
|
||||
this._cctvSyncProgress.textContent = `${loaded}/${total}`;
|
||||
@@ -260,7 +281,10 @@ export function _updateCctvSyncChip(loading, enabled) {
|
||||
if (this._cctvChipWasBusy && enabled && total > 0) {
|
||||
// Load just completed — flash the final count, then auto-hide.
|
||||
this._cctvChipWasBusy = false;
|
||||
this.actions.setSplitFlapText(this._cctvSyncLabel, 'camera grid ready');
|
||||
this.actions.setSplitFlapText(
|
||||
this._cctvSyncLabel,
|
||||
t('cockpit.cctv.syncGridReady'),
|
||||
);
|
||||
this._cctvSyncProgress.textContent = `${total}/${total}`;
|
||||
this._cctvSyncChip.classList.add('visible');
|
||||
clearTimeout(this._cctvChipHideTimer);
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { t } from '../i18n/index.js';
|
||||
|
||||
/** Keep the clear action focusable while busy; the caller owns its transaction. */
|
||||
export function bindClearLayersControl(button, clear) {
|
||||
let destroyed = false;
|
||||
@@ -12,7 +14,9 @@ export function bindClearLayersControl(button, clear) {
|
||||
button.setAttribute('aria-busy', String(busy));
|
||||
button.setAttribute(
|
||||
'aria-label',
|
||||
busy ? 'Clearing selected data layers' : 'Clear selected data layers',
|
||||
busy
|
||||
? t('cockpit.actions.clearLayersBusyAria')
|
||||
: t('shell.actions.clearLayers.ariaLabel'),
|
||||
);
|
||||
},
|
||||
destroy() {
|
||||
|
||||
+66
-19
@@ -1,4 +1,5 @@
|
||||
/** Regional briefing requests, pages and rotation for the Cockpit controller. */
|
||||
import { t } from '../i18n/index.js';
|
||||
import {
|
||||
COCKPIT_BRIEF_ROTATE_MS,
|
||||
COCKPIT_BRIEF_CYCLE_OFF_HELP,
|
||||
@@ -10,6 +11,24 @@ import {
|
||||
formatCockpitWindDirection,
|
||||
} from './cockpitPresentation.js';
|
||||
|
||||
// regionalModel.weatherCodeLabel lives in the shared node/browser source graph,
|
||||
// so it returns stable English labels; localize them here, at the cockpit
|
||||
// presentation boundary (docs/TRANSLATORS.md).
|
||||
const WEATHER_CONDITION_KEYS = Object.freeze({
|
||||
'CONDITIONS UNKNOWN': 'layers.weather.conditionsUnknown',
|
||||
CLEAR: 'layers.weather.clear',
|
||||
'PARTLY CLOUDY': 'layers.weather.partlyCloudy',
|
||||
OVERCAST: 'layers.weather.overcast',
|
||||
FOG: 'layers.weather.fog',
|
||||
DRIZZLE: 'layers.weather.drizzle',
|
||||
RAIN: 'layers.weather.rain',
|
||||
SNOW: 'layers.weather.snow',
|
||||
'RAIN SHOWERS': 'layers.weather.rainShowers',
|
||||
'SNOW SHOWERS': 'layers.weather.snowShowers',
|
||||
THUNDERSTORM: 'layers.weather.thunderstorm',
|
||||
'MIXED CONDITIONS': 'layers.weather.mixed',
|
||||
});
|
||||
|
||||
export function showBriefPage(index, { manual = false } = {}) {
|
||||
if (this.destroyed) return;
|
||||
const count = COCKPIT_BRIEF_PAGES.length;
|
||||
@@ -26,15 +45,16 @@ export function showBriefPage(index, { manual = false } = {}) {
|
||||
if (this.briefKicker) {
|
||||
const indicator = this.briefKicker.querySelector('i');
|
||||
this.briefKicker.replaceChildren(
|
||||
...[indicator, document.createTextNode(` ${page.kicker}`)].filter(
|
||||
...[indicator, document.createTextNode(` ${t(page.kickerKey)}`)].filter(
|
||||
Boolean,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (this.briefSubtitle) this.briefSubtitle.textContent = page.subtitle;
|
||||
if (this.briefSubtitle) this.briefSubtitle.textContent = t(page.subtitleKey);
|
||||
if (this.briefPosition)
|
||||
this.briefPosition.textContent = `${this.briefPageIndex + 1} / ${count}`;
|
||||
if (this.briefSource) this.briefSource.textContent = page.source;
|
||||
if (this.briefSource)
|
||||
this.briefSource.textContent = t(page.sourceKey ?? page.source);
|
||||
if (this.signalStream) this.signalStream.dataset.briefPage = page.id;
|
||||
if (manual && this.briefAutoRotateEnabled)
|
||||
this.startBriefRotation({ reset: true });
|
||||
@@ -56,6 +76,19 @@ export function setBriefAutoRotate(enabled) {
|
||||
: COCKPIT_BRIEF_CYCLE_OFF_HELP;
|
||||
this.briefAutoToggle.setAttribute('aria-label', label);
|
||||
this.briefAutoToggle.title = help;
|
||||
// The English writes above are pinned verbatim by cockpitMarkup.test.mjs;
|
||||
// these t() re-writes are same-value overrides that localize the toggle
|
||||
// under other locales (byte-identical under 'en').
|
||||
const labelKey = this.briefAutoRotateEnabled
|
||||
? 'cockpit.brief.autoOn'
|
||||
: 'cockpit.brief.autoOff';
|
||||
this.briefAutoToggle.textContent = t(labelKey);
|
||||
this.briefAutoToggle.setAttribute('aria-label', t(labelKey));
|
||||
this.briefAutoToggle.title = t(
|
||||
this.briefAutoRotateEnabled
|
||||
? 'cockpit.brief.autoTitleOn'
|
||||
: 'cockpit.brief.autoTitle',
|
||||
);
|
||||
}
|
||||
if (this.briefAutoRotateEnabled) this.startBriefRotation({ reset: true });
|
||||
else this.stopBriefRotation();
|
||||
@@ -94,7 +127,7 @@ export function stopBriefRotation() {
|
||||
export function updateLocalPosition(info) {
|
||||
if (!this.localCoordinates) return;
|
||||
if (!Number.isFinite(info.latitude) || !Number.isFinite(info.longitude)) {
|
||||
this.localCoordinates.textContent = 'POSITION UNAVAILABLE';
|
||||
this.localCoordinates.textContent = t('cockpit.brief.positionUnavailable');
|
||||
return;
|
||||
}
|
||||
const lat = `${Math.abs(info.latitude).toFixed(3)}°${info.latitude >= 0 ? 'N' : 'S'}`;
|
||||
@@ -175,14 +208,14 @@ export function renderRegionalBriefStatus(status, info) {
|
||||
this.newsStatus.dataset.state = status;
|
||||
this.newsStatus.textContent =
|
||||
status === 'loading'
|
||||
? 'ACQUIRING REGIONAL NEWS'
|
||||
: 'REGIONAL NEWS UNAVAILABLE';
|
||||
? t('cockpit.brief.newsAcquiring')
|
||||
: t('cockpit.brief.newsUnavailable');
|
||||
}
|
||||
if (status === 'unavailable') this.newsList?.replaceChildren();
|
||||
if (this.localPlace && status === 'loading')
|
||||
this.localPlace.textContent = 'RESOLVING REGION';
|
||||
this.localPlace.textContent = t('cockpit.brief.localResolving');
|
||||
if (this.localPlace && status === 'unavailable')
|
||||
this.localPlace.textContent = 'REGION UNAVAILABLE';
|
||||
this.localPlace.textContent = t('cockpit.brief.regionUnavailable');
|
||||
this.updateLocalPosition(info);
|
||||
}
|
||||
|
||||
@@ -193,8 +226,8 @@ export function renderRegionalBrief(payload, info) {
|
||||
this.newsStatus.dataset.state = payload?.newsStatus || 'unavailable';
|
||||
this.newsStatus.textContent =
|
||||
payload?.newsStatus === 'empty'
|
||||
? 'NO RECENT LOCATION MATCHES'
|
||||
: 'REGIONAL NEWS UNAVAILABLE';
|
||||
? t('cockpit.brief.newsEmpty')
|
||||
: t('cockpit.brief.newsUnavailable');
|
||||
}
|
||||
if (this.newsList) {
|
||||
this.newsList.replaceChildren(
|
||||
@@ -207,7 +240,10 @@ export function renderRegionalBrief(payload, info) {
|
||||
const title = document.createElement('strong');
|
||||
title.textContent = article.title;
|
||||
const metadata = document.createElement('span');
|
||||
metadata.textContent = `${article.domain || 'SOURCE'} · ${formatCockpitBriefAge(article.publishedAt)}`;
|
||||
metadata.textContent = t('cockpit.brief.articleMetaTemplate', {
|
||||
domain: article.domain || t('cockpit.brief.metadataSourceFallback'),
|
||||
age: formatCockpitBriefAge(article.publishedAt),
|
||||
});
|
||||
link.append(title, metadata);
|
||||
entry.append(link);
|
||||
return entry;
|
||||
@@ -216,7 +252,9 @@ export function renderRegionalBrief(payload, info) {
|
||||
}
|
||||
|
||||
const placeLabel =
|
||||
payload?.place?.label || payload?.place?.country || 'REGION UNAVAILABLE';
|
||||
payload?.place?.label ||
|
||||
payload?.place?.country ||
|
||||
t('cockpit.brief.regionUnavailable');
|
||||
if (this.localPlace) this.localPlace.textContent = placeLabel.toUpperCase();
|
||||
this.updateLocalPosition(info);
|
||||
const weather = payload?.weather;
|
||||
@@ -235,14 +273,19 @@ export function renderRegionalBrief(payload, info) {
|
||||
weather?.windDirectionDeg,
|
||||
);
|
||||
}
|
||||
if (this.localCondition)
|
||||
this.localCondition.textContent = this.services.weatherCodeLabel(
|
||||
weather?.weatherCode,
|
||||
);
|
||||
if (this.localCondition) {
|
||||
const condition = this.services.weatherCodeLabel(weather?.weatherCode);
|
||||
const conditionKey = WEATHER_CONDITION_KEYS[condition];
|
||||
this.localCondition.textContent = conditionKey
|
||||
? t(conditionKey)
|
||||
: condition;
|
||||
}
|
||||
if (this.localCloud) {
|
||||
this.localCloud.textContent = Number.isFinite(weather?.cloudCoverPct)
|
||||
? `CLOUD ${Math.round(weather.cloudCoverPct)}%`
|
||||
: 'CLOUD UNKNOWN';
|
||||
? t('cockpit.brief.cloudTemplate', {
|
||||
pct: Math.round(weather.cloudCoverPct),
|
||||
})
|
||||
: t('cockpit.brief.cloudUnknown');
|
||||
}
|
||||
if (this.localPrecipitation) {
|
||||
this.localPrecipitation.textContent = Number.isFinite(
|
||||
@@ -254,7 +297,11 @@ export function renderRegionalBrief(payload, info) {
|
||||
if (this.signalStream)
|
||||
this.signalStream.dataset.regionalStatus = payload?.status || 'partial';
|
||||
if (this.briefPageIndex === 1 && this.briefSource) {
|
||||
this.briefSource.textContent = `${String(payload?.newsSource || 'REGIONAL NEWS').toUpperCase()} · LOCATION QUERY`;
|
||||
this.briefSource.textContent = t('cockpit.brief.newsSourceLine', {
|
||||
source: String(
|
||||
payload?.newsSource || t('cockpit.brief.kickerNews'),
|
||||
).toUpperCase(),
|
||||
});
|
||||
}
|
||||
this.scheduleContextLayout();
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
/** Contact readouts and nearby-signal presentation for the Cockpit controller. */
|
||||
import * as Cesium from 'cesium';
|
||||
import { t } from '../i18n/index.js';
|
||||
import {
|
||||
bearingBetweenCoordinates,
|
||||
formatCockpitContextScope,
|
||||
@@ -20,8 +21,8 @@ export function updateContext(info, heading) {
|
||||
this.pushCockpitSignal(
|
||||
'context-status',
|
||||
'info',
|
||||
'CONTEXT STANDBY',
|
||||
'ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS',
|
||||
t('cockpit.signal.contextStandby'),
|
||||
t('cockpit.signal.contextStandbyHint'),
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -52,8 +53,14 @@ export function updateContext(info, heading) {
|
||||
const enteringLost = this.context.dataset.state !== 'lost';
|
||||
this.context.dataset.state = 'lost';
|
||||
if (this.contextUncertainty) {
|
||||
// The English literal write is pinned verbatim by cockpitMarkup.test.mjs
|
||||
// ('CONTACT LOST' must appear in this method body); the t() re-write
|
||||
// below is a same-value override that localizes it under other locales.
|
||||
this.contextUncertainty.textContent =
|
||||
'CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR';
|
||||
this.contextUncertainty.textContent = t(
|
||||
'cockpit.context.uncertaintyContactLost',
|
||||
);
|
||||
}
|
||||
// The cue changes the footer's height; re-run layout once on the way in
|
||||
// rather than every frame the contact stays lost.
|
||||
@@ -61,8 +68,10 @@ export function updateContext(info, heading) {
|
||||
this.pushCockpitSignal(
|
||||
'context-status',
|
||||
'warning',
|
||||
`CONTACT LOST · ${snapshot.subject.label || snapshot.subject.id || 'SUBJECT'}`,
|
||||
'SUBJECT LEFT ITS FEED · READOUT HOLDING LAST KNOWN',
|
||||
t('cockpit.signal.contactLostTitle', {
|
||||
subject: snapshot.subject.label || snapshot.subject.id || 'SUBJECT',
|
||||
}),
|
||||
t('cockpit.signal.contactLostDetail'),
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -83,12 +92,17 @@ export function updateContext(info, heading) {
|
||||
const closestLabel = this.services.formatAwarenessLabel(closest);
|
||||
if (this.contextNearestLabel) {
|
||||
this.contextNearestLabel.textContent = closest
|
||||
? `${closest.cohort.label.toUpperCase()} · ${closestLabel}`
|
||||
: 'NO AVAILABLE EXAMPLE';
|
||||
? t('cockpit.context.nearestTemplate', {
|
||||
cohort: closest.cohort.label.toUpperCase(),
|
||||
contact: closestLabel,
|
||||
})
|
||||
: t('cockpit.context.nearestEmpty');
|
||||
this.contextNearestLabel.setAttribute(
|
||||
'aria-label',
|
||||
closest && closestLabel === '—'
|
||||
? `${closest.cohort.label}, Unavailable`
|
||||
? t('cockpit.context.nearestUnavailableAria', {
|
||||
cohort: closest.cohort.label,
|
||||
})
|
||||
: this.contextNearestLabel.textContent,
|
||||
);
|
||||
}
|
||||
@@ -133,15 +147,30 @@ export function updateContext(info, heading) {
|
||||
this.contextDirection.classList.toggle('unknown', relative === null);
|
||||
}
|
||||
if (this.contextBearing) {
|
||||
// The 'BRG —' literal is pinned verbatim by cockpitMarkup.test.mjs; the
|
||||
// t() re-write below is a same-value override that localizes it.
|
||||
if (relative === null) this.contextBearing.textContent = 'BRG —';
|
||||
else if (Math.abs(relative) < 8) this.contextBearing.textContent = 'AHEAD';
|
||||
else
|
||||
this.contextBearing.textContent = `${relative < 0 ? 'L' : 'R'} ${String(Math.round(Math.abs(relative))).padStart(3, '0')}°`;
|
||||
if (relative === null)
|
||||
this.contextBearing.textContent = t('cockpit.context.bearingNone');
|
||||
else if (Math.abs(relative) < 8)
|
||||
this.contextBearing.textContent = t('cockpit.context.bearingAhead');
|
||||
else {
|
||||
this.contextBearing.textContent = t('cockpit.context.bearingSide', {
|
||||
side:
|
||||
relative < 0
|
||||
? t('cockpit.context.sideLeft')
|
||||
: t('cockpit.context.sideRight'),
|
||||
angle: `${String(Math.round(Math.abs(relative))).padStart(3, '0')}°`,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (this.contextUncertainty) {
|
||||
this.contextUncertainty.textContent = unknownCount
|
||||
? `${unknownCount} INPUT${unknownCount === 1 ? '' : 'S'} UNKNOWN · NOT AN ALL-CLEAR`
|
||||
: 'AVAILABLE INPUTS CURRENT · NOT AN ALL-CLEAR';
|
||||
? t('cockpit.context.uncertaintyInputsUnknown', { count: unknownCount })
|
||||
: t('cockpit.context.uncertaintyInputsCurrent');
|
||||
}
|
||||
if (this.contextUpdated) {
|
||||
this.contextUpdated.textContent = Number.isFinite(snapshot.evaluatedAt)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
/** Instrument values, routes and vision controls for the Cockpit controller. */
|
||||
import { t } from '../i18n/index.js';
|
||||
import {
|
||||
COCKPIT_VISION_MODES,
|
||||
normalizeCockpitVisionMode,
|
||||
@@ -31,7 +32,10 @@ export function updateHud(
|
||||
const heading = normalizeHeading(this.heading ?? info.track ?? 0);
|
||||
if (this.callsign) {
|
||||
this.callsign.textContent =
|
||||
info.callsign || info.registration || info.icao24 || 'AIRCRAFT';
|
||||
info.callsign ||
|
||||
info.registration ||
|
||||
info.icao24 ||
|
||||
t('cockpit.hud.fallbackCallsign');
|
||||
}
|
||||
const speedKt = Number.isFinite(info.velocityMps)
|
||||
? info.velocityMps * 1.94384
|
||||
@@ -126,14 +130,22 @@ export function updateHud(
|
||||
this.position.textContent = `${lat} · ${lon}`;
|
||||
}
|
||||
if (this.aircraftMeta) {
|
||||
// State-sibling keys at the writer: each feed state translates
|
||||
// independently so no state can leak English under other locales.
|
||||
const feedState = this.surfaceAcquiring
|
||||
? 'ACQUIRING SURFACE'
|
||||
? t('cockpit.hud.metaFeedAcquiringSurface')
|
||||
: this.surfaceFallback
|
||||
? 'SURFACE FALLBACK'
|
||||
? t('cockpit.hud.metaFeedSurfaceFallback')
|
||||
: info.stale
|
||||
? 'STALE FEED'
|
||||
: 'LIVE TRACK';
|
||||
this.aircraftMeta.textContent = `${info.layerId === 'military' ? 'MILITARY' : 'COMMERCIAL'} · ${feedState} · COURSE ALIGNED`;
|
||||
? t('cockpit.hud.metaFeedStale')
|
||||
: t('cockpit.hud.metaFeedLive');
|
||||
this.aircraftMeta.textContent = t('cockpit.hud.aircraftMetaTemplate', {
|
||||
aircraftClass:
|
||||
info.layerId === 'military'
|
||||
? t('cockpit.hud.metaClassMilitary')
|
||||
: t('cockpit.hud.metaClassCommercial'),
|
||||
feedState,
|
||||
});
|
||||
}
|
||||
this.updateRoute(info);
|
||||
if (
|
||||
@@ -158,13 +170,14 @@ export function updateRoute(info) {
|
||||
const validDestination =
|
||||
Number.isFinite(destination?.lat) && Number.isFinite(destination?.lon);
|
||||
const routeLabel = (airport) =>
|
||||
[airport?.code, airport?.name].filter(Boolean).join(' · ') || 'UNKNOWN';
|
||||
[airport?.code, airport?.name].filter(Boolean).join(' · ') ||
|
||||
t('cockpit.route.unknownEndpoint');
|
||||
if (this.routeFrom) this.routeFrom.textContent = routeLabel(origin);
|
||||
if (this.routeTo) this.routeTo.textContent = routeLabel(destination);
|
||||
if (this.routeStatus) {
|
||||
this.routeStatus.textContent = validDestination
|
||||
? 'ARROW · ESTIMATED DIRECTION'
|
||||
: 'ROUTE DATA UNAVAILABLE';
|
||||
? t('cockpit.route.statusArrowEstimated')
|
||||
: t('cockpit.route.statusUnavailable');
|
||||
}
|
||||
if (this.route) this.route.hidden = !origin && !destination;
|
||||
if (
|
||||
@@ -198,7 +211,9 @@ export function updateRoute(info) {
|
||||
);
|
||||
}
|
||||
if (this.routeDirectionLabel) {
|
||||
this.routeDirectionLabel.textContent = `DEST ${String(Math.round(destinationBearing)).padStart(3, '0')}°`;
|
||||
this.routeDirectionLabel.textContent = t('cockpit.route.directionLabel', {
|
||||
bearing: `${String(Math.round(destinationBearing)).padStart(3, '0')}°`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -206,12 +221,16 @@ export function syncWeatherToggle(enabled) {
|
||||
if (!this.weatherToggle) return;
|
||||
const active = !!enabled;
|
||||
this.weatherToggle.setAttribute('aria-pressed', String(active));
|
||||
this.weatherToggle.setAttribute(
|
||||
'aria-label',
|
||||
`${active ? 'Disable' : 'Enable'} cockpit weather effects`,
|
||||
);
|
||||
this.weatherToggle.title = `${active ? 'Disable' : 'Enable'} cockpit weather effects`;
|
||||
if (this.weatherState) this.weatherState.textContent = active ? 'ON' : 'OFF';
|
||||
const weatherLabel = active
|
||||
? t('cockpit.context.weatherDisableAriaLabel')
|
||||
: t('cockpit.context.weatherEnableAriaLabel');
|
||||
this.weatherToggle.setAttribute('aria-label', weatherLabel);
|
||||
this.weatherToggle.title = weatherLabel;
|
||||
if (this.weatherState) {
|
||||
this.weatherState.textContent = active
|
||||
? t('cockpit.context.weatherStateOn')
|
||||
: t('cockpit.context.weatherStateOff');
|
||||
}
|
||||
}
|
||||
|
||||
export function setVisionMode(mode, { revealParameters = false } = {}) {
|
||||
@@ -230,17 +249,19 @@ export function setVisionMode(mode, { revealParameters = false } = {}) {
|
||||
const names = {
|
||||
optical: inherited,
|
||||
crt: 'CRT',
|
||||
nvg: 'Night vision',
|
||||
thermal: 'Thermal',
|
||||
noir: 'Noir',
|
||||
nvg: t('cockpit.vision.styleNameNightVision'),
|
||||
thermal: t('cockpit.vision.styleNameThermal'),
|
||||
noir: t('cockpit.vision.styleNameNoir'),
|
||||
};
|
||||
if (this.visionCurrent) {
|
||||
this.visionCurrent.dataset.cockpitVision = next;
|
||||
this.visionCurrent.setAttribute(
|
||||
'aria-label',
|
||||
`Current cockpit vision style: ${names[next]}. Activate for next style.`,
|
||||
t('cockpit.vision.currentAriaTemplate', { style: names[next] }),
|
||||
);
|
||||
this.visionCurrent.title = `Current style: ${names[next]} — click for next`;
|
||||
this.visionCurrent.title = t('cockpit.vision.currentTitleTemplate', {
|
||||
style: names[next],
|
||||
});
|
||||
}
|
||||
if (this.visionCurrentLabel)
|
||||
this.visionCurrentLabel.textContent = labels[next];
|
||||
|
||||
+25
-3
@@ -1,4 +1,5 @@
|
||||
/** Measure Cockpit panels against visible HUD and viewport obstacles. */
|
||||
import { t } from '../i18n/index.js';
|
||||
import {
|
||||
resolveCockpitUtilityAnchor,
|
||||
resolveCockpitUtilityLayout,
|
||||
@@ -35,11 +36,26 @@ export function setContextCollapsed(collapsed) {
|
||||
if (this.contextToggle) {
|
||||
const expanded = !this.contextCollapsed;
|
||||
this.contextToggle.setAttribute('aria-expanded', String(expanded));
|
||||
// The English aria-label literal is pinned verbatim by
|
||||
// cockpitMarkup.test.mjs; the t() re-write below is a same-value
|
||||
// override that localizes it (byte-identical under 'en').
|
||||
this.contextToggle.setAttribute(
|
||||
'aria-label',
|
||||
`${expanded ? 'Collapse' : 'Expand'} Contact panel`,
|
||||
);
|
||||
this.contextToggle.title = `${expanded ? 'Collapse' : 'Expand'} contact panel`;
|
||||
this.contextToggle.setAttribute(
|
||||
'aria-label',
|
||||
t(
|
||||
expanded
|
||||
? 'cockpit.context.toggleExpandAriaLabel'
|
||||
: 'cockpit.context.toggleAriaLabel',
|
||||
),
|
||||
);
|
||||
this.contextToggle.title = t(
|
||||
expanded
|
||||
? 'cockpit.context.toggleExpandTitle'
|
||||
: 'cockpit.context.toggleTitle',
|
||||
);
|
||||
const icon = this.contextToggle.querySelector('.material-symbols-outlined');
|
||||
if (icon) icon.textContent = expanded ? 'chevron_left' : 'chevron_right';
|
||||
}
|
||||
@@ -60,9 +76,15 @@ export function setSignalCollapsed(collapsed, { user = false } = {}) {
|
||||
this.signalToggle.setAttribute('aria-expanded', String(expanded));
|
||||
this.signalToggle.setAttribute(
|
||||
'aria-label',
|
||||
`${expanded ? 'Collapse' : 'Expand'} cockpit briefing panel`,
|
||||
t(
|
||||
expanded
|
||||
? 'cockpit.brief.collapseAriaLabel'
|
||||
: 'cockpit.brief.expandAriaLabel',
|
||||
),
|
||||
);
|
||||
this.signalToggle.title = t(
|
||||
expanded ? 'cockpit.brief.collapseTitle' : 'cockpit.brief.expandTitle',
|
||||
);
|
||||
this.signalToggle.title = `${expanded ? 'Collapse' : 'Expand'} briefing panel`;
|
||||
const icon = this.signalToggle.querySelector('.material-symbols-outlined');
|
||||
if (icon)
|
||||
icon.textContent = expanded ? 'right_panel_close' : 'right_panel_open';
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
/** Pure formatting and fixed presentation constants used by Cockpit components. */
|
||||
import { t } from '../i18n/index.js';
|
||||
|
||||
export const COCKPIT_HEADING_SLEW_DPS = 28;
|
||||
|
||||
export const COCKPIT_FORWARD_OFFSET_M = 7;
|
||||
@@ -44,20 +46,22 @@ export const COCKPIT_REGIONAL_REFRESH_DISTANCE_M = 25_000;
|
||||
export const COCKPIT_BRIEF_PAGES = [
|
||||
{
|
||||
id: 'signals',
|
||||
kicker: 'LIVE SIGNALS',
|
||||
subtitle: 'OBSERVED / MAPPED PINGS',
|
||||
source: 'SOURCE-BACKED EVENTS · NO SYNTHETIC NEWS',
|
||||
kickerKey: 'cockpit.brief.kicker',
|
||||
subtitleKey: 'cockpit.brief.subtitle',
|
||||
sourceKey: 'cockpit.brief.sourceNote',
|
||||
},
|
||||
{
|
||||
id: 'news',
|
||||
kicker: 'REGIONAL NEWS',
|
||||
subtitle: 'LATEST LOCATION-MATCHED REPORTING',
|
||||
kickerKey: 'cockpit.brief.kickerNews',
|
||||
subtitleKey: 'cockpit.brief.subtitleNews',
|
||||
// RSS provider attribution is a keep-English boundary string.
|
||||
source: 'GOOGLE NEWS RSS · LOCATION QUERY · RECENT',
|
||||
},
|
||||
{
|
||||
id: 'local',
|
||||
kicker: 'LOCAL INFO',
|
||||
subtitle: 'PLACE / CONDITIONS / POSITION',
|
||||
kickerKey: 'cockpit.brief.kickerLocal',
|
||||
subtitleKey: 'cockpit.brief.subtitleLocal',
|
||||
// Data-source attribution is a keep-English boundary string.
|
||||
source: 'NATURAL EARTH · OPEN-METEO · UTC',
|
||||
},
|
||||
];
|
||||
@@ -80,15 +84,17 @@ export function isRenderedOnScreen(element) {
|
||||
|
||||
export function formatCockpitBriefAge(value) {
|
||||
const timestamp = Date.parse(value);
|
||||
if (!Number.isFinite(timestamp)) return 'TIME UNKNOWN';
|
||||
if (!Number.isFinite(timestamp)) return t('cockpit.brief.age.timeUnknown');
|
||||
const minutes = Math.max(0, Math.round((Date.now() - timestamp) / 60_000));
|
||||
if (minutes < 60) return `${minutes}M AGO`;
|
||||
if (minutes < 60) return t('cockpit.brief.age.minutes', { count: minutes });
|
||||
const hours = Math.round(minutes / 60);
|
||||
return hours < 48 ? `${hours}H AGO` : `${Math.round(hours / 24)}D AGO`;
|
||||
return hours < 48
|
||||
? t('cockpit.brief.age.hours', { count: hours })
|
||||
: t('cockpit.brief.age.days', { count: Math.round(hours / 24) });
|
||||
}
|
||||
|
||||
export function formatCockpitWindDirection(value) {
|
||||
if (!Number.isFinite(value)) return 'DIR UNKNOWN';
|
||||
if (!Number.isFinite(value)) return t('cockpit.brief.wind.dirUnknown');
|
||||
const labels = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW'];
|
||||
const normalized = ((value % 360) + 360) % 360;
|
||||
return `${labels[Math.round(normalized / 45) % labels.length]} · ${Math.round(normalized)}°`;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
/** Reconcile actionable signal rows without disturbing native keyboard focus. */
|
||||
import { t } from '../i18n/index.js';
|
||||
|
||||
export function renderCockpitSignals() {
|
||||
if (!this.signalList) return;
|
||||
@@ -57,7 +58,7 @@ export function renderCockpitSignals() {
|
||||
if (item.target) {
|
||||
heading.dataset.signalLayer = item.target.layerId;
|
||||
heading.dataset.signalId = item.target.id;
|
||||
const label = `Select flight ${item.title}`;
|
||||
const label = t('cockpit.signal.selectFlightAria', { title: item.title });
|
||||
if (heading.getAttribute('aria-label') !== label)
|
||||
heading.setAttribute('aria-label', label);
|
||||
setText(heading.children[0], item.title);
|
||||
@@ -126,7 +127,12 @@ export function updateCockpitSignals(snapshot, unknownCount) {
|
||||
key: `flight:${subject.layerId}:${subject.id}`,
|
||||
tone: 'track',
|
||||
title: subject.label || subject.id,
|
||||
detail: `${subject.layerId === 'military' ? 'MILITARY FLIGHT' : 'COMMERCIAL FLIGHT'} · CURRENT`,
|
||||
detail: t('cockpit.signal.contactCurrent', {
|
||||
aircraftClass:
|
||||
subject.layerId === 'military'
|
||||
? t('cockpit.signal.classMilitary')
|
||||
: t('cockpit.signal.classCommercial'),
|
||||
}),
|
||||
target: { layerId: subject.layerId, id: String(subject.id) },
|
||||
distanceM: -1,
|
||||
});
|
||||
@@ -145,11 +151,15 @@ export function updateCockpitSignals(snapshot, unknownCount) {
|
||||
// contact reads as its registration here too. Same helper the
|
||||
// Context panel's nearest list uses.
|
||||
title: this.services.formatAwarenessLabel(item),
|
||||
detail: `${cohort.id === 'military' ? 'MILITARY FLIGHT' : 'COMMERCIAL FLIGHT'} · ${
|
||||
Number.isFinite(item.distanceM)
|
||||
detail: t('cockpit.signal.contactRange', {
|
||||
aircraftClass:
|
||||
cohort.id === 'military'
|
||||
? t('cockpit.signal.classMilitary')
|
||||
: t('cockpit.signal.classCommercial'),
|
||||
distance: Number.isFinite(item.distanceM)
|
||||
? `${item.distanceM < 10000 ? (item.distanceM / 1000).toFixed(1) : Math.round(item.distanceM / 1000)} KM`
|
||||
: 'DISTANCE UNKNOWN'
|
||||
}`,
|
||||
: t('cockpit.signal.distanceUnknown'),
|
||||
}),
|
||||
target: { layerId: cohort.id, id: String(id) },
|
||||
distanceM: item.distanceM ?? Infinity,
|
||||
});
|
||||
@@ -169,8 +179,8 @@ export function updateCockpitSignals(snapshot, unknownCount) {
|
||||
nextItems.splice(4, Math.max(0, nextItems.length - 4), {
|
||||
key: 'input-status',
|
||||
tone: 'warning',
|
||||
title: `${unknownCount} INPUT${unknownCount === 1 ? '' : 'S'} UNKNOWN`,
|
||||
detail: sources || 'SOURCE STATUS UNAVAILABLE',
|
||||
title: t('cockpit.signal.inputsUnknown', { count: unknownCount }),
|
||||
detail: sources || t('cockpit.signal.sourceStatusUnavailable'),
|
||||
target: null,
|
||||
timestamp:
|
||||
previous.get('input-status')?.timestamp ||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
/** Tracking identity, entry, exit and contact navigation for the Cockpit controller. */
|
||||
import { t } from '../i18n/index.js';
|
||||
import {
|
||||
normalizeHeading,
|
||||
resolveTrackedAircraftInfo,
|
||||
@@ -58,8 +59,8 @@ export function syncTr3bToggle(info) {
|
||||
this.tr3bToggle.hidden = !icao24;
|
||||
this.tr3bToggle.setAttribute('aria-pressed', converted ? 'true' : 'false');
|
||||
this.tr3bToggle.title = converted
|
||||
? 'Restore real aircraft'
|
||||
: 'Reclassify as TR-3B';
|
||||
? t('cockpit.context.tr3bRestoreTitle')
|
||||
: t('cockpit.context.tr3bTitle');
|
||||
}
|
||||
|
||||
export function syncEntry() {
|
||||
@@ -183,15 +184,21 @@ export function enter() {
|
||||
this.showBriefPage(0);
|
||||
this.startBriefRotation();
|
||||
const trackLabel =
|
||||
info.callsign || info.registration || info.icao24 || 'AIRCRAFT';
|
||||
info.callsign ||
|
||||
info.registration ||
|
||||
info.icao24 ||
|
||||
t('cockpit.hud.fallbackCallsign');
|
||||
const trackHeading = String(
|
||||
Math.round(normalizeHeading(info.track ?? 0)),
|
||||
).padStart(3, '0');
|
||||
this.pushCockpitSignal(
|
||||
'track',
|
||||
'track',
|
||||
'TRACK ACQUIRED',
|
||||
`${trackLabel} · COURSE ${trackHeading}°`,
|
||||
t('cockpit.signal.trackAcquired'),
|
||||
t('cockpit.signal.trackDetail', {
|
||||
label: trackLabel,
|
||||
heading: `${trackHeading}°`,
|
||||
}),
|
||||
);
|
||||
this.updateHud(info, performance.now(), true);
|
||||
this.setVisionMode(this.visionMode);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { t } from '../i18n/index.js';
|
||||
import {
|
||||
settleUserFacingContextAction,
|
||||
contextModeWord,
|
||||
@@ -5,8 +6,8 @@ import {
|
||||
|
||||
export async function _runUserFacingContextAction(
|
||||
operation,
|
||||
message = 'Context could not restore every layer; try again',
|
||||
{ falseIsFailure = true } = {},
|
||||
message = t('cockpit.context.toastRestoreFailed'),
|
||||
{ falseIsFailure = true, localizedMessage = null } = {},
|
||||
) {
|
||||
if (this.destroyed) return false;
|
||||
const notificationToken = Symbol('user-facing-context-action');
|
||||
@@ -18,7 +19,7 @@ export async function _runUserFacingContextAction(
|
||||
onFailure: (error) => {
|
||||
if (this.destroyed) return;
|
||||
console.warn('[Context] user-facing transition failed', error);
|
||||
this.showToast(message);
|
||||
this.showToast(localizedMessage || message);
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
@@ -222,21 +223,25 @@ export function clearSelectedLayers() {
|
||||
const operation = managerOperation
|
||||
.then((result) => {
|
||||
if (result.targetIds.length === 0) {
|
||||
this.showToast('No selected data layers');
|
||||
this.showToast(t('layers.clear.toast.noneSelected'));
|
||||
} else if (result.notClearedIds.length > 0) {
|
||||
this.showToast(
|
||||
`${result.notClearedIds.length} data layer${result.notClearedIds.length === 1 ? '' : 's'} could not be cleared`,
|
||||
t('layers.clear.toast.notCleared', {
|
||||
count: result.notClearedIds.length,
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
this.showToast(
|
||||
`Cleared ${result.clearedIds.length} data layer${result.clearedIds.length === 1 ? '' : 's'}`,
|
||||
t('layers.clear.toast.cleared', {
|
||||
count: result.clearedIds.length,
|
||||
}),
|
||||
);
|
||||
}
|
||||
return result;
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn('[Data] clear selected layers failed', error);
|
||||
this.showToast('Selected data layers could not be cleared');
|
||||
this.showToast(t('cockpit.actions.clearLayersFailedToast'));
|
||||
return {
|
||||
targetIds: [],
|
||||
items: [],
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { t } from '../i18n/index.js';
|
||||
import { shouldExpandGlobalContextPanel } from '../rightRailPolicy.js';
|
||||
|
||||
export function _initGlobalContextPanel() {
|
||||
@@ -32,7 +33,7 @@ export function _initGlobalContextPanel() {
|
||||
void this._runUserFacingContextAction(
|
||||
(notificationToken) =>
|
||||
this._selectContextMode(nextMode, { notificationToken }),
|
||||
'Contacts could not complete the requested transition; try again',
|
||||
t('cockpit.context.toastTransitionFailedContacts'),
|
||||
).then((succeeded) => {
|
||||
if (
|
||||
!this.destroyed &&
|
||||
@@ -61,7 +62,7 @@ export function _initGlobalContextPanel() {
|
||||
void this._runUserFacingContextAction(
|
||||
(notificationToken) =>
|
||||
this._selectContextMode(nextMode, { notificationToken }),
|
||||
'Space Missions could not complete the requested transition; try again',
|
||||
t('cockpit.context.toastTransitionFailedMissions'),
|
||||
).then((succeeded) => {
|
||||
if (
|
||||
!this.destroyed &&
|
||||
@@ -108,11 +109,11 @@ export function _initGlobalContextPanel() {
|
||||
this.showToast(
|
||||
stats?.statusMessage ||
|
||||
(stats?.status === 'zoom-in'
|
||||
? 'Zoom in to search mapped installations'
|
||||
: 'Nearby installations refreshed'),
|
||||
? t('cockpit.context.toastZoomToSearch')
|
||||
: t('cockpit.context.toastInstallationsRefreshed')),
|
||||
);
|
||||
return true;
|
||||
}, 'Nearby installations could not be refreshed; try again').finally(() => {
|
||||
}, t('cockpit.context.toastInstallationsRefreshFailed')).finally(() => {
|
||||
if (this.destroyed) return;
|
||||
button.setAttribute('aria-disabled', 'false');
|
||||
button.setAttribute('aria-busy', 'false');
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { t } from '../i18n/index.js';
|
||||
import {
|
||||
cockpitEntryAllowed,
|
||||
isExplicitUserIntentOrigin,
|
||||
@@ -58,12 +59,22 @@ export function _handleContextLayerChange(change) {
|
||||
if (this._contextSessionSnapshot && !this._contextModeChanging) {
|
||||
this._contextMode = null;
|
||||
void this._trackContextLayerReaction(
|
||||
this._runUserFacingContextAction(async (notificationToken) => {
|
||||
await this._restoreContextSessionAfterLayerSettles(change.layerId, {
|
||||
notificationToken,
|
||||
});
|
||||
return true;
|
||||
}, 'Space Missions cancellation could not restore the previous layer state'),
|
||||
this._runUserFacingContextAction(
|
||||
async (notificationToken) => {
|
||||
await this._restoreContextSessionAfterLayerSettles(
|
||||
change.layerId,
|
||||
{
|
||||
notificationToken,
|
||||
},
|
||||
);
|
||||
return true;
|
||||
},
|
||||
{
|
||||
localizedMessage: t(
|
||||
'cockpit.context.toastMissionsCancelRestoreFailed',
|
||||
),
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -83,15 +94,19 @@ export function _handleContextLayerChange(change) {
|
||||
!this._userFacingContextNotificationTokens.has(change.notificationToken)
|
||||
) {
|
||||
this.showToast(
|
||||
change.reason ||
|
||||
'That layer is unavailable in the current Context mode',
|
||||
change.reason || t('cockpit.context.toastLayerUnavailable'),
|
||||
);
|
||||
}
|
||||
this._syncContextModeButtons();
|
||||
return;
|
||||
}
|
||||
if (change?.type === 'visibility-failed') {
|
||||
const failureMessage = `${change.layerId} could not ${change.enabled ? 'start' : 'stop'} cleanly`;
|
||||
const failureMessage = t('cockpit.context.toastLayerLifecycleFailed', {
|
||||
layerId: change.layerId,
|
||||
action: change.enabled
|
||||
? t('cockpit.context.actionStart')
|
||||
: t('cockpit.context.actionStop'),
|
||||
});
|
||||
// A failed direct Context-shell START has already had its siblings
|
||||
// cleared by the visibility guard. Wait outside the synchronous manager
|
||||
// notification for this queue to settle, then reconcile the complete
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { t } from '../i18n/index.js';
|
||||
import {
|
||||
contextLayerEnableBlockReason,
|
||||
isExplicitUserIntentOrigin,
|
||||
@@ -124,7 +125,12 @@ export function connectContextManager(manager) {
|
||||
restoreError,
|
||||
);
|
||||
}
|
||||
return `${entryMode === 'space-missions' ? 'Space Missions' : 'Context'} could not start because another layer did not stop cleanly`;
|
||||
return t('cockpit.context.toastStartBlocked', {
|
||||
mode:
|
||||
entryMode === 'space-missions'
|
||||
? t('cockpit.context.modeSpaceMissions')
|
||||
: t('cockpit.context.modeContext'),
|
||||
});
|
||||
} finally {
|
||||
if (ownsNotificationToken) {
|
||||
this._userFacingContextNotificationTokens.delete(
|
||||
|
||||
+121
-32
@@ -3,8 +3,52 @@ import { syncRowList } from './rowList.js';
|
||||
import { layerFeedState } from '../data/feedState.js';
|
||||
export { layerFeedState } from '../data/feedState.js';
|
||||
import { GUIDANCE_STATUSES } from '../loadingFeedback.js';
|
||||
import { keySetupRequirement } from '../keySetupCore.mjs';
|
||||
import { keySetupRequirementEnvVars } from '../keySetupCore.mjs';
|
||||
import { createWeatherPanel } from './weatherPanel.js';
|
||||
import { t } from '../i18n/index.js';
|
||||
|
||||
// Feed-state enum VALUES are machine identifiers (layerFeedState contract, QA
|
||||
// hooks, CSS feed-* classes); only the final label value translates, at this
|
||||
// presentation boundary. See docs/TRANSLATORS.md keep-English boundary.
|
||||
const FEED_STATE_LABEL_KEYS = Object.freeze({
|
||||
nominal: 'layers.status.on',
|
||||
loading: 'layers.status.loading',
|
||||
degraded: 'layers.status.degraded',
|
||||
stale: 'layers.status.stale',
|
||||
partial: 'layers.status.partial',
|
||||
fallback: 'layers.status.fallback',
|
||||
unavailable: 'layers.status.unavailable',
|
||||
});
|
||||
|
||||
// Layer display names, localized at the same presentation boundary. Registry
|
||||
// ids stay English; a layer without an entry here renders its module name,
|
||||
// falling back to the English panel override when one exists.
|
||||
const LAYER_NAME_KEYS = Object.freeze({
|
||||
flights: 'layers.name.liveFlights',
|
||||
military: 'layers.name.militaryFlights',
|
||||
earthquakes: 'layers.name.earthquakes',
|
||||
satellites: 'layers.name.satellites',
|
||||
'rocket-launches': 'layers.name.rocketLaunches',
|
||||
traffic: 'layers.name.traffic',
|
||||
cctv: 'layers.name.cctv',
|
||||
radio: 'layers.name.radio',
|
||||
bikeshare: 'layers.name.bikeshare',
|
||||
'ais-live-vessels': 'layers.name.aisVessels',
|
||||
'military-installations': 'layers.name.installations',
|
||||
'military-awareness': 'layers.name.globalContext',
|
||||
'local-datacenters': 'layers.name.datacenters',
|
||||
'local-dams': 'layers.name.dams',
|
||||
'telegeography-submarine-cables': 'layers.name.submarineCables',
|
||||
'local-firms': 'layers.name.fires',
|
||||
});
|
||||
|
||||
function lifecycleStateLabel(lifecycleState) {
|
||||
return t(
|
||||
lifecycleState === 'enabling'
|
||||
? 'layers.status.enabling'
|
||||
: 'layers.status.disabling',
|
||||
);
|
||||
}
|
||||
const FEED_STATE_LABELS = Object.freeze({
|
||||
nominal: 'ON',
|
||||
loading: 'LOADING',
|
||||
@@ -79,6 +123,8 @@ const PANEL_LABELS = {
|
||||
};
|
||||
|
||||
function panelLabel(layer) {
|
||||
const key = LAYER_NAME_KEYS[layer.id];
|
||||
if (key) return t(key);
|
||||
return PANEL_LABELS[layer.id] || layer.name;
|
||||
}
|
||||
|
||||
@@ -99,7 +145,10 @@ function panelLabel(layer) {
|
||||
export function layerKeyRequirementTooltip(layer = {}) {
|
||||
if (layer?.stats?.keyRequired !== true) return '';
|
||||
const requiresKeyId = String(layer.requiresKeyId || '').trim();
|
||||
return requiresKeyId ? keySetupRequirement(requiresKeyId) : '';
|
||||
const envVars = requiresKeyId
|
||||
? keySetupRequirementEnvVars(requiresKeyId)
|
||||
: null;
|
||||
return envVars ? t('setup.keySetup.requirement', { envVars }) : '';
|
||||
}
|
||||
|
||||
/** Layer row presentation over supplied state and actions; no layer imports. */
|
||||
@@ -520,23 +569,35 @@ export class LayerPanel {
|
||||
_buildMetaText(layer) {
|
||||
const stats = layer.stats || {};
|
||||
const feedState = layerFeedState(stats);
|
||||
const stateLabel = FEED_STATE_LABELS[feedState];
|
||||
const stateLabel = t(FEED_STATE_LABEL_KEYS[feedState]);
|
||||
const source = stats.source || layer.source;
|
||||
const lifecycleState =
|
||||
layer.lifecycleState || (layer.enabled ? 'enabled' : 'disabled');
|
||||
if (lifecycleState === 'enabling' || lifecycleState === 'disabling') {
|
||||
return `${lifecycleState.toUpperCase()} · ${source}`;
|
||||
return t('layers.meta.transitioning', {
|
||||
state: lifecycleStateLabel(lifecycleState),
|
||||
source,
|
||||
});
|
||||
}
|
||||
if (layer.lifecycleUncertain) {
|
||||
return `UNCERTAIN · ${source} · lifecycle state requires reconciliation`;
|
||||
return t('layers.meta.uncertainLifecycle', { source });
|
||||
}
|
||||
const presentedError =
|
||||
stats.error || stats.lastError || stats.managerRefreshError;
|
||||
if (presentedError) {
|
||||
if (typeof stats.retryInSec === 'number' && stats.retryInSec > 0) {
|
||||
return `${stateLabel} · ${source} · ${presentedError} · retry ${stats.retryInSec}s`;
|
||||
return t('layers.meta.stateSourceRetry', {
|
||||
state: stateLabel,
|
||||
source,
|
||||
detail: presentedError,
|
||||
seconds: stats.retryInSec,
|
||||
});
|
||||
}
|
||||
return `${stateLabel} · ${source} · ${presentedError}`;
|
||||
return t('layers.meta.stateSourceDetail', {
|
||||
state: stateLabel,
|
||||
source,
|
||||
detail: presentedError,
|
||||
});
|
||||
}
|
||||
// A guidance status carries its prompt in `statusMessage`, not `error`, so
|
||||
// the row still tells the operator what to do without reporting a fault.
|
||||
@@ -547,20 +608,26 @@ export class LayerPanel {
|
||||
) {
|
||||
return `${source} · ${stats.statusMessage.trim()}`;
|
||||
}
|
||||
const ago = stats.lastUpdate ? this._timeAgo(stats.lastUpdate) : 'never';
|
||||
const ago = stats.lastUpdate
|
||||
? this._timeAgo(stats.lastUpdate)
|
||||
: t('layers.meta.never');
|
||||
if (stats.loading) {
|
||||
const loadingLabel =
|
||||
typeof stats.loadingLabel === 'string' && stats.loadingLabel.trim()
|
||||
? stats.loadingLabel.trim()
|
||||
: 'loading...';
|
||||
return `${source} · ${loadingLabel}`;
|
||||
: t('layers.meta.loading');
|
||||
return t('layers.meta.sourceLoading', { source, loadingLabel });
|
||||
}
|
||||
if (feedState === 'fallback') {
|
||||
const detail =
|
||||
typeof stats.loadingLabel === 'string' && stats.loadingLabel.trim()
|
||||
? stats.loadingLabel.trim()
|
||||
: stats.coverage || ago;
|
||||
return `${stateLabel} · ${source} · ${detail}`;
|
||||
return t('layers.meta.stateSourceDetail', {
|
||||
state: stateLabel,
|
||||
source,
|
||||
detail,
|
||||
});
|
||||
}
|
||||
if (feedState === 'partial') {
|
||||
const { acceptedRowCount, rawRowCount } = stats;
|
||||
@@ -569,21 +636,40 @@ export class LayerPanel {
|
||||
Number.isInteger(rawRowCount) &&
|
||||
acceptedRowCount >= 0 &&
|
||||
rawRowCount > acceptedRowCount
|
||||
? `${acceptedRowCount} of ${rawRowCount} records accepted`
|
||||
: 'incomplete snapshot';
|
||||
return `${stateLabel} · ${source} · ${detail} · ${ago}`;
|
||||
? t('layers.meta.partialCounts', {
|
||||
accepted: acceptedRowCount,
|
||||
raw: rawRowCount,
|
||||
})
|
||||
: t('layers.meta.partialIncomplete');
|
||||
return t('layers.meta.partialDetail', {
|
||||
state: stateLabel,
|
||||
source,
|
||||
detail,
|
||||
ago,
|
||||
});
|
||||
}
|
||||
if (feedState === 'stale') {
|
||||
const retry =
|
||||
typeof stats.retryInSec === 'number' && stats.retryInSec > 0
|
||||
? ` · retrying in ${stats.retryInSec}s`
|
||||
: '';
|
||||
return `${stateLabel} · ${source} · ${ago}${retry}`;
|
||||
if (typeof stats.retryInSec === 'number' && stats.retryInSec > 0) {
|
||||
return t('layers.meta.stateSourceAgoRetry', {
|
||||
state: stateLabel,
|
||||
source,
|
||||
ago,
|
||||
seconds: stats.retryInSec,
|
||||
});
|
||||
}
|
||||
return t('layers.meta.stateSourceAgo', {
|
||||
state: stateLabel,
|
||||
source,
|
||||
ago,
|
||||
});
|
||||
}
|
||||
if (typeof stats.loadingLabel === 'string' && stats.loadingLabel.trim()) {
|
||||
return `${source} · ${stats.loadingLabel.trim()}`;
|
||||
return t('layers.meta.sourceLoading', {
|
||||
source,
|
||||
loadingLabel: stats.loadingLabel.trim(),
|
||||
});
|
||||
}
|
||||
return `${source} · ${ago}`;
|
||||
return t('layers.meta.sourceAgo', { source, ago });
|
||||
}
|
||||
|
||||
_syncToggleButton(button, layer) {
|
||||
@@ -615,22 +701,24 @@ export class LayerPanel {
|
||||
button.setAttribute('aria-disabled', String(transitioning));
|
||||
button.setAttribute('aria-busy', String(transitioning));
|
||||
button.textContent = transitioning
|
||||
? layer.lifecycleState.toUpperCase()
|
||||
? lifecycleStateLabel(layer.lifecycleState)
|
||||
: uncertain
|
||||
? 'UNCERTAIN'
|
||||
? t('layers.status.uncertain')
|
||||
: layer.enabled
|
||||
? FEED_STATE_LABELS[feedState]
|
||||
: 'OFF';
|
||||
? t(FEED_STATE_LABEL_KEYS[feedState])
|
||||
: t('layers.status.off');
|
||||
const keyGuidance = layerKeyRequirementTooltip(layer);
|
||||
// Name the missing key on the control itself: a row reading KEY REQUIRED
|
||||
// without saying WHICH key leaves a dead control and no next step. Empty
|
||||
// when the layer needs no key, or already has one.
|
||||
button.title = keyGuidance;
|
||||
const ariaLabel = t('layers.meta.toggleAriaLabel', {
|
||||
name: panelLabel(layer),
|
||||
state: button.textContent,
|
||||
});
|
||||
button.setAttribute(
|
||||
'aria-label',
|
||||
keyGuidance
|
||||
? `${panelLabel(layer)}: ${button.textContent}. ${keyGuidance}`
|
||||
: `${panelLabel(layer)}: ${button.textContent}`,
|
||||
keyGuidance ? `${ariaLabel}. ${keyGuidance}` : ariaLabel,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -641,9 +729,10 @@ export class LayerPanel {
|
||||
|
||||
_timeAgo(timestamp) {
|
||||
const diff = Math.floor((Date.now() - timestamp) / 1000);
|
||||
if (diff < 5) return 'just now';
|
||||
if (diff < 60) return `${diff}s ago`;
|
||||
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
|
||||
return `${Math.floor(diff / 3600)}h ago`;
|
||||
if (diff < 5) return t('layers.meta.justNow');
|
||||
if (diff < 60) return t('layers.meta.secondsAgo', { count: diff });
|
||||
if (diff < 3600)
|
||||
return t('layers.meta.minutesAgo', { count: Math.floor(diff / 60) });
|
||||
return t('layers.meta.hoursAgo', { count: Math.floor(diff / 3600) });
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,19 @@
|
||||
import { locationMiniStatus } from '../locationStatus.js';
|
||||
import { t } from '../i18n/index.js';
|
||||
|
||||
const POI_KEYS = ['Q', 'W', 'E', 'R', 'T'];
|
||||
|
||||
/**
|
||||
* locationStatus.js sentinel outputs used as localized-rewrite triggers for
|
||||
* the collapsed LOCATION mini-status. Built by calling the module with no
|
||||
* real data, so the comparisons track that module's copy without restating
|
||||
* English strings here. Resolved destinations (place names) stay verbatim.
|
||||
*/
|
||||
const LOCATION_MINI_EMPTY = Object.freeze(locationMiniStatus());
|
||||
const LOCATION_MINI_SEARCHED_PLACEHOLDER = Object.freeze(
|
||||
locationMiniStatus({ searchedLabel: '·' }),
|
||||
);
|
||||
|
||||
/** Location DOM, keyboard handling and pending row animation over supplied actions. */
|
||||
export class LocationControls {
|
||||
constructor({
|
||||
@@ -133,8 +146,24 @@ export class LocationControls {
|
||||
if (this.destroyed || !this.elements.statusCity || !this.elements.statusPoi)
|
||||
return;
|
||||
const lines = locationMiniStatus(state);
|
||||
this.elements.statusCity.textContent = lines.city;
|
||||
this.elements.statusPoi.textContent = lines.poi;
|
||||
// The initial/placeholder states get catalog keys at the writer so a
|
||||
// non-English boot never leaks the English sentinels back over the
|
||||
// localized static readout (resolved place names stay verbatim data).
|
||||
this.elements.statusCity.textContent =
|
||||
lines.city === LOCATION_MINI_EMPTY.city
|
||||
? t('cockpit.location.miniCityInitial')
|
||||
: lines.city;
|
||||
if (lines.poi === LOCATION_MINI_EMPTY.poi) {
|
||||
this.elements.statusPoi.textContent = t(
|
||||
'cockpit.location.miniPoiInitial',
|
||||
);
|
||||
} else if (lines.poi === LOCATION_MINI_SEARCHED_PLACEHOLDER.poi) {
|
||||
this.elements.statusPoi.textContent = t(
|
||||
'cockpit.location.miniSearchedPlaceholder',
|
||||
);
|
||||
} else {
|
||||
this.elements.statusPoi.textContent = lines.poi;
|
||||
}
|
||||
}
|
||||
createOrbitIndicator() {
|
||||
if (this.destroyed) return null;
|
||||
@@ -144,7 +173,7 @@ export class LocationControls {
|
||||
const icon = this.doc.createElement('span');
|
||||
icon.className = 'orbit-icon';
|
||||
icon.textContent = '↻';
|
||||
this.orbitIndicator.append(icon, ' ORBIT');
|
||||
this.orbitIndicator.append(icon, ` ${t('cockpit.location.orbitLabel')}`);
|
||||
this.doc.body.appendChild(this.orbitIndicator);
|
||||
}
|
||||
return this.orbitIndicator;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { t } from '../i18n/index.js';
|
||||
import { createStateChannel } from '../app/stateChannel.js';
|
||||
import { LocationControls } from './location.js';
|
||||
|
||||
@@ -87,8 +88,10 @@ export class LocationNavigation {
|
||||
this._currentPoi = null;
|
||||
this._collapsePOIRow();
|
||||
this._updateLocationMiniStatus();
|
||||
} else if (change.type === 'missing') this._showToast('Location not found');
|
||||
else if (change.type === 'failed') this._showToast('Search failed');
|
||||
} else if (change.type === 'missing')
|
||||
this._showToast(t('cockpit.location.toastNotFound'));
|
||||
else if (change.type === 'failed')
|
||||
this._showToast(t('cockpit.location.toastSearchFailed'));
|
||||
else if (change.type === 'settled')
|
||||
this._settleLocationSearchUi(change.generation);
|
||||
else if (
|
||||
@@ -277,7 +280,7 @@ export class LocationNavigation {
|
||||
|
||||
_toggleOrbit() {
|
||||
if (!this._currentTarget) {
|
||||
this._showToast('Fly to a POI first');
|
||||
this._showToast(t('cockpit.location.toastFlyToPoiFirst'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -379,11 +382,11 @@ export class LocationNavigation {
|
||||
};
|
||||
this._resetGlobeBtn?.setAttribute(
|
||||
'aria-label',
|
||||
'Reset to full globe view',
|
||||
t('cockpit.hud.resetGlobeAria'),
|
||||
);
|
||||
this._cockpitResetGlobeBtn?.setAttribute(
|
||||
'aria-label',
|
||||
'Reset cockpit to full globe view',
|
||||
t('cockpit.hud.resetGlobeCockpitAria'),
|
||||
);
|
||||
this._globeResetPromise = null;
|
||||
this._cancelGlobeReset = null;
|
||||
@@ -399,11 +402,11 @@ export class LocationNavigation {
|
||||
}, 4200);
|
||||
this._resetGlobeBtn?.setAttribute(
|
||||
'aria-label',
|
||||
'Resetting to full globe view',
|
||||
t('cockpit.hud.resettingGlobeAria'),
|
||||
);
|
||||
this._cockpitResetGlobeBtn?.setAttribute(
|
||||
'aria-label',
|
||||
'Resetting cockpit to full globe view',
|
||||
t('cockpit.hud.resettingGlobeCockpitAria'),
|
||||
);
|
||||
const target = flyToGlobeView(this.viewer, {
|
||||
onComplete: () => finish(false),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { renderMapStackChips, syncMapStackChips } from '../mapStackChips.js';
|
||||
import { t } from '../i18n/index.js';
|
||||
|
||||
/**
|
||||
* Own Map Source presentation and selection without constructing map providers.
|
||||
@@ -28,7 +29,9 @@ export function createMapSourceControls({
|
||||
statusElement.textContent =
|
||||
state.status === 'switching'
|
||||
? '...'
|
||||
: stack?.shortLabel || stack?.label || 'MAP';
|
||||
: stack?.shortLabel ||
|
||||
stack?.label ||
|
||||
t('cockpit.presets.mapStackFallback');
|
||||
statusElement.classList.toggle('warn', !!state.lastError);
|
||||
}
|
||||
}
|
||||
|
||||
+28
-11
@@ -1,4 +1,5 @@
|
||||
import { UiLifetime } from './uiLifetime.js';
|
||||
import { t } from '../i18n/index.js';
|
||||
import { displayPanelScroller } from './displayPanelScroll.js';
|
||||
import { PanelPositionControls } from './panelPositionControls.js';
|
||||
import { PanelLayoutController } from './panelLayoutController.js';
|
||||
@@ -305,14 +306,28 @@ export class PanelChrome {
|
||||
const panelName =
|
||||
panelEl
|
||||
.querySelector('.panel-title, .pp-header-label')
|
||||
?.textContent?.trim() || 'panel';
|
||||
const action = collapsed ? 'Expand' : 'Collapse';
|
||||
btn.title = `${action} ${panelName}`;
|
||||
btn.setAttribute('aria-label', `${action} ${panelName}`);
|
||||
?.textContent?.trim() || t('cockpit.panel.fallbackName');
|
||||
const titleKey = collapsed
|
||||
? 'cockpit.panel.expandTitle'
|
||||
: 'cockpit.panel.collapseTitle';
|
||||
btn.title = t(titleKey, { name: panelName });
|
||||
btn.setAttribute('aria-label', t(titleKey, { name: panelName }));
|
||||
if (panelEl.id === 'radio-panel') {
|
||||
const action = collapsed ? 'Expand' : 'Collapse';
|
||||
btn.title = `${action} Radio`;
|
||||
btn.setAttribute('aria-label', `${action} Radio section`);
|
||||
// Expand state reuses the layers.* keys seeded on the static button;
|
||||
// the collapse state is runtime-only, so it owns cockpit.* keys.
|
||||
btn.title = t(
|
||||
collapsed
|
||||
? 'layers.radio.expandTitle'
|
||||
: 'cockpit.panel.radioCollapseTitle',
|
||||
);
|
||||
btn.setAttribute(
|
||||
'aria-label',
|
||||
t(
|
||||
collapsed
|
||||
? 'layers.radio.expandAriaLabel'
|
||||
: 'cockpit.panel.radioCollapseAria',
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
const dockToggle = panelEl.querySelector(
|
||||
@@ -322,11 +337,13 @@ export class PanelChrome {
|
||||
const panelName =
|
||||
panelEl
|
||||
.querySelector('.panel-title, .location-toolbar-label')
|
||||
?.textContent?.trim() || 'panel';
|
||||
const action = collapsed ? 'Expand' : 'Collapse';
|
||||
?.textContent?.trim() || t('cockpit.panel.fallbackName');
|
||||
const titleKey = collapsed
|
||||
? 'cockpit.panel.expandTitle'
|
||||
: 'cockpit.panel.collapseTitle';
|
||||
dockToggle.setAttribute('aria-expanded', String(!collapsed));
|
||||
dockToggle.setAttribute('aria-label', `${action} ${panelName}`);
|
||||
dockToggle.title = `${action} ${panelName}`;
|
||||
dockToggle.setAttribute('aria-label', t(titleKey, { name: panelName }));
|
||||
dockToggle.title = t(titleKey, { name: panelName });
|
||||
}
|
||||
if (panelEl.id === 'radio-panel' && this._contextRadioDetailsBtn) {
|
||||
this._contextRadioDetailsBtn.setAttribute(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
/** Own panel position preferences, viewport clamping and drag listeners. */
|
||||
import { t } from '../i18n/index.js';
|
||||
/** Versioned localStorage namespace prefix to invalidate stale panel layouts. */
|
||||
const PANEL_LAYOUT_STORAGE_VERSION = 'v6';
|
||||
/**
|
||||
@@ -55,9 +56,7 @@ export class PanelPositionControls {
|
||||
key.startsWith('godsEyeView.v6.panelPos.'),
|
||||
);
|
||||
if (hadOldPositions) {
|
||||
this._showToast(
|
||||
'Panel layout updated — positions reset to new defaults',
|
||||
);
|
||||
this._showToast(t('cockpit.panel.layoutResetToast'));
|
||||
}
|
||||
} catch {
|
||||
// storage unavailable
|
||||
|
||||
+44
-12
@@ -1,3 +1,4 @@
|
||||
import { t } from '../i18n/index.js';
|
||||
import {
|
||||
radioTunerSlot,
|
||||
radioTunerCommitSlot,
|
||||
@@ -47,22 +48,36 @@ export function bindRadioControls() {
|
||||
);
|
||||
if (displayOpen) this.actions.revealStyleParameters();
|
||||
if (this._cockpitDisplayToggleBtn) {
|
||||
const action = displayOpen ? 'Collapse' : 'Expand';
|
||||
this._cockpitDisplayToggleBtn.textContent = displayOpen ? '▶' : '◀';
|
||||
this._cockpitDisplayToggleBtn.setAttribute(
|
||||
'aria-label',
|
||||
`${action} Cockpit display options`,
|
||||
t(
|
||||
displayOpen
|
||||
? 'cockpit.utility.displayToggleCollapseAriaLabel'
|
||||
: 'cockpit.utility.displayToggleAriaLabel',
|
||||
),
|
||||
);
|
||||
this._cockpitDisplayToggleBtn.title = t(
|
||||
displayOpen
|
||||
? 'cockpit.utility.displayToggleCollapseAriaLabel'
|
||||
: 'cockpit.utility.displayToggleAriaLabel',
|
||||
);
|
||||
this._cockpitDisplayToggleBtn.title = `${action} Cockpit display options`;
|
||||
}
|
||||
if (this._cockpitRadioToggleBtn) {
|
||||
const action = radioOpen ? 'Collapse' : 'Expand';
|
||||
this._cockpitRadioToggleBtn.textContent = radioOpen ? '▶' : '◀';
|
||||
this._cockpitRadioToggleBtn.setAttribute(
|
||||
'aria-label',
|
||||
`${action} Cockpit Radio controls`,
|
||||
t(
|
||||
radioOpen
|
||||
? 'cockpit.utility.radioToggleCollapseAriaLabel'
|
||||
: 'cockpit.utility.radioToggleAriaLabel',
|
||||
),
|
||||
);
|
||||
this._cockpitRadioToggleBtn.title = t(
|
||||
radioOpen
|
||||
? 'cockpit.utility.radioToggleCollapseAriaLabel'
|
||||
: 'cockpit.utility.radioToggleAriaLabel',
|
||||
);
|
||||
this._cockpitRadioToggleBtn.title = `${action} Cockpit Radio controls`;
|
||||
}
|
||||
if (!expanded && returnFocus) {
|
||||
(kind === 'display'
|
||||
@@ -140,17 +155,21 @@ export function bindRadioControls() {
|
||||
if (this._radioTunerValue) {
|
||||
this._radioTunerValue.textContent = station
|
||||
? `CH ${String(slot.stationIndex + 1).padStart(2, '0')} / ${String(this._radioTunerStations.length).padStart(2, '0')}`
|
||||
: 'NO STATIONS';
|
||||
: t('cockpit.radio.tunerNoStations');
|
||||
}
|
||||
if (this._radioTunerStation)
|
||||
this._radioTunerStation.textContent =
|
||||
station?.name || 'NO STATION AVAILABLE';
|
||||
station?.name || t('layers.radio.noStationAvailable');
|
||||
if (this._radioTunerSlider) {
|
||||
this._radioTunerSlider.setAttribute(
|
||||
'aria-valuetext',
|
||||
station
|
||||
? `${station.name}, station ${slot.stationIndex + 1} of ${this._radioTunerStations.length}`
|
||||
: 'No station available',
|
||||
? t('cockpit.radio.tunerStationAria', {
|
||||
name: station.name,
|
||||
index: slot.stationIndex + 1,
|
||||
total: this._radioTunerStations.length,
|
||||
})
|
||||
: t('cockpit.radio.tunerNoStationAria'),
|
||||
);
|
||||
}
|
||||
if (syncStatic)
|
||||
@@ -301,9 +320,11 @@ export function bindRadioControls() {
|
||||
this._radioTunerBandPinnedForNavigation = false;
|
||||
if (result.reason === 'station-unavailable') {
|
||||
if (this._radioTunerValue)
|
||||
this._radioTunerValue.textContent = 'OFF AIR';
|
||||
this._radioTunerValue.textContent = t('cockpit.radio.tunerOffAir');
|
||||
if (this._radioTunerStation)
|
||||
this._radioTunerStation.textContent = 'STATION UNAVAILABLE';
|
||||
this._radioTunerStation.textContent = t(
|
||||
'cockpit.radio.tunerStationUnavailable',
|
||||
);
|
||||
this._radioTunerSlider?.setAttribute(
|
||||
'aria-valuetext',
|
||||
'Station unavailable after directory refresh',
|
||||
@@ -340,7 +361,18 @@ export function bindRadioControls() {
|
||||
origin: 'user',
|
||||
notificationToken,
|
||||
}),
|
||||
// Lifecycle toast copy: the English template below is pinned
|
||||
// verbatim by contextSessionOrdering.test.mjs, so it stays the
|
||||
// argument and localizedMessage rewrites it at the toast boundary
|
||||
// (identical value under 'en').
|
||||
`Radio could not ${enabling ? 'start' : 'stop'} cleanly`,
|
||||
{
|
||||
localizedMessage: t('cockpit.radio.toastLifecycleFailed', {
|
||||
action: enabling
|
||||
? t('cockpit.context.actionStart')
|
||||
: t('cockpit.context.actionStop'),
|
||||
}),
|
||||
},
|
||||
);
|
||||
if (this.destroyed || toggled === false) return;
|
||||
if (
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { t } from '../i18n/index.js';
|
||||
import { bindRadioControls } from './radioBindings.js';
|
||||
import { renderRadioState } from './radioPresentation.js';
|
||||
|
||||
@@ -198,12 +199,13 @@ export class RadioControls {
|
||||
'aria-expanded',
|
||||
String(compactOpen),
|
||||
);
|
||||
const action = compactOpen ? 'Close' : 'Open';
|
||||
this._contextRadioToggleBtn.setAttribute(
|
||||
'aria-label',
|
||||
`${action} compact Radio controls`,
|
||||
const launcherLabel = t(
|
||||
compactOpen
|
||||
? 'cockpit.context.radioToggleCloseAriaLabel'
|
||||
: 'cockpit.context.radioToggleAriaLabel',
|
||||
);
|
||||
this._contextRadioToggleBtn.title = `${action} compact Radio controls`;
|
||||
this._contextRadioToggleBtn.setAttribute('aria-label', launcherLabel);
|
||||
this._contextRadioToggleBtn.title = launcherLabel;
|
||||
}
|
||||
|
||||
destroy() {
|
||||
|
||||
+92
-101
@@ -1,4 +1,6 @@
|
||||
/** Render Radio state without making playback or Context decisions. */
|
||||
import { t } from '../i18n/index.js';
|
||||
|
||||
export function renderRadioState(state) {
|
||||
if (this.destroyed || !state || !this._radioPanel) return;
|
||||
const lifecycle = this.actions.getLifecycle() || null;
|
||||
@@ -31,32 +33,38 @@ export function renderRadioState(state) {
|
||||
this._syncContextRadioLauncherState();
|
||||
this._radioLayerState?.classList.toggle('active', enabled);
|
||||
if (this._radioLayerState) {
|
||||
// Lifecycle enum values map to status-chip keys; counts stay machine.
|
||||
const lifecycleLabel =
|
||||
lifecycleState === 'enabling'
|
||||
? t('layers.status.enabling')
|
||||
: t('layers.status.disabling');
|
||||
this._radioLayerState.textContent = transitioning
|
||||
? lifecycleState.toUpperCase()
|
||||
? lifecycleLabel
|
||||
: uncertain
|
||||
? 'UNCERTAIN'
|
||||
? t('layers.status.uncertain')
|
||||
: state.loading
|
||||
? 'SYNC'
|
||||
? t('cockpit.radio.stateSync')
|
||||
: enabled
|
||||
? `${state.filteredCount}/${state.stationCount}`
|
||||
: 'OFF';
|
||||
: t('layers.status.off');
|
||||
}
|
||||
const enableLabel = uncertain
|
||||
? t('layers.radio.reconcile')
|
||||
: enabled
|
||||
? t('layers.radio.disable')
|
||||
: t('layers.radio.enable');
|
||||
const enableAria = uncertain
|
||||
? t('layers.radio.reconcileAria')
|
||||
: enabled
|
||||
? t('layers.radio.disableAria')
|
||||
: t('layers.radio.enableAria');
|
||||
if (this._radioEnableBtn) {
|
||||
this._radioEnableBtn.classList.toggle('active', enabled);
|
||||
this._radioEnableBtn.setAttribute('aria-pressed', String(enabled));
|
||||
this._radioEnableBtn.textContent = transitioning
|
||||
? lifecycleState.toUpperCase()
|
||||
: uncertain
|
||||
? 'RECONCILE'
|
||||
: enabled
|
||||
? 'DISABLE'
|
||||
: 'ENABLE';
|
||||
this._radioEnableBtn.setAttribute(
|
||||
'aria-label',
|
||||
uncertain
|
||||
? 'Reconcile Radio — lifecycle uncertain'
|
||||
: `${enabled ? 'Disable' : 'Enable'} Radio`,
|
||||
);
|
||||
? lifecycleLabel
|
||||
: enableLabel;
|
||||
this._radioEnableBtn.setAttribute('aria-label', enableAria);
|
||||
this._radioEnableBtn.disabled = false;
|
||||
this._radioEnableBtn.setAttribute('aria-disabled', String(transitioning));
|
||||
this._radioEnableBtn.setAttribute('aria-busy', String(transitioning));
|
||||
@@ -68,18 +76,9 @@ export function renderRadioState(state) {
|
||||
String(enabled),
|
||||
);
|
||||
this._contextRadioMiniEnableBtn.textContent = transitioning
|
||||
? lifecycleState.toUpperCase()
|
||||
: uncertain
|
||||
? 'RECONCILE'
|
||||
: enabled
|
||||
? 'DISABLE'
|
||||
: 'ENABLE';
|
||||
this._contextRadioMiniEnableBtn.setAttribute(
|
||||
'aria-label',
|
||||
uncertain
|
||||
? 'Reconcile Radio — lifecycle uncertain'
|
||||
: `${enabled ? 'Disable' : 'Enable'} Radio`,
|
||||
);
|
||||
? lifecycleLabel
|
||||
: enableLabel;
|
||||
this._contextRadioMiniEnableBtn.setAttribute('aria-label', enableAria);
|
||||
this._contextRadioMiniEnableBtn.disabled = false;
|
||||
this._contextRadioMiniEnableBtn.setAttribute(
|
||||
'aria-disabled',
|
||||
@@ -94,18 +93,9 @@ export function renderRadioState(state) {
|
||||
this._cockpitRadioEnableBtn.classList.toggle('active', enabled);
|
||||
this._cockpitRadioEnableBtn.setAttribute('aria-pressed', String(enabled));
|
||||
this._cockpitRadioEnableBtn.textContent = transitioning
|
||||
? lifecycleState.toUpperCase()
|
||||
: uncertain
|
||||
? 'RECONCILE'
|
||||
: enabled
|
||||
? 'DISABLE'
|
||||
: 'ENABLE';
|
||||
this._cockpitRadioEnableBtn.setAttribute(
|
||||
'aria-label',
|
||||
uncertain
|
||||
? 'Reconcile Radio — lifecycle uncertain'
|
||||
: `${enabled ? 'Disable' : 'Enable'} Radio`,
|
||||
);
|
||||
? lifecycleLabel
|
||||
: enableLabel;
|
||||
this._cockpitRadioEnableBtn.setAttribute('aria-label', enableAria);
|
||||
this._cockpitRadioEnableBtn.disabled = false;
|
||||
this._cockpitRadioEnableBtn.setAttribute(
|
||||
'aria-disabled',
|
||||
@@ -156,8 +146,12 @@ export function renderRadioState(state) {
|
||||
);
|
||||
this._radioTunerBandLabel.textContent =
|
||||
state.filter === 'all'
|
||||
? 'DIRECTORY BAND'
|
||||
: `${String(activeCategory?.label || state.filter).toUpperCase()} BAND`;
|
||||
? t('layers.radio.bandLabel')
|
||||
: t('cockpit.radio.tunerCategoryBand', {
|
||||
category: String(
|
||||
activeCategory?.label || state.filter,
|
||||
).toUpperCase(),
|
||||
});
|
||||
}
|
||||
this._radioTuner?.classList.toggle('is-static', Boolean(state.tuningStatic));
|
||||
if (tunerAvailable) this._refreshRadioTunerBand?.();
|
||||
@@ -176,7 +170,7 @@ export function renderRadioState(state) {
|
||||
|
||||
if (this._radioStationName)
|
||||
this._radioStationName.textContent =
|
||||
selected?.name || 'NO STATION SELECTED';
|
||||
selected?.name || t('layers.radio.noStation');
|
||||
if (this._radioStationMeta) {
|
||||
const place = selected
|
||||
? [selected.state, selected.countryCode].filter(Boolean).join(' · ')
|
||||
@@ -188,15 +182,15 @@ export function renderRadioState(state) {
|
||||
: '';
|
||||
this._radioStationMeta.textContent = selected
|
||||
? [place, signal].filter(Boolean).join(' / ') ||
|
||||
'Directory metadata only'
|
||||
t('layers.radio.meta.metadataOnly')
|
||||
: state.loading
|
||||
? 'Loading station directory…'
|
||||
: 'Choose a globe marker or use next.';
|
||||
? t('layers.radio.meta.loading')
|
||||
: t('layers.radio.meta.chooseHint');
|
||||
}
|
||||
if (this._radioStationTags) {
|
||||
const tags = Array.isArray(selected?.tags) ? selected.tags.slice(0, 8) : [];
|
||||
this._radioStationTags.textContent = tags.length
|
||||
? `TAGS · ${tags.join(' · ')}`
|
||||
? t('layers.radio.tags', { tags: tags.join(' · ') })
|
||||
: '';
|
||||
}
|
||||
if (this._radioStationHomepage) {
|
||||
@@ -218,49 +212,37 @@ export function renderRadioState(state) {
|
||||
this._cockpitRadioPrevBtn.disabled = !interactive || !hasStations;
|
||||
if (this._cockpitRadioNextBtn)
|
||||
this._cockpitRadioNextBtn.disabled = !interactive || !hasStations;
|
||||
const playbackAction = activePlayback
|
||||
? t('cockpit.radio.actionPause')
|
||||
: state.audioState === 'paused'
|
||||
? t('cockpit.radio.actionResume')
|
||||
: t('cockpit.radio.actionPlay');
|
||||
const playTargetWord = selected
|
||||
? t('cockpit.radio.targetSelected')
|
||||
: t('cockpit.radio.targetNearest');
|
||||
const playAria = t('cockpit.radio.playStateAria', {
|
||||
action: playbackAction,
|
||||
target: playTargetWord,
|
||||
});
|
||||
if (this._radioPlayBtn) {
|
||||
const action = activePlayback
|
||||
? 'Pause'
|
||||
: state.audioState === 'paused'
|
||||
? 'Resume'
|
||||
: 'Play';
|
||||
this._radioPlayBtn.disabled = !interactive || !hasStations;
|
||||
this._radioPlayBtn.classList.toggle('active', activePlayback);
|
||||
this._radioPlayBtn.textContent = action.toUpperCase();
|
||||
this._radioPlayBtn.setAttribute(
|
||||
'aria-label',
|
||||
`${action} ${selected ? 'selected' : 'nearest'} radio station`,
|
||||
);
|
||||
this._radioPlayBtn.textContent = playbackAction.toUpperCase();
|
||||
this._radioPlayBtn.setAttribute('aria-label', playAria);
|
||||
}
|
||||
if (this._contextRadioMiniPlayBtn) {
|
||||
const action = activePlayback
|
||||
? 'Pause'
|
||||
: state.audioState === 'paused'
|
||||
? 'Resume'
|
||||
: 'Play';
|
||||
this._contextRadioMiniPlayBtn.disabled = !interactive || !hasStations;
|
||||
this._contextRadioMiniPlayBtn.classList.toggle('active', activePlayback);
|
||||
this._contextRadioMiniPlayBtn.textContent = activePlayback ? 'Ⅱ' : '▶';
|
||||
this._contextRadioMiniPlayBtn.setAttribute(
|
||||
'aria-label',
|
||||
`${action} ${selected ? 'selected' : 'nearest'} radio station`,
|
||||
);
|
||||
this._contextRadioMiniPlayBtn.title = action;
|
||||
this._contextRadioMiniPlayBtn.setAttribute('aria-label', playAria);
|
||||
this._contextRadioMiniPlayBtn.title = playbackAction;
|
||||
}
|
||||
if (this._cockpitRadioPlayBtn) {
|
||||
const action = activePlayback
|
||||
? 'Pause'
|
||||
: state.audioState === 'paused'
|
||||
? 'Resume'
|
||||
: 'Play';
|
||||
this._cockpitRadioPlayBtn.disabled = !interactive || !hasStations;
|
||||
this._cockpitRadioPlayBtn.classList.toggle('active', activePlayback);
|
||||
this._cockpitRadioPlayBtn.textContent = activePlayback ? 'Ⅱ' : '▶';
|
||||
this._cockpitRadioPlayBtn.setAttribute(
|
||||
'aria-label',
|
||||
`${action} ${selected ? 'selected' : 'nearest'} radio station`,
|
||||
);
|
||||
this._cockpitRadioPlayBtn.title = action;
|
||||
this._cockpitRadioPlayBtn.setAttribute('aria-label', playAria);
|
||||
this._cockpitRadioPlayBtn.title = playbackAction;
|
||||
}
|
||||
if (this._radioStopBtn)
|
||||
this._radioStopBtn.disabled =
|
||||
@@ -295,56 +277,65 @@ export function renderRadioState(state) {
|
||||
}
|
||||
if (this._contextRadioMiniStation) {
|
||||
this._contextRadioMiniStation.textContent = uncertain
|
||||
? 'RADIO STATE UNCERTAIN'
|
||||
: selected?.name || (state.loading ? 'SYNCING DIRECTORY' : 'RADIO READY');
|
||||
? t('cockpit.radio.miniStateUncertain')
|
||||
: selected?.name ||
|
||||
(state.loading
|
||||
? t('cockpit.radio.miniSyncingDirectory')
|
||||
: t('cockpit.context.radioMiniReady'));
|
||||
}
|
||||
if (this._cockpitRadioStation) {
|
||||
this._cockpitRadioStation.textContent = uncertain
|
||||
? 'UNCERTAIN'
|
||||
: selected?.name || (state.loading ? 'SYNCING' : 'READY');
|
||||
? t('layers.status.uncertain')
|
||||
: selected?.name ||
|
||||
(state.loading
|
||||
? t('cockpit.radio.stationSyncing')
|
||||
: t('cockpit.radio.station.ready'));
|
||||
}
|
||||
if (this._radioPlaybackState) {
|
||||
const stationName = selected?.name || t('cockpit.radio.stationFallback');
|
||||
const catalogSuffix = state.degraded
|
||||
? state.stale
|
||||
? ' · stale/degraded directory'
|
||||
: ' · degraded directory'
|
||||
? t('layers.radio.state.staleDegradedDirectory')
|
||||
: t('layers.radio.state.degradedDirectory')
|
||||
: state.stale
|
||||
? ' · stale directory'
|
||||
? t('layers.radio.state.staleDirectory')
|
||||
: '';
|
||||
const outsideFilter =
|
||||
selected && state.selectedIndex < 0 ? ' · outside current filter' : '';
|
||||
selected && state.selectedIndex < 0
|
||||
? t('layers.radio.state.outsideFilter')
|
||||
: '';
|
||||
const messages = {
|
||||
stopped: enabled
|
||||
? 'Ready — playback starts only from your action'
|
||||
: 'Radio off',
|
||||
loading: 'Connecting directly to broadcaster…',
|
||||
buffering: 'Buffering broadcaster stream…',
|
||||
playing: `Playing ${selected?.name || 'station'}`,
|
||||
paused: `Paused ${selected?.name || 'station'}`,
|
||||
error: state.audioError || 'Broadcaster stream unavailable',
|
||||
? t('layers.radio.state.ready')
|
||||
: t('layers.radio.playbackOff'),
|
||||
loading: t('layers.radio.state.loading'),
|
||||
buffering: t('layers.radio.state.buffering'),
|
||||
playing: t('layers.radio.state.playing', { station: stationName }),
|
||||
paused: t('layers.radio.state.paused', { station: stationName }),
|
||||
error: state.audioError || t('layers.radio.state.error'),
|
||||
};
|
||||
const voiceSuffix = state.voiceDucked
|
||||
? ' · muted during voice interaction'
|
||||
? t('layers.radio.state.voiceMuted')
|
||||
: state.voiceRestoring
|
||||
? ' · restoring volume after voice'
|
||||
? t('layers.radio.state.voiceRestoring')
|
||||
: '';
|
||||
const tuningSuffix = state.tuningAwaitingStationId
|
||||
? state.audioState === 'error'
|
||||
? ' · static indicates no broadcaster audio'
|
||||
: ' · tuning static until broadcaster starts'
|
||||
? t('layers.radio.state.staticNoAudio')
|
||||
: t('layers.radio.state.tuningStatic')
|
||||
: '';
|
||||
const unavailable = state.tuningUnavailableStationId
|
||||
? 'Station unavailable after directory refresh — choose another channel'
|
||||
? t('layers.radio.state.stationUnavailable')
|
||||
: null;
|
||||
const lifecycleMessage = transitioning
|
||||
? lifecycleState === 'enabling'
|
||||
? 'Radio is enabling…'
|
||||
: 'Radio is disabling…'
|
||||
? t('layers.radio.state.enabling')
|
||||
: t('layers.radio.state.disabling')
|
||||
: null;
|
||||
const uncertainMessage = uncertain
|
||||
? 'Radio lifecycle is uncertain — use Enable or Disable to reconcile'
|
||||
? t('layers.radio.state.uncertain')
|
||||
: null;
|
||||
this._radioPlaybackState.textContent = `${uncertainMessage || unavailable || lifecycleMessage || state.error || messages[state.audioState] || 'Ready'}${tuningSuffix}${voiceSuffix}${catalogSuffix}${outsideFilter}`;
|
||||
this._radioPlaybackState.textContent = `${uncertainMessage || unavailable || lifecycleMessage || state.error || messages[state.audioState] || t('cockpit.radio.playbackReadyFallback')}${tuningSuffix}${voiceSuffix}${catalogSuffix}${outsideFilter}`;
|
||||
this._radioPlaybackState.classList.toggle(
|
||||
'error',
|
||||
Boolean(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
/** Own Scene panel listeners and presentation through project reads and actions. */
|
||||
import { t } from '../i18n/index.js';
|
||||
import {
|
||||
sceneElements,
|
||||
renderSceneOptions,
|
||||
@@ -57,7 +58,7 @@ export class SceneControls {
|
||||
if (!this.destroyed) elements.file.value = '';
|
||||
});
|
||||
if (!subscribe) {
|
||||
this.updateStatus('Ready');
|
||||
this.updateStatus(t('setup.scenes.statusReady'));
|
||||
this.setProgress(0);
|
||||
this.setButtons(false);
|
||||
}
|
||||
@@ -123,7 +124,7 @@ export class SceneControls {
|
||||
const generation = ++this.actionGeneration;
|
||||
const failed = () => {
|
||||
if (!this.destroyed && generation === this.actionGeneration)
|
||||
this.updateStatus('Scene action failed');
|
||||
this.updateStatus(t('setup.scenes.status.actionFailed'));
|
||||
};
|
||||
try {
|
||||
const result = this.actions[action](...args);
|
||||
@@ -160,7 +161,12 @@ export class SceneControls {
|
||||
if (this.destroyed) return;
|
||||
const scene = this.read().scenes.find((item) => item.id === sceneId);
|
||||
const shot = scene?.shots.find((item) => item.id === shotId);
|
||||
if (shot && window.confirm(`Delete shot "${shot.title}"?`))
|
||||
if (
|
||||
shot &&
|
||||
window.confirm(
|
||||
t('setup.scenes.status.deleteShotConfirm', { shot: shot.title }),
|
||||
)
|
||||
)
|
||||
this.run('deleteShot', sceneId, shotId);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { LayerStateCoordinator } from '../data/layerState.js';
|
||||
import { stampInitialShareGesture } from '../navigationPolicy.js';
|
||||
import { canPresentDeferredStatusNotice } from '../loadingFeedback.js';
|
||||
import { t } from '../i18n/index.js';
|
||||
import { UiLifetime } from './uiLifetime.js';
|
||||
|
||||
/** Own initial share restoration, durable layer state and restoration notices. */
|
||||
@@ -188,9 +189,13 @@ export class ShareRestoration {
|
||||
if (result.classification === 'pending') {
|
||||
this._shareTrackingNoticeGeneration += 1;
|
||||
this._shareTrackingAcquiringKey = trackingKey;
|
||||
this.showStatus('ACQUIRING', {
|
||||
this.showStatus(t('cockpit.status.acquiring'), {
|
||||
state: 'acquiring',
|
||||
detail: `SHARED ${String(result.label || 'SUBJECT').toUpperCase()}`,
|
||||
detail: t('cockpit.status.sharedSubjectDetail', {
|
||||
subject: String(
|
||||
result.label || t('cockpit.status.subjectFallback'),
|
||||
).toUpperCase(),
|
||||
}),
|
||||
persistent: true,
|
||||
});
|
||||
return;
|
||||
@@ -214,13 +219,13 @@ export class ShareRestoration {
|
||||
const noticeGeneration = ownsAcquiringNotice
|
||||
? this._shareTrackingNoticeGeneration
|
||||
: ++this._shareTrackingNoticeGeneration;
|
||||
const subject = result.label || 'entity';
|
||||
const subject = result.label || t('cockpit.status.subjectFallback');
|
||||
const message =
|
||||
result.classification === 'expired'
|
||||
? `Shared ${subject} follow expired`
|
||||
? t('cockpit.status.sharedFollowExpired', { subject })
|
||||
: result.classification === 'source-unavailable'
|
||||
? `Shared ${subject} could not be restored — feed unavailable`
|
||||
: `Shared ${subject} is unavailable`;
|
||||
? t('cockpit.status.sharedRestoreFailed', { subject })
|
||||
: t('cockpit.status.sharedUnavailable', { subject });
|
||||
const showAfterStartupCover = () => {
|
||||
this._lifetime.frame(() => {
|
||||
if (
|
||||
|
||||
@@ -54,6 +54,7 @@ export function readShellElements(document = globalThis.document) {
|
||||
_scopeFeatherValue: document.getElementById('scope-feather-value'),
|
||||
_mapStackChips: document.getElementById('map-stack-chips'),
|
||||
_mapStackStatus: document.getElementById('map-stack-status'),
|
||||
_mapSourceLabel: document.getElementById('map-source-label'),
|
||||
_cleanViewBtn: document.getElementById('clean-view-toggle'),
|
||||
_cleanViewExitBtn: document.getElementById('clean-view-exit'),
|
||||
_dataPanel: document.getElementById('data-panel'),
|
||||
|
||||
+57
-6
@@ -9,6 +9,51 @@ import {
|
||||
reduceTrafficSyncFeedback,
|
||||
} from '../loadingFeedback.js';
|
||||
import { setSplitFlapText, disposeSplitFlap } from '../splitFlap.js';
|
||||
import { t } from '../i18n/index.js';
|
||||
|
||||
// loadingFeedback.js composes the shared status copy inside the portable
|
||||
// node/browser graph (the server-side HUD summary reads the same modules), so
|
||||
// its labels stay English there. This chip is the presentation boundary: map
|
||||
// the closed label vocabulary to the catalog, and pass anything unrecognized
|
||||
// (a live provider error) through verbatim (docs/TRANSLATORS.md).
|
||||
const STATUS_LABEL_KEYS = Object.freeze({
|
||||
'LOADING LIVE DATA': 'shell.status.loadingLiveData',
|
||||
'LOAD COMPLETE': 'shell.status.loadComplete',
|
||||
'LOAD CANCELLED': 'shell.status.loadCancelled',
|
||||
'LOAD FAILED': 'shell.status.loadFailed',
|
||||
'LIVE DATA OFF': 'shell.status.liveDataOff',
|
||||
'MAPPED SITES LOADED': 'shell.status.mappedSitesLoaded',
|
||||
'RETRYING MAPPED SITES': 'shell.status.retryingMappedSites',
|
||||
'FETCHING MAPPED SITES': 'shell.status.fetchingMappedSites',
|
||||
'TURNING OFF LIVE DATA': 'shell.status.turningOffLiveData',
|
||||
'REFRESHING LIVE DATA': 'shell.status.refreshingLiveData',
|
||||
'syncing road network': 'shell.status.trafficSyncing',
|
||||
});
|
||||
|
||||
function localizeStatusLabel(label) {
|
||||
if (typeof label !== 'string' || !label) return label;
|
||||
const key = STATUS_LABEL_KEYS[label.trim()];
|
||||
return key ? t(key) : label;
|
||||
}
|
||||
|
||||
const RETRY_DETAIL_RE = /^(?:ALPR cameras · )?retrying in (\d+)s$/;
|
||||
|
||||
function localizeStatusDetail(detail) {
|
||||
if (typeof detail !== 'string' || !detail) return detail;
|
||||
const match = detail.match(RETRY_DETAIL_RE);
|
||||
if (match) return t('shell.status.retryingIn', { seconds: match[1] });
|
||||
if (detail.trim() === 'retry pending') return t('shell.status.retryPending');
|
||||
return detail;
|
||||
}
|
||||
|
||||
function localizeLoadingPresentation(presentation) {
|
||||
if (!presentation) return presentation;
|
||||
return {
|
||||
...presentation,
|
||||
label: localizeStatusLabel(presentation.label),
|
||||
detail: localizeStatusDetail(presentation.detail),
|
||||
};
|
||||
}
|
||||
export class ShellFeedback {
|
||||
constructor({ readLayers }) {
|
||||
this.readLayers = readLayers;
|
||||
@@ -76,10 +121,15 @@ export class ShellFeedback {
|
||||
// the flap keeps textContent equal to the settled label throughout, so
|
||||
// this stays a no-op on the repeat ticks exactly as it did before.
|
||||
if (presentation.label)
|
||||
setSplitFlapText(this._trafficSyncLabel, presentation.label, {
|
||||
// Completion is a timing signal, not another animated loading stage.
|
||||
immediate: !presentation.busy,
|
||||
});
|
||||
if (presentation.label)
|
||||
setSplitFlapText(
|
||||
this._trafficSyncLabel,
|
||||
localizeStatusLabel(presentation.label),
|
||||
{
|
||||
// Completion is a timing signal, not another animated loading stage.
|
||||
immediate: !presentation.busy,
|
||||
},
|
||||
);
|
||||
// Written on every change INCLUDING the empty settled value — the reducer
|
||||
// clears the progress number once the sync lands, and a truthiness guard
|
||||
// here would strand the last "..." beside the settled label.
|
||||
@@ -120,6 +170,7 @@ export class ShellFeedback {
|
||||
if (this._loadingFeedbackState?.phase !== 'idle' || noticeNeedsTicker) {
|
||||
this._armLoadingFeedbackTicker();
|
||||
}
|
||||
const localized = localizeLoadingPresentation(presentation);
|
||||
this._globalLoadingStatus.hidden = !presentation;
|
||||
if (!presentation) {
|
||||
delete this._globalLoadingStatus.dataset.state;
|
||||
@@ -131,8 +182,8 @@ export class ShellFeedback {
|
||||
// here: this runs on every 60 ms and 500 ms tick. The detail line is the
|
||||
// live layer roster inside an ellipsised, width-capped span — flapping a
|
||||
// list that churns as layers join would be noise, not delight.
|
||||
setSplitFlapText(this._globalLoadingLabel, presentation.label);
|
||||
this._globalLoadingDetail.textContent = presentation.detail;
|
||||
setSplitFlapText(this._globalLoadingLabel, localized.label);
|
||||
this._globalLoadingDetail.textContent = localized.detail;
|
||||
}
|
||||
|
||||
_showGlobalStatusNotice(message, options = {}) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!-- Aircraft first-person view. It can be entered from the visible view switcher. -->
|
||||
<section id="cockpit-hud" aria-label="Aircraft cockpit view" hidden>
|
||||
<section id="cockpit-hud" aria-label="Aircraft cockpit view" hidden data-i18n-aria-label="cockpit.hud.sectionLabel">
|
||||
<!-- 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">
|
||||
@@ -61,19 +61,19 @@
|
||||
<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>LEVEL</small><span></span>
|
||||
<span data-i18n="cockpit.hud.level"></span><small>LEVEL</small><span></span>
|
||||
</div>
|
||||
<div id="cockpit-route-direction" class="cockpit-route-direction" hidden aria-label="Estimated destination direction">
|
||||
<div id="cockpit-route-direction" class="cockpit-route-direction" hidden aria-label="Estimated destination direction" data-i18n-aria-label="cockpit.hud.routeDirectionAriaLabel">
|
||||
<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">DEST ---°</small>
|
||||
<small id="cockpit-route-direction-label" data-i18n="cockpit.hud.routeDirectionIdle">DEST ---°</small>
|
||||
</div>
|
||||
<div class="cockpit-visor-status cockpit-visor-status-left">OPTICAL PLANE · 01</div>
|
||||
<div class="cockpit-visor-status cockpit-visor-status-right">VISOR LOCK · ACTIVE</div>
|
||||
<div class="cockpit-visor-status cockpit-visor-status-left" data-i18n="cockpit.hud.visorPlane">OPTICAL PLANE · 01</div>
|
||||
<div class="cockpit-visor-status cockpit-visor-status-right" data-i18n="cockpit.hud.visorLock">VISOR LOCK · ACTIVE</div>
|
||||
</div>
|
||||
<div id="cockpit-speed-rim" class="cockpit-altitude-rim cockpit-speed-rim">
|
||||
<span class="cockpit-altitude-rim-label">GROUND SPEED · KTS</span>
|
||||
<span class="cockpit-altitude-rim-label" data-i18n="cockpit.readout.rimGroundSpeed">GROUND SPEED · KTS</span>
|
||||
<div class="cockpit-altitude-rim-rail"></div>
|
||||
<div class="cockpit-altitude-rim-scale">
|
||||
<span data-speed-rim-tick><b></b><i></i></span>
|
||||
@@ -92,7 +92,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div id="cockpit-altitude-rim" class="cockpit-altitude-rim">
|
||||
<span class="cockpit-altitude-rim-label">ALTITUDE · FT</span>
|
||||
<span class="cockpit-altitude-rim-label" data-i18n="cockpit.readout.rimAltitude">ALTITUDE · FT</span>
|
||||
<div class="cockpit-altitude-rim-rail"></div>
|
||||
<div class="cockpit-altitude-rim-scale">
|
||||
<span data-altitude-rim-tick><b></b><i></i></span>
|
||||
@@ -112,36 +112,36 @@
|
||||
</div>
|
||||
<div class="cockpit-topline">
|
||||
<div>
|
||||
<span class="cockpit-kicker">FIRST PERSON</span>
|
||||
<span class="cockpit-kicker" data-i18n="cockpit.hud.firstPerson">FIRST PERSON</span>
|
||||
<strong id="cockpit-callsign">AIRCRAFT</strong>
|
||||
<span id="cockpit-aircraft-meta" class="cockpit-aircraft-meta">LIVE TRACK · COURSE ALIGNED</span>
|
||||
<span id="cockpit-aircraft-meta" class="cockpit-aircraft-meta" data-i18n="cockpit.hud.aircraftMetaInitial">LIVE TRACK · COURSE ALIGNED</span>
|
||||
</div>
|
||||
<div class="cockpit-vision-controls" role="group" aria-label="Cockpit vision style">
|
||||
<button id="cockpit-vision-previous" class="cockpit-vision-nav" type="button" aria-label="Previous cockpit vision style" title="Previous vision style">
|
||||
<div class="cockpit-vision-controls" role="group" aria-label="Cockpit vision style" data-i18n-aria-label="cockpit.hud.visionGroupAriaLabel">
|
||||
<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="cockpit.vision.previousAriaLabel" data-i18n-title="cockpit.vision.previousTitle">
|
||||
<span class="material-symbols-outlined" aria-hidden="true">chevron_left</span>
|
||||
<small>PREV</small>
|
||||
<small data-i18n="cockpit.vision.previousLabel">PREV</small>
|
||||
</button>
|
||||
<button id="cockpit-vision-current" class="cockpit-vision-current" type="button" data-cockpit-vision="optical" aria-label="Current cockpit vision style: NORMAL. Activate for next style." title="Current style: NORMAL — click for next">
|
||||
<small>CURRENT</small>
|
||||
<button id="cockpit-vision-current" class="cockpit-vision-current" type="button" data-cockpit-vision="optical" aria-label="Current cockpit vision style: NORMAL. Activate for next style." title="Current style: NORMAL — click for next" data-i18n-aria-label="cockpit.vision.currentAriaLabel" data-i18n-title="cockpit.vision.currentTitle">
|
||||
<small data-i18n="cockpit.vision.current">CURRENT</small>
|
||||
<strong id="cockpit-vision-current-label" aria-live="polite">NORMAL</strong>
|
||||
</button>
|
||||
<button id="cockpit-vision-next" class="cockpit-vision-nav" type="button" aria-label="Next cockpit vision style" title="Next vision style">
|
||||
<small>NEXT</small>
|
||||
<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="cockpit.vision.nextAriaLabel" data-i18n-title="cockpit.vision.nextTitle">
|
||||
<small data-i18n="cockpit.vision.nextLabel">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">
|
||||
<div class="cockpit-compass" aria-label="Current aircraft heading" data-i18n-aria-label="cockpit.hud.compassAriaLabel">
|
||||
<div class="cockpit-instrument-row">
|
||||
<div class="cockpit-readout cockpit-speed">
|
||||
<span class="cockpit-readout-label">GROUND SPEED</span>
|
||||
<span class="cockpit-readout-label" data-i18n="cockpit.readout.groundSpeed">GROUND SPEED</span>
|
||||
<strong id="cockpit-speed-value">---</strong>
|
||||
<span class="cockpit-readout-unit">KTS</span>
|
||||
</div>
|
||||
<div class="cockpit-heading-value"><span id="cockpit-heading-value">---</span><small>°</small></div>
|
||||
<div class="cockpit-readout cockpit-altitude">
|
||||
<span class="cockpit-readout-label">ALTITUDE</span>
|
||||
<span class="cockpit-readout-label" data-i18n="cockpit.readout.altitude">ALTITUDE</span>
|
||||
<strong id="cockpit-altitude-value">-----</strong>
|
||||
<span class="cockpit-readout-unit">FT</span>
|
||||
</div>
|
||||
@@ -153,26 +153,26 @@
|
||||
<span id="cockpit-clock">--:--:--Z</span>
|
||||
<span id="cockpit-position">--° · --°</span>
|
||||
</div>
|
||||
<aside id="cockpit-context" class="cockpit-context-window" aria-label="Contact cockpit summary" hidden>
|
||||
<aside id="cockpit-context" class="cockpit-context-window" aria-label="Contact cockpit summary" hidden data-i18n-aria-label="cockpit.context.panelAriaLabel">
|
||||
<header class="cockpit-context-header">
|
||||
<div>
|
||||
<span class="cockpit-context-kicker">CONTACT</span>
|
||||
<strong id="cockpit-context-subject">CONTACTS · 250 KM</strong>
|
||||
<strong id="cockpit-context-subject" data-i18n="cockpit.context.subjectWindow">CONTACTS · 250 KM</strong>
|
||||
</div>
|
||||
<nav class="cockpit-context-actions" aria-label="Contact navigation">
|
||||
<button id="cockpit-context-previous" type="button" aria-label="Previous — prior visited contact in the 250 km window" title="Previous — prior visited contact in the 250 km window">
|
||||
<nav class="cockpit-context-actions" aria-label="Contact navigation" data-i18n-aria-label="cockpit.context.navAriaLabel">
|
||||
<button id="cockpit-context-previous" type="button" 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="cockpit.context.previousAriaLabel" data-i18n-title="cockpit.context.previousTitle">
|
||||
<span class="material-symbols-outlined" aria-hidden="true">skip_previous</span>
|
||||
</button>
|
||||
<button id="cockpit-context-next" type="button" aria-label="Next — nearest unvisited contact in the 250 km window" title="Next — nearest unvisited contact in the 250 km window">
|
||||
<button id="cockpit-context-next" type="button" 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="cockpit.context.nextAriaLabel" data-i18n-title="cockpit.context.nextTitle">
|
||||
<span class="material-symbols-outlined" aria-hidden="true">skip_next</span>
|
||||
</button>
|
||||
<button id="cockpit-context-toggle" type="button" aria-label="Collapse Contact panel" aria-expanded="true" title="Collapse contact panel">
|
||||
<button id="cockpit-context-toggle" type="button" aria-label="Collapse Contact panel" aria-expanded="true" title="Collapse contact panel" data-i18n-aria-label="cockpit.context.toggleAriaLabel" data-i18n-title="cockpit.context.toggleTitle">
|
||||
<span class="material-symbols-outlined" aria-hidden="true">chevron_left</span>
|
||||
</button>
|
||||
</nav>
|
||||
<span class="cockpit-context-qualifier">CONTEXT ONLY</span>
|
||||
<span class="cockpit-context-qualifier" data-i18n="cockpit.context.qualifier">CONTEXT ONLY</span>
|
||||
</header>
|
||||
<div class="cockpit-context-cohorts" role="list" aria-label="Nearby cohort counts">
|
||||
<div class="cockpit-context-cohorts" role="list" aria-label="Nearby cohort counts" data-i18n-aria-label="cockpit.context.cohortsAriaLabel">
|
||||
<div role="listitem" data-context-cohort="flights"><span>FLT</span><strong>—</strong><small>OpenSky</small></div>
|
||||
<div role="listitem" data-context-cohort="military"><span>MIL</span><strong>—</strong><small>adsb.lol</small></div>
|
||||
<div role="listitem" data-context-cohort="ais-live-vessels"><span>AIS</span><strong>—</strong><small>AISStream</small></div>
|
||||
@@ -181,14 +181,14 @@
|
||||
<div class="cockpit-context-nearest">
|
||||
<span id="cockpit-context-direction" class="material-symbols-outlined" aria-hidden="true">navigation</span>
|
||||
<div>
|
||||
<small>NEAREST OBSERVED / MAPPED</small>
|
||||
<strong id="cockpit-context-nearest-label">NO AVAILABLE EXAMPLE</strong>
|
||||
<small data-i18n="cockpit.context.nearestLabel">NEAREST OBSERVED / MAPPED</small>
|
||||
<strong id="cockpit-context-nearest-label" data-i18n="cockpit.context.nearestEmpty">NO AVAILABLE EXAMPLE</strong>
|
||||
</div>
|
||||
<span id="cockpit-context-bearing">—</span>
|
||||
<span id="cockpit-context-distance">—</span>
|
||||
</div>
|
||||
<footer class="cockpit-context-footer">
|
||||
<span id="cockpit-context-uncertainty">AVAILABLE INPUTS ONLY · NOT AN ALL-CLEAR</span>
|
||||
<span id="cockpit-context-uncertainty" data-i18n="cockpit.context.uncertaintyNote">AVAILABLE INPUTS ONLY · NOT AN ALL-CLEAR</span>
|
||||
<button
|
||||
id="cockpit-weather-toggle"
|
||||
class="cockpit-context-weather"
|
||||
@@ -196,6 +196,7 @@
|
||||
aria-label="Enable cockpit weather effects"
|
||||
aria-pressed="false"
|
||||
title="Enable cockpit weather effects"
|
||||
data-i18n-aria-label="cockpit.context.weatherEnableAriaLabel" data-i18n-title="cockpit.context.weatherEnableTitle"
|
||||
>
|
||||
<span>WX</span>
|
||||
<strong id="cockpit-weather-state">OFF</strong>
|
||||
@@ -203,35 +204,35 @@
|
||||
<time id="cockpit-context-updated">--:--:--Z</time>
|
||||
</footer>
|
||||
</aside>
|
||||
<aside id="cockpit-signal-stream" class="cockpit-signal-window" aria-label="Cockpit briefing carousel" hidden>
|
||||
<section id="cockpit-route" class="cockpit-route-card" aria-label="Estimated flight plan" hidden>
|
||||
<aside id="cockpit-signal-stream" class="cockpit-signal-window" aria-label="Cockpit briefing carousel" hidden data-i18n-aria-label="cockpit.brief.carouselAriaLabel">
|
||||
<section id="cockpit-route" class="cockpit-route-card" aria-label="Estimated flight plan" hidden data-i18n-aria-label="cockpit.route.cardAriaLabel">
|
||||
<div class="cockpit-route-heading">
|
||||
<span class="cockpit-route-kicker">ESTIMATED FLIGHT PLAN</span>
|
||||
<small id="cockpit-route-status">ROUTE DATA UNAVAILABLE</small>
|
||||
<span class="cockpit-route-kicker" data-i18n="cockpit.route.kicker">ESTIMATED FLIGHT PLAN</span>
|
||||
<small id="cockpit-route-status" data-i18n="cockpit.route.statusUnavailable">ROUTE DATA UNAVAILABLE</small>
|
||||
</div>
|
||||
<div class="cockpit-route-endpoints">
|
||||
<div class="cockpit-route-airport">
|
||||
<span>FROM</span>
|
||||
<strong id="cockpit-route-from">UNKNOWN</strong>
|
||||
<span data-i18n="cockpit.route.fromLabel">FROM</span>
|
||||
<strong id="cockpit-route-from" data-i18n="cockpit.route.unknownEndpoint">UNKNOWN</strong>
|
||||
</div>
|
||||
<span class="cockpit-route-arrow material-symbols-outlined" aria-hidden="true">east</span>
|
||||
<div class="cockpit-route-airport">
|
||||
<span>TO</span>
|
||||
<strong id="cockpit-route-to">UNKNOWN</strong>
|
||||
<span data-i18n="cockpit.route.toLabel">TO</span>
|
||||
<strong id="cockpit-route-to" data-i18n="cockpit.route.unknownEndpoint">UNKNOWN</strong>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<header class="cockpit-signal-header">
|
||||
<div class="cockpit-brief-heading">
|
||||
<span id="cockpit-brief-kicker" class="cockpit-signal-kicker"><i aria-hidden="true"></i> LIVE SIGNALS</span>
|
||||
<strong id="cockpit-brief-subtitle">OBSERVED / MAPPED PINGS</strong>
|
||||
<strong id="cockpit-brief-subtitle" data-i18n="cockpit.brief.subtitle">OBSERVED / MAPPED PINGS</strong>
|
||||
</div>
|
||||
<nav class="cockpit-brief-actions" aria-label="Cockpit briefing controls">
|
||||
<button id="cockpit-brief-previous" type="button" aria-label="Previous briefing page" title="Previous briefing page">
|
||||
<nav class="cockpit-brief-actions" aria-label="Cockpit briefing controls" data-i18n-aria-label="cockpit.brief.actionsAriaLabel">
|
||||
<button id="cockpit-brief-previous" type="button" aria-label="Previous briefing page" title="Previous briefing page" data-i18n-aria-label="cockpit.brief.previousAriaLabel" data-i18n-title="cockpit.brief.previousTitle">
|
||||
<span class="material-symbols-outlined" aria-hidden="true">chevron_left</span>
|
||||
</button>
|
||||
<span id="cockpit-brief-position" aria-live="polite">1 / 3</span>
|
||||
<button id="cockpit-brief-next" type="button" aria-label="Next briefing page" title="Next briefing page">
|
||||
<button id="cockpit-brief-next" type="button" aria-label="Next briefing page" title="Next briefing page" data-i18n-aria-label="cockpit.brief.nextAriaLabel" data-i18n-title="cockpit.brief.nextTitle">
|
||||
<span class="material-symbols-outlined" aria-hidden="true">chevron_right</span>
|
||||
</button>
|
||||
<button
|
||||
@@ -240,51 +241,52 @@
|
||||
aria-label="CYCLE OFF"
|
||||
aria-pressed="false"
|
||||
title="Cycle briefing pages automatically every 9 seconds (Signals → News → Local). Pauses while you hover or focus the panel. Live signal data refreshes continuously either way."
|
||||
data-i18n-title="cockpit.brief.autoTitle"
|
||||
>CYCLE OFF</button>
|
||||
<button id="cockpit-signal-toggle" type="button" aria-label="Collapse cockpit briefing panel" aria-expanded="true" title="Collapse briefing panel">
|
||||
<button id="cockpit-signal-toggle" type="button" aria-label="Collapse cockpit briefing panel" aria-expanded="true" title="Collapse briefing panel" data-i18n-aria-label="cockpit.brief.collapseAriaLabel" data-i18n-title="cockpit.brief.collapseTitle">
|
||||
<span class="material-symbols-outlined" aria-hidden="true">right_panel_close</span>
|
||||
</button>
|
||||
</nav>
|
||||
</header>
|
||||
<div class="cockpit-brief-stage">
|
||||
<section data-cockpit-brief-page="signals" aria-label="Live signals">
|
||||
<section data-cockpit-brief-page="signals" aria-label="Live signals" data-i18n-aria-label="cockpit.brief.signalsAriaLabel">
|
||||
<ol id="cockpit-signal-list" class="cockpit-signal-list" aria-live="polite"></ol>
|
||||
</section>
|
||||
<section data-cockpit-brief-page="news" aria-label="Latest regional news" hidden>
|
||||
<div id="cockpit-news-status" class="cockpit-brief-status">ACQUIRING REGIONAL NEWS</div>
|
||||
<section data-cockpit-brief-page="news" aria-label="Latest regional news" hidden data-i18n-aria-label="cockpit.brief.newsAriaLabel">
|
||||
<div id="cockpit-news-status" class="cockpit-brief-status" data-i18n="cockpit.brief.newsAcquiring">ACQUIRING REGIONAL NEWS</div>
|
||||
<ol id="cockpit-news-list" class="cockpit-news-list" aria-live="polite"></ol>
|
||||
</section>
|
||||
<section data-cockpit-brief-page="local" aria-label="Location-based information" hidden>
|
||||
<section data-cockpit-brief-page="local" aria-label="Location-based information" hidden data-i18n-aria-label="cockpit.brief.localAriaLabel">
|
||||
<div class="cockpit-local-place">
|
||||
<strong id="cockpit-local-place">RESOLVING REGION</strong>
|
||||
<strong id="cockpit-local-place" data-i18n="cockpit.brief.localResolving">RESOLVING REGION</strong>
|
||||
<small id="cockpit-local-coordinates">--</small>
|
||||
</div>
|
||||
<div class="cockpit-local-grid">
|
||||
<div><span>TEMP</span><strong id="cockpit-local-temperature">—</strong><small>OPEN-METEO</small></div>
|
||||
<div><span>WIND</span><strong id="cockpit-local-wind">—</strong><small id="cockpit-local-wind-direction">—</small></div>
|
||||
<div><span>SKY</span><strong id="cockpit-local-condition">UNKNOWN</strong><small id="cockpit-local-cloud">—</small></div>
|
||||
<div><span>PRECIP</span><strong id="cockpit-local-precipitation">—</strong><small>MM</small></div>
|
||||
<div data-i18n="cockpit.brief.labelTemp"><span>TEMP</span><strong id="cockpit-local-temperature">—</strong><small>OPEN-METEO</small></div>
|
||||
<div data-i18n="cockpit.brief.labelWind"><span>WIND</span><strong id="cockpit-local-wind">—</strong><small id="cockpit-local-wind-direction">—</small></div>
|
||||
<div data-i18n="cockpit.brief.labelSky"><span>SKY</span><strong id="cockpit-local-condition">UNKNOWN</strong><small id="cockpit-local-cloud">—</small></div>
|
||||
<div data-i18n="cockpit.brief.labelPrecip"><span>PRECIP</span><strong id="cockpit-local-precipitation">—</strong><small>MM</small></div>
|
||||
</div>
|
||||
<a class="cockpit-weather-credit" href="https://open-meteo.com/" target="_blank" rel="noopener">Weather data by Open-Meteo.com</a>
|
||||
</section>
|
||||
</div>
|
||||
<footer class="cockpit-signal-footer">
|
||||
<span id="cockpit-brief-source">SOURCE-BACKED EVENTS · NO SYNTHETIC NEWS</span>
|
||||
<span id="cockpit-brief-source" data-i18n="cockpit.brief.sourceNote">SOURCE-BACKED EVENTS · NO SYNTHETIC NEWS</span>
|
||||
<nav class="cockpit-brief-tabs" aria-label="Cockpit briefing pages">
|
||||
<button type="button" data-cockpit-brief-index="0" aria-label="Show Live Signals" aria-current="true">SIG</button>
|
||||
<button type="button" data-cockpit-brief-index="1" aria-label="Show Regional News">NEWS</button>
|
||||
<button type="button" data-cockpit-brief-index="2" aria-label="Show Local Info">LOCAL</button>
|
||||
<button type="button" data-cockpit-brief-index="0" aria-label="Show Live Signals" aria-current="true" data-i18n-aria-label="cockpit.brief.tabSignalsAriaLabel">SIG</button>
|
||||
<button type="button" data-cockpit-brief-index="1" aria-label="Show Regional News" data-i18n-aria-label="cockpit.brief.tabNewsAriaLabel">NEWS</button>
|
||||
<button type="button" data-cockpit-brief-index="2" aria-label="Show Local Info" data-i18n-aria-label="cockpit.brief.tabLocalAriaLabel">LOCAL</button>
|
||||
</nav>
|
||||
</footer>
|
||||
</aside>
|
||||
<aside id="cockpit-utility-controls" class="cockpit-utility-controls" aria-label="Cockpit display and Radio controls">
|
||||
<aside id="cockpit-utility-controls" class="cockpit-utility-controls" aria-label="Cockpit display and Radio controls" data-i18n-aria-label="cockpit.utility.controlsAriaLabel">
|
||||
<div class="cockpit-utility-control">
|
||||
<div class="cockpit-utility-launcher" data-cockpit-launcher="display">
|
||||
<span>DISPLAY</span>
|
||||
<span data-i18n="cockpit.utility.displayLabel">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="false" aria-controls="cockpit-display-panel" aria-label="Expand Cockpit display options" title="Expand Cockpit display options">◀</button>
|
||||
<button id="cockpit-display-toggle-btn" class="cockpit-utility-glyph" type="button" aria-expanded="false" aria-controls="cockpit-display-panel" aria-label="Expand Cockpit display options" title="Expand Cockpit display options" data-i18n-aria-label="cockpit.utility.displayToggleAriaLabel" data-i18n-title="cockpit.utility.displayToggleTitle">◀</button>
|
||||
</div>
|
||||
<div id="cockpit-display-panel" class="cockpit-utility-popover" role="group" aria-label="Cockpit display options" hidden>
|
||||
<div id="cockpit-display-panel" class="cockpit-utility-popover" role="group" aria-label="Cockpit display options" hidden data-i18n-aria-label="cockpit.utility.displayPanelAriaLabel">
|
||||
<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>
|
||||
@@ -293,21 +295,21 @@
|
||||
</div>
|
||||
<div class="cockpit-utility-control">
|
||||
<div class="cockpit-utility-launcher" data-cockpit-launcher="radio">
|
||||
<span>RADIO</span>
|
||||
<span data-i18n="cockpit.utility.radioLabel">RADIO</span>
|
||||
<span class="cockpit-utility-divider" aria-hidden="true"></span>
|
||||
<button id="cockpit-radio-toggle-btn" class="cockpit-utility-glyph" type="button" aria-expanded="false" aria-controls="cockpit-radio-panel" aria-label="Expand Cockpit Radio controls" title="Expand Cockpit Radio controls">◀</button>
|
||||
<button id="cockpit-radio-toggle-btn" class="cockpit-utility-glyph" type="button" aria-expanded="false" aria-controls="cockpit-radio-panel" aria-label="Expand Cockpit Radio controls" title="Expand Cockpit Radio controls" data-i18n-aria-label="cockpit.utility.radioToggleAriaLabel" data-i18n-title="cockpit.utility.radioToggleTitle">◀</button>
|
||||
</div>
|
||||
<div id="cockpit-radio-panel" class="cockpit-utility-popover cockpit-radio-compact" role="group" aria-label="Cockpit compact Radio controls" hidden>
|
||||
<strong id="cockpit-radio-station" class="cockpit-radio-station">READY</strong>
|
||||
<button id="cockpit-radio-enable-btn" class="panel-layer-toggle" type="button" aria-pressed="false">ENABLE</button>
|
||||
<div id="cockpit-radio-panel" class="cockpit-utility-popover cockpit-radio-compact" role="group" aria-label="Cockpit compact Radio controls" hidden data-i18n-aria-label="cockpit.utility.radioPanelAriaLabel">
|
||||
<strong id="cockpit-radio-station" class="cockpit-radio-station" data-i18n="cockpit.radio.station.ready">READY</strong>
|
||||
<button id="cockpit-radio-enable-btn" class="panel-layer-toggle" type="button" aria-pressed="false" data-i18n="cockpit.radio.enable">ENABLE</button>
|
||||
<div class="cockpit-radio-transport">
|
||||
<button id="cockpit-radio-prev-btn" type="button" aria-label="Previous filtered radio station" disabled>◀◀</button>
|
||||
<button id="cockpit-radio-play-btn" type="button" aria-label="Play selected radio station" disabled>▶</button>
|
||||
<button id="cockpit-radio-next-btn" type="button" aria-label="Next filtered radio station" disabled>▶▶</button>
|
||||
<button id="cockpit-radio-prev-btn" type="button" aria-label="Previous filtered radio station" disabled data-i18n-aria-label="cockpit.radio.prevAriaLabel">◀◀</button>
|
||||
<button id="cockpit-radio-play-btn" type="button" aria-label="Play selected radio station" disabled data-i18n-aria-label="cockpit.radio.playAriaLabel">▶</button>
|
||||
<button id="cockpit-radio-next-btn" type="button" aria-label="Next filtered radio station" disabled data-i18n-aria-label="cockpit.radio.nextAriaLabel">▶▶</button>
|
||||
</div>
|
||||
<label class="cockpit-radio-volume" for="cockpit-radio-volume">
|
||||
<span>VOLUME</span>
|
||||
<input id="cockpit-radio-volume" class="gev-quantitative-slider" type="range" min="0" max="100" value="80" aria-label="Cockpit Radio volume" disabled />
|
||||
<span data-i18n="cockpit.radio.volumeLabel">VOLUME</span>
|
||||
<input id="cockpit-radio-volume" class="gev-quantitative-slider" type="range" min="0" max="100" value="80" aria-label="Cockpit Radio volume" disabled data-i18n-aria-label="cockpit.radio.volumeAriaLabel" />
|
||||
<output id="cockpit-radio-volume-value" class="gev-slider-value" for="cockpit-radio-volume">80%</output>
|
||||
</label>
|
||||
</div>
|
||||
@@ -316,13 +318,13 @@
|
||||
<div class="cockpit-help">ESC EXIT <span>·</span> C TOGGLE</div>
|
||||
</section>
|
||||
|
||||
<nav id="view-switcher" aria-label="View switcher">
|
||||
<button id="cockpit-reset-globe" type="button" aria-label="Reset cockpit to full globe view" title="Exit cockpit and return to full globe view" hidden>
|
||||
<nav id="view-switcher" aria-label="View switcher" data-i18n-aria-label="cockpit.exit.navAriaLabel">
|
||||
<button id="cockpit-reset-globe" type="button" aria-label="Reset cockpit to full globe view" title="Exit cockpit and return to full globe view" hidden data-i18n-aria-label="cockpit.exit.resetAriaLabel" data-i18n-title="cockpit.exit.resetTitle">
|
||||
<span class="material-symbols-outlined" aria-hidden="true">public</span>
|
||||
<span>RESET</span>
|
||||
<span data-i18n="cockpit.exit.resetLabel">RESET</span>
|
||||
</button>
|
||||
<button id="map-view-switch" type="button" aria-label="Exit cockpit view" aria-keyshortcuts="Escape C" title="Exit cockpit view" hidden>
|
||||
<button id="map-view-switch" type="button" aria-label="Exit cockpit view" aria-keyshortcuts="Escape C" title="Exit cockpit view" hidden data-i18n-aria-label="cockpit.exit.ariaLabel" data-i18n-title="cockpit.exit.title">
|
||||
<span class="material-symbols-outlined" aria-hidden="true">close_fullscreen</span>
|
||||
<span>EXIT COCKPIT</span>
|
||||
<span data-i18n="cockpit.exit.label">EXIT COCKPIT</span>
|
||||
</button>
|
||||
</nav>
|
||||
@@ -1,51 +1,58 @@
|
||||
<!-- Unified bottom command dock: locations, voice (injected at runtime), visual presets -->
|
||||
<div id="command-dock" aria-label="Navigation, voice, and visual preset controls">
|
||||
<div id="command-dock" aria-label="Navigation, voice, and visual preset controls" data-i18n-aria-label="shell.dock.ariaLabel">
|
||||
<!-- Control panel -->
|
||||
<div id="control-panel" class="panel-collapsible collapsed">
|
||||
<div class="panel-glow"></div>
|
||||
<div class="panel-inner">
|
||||
<button id="control-panel-toggle" class="panel-header dock-tray-toggle" type="button"
|
||||
data-dock-toggle-target="control-panel" aria-expanded="false"
|
||||
aria-controls="control-panel-popover" aria-label="Expand Visual Presets">
|
||||
aria-controls="control-panel-popover" aria-label="Expand Visual Presets" data-i18n-aria-label="cockpit.presets.toggleAriaLabel">
|
||||
<span class="panel-title"><span class="dock-label-icon dock-label-icon-presets" aria-hidden="true"></span>VISUAL PRESETS</span>
|
||||
<span class="panel-divider"></span>
|
||||
</button>
|
||||
<!-- Language selector: only the group container is static (its
|
||||
data-i18n-aria-label is applied from the catalog at boot). The
|
||||
buttons are rendered at runtime by ui.js _initLocaleSelector(): one
|
||||
per locale in the configured GEV_DEFAULT_LOCALE / GEV_SECONDARY_LOCALE
|
||||
pair plus the always-shipped English fallback, deduped, labels EN/ES/FR
|
||||
with aria-labels from shell.locale.<code>.ariaLabel. -->
|
||||
<div class="dock-locale-switch" role="group" data-i18n-aria-label="shell.locale.groupAriaLabel"></div>
|
||||
<div id="control-panel-popover" class="dock-popover-content">
|
||||
<button class="dock-pin-btn" data-pin-target="control-panel" type="button" aria-label="Pin visual presets" aria-pressed="false" title="Keep visual presets open"><img src="/pin.svg" alt="" /></button>
|
||||
<button class="dock-pin-btn" data-pin-target="control-panel" type="button" aria-label="Pin visual presets" aria-pressed="false" title="Keep visual presets open" data-i18n-aria-label="cockpit.presets.pinAriaLabel" data-i18n-title="cockpit.presets.pinTitle"><img src="/pin.svg" alt="" /></button>
|
||||
<div id="style-buttons" class="button-grid">
|
||||
<button class="style-btn active" data-style="normal" title="Show the globe without a visual filter.">
|
||||
<button class="style-btn active" data-style="normal" title="Show the globe without a visual filter." data-i18n-title="cockpit.presets.styleNormalTitle">
|
||||
<span class="btn-icon">◯</span>
|
||||
<span class="btn-label">Normal</span>
|
||||
<span class="btn-label" data-i18n="cockpit.presets.styleNormalLabel">Normal</span>
|
||||
<span class="btn-key">1</span>
|
||||
</button>
|
||||
<button class="style-btn" data-style="retro" title="Emulate a green phosphor CRT with scanlines and screen curvature.">
|
||||
<button class="style-btn" data-style="retro" title="Emulate a green phosphor CRT with scanlines and screen curvature." data-i18n-title="cockpit.presets.styleCrtTitle">
|
||||
<span class="btn-icon">▦</span>
|
||||
<span class="btn-label">CRT</span>
|
||||
<span class="btn-label" data-i18n="cockpit.presets.styleCrtLabel">CRT</span>
|
||||
<span class="btn-key">2</span>
|
||||
</button>
|
||||
<button class="style-btn" data-style="surveillance" title="Simulate night-vision goggles with green intensification and a tube vignette.">
|
||||
<button class="style-btn" data-style="surveillance" title="Simulate night-vision goggles with green intensification and a tube vignette." data-i18n-title="cockpit.presets.styleNvgTitle">
|
||||
<span class="btn-icon">🌙</span>
|
||||
<span class="btn-label">NVG</span>
|
||||
<span class="btn-label" data-i18n="cockpit.presets.styleNvgLabel">NVG</span>
|
||||
<span class="btn-key">3</span>
|
||||
</button>
|
||||
<button class="style-btn" data-style="thermal" title="Simulate FLIR-style thermal contrast. Turn up Ironbow for color.">
|
||||
<button class="style-btn" data-style="thermal" title="Simulate FLIR-style thermal contrast. Turn up Ironbow for color." data-i18n-title="cockpit.presets.styleFlirTitle">
|
||||
<span class="btn-icon">🌡️</span>
|
||||
<span class="btn-label">FLIR</span>
|
||||
<span class="btn-label" data-i18n="cockpit.presets.styleFlirLabel">FLIR</span>
|
||||
<span class="btn-key">4</span>
|
||||
</button>
|
||||
<button class="style-btn" data-style="anime" title="Apply bright cel-shaded color and illustrated outlines.">
|
||||
<button class="style-btn" data-style="anime" title="Apply bright cel-shaded color and illustrated outlines." data-i18n-title="cockpit.presets.styleAnimeTitle">
|
||||
<span class="btn-icon">✦</span>
|
||||
<span class="btn-label">Anime</span>
|
||||
<span class="btn-label" data-i18n="cockpit.presets.styleAnimeLabel">Anime</span>
|
||||
<span class="btn-key">5</span>
|
||||
</button>
|
||||
<button class="style-btn" data-style="noir" title="Apply high-contrast monochrome film-noir grading.">
|
||||
<button class="style-btn" data-style="noir" title="Apply high-contrast monochrome film-noir grading." data-i18n-title="cockpit.presets.styleNoirTitle">
|
||||
<span class="btn-icon">◐</span>
|
||||
<span class="btn-label">Noir</span>
|
||||
<span class="btn-label" data-i18n="cockpit.presets.styleNoirLabel">Noir</span>
|
||||
<span class="btn-key">6</span>
|
||||
</button>
|
||||
<button class="style-btn" data-style="snow" title="Add a cold, snowy whiteout treatment to the scene.">
|
||||
<button class="style-btn" data-style="snow" title="Add a cold, snowy whiteout treatment to the scene." data-i18n-title="cockpit.presets.styleSnowTitle">
|
||||
<span class="btn-icon">❄</span>
|
||||
<span class="btn-label">Snow</span>
|
||||
<span class="btn-label" data-i18n="cockpit.presets.styleSnowLabel">Snow</span>
|
||||
<span class="btn-key">7</span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -58,7 +65,7 @@
|
||||
</section>
|
||||
</div>
|
||||
<div id="style-mini-status" class="panel-mini-status" aria-live="polite">
|
||||
<span class="panel-mini-label">Style</span>
|
||||
<span class="panel-mini-label" data-i18n="cockpit.presets.miniStyleLabel">Style</span>
|
||||
<span id="style-mini-value" class="panel-mini-value">NORMAL</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -69,26 +76,26 @@
|
||||
<div class="panel-glow"></div>
|
||||
<div class="location-inner">
|
||||
<div class="location-toolbar">
|
||||
<button id="location-bar-toggle" class="dock-tray-toggle" type="button" data-dock-toggle-target="location-bar" aria-controls="location-bar-popover" aria-expanded="false" aria-label="Expand LOCATION" title="Expand LOCATION">
|
||||
<button id="location-bar-toggle" class="dock-tray-toggle" type="button" data-dock-toggle-target="location-bar" aria-controls="location-bar-popover" aria-expanded="false" aria-label="Expand LOCATION" title="Expand LOCATION" data-i18n-aria-label="cockpit.location.toggleAria" data-i18n-title="cockpit.location.toggleAria">
|
||||
<span class="location-toolbar-label"><span class="dock-label-icon dock-label-icon-location" aria-hidden="true"></span>LOCATION</span>
|
||||
</button>
|
||||
<button class="panel-collapse-btn" data-collapse-target="location-bar" title="Collapse panel">+</button>
|
||||
<button class="panel-collapse-btn" data-collapse-target="location-bar" title="Collapse panel" data-i18n-title="cockpit.location.collapseTitle">+</button>
|
||||
</div>
|
||||
<div id="location-mini-status" class="location-mini-status" aria-live="polite">
|
||||
<div id="location-mini-city" class="location-mini-city">📍 Location: --</div>
|
||||
<div id="location-mini-poi" class="location-mini-poi">Landmark: --</div>
|
||||
<div id="location-mini-city" class="location-mini-city" data-i18n="cockpit.location.miniCityInitial">📍 Location: --</div>
|
||||
<div id="location-mini-poi" class="location-mini-poi" data-i18n="cockpit.location.miniPoiInitial">Landmark: --</div>
|
||||
</div>
|
||||
<div id="location-bar-popover" class="dock-popover-content">
|
||||
<button class="dock-pin-btn" data-pin-target="location-bar" type="button" aria-label="Pin location tray" aria-pressed="false" title="Keep location tray open"><img src="/pin.svg" alt="" /></button>
|
||||
<button class="dock-pin-btn" data-pin-target="location-bar" type="button" aria-label="Pin location tray" aria-pressed="false" title="Keep location tray open" data-i18n-aria-label="cockpit.location.pinAriaLabel" data-i18n-title="cockpit.location.pinTitle"><img src="/pin.svg" alt="" /></button>
|
||||
<div id="poi-row" class="poi-row-container"></div>
|
||||
<div class="location-bar-divider" id="location-bar-divider"></div>
|
||||
<div class="location-city-row">
|
||||
<div id="location-pills" class="location-pills"></div>
|
||||
<div class="location-search-wrap">
|
||||
<button id="search-toggle" class="search-toggle-btn" title="Search any location">
|
||||
<button id="search-toggle" class="search-toggle-btn" title="Search any location" data-i18n-title="cockpit.location.searchToggleTitle">
|
||||
<span>🔎</span>
|
||||
</button>
|
||||
<input type="text" id="location-search" placeholder="Search any location..." autocomplete="off" spellcheck="false" aria-label="Search location by name or coordinates" />
|
||||
<input type="text" id="location-search" placeholder="Search any location..." autocomplete="off" spellcheck="false" aria-label="Search location by name or coordinates" data-i18n-aria-label="cockpit.location.searchAria" data-i18n-placeholder="cockpit.location.searchPlaceholder" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -28,112 +28,112 @@
|
||||
<div class="panel-glow"></div>
|
||||
<div class="global-context-panel-inner" data-rail-scroller>
|
||||
<div class="panel-header">
|
||||
<span class="panel-title">CONTEXT</span>
|
||||
<span class="panel-title" data-i18n="cockpit.context.panelTitle">CONTEXT</span>
|
||||
<span class="panel-divider"></span>
|
||||
<div id="context-radio-dock" class="context-radio-dock">
|
||||
<button id="context-radio-toggle-btn" class="context-radio-toggle" type="button" aria-expanded="false" aria-controls="context-radio-mini" aria-label="Open compact Radio controls" title="Open compact Radio controls">
|
||||
<button id="context-radio-toggle-btn" class="context-radio-toggle" type="button" aria-expanded="false" aria-controls="context-radio-mini" aria-label="Open compact Radio controls" title="Open compact Radio controls" data-i18n-aria-label="cockpit.context.radioToggleAriaLabel" data-i18n-title="cockpit.context.radioToggleTitle">
|
||||
<span class="material-symbols-outlined" aria-hidden="true">radio</span>
|
||||
</button>
|
||||
<div id="context-radio-mini" class="context-radio-mini" role="group" aria-label="Compact Radio controls" hidden>
|
||||
<div id="context-radio-mini" class="context-radio-mini" role="group" aria-label="Compact Radio controls" hidden data-i18n-aria-label="cockpit.context.radioMiniGroupAriaLabel">
|
||||
<div class="context-radio-mini-heading">
|
||||
<span>RADIO</span>
|
||||
<strong id="context-radio-mini-station">RADIO READY</strong>
|
||||
<span data-i18n="cockpit.context.radioMiniTitle">RADIO</span>
|
||||
<strong id="context-radio-mini-station" data-i18n="cockpit.context.radioMiniReady">RADIO READY</strong>
|
||||
<span class="context-radio-mini-actions">
|
||||
<button id="context-radio-details-btn" type="button" aria-label="Open detailed Radio controls" aria-expanded="false" aria-controls="radio-panel" title="Open detailed Radio controls">
|
||||
<button id="context-radio-details-btn" type="button" aria-label="Open detailed Radio controls" aria-expanded="false" aria-controls="radio-panel" title="Open detailed Radio controls" data-i18n-aria-label="cockpit.context.radioDetailsAriaLabel" data-i18n-title="cockpit.context.radioDetailsTitle">
|
||||
<span class="material-symbols-outlined" aria-hidden="true">open_in_full</span>
|
||||
</button>
|
||||
<button id="context-radio-mini-close-btn" type="button" aria-label="Close compact Radio controls" title="Close compact Radio controls">
|
||||
<button id="context-radio-mini-close-btn" type="button" aria-label="Close compact Radio controls" title="Close compact Radio controls" data-i18n-aria-label="cockpit.context.radioCloseAriaLabel" data-i18n-title="cockpit.context.radioCloseTitle">
|
||||
<span class="material-symbols-outlined" aria-hidden="true">close</span>
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
<button id="context-radio-mini-enable-btn" class="panel-layer-toggle" type="button" aria-pressed="false">ENABLE</button>
|
||||
<button id="context-radio-mini-enable-btn" class="panel-layer-toggle" type="button" aria-pressed="false" data-i18n="cockpit.context.radioMiniEnable">ENABLE</button>
|
||||
<div class="context-radio-mini-transport">
|
||||
<button id="context-radio-mini-prev-btn" type="button" aria-label="Previous filtered radio station" title="Previous station" disabled>◀◀</button>
|
||||
<button id="context-radio-mini-play-btn" type="button" aria-label="Play selected radio station" title="Play" disabled>▶</button>
|
||||
<button id="context-radio-mini-next-btn" type="button" aria-label="Next filtered radio station" title="Next station" disabled>▶▶</button>
|
||||
<button id="context-radio-mini-prev-btn" type="button" aria-label="Previous filtered radio station" title="Previous station" disabled data-i18n-aria-label="cockpit.context.radioMiniPrevAriaLabel" data-i18n-title="cockpit.context.radioMiniPrevTitle">◀◀</button>
|
||||
<button id="context-radio-mini-play-btn" type="button" aria-label="Play selected radio station" title="Play" disabled data-i18n-aria-label="cockpit.context.radioMiniPlayAriaLabel" data-i18n-title="cockpit.context.radioMiniPlayTitle">▶</button>
|
||||
<button id="context-radio-mini-next-btn" type="button" aria-label="Next filtered radio station" title="Next station" disabled data-i18n-aria-label="cockpit.context.radioMiniNextAriaLabel" data-i18n-title="cockpit.context.radioMiniNextTitle">▶▶</button>
|
||||
</div>
|
||||
<label class="context-radio-mini-volume" for="context-radio-mini-volume">
|
||||
<span>VOLUME</span>
|
||||
<input id="context-radio-mini-volume" class="gev-quantitative-slider" type="range" min="0" max="100" value="80" aria-label="Compact Radio volume" disabled />
|
||||
<span data-i18n="cockpit.context.radioMiniVolumeLabel">VOLUME</span>
|
||||
<input id="context-radio-mini-volume" class="gev-quantitative-slider" type="range" min="0" max="100" value="80" aria-label="Compact Radio volume" disabled data-i18n-aria-label="cockpit.context.radioMiniVolumeAriaLabel" />
|
||||
<output id="context-radio-mini-volume-value" class="gev-slider-value" for="context-radio-mini-volume">80%</output>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<button class="panel-collapse-btn" data-collapse-target="global-context-panel" title="Expand panel">▶</button>
|
||||
<button class="panel-collapse-btn" data-collapse-target="global-context-panel" title="Expand panel" data-i18n-title="cockpit.context.collapseTitle">▶</button>
|
||||
</div>
|
||||
<div class="cyber-panel-body" data-rail-scroller>
|
||||
<div class="global-context-modes" role="tablist" aria-label="Context mode">
|
||||
<button id="global-context-flights-btn" class="context-mode-button" type="button" role="tab" tabindex="0" aria-selected="false" aria-controls="context-flights-view" aria-label="CONTACTS" title="Cycles the nearest contacts of whatever type you select — planes, vessels, installations. Satellites track independently.">
|
||||
<div class="global-context-modes" role="tablist" aria-label="Context mode" data-i18n-aria-label="cockpit.context.modesAriaLabel">
|
||||
<button id="global-context-flights-btn" class="context-mode-button" type="button" role="tab" tabindex="0" aria-selected="false" 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="cockpit.context.contactsTabAriaLabel" data-i18n-title="cockpit.context.contactsTabTitle">
|
||||
<span class="material-symbols-outlined" aria-hidden="true">radar</span>
|
||||
<span>CONTACTS</span>
|
||||
<span data-i18n="cockpit.context.contactsTabLabel">CONTACTS</span>
|
||||
</button>
|
||||
<button id="global-context-missions-btn" class="context-mode-button" type="button" role="tab" tabindex="0" aria-selected="false" aria-controls="context-missions-view">
|
||||
<span class="material-symbols-outlined" aria-hidden="true">rocket_launch</span>
|
||||
<span>SPACE MISSIONS</span>
|
||||
<span data-i18n="cockpit.context.missionsTabLabel">SPACE MISSIONS</span>
|
||||
</button>
|
||||
</div>
|
||||
<div id="context-mode-standby" class="context-mode-standby">
|
||||
<strong>SELECT CONTEXT</strong>
|
||||
<strong data-i18n="cockpit.context.standbyTitle">SELECT CONTEXT</strong>
|
||||
<span>CONTACTS — nearest planes · vessels · sites<br>SPACE MISSIONS — launches & orbital assets</span>
|
||||
</div>
|
||||
<section id="context-flights-view" class="context-mode-view" role="tabpanel" aria-labelledby="global-context-flights-btn" hidden>
|
||||
<div class="global-context-actions" aria-label="Contact Context actions">
|
||||
<div class="global-context-actions" aria-label="Contact Context actions" data-i18n-aria-label="cockpit.context.actionsAriaLabel">
|
||||
<button id="cockpit-entry" type="button" hidden>
|
||||
<span class="material-symbols-outlined" aria-hidden="true">flight</span>
|
||||
<span>COCKPIT</span>
|
||||
<span data-i18n="cockpit.context.cockpitEntryLabel">COCKPIT</span>
|
||||
</button>
|
||||
<button id="installations-search-btn" class="panel-layer-toggle" type="button">SEARCH NEARBY SITES</button>
|
||||
<button id="tr3b-toggle" class="tr3b-toggle" type="button" aria-pressed="false" hidden aria-label="Reclassify tracked contact as TR-3B" title="Reclassify as TR-3B">🛸</button>
|
||||
<button id="installations-search-btn" class="panel-layer-toggle" type="button" data-i18n="cockpit.context.searchNearbyLabel">SEARCH NEARBY SITES</button>
|
||||
<button id="tr3b-toggle" class="tr3b-toggle" type="button" aria-pressed="false" hidden aria-label="Reclassify tracked contact as TR-3B" title="Reclassify as TR-3B" data-i18n-aria-label="cockpit.context.tr3bAriaLabel" data-i18n-title="cockpit.context.tr3bTitle">🛸</button>
|
||||
</div>
|
||||
<aside id="military-awareness-panel">
|
||||
<div class="military-awareness-standby">
|
||||
<strong>CONTACTS CONTEXT OFF</strong>
|
||||
<span>SELECT CONTACTS TO LOAD OBSERVED / MAPPED PROXIMITY</span>
|
||||
<strong data-i18n="cockpit.context.awarenessOffTitle">CONTACTS CONTEXT OFF</strong>
|
||||
<span data-i18n="cockpit.context.awarenessOffHint">SELECT CONTACTS TO LOAD OBSERVED / MAPPED PROXIMITY</span>
|
||||
</div>
|
||||
</aside>
|
||||
</section>
|
||||
<section id="context-missions-view" class="context-mode-view" role="tabpanel" aria-labelledby="global-context-missions-btn" hidden>
|
||||
<div id="space-mission-panel-host">
|
||||
<div id="space-mission-roster" class="space-mission-roster" aria-label="Available Space Missions">
|
||||
<div id="space-mission-roster" class="space-mission-roster" aria-label="Available Space Missions" data-i18n-aria-label="cockpit.context.rosterAriaLabel">
|
||||
<div class="space-mission-roster-header">
|
||||
<div>
|
||||
<strong>AVAILABLE MISSIONS</strong>
|
||||
<span>SELECT A MISSION TO INSPECT</span>
|
||||
<strong data-i18n="cockpit.context.rosterTitle">AVAILABLE MISSIONS</strong>
|
||||
<span data-i18n="cockpit.context.rosterHint">SELECT A MISSION TO INSPECT</span>
|
||||
</div>
|
||||
<output data-mission-roster-count>—</output>
|
||||
</div>
|
||||
<div class="space-mission-roster-list" data-mission-roster-list>
|
||||
<div class="space-mission-roster-empty">LOADING 30-DAY MISSION INDEX</div>
|
||||
<div class="space-mission-roster-empty" data-i18n="cockpit.context.rosterLoading">LOADING 30-DAY MISSION INDEX</div>
|
||||
</div>
|
||||
<p class="space-mission-roster-focus-continuation" tabindex="-1" data-mission-roster-focus-continuation>TAB PREVIEWS · ENTER / SPACE SELECTS</p>
|
||||
<p class="space-mission-roster-focus-continuation" tabindex="-1" data-mission-roster-focus-continuation data-i18n="layers.missions.roster.keyboardHint">TAB PREVIEWS · ENTER / SPACE SELECTS</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section id="radio-panel" class="panel-collapsible collapsed" data-panel-id="radio-panel" aria-label="Internet radio and local SDR">
|
||||
<section id="radio-panel" class="panel-collapsible collapsed" data-panel-id="radio-panel" aria-label="Internet radio and local SDR" data-i18n-aria-label="layers.radio.panelAriaLabel">
|
||||
<div class="radio-panel-inner">
|
||||
<div class="panel-header">
|
||||
<span class="panel-title">RADIO</span>
|
||||
<span class="panel-title" data-i18n="layers.radio.panelTitle">RADIO</span>
|
||||
<span id="radio-layer-state" class="radio-layer-state">OFF</span>
|
||||
<span class="panel-divider"></span>
|
||||
<button class="panel-collapse-btn" data-collapse-target="radio-panel" title="Expand Radio" aria-label="Expand Radio section">+</button>
|
||||
<button class="panel-collapse-btn" data-collapse-target="radio-panel" title="Expand Radio" aria-label="Expand Radio section" data-i18n-title="layers.radio.expandTitle" data-i18n-aria-label="layers.radio.expandAriaLabel">+</button>
|
||||
</div>
|
||||
<div class="radio-directory-row">
|
||||
<button id="radio-enable-btn" class="panel-layer-toggle" type="button" aria-pressed="false">ENABLE</button>
|
||||
<label class="radio-filter-label" for="radio-filter">STATION TAG</label>
|
||||
<select id="radio-filter" aria-label="Filter stations by station tag" disabled>
|
||||
<option value="all">All</option>
|
||||
<button id="radio-enable-btn" class="panel-layer-toggle" type="button" aria-pressed="false" data-i18n="layers.radio.enable">ENABLE</button>
|
||||
<label class="radio-filter-label" for="radio-filter" data-i18n="layers.radio.stationTagLabel">STATION TAG</label>
|
||||
<select id="radio-filter" aria-label="Filter stations by station tag" disabled data-i18n-aria-label="layers.radio.filterAriaLabel">
|
||||
<option value="all" data-i18n="layers.radio.filterAll">All</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="radio-station-card" class="radio-station-card" aria-live="polite">
|
||||
<strong id="radio-station-name">NO STATION SELECTED</strong>
|
||||
<span id="radio-station-meta">Enable Radio, then choose a globe marker or use next.</span>
|
||||
<strong id="radio-station-name" data-i18n="layers.radio.noStation">NO STATION SELECTED</strong>
|
||||
<span id="radio-station-meta" data-i18n="layers.radio.stationHint">Enable Radio, then choose a globe marker or use next.</span>
|
||||
<span id="radio-station-tags"></span>
|
||||
</div>
|
||||
<div id="radio-tuner" class="radio-tuner" hidden>
|
||||
<div class="radio-tuner-header">
|
||||
<span id="radio-tuner-band-label">DIRECTORY BAND</span>
|
||||
<output id="radio-tuner-value" for="radio-tuner-slider">DRAG TO TUNE</output>
|
||||
<output id="radio-tuner-value" for="radio-tuner-slider" data-i18n="layers.radio.dragToTune">DRAG TO TUNE</output>
|
||||
</div>
|
||||
<div class="radio-tuner-dial">
|
||||
<div class="radio-tuner-scale" aria-hidden="true"></div>
|
||||
@@ -142,26 +142,27 @@
|
||||
aria-label="Tune available internet radio stations" aria-valuetext="No station available" disabled />
|
||||
</div>
|
||||
<div class="radio-tuner-readout">
|
||||
<span id="radio-tuner-station">ALL · DRAG THE NEEDLE</span>
|
||||
<span>SNAPS TO AVAILABLE STATIONS</span>
|
||||
<span id="radio-tuner-station" data-i18n="layers.radio.tunerIdle">ALL · DRAG THE NEEDLE</span>
|
||||
<span data-i18n="layers.radio.snapsNote">SNAPS TO AVAILABLE STATIONS</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="radio-transport" role="group" aria-label="Radio playback">
|
||||
<button id="radio-prev-btn" type="button" aria-label="Previous filtered station" disabled>PREV</button>
|
||||
<button id="radio-play-btn" type="button" aria-label="Play selected station" disabled>PLAY</button>
|
||||
<button id="radio-next-btn" type="button" aria-label="Next filtered station" disabled>NEXT</button>
|
||||
<button id="radio-stop-btn" type="button" aria-label="Stop radio playback" disabled>STOP</button>
|
||||
<div class="radio-transport" role="group" aria-label="Radio playback" data-i18n-aria-label="layers.radio.transportAriaLabel">
|
||||
<button id="radio-prev-btn" type="button" aria-label="Previous filtered station" disabled data-i18n-aria-label="layers.radio.prevAriaLabel" data-i18n="layers.radio.prev">PREV</button>
|
||||
<button id="radio-play-btn" type="button" aria-label="Play selected station" disabled data-i18n-aria-label="layers.radio.playAriaLabel" data-i18n="layers.radio.play">PLAY</button>
|
||||
<button id="radio-next-btn" type="button" aria-label="Next filtered station" disabled data-i18n-aria-label="layers.radio.nextAriaLabel" data-i18n="layers.radio.next">NEXT</button>
|
||||
<button id="radio-stop-btn" type="button" aria-label="Stop radio playback" disabled data-i18n-aria-label="layers.radio.stopAriaLabel" data-i18n="layers.radio.stop">STOP</button>
|
||||
</div>
|
||||
<label class="radio-volume-row" for="radio-volume">
|
||||
<span>VOLUME</span>
|
||||
<input id="radio-volume" class="gev-quantitative-slider" type="range" min="0" max="100" value="80" aria-label="Radio volume" />
|
||||
<span data-i18n="layers.radio.volumeLabel">VOLUME</span>
|
||||
<input id="radio-volume" class="gev-quantitative-slider" type="range" min="0" max="100" value="80" aria-label="Radio volume" data-i18n-aria-label="layers.radio.volumeAriaLabel" />
|
||||
<output id="radio-volume-value" class="gev-slider-value" for="radio-volume">80%</output>
|
||||
</label>
|
||||
<div id="radio-playback-state" class="radio-playback-state" role="status" aria-live="polite">Radio off</div>
|
||||
<div id="radio-playback-state" class="radio-playback-state" role="status" aria-live="polite" data-i18n="layers.radio.playbackOff">Radio off</div>
|
||||
<div class="radio-links">
|
||||
<a id="radio-station-homepage" href="#" target="_blank" rel="noopener noreferrer" hidden>STATION SITE</a>
|
||||
<a id="radio-station-homepage" href="#" target="_blank" rel="noopener noreferrer" hidden data-i18n="layers.radio.stationSite">STATION SITE</a>
|
||||
<a href="https://www.radio-browser.info/" target="_blank" rel="noopener noreferrer">DIRECTORY: RADIO BROWSER</a>
|
||||
</div>
|
||||
<p class="radio-privacy" data-i18n="layers.radio.privacyNote">Audio connects directly to the broadcaster after you press play. Your IP is visible to that broadcaster.</p>
|
||||
<p class="radio-privacy">Audio connects directly to the broadcaster after you press play. Your IP is visible to that broadcaster.</p>
|
||||
<div id="sdr-radio-card" class="sdr-radio-card" role="group" aria-label="Local RTL-SDR receiver">
|
||||
<div class="sdr-radio-heading">
|
||||
|
||||
@@ -3,20 +3,20 @@
|
||||
<div class="compact pp-header-row">
|
||||
<span class="pp-header-label">DISPLAY</span>
|
||||
<span class="panel-divider" aria-hidden="true"></span>
|
||||
<button class="panel-collapse-btn pp-collapse-btn" data-collapse-target="pp-toggles" title="Collapse panel">◀</button>
|
||||
<button class="panel-collapse-btn pp-collapse-btn" data-collapse-target="pp-toggles" title="Collapse panel" data-i18n-title="cockpit.display.collapseTitle">◀</button>
|
||||
</div>
|
||||
<div class="pp-panel-body" data-rail-scroller>
|
||||
<div class="pp-toggle-group">
|
||||
<button class="pp-toggle-btn" id="hud-toggle" title="Intelligence HUD (H)">
|
||||
<button class="pp-toggle-btn" id="hud-toggle" title="Intelligence HUD (H)" data-i18n-title="cockpit.display.hudToggleTitle">
|
||||
<span class="pp-icon">◎</span>
|
||||
<span class="pp-label">HUD</span>
|
||||
</button>
|
||||
<div class="pp-slider-row" id="hud-layout-row">
|
||||
<span class="pp-slider-mini-label">Layout</span>
|
||||
<select id="hud-layout-select" class="pp-select" aria-label="HUD layout">
|
||||
<option value="tactical">Tactical</option>
|
||||
<option value="operator">Operator</option>
|
||||
<option value="minimal">Minimal</option>
|
||||
<span class="pp-slider-mini-label" data-i18n="cockpit.display.hudLayoutLabel">Layout</span>
|
||||
<select id="hud-layout-select" class="pp-select" aria-label="HUD layout" data-i18n-aria-label="cockpit.display.hudLayoutAriaLabel">
|
||||
<option value="tactical" data-i18n="cockpit.display.hudLayoutTactical">Tactical</option>
|
||||
<option value="operator" data-i18n="cockpit.display.hudLayoutOperator">Operator</option>
|
||||
<option value="minimal" data-i18n="cockpit.display.hudLayoutMinimal">Minimal</option>
|
||||
<option value="cyber">Cyber</option>
|
||||
</select>
|
||||
</div>
|
||||
@@ -52,39 +52,39 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="pp-toggle-group">
|
||||
<button class="pp-toggle-btn" id="detection-toggle" title="Detection Overlay (D)" aria-label="Detection overlay" aria-pressed="false">
|
||||
<button class="pp-toggle-btn" id="detection-toggle" title="Detection Overlay (D)" aria-label="Detection overlay" aria-pressed="false" data-i18n-title="cockpit.display.detectionToggleTitle" data-i18n-aria-label="cockpit.display.detectionAriaLabel">
|
||||
<span class="pp-icon">▣</span>
|
||||
<span class="pp-label">DETECT</span>
|
||||
<span class="pp-label" data-i18n="cockpit.display.detectionLabel">DETECT</span>
|
||||
</button>
|
||||
<div class="pp-slider-row detection-slider-row" id="detection-slider-row">
|
||||
<label class="pp-slider-mini-label" for="detection-density-slider">Density</label>
|
||||
<input type="range" class="pp-slider" id="detection-density-slider" min="0" max="100" step="25" value="50" aria-label="Detection label density" />
|
||||
<label class="pp-slider-mini-label" for="detection-density-slider" data-i18n="cockpit.display.densityLabel">Density</label>
|
||||
<input type="range" class="pp-slider" id="detection-density-slider" min="0" max="100" step="25" value="50" aria-label="Detection label density" data-i18n-aria-label="cockpit.display.densityAriaLabel" />
|
||||
<span class="pp-slider-value" id="detection-density-value">50%</span>
|
||||
</div>
|
||||
<div class="pp-slider-row detection-allocation-row" id="detection-allocation-row">
|
||||
<span class="pp-slider-mini-label">Allocation</span>
|
||||
<div class="pp-mode-seg detection-allocation-seg" role="radiogroup" aria-label="Detection label allocation">
|
||||
<button class="pp-mode-btn active" id="detection-allocation-elastic" data-allocation="ELASTIC" role="radio" aria-checked="true">Elastic</button>
|
||||
<button class="pp-mode-btn" id="detection-allocation-weighted" data-allocation="WEIGHTED" role="radio" aria-checked="false">Weighted</button>
|
||||
<span class="pp-slider-mini-label" data-i18n="cockpit.display.allocationLabel">Allocation</span>
|
||||
<div class="pp-mode-seg detection-allocation-seg" role="radiogroup" aria-label="Detection label allocation" data-i18n-aria-label="cockpit.display.allocationAriaLabel">
|
||||
<button class="pp-mode-btn active" id="detection-allocation-elastic" data-allocation="ELASTIC" role="radio" aria-checked="true" data-i18n="cockpit.display.allocationElastic">Elastic</button>
|
||||
<button class="pp-mode-btn" id="detection-allocation-weighted" data-allocation="WEIGHTED" role="radio" aria-checked="false" data-i18n="cockpit.display.allocationWeighted">Weighted</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pp-slider-row detection-fade-row" id="detection-fade-row">
|
||||
<label class="pp-slider-mini-label" for="detection-fade-slider">Fade</label>
|
||||
<input type="range" class="pp-slider" id="detection-fade-slider" min="0" max="40" step="1" value="7" aria-label="Detection fade distance" title="World-overlay fade distance outside the keyhole as a percentage of its radius" />
|
||||
<label class="pp-slider-mini-label" for="detection-fade-slider" data-i18n="cockpit.display.fadeLabel">Fade</label>
|
||||
<input type="range" class="pp-slider" id="detection-fade-slider" min="0" max="40" step="1" value="7" aria-label="Detection fade distance" title="World-overlay fade distance outside the keyhole as a percentage of its radius" data-i18n-aria-label="cockpit.display.fadeAriaLabel" data-i18n-title="cockpit.display.fadeTitle" />
|
||||
<span class="pp-slider-value" id="detection-fade-value">7%</span>
|
||||
</div>
|
||||
<div class="pp-slider-row detection-opacity-row" id="detection-opacity-row">
|
||||
<label class="pp-slider-mini-label" for="detection-opacity-slider">Outside</label>
|
||||
<input type="range" class="pp-slider" id="detection-opacity-slider" min="0" max="100" step="1" value="1" aria-label="Detection opacity outside the keyhole" title="World-overlay label and card opacity beyond the fade distance" />
|
||||
<label class="pp-slider-mini-label" for="detection-opacity-slider" data-i18n="cockpit.display.outsideLabel">Outside</label>
|
||||
<input type="range" class="pp-slider" id="detection-opacity-slider" min="0" max="100" step="1" value="1" aria-label="Detection opacity outside the keyhole" title="World-overlay label and card opacity beyond the fade distance" data-i18n-aria-label="cockpit.display.outsideAriaLabel" data-i18n-title="cockpit.display.outsideTitle" />
|
||||
<span class="pp-slider-value" id="detection-opacity-value">1%</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- The one shared preset-parameter surface is inserted here at runtime. -->
|
||||
<div id="param-slider-panel">
|
||||
<div class="param-panel-header">
|
||||
<span class="param-panel-title">PARAMETERS</span>
|
||||
<span class="param-panel-title" data-i18n="cockpit.display.parametersTitle">PARAMETERS</span>
|
||||
<span class="param-panel-divider"></span>
|
||||
<button class="panel-collapse-btn" id="param-panel-collapse-btn" data-collapse-target="param-slider-panel" title="Collapse panel">−</button>
|
||||
<button class="panel-collapse-btn" id="param-panel-collapse-btn" data-collapse-target="param-slider-panel" title="Collapse panel" data-i18n-title="cockpit.display.parametersCollapseTitle">−</button>
|
||||
</div>
|
||||
<div id="param-sliders"></div>
|
||||
</div>
|
||||
@@ -93,31 +93,31 @@
|
||||
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. -->
|
||||
<button class="pp-toggle-btn active" id="models3d-toggle" aria-pressed="true" title="3D aircraft — flat icons zoomed out, 3D models up close">
|
||||
<button class="pp-toggle-btn active" id="models3d-toggle" aria-pressed="true" title="3D aircraft — flat icons zoomed out, 3D models up close" data-i18n-title="cockpit.display.modelsToggleTitle">
|
||||
<span class="pp-icon">✈</span>
|
||||
<span class="pp-label">3D</span>
|
||||
</button>
|
||||
<div class="pp-slider-row visible" id="models3d-mode-row">
|
||||
<span class="pp-slider-mini-label">Models</span>
|
||||
<div class="pp-mode-seg" role="radiogroup" aria-label="3D model coverage">
|
||||
<button class="pp-mode-btn active" id="models3d-mode-proximity" data-mode="proximity" role="radio" aria-checked="true">Proximity</button>
|
||||
<button class="pp-mode-btn" id="models3d-mode-all" data-mode="all" role="radio" aria-checked="false">All</button>
|
||||
<span class="pp-slider-mini-label" data-i18n="cockpit.display.modelsLabel">Models</span>
|
||||
<div class="pp-mode-seg" role="radiogroup" aria-label="3D model coverage" data-i18n-aria-label="cockpit.display.modelsCoverageAriaLabel">
|
||||
<button class="pp-mode-btn active" id="models3d-mode-proximity" data-mode="proximity" role="radio" aria-checked="true" data-i18n="cockpit.display.modelsModeProximity">Proximity</button>
|
||||
<button class="pp-mode-btn" id="models3d-mode-all" data-mode="all" role="radio" aria-checked="false" data-i18n="cockpit.display.modelsModeAll">All</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pp-toggle-group">
|
||||
<button class="pp-toggle-btn active" id="scope-toggle" title="Scope — the circular viewport mask" aria-pressed="true">
|
||||
<button class="pp-toggle-btn active" id="scope-toggle" title="Scope — the circular viewport mask" aria-pressed="true" data-i18n-title="cockpit.display.scopeToggleTitle">
|
||||
<span class="pp-icon material-symbols-outlined" aria-hidden="true">adjust</span>
|
||||
<span class="pp-label">Scope</span>
|
||||
<span class="pp-label" data-i18n="cockpit.display.scopeLabel">Scope</span>
|
||||
</button>
|
||||
<div class="pp-slider-row visible" id="scope-slider-row">
|
||||
<span class="pp-slider-mini-label">Feather</span>
|
||||
<span class="pp-slider-mini-label" data-i18n="cockpit.display.featherLabel">Feather</span>
|
||||
<!-- Feather starts at an 11% soft scope-mask edge (owner final lock
|
||||
2026-08-24, superseding the 08-22 hard-crop and 08-23 8% rulings);
|
||||
mirrors SCOPE_FEATHER_RATIO_DEFAULT in src/scopeMask.js. Feather
|
||||
softens the black mask edge; label fading is the separate Fade
|
||||
slider. -->
|
||||
<input type="range" class="pp-slider" id="scope-feather-slider" min="0" max="100" value="11" aria-label="Scope edge feather" title="Scope edge feather as a percentage of the keyhole radius" />
|
||||
<input type="range" class="pp-slider" id="scope-feather-slider" min="0" max="100" value="11" aria-label="Scope edge feather" title="Scope edge feather as a percentage of the keyhole radius" data-i18n-aria-label="cockpit.display.featherAria" data-i18n-title="cockpit.display.featherTitle" />
|
||||
<span class="pp-slider-value" id="scope-feather-value">11%</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -148,35 +148,35 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="pp-toggle-group">
|
||||
<button class="pp-toggle-btn" id="celestial-toggle" title="Celestial ring — reveal the full globe" aria-pressed="false">
|
||||
<button class="pp-toggle-btn" id="celestial-toggle" title="Celestial ring — reveal the full globe" aria-pressed="false" data-i18n-title="cockpit.display.celestialToggleTitle">
|
||||
<span class="pp-icon material-symbols-outlined" aria-hidden="true">flare</span>
|
||||
<span class="pp-label">Celestial</span>
|
||||
<span class="pp-label" data-i18n="cockpit.display.celestialLabel">Celestial</span>
|
||||
</button>
|
||||
</div>
|
||||
<button class="pp-toggle-btn" id="clean-view-toggle" title="Hide UI chrome">
|
||||
<button class="pp-toggle-btn" id="clean-view-toggle" title="Hide UI chrome" data-i18n-title="cockpit.display.cleanViewToggleTitle">
|
||||
<span class="pp-icon">□</span>
|
||||
<span class="pp-label">Clean UI</span>
|
||||
<span class="pp-label" data-i18n="cockpit.display.cleanViewLabel">Clean UI</span>
|
||||
</button>
|
||||
<div class="pp-toggle-group">
|
||||
<button class="pp-toggle-btn" id="bloom-toggle" title="Bloom / Glow">
|
||||
<button class="pp-toggle-btn" id="bloom-toggle" title="Bloom / Glow" data-i18n-title="cockpit.display.bloomToggleTitle">
|
||||
<span class="pp-icon">✨</span>
|
||||
<span class="pp-label">Bloom</span>
|
||||
<span class="pp-label" data-i18n="cockpit.display.bloomLabel">Bloom</span>
|
||||
</button>
|
||||
<div class="pp-slider-row" id="bloom-slider-row">
|
||||
<input type="range" class="pp-slider" id="bloom-intensity-slider" min="0" max="200" value="0" aria-label="Bloom intensity" />
|
||||
<input type="range" class="pp-slider" id="bloom-intensity-slider" min="0" max="200" value="0" aria-label="Bloom intensity" data-i18n-aria-label="cockpit.display.bloomAria" />
|
||||
<span class="pp-slider-value" id="bloom-intensity-value">0%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pp-toggle-group">
|
||||
<button class="pp-toggle-btn" id="sharpen-toggle" title="Sharpening">
|
||||
<button class="pp-toggle-btn" id="sharpen-toggle" title="Sharpening" data-i18n-title="cockpit.display.sharpenToggleTitle">
|
||||
<span class="pp-icon">🔍</span>
|
||||
<span class="pp-label">Sharpen</span>
|
||||
<span class="pp-label" data-i18n="cockpit.display.sharpenLabel">Sharpen</span>
|
||||
</button>
|
||||
<div class="pp-slider-row" id="sharpen-slider-row">
|
||||
<input type="range" class="pp-slider" id="sharpen-intensity-slider" min="0" max="100" value="49" aria-label="Sharpen intensity" />
|
||||
<input type="range" class="pp-slider" id="sharpen-intensity-slider" min="0" max="100" value="49" aria-label="Sharpen intensity" data-i18n-aria-label="cockpit.display.sharpenAria" />
|
||||
<span class="pp-slider-value" id="sharpen-intensity-value">49%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button id="clean-view-exit" title="Return UI controls">EXIT CLEAN VIEW</button>
|
||||
<button id="clean-view-exit" title="Return UI controls" data-i18n-title="cockpit.display.cleanViewExitTitle" data-i18n="cockpit.display.cleanViewExitLabel">EXIT CLEAN VIEW</button>
|
||||
@@ -6,6 +6,6 @@
|
||||
<div class="loader-content">
|
||||
<span class="loader-logo brand-logo" data-logo-gaze data-logo-src="/logo.svg" aria-hidden="true"><img src="/logo.svg" alt="" /></span>
|
||||
<h2>GOD'S EYE <span class="title-accent">VIEW</span></h2>
|
||||
<p class="loader-status">Initializing photorealistic world...</p>
|
||||
<p class="loader-status" data-i18n="shell.loading.initialStatus">Initializing photorealistic world...</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -4,9 +4,9 @@
|
||||
<div class="panel-glow"></div>
|
||||
<div class="data-panel-inner">
|
||||
<div class="panel-header">
|
||||
<span class="panel-title">DATA LAYERS</span>
|
||||
<span class="panel-title" data-i18n="shell.panels.dataLayers">DATA LAYERS</span>
|
||||
<span class="panel-divider"></span>
|
||||
<button class="panel-collapse-btn" data-collapse-target="data-panel" title="Collapse panel">+</button>
|
||||
<button class="panel-collapse-btn" data-collapse-target="data-panel" title="Collapse panel" data-i18n-title="shell.panels.collapseTitle">+</button>
|
||||
</div>
|
||||
<div id="data-toggles" class="data-toggle-list"></div>
|
||||
</div>
|
||||
@@ -17,56 +17,56 @@
|
||||
<div class="panel-glow"></div>
|
||||
<div class="cctv-panel-inner" data-rail-scroller>
|
||||
<div class="panel-header">
|
||||
<span class="panel-title">CCTV</span>
|
||||
<span class="panel-title" data-i18n="layers.cctv.panelTitle">CCTV</span>
|
||||
<span class="panel-divider"></span>
|
||||
<button class="panel-collapse-btn" data-collapse-target="cctv-panel" title="Collapse panel">+</button>
|
||||
<button class="panel-collapse-btn" data-collapse-target="cctv-panel" title="Collapse panel" data-i18n-title="layers.cctv.collapseTitle">+</button>
|
||||
</div>
|
||||
<div class="cyber-panel-body" data-rail-scroller>
|
||||
<div id="cctv-frame-wrap" class="cctv-frame-wrap">
|
||||
<img id="cctv-frame" alt="CCTV feed frame" />
|
||||
<canvas id="cctv-video" class="cctv-frame" hidden></canvas>
|
||||
</div>
|
||||
<div id="cctv-source-badge" role="status" aria-live="polite">SOURCE · UNKNOWN</div>
|
||||
<div id="cctv-meta">Enable CCTV to load camera intersections</div>
|
||||
<div id="cctv-source-badge" role="status" aria-live="polite" data-i18n="layers.cctv.sourceUnknown">SOURCE · UNKNOWN</div>
|
||||
<div id="cctv-meta" data-i18n="layers.cctv.metaIdle">Enable CCTV to load camera intersections</div>
|
||||
<div class="cctv-controls">
|
||||
<button id="cctv-enable-btn" class="scene-btn">CCTV OFF</button>
|
||||
<button id="cctv-nearest-btn" class="scene-btn">NEAREST</button>
|
||||
<button id="cctv-enable-btn" class="scene-btn" data-i18n="layers.cctv.toggleOff">CCTV OFF</button>
|
||||
<button id="cctv-nearest-btn" class="scene-btn" data-i18n="layers.cctv.nearest">NEAREST</button>
|
||||
</div>
|
||||
<div class="cctv-controls">
|
||||
<button id="cctv-prev-btn" class="scene-btn">PREV</button>
|
||||
<select id="cctv-camera-select" aria-label="CCTV camera"></select>
|
||||
<button id="cctv-next-btn" class="scene-btn">NEXT</button>
|
||||
<button id="cctv-prev-btn" class="scene-btn" data-i18n="layers.cctv.prev">PREV</button>
|
||||
<select id="cctv-camera-select" aria-label="CCTV camera" data-i18n-aria-label="layers.cctv.cameraAriaLabel"></select>
|
||||
<button id="cctv-next-btn" class="scene-btn" data-i18n="layers.cctv.next">NEXT</button>
|
||||
</div>
|
||||
<div class="cctv-controls">
|
||||
<button id="cctv-focus-btn" class="scene-btn">FOCUS</button>
|
||||
<button id="cctv-coverage-btn" class="scene-btn">COVERAGE OFF</button>
|
||||
<button id="cctv-auto-hop-btn" class="scene-btn">AUTO HOP OFF</button>
|
||||
<button id="cctv-focus-btn" class="scene-btn" data-i18n="layers.cctv.focus">FOCUS</button>
|
||||
<button id="cctv-coverage-btn" class="scene-btn" data-i18n="layers.cctv.coverageOff">COVERAGE OFF</button>
|
||||
<button id="cctv-auto-hop-btn" class="scene-btn" data-i18n="layers.cctv.autoHopOff">AUTO HOP OFF</button>
|
||||
</div>
|
||||
<div class="cctv-controls">
|
||||
<button id="cctv-projection-btn" class="scene-btn">PROJECTION ON</button>
|
||||
<button id="cctv-projection-btn" class="scene-btn" data-i18n="layers.cctv.projectionOn">PROJECTION ON</button>
|
||||
<span id="cctv-quality-chip">CAL · --</span>
|
||||
</div>
|
||||
<div class="cctv-calibration-block">
|
||||
<div class="cctv-calibration-header">
|
||||
<div class="cctv-summary-label">CALIBRATION</div>
|
||||
<button id="cctv-adjust-btn" class="scene-btn" title="Drag the camera in the world: rings rotate, arrows move, handles set range/FOV">ADJUST</button>
|
||||
<div class="cctv-summary-label" data-i18n="layers.cctv.calibrationLabel">CALIBRATION</div>
|
||||
<button id="cctv-adjust-btn" class="scene-btn" title="Drag the camera in the world: rings rotate, arrows move, handles set range/FOV" data-i18n-title="layers.cctv.adjustTitle" data-i18n="layers.cctv.adjustLabel">ADJUST</button>
|
||||
</div>
|
||||
<div id="cctv-cal-readout" class="cctv-cal-readout" aria-label="Camera pose — click a value to type">
|
||||
<button class="cctv-cal-value" data-cal-field="heading" title="Heading (compass °) — click to type">HDG --</button>
|
||||
<button class="cctv-cal-value" data-cal-field="pitch" title="Pitch (° up/down) — click to type">PITCH --</button>
|
||||
<button class="cctv-cal-value" data-cal-field="fov" title="Horizontal FOV (°) — click to type">FOV --</button>
|
||||
<button class="cctv-cal-value" data-cal-field="range" title="Range / monitor-plane distance (m) — click to type">RANGE --</button>
|
||||
<button class="cctv-cal-value" data-cal-field="height" title="Mount height above ground (m) — click to type">HGT --</button>
|
||||
<button class="cctv-cal-value" data-cal-field="north" title="North offset from catalog position (m) — click to type">ΔN --</button>
|
||||
<button class="cctv-cal-value" data-cal-field="east" title="East offset from catalog position (m) — click to type">ΔE --</button>
|
||||
<div id="cctv-cal-readout" class="cctv-cal-readout" aria-label="Camera pose — click a value to type" data-i18n-aria-label="layers.cctv.calPoseAriaLabel">
|
||||
<button class="cctv-cal-value" data-cal-field="heading" title="Heading (compass °) — click to type" data-i18n-title="layers.cctv.calHeadingTitle">HDG --</button>
|
||||
<button class="cctv-cal-value" data-cal-field="pitch" title="Pitch (° up/down) — click to type" data-i18n-title="layers.cctv.calPitchTitle">PITCH --</button>
|
||||
<button class="cctv-cal-value" data-cal-field="fov" title="Horizontal FOV (°) — click to type" data-i18n-title="layers.cctv.calFovTitle">FOV --</button>
|
||||
<button class="cctv-cal-value" data-cal-field="range" title="Range / monitor-plane distance (m) — click to type" data-i18n-title="layers.cctv.calRangeTitle">RANGE --</button>
|
||||
<button class="cctv-cal-value" data-cal-field="height" title="Mount height above ground (m) — click to type" data-i18n-title="layers.cctv.calHeightTitle">HGT --</button>
|
||||
<button class="cctv-cal-value" data-cal-field="north" title="North offset from catalog position (m) — click to type" data-i18n-title="layers.cctv.calNorthTitle">ΔN --</button>
|
||||
<button class="cctv-cal-value" data-cal-field="east" title="East offset from catalog position (m) — click to type" data-i18n-title="layers.cctv.calEastTitle">ΔE --</button>
|
||||
</div>
|
||||
<div class="cctv-controls cctv-calibration-actions">
|
||||
<button id="cctv-calib-save-btn" class="scene-btn">SAVE CAL</button>
|
||||
<button id="cctv-calib-reset-btn" class="scene-btn">RESET CAL</button>
|
||||
<button id="cctv-calib-save-btn" class="scene-btn" data-i18n="layers.cctv.saveCal">SAVE CAL</button>
|
||||
<button id="cctv-calib-reset-btn" class="scene-btn" data-i18n="layers.cctv.resetCal">RESET CAL</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cctv-summary-label">SCENE SUMMARY</div>
|
||||
<div id="cctv-summary">Enable CCTV to start camera-linked intelligence summaries.</div>
|
||||
<div class="cctv-summary-label" data-i18n="layers.cctv.summaryLabel">SCENE SUMMARY</div>
|
||||
<div id="cctv-summary" data-i18n="layers.cctv.summaryIdle">Enable CCTV to start camera-linked intelligence summaries.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -76,35 +76,35 @@
|
||||
<div class="panel-glow"></div>
|
||||
<div class="scene-panel-inner">
|
||||
<div class="panel-header">
|
||||
<span class="panel-title">SCENES</span>
|
||||
<span class="panel-title" data-i18n="setup.scenes.panelTitle">SCENES</span>
|
||||
<span class="panel-divider"></span>
|
||||
<button class="panel-collapse-btn" data-collapse-target="scene-panel" title="Collapse panel">+</button>
|
||||
<button class="panel-collapse-btn" data-collapse-target="scene-panel" title="Collapse panel" data-i18n-title="setup.scenes.collapseTitle">+</button>
|
||||
</div>
|
||||
<div class="scene-controls">
|
||||
<select id="scene-select" aria-label="Scene recipe"></select>
|
||||
<button id="scene-new-btn" class="scene-btn">NEW</button>
|
||||
<button id="scene-delete-btn" class="scene-btn scene-btn-danger">DEL</button>
|
||||
<select id="scene-select" aria-label="Scene recipe" data-i18n-aria-label="setup.scenes.recipeAriaLabel"></select>
|
||||
<button id="scene-new-btn" class="scene-btn" data-i18n="setup.scenes.new">NEW</button>
|
||||
<button id="scene-delete-btn" class="scene-btn scene-btn-danger" data-i18n="setup.scenes.delete">DEL</button>
|
||||
</div>
|
||||
<div class="scene-controls">
|
||||
<button id="scene-capture-btn" class="scene-btn">CAPTURE SHOT</button>
|
||||
<button id="scene-update-shot-btn" class="scene-btn">UPDATE SHOT</button>
|
||||
<button id="scene-capture-btn" class="scene-btn" data-i18n="setup.scenes.capture">CAPTURE SHOT</button>
|
||||
<button id="scene-update-shot-btn" class="scene-btn" data-i18n="setup.scenes.updateShot">UPDATE SHOT</button>
|
||||
</div>
|
||||
<div id="scene-shot-list" class="scene-shot-list"></div>
|
||||
<div class="scene-controls">
|
||||
<button id="scene-start-btn" class="scene-btn">START</button>
|
||||
<button id="scene-stop-btn" class="scene-btn scene-btn-danger">STOP</button>
|
||||
<button id="scene-next-btn" class="scene-btn">NEXT</button>
|
||||
<button id="scene-start-btn" class="scene-btn" data-i18n="setup.scenes.start">START</button>
|
||||
<button id="scene-stop-btn" class="scene-btn scene-btn-danger" data-i18n="setup.scenes.stop">STOP</button>
|
||||
<button id="scene-next-btn" class="scene-btn" data-i18n="setup.scenes.next">NEXT</button>
|
||||
</div>
|
||||
<div class="scene-controls">
|
||||
<button id="scene-export-btn" class="scene-btn">EXPORT PRESETS</button>
|
||||
<button id="scene-import-btn" class="scene-btn">IMPORT</button>
|
||||
<button id="scene-download-btn" class="scene-btn">RUN LOG</button>
|
||||
<button id="scene-export-btn" class="scene-btn" data-i18n="setup.scenes.exportPresets">EXPORT PRESETS</button>
|
||||
<button id="scene-import-btn" class="scene-btn" data-i18n="setup.scenes.import">IMPORT</button>
|
||||
<button id="scene-download-btn" class="scene-btn" data-i18n="setup.scenes.runLog">RUN LOG</button>
|
||||
<input type="file" id="scene-import-file" accept="application/json,.json" hidden />
|
||||
</div>
|
||||
<div class="scene-progress">
|
||||
<div id="scene-progress-fill">0%</div>
|
||||
</div>
|
||||
<div id="scene-status">Ready</div>
|
||||
<div id="scene-status" data-i18n="setup.scenes.statusReady">Ready</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -4,22 +4,22 @@
|
||||
that payload, so the key registry lives in src/keySetupCore.mjs alone. -->
|
||||
<button id="key-setup-chip" type="button" hidden aria-haspopup="dialog" aria-controls="key-setup">
|
||||
<span class="material-symbols-outlined" aria-hidden="true">bolt</span>
|
||||
<span data-key-setup-chip-label>POWER UP</span>
|
||||
<span data-key-setup-chip-label data-i18n="setup.keySetup.chip">POWER UP</span>
|
||||
</button>
|
||||
<aside id="key-setup" role="dialog" aria-labelledby="key-setup-title" aria-describedby="key-setup-description" hidden>
|
||||
<div class="key-setup-scanline" aria-hidden="true"></div>
|
||||
<header class="key-setup-header">
|
||||
<span class="key-setup-kicker">GROUND STATION · PROVIDER SETTINGS</span>
|
||||
<button type="button" class="key-setup-close" data-key-setup-close aria-label="Close key setup">
|
||||
<span class="key-setup-kicker" data-i18n="setup.keySetup.kicker">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="setup.keySetup.closeAriaLabel">
|
||||
<span class="material-symbols-outlined" aria-hidden="true">close</span>
|
||||
</button>
|
||||
</header>
|
||||
<h2 id="key-setup-title">Power up the globe</h2>
|
||||
<p id="key-setup-description">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" data-i18n="setup.keySetup.title">Power up the globe</h2>
|
||||
<p id="key-setup-description" data-i18n="setup.keySetup.description">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" data-key-setup-rows></div>
|
||||
<div class="key-setup-footer">
|
||||
<button type="button" class="key-setup-apply" data-key-setup-apply>SAVE KEYS</button>
|
||||
<span class="key-setup-hint">ESC to close</span>
|
||||
<button type="button" class="key-setup-apply" data-key-setup-apply data-i18n="setup.keySetup.apply">SAVE KEYS</button>
|
||||
<span class="key-setup-hint" data-i18n="setup.keySetup.hint">ESC to close</span>
|
||||
</div>
|
||||
<p class="key-setup-note" data-key-setup-status role="status" aria-live="polite">The Google Maps key buys the photorealistic planet — everything else stacks on top.</p>
|
||||
<p class="key-setup-note" data-key-setup-status role="status" aria-live="polite" data-i18n="setup.keySetup.note">The Google Maps key buys the photorealistic planet — everything else stacks on top.</p>
|
||||
</aside>
|
||||
@@ -2,7 +2,7 @@
|
||||
<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">
|
||||
<div id="world-overlay-actions" class="world-overlay-accessibility" role="region" aria-label="Visible map targets" data-i18n-aria-label="layers.overlay.regionAriaLabel">
|
||||
<div id="world-overlay-action-list"></div>
|
||||
<div id="world-overlay-status" role="status" aria-live="polite" aria-atomic="true"></div>
|
||||
</div>
|
||||
@@ -11,21 +11,21 @@
|
||||
<div id="title-bar">
|
||||
<div class="title-glow"></div>
|
||||
<h1><span class="title-logo brand-logo" data-logo-gaze data-logo-src="/logo.svg" aria-hidden="true"><img src="/logo.svg" alt="" /></span> <span>GOD'S EYE <span class="title-accent">VIEW</span></span></h1>
|
||||
<p class="subtitle">NO PLACE LEFT BEHIND</p>
|
||||
<p class="subtitle" data-i18n="shell.title.subtitle">NO PLACE LEFT BEHIND</p>
|
||||
</div>
|
||||
|
||||
<!-- Style indicator -->
|
||||
<div id="style-indicator">
|
||||
<span class="indicator-label">ACTIVE STYLE</span>
|
||||
<span class="indicator-label" data-i18n="cockpit.presets.activeStyleLabel">ACTIVE STYLE</span>
|
||||
<span class="indicator-value" id="active-style-name">NORMAL</span>
|
||||
</div>
|
||||
|
||||
<!-- Top-center camera and share actions -->
|
||||
<nav id="top-center-actions" aria-label="Globe actions">
|
||||
<button id="clear-selected-layers" type="button" aria-label="Clear selected data layers" title="Turn off all selected data layers">
|
||||
<nav id="top-center-actions" aria-label="Globe actions" data-i18n-aria-label="shell.actions.navAriaLabel">
|
||||
<button id="clear-selected-layers" type="button" aria-label="Clear selected data layers" title="Turn off all selected data layers" data-i18n-aria-label="shell.actions.clearLayers.ariaLabel" data-i18n-title="shell.actions.clearLayers.title">
|
||||
<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">
|
||||
<button id="share-btn" type="button" aria-label="Copy share link" title="Copy share link" data-i18n-aria-label="shell.actions.share.ariaLabel" data-i18n-title="shell.actions.share.title">
|
||||
<span class="share-icon" aria-hidden="true">🔗</span>
|
||||
</button>
|
||||
<button id="tilt-map-view" type="button" aria-label="Tilt map to oblique view" aria-pressed="false" title="Toggle straight-down and tilted map views">
|
||||
@@ -35,20 +35,20 @@
|
||||
<span class="camera-compass-n" aria-hidden="true">N</span>
|
||||
<span class="camera-compass-needle material-symbols-outlined" aria-hidden="true">navigation</span>
|
||||
</button>
|
||||
<button id="reset-globe-view" type="button" aria-label="Reset to full globe view" title="Reset camera and return to full globe view">
|
||||
<button id="reset-globe-view" type="button" aria-label="Reset to full globe view" title="Reset camera and return to full globe view" data-i18n-aria-label="shell.actions.resetView.ariaLabel" data-i18n-title="shell.actions.resetView.title">
|
||||
<span class="material-symbols-outlined" aria-hidden="true">public</span>
|
||||
</button>
|
||||
</nav>
|
||||
<div id="global-loading-status" role="status" aria-live="polite" aria-atomic="true" hidden>
|
||||
<span id="global-loading-label">LOADING LIVE DATA</span>
|
||||
<span id="global-loading-label" data-i18n="shell.status.loadingLiveData">LOADING LIVE DATA</span>
|
||||
<span id="global-loading-detail"></span>
|
||||
</div>
|
||||
<div id="traffic-sync-chip" aria-live="polite">
|
||||
<span id="traffic-sync-label">syncing road network</span>
|
||||
<span id="traffic-sync-label" data-i18n="shell.status.trafficSyncing">syncing road network</span>
|
||||
<span id="traffic-sync-progress">0%</span>
|
||||
</div>
|
||||
<div id="cctv-sync-chip" aria-live="polite">
|
||||
<span id="cctv-sync-label">loading frames</span>
|
||||
<span id="cctv-sync-label" data-i18n="shell.status.framesLoading">loading frames</span>
|
||||
<span id="cctv-sync-progress">0/0</span>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -5,9 +5,9 @@
|
||||
<aside id="first-run-launcher" role="dialog" aria-labelledby="first-run-title" aria-describedby="first-run-description" hidden>
|
||||
<div class="first-run-scanline" aria-hidden="true"></div>
|
||||
<header class="first-run-header">
|
||||
<span class="first-run-kicker">MISSION CONTROL · FIRST LAUNCH</span>
|
||||
<span class="first-run-kicker" data-i18n="setup.firstRun.kicker">MISSION CONTROL · FIRST LAUNCH</span>
|
||||
</header>
|
||||
<h2 id="first-run-title">Choose your first view</h2>
|
||||
<h2 id="first-run-title" data-i18n="setup.firstRun.title">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
|
||||
@@ -16,12 +16,12 @@
|
||||
<div class="first-run-choices">
|
||||
<button type="button" data-first-run-choice="contacts">
|
||||
<span class="material-symbols-outlined" aria-hidden="true">radar</span>
|
||||
<span><strong>LIVE CONTACTS</strong><small>Aircraft, vessels and nearby intelligence</small></span>
|
||||
<span><strong data-i18n="setup.firstRun.choice.contacts">LIVE CONTACTS</strong><small data-i18n="setup.firstRun.choice.contactsSub">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">
|
||||
<span class="material-symbols-outlined" aria-hidden="true">rocket_launch</span>
|
||||
<span><strong>SPACE MISSIONS</strong><small>Launches, spacecraft and orbital context</small></span>
|
||||
<span><strong data-i18n="setup.firstRun.choice.spaceMissions">SPACE MISSIONS</strong><small data-i18n="setup.firstRun.choice.spaceMissionsSub">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">
|
||||
@@ -35,18 +35,18 @@
|
||||
</button>
|
||||
<button type="button" class="first-run-explore" data-first-run-choice="explore">
|
||||
<span class="material-symbols-outlined" aria-hidden="true">public</span>
|
||||
<span><strong>EXPLORE MANUALLY</strong><small>Begin with a clean globe</small></span>
|
||||
<span data-i18n="setup.firstRun.choice.explore" data-i18n="setup.firstRun.choice.exploreSub"><strong>EXPLORE MANUALLY</strong><small>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 />
|
||||
<span>Don't show this again</span>
|
||||
<span data-i18n="setup.firstRun.suppress">Don't show this again</span>
|
||||
</label>
|
||||
<span>ESC to dismiss</span>
|
||||
<span data-i18n="setup.firstRun.dismissHint">ESC to dismiss</span>
|
||||
</div>
|
||||
<!-- Kept out of the footer row: it swaps to mission progress and errors, and
|
||||
a live region that also holds layout makes the row jump as it speaks. -->
|
||||
<p class="first-run-note" data-first-run-status role="status" aria-live="polite">Tip: the GEV MIC button in the dock lets you talk to the map.</p>
|
||||
<p class="first-run-note" data-first-run-status role="status" aria-live="polite" data-i18n="setup.firstRun.note">Tip: the GEV MIC button in the dock lets you talk to the map.</p>
|
||||
</aside>
|
||||
@@ -1,4 +1,5 @@
|
||||
import { STYLE_STATUS_LABELS } from './visualPresets.js';
|
||||
import { t } from '../i18n/index.js';
|
||||
import { UiLifetime } from './uiLifetime.js';
|
||||
import {
|
||||
VisualEffects,
|
||||
@@ -1165,8 +1166,8 @@ export class VisualSettings {
|
||||
this._celestialBtn.disabled = !styleSupported;
|
||||
this._celestialBtn.setAttribute('aria-disabled', String(!styleSupported));
|
||||
this._celestialBtn.title = styleSupported
|
||||
? 'Celestial ring — reveal the full globe'
|
||||
: 'Celestial ring — available in Normal style';
|
||||
? t('cockpit.display.celestialToggleTitle')
|
||||
: t('cockpit.display.celestialUnavailableTitle');
|
||||
}
|
||||
let cameraFocused = false;
|
||||
if (nextEnabled && focus) {
|
||||
@@ -1609,21 +1610,31 @@ export class VisualSettings {
|
||||
btn.setAttribute(
|
||||
'aria-label',
|
||||
enabled
|
||||
? `Detection overlay: ${String(modeLabel).toLowerCase()}`
|
||||
: 'Detection overlay: off',
|
||||
? t('cockpit.display.detectionAriaTemplate', {
|
||||
mode: String(modeLabel).toLowerCase(),
|
||||
})
|
||||
: t('cockpit.display.detectionAriaOff'),
|
||||
);
|
||||
btn.classList.remove('active', 'god', 'panoptic');
|
||||
if (modeLabel === 'SPARSE') {
|
||||
btn.querySelector('.pp-label').textContent = 'SPARSE';
|
||||
btn.querySelector('.pp-label').textContent = t(
|
||||
'cockpit.display.detectionLabelSparse',
|
||||
);
|
||||
btn.classList.add('active');
|
||||
} else if (modeLabel === 'BALANCED') {
|
||||
btn.querySelector('.pp-label').textContent = 'BALANCED';
|
||||
btn.querySelector('.pp-label').textContent = t(
|
||||
'cockpit.display.detectionLabelBalanced',
|
||||
);
|
||||
btn.classList.add('active');
|
||||
} else if (modeLabel === 'DENSE') {
|
||||
btn.querySelector('.pp-label').textContent = 'DENSE';
|
||||
btn.querySelector('.pp-label').textContent = t(
|
||||
'cockpit.display.detectionLabelDense',
|
||||
);
|
||||
btn.classList.add('active', 'panoptic');
|
||||
} else {
|
||||
btn.querySelector('.pp-label').textContent = 'DETECT';
|
||||
btn.querySelector('.pp-label').textContent = t(
|
||||
'cockpit.display.detectionLabel',
|
||||
);
|
||||
}
|
||||
|
||||
if (this._detectionSliderRow) {
|
||||
|
||||
+13
-11
@@ -1,3 +1,5 @@
|
||||
import { t } from '../i18n/index.js';
|
||||
|
||||
/** Build the voice control independently of its connection backend. */
|
||||
export function createVoiceControl({ reset = false } = {}) {
|
||||
let root = document.getElementById('gev-voice-control');
|
||||
@@ -12,14 +14,14 @@ export function createVoiceControl({ reset = false } = {}) {
|
||||
root.dataset.speaker = 'idle';
|
||||
root.innerHTML = `
|
||||
<div class="gev-voice-heading">
|
||||
<div class="gev-voice-kicker">AI AGENT</div>
|
||||
<div id="gev-voice-status">OFF</div>
|
||||
<div class="gev-voice-kicker">${t('setup.voice.kicker.agent')}</div>
|
||||
<div id="gev-voice-status">${t('setup.voice.status.idle')}</div>
|
||||
<div class="gev-voice-cost">
|
||||
<button id="gev-voice-tier" class="gev-voice-tier-btn" type="button" aria-pressed="false" title="Voice model tier — applies next session">STD</button>
|
||||
<span id="gev-voice-cost-value" class="gev-voice-cost-value" data-level="ok" title="Estimated session cost">~$0.00</span>
|
||||
<button id="gev-voice-tier" class="gev-voice-tier-btn" type="button" aria-pressed="false" title="${t('setup.voice.tier.buttonTitle')}">STD</button>
|
||||
<span id="gev-voice-cost-value" class="gev-voice-cost-value" data-level="ok" title="${t('setup.voice.cost.buttonTitle')}">~$0.00</span>
|
||||
</div>
|
||||
</div>
|
||||
<button id="gev-voice-button" type="button" aria-label="Voice control — activate to toggle voice; hold Space to speak" aria-describedby="gev-voice-help">
|
||||
<button id="gev-voice-button" type="button" aria-label="${t('setup.voice.button.ariaLabel')}" aria-describedby="gev-voice-help">
|
||||
<span class="gev-mic-orbit"><img src="/mic.svg" alt="" /></span>
|
||||
<span class="gev-mic-label">ON/OFF</span>
|
||||
</button>
|
||||
@@ -27,19 +29,19 @@ export function createVoiceControl({ reset = false } = {}) {
|
||||
${Array.from({ length: 15 }, (_, index) => `<span style="--bar:${index}"></span>`).join('')}
|
||||
</div>
|
||||
<div class="gev-voice-readout">
|
||||
<div id="gev-voice-detail">VOICE STANDBY</div>
|
||||
<div id="gev-voice-detail">${t('setup.voice.detail.standby')}</div>
|
||||
</div>
|
||||
<div id="gev-voice-help" class="gev-voice-help-tray" role="tooltip">
|
||||
<span class="gev-voice-help-kicker">VOICE CONTROL</span>
|
||||
<span class="gev-voice-help-detail">Hold Space to speak · tap Space to activate focused controls</span>
|
||||
<span class="gev-voice-help-kicker">${t('setup.voice.kicker.control')}</span>
|
||||
<span class="gev-voice-help-detail">${t('setup.voice.hint.default')}</span>
|
||||
</div>
|
||||
<div class="gev-voice-error-tray" role="alert" aria-live="assertive">
|
||||
<div class="gev-voice-error-header">
|
||||
<span>VOICE SYSTEM ERROR</span>
|
||||
<button class="gev-voice-error-dismiss" type="button">DISMISS</button>
|
||||
<span>${t('setup.voice.error.trayTitle')}</span>
|
||||
<button class="gev-voice-error-dismiss" type="button">${t('setup.voice.error.dismiss')}</button>
|
||||
</div>
|
||||
<div id="gev-voice-error-detail"></div>
|
||||
<div class="gev-voice-error-hint">Check microphone permission and network access, then try again.</div>
|
||||
<div class="gev-voice-error-hint">${t('setup.voice.error.hint')}</div>
|
||||
</div>
|
||||
`;
|
||||
const commandDock = document.getElementById('command-dock');
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { t } from '../i18n/index.js';
|
||||
|
||||
const DISCONNECT_GRACE_MS = 6000;
|
||||
|
||||
function releaseStartResources({ localStream = null, localPc = null } = {}) {
|
||||
@@ -59,7 +61,7 @@ export class RealtimeConnection {
|
||||
this.input.pushToTalkKeyHeld = pushToTalkKeyHeld;
|
||||
this.input.spaceKeyHeld = spaceKeyHeld;
|
||||
if (!window.RTCPeerConnection || !navigator.mediaDevices?.getUserMedia) {
|
||||
this.setStatus('error', 'WebRTC microphone support unavailable');
|
||||
this.setStatus('error', t('setup.voice.detail.microphoneUnavailable'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -78,7 +80,7 @@ export class RealtimeConnection {
|
||||
// — this is what "applies next session" means.
|
||||
this.cost.prepareSession();
|
||||
this.syncCostUi();
|
||||
this.setStatus('connecting', 'Requesting microphone');
|
||||
this.setStatus('connecting', t('setup.voice.detail.requestingMicrophone'));
|
||||
this.debugLog('session.starting', {
|
||||
epoch,
|
||||
tier: this.cost.voiceTier,
|
||||
@@ -176,9 +178,9 @@ export class RealtimeConnection {
|
||||
if (!ownsChannel()) return;
|
||||
const detail = this.input.pushToTalkMode
|
||||
? this.input.pushToTalkKeyHeld
|
||||
? 'Release Space to send'
|
||||
: 'Hold Space to talk'
|
||||
: 'Ask or command';
|
||||
? t('setup.voice.detail.releaseSpaceSend')
|
||||
: t('setup.voice.detail.holdSpaceTalk')
|
||||
: t('setup.voice.detail.askOrCommand');
|
||||
this.setStatus('listening', detail);
|
||||
this.debugLog('data_channel.open', {
|
||||
connection: this.connectionDiagnostics(dataChannel),
|
||||
|
||||
@@ -44,13 +44,17 @@ export {
|
||||
|
||||
import { createRealtimeBackend } from './realtimeBackend.js';
|
||||
|
||||
const STATUS = {
|
||||
idle: 'OFF',
|
||||
connecting: 'CONNECTING',
|
||||
listening: 'LISTENING',
|
||||
executing: 'EXECUTING',
|
||||
error: 'ERROR',
|
||||
};
|
||||
import { t } from '../i18n/index.js';
|
||||
|
||||
// Status enum VALUES are machine identifiers (dataset.status, CSS hooks);
|
||||
// only the LABEL rendered from them localizes, resolved at write time via t().
|
||||
const STATUS_LABEL_KEYS = Object.freeze({
|
||||
idle: 'setup.voice.status.idle',
|
||||
connecting: 'setup.voice.status.connecting',
|
||||
listening: 'setup.voice.status.listening',
|
||||
executing: 'setup.voice.status.executing',
|
||||
error: 'setup.voice.status.error',
|
||||
});
|
||||
|
||||
/** Compose voice state owners and coordinate ordered session startup/teardown. */
|
||||
export class GevRealtimeController extends RealtimeFacade {
|
||||
@@ -267,7 +271,7 @@ export class GevRealtimeController extends RealtimeFacade {
|
||||
this.ui.root.remove();
|
||||
}
|
||||
if (!preserveStatus && !removeUi) {
|
||||
this.setStatus('idle', 'Voice off');
|
||||
this.setStatus('idle', t('setup.voice.detail.voiceOff'));
|
||||
}
|
||||
this.setRadioVoiceDucking(false);
|
||||
if (removeUi) this.emitSessionEvent({ type: 'disposed' });
|
||||
@@ -287,24 +291,28 @@ export class GevRealtimeController extends RealtimeFacade {
|
||||
this.ui.root.dataset.status = status;
|
||||
if (status === 'error') this.ui.root.classList.remove('error-dismissed');
|
||||
this.updateVoiceButtonLabel();
|
||||
this.ui.status.textContent = STATUS[status] || STATUS.idle;
|
||||
this.ui.status.textContent = t(
|
||||
STATUS_LABEL_KEYS[status] || STATUS_LABEL_KEYS.idle,
|
||||
);
|
||||
const resolvedDetail =
|
||||
status === 'listening' && this.pushToTalkMode
|
||||
? this.pushToTalkKeyHeld
|
||||
? 'Release Space to send'
|
||||
: 'Hold Space to talk'
|
||||
? t('setup.voice.detail.releaseSpaceSend')
|
||||
: t('setup.voice.detail.holdSpaceTalk')
|
||||
: detail;
|
||||
const primaryDetail =
|
||||
status === 'error'
|
||||
? 'VOICE UNAVAILABLE'
|
||||
? t('setup.voice.detail.unavailable')
|
||||
: resolvedDetail ||
|
||||
(status === 'idle' ? 'VOICE STANDBY' : 'VOICE ACTIVE');
|
||||
(status === 'idle'
|
||||
? t('setup.voice.detail.standby')
|
||||
: t('setup.voice.detail.active'));
|
||||
this.ui.detail.textContent = primaryDetail;
|
||||
this.ui.detail.title = primaryDetail;
|
||||
if (this.ui.errorDetail) {
|
||||
this.ui.errorDetail.textContent =
|
||||
status === 'error'
|
||||
? resolvedDetail || 'Voice session could not be started.'
|
||||
? resolvedDetail || t('setup.voice.error.sessionStart')
|
||||
: '';
|
||||
}
|
||||
if (status === 'idle' || status === 'connecting' || status === 'error') {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { t } from '../i18n/index.js';
|
||||
import {
|
||||
readStoredVoiceTier,
|
||||
readStoredVoiceLimits,
|
||||
@@ -50,18 +51,25 @@ export class RealtimeCost {
|
||||
const pendingId = resolveVoiceModel(pendingTier).id;
|
||||
this.ui.tierButton.title =
|
||||
this.isActive() && state.modelId !== pendingId
|
||||
? `Next session: ${pendingId} — this session stays on ${state.modelId}`
|
||||
: `Voice model: ${pendingId} — click to switch to ${
|
||||
isMini ? 'standard' : 'mini'
|
||||
}; applies next session`;
|
||||
? t('setup.voice.tierNextSession', {
|
||||
pendingId,
|
||||
modelId: state.modelId,
|
||||
})
|
||||
: t('setup.voice.tierSwitchHint', {
|
||||
pendingId,
|
||||
target: isMini ? 'standard' : 'mini',
|
||||
});
|
||||
}
|
||||
if (this.ui?.costValue) {
|
||||
this.ui.costValue.textContent = state.display;
|
||||
this.ui.costValue.dataset.level = state.level;
|
||||
this.ui.costValue.title =
|
||||
`Estimated session cost on ${state.modelId} — ${state.responses} response(s). ` +
|
||||
`Warns at ${formatCostUsd(state.warnUsd)}, ends the session at ${formatCostUsd(state.capUsd)}.` +
|
||||
(state.note ? ` ${state.note}` : '');
|
||||
t('setup.voice.costTooltip', {
|
||||
modelId: state.modelId,
|
||||
responses: state.responses,
|
||||
warn: formatCostUsd(state.warnUsd),
|
||||
cap: formatCostUsd(state.capUsd),
|
||||
}) + (state.note ? ` ${state.note}` : '');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,7 +112,9 @@ export class RealtimeCost {
|
||||
if (this.isActive() && this.ui?.detail) {
|
||||
this.setStatus(
|
||||
this.status,
|
||||
`${this.voiceTier.toUpperCase()} applies next session`,
|
||||
t('setup.voice.tier.appliesNextSession', {
|
||||
tier: this.voiceTier.toUpperCase(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
return this.voiceTier;
|
||||
@@ -182,7 +192,10 @@ export class RealtimeCost {
|
||||
try {
|
||||
this.stop({ preserveStatus: true });
|
||||
} finally {
|
||||
this.setStatus('idle', `Session ended — cost cap ${state.display}`);
|
||||
this.setStatus(
|
||||
'idle',
|
||||
t('setup.voice.status.sessionCostCap', { cost: state.display }),
|
||||
);
|
||||
this.syncCostUi();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { t } from '../i18n/index.js';
|
||||
import {
|
||||
PUSH_TO_TALK_HOLD_DELAY_MS,
|
||||
MICROPHONE_VISUALIZER_GATE,
|
||||
@@ -108,7 +109,10 @@ export class RealtimeInput {
|
||||
this.ui.root.dataset.pushToTalk = 'held';
|
||||
this.setMicrophoneEnabled(true);
|
||||
if (this.status === 'listening')
|
||||
this.setStatus('listening', 'Release Space to send');
|
||||
this.setStatus(
|
||||
'listening',
|
||||
t('setup.voice.detail.releaseSpaceSend'),
|
||||
);
|
||||
} else {
|
||||
this.start({ pushToTalk: true });
|
||||
// start() performs a controlled stop() before connecting. Restore the
|
||||
@@ -181,7 +185,7 @@ export class RealtimeInput {
|
||||
if (!this.pushToTalkMode) return;
|
||||
this.setMicrophoneEnabled(false);
|
||||
if (this.status === 'listening')
|
||||
this.setStatus('listening', 'Hold Space to talk');
|
||||
this.setStatus('listening', t('setup.voice.detail.holdSpaceTalk'));
|
||||
else this.updateVoiceButtonLabel();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { t } from '../i18n/index.js';
|
||||
|
||||
export const PUSH_TO_TALK_HOLD_DELAY_MS = 500;
|
||||
|
||||
// The input meter is intentionally stricter than the assistant-output meter:
|
||||
@@ -150,8 +152,8 @@ export function resolveVoiceVisualizerSpeaker(
|
||||
*/
|
||||
export function resolveVoiceControlHint(pushToTalkMode, pushToTalkKeyHeld) {
|
||||
return pushToTalkMode && pushToTalkKeyHeld
|
||||
? 'Release Space to send'
|
||||
: 'Hold Space to speak · tap Space to activate focused controls';
|
||||
? t('setup.voice.detail.releaseSpaceSend')
|
||||
: t('setup.voice.hint.default');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { t } from '../i18n/index.js';
|
||||
import {
|
||||
silenceRadioForVoice,
|
||||
startPreparedRadioAfterPlaybackReady,
|
||||
@@ -245,7 +246,7 @@ export class RealtimeRadio {
|
||||
if (radioHandoff.result?.ok || radioHandoff.cancelled || !stillCurrent)
|
||||
return;
|
||||
if (this.dc?.readyState === 'open' && !this.userTurnPending) {
|
||||
this.setStatus('listening', 'Radio did not start');
|
||||
this.setStatus('listening', t('setup.voice.detail.radioDidNotStart'));
|
||||
this.queueResponseCreate(
|
||||
'Say exactly one short correction: “The Radio station could not start. Voice is still on.”',
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { realtimeSessionEvent } from './realtimeEvents.js';
|
||||
import { t } from '../i18n/index.js';
|
||||
import { readLayerLifecycleSummary } from './layerSummary.js';
|
||||
import {
|
||||
CALL_DEDUPE_MS,
|
||||
@@ -207,7 +208,7 @@ export class RealtimeTurns {
|
||||
eventId: payload.event_id,
|
||||
activeResponseMessage: payload.error?.message || null,
|
||||
});
|
||||
this.setStatus('listening', 'Ask or command');
|
||||
this.setStatus('listening', t('setup.voice.detail.askOrCommand'));
|
||||
return;
|
||||
}
|
||||
// A conversation.item.delete for a stale viewport screenshot can land
|
||||
@@ -327,7 +328,7 @@ export class RealtimeTurns {
|
||||
return;
|
||||
}
|
||||
|
||||
this.setStatus('executing', 'Running command');
|
||||
this.setStatus('executing', t('setup.voice.detail.runningCommand'));
|
||||
this.pruneProcessedCalls();
|
||||
let sentOutput = false;
|
||||
let lastResult = null;
|
||||
@@ -577,7 +578,7 @@ export class RealtimeTurns {
|
||||
),
|
||||
);
|
||||
}
|
||||
this.setStatus('listening', 'Ask or command');
|
||||
this.setStatus('listening', t('setup.voice.detail.askOrCommand'));
|
||||
}
|
||||
|
||||
sendToolOutput(callId, result) {
|
||||
@@ -642,7 +643,7 @@ export class RealtimeTurns {
|
||||
// connection is still live. Recover to listening so the user can retry
|
||||
// (mirrors the transient-blip philosophy, H8).
|
||||
if (this.dc?.readyState === 'open') {
|
||||
this.setStatus('listening', 'Ask or command');
|
||||
this.setStatus('listening', t('setup.voice.detail.askOrCommand'));
|
||||
}
|
||||
}
|
||||
if (!this.radio.pendingRadioPlaybackResult) {
|
||||
|
||||
Reference in new issue
Block a user