refactor(voice): isolate gemini live provider core

This commit is contained in:
manjunath committed 2026-10-19 21:40:40 +05:30
commit 10916227cb
655 files changed
+45946 -26483

No files matched your search

+156 -2
View File
@@ -37,6 +37,16 @@ import { ShellFeedback } from './shellFeedback.js';
import { runCctvLayerEnableTransition } from '../cctvFocusPolicy.js';
import {
describeUnsavedWork,
getLocale,
msg,
persistLocaleAndReload,
registerUnsavedWork,
t,
} from '../i18n/index.js';
import { applyLocaleChoice, mountLocalePicker } from './localePicker.js';
/**
* Central UI orchestrator for the God's Eye View application.
*
@@ -566,7 +576,11 @@ export class StyleManager extends ShellFacade {
});
this._initUI();
this._initMapStackControl();
// Headers first: panel chrome builds its toggle labels from them.
this._applyRuntimeStaticHeaderText();
this._initPanelChrome();
this._initLocaleSelector();
this._sizeDisplayLabelColumn();
this._initLeftPanelAdaptiveLayout();
this._initRightPanelAdaptiveLayout();
this._initRadioPanel();
@@ -700,6 +714,141 @@ export class StyleManager extends ShellFacade {
this._displayBindings._initUI();
}
/**
* Mounts the language picker (src/ui/localePicker.js). A choice reloads the
* page; _confirmLocaleReload asks first when that would lose work.
* @returns {void}
*/
_initLocaleSelector() {
// Work this shell can see that a reload drops. Other modules register
// their own (registerUnsavedWork in src/i18n/index.js).
this._unsavedWorkChecks = [
registerUnsavedWork(() =>
this.services?.cctvLayer
?.getUIState?.()
?.cameras?.some((camera) => camera.calDirty)
? msg('unsaved CCTV calibration')
: null,
),
registerUnsavedWork(() =>
this.cockpitView?.active ? msg('the open cockpit') : null,
),
];
const root = globalThis.document?.getElementById?.('locale-picker');
if (!root) return;
this._localePicker = mountLocalePicker({
root,
current: getLocale(),
onSelect: (locale) => this._chooseLocale(locale),
});
}
/**
* Switch to `locale`: nothing for the active language, otherwise store it
* and reload once _confirmLocaleReload agrees.
* @param {string} locale
* @returns {'unchanged'|'declined'|'switched'}
*/
_chooseLocale(locale) {
return applyLocaleChoice(locale, {
current: getLocale(),
confirmSwitch: () => this._confirmLocaleReload(),
switchLocale: (next) => persistLocaleAndReload(next),
});
}
/**
* Switching language reloads the page. While that would lose work the page
* cannot restore, ask first.
* @returns {boolean} true to switch.
*/
_confirmLocaleReload() {
const lost = describeUnsavedWork();
if (!lost.length || typeof globalThis.confirm !== 'function') return true;
return globalThis.confirm(
t(
'Switching language reloads the page. This will be lost: {items}. Switch anyway?',
{ items: lost.join(', ') },
),
);
}
/**
* Sizes one Display-panel label column to the longest label in the active
* language, so slider tracks line up. Counts characters (labels are
* monospace and often hidden); CSS turns the count into a width.
* @returns {void}
*/
_sizeDisplayLabelColumn() {
const labels = document.querySelectorAll(
'#pp-toggles .pp-slider-row > .pp-slider-mini-label',
);
let longest = 0;
for (const label of labels) {
longest = Math.max(longest, label.textContent.trim().length);
}
if (longest > 0) {
document.documentElement.style.setProperty(
'--pp-label-chars',
String(longest),
);
}
}
/**
* Rewrites a header's last text node, keeping the leading icon span that a
* textContent write would destroy.
* @param {Element|null} element
* @param {string} text
* @returns {void}
*/
_setHeaderText(element, text) {
if (!element) return;
const textNode = Array.from(element.childNodes)
.reverse()
.find(
(node) => node.nodeType === Node.TEXT_NODE && node.nodeValue?.trim(),
);
if (textNode) textNode.nodeValue = text;
else element.textContent = text;
}
/**
* Translates at boot the header texts that share their element with an
* icon or a <br> (data-i18n-runtime in the templates).
* @returns {void}
*/
_applyRuntimeStaticHeaderText() {
this._setHeaderText(
document.querySelector('#control-panel .panel-title'),
t('VISUAL PRESETS'),
);
this._setHeaderText(
document.querySelector('#location-bar .location-toolbar-label'),
t('LOCATION'),
);
// One span holds both mode descriptions around a <br>; keep the <br>.
const standbyDescription = document.querySelector(
'#context-mode-standby span',
);
if (standbyDescription) {
standbyDescription.replaceChildren(
document.createTextNode(
t('CONTACTS — nearest planes · vessels · sites'),
),
document.createElement('br'),
document.createTextNode(
t('SPACE MISSIONS — launches & orbital assets'),
),
);
}
// Leading space after the live dot, as showBriefPage writes it.
const briefKicker = document.querySelector('#cockpit-brief-kicker');
if (briefKicker) {
this._setHeaderText(briefKicker, ` ${t('LIVE SIGNALS')}`);
}
}
/**
* Renders the owner-approved map stack chip row from the matching controller
* entries. Cesium ion/Bing chips remain keyboard-focusable but unavailable,
@@ -708,6 +857,7 @@ export class StyleManager extends ShellFacade {
*/
_initMapStackControl() {
if (!this.mapStackController) return;
this._mapSourceControls?.destroy();
this._mapSourceControls = createMapSourceControls({
container: this._mapStackChips,
@@ -1029,7 +1179,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('CCTV layer unavailable'));
return false;
}
const enabled = this._dataManager.isEnabled('cctv');
@@ -1478,7 +1628,7 @@ 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('Link copied!') : t('Copy failed'));
});
}
@@ -1576,6 +1726,10 @@ export class StyleManager extends ShellFacade {
this._shareState.destroy();
this._locationNavigation.destroy();
this._lifetime.destroy();
for (const unregister of this._unsavedWorkChecks?.splice(0) ?? [])
unregister();
this._localePicker?.destroy();
this._localePicker = null;
this._recording.destroy();
this._panelChrome.destroy();
this._feedback.destroy();
+8 -5
View File
@@ -1,5 +1,6 @@
import * as Cesium from 'cesium';
import { isPickedWorldPosition } from '../data/scenePick.js';
import { t } from '../i18n/index.js';
function trackedTarget(viewer) {
const entity = viewer.trackedEntity;
@@ -288,8 +289,8 @@ export function bindCameraOrientationControls({
tiltButton?.setAttribute(
'aria-label',
next.tilted
? 'Return map to straight-down view'
: 'Tilt map to oblique view',
? t('Return map to straight-down view')
: t('Tilt map to oblique view'),
);
}
if (!applied || applied.heading !== next.heading) {
@@ -297,7 +298,9 @@ export function bindCameraOrientationControls({
northButton?.style?.setProperty('--camera-heading', `${next.heading}deg`);
northButton?.setAttribute(
'aria-label',
`Reset map to north up. Current heading ${next.heading} degrees`,
t('Reset map to north up. Current heading {heading} degrees', {
heading: next.heading,
}),
);
}
applied = next;
@@ -323,7 +326,7 @@ export function bindCameraOrientationControls({
: false;
});
if (result) {
showToast?.(result.tilted ? 'Tilted view' : 'Straight-down view');
showToast?.(result.tilted ? t('Tilted view') : t('Straight-down view'));
// The action reports the state it just commanded, so the button can
// follow it without a second pick.
tilted = result.tilted;
@@ -342,7 +345,7 @@ export function bindCameraOrientationControls({
})
);
});
if (result) showToast?.('North up');
if (result) showToast?.(t('North up'));
sync();
});
+3 -1
View File
@@ -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('CCTV calibration saved'));
});
this.listen(this._cctvCalibResetBtn, 'click', () => {
+35 -12
View File
@@ -1,3 +1,5 @@
import { formatNumber, t, msg } from '../i18n/index.js';
/** Shortest-wrap signed degrees, for heading offsets typed as absolute values. */
const signedNormalizeDeg = (deg) => ((((deg + 180) % 360) + 360) % 360) - 180;
@@ -10,7 +12,7 @@ const signedNormalizeDeg = (deg) => ((((deg + 180) % 360) + 360) % 360) - 180;
*/
const CCTV_CAL_FIELDS = {
heading: {
label: 'HDG',
label: msg('HDG'),
unit: '°',
decimals: 1,
get: (cam) => cam.headingDeg,
@@ -19,21 +21,21 @@ const CCTV_CAL_FIELDS = {
}),
},
pitch: {
label: 'PITCH',
label: msg('PITCH'),
unit: '°',
decimals: 1,
get: (cam) => cam.pitchDeg,
toPatch: (value, base) => ({ pitchDeg: value - base.pitchDeg }),
},
fov: {
label: 'FOV',
label: msg('FOV'),
unit: '°',
decimals: 0,
get: (cam) => cam.fovDeg,
toPatch: (value, base) => ({ fovDeg: value - base.fovDeg }),
},
range: {
label: 'RANGE',
label: msg('RANGE'),
unit: 'm',
decimals: 0,
get: (cam) => cam.rangeM,
@@ -42,21 +44,21 @@ const CCTV_CAL_FIELDS = {
}),
},
height: {
label: 'HGT',
label: msg('HGT'),
unit: 'm',
decimals: 0,
get: (cam) => cam.mountHeightM,
toPatch: (value, base) => ({ heightM: value - base.mountHeightM }),
},
north: {
label: 'ΔN',
label: msg('ΔN'),
unit: 'm',
decimals: 1,
get: (cam) => cam.calibration?.offsetNorthM || 0,
toPatch: (value) => ({ offsetNorthM: value }),
},
east: {
label: 'ΔE',
label: msg('ΔE'),
unit: 'm',
decimals: 1,
get: (cam) => cam.calibration?.offsetEastM || 0,
@@ -64,6 +66,27 @@ const CCTV_CAL_FIELDS = {
},
};
/** Chip text for one pose value: locale decimals, translated metre unit. */
function calValueText(field, value) {
const options = {
minimumFractionDigits: field.decimals,
maximumFractionDigits: field.decimals,
};
if (field.unit === 'm') {
// English keeps its compact "RANGE 120m"; other languages space the unit.
const metres = formatNumber(Number(value), {
useGrouping: false,
...options,
});
return `${t(field.label)} ${t('{value}m', { value: metres })}`;
}
const number = formatNumber(Number(value), {
useGrouping: false,
...options,
});
return `${t(field.label)} ${number}${field.unit}`;
}
export function _activeCctvCameraId() {
return this._cctvState?.activeCameraId || this._cctvSelect?.value || '';
}
@@ -81,7 +104,7 @@ export function _resetCctvCalibration() {
},
{ origin: 'user' },
);
this.actions.showToast('CCTV calibration reset');
this.actions.showToast(t('CCTV calibration reset'));
}
export function _beginCctvCalValueEdit(chip) {
@@ -99,7 +122,7 @@ export function _beginCctvCalValueEdit(chip) {
input.step = field.decimals > 0 ? '0.1' : '1';
input.value = Number(startValue).toFixed(field.decimals);
input.className = 'cctv-cal-input';
chip.textContent = `${field.label} `;
chip.textContent = `${t(field.label)} `;
chip.appendChild(input);
input.focus();
input.select();
@@ -162,7 +185,7 @@ 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('ADJUST ON') : t('ADJUST');
this._cctvAdjustBtn.disabled = !canCalibrate;
}
if (this._cctvCalReadout) {
@@ -174,8 +197,8 @@ export function _syncCctvCalReadout(enabled, activeCamera) {
if (!field) continue;
const value = canCalibrate ? field.get(activeCamera) : null;
chip.textContent = Number.isFinite(value)
? `${field.label} ${Number(value).toFixed(field.decimals)}${field.unit}`
: `${field.label} --`;
? calValueText(field, value)
: `${t(field.label)} --`;
chip.disabled = !canCalibrate;
}
}
+84 -6
View File
@@ -1,3 +1,81 @@
import { msg, t } from '../i18n/index.js';
/**
* Display text for the CCTV source health the server reports
* (server/providers/cctv.js `setHealth`). The server keeps its English codes
* and messages; the panel translates them where it renders.
*/
const SOURCE_KIND_LABELS = Object.freeze({
snapshot: msg('SNAPSHOT'),
live: msg('LIVE'),
fallback: msg('FALLBACK'),
upstream: msg('UPSTREAM'),
synthetic: msg('SYNTHETIC'),
configured: msg('CONFIGURED'),
seed: msg('SEED'),
image: msg('IMAGE'),
unknown: msg('UNKNOWN'),
});
const SOURCE_STATUS_LABELS = Object.freeze({
degraded: msg('DEGRADED'),
error: msg('ERROR'),
unknown: msg('UNKNOWN'),
});
const SOURCE_MESSAGES = Object.freeze({
'Live HLS connected': msg('Live HLS connected'),
'Live HLS unavailable': msg('Live HLS unavailable'),
'No stream URL configured': msg('No stream URL configured'),
'Live stream connected': msg('Live stream connected'),
'Snapshot feed connected': msg('Snapshot feed connected'),
'Media fetch failed': msg('Media fetch failed'),
'Upstream snapshot active': msg('Upstream snapshot active'),
'Fallback Street View frame': msg('Fallback Street View frame'),
'Upstream unavailable': msg('Upstream unavailable'),
'No source configured': msg('No source configured'),
});
const SOURCE_LABELS = Object.freeze({
'Configured source': msg('Configured source'),
'Configured Source': msg('Configured Source'),
'Synthetic fallback': msg('Synthetic fallback'),
});
/** "SNAPSHOT" / "СНИМОК"; unknown codes (HLS, MJPEG…) stay as uppercase codes. */
export function cctvSourceKindLabel(kind) {
const key = String(kind || 'unknown').toLowerCase();
return SOURCE_KIND_LABELS[key]
? t(SOURCE_KIND_LABELS[key])
: key.toUpperCase();
}
/** "DEGRADED" / "ПЕРЕБОИ"; OK and unknown codes stay as uppercase codes. */
export function cctvSourceStatusLabel(status) {
const key = String(status || 'unknown').toLowerCase();
return SOURCE_STATUS_LABELS[key]
? t(SOURCE_STATUS_LABELS[key])
: key.toUpperCase();
}
/** A server health message in the page language; unknown text passes through. */
export function cctvSourceMessageText(message) {
const text = String(message || '');
if (SOURCE_MESSAGES[text]) return t(SOURCE_MESSAGES[text]);
const http = /^Upstream HTTP (\d{3})$/.exec(text);
if (http) return t('Upstream HTTP {status}', { status: http[1] });
const media = /^Unexpected media type (.+)$/.exec(text);
if (media) return t('Unexpected media type {type}', { type: media[1] });
return text;
}
/** A generic provider label from the server; real provider names pass through. */
export function cctvSourceLabelText(label) {
const text = String(label || '');
return SOURCE_LABELS[text] ? t(SOURCE_LABELS[text]) : text;
}
export function _clearCctvFrame() {
this._cctvFrameRequestToken += 1;
if (this._cctvFramePreloader) {
@@ -86,26 +164,26 @@ 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('SOURCE · UNKNOWN');
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('FRAME · LOADING');
this._cctvSourceBadge.dataset.frameState = 'loading';
return;
}
if (this._cctvFrame?.dataset.error === 'true' && !hasDisplayedFrame) {
this._cctvSourceBadge.textContent = 'FRAME · UNAVAILABLE';
this._cctvSourceBadge.textContent = t('FRAME · UNAVAILABLE');
this._cctvSourceBadge.dataset.frameState = 'error';
return;
}
const kind = String(
const kind = cctvSourceKindLabel(
activeCamera.sourceKind || activeCamera.feedType || 'unknown',
).toUpperCase();
const status = String(activeCamera.sourceStatus || 'unknown').toUpperCase();
);
const status = cctvSourceStatusLabel(activeCamera.sourceStatus || 'unknown');
this._cctvSourceBadge.textContent = `${kind} · ${status}`;
this._cctvSourceBadge.dataset.frameState = 'ready';
}
+56 -25
View File
@@ -1,12 +1,15 @@
import { t, tn } from '../i18n/index.js';
import { cctvSourceLabelText, cctvSourceMessageText } from './cctvFrames.js';
import { createCctvVideoSurface } from './cctvVideo.js';
export function _calBadgeLabel(badge) {
switch (badge) {
case 'calibrated':
return 'CALIBRATED';
return t('CALIBRATED');
case 'curated':
return 'CURATED';
return t('CURATED');
case 'raw-prior':
return 'RAW PRIOR';
return t('RAW PRIOR');
default:
return '--';
}
@@ -51,7 +54,7 @@ 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('CCTV ON') : t('CCTV OFF');
}
if (this._cctvSelect) {
@@ -99,17 +102,19 @@ export function _renderCctvState(state) {
this._cctvCoverageBtn.classList.toggle('active', mode !== 'off');
this._cctvCoverageBtn.textContent =
mode === 'viewshed'
? 'VIEWSHED ON'
? t('VIEWSHED ON')
: mode === 'on'
? 'COVERAGE ON'
: 'COVERAGE OFF';
? t('COVERAGE ON')
: t('COVERAGE OFF');
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('AUTO HOP ON')
: t('AUTO HOP OFF');
this._cctvAutoHopBtn.disabled = !enabled;
}
@@ -117,8 +122,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('PROJECTION ON')
: t('PROJECTION OFF');
this._cctvProjectionBtn.disabled = !enabled;
}
@@ -133,8 +138,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('CAL · EDITED (UNSAVED)')
: t('CAL · {badge}', {
badge: this._calBadgeLabel(badge),
});
this._cctvQualityChip.dataset.calBadge = dirty ? 'edited' : badge || '';
}
@@ -142,26 +149,50 @@ export function _renderCctvState(state) {
if (this._cctvMeta) {
if (activeCamera) {
const provider =
const provider = cctvSourceLabelText(
activeCamera.sourceLabel ||
activeCamera.provider ||
'Configured Source';
activeCamera.provider ||
'Configured Source',
);
const statusMsg = activeCamera.sourceMessage
? ` · ${activeCamera.sourceMessage}`
? ` · ${cctvSourceMessageText(activeCamera.sourceMessage)}`
: '';
// A partner-supplied feed inside a pack names its owner here.
const credit = activeCamera.credit ? ` · ${activeCamera.credit}` : '';
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('MONITOR') : t('OFF');
this._cctvMeta.textContent = t(
'{city} · HDG {heading} · FOV {fov} · RANGE {range}m · {projection}{calBadge} · {provider}{status}',
{
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`;
? tn(
cameras.length,
'{count} camera loaded · click a camera to activate',
'{count} cameras loaded · click a camera to activate',
)
: tn(
cameras.length,
'{count} camera loaded · enable CCTV to activate',
'{count} cameras loaded · enable CCTV to activate',
);
} else {
this._cctvMeta.textContent = 'Enable CCTV to load camera intersections';
this._cctvMeta.textContent = t(
'Enable CCTV to load camera intersections',
);
}
}
@@ -211,13 +242,13 @@ export function _renderCctvState(state) {
this._syncCctvSourceBadge(activeCamera, enabled);
this._typeCctvSummary(
state?.summary ||
'Enable CCTV to start camera-linked intelligence summaries.',
t('Enable CCTV to start camera-linked intelligence summaries.'),
);
}
export function _typeCctvSummary(text) {
if (this.destroyed || !this._cctvSummary) return;
const nextText = String(text || '').trim() || 'No summary available.';
const nextText = String(text || '').trim() || t('No summary available.');
if (nextText === this._lastCctvSummaryText) return;
this._lastCctvSummaryText = nextText;
@@ -249,7 +280,7 @@ 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('loading frames'));
// 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 +291,7 @@ 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('camera grid ready'));
this._cctvSyncProgress.textContent = `${total}/${total}`;
this._cctvSyncChip.classList.add('visible');
clearTimeout(this._cctvChipHideTimer);
+5 -1
View File
@@ -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('Clearing selected data layers')
: t('Clear selected data layers'),
);
},
destroy() {
+75 -23
View File
@@ -1,4 +1,5 @@
/** Regional briefing requests, pages and rotation for the Cockpit controller. */
import { formatNumber, t, msg } from '../i18n/index.js';
import {
COCKPIT_BRIEF_ROTATE_MS,
COCKPIT_BRIEF_CYCLE_OFF_HELP,
@@ -7,9 +8,27 @@ import {
COCKPIT_REGIONAL_REFRESH_DISTANCE_M,
COCKPIT_BRIEF_PAGES,
formatCockpitBriefAge,
formatCockpitCoordinate,
formatCockpitWindDirection,
} from './cockpitPresentation.js';
// regionalModel.weatherCodeLabel is shared with node code and returns
// English; translate it here.
const WEATHER_CONDITION_KEYS = Object.freeze({
'CONDITIONS UNKNOWN': msg('CONDITIONS UNKNOWN'),
CLEAR: msg('CLEAR'),
'PARTLY CLOUDY': msg('PARTLY CLOUDY'),
OVERCAST: msg('OVERCAST'),
FOG: msg('FOG'),
DRIZZLE: msg('DRIZZLE'),
RAIN: msg('RAIN'),
SNOW: msg('SNOW'),
'RAIN SHOWERS': msg('RAIN SHOWERS'),
'SNOW SHOWERS': msg('SNOW SHOWERS'),
THUNDERSTORM: msg('THUNDERSTORM'),
'MIXED CONDITIONS': msg('MIXED CONDITIONS'),
});
export function showBriefPage(index, { manual = false } = {}) {
if (this.destroyed) return;
const count = COCKPIT_BRIEF_PAGES.length;
@@ -26,15 +45,18 @@ 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 = page.sourceKey
? t(page.sourceKey, { source: page.sourceName })
: page.source;
if (this.signalStream) this.signalStream.dataset.briefPage = page.id;
if (manual && this.briefAutoRotateEnabled)
this.startBriefRotation({ reset: true });
@@ -56,6 +78,22 @@ export function setBriefAutoRotate(enabled) {
: COCKPIT_BRIEF_CYCLE_OFF_HELP;
this.briefAutoToggle.setAttribute('aria-label', label);
this.briefAutoToggle.title = help;
// cockpitMarkup.test.mjs pins the English writes above; these translate
// them.
const labelKey = this.briefAutoRotateEnabled
? msg('CYCLE ON')
: msg('CYCLE OFF');
this.briefAutoToggle.textContent = t(labelKey);
this.briefAutoToggle.setAttribute('aria-label', t(labelKey));
this.briefAutoToggle.title = t(
this.briefAutoRotateEnabled
? msg(
'Stop automatic page cycling. Previous, Next, and the SIG/NEWS/LOCAL tabs stay available.',
)
: msg(
'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.',
),
);
}
if (this.briefAutoRotateEnabled) this.startBriefRotation({ reset: true });
else this.stopBriefRotation();
@@ -94,11 +132,11 @@ 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('POSITION UNAVAILABLE');
return;
}
const lat = `${Math.abs(info.latitude).toFixed(3)}°${info.latitude >= 0 ? 'N' : 'S'}`;
const lon = `${Math.abs(info.longitude).toFixed(3)}°${info.longitude >= 0 ? 'E' : 'W'}`;
const lat = formatCockpitCoordinate(info.latitude, 'N', 'S');
const lon = formatCockpitCoordinate(info.longitude, 'E', 'W');
this.localCoordinates.textContent = `${lat} · ${lon}`;
}
@@ -175,14 +213,14 @@ export function renderRegionalBriefStatus(status, info) {
this.newsStatus.dataset.state = status;
this.newsStatus.textContent =
status === 'loading'
? 'ACQUIRING REGIONAL NEWS'
: 'REGIONAL NEWS UNAVAILABLE';
? t('ACQUIRING REGIONAL NEWS')
: t('REGIONAL NEWS UNAVAILABLE');
}
if (status === 'unavailable') this.newsList?.replaceChildren();
if (this.localPlace && status === 'loading')
this.localPlace.textContent = 'RESOLVING REGION';
this.localPlace.textContent = t('RESOLVING REGION');
if (this.localPlace && status === 'unavailable')
this.localPlace.textContent = 'REGION UNAVAILABLE';
this.localPlace.textContent = t('REGION UNAVAILABLE');
this.updateLocalPosition(info);
}
@@ -193,8 +231,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('NO RECENT LOCATION MATCHES')
: t('REGIONAL NEWS UNAVAILABLE');
}
if (this.newsList) {
this.newsList.replaceChildren(
@@ -207,7 +245,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('{domain} · {age}', {
domain: article.domain || t('SOURCE'),
age: formatCockpitBriefAge(article.publishedAt),
});
link.append(title, metadata);
entry.append(link);
return entry;
@@ -216,7 +257,7 @@ export function renderRegionalBrief(payload, info) {
}
const placeLabel =
payload?.place?.label || payload?.place?.country || 'REGION UNAVAILABLE';
payload?.place?.label || payload?.place?.country || t('REGION UNAVAILABLE');
if (this.localPlace) this.localPlace.textContent = placeLabel.toUpperCase();
this.updateLocalPosition(info);
const weather = payload?.weather;
@@ -227,7 +268,7 @@ export function renderRegionalBrief(payload, info) {
}
if (this.localWind) {
this.localWind.textContent = Number.isFinite(weather?.windKph)
? `${Math.round(weather.windKph)} KM/H`
? t('{value} KM/H', { value: Math.round(weather.windKph) })
: '—';
}
if (this.localWindDirection) {
@@ -235,26 +276,37 @@ 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('CLOUD {pct}%', {
pct: Math.round(weather.cloudCoverPct),
})
: t('CLOUD UNKNOWN');
}
if (this.localPrecipitation) {
this.localPrecipitation.textContent = Number.isFinite(
weather?.precipitationMm,
)
? weather.precipitationMm.toFixed(1)
? formatNumber(weather.precipitationMm, {
useGrouping: false,
minimumFractionDigits: 1,
maximumFractionDigits: 1,
})
: '—';
}
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('{source} · LOCATION QUERY', {
source: String(payload?.newsSource || t('REGIONAL NEWS')).toUpperCase(),
});
}
this.scheduleContextLayout();
}
+49 -17
View File
@@ -1,11 +1,25 @@
/** Contact readouts and nearby-signal presentation for the Cockpit controller. */
import * as Cesium from 'cesium';
import { formatNumber, t, tn } from '../i18n/index.js';
import { unitLabel } from './unitLabels.js';
import {
bearingBetweenCoordinates,
formatCockpitContextScope,
relativeBearing,
resolveCockpitContextReadout,
} from '../cockpitMath.js';
import { formatCockpitDistance } from './cockpitPresentation.js';
/** The Context scope line in the active language (see cockpitMath.js). */
const LOCALIZED_CONTEXT_SCOPE = Object.freeze({
window: ({ label, radiusKm }) =>
t('{label} · {radius} AIR/SEA WINDOW', {
label,
radius: `${radiusKm === null ? '—' : formatNumber(radiusKm)}\u00a0${unitLabel('km').toUpperCase()}`,
}),
installations: ({ scope, coverage }) =>
t('{scope} · INSTALLATIONS {coverage}', { scope, coverage }),
});
export function updateContext(info, heading) {
if (!this.context) return;
@@ -20,8 +34,8 @@ export function updateContext(info, heading) {
this.pushCockpitSignal(
'context-status',
'info',
'CONTEXT STANDBY',
'ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS',
t('CONTEXT STANDBY'),
t('ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS'),
);
return;
}
@@ -36,6 +50,7 @@ export function updateContext(info, heading) {
snapshot.subject.label || trackedId,
snapshot.radiusM,
installationCoverage,
LOCALIZED_CONTEXT_SCOPE,
);
}
// Navigation stays wired in every state — the operator must always be able
@@ -52,8 +67,9 @@ export function updateContext(info, heading) {
const enteringLost = this.context.dataset.state !== 'lost';
this.context.dataset.state = 'lost';
if (this.contextUncertainty) {
this.contextUncertainty.textContent =
'CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR';
this.contextUncertainty.textContent = t(
'CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR',
);
}
// 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 +77,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('CONTACT LOST · {subject}', {
subject: snapshot.subject.label || snapshot.subject.id || 'SUBJECT',
}),
t('SUBJECT LEFT ITS FEED · READOUT HOLDING LAST KNOWN'),
);
return;
}
@@ -83,25 +101,30 @@ 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('{cohort} · {contact}', {
cohort: closest.cohort.label.toUpperCase(),
contact: closestLabel,
})
: t('NO AVAILABLE EXAMPLE');
this.contextNearestLabel.setAttribute(
'aria-label',
closest && closestLabel === '—'
? `${closest.cohort.label}, Unavailable`
? t('{cohort}, Unavailable', {
cohort: closest.cohort.label,
})
: this.contextNearestLabel.textContent,
);
}
if (this.contextDistance) {
const distanceM = closest?.distanceM;
this.contextDistance.textContent = Number.isFinite(distanceM)
? `${distanceM < 10000 ? (distanceM / 1000).toFixed(1) : Math.round(distanceM / 1000)} KM`
? formatCockpitDistance(distanceM)
: '—';
this.contextDistance.setAttribute(
'aria-label',
Number.isFinite(distanceM)
? this.contextDistance.textContent
: 'Unavailable',
: t('Unavailable'),
);
}
@@ -133,15 +156,24 @@ export function updateContext(info, heading) {
this.contextDirection.classList.toggle('unknown', relative === null);
}
if (this.contextBearing) {
if (relative === null) this.contextBearing.textContent = 'BRG —';
else if (Math.abs(relative) < 8) this.contextBearing.textContent = 'AHEAD';
else
this.contextBearing.textContent = `${relative < 0 ? 'L' : 'R'} ${String(Math.round(Math.abs(relative))).padStart(3, '0')}°`;
if (relative === null) this.contextBearing.textContent = t('BRG —');
else if (Math.abs(relative) < 8)
this.contextBearing.textContent = t('AHEAD');
else {
this.contextBearing.textContent = t('{side} {angle}', {
side: relative < 0 ? t('L') : t('R'),
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';
? tn(
unknownCount,
'{count} INPUT UNKNOWN · NOT AN ALL-CLEAR',
'{count} INPUTS UNKNOWN · NOT AN ALL-CLEAR',
)
: t('AVAILABLE INPUTS CURRENT · NOT AN ALL-CLEAR');
}
if (this.contextUpdated) {
this.contextUpdated.textContent = Number.isFinite(snapshot.evaluatedAt)
+77 -39
View File
@@ -1,4 +1,5 @@
/** Instrument values, routes and vision controls for the Cockpit controller. */
import { formatNumber, t } from '../i18n/index.js';
import {
COCKPIT_VISION_MODES,
normalizeCockpitVisionMode,
@@ -20,9 +21,27 @@ import {
} from '../cockpitMath.js';
import {
COCKPIT_CONTEXT_UPDATE_MS,
compassPointLabel,
formatCockpitCoordinate,
setCockpitRollingValue,
} from './cockpitPresentation.js';
/** A transponder status name (src/data/aircraftEmergency.js) for the HUD. */
const emergencyLabel = ({ name }) => t(name).toUpperCase();
/** The emergency line in the active language (see cockpitMath.js). */
const LOCALIZED_EMERGENCY = Object.freeze({
squawk: (parts) =>
t('SQUAWK {code} · {status}', {
code: parts.squawk,
status: emergencyLabel(parts),
}),
adsb: (parts) =>
parts.priority
? t('ADS-B PRIORITY · {status}', { status: emergencyLabel(parts) })
: t('ADS-B EMERGENCY · {status}', { status: emergencyLabel(parts) }),
});
export function updateHud(
info,
nowMs = performance.now(),
@@ -32,7 +51,7 @@ 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('AIRCRAFT');
}
const speedKt = Number.isFinite(info.velocityMps)
? info.velocityMps * 1.94384
@@ -67,7 +86,7 @@ export function updateHud(
setCockpitRollingValue(
this.altitude,
displayedAltitudeFt !== null
? displayedAltitudeFt.toLocaleString('en-US')
? formatNumber(displayedAltitudeFt)
: '-----',
displayedAltitudeFt,
{ immediate: forceContext },
@@ -110,7 +129,7 @@ export function updateHud(
this.compassTape.innerHTML = divisions
.map((division, index) => {
const slot = index - 3;
return `<span class="${slot === 0 ? 'active' : ''}" style="--slot:${slot};--depth:${Math.abs(slot)}">${formatCompassDivision(division)}</span>`;
return `<span class="${slot === 0 ? 'active' : ''}" style="--slot:${slot};--depth:${Math.abs(slot)}">${compassPointLabel(formatCompassDivision(division))}</span>`;
})
.join('');
}
@@ -119,26 +138,37 @@ export function updateHud(
this.clock.textContent = new Date().toISOString().slice(11, 19) + 'Z';
if (this.position) {
const lat = Number.isFinite(info.latitude)
? `${Math.abs(info.latitude).toFixed(3)}°${info.latitude >= 0 ? 'N' : 'S'}`
? formatCockpitCoordinate(info.latitude, 'N', 'S')
: '--';
const lon = Number.isFinite(info.longitude)
? `${Math.abs(info.longitude).toFixed(3)}°${info.longitude >= 0 ? 'E' : 'W'}`
? formatCockpitCoordinate(info.longitude, 'E', 'W')
: '--';
this.position.textContent = `${lat} · ${lon}`;
}
if (this.aircraftMeta) {
const feedState = this.surfaceAcquiring
? 'ACQUIRING SURFACE'
? t('ACQUIRING SURFACE')
: this.surfaceFallback
? 'SURFACE FALLBACK'
? t('SURFACE FALLBACK')
: info.stale
? 'STALE FEED'
: 'LIVE TRACK';
const fleet = info.layerId === 'military' ? 'MILITARY' : 'COMMERCIAL';
const emergency = formatCockpitEmergency(info.emergency);
? t('STALE FEED')
: t('LIVE TRACK');
const aircraftClass =
info.layerId === 'military' ? t('MILITARY') : t('COMMERCIAL');
const emergency = formatCockpitEmergency(
info.emergency,
LOCALIZED_EMERGENCY,
);
this.aircraftMeta.textContent = emergency
? `${fleet} · ${emergency} · ${feedState}`
: `${fleet} · ${feedState} · COURSE ALIGNED`;
? t('{aircraftClass} · {emergency} · {feedState}', {
aircraftClass,
emergency,
feedState,
})
: t('{aircraftClass} · {feedState} · COURSE ALIGNED', {
aircraftClass,
feedState,
});
this.aircraftMeta.classList.toggle(
'emergency',
info.emergency?.severity === 'emergency',
@@ -167,13 +197,13 @@ 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('UNKNOWN');
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('ARROW · ESTIMATED DIRECTION')
: t('ROUTE DATA UNAVAILABLE');
}
if (this.route) this.route.hidden = !origin && !destination;
if (
@@ -207,7 +237,9 @@ export function updateRoute(info) {
);
}
if (this.routeDirectionLabel) {
this.routeDirectionLabel.textContent = `DEST ${String(Math.round(destinationBearing)).padStart(3, '0')}°`;
this.routeDirectionLabel.textContent = t('DEST {bearing}', {
bearing: `${String(Math.round(destinationBearing)).padStart(3, '0')}°`,
});
}
}
@@ -215,42 +247,48 @@ 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('Disable cockpit weather effects')
: t('Enable cockpit weather effects');
this.weatherToggle.setAttribute('aria-label', weatherLabel);
this.weatherToggle.title = weatherLabel;
if (this.weatherState) {
this.weatherState.textContent = active ? t('ON') : t('OFF');
}
}
export function setVisionMode(mode, { revealParameters = false } = {}) {
const next = normalizeCockpitVisionMode(mode);
this.visionMode = next;
const labels = {
optical: 'NORMAL',
crt: 'CRT',
nvg: 'NVG',
thermal: 'FLIR',
anime: 'ANIME',
noir: 'NOIR',
snow: 'SNOW',
optical: t('NORMAL'),
crt: t('CRT'),
nvg: t('NVG'),
thermal: t('FLIR'),
anime: t('ANIME'),
noir: t('NOIR'),
snow: t('SNOW'),
};
const names = {
optical: 'Normal',
crt: 'CRT',
nvg: 'Night vision',
thermal: 'Thermal',
anime: 'Anime',
noir: 'Noir',
snow: 'Snow',
optical: t('Normal'),
crt: t('CRT'),
nvg: t('Night vision'),
thermal: t('Thermal'),
anime: t('Anime'),
noir: t('Noir'),
snow: t('Snow'),
};
if (this.visionCurrent) {
this.visionCurrent.dataset.cockpitVision = next;
this.visionCurrent.setAttribute(
'aria-label',
`Current cockpit vision style: ${names[next]}. Activate for next style.`,
t('Current cockpit vision style: {style}. Activate for next style.', {
style: names[next],
}),
);
this.visionCurrent.title = `Current style: ${names[next]} — click for next`;
this.visionCurrent.title = t('Current style: {style} — click for next', {
style: names[next],
});
}
if (this.visionCurrentLabel)
this.visionCurrentLabel.textContent = labels[next];
+13 -4
View File
@@ -1,4 +1,5 @@
/** Measure Cockpit panels against visible HUD and viewport obstacles. */
import { t, msg } from '../i18n/index.js';
import {
resolveCockpitUtilityAnchor,
resolveCockpitUtilityLayout,
@@ -37,9 +38,11 @@ export function setContextCollapsed(collapsed) {
this.contextToggle.setAttribute('aria-expanded', String(expanded));
this.contextToggle.setAttribute(
'aria-label',
`${expanded ? 'Collapse' : 'Expand'} Contact panel`,
expanded ? t('Collapse Contact panel') : t('Expand Contact panel'),
);
this.contextToggle.title = t(
expanded ? msg('Collapse contact panel') : msg('Expand contact panel'),
);
this.contextToggle.title = `${expanded ? 'Collapse' : 'Expand'} contact panel`;
const icon = this.contextToggle.querySelector('.material-symbols-outlined');
if (icon) icon.textContent = expanded ? 'chevron_left' : 'chevron_right';
}
@@ -60,9 +63,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
? msg('Collapse cockpit briefing panel')
: msg('Expand cockpit briefing panel'),
),
);
this.signalToggle.title = t(
expanded ? msg('Collapse briefing panel') : msg('Expand briefing panel'),
);
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';
+90 -16
View File
@@ -1,4 +1,7 @@
/** Pure formatting and fixed presentation constants used by Cockpit components. */
import { formatNumber, t, msg } from '../i18n/index.js';
import { formatQuantity } from './unitLabels.js';
export const COCKPIT_HEADING_SLEW_DPS = 28;
export const COCKPIT_FORWARD_OFFSET_M = 7;
@@ -44,20 +47,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: msg('LIVE SIGNALS'),
subtitleKey: msg('OBSERVED / MAPPED PINGS'),
sourceKey: msg('SOURCE-BACKED EVENTS · NO SYNTHETIC NEWS'),
},
{
id: 'news',
kicker: 'REGIONAL NEWS',
subtitle: 'LATEST LOCATION-MATCHED REPORTING',
source: 'GOOGLE NEWS RSS · LOCATION QUERY · RECENT',
kickerKey: msg('REGIONAL NEWS'),
subtitleKey: msg('LATEST LOCATION-MATCHED REPORTING'),
sourceKey: msg('{source} · LOCATION QUERY · RECENT'),
sourceName: 'GOOGLE NEWS RSS', // i18n-ignore: provider name
},
{
id: 'local',
kicker: 'LOCAL INFO',
subtitle: 'PLACE / CONDITIONS / POSITION',
kickerKey: msg('LOCAL INFO'),
subtitleKey: msg('PLACE / CONDITIONS / POSITION'),
// Attribution stays English.
source: 'NATURAL EARTH · OPEN-METEO · UTC',
},
];
@@ -80,18 +85,87 @@ 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('TIME UNKNOWN');
const minutes = Math.max(0, Math.round((Date.now() - timestamp) / 60_000));
if (minutes < 60) return `${minutes}M AGO`;
if (minutes < 60) return t('{count}M AGO', { count: minutes });
const hours = Math.round(minutes / 60);
return hours < 48 ? `${hours}H AGO` : `${Math.round(hours / 24)}D AGO`;
return hours < 48
? t('{count}H AGO', { count: hours })
: t('{count}D AGO', { count: Math.round(hours / 24) });
}
/** Compass point codes in display order and the label shown for each. */
const COMPASS_POINT_LABELS = Object.freeze({
N: msg('N'),
NE: msg('NE'),
E: msg('E'),
SE: msg('SE'),
S: msg('S'),
SW: msg('SW'),
W: msg('W'),
NW: msg('NW'),
});
/**
* The shown label for a compass point code (`N`, `SW`…): С, ЮЗ… in Russian.
* Anything else (a degree reading such as `030`) passes through unchanged.
*/
export function compassPointLabel(code) {
return Object.hasOwn(COMPASS_POINT_LABELS, code)
? t(COMPASS_POINT_LABELS[code])
: code;
}
/** `30.203°N` in the active language: `30,203° с. ш.` in Russian. */
export function formatCockpitCoordinate(value, positive, negative) {
const amount = formatNumber(Math.abs(value), {
useGrouping: false,
minimumFractionDigits: 3,
maximumFractionDigits: 3,
});
const code = value >= 0 ? positive : negative;
// Not «30,203°С»: after a degree sign «С» reads as Celsius.
switch (code) {
case 'N':
return t('{value}°N', { value: amount });
case 'S':
return t('{value}°S', { value: amount });
case 'E':
return t('{value}°E', { value: amount });
case 'W':
return t('{value}°W', { value: amount });
default:
return `${amount}°${compassPointLabel(code)}`;
}
}
/**
* A contact distance such as `4.2 KM` / `44 KM` in the active language
* (`4,2 КМ`): one decimal under 10 km, whole kilometres beyond.
*/
export function formatCockpitDistance(distanceM) {
const digits = distanceM < 10000 ? 1 : 0;
return formatQuantity(distanceM / 1000, 'km', {
minimumFractionDigits: digits,
maximumFractionDigits: digits,
}).toUpperCase();
}
export function formatCockpitWindDirection(value) {
if (!Number.isFinite(value)) return 'DIR UNKNOWN';
const labels = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW'];
if (!Number.isFinite(value)) return t('DIR UNKNOWN');
const labels = Object.keys(COMPASS_POINT_LABELS);
const normalized = ((value % 360) + 360) % 360;
return `${labels[Math.round(normalized / 45) % labels.length]} · ${Math.round(normalized)}°`;
return `${compassPointLabel(labels[Math.round(normalized / 45) % labels.length])} · ${Math.round(normalized)}°`;
}
// Digit grouping and decimal marks (`26,175`, `26 175`, `26.175`) take a
// narrow cell; a full digit cell would open a gap in the middle of a number.
const ROLL_SEPARATOR = /^[,.\u00a0\u202f]$/;
function rollTokenClass(character) {
return ROLL_SEPARATOR.test(character)
? 'cockpit-roll-token is-separator'
: 'cockpit-roll-token';
}
export function setCockpitRollingValue(
@@ -130,7 +204,7 @@ export function setCockpitRollingValue(
element.replaceChildren(
...Array.from(nextText, (character) => {
const token = document.createElement('span');
token.className = 'cockpit-roll-token';
token.className = rollTokenClass(character);
token.setAttribute('aria-hidden', 'true');
token.textContent = character;
return token;
@@ -159,7 +233,7 @@ export function setCockpitRollingValue(
const previousCharacter = from[index];
const nextCharacter = to[index];
const token = document.createElement('span');
token.className = 'cockpit-roll-token';
token.className = rollTokenClass(nextCharacter);
token.setAttribute('aria-hidden', 'true');
if (
+24 -9
View File
@@ -1,4 +1,6 @@
/** Reconcile actionable signal rows without disturbing native keyboard focus. */
import { t, tn } from '../i18n/index.js';
import { formatCockpitDistance } from './cockpitPresentation.js';
export function renderCockpitSignals() {
if (!this.signalList) return;
@@ -57,7 +59,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('Select flight {title}', { title: item.title });
if (heading.getAttribute('aria-label') !== label)
heading.setAttribute('aria-label', label);
setText(heading.children[0], item.title);
@@ -126,7 +128,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('{aircraftClass} · CURRENT', {
aircraftClass:
subject.layerId === 'military'
? t('MILITARY FLIGHT')
: t('COMMERCIAL FLIGHT'),
}),
target: { layerId: subject.layerId, id: String(subject.id) },
distanceM: -1,
});
@@ -145,11 +152,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)
? `${item.distanceM < 10000 ? (item.distanceM / 1000).toFixed(1) : Math.round(item.distanceM / 1000)} KM`
: 'DISTANCE UNKNOWN'
}`,
detail: t('{aircraftClass} · {distance}', {
aircraftClass:
cohort.id === 'military'
? t('MILITARY FLIGHT')
: t('COMMERCIAL FLIGHT'),
distance: Number.isFinite(item.distanceM)
? formatCockpitDistance(item.distanceM)
: t('DISTANCE UNKNOWN'),
}),
target: { layerId: cohort.id, id: String(id) },
distanceM: item.distanceM ?? Infinity,
});
@@ -169,8 +180,12 @@ 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: tn(
unknownCount,
'{count} INPUT UNKNOWN',
'{count} INPUTS UNKNOWN',
),
detail: sources || t('SOURCE STATUS UNAVAILABLE'),
target: null,
timestamp:
previous.get('input-status')?.timestamp ||
+9 -5
View File
@@ -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('Restore real aircraft')
: t('Reclassify as TR-3B');
}
export function syncEntry() {
@@ -183,15 +184,18 @@ export function enter() {
this.showBriefPage(0);
this.startBriefRotation();
const trackLabel =
info.callsign || info.registration || info.icao24 || 'AIRCRAFT';
info.callsign || info.registration || info.icao24 || t('AIRCRAFT');
const trackHeading = String(
Math.round(normalizeHeading(info.track ?? 0)),
).padStart(3, '0');
this.pushCockpitSignal(
'track',
'track',
'TRACK ACQUIRED',
`${trackLabel} · COURSE ${trackHeading}°`,
t('TRACK ACQUIRED'),
t('{label} · COURSE {heading}°', {
label: trackLabel,
heading: trackHeading,
}),
);
this.updateHud(info, performance.now(), true);
this.setVisionMode(this.getInitialVisionMode());
+1 -1
View File
@@ -1,8 +1,8 @@
/** Compose UI controls with the application's existing engines and layer instances. */
import { StyleManager as ApplicationShell } from './applicationShell.js';
import { LocationSearch } from './location.js';
import { CITY_POIS } from '../cityPois.js';
import {
CITY_POIS,
GLOBE_VIEW,
flyToGlobeView,
flyToPresetLocation,
+16 -7
View File
@@ -1,3 +1,4 @@
import { t, tn } 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('Context could not restore every layer; try again'),
{ 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,29 @@ export function clearSelectedLayers() {
const operation = managerOperation
.then((result) => {
if (result.targetIds.length === 0) {
this.showToast('No selected data layers');
this.showToast(t('No selected data layers'));
} else if (result.notClearedIds.length > 0) {
this.showToast(
`${result.notClearedIds.length} data layer${result.notClearedIds.length === 1 ? '' : 's'} could not be cleared`,
tn(
result.notClearedIds.length,
'{count} data layer could not be cleared',
'{count} data layers could not be cleared',
),
);
} else {
this.showToast(
`Cleared ${result.clearedIds.length} data layer${result.clearedIds.length === 1 ? '' : 's'}`,
tn(
result.clearedIds.length,
'Cleared {count} data layer',
'Cleared {count} data layers',
),
);
}
return result;
})
.catch((error) => {
console.warn('[Data] clear selected layers failed', error);
this.showToast('Selected data layers could not be cleared');
this.showToast(t('Selected data layers could not be cleared'));
return {
targetIds: [],
items: [],
+14 -9
View File
@@ -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('Contacts could not complete the requested transition; try again'),
).then((succeeded) => {
if (
!this.destroyed &&
@@ -61,7 +62,9 @@ export function _initGlobalContextPanel() {
void this._runUserFacingContextAction(
(notificationToken) =>
this._selectContextMode(nextMode, { notificationToken }),
'Space Missions could not complete the requested transition; try again',
t(
'Space Missions could not complete the requested transition; try again',
),
).then((succeeded) => {
if (
!this.destroyed &&
@@ -108,14 +111,16 @@ export function _initGlobalContextPanel() {
this.showToast(
stats?.statusMessage ||
(stats?.status === 'zoom-in'
? 'Zoom in to search mapped installations'
: 'Nearby installations refreshed'),
? t('Zoom in to search mapped installations')
: t('Nearby installations refreshed')),
);
return true;
}, 'Nearby installations could not be refreshed; try again').finally(() => {
if (this.destroyed) return;
button.setAttribute('aria-disabled', 'false');
button.setAttribute('aria-busy', 'false');
});
}, t('Nearby installations could not be refreshed; try again')).finally(
() => {
if (this.destroyed) return;
button.setAttribute('aria-disabled', 'false');
button.setAttribute('aria-busy', 'false');
},
);
});
}
+116
View File
@@ -0,0 +1,116 @@
// The blocked-visibility toast reason in the UI language.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { setLocale } from '../i18n/index.js';
import {
contextLayerEnableBlockReason,
parseContextLayerBlockReason,
} from '../contextModePolicy.js';
import {
_handleContextLayerChange,
localizeVisibilityBlockReason,
} from './contextLayerChanges.js';
function fakeShell() {
const toasts = [];
return {
destroyed: false,
_userFacingContextNotificationTokens: new Set(),
_contextSessionSnapshot: null,
_contextModeChanging: false,
_contextMode: 'space-missions',
_contextModeEntering: null,
_syncContextModeButtons() {},
showToast(message) {
toasts.push(message);
},
toasts,
};
}
const ENGLISH_GUARD =
'Space Missions isolates replay data. Exit the mode to enable Live Flights.';
test.afterEach(() => setLocale('en'));
test('the guard reason is English data and parses back to its layer name', () => {
const reason = contextLayerEnableBlockReason({
contextMode: 'space-missions',
change: { layerId: 'flights', enabled: true, origin: 'user' },
layerName: 'Live Flights',
});
assert.equal(reason, ENGLISH_GUARD);
assert.deepEqual(parseContextLayerBlockReason(reason), {
layer: 'Live Flights',
});
assert.equal(parseContextLayerBlockReason('Data source unavailable'), null);
assert.equal(
parseContextLayerBlockReason(
'Space Missions isolates replay data. Exit the mode to enable .',
),
null,
);
});
test('en: the toast shows the reason unchanged', () => {
setLocale('en');
const shell = fakeShell();
_handleContextLayerChange.call(shell, {
type: 'visibility-blocked',
layerId: 'flights',
enabled: true,
origin: 'user',
reason: ENGLISH_GUARD,
});
assert.deepEqual(shell.toasts, [ENGLISH_GUARD]);
assert.equal(
localizeVisibilityBlockReason('Overpass returned HTTP 503'),
'Overpass returned HTTP 503',
);
});
for (const locale of ['es', 'ru']) {
test(`${locale}: the Space Missions block toast is localized with the layer name`, () => {
setLocale(locale);
const reason = contextLayerEnableBlockReason({
contextMode: 'space-missions',
change: { layerId: 'flights', enabled: true, origin: 'user' },
layerName: 'Live Flights',
});
const shell = fakeShell();
_handleContextLayerChange.call(shell, {
type: 'visibility-blocked',
layerId: 'flights',
enabled: true,
origin: 'user',
reason,
});
assert.equal(shell.toasts.length, 1);
assert.notEqual(shell.toasts[0], ENGLISH_GUARD, 'toast must be localized');
assert.doesNotMatch(shell.toasts[0], /Space Missions isolates/);
assert.doesNotMatch(shell.toasts[0], /Live Flights/);
});
test(`${locale}: a known lifecycle reason is looked up in the catalog`, () => {
setLocale(locale);
const shell = fakeShell();
shell._contextMode = null;
_handleContextLayerChange.call(shell, {
type: 'visibility-blocked',
layerId: 'street-level',
enabled: true,
origin: 'user',
reason: 'Data source unavailable',
});
assert.equal(shell.toasts.length, 1);
assert.notEqual(shell.toasts[0], 'Data source unavailable');
});
test(`${locale}: an unknown provider message passes through verbatim`, () => {
setLocale(locale);
assert.equal(
localizeVisibilityBlockReason('Overpass returned HTTP 503'),
'Overpass returned HTTP 503',
);
});
}
+42 -9
View File
@@ -1,11 +1,30 @@
import { t, translateKnown } from '../i18n/index.js';
import {
SPACE_MISSIONS_LAYER_BLOCK_REASON,
cockpitEntryAllowed,
isExplicitUserIntentOrigin,
parseContextLayerBlockReason,
recordContextRestoreExplicitChange,
recordContextSessionUserChange,
shouldExitContextForLayerChange,
spaceMissionEntryCancellationDisposition,
} from '../contextModePolicy.js';
import { localizeLayerName } from './layerNames.js';
/**
* A blocked-visibility reason in the UI language. Reasons stay English (voice
* and tools read them): the Space Missions refusal is rebuilt with the layer
* name localized, a known sentence is looked up, anything else passes.
*/
export function localizeVisibilityBlockReason(reason) {
if (typeof reason !== 'string' || !reason) return reason;
const blocked = parseContextLayerBlockReason(reason);
if (blocked)
return t(SPACE_MISSIONS_LAYER_BLOCK_REASON, {
layer: localizeLayerName(blocked.layer),
});
return translateKnown(reason);
}
export function _handleContextLayerChange(change) {
if (this.destroyed) return;
@@ -58,12 +77,23 @@ 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;
},
'Space Missions cancellation could not restore the previous layer state', // i18n-ignore: English message; the toast shows localizedMessage
{
localizedMessage: t(
'Space Missions cancellation could not restore the previous layer state',
),
},
),
);
}
}
@@ -83,15 +113,18 @@ export function _handleContextLayerChange(change) {
!this._userFacingContextNotificationTokens.has(change.notificationToken)
) {
this.showToast(
change.reason ||
'That layer is unavailable in the current Context mode',
localizeVisibilityBlockReason(change.reason) ||
t('That layer is unavailable in the current Context mode'),
);
}
this._syncContextModeButtons();
return;
}
if (change?.type === 'visibility-failed') {
const failureMessage = `${change.layerId} could not ${change.enabled ? 'start' : 'stop'} cleanly`;
const failureMessage = t('{layerId} could not {action} cleanly', {
layerId: change.layerId,
action: change.enabled ? t('start') : t('stop'),
});
// 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
+10 -1
View File
@@ -1,3 +1,4 @@
import { t } from '../i18n/index.js';
import {
contextLayerEnableBlockReason,
isExplicitUserIntentOrigin,
@@ -124,7 +125,15 @@ export function connectContextManager(manager) {
restoreError,
);
}
return `${entryMode === 'space-missions' ? 'Space Missions' : 'Context'} could not start because another layer did not stop cleanly`;
return t(
'{mode} could not start because another layer did not stop cleanly',
{
mode:
entryMode === 'space-missions'
? t('Space Missions')
: t('Context'),
},
);
} finally {
if (ownsNotificationToken) {
this._userFacingContextNotificationTokens.delete(
+34
View File
@@ -0,0 +1,34 @@
// The CCTV "ACQUIRING FRAME" caption lives in CSS `content:` and the
// i18n scanner (scripts/i18n.mjs) never reads CSS, so es/ru show it in English.
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync, readdirSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const HERE = path.dirname(fileURLToPath(import.meta.url));
const STYLES = path.join(HERE, 'styles');
const LOCALES = path.join(HERE, '..', 'i18n', 'locales');
// cyber.css carries decorative theme strings (LOC / GRID, SYS 06 // ...);
// street-level.css ' · ESC' is a key cap. Neither is UI copy.
const DECORATIVE = new Set(['cyber.css', 'street-level.css']);
test('no user-visible text in CSS content: outside decorative themes', () => {
const hits = [];
for (const file of readdirSync(STYLES)) {
if (!file.endsWith('.css') || DECORATIVE.has(file)) continue;
const css = readFileSync(path.join(STYLES, file), 'utf8');
css.split('\n').forEach((line, i) => {
if (/^\s*content:\s*['"][^'"]*[A-Za-z]{2,}/.test(line))
hits.push(`${file}:${i + 1} ${line.trim()}`);
});
}
assert.deepEqual(hits, []);
});
test('the CCTV acquisition caption has es and ru translations', () => {
for (const lang of ['es', 'ru']) {
const dict = JSON.parse(readFileSync(path.join(LOCALES, `${lang}.json`), 'utf8'));
assert.ok('ACQUIRING FRAME' in dict, `${lang}.json lacks "ACQUIRING FRAME"`);
}
});
+1 -1
View File
@@ -617,7 +617,7 @@ test('the Display selector, voice schema and final stylesheet expose Cyber', ()
const militaryRendering = read('../layers/military/rendering.js');
const setHud = GEV_ACTION_SCHEMAS.find((schema) => schema.name === 'set_hud');
assert.match(display, /<option value="cyber">Cyber<\/option>/);
assert.match(display, /<option value="cyber" data-i18n>Cyber<\/option>/);
assert.match(display, /id="cyber-sonar-toggle"/);
assert.match(
display,
+69
View File
@@ -0,0 +1,69 @@
// Flights / Military feed failure texts reach the DATA LAYERS row in the
// UI language; the feeds keep them in English.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { LayerPanel } from './layerPanel.js';
import { setLocale } from '../i18n/index.js';
import { httpError, LiveSourceError } from '../sources/live/contract.js';
const panel = LayerPanel.prototype;
function flightsRow(error) {
return panel._buildMetaText({
id: 'flights',
enabled: true,
stats: {
count: 0,
stale: true,
error,
status: 429,
retryInSec: 45,
source: 'OpenSky',
lastUpdate: Date.now(),
},
});
}
const cases = [
['OpenSky 429 anonymous', 'OpenSky rate limited (anonymous)'],
['OpenSky auth', 'OpenSky auth required'],
['generic 429', httpError({ status: 429 }, 'Flights').message],
['HTTP 503', httpError({ status: 503 }, 'Military aircraft').message],
[
'network error',
new LiveSourceError('unavailable', 'Flights network error').message,
],
['stale snapshot', 'Source snapshot 7 min old'],
['snapshot time unknown', 'Source snapshot time unavailable'],
['non-LiveSourceError', 'Live data unavailable'],
['malformed', 'Malformed OpenSky response'],
['malformed rows', 'Malformed adsb.lol aircraft rows'],
];
test('en: feed failure texts stay English', () => {
setLocale('en');
for (const [, message] of cases)
assert.equal(
flightsRow(message),
`STALE · OpenSky · ${message} · retry 45s`,
);
});
for (const locale of ['es', 'ru']) {
test(`${locale}: feed failure texts are translated in the layer row`, () => {
setLocale(locale);
try {
for (const [name, message] of cases) {
const row = flightsRow(message);
assert.ok(
!row.includes(message),
`${locale} ${name}: row still contains English detail: ${row}`,
);
}
assert.match(flightsRow('Source snapshot 7 min old'), /\b7\b/);
assert.match(flightsRow('Flights HTTP 503'), /\b503\b/);
} finally {
setLocale('en');
}
});
}
+5 -1
View File
@@ -1,3 +1,4 @@
import { t } from '../i18n/index.js';
/** Count rendered globe frames while the optional readout is visible. */
export function createFrameRateMonitor({ viewer, documentRef = document }) {
const host = documentRef.getElementById('title-bar');
@@ -7,8 +8,9 @@ export function createFrameRateMonitor({ viewer, documentRef = document }) {
const readout = documentRef.createElement('div');
readout.className = 'frame-rate-readout';
readout.hidden = true;
// i18n-ignore: FPS is a unit code, the same in every language
readout.textContent = 'FPS —';
readout.title = 'Rendered globe frames per second · toggle with `';
readout.title = t('Rendered globe frames per second · toggle with `');
host.appendChild(readout);
let removeFrameListener = null;
let timer = null;
@@ -27,6 +29,7 @@ export function createFrameRateMonitor({ viewer, documentRef = document }) {
function show() {
frames = 0;
startedAt = performance.now();
// i18n-ignore: FPS is a unit code, the same in every language
readout.textContent = 'FPS —';
readout.hidden = false;
removeFrameListener = frameEvent.addEventListener(() => {
@@ -36,6 +39,7 @@ export function createFrameRateMonitor({ viewer, documentRef = document }) {
const now = performance.now();
const elapsed = now - startedAt;
readout.textContent =
// i18n-ignore: FPS is a unit code, the same in every language
documentRef.hidden || elapsed <= 0
? 'FPS —'
: `FPS ${Math.round((frames * 1000) / elapsed)}`;
+50 -13
View File
@@ -5,6 +5,14 @@ import {
MAX_IMPORTED_FILES,
loadGeoFile,
} from '../data/geoFileImport.js';
import {
formatNumber,
msg,
registerUnsavedWork,
t,
tn,
translateKnown,
} from '../i18n/index.js';
import { flyToDataSource } from './flyToDataSource.js';
// One color per file, in import order, from the whiteboard palette.
@@ -43,6 +51,11 @@ export function initGeoFileImport({
let cameraIntent = 0;
const lifetime = new AbortController();
let removeMapSource;
// Imports live for the page session: switching language (a reload) asks
// first while any are on the map.
const unregisterUnsavedWork = registerUnsavedWork(() =>
!destroyed && imports.length ? msg('imported map files') : null,
);
const listen = (target, type, listener, options) => {
if (!target?.addEventListener) return;
@@ -78,7 +91,9 @@ export function initGeoFileImport({
} catch {
if (!destroyed)
setHint(
'Ground heights could not be refreshed. Try the file again when the map has loaded.',
t(
'Ground heights could not be refreshed. Try the file again when the map has loaded.',
),
);
} finally {
groundQueued = false;
@@ -139,15 +154,22 @@ export function initGeoFileImport({
name.className = 'geo-import-name';
// textContent, never markup: the name comes from the file system.
name.textContent = item.name;
name.title = `${item.name} — ${item.format.toUpperCase()}, ${item.entityCount.toLocaleString()} map object${item.entityCount === 1 ? '' : 's'}${item.ownStyles ? ', drawn with its own styles' : ''}`;
name.title = `${item.name} — ${item.format.toUpperCase()}, ${tn(
item.entityCount,
'{count} map object',
'{count} map objects',
{ count: formatNumber(item.entityCount) },
)}${item.ownStyles ? t(', drawn with its own styles') : ''}`;
const actions = [
[
item.visible ? 'visibility' : 'visibility_off',
item.visible ? `Hide ${item.name}` : `Show ${item.name}`,
item.visible
? t('Hide {name}', { name: item.name })
: t('Show {name}', { name: item.name }),
'toggle',
],
['my_location', `Fly to ${item.name}`, 'fly'],
['close', `Remove ${item.name}`, 'remove'],
['my_location', t('Fly to {name}', { name: item.name }), 'fly'],
['close', t('Remove {name}', { name: item.name }), 'remove'],
].map(([icon, label, action]) => {
const btn = doc.createElement('button');
btn.type = 'button';
@@ -264,11 +286,13 @@ export function initGeoFileImport({
outcomes.push({
name: fileName,
ok: false,
error: `At most ${MAX_IMPORTED_FILES} files at once — remove one first.`,
error: t('At most {count} files at once — remove one first.', {
count: formatNumber(MAX_IMPORTED_FILES),
}),
});
continue;
}
setHint(`Reading ${fileName}…`);
setHint(t('Reading {name}…', { name: fileName }));
const color = COLORS[colorAt % COLORS.length];
let loaded;
try {
@@ -279,8 +303,8 @@ export function initGeoFileImport({
ok: false,
error:
error instanceof GeoImportError || error?.name === 'GeoImportError'
? error.message
: 'That file could not be read.',
? t(error.template || error.message, error.values)
: t('That file could not be read.'),
});
continue;
}
@@ -416,6 +440,7 @@ export function initGeoFileImport({
destroy() {
if (destroyed) return;
destroyed = true;
unregisterUnsavedWork();
lifetime.abort();
removeMapSource?.();
clearTimeout(groundTimer);
@@ -447,16 +472,28 @@ export function summarize(outcomes) {
const failed = outcomes.filter((o) => !o.ok);
const added = outcomes.length - failed.length;
const parts = [];
if (added) parts.push(`Added ${added} file${added === 1 ? '' : 's'}.`);
if (failed.length === 1) parts.push(`${failed[0].name}: ${failed[0].error}`);
if (added)
parts.push(
tn(added, 'Added {count} file.', 'Added {count} files.', {
count: formatNumber(added),
}),
);
// Import errors stay English in the outcome; they translate here.
const error = translateKnown(failed[0]?.error);
if (failed.length === 1) parts.push(`${failed[0].name}: ${error}`);
else if (failed.length > 1)
parts.push(
`${failed.length} files could not be added (${failed[0].name}: ${failed[0].error})`,
tn(
failed.length,
'{count} file could not be added ({name}: {error})',
'{count} files could not be added ({name}: {error})',
{ count: formatNumber(failed.length), name: failed[0].name, error },
),
);
const stripped = outcomes.reduce(
(n, o) => n + (o.ok ? o.removed || 0 : 0),
0,
);
if (added && stripped) parts.push('Some KML content was left out.');
if (added && stripped) parts.push(t('Some KML content was left out.'));
return parts.join(' ');
}
+31
View File
@@ -8,6 +8,7 @@ import assert from 'node:assert/strict';
import fs from 'node:fs';
import { initGeoFileImport, summarize } from './geoFileImport.js';
import { GeoImportError, MAX_IMPORTED_FILES } from '../data/geoFileImport.js';
import { describeUnsavedWork } from '../i18n/index.js';
class FakeClassList {
constructor() {
@@ -211,6 +212,19 @@ const rows = (els) => els['geo-import-list'].children;
const action = (row, name) =>
row.children.find((c) => c.dataset.action === name);
// Runs first: tools left by later tests would answer the same registry.
test('imported files make the language switch ask first', async () => {
const t = setup();
assert.deepEqual(describeUnsavedWork(), []);
await t.tool.importFiles([file('a.geojson')]);
assert.deepEqual(describeUnsavedWork(), ['imported map files']);
t.tool.remove(t.tool.list()[0].id);
assert.deepEqual(describeUnsavedWork(), [], 'nothing left to lose');
await t.tool.importFiles([file('b.kml')]);
t.tool.destroy();
assert.deepEqual(describeUnsavedWork(), [], 'destroy unregisters');
});
test('returns null without its markup or viewer', () => {
const document = { getElementById: () => null };
assert.equal(initGeoFileImport({ viewer: {}, document }), null);
@@ -698,3 +712,20 @@ test('KML and KMZ rows say they keep their own styles instead of showing a color
assert.ok(!gpx.children[0].classList.contains('own-styles'));
assert.ok(gpx.children[0].style.background);
});
test('the map-object count in a row title keeps digit grouping', async () => {
const t = setup({
load: async (f) => ({
dataSource: {},
name: f.name,
format: 'geojson',
entityCount: 12345,
removed: 0,
revoke() {},
}),
});
await t.tool.importFiles([file('big.geojson')]);
const [row] = rows(t.els);
assert.match(row.children[1].title, /12,345 map objects/);
t.tool.destroy();
});
+4 -2
View File
@@ -13,6 +13,7 @@ import {
releasePointer,
} from '../data/inputOwnership.js';
import { validateBox } from '../layers/recentImagery/model.js';
import { msg } from '../i18n/index.js';
/** The id this tool claims the pointer under. */
export const IMAGERY_BOX_POINTER_OWNER = 'recent-imagery-box';
@@ -150,7 +151,7 @@ export function initImageryBoxTool({
if (!active || destroyed || anchor) return;
const point = worldAt(event.position);
if (!point) {
onCancel?.('sky', 'Press on the ground, not the sky');
onCancel?.('sky', msg('Press on the ground, not the sky'));
return;
}
anchor = point;
@@ -169,7 +170,8 @@ export function initImageryBoxTool({
corner = worldAt(event.position) || corner;
const box = dragBox();
const result = validateBox(box);
if (result.reason === 'degenerate') result.message = 'Drag to size the box';
if (result.reason === 'degenerate')
result.message = msg('Drag to size the box');
endDrag();
setActive(false);
if (result.ok) onBox(result.box);
+49
View File
@@ -0,0 +1,49 @@
/**
* Key guidance in the UI language. keySetupCore.mjs builds the same sentences
* in English for the server and the AI tools; these are the ones people read.
*/
import {
KEY_SETUP_KEYS,
keySetupRequirementEnvVars,
} from '../keySetupCore.mjs';
import { t, translateKnown } from '../i18n/index.js';
/** "Needs X — add it in Provider Settings", or '' for an unknown or hidden key. */
export function keyRequirementText(id) {
const envVars = id ? keySetupRequirementEnvVars(id) : null;
return envVars
? t('Needs {envVars} — add it in Provider Settings', { envVars })
: '';
}
/** The key's product name in title case: GOOGLE MAPS → Google Maps. */
function keyName(id) {
const entry = KEY_SETUP_KEYS.find((candidate) => candidate.id === id);
return entry
? entry.title
.toLowerCase()
.replace(/\b\w/g, (letter) => letter.toUpperCase())
: null;
}
/**
* The requirement, then the switched-off sources that would draw with a key
* already set (see keySetupWithAlternatives).
*
* @param {string} requirement
* @param {Array<{label: string, requiresKeyId: string|null}>} [alternatives]
*/
export function keyGuidanceText(requirement, alternatives = []) {
const options = alternatives.map(({ label, requiresKeyId }) => {
const name = keyName(requiresKeyId);
const source = translateKnown(label);
return name
? t('light {source} to use your {name} key', { source, name })
: t('light {source}', { source });
});
if (!options.length) return requirement;
const offer = options.join(t(', or '));
if (requirement)
return t('{requirement}, or {offer}', { requirement, offer });
return offer.charAt(0).toLocaleUpperCase() + offer.slice(1);
}
+2 -1
View File
@@ -130,9 +130,10 @@ export class LayerBindings {
]) {
const layer = this._dataManager?.layers?.get(id)?.module;
if (typeof layer?.attachShellServices !== 'function') continue;
const noun = id === 'weather-cyclones' ? 'cyclone' : 'weather';
layer.attachShellServices({
runNavigation: (navigate) =>
this.runImmediateNavigation('weather', navigate),
this.runImmediateNavigation(noun, navigate),
imageryHost: this.services.imageryHost,
});
this._weatherShellModules.push(layer);
+138
View File
@@ -0,0 +1,138 @@
/**
* Layer and source names as the page shows them.
*
* Layer ids, English module names (`layer.name`) and declared source names
* stay English in layer state, share links and AI tool output; these helpers
* translate them only at a presentation boundary. The layer panel, the HUD
* and the voice card name a layer the same way through them.
*
* Pure: imports only i18n, no DOM.
* @module ui/layerNames
*/
import { msg, t, translateKnown } from '../i18n/index.js';
/** English module names (`layer.name`) by layer id. */
export const LAYER_NAME_KEYS = Object.freeze({
flights: msg('Live Flights'),
military: msg('Military Flights'),
earthquakes: msg('Earthquakes (24h)'),
satellites: msg('Satellites'),
'rocket-launches': msg('Space Missions (30d)'),
traffic: msg('Street Traffic'),
cctv: msg('CCTV'),
radio: msg('Radio'),
bikeshare: msg('Bikeshare'),
'ais-live-vessels': msg('Live AIS Vessels'),
'military-installations': msg('Mapped Installations'),
'military-awareness': msg('Global Context'),
'local-datacenters': msg('Datacenters'),
'local-dams': msg('Dams'),
'telegeography-submarine-cables': msg('Submarine Cables'),
'local-firms': msg('FIRMS Active Fires'),
'local-adsb': msg('Local ADS-B'),
transit: msg('Transit'),
'recent-imagery': msg('Recent Imagery'),
'fire-perimeters': msg('Fire Perimeters'),
'gnss-integrity': msg('GNSS Integrity'),
wind: msg('Wind'),
'weather-radar': msg('Rain radar'),
'weather-satellite': msg('Satellite clouds'),
'weather-lightning': msg('Lightning density'),
'weather-cyclones': msg('Cyclone advisories'),
'air-quality': msg('Air quality'),
directions: msg('Directions'),
'alpr-cameras': msg('ALPR Cameras'),
});
/** The layer panel's own English names, where they differ from the module's. */
export const PANEL_LABEL_KEYS = Object.freeze({
'ais-live-vessels': msg('Live Vessels'),
bikeshare: msg('Bike Share'),
cctv: msg('Cameras'),
'street-level': msg('Street Level'),
'alpr-cameras': msg('Mapped ALPR Cameras'),
'local-datacenters': msg('Data Centers'),
'local-firms': msg('Active Fires'),
});
// Looked up by the English text, so a surface that holds `layer.name` keeps
// its English data and only the shown text translates.
const MODULE_NAME_KEYS = new Map(
[...Object.values(LAYER_NAME_KEYS), ...Object.values(PANEL_LABEL_KEYS)].map(
(key) => [key, key],
),
);
/**
* Translate a layer's English module name for display; any other text
* (a provider name, an unknown layer) is returned unchanged.
* @param {string} name English `layer.name`.
* @returns {string}
*/
export function localizeLayerName(name) {
const key = MODULE_NAME_KEYS.get(String(name ?? '').trim());
return key ? t(key) : name;
}
/**
* A layer id's module name in the active language, or null for an id this
* module does not know.
* @param {string} id Layer id, e.g. `earthquakes`.
* @returns {string|null}
*/
export function layerNameForId(id) {
const key = Object.hasOwn(LAYER_NAME_KEYS, id)
? LAYER_NAME_KEYS[id]
: Object.hasOwn(PANEL_LABEL_KEYS, id)
? PANEL_LABEL_KEYS[id]
: null;
return key ? t(key) : null;
}
/**
* A layer as shown: its English module name or its id, whichever is known,
* in the active language; otherwise the text as given.
* @param {string} [name] English `layer.name`, or an id when the layer had none.
* @param {string} [id] Layer id.
* @returns {string}
*/
export function shownLayerName(name, id) {
const text = String(name ?? '').trim();
if (text && MODULE_NAME_KEYS.has(text)) return t(MODULE_NAME_KEYS.get(text));
return layerNameForId(id || text) || text || String(id ?? '');
}
// Layer-declared source names. Provider names (USGS, CelesTrak, GTFS-RT…)
// are not listed and pass through unchanged unless the catalog knows them.
const SOURCE_LABEL_KEYS = new Map(
[
msg('Flights'),
msg('Military aircraft'),
msg('Vessels'),
msg('CCTV + Street View fallback'),
msg('OpenStreetMap + optional Google Maps Places'),
msg('GFS / ECMWF IFS · FORECAST'),
msg('NOAA nowCOAST · OBSERVED'),
msg('NASA FIRMS · LIVE'),
msg('OSM routing'),
msg('Mapped camera locations'),
msg('OpenStreetMap · community mapped'),
].map((key) => [key, key]),
);
/**
* A layer's declared source name as shown.
* @param {string} source English source name, e.g. `Flights` or `OpenSky`.
* @returns {string}
*/
export function sourceLabel(source) {
if (typeof source !== 'string') return source;
if (source === 'Local') return t('Local', { context: 'data source' });
const key = SOURCE_LABEL_KEYS.get(source);
if (key) return t(key);
// A layer with several providers joins their names ("Mapillary · Google
// Street View"); a name with a translation shows it.
return source.includes(' · ')
? source.split(' · ').map(translateKnown).join(' · ')
: translateKnown(source);
}
+53
View File
@@ -0,0 +1,53 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { APPLICATION_LAYER_METADATA } from '../app/constructCatalog.js';
import { setLocale, t } from '../i18n/index.js';
import { FEED_SOURCE, COMBINED_SOURCE } from '../layers/localAdsb/status.js';
import {
LAYER_NAME_KEYS,
PANEL_LABEL_KEYS,
sourceLabel,
} from './layerNames.js';
// Layers whose module name is a proper name and stays as it is in every
// language: the Bhote Koshi pair belong to a scripted scene.
const UNTRANSLATED_NAMES = new Set(['bhote-koshi-2026', 'bhote-koshi-locator']);
const catalogIds = APPLICATION_LAYER_METADATA.map((layer) => layer.id);
test('every catalog layer has a translatable name', () => {
const missing = catalogIds.filter(
(id) =>
!UNTRANSLATED_NAMES.has(id) &&
!Object.hasOwn(LAYER_NAME_KEYS, id) &&
!Object.hasOwn(PANEL_LABEL_KEYS, id),
);
assert.deepEqual(
missing,
[],
'add the layer to LAYER_NAME_KEYS in src/ui/layerNames.js',
);
});
test('the name tables list only catalog layers', () => {
const unknown = [
...Object.keys(LAYER_NAME_KEYS),
...Object.keys(PANEL_LABEL_KEYS),
...UNTRANSLATED_NAMES,
].filter((id) => !catalogIds.includes(id));
assert.deepEqual(unknown, []);
});
for (const locale of ['es', 'ru']) {
test(`${locale}: Local ADS-B source names translate without their own entry`, () => {
setLocale(locale);
try {
for (const source of [FEED_SOURCE, COMBINED_SOURCE]) {
assert.notEqual(t(source), source, 'the catalog has the entry');
assert.equal(sourceLabel(source), t(source));
}
} finally {
setLocale('en');
}
});
}
+228 -77
View File
@@ -3,25 +3,43 @@ import { syncRowList } from './rowList.js';
import { layerFeedState } from '../data/feedState.js';
export { layerFeedState } from '../data/feedState.js';
import { GUIDANCE_STATUSES } from '../loadingFeedback.js';
import { keyGuidanceText, keyRequirementText } from './keyGuidance.js';
import {
keySetupRequirement,
keySetupWithAlternatives,
} from '../keySetupCore.mjs';
LAYER_NAME_KEYS,
PANEL_LABEL_KEYS as PANEL_LABELS,
sourceLabel,
} from './layerNames.js';
export { localizeLayerName } from './layerNames.js';
import { createWeatherPanel } from './weatherPanel.js';
const FEED_STATE_LABELS = Object.freeze({
nominal: 'ON',
loading: 'LOADING',
degraded: 'DEGRADED',
stale: 'STALE',
partial: 'PARTIAL',
fallback: 'FALLBACK',
unavailable: 'UNAVAILABLE',
import { translateFeedError, translateStatusLine } from './statusText.js';
import {
formatNumber,
getLocale,
msg,
t,
translateKnown,
} from '../i18n/index.js';
// Feed-state values are machine ids (QA hooks, CSS feed-* classes); only the
// label translates.
const FEED_STATE_LABEL_KEYS = Object.freeze({
nominal: msg('ON'),
loading: msg('LOADING'),
degraded: msg('DEGRADED'),
stale: msg('STALE'),
partial: msg('PARTIAL'),
fallback: msg('FALLBACK'),
unavailable: msg('UNAVAILABLE'),
});
function lifecycleStateLabel(lifecycleState) {
return t(lifecycleState === 'enabling' ? msg('ENABLING') : msg('DISABLING'));
}
// Presentation order is independent of catalog registration and startup order.
const PANEL_GROUPS = [
{
label: 'Movement',
label: msg('Movement'),
ids: [
'satellites',
'flights',
@@ -34,11 +52,11 @@ const PANEL_GROUPS = [
],
},
{
label: 'Cameras',
label: msg('Cameras'),
ids: ['cctv', 'recent-imagery', 'street-level'],
},
{
label: 'Infrastructure',
label: msg('Infrastructure'),
ids: [
'alpr-cameras',
'military-installations',
@@ -48,7 +66,7 @@ const PANEL_GROUPS = [
],
},
{
label: 'Events',
label: msg('Events'),
ids: [
'rocket-launches',
'earthquakes',
@@ -58,7 +76,7 @@ const PANEL_GROUPS = [
],
},
{
label: 'Weather',
label: msg('Weather'),
ids: [
'air-quality',
'wind',
@@ -69,7 +87,7 @@ const PANEL_GROUPS = [
],
},
{
label: 'Utilities',
label: msg('Utilities'),
ids: ['directions', 'radio'],
},
];
@@ -79,18 +97,16 @@ const PANEL_ORDER = PANEL_GROUPS.flatMap(({ label, ids }) =>
const PANEL_POSITIONS = new Map(
PANEL_ORDER.map(({ id }, index) => [id, index]),
);
const PANEL_LABELS = {
'ais-live-vessels': 'Live Vessels',
bikeshare: 'Bike Share',
cctv: 'Cameras',
'street-level': 'Street Level',
'alpr-cameras': 'Mapped ALPR Cameras',
'local-datacenters': 'Data Centers',
'local-firms': 'Active Fires',
};
function englishPanelLabel(layer) {
return PANEL_LABELS[layer.id] || LAYER_NAME_KEYS[layer.id] || layer.name;
}
// PANEL_LABELS win over layer-name keys; otherwise the module's own name.
function panelLabel(layer) {
return PANEL_LABELS[layer.id] || layer.name;
if (PANEL_LABELS[layer.id]) return t(PANEL_LABELS[layer.id]);
const key = LAYER_NAME_KEYS[layer.id];
return key ? t(key) : layer.name;
}
/**
@@ -111,12 +127,71 @@ export function layerKeyRequirementTooltip(layer = {}) {
if (layer?.stats?.keyRequired !== true) return '';
const requiresKeyId = String(layer.requiresKeyId || '').trim();
// A layer with several sources also names the ones it could use instead.
return keySetupWithAlternatives(
requiresKeyId ? keySetupRequirement(requiresKeyId) : '',
return keyGuidanceText(
requiresKeyId ? keyRequirementText(requiresKeyId) : '',
layer.stats.keyAlternatives || [],
);
}
const ROW_TEXT_SEPARATOR = ' · ';
// A short Latin code joined by hyphens. Longer runs (a URL, a token in an
// error) keep their break opportunities so they cannot overflow the rail.
const HYPHENATED_CODE = /[A-Za-z0-9]+(?:-[A-Za-z0-9]+)+/g;
const HYPHENATED_CODE_MAX = 16;
/** A span for one run of row text; `keep` stops the browser breaking inside it. */
function rowTextSpan(text, keep) {
const span = document.createElement('span');
if (keep) span.className = 'data-keep';
span.textContent = text;
return span;
}
/**
* Set a row's name, count or meta line so that a narrow column wraps it well
* in every language: a Latin code with hyphens (ADS-B, RTL-SDR, GTFS-RT) never
* splits at its hyphen, and a " · " separator stays at the end of the line it
* closes, so no line starts with "·". The element's textContent is still the
* plain string; text with neither feature is set directly.
*
* @param {HTMLElement} element
* @param {string} text
*/
export function setRowText(element, text) {
const value = String(text ?? '');
// Refreshes run every second; leave unchanged text (and its spans) alone.
if (element.textContent === value) return;
const codes = [...value.matchAll(HYPHENATED_CODE)].filter(
(match) => match[0].length <= HYPHENATED_CODE_MAX,
);
if (!codes.length && !value.includes(ROW_TEXT_SEPARATOR)) {
element.textContent = value;
return;
}
const spans = [];
const pushPlain = (from, to) => {
if (to <= from) return;
const pieces = value.slice(from, to).split(ROW_TEXT_SEPARATOR);
pieces.forEach((piece, index) => {
// The space before the dot belongs to a no-wrap span, so the only break
// opportunity around a separator is the space after it.
if (index > 0) spans.push(rowTextSpan(' ·', true));
const textPiece = index > 0 ? ` ${piece}` : piece;
if (textPiece) spans.push(rowTextSpan(textPiece, false));
});
};
let cursor = 0;
for (const match of codes) {
pushPlain(cursor, match.index);
spans.push(rowTextSpan(match[0], true));
cursor = match.index + match[0].length;
}
pushPlain(cursor, value.length);
element.textContent = '';
for (const span of spans) element.appendChild(span);
}
/** Layer row presentation over supplied state and actions; no layer imports. */
export class LayerPanel {
constructor({
@@ -218,17 +293,20 @@ export class LayerPanel {
for (const layer of layers) {
if (!layer.showInTogglePanel) continue;
const group =
PANEL_ORDER[PANEL_POSITIONS.get(layer.id)]?.label ?? 'Other layers';
PANEL_ORDER[PANEL_POSITIONS.get(layer.id)]?.label ??
msg('Other layers');
if (group && group !== previousGroup) {
const heading = document.createElement('h3');
heading.className = 'data-layer-group-heading';
heading.textContent = group;
heading.textContent = t(group);
this._toggleContainer.appendChild(heading);
}
previousGroup = group;
const row = document.createElement('div');
row.className = 'data-toggle-row';
row.dataset.layerId = layer.id;
// English name for voice tools, which must not receive translated text.
row.dataset.layerName = englishPanelLabel(layer);
const topRow = document.createElement('div');
topRow.className = 'data-toggle-top';
@@ -241,7 +319,7 @@ export class LayerPanel {
icon.textContent = layer.icon;
const name = document.createElement('span');
name.className = 'data-name';
name.textContent = panelLabel(layer);
setRowText(name, panelLabel(layer));
left.appendChild(icon);
left.appendChild(name);
@@ -250,7 +328,7 @@ export class LayerPanel {
const count = document.createElement('span');
count.className = 'data-count';
count.textContent = this._layerCountText(layer.stats);
this._syncCount(row, count, layer.stats);
const toggle = document.createElement('button');
toggle.type = 'button';
@@ -289,7 +367,7 @@ export class LayerPanel {
const bottomRow = document.createElement('div');
bottomRow.className = 'data-toggle-meta';
bottomRow.textContent = this._buildMetaText(layer);
setRowText(bottomRow, this._buildMetaText(layer));
row.appendChild(topRow);
row.appendChild(bottomRow);
@@ -393,6 +471,23 @@ export class LayerPanel {
);
}
/**
* Write a row's count. Outside English a count phrase ("24 nearby · 2 on
* screen") gets its own line, or it squeezes a longer translated name to a
* word per line. "—" is marked so a wide status chip can replace it
* (radio.css).
*/
_syncCount(row, count, stats) {
const text = this._layerCountText(stats);
setRowText(count, text);
const phrase =
getLocale() !== 'en' &&
typeof stats.countLabel === 'string' &&
/\d/.test(stats.countLabel);
row.classList.toggle('data-toggle-row--count-line', phrase);
count.classList.toggle('data-count-empty', text === '—');
}
/** Qualify a loaded count when it does not mean items currently on screen. */
_layerCountText(stats) {
if (typeof stats.countLabel === 'string' && stats.countLabel.trim())
@@ -516,12 +611,12 @@ export class LayerPanel {
const count = row.querySelector('.data-count');
if (count) {
count.textContent = this._layerCountText(layer.stats);
this._syncCount(row, count, layer.stats);
}
const meta = row.querySelector('.data-toggle-meta');
if (meta) {
meta.textContent = this._buildMetaText(layer);
setRowText(meta, this._buildMetaText(layer));
}
this._syncRowControls(
@@ -536,23 +631,39 @@ export class LayerPanel {
_buildMetaText(layer) {
const stats = layer.stats || {};
const feedState = layerFeedState(stats);
const stateLabel = FEED_STATE_LABELS[feedState];
const source = stats.source || layer.source;
const stateLabel = t(FEED_STATE_LABEL_KEYS[feedState]);
const source = sourceLabel(stats.source || layer.source);
const lifecycleState =
layer.lifecycleState || (layer.enabled ? 'enabled' : 'disabled');
if (lifecycleState === 'enabling' || lifecycleState === 'disabling') {
return `${lifecycleState.toUpperCase()} · ${source}`;
return t('{state} · {source}', {
state: lifecycleStateLabel(lifecycleState),
source,
});
}
if (layer.lifecycleUncertain) {
return `UNCERTAIN · ${source} · lifecycle state requires reconciliation`;
return t(
'UNCERTAIN · {source} · lifecycle state requires reconciliation',
{ source },
);
}
const presentedError =
stats.error || stats.lastError || stats.managerRefreshError;
const presentedError = translateFeedError(
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('{state} · {source} · {detail} · retry {seconds}s', {
state: stateLabel,
source,
detail: presentedError,
seconds: stats.retryInSec,
});
}
return `${stateLabel} · ${source} · ${presentedError}`;
return t('{state} · {source} · {detail}', {
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.
@@ -561,22 +672,26 @@ export class LayerPanel {
typeof stats.statusMessage === 'string' &&
stats.statusMessage.trim()
) {
return `${source} · ${stats.statusMessage.trim()}`;
return `${source} · ${translateKnown(stats.statusMessage.trim())}`;
}
const ago = stats.lastUpdate ? this._timeAgo(stats.lastUpdate) : 'never';
const ago = stats.lastUpdate ? this._timeAgo(stats.lastUpdate) : t('never');
if (stats.loading) {
const loadingLabel =
typeof stats.loadingLabel === 'string' && stats.loadingLabel.trim()
? stats.loadingLabel.trim()
: 'loading...';
return `${source} · ${loadingLabel}`;
? translateStatusLine(stats.loadingLabel.trim())
: t('loading...');
return t('{source} · {loadingLabel}', { source, loadingLabel });
}
if (feedState === 'fallback') {
const detail =
typeof stats.loadingLabel === 'string' && stats.loadingLabel.trim()
? stats.loadingLabel.trim()
? translateStatusLine(stats.loadingLabel.trim())
: stats.coverage || ago;
return `${stateLabel} · ${source} · ${detail}`;
return t('{state} · {source} · {detail}', {
state: stateLabel,
source,
detail,
});
}
if (feedState === 'partial') {
const { acceptedRowCount, rawRowCount } = stats;
@@ -585,21 +700,40 @@ export class LayerPanel {
Number.isInteger(rawRowCount) &&
acceptedRowCount >= 0 &&
rawRowCount > acceptedRowCount
? `${acceptedRowCount} of ${rawRowCount} records accepted`
: 'incomplete snapshot';
return `${stateLabel} · ${source} · ${detail} · ${ago}`;
? t('{accepted} of {raw} records accepted', {
accepted: acceptedRowCount,
raw: rawRowCount,
})
: t('incomplete snapshot');
return t('{state} · {source} · {detail} · {ago}', {
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('{state} · {source} · {ago} · retrying in {seconds}s', {
state: stateLabel,
source,
ago,
seconds: stats.retryInSec,
});
}
return t('{state} · {source} · {ago}', {
state: stateLabel,
source,
ago,
});
}
if (typeof stats.loadingLabel === 'string' && stats.loadingLabel.trim()) {
return `${source} · ${stats.loadingLabel.trim()}`;
return t('{source} · {loadingLabel}', {
source,
loadingLabel: translateStatusLine(stats.loadingLabel.trim()),
});
}
return `${source} · ${ago}`;
return t('{source} · {ago}', { source, ago });
}
_syncToggleButton(button, layer) {
@@ -618,7 +752,7 @@ export class LayerPanel {
button.classList.toggle('enabling', layer.lifecycleState === 'enabling');
button.classList.toggle('disabling', layer.lifecycleState === 'disabling');
button.classList.toggle('lifecycle-uncertain', uncertain);
for (const state of Object.keys(FEED_STATE_LABELS)) {
for (const state of Object.keys(FEED_STATE_LABEL_KEYS)) {
button.classList.toggle(
`feed-${state}`,
(layer.enabled || unavailable) && !uncertain && feedState === state,
@@ -638,38 +772,55 @@ export class LayerPanel {
String(transitioning || (unavailable && !layer.enabled)),
);
button.setAttribute('aria-busy', String(transitioning));
button.textContent = transitioning
? layer.lifecycleState.toUpperCase()
const showsFeedState =
!transitioning && !uncertain && (layer.enabled || unavailable);
const stateText = transitioning
? lifecycleStateLabel(layer.lifecycleState)
: uncertain
? 'UNCERTAIN'
: layer.enabled || unavailable
? FEED_STATE_LABELS[feedState]
: 'OFF';
? t('UNCERTAIN')
: showsFeedState
? t(FEED_STATE_LABEL_KEYS[feedState])
: t('OFF');
// The chip may abbreviate where a language's word is wider than the
// status column; the meta line and the accessible name keep the full word.
button.textContent =
showsFeedState && feedState === 'unavailable'
? t('UNAVAILABLE', { context: 'status chip' })
: stateText;
const keyGuidance = unavailable
? String(layer.stats.error || 'Data source unavailable')
? String(
translateFeedError(layer.stats.error) || t('Data source unavailable'),
)
: 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('{name}: {state}', {
name: panelLabel(layer),
state: stateText,
});
button.setAttribute(
'aria-label',
keyGuidance
? `${panelLabel(layer)}: ${button.textContent}. ${keyGuidance}`
: `${panelLabel(layer)}: ${button.textContent}`,
keyGuidance ? `${ariaLabel}. ${keyGuidance}` : ariaLabel,
);
}
_formatCount(n) {
if (n >= 1000) return `${(n / 1000).toFixed(1)}K`;
if (n >= 1000)
return `${formatNumber(n / 1000, {
minimumFractionDigits: 1,
maximumFractionDigits: 1,
useGrouping: false,
})}K`;
return String(n);
}
_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('just now');
if (diff < 60) return t('{count}s ago', { count: diff });
if (diff < 3600) return t('{count}m ago', { count: Math.floor(diff / 60) });
return t('{count}h ago', { count: Math.floor(diff / 3600) });
}
}
+72 -1
View File
@@ -13,7 +13,9 @@ test('panel presentation places Transit between Street Traffic and Bike Share in
source.indexOf('const PANEL_POSITIONS ='),
);
const order = JSON.parse(
runInNewContext(`${declarations}\nJSON.stringify(PANEL_ORDER)`),
runInNewContext(
`const msg = (text) => text;\n${declarations}\nJSON.stringify(PANEL_ORDER)`,
),
);
assert.deepEqual(
order.filter(({ label }) => label === 'Movement').map(({ id }) => id),
@@ -259,6 +261,75 @@ test('an omitted source is visibly unavailable and cannot be toggled on', async
);
});
test('row text keeps a hyphenated code whole and never starts a line with the separator', async () => {
const { setRowText } = await import('./layerPanel.js');
const previousDocument = globalThis.document;
const element = () => ({
className: '',
textContent: '',
children: [],
appendChild(child) {
this.children.push(child);
return child;
},
});
globalThis.document = { createElement: element };
try {
const text = (node) =>
node.children.length
? node.children.map((child) => child.textContent).join('')
: node.textContent;
const kept = (node) =>
node.children
.filter((child) => child.className === 'data-keep')
.map((child) => child.textContent);
const name = element();
setRowText(name, 'Локальный ADS-B');
assert.equal(text(name), 'Локальный ADS-B');
assert.deepEqual(kept(name), ['ADS-B']);
const meta = element();
setRowText(meta, 'UNAVAILABLE · NASA FIRMS · LIVE · KEY REQUIRED');
assert.equal(text(meta), 'UNAVAILABLE · NASA FIRMS · LIVE · KEY REQUIRED');
assert.deepEqual(kept(meta), [' ·', ' ·', ' ·']);
assert.ok(
meta.children.every((child) => !child.textContent.startsWith('·')),
'the space before each dot is held to the piece it closes',
);
const plain = element();
setRowText(plain, 'Satellites');
assert.equal(plain.textContent, 'Satellites');
assert.equal(plain.children.length, 0);
const long = element();
setRowText(long, 'see https-proxy-endpoint-config-x');
assert.deepEqual(kept(long), [], 'a long hyphenated run may still wrap');
} finally {
globalThis.document = previousDocument;
}
});
test('the unavailable chip may abbreviate, but its accessible name keeps the full state', async () => {
const { LayerPanel } = await import('./layerPanel.js');
const attrs = new Map();
const button = {
classList: { toggle() {} },
dataset: {},
setAttribute: (key, value) => attrs.set(key, value),
};
LayerPanel.prototype._syncToggleButton(button, {
id: 'local-firms',
name: 'FIRMS Active Fires',
enabled: false,
stats: { sourceUnavailable: true, error: 'KEY REQUIRED' },
});
assert.equal(button.textContent, 'UNAVAILABLE');
assert.equal(button.dataset.feedState, 'unavailable');
assert.match(attrs.get('aria-label'), /^Active Fires: UNAVAILABLE\./);
});
test('the Weather readout receives enabled air-quality alongside weather layers', async () => {
const { LayerPanel } = await import('./layerPanel.js');
const layers = ['wind', 'air-quality', 'other'].map((id) => ({
+89
View File
@@ -0,0 +1,89 @@
// The DATA LAYERS row shows the English status texts that GNSS, Transit,
// CelesTrak and AQHI produce in the UI language.
import assert from 'node:assert/strict';
import test from 'node:test';
import { setLocale } from '../i18n/index.js';
import { LayerPanel } from './layerPanel.js';
const row = (id, stats) =>
LayerPanel.prototype._buildMetaText({ id, enabled: true, stats });
const now = Date.now();
// [layer id, stats, English fragment that must not reach the row]
const cases = [
[
'gnss-integrity',
{
count: 0,
error: 'GNSS feed rate limited (HTTP 429)',
source: 'GNSS',
lastUpdate: now,
},
'rate limited',
],
[
'gnss-integrity',
{
count: 0,
error: 'GNSS feed unavailable (HTTP 503)',
source: 'GNSS',
lastUpdate: now,
},
'feed unavailable',
],
[
'transit',
{
count: 0,
error: 'GO Transit feed unavailable',
source: 'GTFS-RT',
lastUpdate: now,
},
'feed unavailable',
],
[
'transit',
{
count: 0,
loading: true,
loadingLabel: 'Loading GO Transit, Metro Transit',
source: 'GTFS-RT',
},
'Loading',
],
[
'satellites',
{
count: 10,
error: '1 CelesTrak group unavailable',
source: 'CelesTrak',
lastUpdate: now,
},
'group unavailable',
],
[
'air-quality',
{
count: 0,
error: 'ECCC request timed out',
source: 'ECCC',
lastUpdate: now,
},
'request timed out',
],
];
for (const locale of ['es', 'ru']) {
test(`${locale}: layer status texts are translated in the row`, () => {
setLocale(locale);
try {
const english = cases
.map(([id, stats, marker]) => [id, row(id, stats), marker])
.filter(([, text, marker]) => text.includes(marker))
.map(([id, text]) => `${id}: ${text}`);
assert.deepEqual(english, []);
} finally {
setLocale('en');
}
});
}
+51
View File
@@ -0,0 +1,51 @@
import test from 'node:test';
import assert from 'node:assert/strict';
test('Local ADS-B guidance hint is translated in the layer row', async () => {
const { setLocale, t } = await import('../i18n/index.js');
const { LayerPanel } = await import('./layerPanel.js');
const { localAdsbStatus } = await import('../layers/localAdsb/status.js');
const english = localAdsbStatus({
receiver: { webUsbSupported: true, connected: false },
feedState: null,
heard: 0,
});
assert.equal(
LayerPanel.prototype._buildMetaText.call(LayerPanel.prototype, {
id: 'local-adsb',
enabled: true,
lifecycleState: 'enabled',
stats: { count: 0, lastUpdate: null, ...english },
}),
'RTL-SDR · WebUSB · connect a receiver in Radio',
);
for (const locale of ['es', 'ru']) {
setLocale(locale);
for (const receiver of [
{ webUsbSupported: true, connected: false },
{ webUsbSupported: false, connected: false },
{ webUsbSupported: true, connected: true, mode: 'fm' },
]) {
const stats = localAdsbStatus({ receiver, feedState: null, heard: 0 });
const text = LayerPanel.prototype._buildMetaText.call(
LayerPanel.prototype,
{
id: 'local-adsb',
enabled: true,
lifecycleState: 'enabled',
stats: { count: 0, lastUpdate: null, ...stats },
},
);
assert.notEqual(
t(stats.statusMessage),
stats.statusMessage,
`${locale}: no catalog entry for "${stats.statusMessage}"`,
);
assert.ok(
!text.includes(stats.statusMessage),
`${locale}: row shows English hint: ${text}`,
);
}
}
setLocale('en');
});
+65
View File
@@ -0,0 +1,65 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { localSdrCardView } from './localSdrPresentation.js';
import { setLocale } from '../i18n/index.js';
const fmStreaming = {
webUsbSupported: true,
connected: true,
status: 'streaming',
message: 'Streaming',
mode: 'fm',
frequencyHz: 98_500_000,
volume: 0.8,
gain: 'auto',
seeking: false,
seekMessage: '',
decodedMessages: 0,
messagesPerSecond: null,
aircraftHeard: 0,
aircraftPositioned: 0,
deviceLabel: 'RTL2838',
locationStatus: 'unknown',
audioState: 'running',
samplesPerSecond: 2_400_000,
workerBlocks: 3,
iqLevelDbfs: -20,
audioLevelDbfs: -12,
};
test('FM status line names the AudioContext state in the UI language', () => {
try {
for (const [locale, state] of [
['es', 'running'],
['ru', 'running'],
['es', 'suspended'],
['ru', 'closed'],
['es', 'idle'],
['ru', 'idle'],
]) {
setLocale(locale);
const text = localSdrCardView({
...fmStreaming,
audioState: state,
}).statusText;
assert.doesNotMatch(
text,
new RegExp(`audio[: ]+${state}\\b|аудио[: ]+${state}\\b`),
`${locale}: ${text}`,
);
}
} finally {
setLocale('en');
}
});
test('FM status line keeps the English AudioContext state words', () => {
setLocale('en');
for (const state of ['running', 'suspended', 'closed', 'idle']) {
const text = localSdrCardView({
...fmStreaming,
audioState: state,
}).statusText;
assert.ok(text.endsWith(` · audio ${state}`), text);
}
});
+12 -1
View File
@@ -1,8 +1,10 @@
import { t } from '../i18n/index.js';
import {
internetRadioAudioActive,
localSdrCardView,
localSdrFeedLine,
localSdrFmAudioActive,
localSdrGainOptionLabel,
} from './localSdrPresentation.js';
const ELEMENT_IDS = Object.freeze({
@@ -77,6 +79,7 @@ export class LocalSdrControls {
this._renderFrame = null;
this._destroyed = false;
if (!this.elements.card) return;
this._labelGainOptions();
this._bind();
this._receiverUnsubscribe = receiver.subscribe((state) =>
this._onReceiverState(state),
@@ -95,6 +98,14 @@ export class LocalSdrControls {
return Boolean(this._state?.connected);
}
/** Numeric gain options read in the page language; values stay numeric. */
_labelGainOptions() {
for (const option of this.elements.gain?.options || []) {
if (option.value !== 'auto')
option.textContent = localSdrGainOptionLabel(option.value);
}
}
_listen(element, type, handler) {
element?.addEventListener(type, handler, {
signal: this._listeners.signal,
@@ -188,7 +199,7 @@ export class LocalSdrControls {
// Internet radio just started: it takes over from local FM.
if (radioAudioActive && !this._radioAudioActive && this._fmAudioActive) {
void this.receiver.stop({
message: 'Local FM stopped: internet radio started',
message: t('Local FM stopped: internet radio started'),
});
}
this._radioAudioActive = radioAudioActive;
+58
View File
@@ -0,0 +1,58 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { setLocale } from '../i18n/index.js';
import { localSdrFeedLine } from './localSdrPresentation.js';
for (const locale of ['es', 'ru']) {
test(`decoder feed line is translated in ${locale}`, () => {
setLocale(locale);
try {
const feeds = [{ id: '1090', band: '1090', status: 'live' }];
const polling = localSdrFeedLine({
configured: true,
polling: true,
feeds: [{ ...feeds[0], status: 'unreachable' }],
});
const idle = localSdrFeedLine({
configured: true,
polling: false,
feeds,
});
assert.doesNotMatch(polling, /Decoder feeds|unreachable/);
assert.match(polling, /1090/);
assert.doesNotMatch(idle, /Decoder feeds|read while Local ADS-B is on/);
assert.match(idle, /1090/);
} finally {
setLocale('en');
}
});
}
test('an unknown feed status is shown as given', () => {
setLocale('en');
const feeds = [{ band: '1090', status: 'odd' }];
assert.equal(
localSdrFeedLine({ configured: true, polling: true, feeds }),
'Decoder feeds: 1090 odd',
);
});
// A feed status agrees with "fuente" in Spanish; the shared "invalid" key
// (any other masculine noun) is left alone.
test('es: an invalid decoder feed reads "no válida"', () => {
setLocale('es');
try {
const line = localSdrFeedLine({
configured: true,
polling: true,
feeds: [
{ band: '1090', status: 'live' },
{ band: '1090', status: 'invalid' },
],
});
assert.match(line, /no válida/);
assert.doesNotMatch(line, /no válido\b/);
} finally {
setLocale('en');
}
});
@@ -0,0 +1,92 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { setLocale } from '../i18n/index.js';
import { SdrController } from '../sdr/controller.js';
import { LocalSdrControls } from './localSdrControls.js';
import { localSdrCardView } from './localSdrPresentation.js';
function stubDocument() {
const elements = new Map();
const doc = {
activeElement: null,
getElementById(id) {
if (!elements.has(id)) {
const listeners = new Map();
elements.set(id, {
id,
value: '',
hidden: false,
disabled: false,
textContent: '',
ownerDocument: doc,
classList: { toggle() {} },
setAttribute() {},
closest: () => null,
addEventListener(type, handler) {
listeners.set(type, handler);
},
});
}
return elements.get(id);
},
};
return doc;
}
function stubRadio() {
const listeners = new Set();
return {
subscribe(fn) {
listeners.add(fn);
return () => listeners.delete(fn);
},
stopPlayback() {},
emit(state) {
for (const fn of listeners) fn(state);
},
};
}
async function stopLocalFmForInternetRadio(t, locale) {
setLocale(locale);
const receiver = new SdrController({ webUsb: {}, storage: null });
// Pretend local FM is streaming (no real device: stop() has nothing to tear down).
receiver._setState({ connected: true, status: 'streaming', mode: 'fm' });
const radio = stubRadio();
const controls = new LocalSdrControls({
document: stubDocument(),
receiver,
radio,
});
t.after(() => {
controls.destroy();
setLocale('en');
});
radio.emit({ audioState: 'playing' });
await new Promise((resolve) => setTimeout(resolve, 50));
assert.equal(receiver.state.connected, false);
return localSdrCardView(receiver.state);
}
for (const locale of ['es', 'ru']) {
test(`FM stop message after internet radio start is shown in ${locale}`, async (t) => {
const view = await stopLocalFmForInternetRadio(t, locale);
assert.notEqual(
view.connectLabel,
'CONNECT',
'surrounding UI is translated',
);
assert.notEqual(
view.statusText,
'Local FM stopped: internet radio started',
`status must not be the English literal under ${locale}`,
);
});
}
test('FM stop message after internet radio start stays English', async (t) => {
const view = await stopLocalFmForInternetRadio(t, 'en');
assert.equal(view.statusText, 'Local FM stopped: internet radio started');
});
+50
View File
@@ -0,0 +1,50 @@
// The SDR status line and stats write numbers in the UI language, like
// the frequency next to them ("Сканирование 101,1 МГц…").
import test from 'node:test';
import assert from 'node:assert/strict';
import { setLocale } from '../i18n/index.js';
import { localSdrCardView } from './localSdrPresentation.js';
const fm = {
connected: true,
status: 'streaming',
mode: 'fm',
message: 'Tuned',
seekMessage: '',
samplesPerSecond: 2_400_000,
iqLevelDbfs: -21.44,
audioLevelDbfs: -18.26,
workerBlocks: 0,
audioState: 'running',
gain: 20.7,
frequencyHz: 101_100_000,
volume: 0.5,
messagesPerSecond: 13.8,
webUsbSupported: true,
};
test('en: SDR readings keep their English digits', () => {
setLocale('en');
const view = localSdrCardView(fm);
assert.match(view.statusText, /· 2\.40 MS\/s IQ ·/);
assert.match(view.statusText, /· RF -21\.4 dBFS ·/);
assert.match(view.statusText, /-18\.3 dBFS audio/);
});
for (const locale of ['es', 'ru']) {
test(`${locale}: SDR readings use the locale's decimal comma`, () => {
setLocale(locale);
try {
const view = localSdrCardView(fm);
assert.match(view.statusText, /· 2,40 MS\/s IQ ·/);
assert.match(view.statusText, /· RF -21,4 dBFS ·/);
assert.match(view.statusText, /-18,3 dBFS/);
const adsb = localSdrCardView({ ...fm, mode: 'adsb' });
assert.equal(adsb.stats.rate, '13,8');
assert.equal(adsb.stats.iq, '-21,4 dBFS');
assert.equal(adsb.gainValue, '20.7', 'input values stay machine');
} finally {
setLocale('en');
}
});
}
+102 -22
View File
@@ -1,17 +1,89 @@
import { localReceiverFeedName } from '../layers/localAdsb/feedNames.js';
import { formatNumber, msg, t, tn } from '../i18n/index.js';
import { formatQuantity } from './unitLabels.js';
/**
* Pure presentation of the Local RTL-SDR card in the Radio panel. Receives the
* receiver session snapshot and returns what each control should show.
*/
// Receiver status codes shown on the connection chip and the connect button.
const STATUS_LABELS = Object.freeze({
idle: msg('IDLE'),
connecting: msg('CONNECTING'),
tuning: msg('TUNING'),
streaming: msg('STREAMING'),
error: msg('ERROR'),
unsupported: msg('UNSUPPORTED'),
});
function statusLabel(status) {
const key = String(status || 'idle');
return STATUS_LABELS[key] ? t(STATUS_LABELS[key]) : key.toUpperCase();
}
// AudioContext.state values (plus the controller's "idle") as shown in the
// FM status line; the value itself stays machine.
const AUDIO_STATE_LABELS = Object.freeze({
idle: msg('idle'),
running: msg('running'),
suspended: msg('suspended'),
closed: msg('closed'),
});
function audioStateLabel(audioState) {
const key = String(audioState || 'idle');
return AUDIO_STATE_LABELS[key] ? t(AUDIO_STATE_LABELS[key]) : key;
}
// Decoder feed statuses from the server route, as shown on the card. They
// carry their own context: the word agrees with "feed" (es "fuente no válida"),
// unlike the shared "invalid" / "stale" elsewhere.
function feedStatusLabel(status) {
const key = String(status || 'unreachable');
switch (key) {
case 'live':
return t('live', { context: 'feed status' });
case 'stale':
return t('stale', { context: 'feed status' });
case 'unreachable':
return t('unreachable', { context: 'feed status' });
case 'invalid':
return t('invalid', { context: 'feed status' });
default:
return key;
}
}
/**
* Text for a numeric gain option: the option value stays the machine value
* ("28.0"); only the label follows the locale ("28,0 дБ").
*/
export function localSdrGainOptionLabel(value) {
const gain = Number(value);
if (!Number.isFinite(gain)) return String(value);
return formatQuantity(gain, 'dB', {
minimumFractionDigits: 1,
maximumFractionDigits: 1,
});
}
function formatGain(gain) {
return gain === 'auto' || !Number.isFinite(gain) ? 'auto' : gain.toFixed(1);
}
/** A reading with a fixed number of decimals, in the UI language ("2,40"). */
function fixed(value, digits) {
return formatNumber(value, {
minimumFractionDigits: digits,
maximumFractionDigits: digits,
useGrouping: false,
});
}
function formatIq(state) {
return Number.isFinite(state.iqLevelDbfs)
? `${state.iqLevelDbfs.toFixed(1)} dBFS`
? `${fixed(state.iqLevelDbfs, 1)} dBFS`
: '--';
}
@@ -19,11 +91,16 @@ function statusText(state) {
const fmActive = state.mode === 'fm';
const iqStatus =
state.samplesPerSecond > 0
? `${(state.samplesPerSecond / 1_000_000).toFixed(2)} MS/s IQ`
: 'waiting for IQ';
? `${fixed(state.samplesPerSecond / 1_000_000, 2)} MS/s IQ`
: t('waiting for IQ');
const device = state.deviceLabel ? ` · ${state.deviceLabel}` : '';
if (state.connected && !fmActive) {
return `${state.message}${device} · ${iqStatus} · ${state.decodedMessages} messages`;
const decoded = tn(
state.decodedMessages,
'{count} message',
'{count} messages',
);
return `${state.message}${device} · ${iqStatus} · ${decoded}`;
}
const detail = state.seeking
? state.seekMessage
@@ -31,13 +108,16 @@ function statusText(state) {
if (state.connected && fmActive) {
const workerStatus =
state.workerBlocks > 0
? `DSP ${state.workerBlocks} blocks`
: 'DSP waiting';
? tn(state.workerBlocks, 'DSP {count} block', 'DSP {count} blocks')
: t('DSP waiting');
const rfStatus = `RF ${formatIq(state)}`;
const audioSignal = Number.isFinite(state.audioLevelDbfs)
? `${state.audioLevelDbfs.toFixed(1)} dBFS audio`
: 'audio signal --';
return `${detail}${device} · ${iqStatus} · ${workerStatus} · ${rfStatus} · ${audioSignal} · audio ${state.audioState}`;
? t('{level} dBFS audio', { level: fixed(state.audioLevelDbfs, 1) })
: t('audio signal --');
const audio = t('audio {state}', {
state: audioStateLabel(state.audioState),
});
return `${detail}${device} · ${iqStatus} · ${workerStatus} · ${rfStatus} · ${audioSignal} · ${audio}`;
}
return detail;
}
@@ -57,15 +137,15 @@ export function localSdrCardView(state) {
return {
connectionLabel: state.connected
? adsbActive
? `${state.aircraftHeard || 0} HEARD`
: 'STREAMING'
: String(state.status || 'idle').toUpperCase(),
? tn(state.aircraftHeard || 0, '{count} HEARD', '{count} HEARD')
: t('STREAMING')
: statusLabel(state.status),
connectionActive: Boolean(state.connected),
connectLabel: transitional
? String(state.status).toUpperCase()
? statusLabel(state.status)
: state.connected
? 'DISCONNECT'
: 'CONNECT',
? t('DISCONNECT')
: t('CONNECT'),
connectDisabled: transitional || !state.webUsbSupported,
connectPressed: Boolean(state.connected),
fmActive,
@@ -73,10 +153,10 @@ export function localSdrCardView(state) {
modeDisabled: transitional,
locateLabel:
state.locationStatus === 'requesting'
? 'LOCATING…'
? t('LOCATING…')
: state.locationStatus === 'ready'
? 'LOCATED'
: 'LOCATE',
? t('LOCATED')
: t('LOCATE'),
locateDisabled: state.locationStatus === 'requesting',
locateActive: state.locationStatus === 'ready',
changeDeviceDisabled: transitional || !state.webUsbSupported,
@@ -85,7 +165,7 @@ export function localSdrCardView(state) {
statsHidden: !(adsbActive && state.connected),
stats: {
rate: Number.isFinite(state.messagesPerSecond)
? state.messagesPerSecond.toFixed(1)
? fixed(state.messagesPerSecond, 1)
: '--',
heard: String(state.aircraftHeard || 0),
positioned: String(state.aircraftPositioned || 0),
@@ -138,9 +218,9 @@ export function localSdrFeedLine(feedState) {
const names = feeds.map((feed) => {
const name = localReceiverFeedName(feed, feeds);
return feedState.polling || feed?.status === 'invalid'
? `${name} ${feed?.status || 'unreachable'}`
? `${name} ${feedStatusLabel(feed?.status)}`
: name;
});
const suffix = feedState.polling ? '' : ' · read while Local ADS-B is on';
return `Decoder feeds: ${names.join(' · ')}${suffix}`;
const suffix = feedState.polling ? '' : t(' · read while Local ADS-B is on');
return `${t('Decoder feeds: {feeds}', { feeds: names.join(' · ') })}${suffix}`;
}
+14
View File
@@ -6,7 +6,9 @@ import {
localSdrCardView,
localSdrFeedLine,
localSdrFmAudioActive,
localSdrGainOptionLabel,
} from './localSdrPresentation.js';
import { setLocale } from '../i18n/index.js';
const idle = Object.freeze({
webUsbSupported: true,
@@ -153,3 +155,15 @@ test('two feeds on one band are told apart by their ordinal labels', () => {
'Decoder feeds: 1090 · 978 MHz UAT #1 · 978 MHz UAT #2 · read while Local ADS-B is on',
);
});
test('gain option labels follow the page language; values stay numeric', () => {
try {
setLocale('en');
assert.equal(localSdrGainOptionLabel('28.0'), '28.0\u00a0dB');
assert.equal(localSdrGainOptionLabel('0.9'), '0.9\u00a0dB');
setLocale('ru');
assert.match(localSdrGainOptionLabel('28.0'), /^28,0\u00a0\S+$/);
} finally {
setLocale('en');
}
});
+629
View File
@@ -0,0 +1,629 @@
/**
* Interface language picker: a HUD button in the bottom-right corner that
* opens a listbox of the shipped languages.
*
* The logic is pure and DOM-free; mountLocalePicker() only renders it. The
* shell decides what a choice does (confirm, reload).
*
* The list follows the ARIA listbox pattern: the button opens it, focus moves
* into the list (or into the search field, which is a combobox, once there are
* more than LOCALE_SEARCH_THRESHOLD languages), and aria-activedescendant
* marks the active option.
*/
import { t } from '../i18n/index.js';
import { LOCALES, LOCALE_NAMES } from '../i18n/locale.js';
/** Above this many languages the list gets a search field. */
export const LOCALE_SEARCH_THRESHOLD = 8;
/** Type-ahead keystrokes closer together than this extend one prefix. */
export const TYPEAHEAD_RESET_MS = 500;
/**
* Text folded for search: no diacritics, lower case. 'Español' → 'espanol',
* 'Ёжик' → 'ежик'.
* @param {unknown} text
* @returns {string}
*/
export function foldSearchText(text) {
return String(text ?? '')
.normalize('NFD')
.replace(/\p{M}+/gu, '')
.toLowerCase()
.trim();
}
/**
* A reader of language names in the interface language, via Intl.DisplayNames.
* Returns '' for anything it cannot name (no Intl support, unknown code).
* @param {string} locale Interface language.
* @param {typeof Intl.DisplayNames} [DisplayNames]
* @returns {(code: string) => string}
*/
export function createLocalNameReader(
locale,
DisplayNames = globalThis.Intl?.DisplayNames,
) {
let names = null;
try {
names = new DisplayNames([locale], { type: 'language', fallback: 'none' });
} catch {
return () => '';
}
return (code) => {
try {
const name = names.of(code);
return typeof name === 'string' && name !== code ? name : '';
} catch {
return '';
}
};
}
/**
* The rows of the list: the current language first, then the rest in
* alphabetical order of their own-language names (collated for the
* interface language). `localName` is the name in the interface language,
* left empty when it only repeats `nativeName`.
* @param {object} input
* @param {readonly string[]} input.locales
* @param {string} input.current
* @param {Record<string, string>} input.nativeNames
* @param {(code: string) => string} [input.localName]
* @returns {Array<{code: string, nativeName: string, localName: string, selected: boolean}>}
*/
export function buildLocaleOptions({
locales,
current,
nativeNames,
localName = () => '',
}) {
const rows = locales.map((code) => {
const nativeName = nativeNames[code] || code;
const translated = localName(code) || '';
return {
code,
nativeName,
localName:
foldSearchText(translated) === foldSearchText(nativeName)
? ''
: translated,
selected: code === current,
};
});
let collate;
try {
collate = new Intl.Collator(current).compare;
} catch {
collate = (a, b) => a.localeCompare(b);
}
const others = rows
.filter((row) => !row.selected)
.sort((a, b) => collate(a.nativeName, b.nativeName));
return [...rows.filter((row) => row.selected), ...others];
}
/**
* Rows matching a search: by own-language name, interface-language name or
* code, ignoring case and diacritics. An empty query keeps every row.
* @template {{code: string, nativeName: string, localName: string}} Row
* @param {Row[]} options
* @param {string} query
* @returns {Row[]}
*/
export function filterLocaleOptions(options, query) {
const needle = foldSearchText(query);
if (!needle) return options.slice();
return options.filter((option) =>
[option.nativeName, option.localName, option.code].some((value) =>
foldSearchText(value).includes(needle),
),
);
}
/**
* The option a navigation key moves to; null for any other key. The list
* does not wrap, as in the ARIA listbox pattern.
* @param {string} key
* @param {number} index Active option, -1 for none.
* @param {number} count
* @returns {number|null}
*/
export function moveActiveIndex(key, index, count) {
if (count <= 0) return null;
switch (key) {
case 'ArrowDown':
return index < 0 ? 0 : Math.min(index + 1, count - 1);
case 'ArrowUp':
return index < 0 ? count - 1 : Math.max(index - 1, 0);
case 'Home':
return 0;
case 'End':
return count - 1;
default:
return null;
}
}
/**
* Type-ahead: the next option after `fromIndex` (wrapping) whose own-language
* or interface-language name starts with `buffer`. A buffer of one repeated
* letter cycles through the options starting with that letter.
* @param {Array<{nativeName: string, localName: string}>} options
* @param {string} buffer
* @param {number} fromIndex
* @returns {number} -1 when nothing matches.
*/
export function typeaheadIndex(options, buffer, fromIndex) {
const folded = foldSearchText(buffer);
if (!folded || !options.length) return -1;
const repeated = [...folded].every((char) => char === folded[0]);
const prefix = repeated ? folded[0] : folded;
// A longer prefix keeps refining the current option; a single letter moves on.
const start = repeated ? fromIndex + 1 : Math.max(fromIndex, 0);
for (let step = 0; step < options.length; step++) {
const index = (start + step) % options.length;
const option = options[index];
if (
foldSearchText(option.nativeName).startsWith(prefix) ||
foldSearchText(option.localName).startsWith(prefix)
)
return index;
}
return -1;
}
/**
* What choosing a language does. The current one changes nothing; another one
* switches after `confirmSwitch()` agrees (it asks when a reload loses work).
* @param {string} locale
* @param {{current: string, confirmSwitch: () => boolean, switchLocale: (locale: string) => void}} actions
* @returns {'unchanged'|'declined'|'switched'}
*/
export function applyLocaleChoice(
locale,
{ current, confirmSwitch, switchLocale },
) {
if (locale === current) return 'unchanged';
if (!confirmSwitch()) return 'declined';
switchLocale(locale);
return 'switched';
}
/**
* The picker's state without a DOM: open/closed, query, visible rows, active
* row and type-ahead. `keydown` answers what a key did so the DOM layer only
* has to render and focus.
* @param {object} input
* @param {readonly string[]} [input.locales]
* @param {string} input.current
* @param {Record<string, string>} [input.nativeNames]
* @param {(code: string) => string} [input.localName]
* @param {number} [input.searchThreshold]
* @param {() => number} [input.now]
*/
export function createLocalePickerModel({
locales = LOCALES,
current,
nativeNames = LOCALE_NAMES,
localName = () => '',
searchThreshold = LOCALE_SEARCH_THRESHOLD,
now = () => Date.now(),
}) {
const options = buildLocaleOptions({
locales,
current,
nativeNames,
localName,
});
const searchable = locales.length > searchThreshold;
let open = false;
let query = '';
let visible = options;
let active = -1;
let typed = '';
let typedAt = -Infinity;
const activateSelected = () => {
const selected = visible.findIndex((option) => option.selected);
active = visible.length ? Math.max(selected, 0) : -1;
};
return {
current,
options,
searchable,
get open() {
return open;
},
get query() {
return query;
},
get visible() {
return visible;
},
get activeIndex() {
return active;
},
get activeOption() {
return visible[active] || null;
},
openList() {
open = true;
query = '';
visible = options;
typed = '';
activateSelected();
},
closeList() {
open = false;
typed = '';
},
setQuery(text) {
query = String(text ?? '');
visible = filterLocaleOptions(options, query);
// The current language stays active while it matches; else the first.
activateSelected();
},
setActive(index) {
if (index >= 0 && index < visible.length) active = index;
},
/**
* @param {{key: string, altKey?: boolean, ctrlKey?: boolean, metaKey?: boolean}} event
* @returns {{handled: boolean, close?: boolean, choose?: string}}
*/
keydown({ key, altKey = false, ctrlKey = false, metaKey = false }) {
if (!open) return { handled: false };
if (key === 'Escape') return { handled: true, close: true };
if (key === 'Enter' || (key === ' ' && !searchable)) {
const option = visible[active];
return option
? { handled: true, choose: option.code }
: { handled: true };
}
// In the search field Home/End move the caret, as in the APG combobox.
if (searchable && (key === 'Home' || key === 'End'))
return { handled: false };
const moved = moveActiveIndex(key, active, visible.length);
if (moved !== null) {
active = moved;
return { handled: true };
}
const printable =
key.length === 1 && key !== ' ' && !altKey && !ctrlKey && !metaKey;
// With a search field, letters are typed into it.
if (!printable || searchable) return { handled: false };
const time = now();
typed = time - typedAt > TYPEAHEAD_RESET_MS ? key : typed + key;
typedAt = time;
const match = typeaheadIndex(visible, typed, active);
if (match >= 0) active = match;
return { handled: true };
},
};
}
/* ── DOM ───────────────────────────────────────────────────────────────── */
const SVG_NS = 'http://www.w3.org/2000/svg';
/** Thin-line globe, check mark and caret, drawn in currentColor. */
const ICONS = Object.freeze({
globe: {
stroke: [
'M12 3a9 9 0 1 0 0 18a9 9 0 1 0 0-18Z',
'M12 3c-2.6 2.5-3.9 5.5-3.9 9s1.3 6.5 3.9 9',
'M12 3c2.6 2.5 3.9 5.5 3.9 9s-1.3 6.5-3.9 9',
'M3 12h18',
'M4.3 7.5h15.4',
'M4.3 16.5h15.4',
],
},
check: { stroke: ['M5 12.5l4.3 4.3L19 7'] },
caret: { fill: ['M7 10h10l-5 5.5Z'] },
});
function icon(documentRef, name, className) {
const svg = documentRef.createElementNS(SVG_NS, 'svg');
svg.setAttribute('viewBox', '0 0 24 24');
svg.setAttribute('aria-hidden', 'true');
svg.setAttribute('focusable', 'false');
svg.setAttribute('class', className);
const { stroke = [], fill = [] } = ICONS[name];
for (const d of stroke) {
const path = documentRef.createElementNS(SVG_NS, 'path');
path.setAttribute('d', d);
path.setAttribute('fill', 'none');
path.setAttribute('stroke', 'currentColor');
path.setAttribute('stroke-width', '1.4');
path.setAttribute('stroke-linecap', 'round');
path.setAttribute('stroke-linejoin', 'round');
svg.append(path);
}
for (const d of fill) {
const path = documentRef.createElementNS(SVG_NS, 'path');
path.setAttribute('d', d);
path.setAttribute('fill', 'currentColor');
svg.append(path);
}
return svg;
}
function element(documentRef, tag, className) {
const node = documentRef.createElement(tag);
if (className) node.className = className;
return node;
}
/**
* Render the picker into `root` and wire it.
* @param {object} input
* @param {HTMLElement} input.root Empty #locale-picker container.
* @param {string} input.current Active interface language.
* @param {(locale: string) => void} input.onSelect Called after the list
* closes, for a language other than the current one.
* @param {readonly string[]} [input.locales]
* @param {Record<string, string>} [input.nativeNames]
* @param {number} [input.searchThreshold]
* @returns {{open: Function, close: Function, isOpen: () => boolean, destroy: Function}}
*/
export function mountLocalePicker({
root,
current,
onSelect,
locales = LOCALES,
nativeNames = LOCALE_NAMES,
searchThreshold = LOCALE_SEARCH_THRESHOLD,
}) {
const documentRef = root.ownerDocument;
const windowRef = documentRef.defaultView;
const model = createLocalePickerModel({
locales,
current,
nativeNames,
localName: createLocalNameReader(current),
searchThreshold,
});
const id = root.id || 'locale-picker';
const ids = {
popup: `${id}-popup`,
heading: `${id}-heading`,
listbox: `${id}-listbox`,
option: (code) => `${id}-option-${code}`,
};
root.textContent = '';
root.classList.add('locale-picker');
const button = element(documentRef, 'button', 'locale-picker-button');
button.type = 'button';
button.setAttribute('aria-haspopup', 'listbox');
button.setAttribute('aria-expanded', 'false');
button.setAttribute('aria-controls', ids.popup);
button.setAttribute(
'aria-label',
t('Language: {language}', {
language: nativeNames[current] || current,
}),
);
button.title = t('Change the interface language');
const code = element(documentRef, 'span', 'locale-picker-code');
code.setAttribute('translate', 'no');
code.setAttribute('aria-hidden', 'true');
code.textContent = current.toUpperCase();
button.append(
icon(documentRef, 'globe', 'locale-picker-globe'),
code,
icon(documentRef, 'caret', 'locale-picker-caret'),
);
const popup = element(documentRef, 'div', 'locale-picker-popup');
popup.id = ids.popup;
popup.hidden = true;
const heading = element(documentRef, 'div', 'locale-picker-heading');
heading.id = ids.heading;
heading.textContent = t('Interface language');
popup.append(heading);
const listbox = element(documentRef, 'ul', 'locale-picker-list');
listbox.id = ids.listbox;
listbox.setAttribute('role', 'listbox');
listbox.setAttribute('aria-labelledby', ids.heading);
listbox.tabIndex = -1;
let search = null;
if (model.searchable) {
search = element(documentRef, 'input', 'locale-picker-search');
search.type = 'search';
search.autocomplete = 'off';
search.spellcheck = false;
search.setAttribute('role', 'combobox');
search.setAttribute('aria-autocomplete', 'list');
search.setAttribute('aria-expanded', 'true');
search.setAttribute('aria-controls', ids.listbox);
search.setAttribute('aria-label', t('Search languages'));
search.placeholder = t('Search languages');
popup.append(search);
}
popup.append(listbox);
const empty = element(documentRef, 'p', 'locale-picker-empty');
empty.setAttribute('role', 'status');
empty.textContent = t('No matching languages');
empty.hidden = true;
popup.append(empty);
root.append(button, popup);
const optionNodes = new Map();
for (const option of model.options) {
const item = element(documentRef, 'li', 'locale-picker-option');
item.id = ids.option(option.code);
item.setAttribute('role', 'option');
item.setAttribute('aria-selected', String(option.selected));
item.lang = option.code;
item.dataset.locale = option.code;
const name = element(documentRef, 'span', 'locale-picker-native');
name.textContent = option.nativeName;
// Every row reads the same: the language named in itself, then its code.
// The interface-language name only helps the search find it.
const meta = element(documentRef, 'span', 'locale-picker-meta');
const codeLabel = element(documentRef, 'span', 'locale-picker-option-code');
codeLabel.setAttribute('translate', 'no');
codeLabel.textContent = option.code.toUpperCase();
meta.append(codeLabel);
const text = element(documentRef, 'span', 'locale-picker-text');
text.append(name, meta);
item.append(text);
if (option.selected)
item.append(icon(documentRef, 'check', 'locale-picker-check'));
optionNodes.set(option.code, item);
}
const focusTarget = () => search || listbox;
const render = () => {
const visible = new Set(model.visible.map((option) => option.code));
listbox.replaceChildren(
...model.visible.map((option) => optionNodes.get(option.code)),
);
for (const [localeCode, item] of optionNodes) {
item.classList.toggle(
'is-active',
visible.has(localeCode) && model.activeOption?.code === localeCode,
);
}
empty.hidden = model.visible.length > 0;
const activeNode = model.activeOption
? optionNodes.get(model.activeOption.code)
: null;
if (activeNode) {
focusTarget().setAttribute('aria-activedescendant', activeNode.id);
activeNode.scrollIntoView?.({ block: 'nearest' });
} else {
focusTarget().removeAttribute('aria-activedescendant');
}
};
let swallowSpaceKeyup = false;
const close = ({ restoreFocus = false } = {}) => {
if (!model.open) return;
model.closeList();
popup.hidden = true;
root.classList.remove('is-open');
button.setAttribute('aria-expanded', 'false');
windowRef?.removeEventListener('keydown', onWindowKeyDown, true);
documentRef.removeEventListener('pointerdown', onDocumentPointerDown, true);
if (restoreFocus) button.focus({ preventScroll: true });
};
const choose = (localeCode) => {
close({ restoreFocus: true });
if (localeCode !== current) onSelect(localeCode);
};
const open = () => {
if (model.open) return;
model.openList();
if (search) search.value = '';
popup.hidden = false;
root.classList.add('is-open');
button.setAttribute('aria-expanded', 'true');
render();
// Window capture runs before the document-level shortcut handlers
// (cockpit, hotkeys, push-to-talk), so keys typed in the list stay here.
windowRef?.addEventListener('keydown', onWindowKeyDown, true);
documentRef.addEventListener('pointerdown', onDocumentPointerDown, true);
focusTarget().focus({ preventScroll: true });
};
function onWindowKeyDown(event) {
if (!model.open || event.isComposing) return;
const inside = root.contains(event.target);
if (!inside && event.key !== 'Escape') return;
const result = model.keydown(event);
if (!result.handled) return;
event.preventDefault();
event.stopImmediatePropagation();
if (result.close) close({ restoreFocus: true });
else if (result.choose) {
if (event.key === ' ') swallowSpaceKeyup = true;
choose(result.choose);
} else render();
}
function onDocumentPointerDown(event) {
if (!root.contains(event.target)) close();
}
const onButtonClick = () => {
if (model.open) close({ restoreFocus: true });
else open();
};
const onButtonKeyDown = (event) => {
if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') return;
event.preventDefault();
event.stopPropagation();
open();
};
// Space chose an option and moved focus here; its keyup must not click.
const onButtonKeyUp = (event) => {
if (event.key !== ' ' || !swallowSpaceKeyup) return;
swallowSpaceKeyup = false;
event.preventDefault();
};
const optionFromEvent = (event) =>
event.target?.closest?.('[role="option"]')?.dataset.locale || null;
const onListClick = (event) => {
const localeCode = optionFromEvent(event);
if (localeCode) choose(localeCode);
};
const onListPointerMove = (event) => {
const localeCode = optionFromEvent(event);
if (!localeCode || model.activeOption?.code === localeCode) return;
model.setActive(
model.visible.findIndex((option) => option.code === localeCode),
);
render();
};
const onSearchInput = () => {
model.setQuery(search.value);
render();
};
const onFocusOut = (event) => {
const next = event.relatedTarget;
if (model.open && next && !root.contains(next)) close();
};
button.addEventListener('click', onButtonClick);
button.addEventListener('keydown', onButtonKeyDown);
button.addEventListener('keyup', onButtonKeyUp);
listbox.addEventListener('click', onListClick);
listbox.addEventListener('pointermove', onListPointerMove);
search?.addEventListener('input', onSearchInput);
root.addEventListener('focusout', onFocusOut);
return {
open,
close,
isOpen: () => model.open,
destroy() {
close();
button.removeEventListener('click', onButtonClick);
button.removeEventListener('keydown', onButtonKeyDown);
button.removeEventListener('keyup', onButtonKeyUp);
listbox.removeEventListener('click', onListClick);
listbox.removeEventListener('pointermove', onListPointerMove);
search?.removeEventListener('input', onSearchInput);
root.removeEventListener('focusout', onFocusOut);
root.textContent = '';
},
};
}
+352
View File
@@ -0,0 +1,352 @@
// The interface language picker without a DOM: row order, search, the
// keyboard model and what choosing a language does.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
LOCALE_SEARCH_THRESHOLD,
TYPEAHEAD_RESET_MS,
applyLocaleChoice,
buildLocaleOptions,
createLocalNameReader,
createLocalePickerModel,
filterLocaleOptions,
foldSearchText,
moveActiveIndex,
typeaheadIndex,
} from './localePicker.js';
import { LOCALES, LOCALE_NAMES } from '../i18n/locale.js';
const NAMES = {
en: 'English',
es: 'Español',
ru: 'Русский',
de: 'Deutsch',
fr: 'Français',
pt: 'Português',
tr: 'Türkçe',
uk: 'Українська',
ja: '日本語',
};
const MANY = Object.keys(NAMES);
const codes = (rows) => rows.map((row) => row.code);
test('the current language comes first, the rest by their own names', () => {
assert.deepEqual(
codes(
buildLocaleOptions({
locales: LOCALES,
current: 'ru',
nativeNames: LOCALE_NAMES,
}),
),
['ru', 'en', 'es'],
);
assert.deepEqual(
codes(
buildLocaleOptions({
locales: LOCALES,
current: 'es',
nativeNames: LOCALE_NAMES,
}),
),
['es', 'en', 'ru'],
);
assert.deepEqual(
codes(
buildLocaleOptions({
locales: LOCALES,
current: 'en',
nativeNames: LOCALE_NAMES,
}),
),
['en', 'es', 'ru'],
);
const rows = buildLocaleOptions({
locales: MANY,
current: 'en',
nativeNames: NAMES,
});
assert.deepEqual(codes(rows), [
'en',
'de',
'es',
'fr',
'pt',
'tr',
'ru',
'uk',
'ja',
]);
assert.deepEqual(
rows.map((row) => row.selected),
[true, false, false, false, false, false, false, false, false],
);
});
test('each row gets the interface-language name for search, unless it only repeats the native one', () => {
const local = createLocalNameReader('ru');
const rows = buildLocaleOptions({
locales: LOCALES,
current: 'ru',
nativeNames: LOCALE_NAMES,
localName: local,
});
const byCode = Object.fromEntries(rows.map((row) => [row.code, row]));
assert.equal(byCode.en.localName, 'английский');
assert.equal(byCode.es.localName, 'испанский');
assert.equal(byCode.ru.localName, '', 'русский only repeats Русский');
const english = buildLocaleOptions({
locales: LOCALES,
current: 'en',
nativeNames: LOCALE_NAMES,
localName: createLocalNameReader('en'),
});
assert.equal(english.find((row) => row.code === 'es').localName, 'Spanish');
});
test('without Intl.DisplayNames the interface-language name is simply left out', () => {
const read = createLocalNameReader('ru', null);
assert.equal(read('en'), '');
class Broken {
of() {
throw new RangeError('bad code');
}
}
assert.equal(createLocalNameReader('ru', Broken)('en'), '');
class Echo {
of(code) {
return code;
}
}
assert.equal(
createLocalNameReader('ru', Echo)('xx'),
'',
'a code echoed back is no name',
);
});
test('search folds case and diacritics, and matches either name or the code', () => {
assert.equal(foldSearchText(' Español '), 'espanol');
assert.equal(foldSearchText('Français'), 'francais');
assert.equal(foldSearchText('Ёлка'), 'елка');
const rows = buildLocaleOptions({
locales: MANY,
current: 'ru',
nativeNames: NAMES,
localName: createLocalNameReader('ru'),
});
assert.deepEqual(codes(filterLocaleOptions(rows, 'espan')), ['es']);
assert.deepEqual(codes(filterLocaleOptions(rows, 'ESPAÑ')), ['es']);
assert.deepEqual(codes(filterLocaleOptions(rows, 'francais')), ['fr']);
assert.deepEqual(codes(filterLocaleOptions(rows, 'turkce')), ['tr']);
assert.deepEqual(
codes(filterLocaleOptions(rows, 'испан')),
['es'],
'by the interface-language name',
);
assert.deepEqual(
codes(filterLocaleOptions(rows, 'УКРАИН')),
['uk'],
'й folds to и',
);
assert.deepEqual(
codes(filterLocaleOptions(rows, 'JA')),
['ja'],
'by the code',
);
assert.deepEqual(
codes(filterLocaleOptions(rows, ' ')),
codes(rows),
'empty keeps all',
);
assert.deepEqual(filterLocaleOptions(rows, 'klingon'), []);
});
test('the search field appears only above the threshold', () => {
assert.equal(LOCALE_SEARCH_THRESHOLD, 8);
assert.equal(createLocalePickerModel({ current: 'en' }).searchable, false);
const eight = MANY.slice(0, 8);
assert.equal(
createLocalePickerModel({
locales: eight,
nativeNames: NAMES,
current: 'en',
}).searchable,
false,
);
assert.equal(
createLocalePickerModel({
locales: MANY,
nativeNames: NAMES,
current: 'en',
}).searchable,
true,
);
});
test('choosing the current language changes nothing; another asks, then switches', () => {
const calls = [];
const actions = (answer) => ({
current: 'ru',
confirmSwitch: () => {
calls.push('confirm');
return answer;
},
switchLocale: (locale) => calls.push(`switch:${locale}`),
});
assert.equal(applyLocaleChoice('ru', actions(true)), 'unchanged');
assert.deepEqual(calls, [], 'no prompt, no reload');
assert.equal(applyLocaleChoice('es', actions(false)), 'declined');
assert.deepEqual(calls, ['confirm']);
assert.equal(applyLocaleChoice('es', actions(true)), 'switched');
assert.deepEqual(calls, ['confirm', 'confirm', 'switch:es']);
});
test('navigation keys move without wrapping', () => {
assert.equal(moveActiveIndex('ArrowDown', 0, 3), 1);
assert.equal(moveActiveIndex('ArrowDown', 2, 3), 2);
assert.equal(moveActiveIndex('ArrowUp', 0, 3), 0);
assert.equal(moveActiveIndex('ArrowUp', 2, 3), 1);
assert.equal(moveActiveIndex('ArrowDown', -1, 3), 0);
assert.equal(moveActiveIndex('ArrowUp', -1, 3), 2);
assert.equal(moveActiveIndex('Home', 2, 3), 0);
assert.equal(moveActiveIndex('End', 0, 3), 2);
assert.equal(moveActiveIndex('Tab', 0, 3), null);
assert.equal(moveActiveIndex('ArrowDown', 0, 0), null);
});
test('type-ahead cycles by first letter and refines with more letters', () => {
const rows = buildLocaleOptions({
locales: MANY,
current: 'en',
nativeNames: NAMES,
});
// en, de, es, fr, pt, tr, ru, uk, ja
assert.equal(
typeaheadIndex(rows, 'e', 0),
2,
'from English, e moves on to Español',
);
assert.equal(typeaheadIndex(rows, 'e', 2), 0, 'and wraps back to English');
assert.equal(
typeaheadIndex(rows, 'ee', 2),
0,
'a repeated letter keeps cycling',
);
assert.equal(typeaheadIndex(rows, 'es', 0), 2);
assert.equal(
typeaheadIndex(rows, 'en', 0),
0,
'a longer prefix may stay put',
);
assert.equal(
typeaheadIndex(rows, 'р', 0),
6,
'Русский by its Cyrillic letter',
);
assert.equal(typeaheadIndex(rows, 'q', 0), -1);
});
test('keyboard model: open on the current language, move, choose, close', () => {
const model = createLocalePickerModel({
current: 'ru',
localName: createLocalNameReader('ru'),
});
assert.deepEqual(
model.keydown({ key: 'ArrowDown' }),
{ handled: false },
'closed: ignored',
);
model.openList();
assert.equal(model.activeOption.code, 'ru');
assert.deepEqual(model.keydown({ key: 'ArrowDown' }), { handled: true });
assert.equal(model.activeOption.code, 'en');
model.keydown({ key: 'End' });
assert.equal(model.activeOption.code, 'es');
model.keydown({ key: 'Home' });
assert.equal(model.activeOption.code, 'ru');
model.keydown({ key: 'ArrowUp' });
assert.equal(model.activeOption.code, 'ru', 'no wrap at the top');
assert.deepEqual(model.keydown({ key: 'Enter' }), {
handled: true,
choose: 'ru',
});
model.keydown({ key: 'ArrowDown' });
assert.deepEqual(model.keydown({ key: ' ' }), {
handled: true,
choose: 'en',
});
assert.deepEqual(model.keydown({ key: 'Escape' }), {
handled: true,
close: true,
});
assert.deepEqual(
model.keydown({ key: 'Tab' }),
{ handled: false },
'Tab leaves the list',
);
model.closeList();
assert.equal(model.open, false);
});
test('keyboard model: type-ahead resets after a pause and ignores shortcuts', () => {
let clock = 0;
const model = createLocalePickerModel({ current: 'en', now: () => clock });
model.openList();
assert.deepEqual(model.keydown({ key: 'e' }), { handled: true });
assert.equal(model.activeOption.code, 'es');
clock += 100;
model.keydown({ key: 's' });
assert.equal(model.activeOption.code, 'es', '"es" keeps Español');
clock += TYPEAHEAD_RESET_MS + 1;
model.keydown({ key: 'Р' });
assert.equal(model.activeOption.code, 'ru');
assert.deepEqual(
model.keydown({ key: 'z' }),
{ handled: true },
'an unmatched letter is still the list’s, not a global hotkey',
);
assert.equal(model.activeOption.code, 'ru');
assert.deepEqual(model.keydown({ key: 'c', metaKey: true }), {
handled: false,
});
});
test('keyboard model with search: letters, Space, Home and End belong to the field', () => {
const model = createLocalePickerModel({
locales: MANY,
nativeNames: NAMES,
current: 'en',
localName: createLocalNameReader('en'),
});
model.openList();
assert.deepEqual(model.keydown({ key: 'f' }), { handled: false });
assert.deepEqual(model.keydown({ key: ' ' }), { handled: false });
assert.deepEqual(model.keydown({ key: 'Home' }), { handled: false });
model.setQuery('SPAN');
assert.deepEqual(codes(model.visible), ['es']);
assert.equal(model.activeOption.code, 'es');
assert.deepEqual(model.keydown({ key: 'Enter' }), {
handled: true,
choose: 'es',
});
model.setQuery('e');
assert.equal(
model.activeOption.code,
'en',
'the current language stays active while it matches',
);
model.keydown({ key: 'ArrowDown' });
assert.notEqual(model.activeOption.code, 'en');
model.setQuery('nothing like it');
assert.equal(model.activeOption, null);
assert.deepEqual(
model.keydown({ key: 'Enter' }),
{ handled: true },
'nothing to choose',
);
model.openList();
assert.equal(model.query, '', 'reopening clears the search');
assert.equal(model.visible.length, MANY.length);
});
+138
View File
@@ -0,0 +1,138 @@
// The language picker reloads the page, so it asks first while the
// reload would lose work (unsaved CCTV calibration, the open cockpit, anything
// a module registers). Choosing the language already active does nothing.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { StyleManager } from './applicationShell.js';
import {
describeUnsavedWork,
msg,
registerUnsavedWork,
setLocale,
} from '../i18n/index.js';
/** Wire the shell's language choice with the given shell state. No picker
* markup here: the picker's own behaviour is covered by localePicker.test. */
function renderSwitch({ calDirty = false, cockpit = false } = {}) {
const previous = {
document: globalThis.document,
location: globalThis.location,
history: globalThis.history,
confirm: globalThis.confirm,
};
const reloads = [];
const prompts = [];
let answer = true;
globalThis.document = { getElementById: () => null };
globalThis.location = {
href: 'https://gev.example/#v=2',
assign() {},
reload: () => reloads.push(true),
};
globalThis.history = { replaceState() {} };
globalThis.confirm = (text) => {
prompts.push(text);
return answer;
};
const shell = {
services: {
cctvLayer: {
getUIState: () => ({ cameras: [{ calDirty: false }, { calDirty }] }),
},
},
cockpitView: { active: cockpit },
_confirmLocaleReload: StyleManager.prototype._confirmLocaleReload,
_chooseLocale: StyleManager.prototype._chooseLocale,
};
StyleManager.prototype._initLocaleSelector.call(shell);
return {
choose: (locale) => shell._chooseLocale(locale),
reloads,
prompts,
answer: (value) => (answer = value),
restore() {
for (const unregister of shell._unsavedWorkChecks) unregister();
Object.assign(globalThis, previous);
},
};
}
const storage = globalThis.localStorage;
test.before(() => {
globalThis.localStorage = { setItem() {}, getItem: () => null };
});
test.after(() => {
globalThis.localStorage = storage;
});
test('nothing to lose: the switch reloads without asking', () => {
const view = renderSwitch();
try {
view.choose('es');
assert.deepEqual(view.prompts, []);
assert.equal(view.reloads.length, 1);
} finally {
view.restore();
}
});
test('unsaved calibration or the open cockpit: the switch asks first', () => {
const view = renderSwitch({ calDirty: true, cockpit: true });
try {
view.answer(false);
view.choose('ru');
assert.equal(view.prompts.length, 1);
assert.match(view.prompts[0], /unsaved CCTV calibration/);
assert.match(view.prompts[0], /the open cockpit/);
assert.equal(view.reloads.length, 0, 'declined: no reload');
view.answer(true);
view.choose('ru');
assert.equal(view.reloads.length, 1, 'confirmed: reload');
} finally {
view.restore();
}
});
test('modules register their own unsaved work', () => {
const unregister = registerUnsavedWork(() => msg('drawings on the map'));
const failing = registerUnsavedWork(() => {
throw new Error('broken check');
});
const view = renderSwitch();
try {
view.answer(false);
view.choose('es');
assert.match(view.prompts[0], /drawings on the map/);
assert.equal(view.reloads.length, 0);
} finally {
view.restore();
unregister();
failing();
}
assert.deepEqual(describeUnsavedWork(), []);
});
test('the prompt reads in the active language', () => {
setLocale('es');
const view = renderSwitch({ calDirty: true });
try {
view.answer(false);
view.choose('en');
assert.equal(view.prompts.length, 1);
assert.doesNotMatch(view.prompts[0], /Switching language/);
} finally {
view.restore();
setLocale('en');
}
});
test('the language already active neither asks nor reloads', () => {
const view = renderSwitch({ calDirty: true });
try {
assert.equal(view.choose('en'), 'unchanged');
assert.deepEqual(view.prompts, []);
assert.equal(view.reloads.length, 0);
} finally {
view.restore();
}
});
+33 -5
View File
@@ -1,6 +1,18 @@
import { locationMiniStatus } from '../locationStatus.js';
import { t } from '../i18n/index.js';
import { localizePlaceName } from './placeNames.js';
const POI_KEYS = ['Q', 'W', 'E', 'R', 'T'];
/**
* locationStatus.js placeholder lines, built by the module itself; renderStatus
* swaps them for translated text. Place names are shown as they are.
*/
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({
@@ -38,7 +50,7 @@ export class LocationControls {
pill.type = 'button';
pill.className = 'location-pill';
pill.dataset.locationId = id;
pill.textContent = city.name;
pill.textContent = localizePlaceName(city.name);
this.bind(pill, 'click', () => onCity(id));
elements.pills.appendChild(pill);
}
@@ -129,12 +141,28 @@ export class LocationControls {
for (const pill of this.elements.pills.querySelectorAll('.location-pill'))
pill.classList.toggle('active', pill.dataset.locationId === id);
}
renderStatus(state) {
renderStatus(record) {
if (this.destroyed || !this.elements.statusCity || !this.elements.statusPoi)
return;
// A preset city shows its name in the UI language; the record stays English.
const state = record?.city?.name
? {
...record,
city: { ...record.city, name: localizePlaceName(record.city.name) },
}
: record;
const lines = locationMiniStatus(state);
this.elements.statusCity.textContent = lines.city;
this.elements.statusPoi.textContent = lines.poi;
this.elements.statusCity.textContent =
lines.city === LOCATION_MINI_EMPTY.city
? t('📍 Location: --')
: lines.city;
if (lines.poi === LOCATION_MINI_EMPTY.poi) {
this.elements.statusPoi.textContent = t('Landmark: --');
} else if (lines.poi === LOCATION_MINI_SEARCHED_PLACEHOLDER.poi) {
this.elements.statusPoi.textContent = t('Searched location');
} else {
this.elements.statusPoi.textContent = lines.poi;
}
}
createOrbitIndicator() {
if (this.destroyed) return null;
@@ -144,7 +172,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('ORBIT')}`);
this.doc.body.appendChild(this.orbitIndicator);
}
return this.orbitIndicator;
+10 -8
View File
@@ -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,9 @@ 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('Location not found'));
else if (change.type === 'failed') this._showToast(t('Search failed'));
else if (change.type === 'settled')
this._settleLocationSearchUi(change.generation);
else if (
@@ -117,7 +119,7 @@ export class LocationNavigation {
}),
onResult: (destination) => {
if (destination.outlineUnavailable)
this._showToast('Detailed outline unavailable');
this._showToast(t('Detailed outline unavailable'));
},
onError: (error) => console.error('[Search] Geocoding failed:', error),
});
@@ -277,7 +279,7 @@ export class LocationNavigation {
_toggleOrbit() {
if (!this._currentTarget) {
this._showToast('Fly to a POI first');
this._showToast(t('Fly to a POI first'));
return;
}
@@ -379,11 +381,11 @@ export class LocationNavigation {
};
this._resetGlobeBtn?.setAttribute(
'aria-label',
'Reset to full globe view',
t('Reset to full globe view'),
);
this._cockpitResetGlobeBtn?.setAttribute(
'aria-label',
'Reset cockpit to full globe view',
t('Reset cockpit to full globe view'),
);
this._globeResetPromise = null;
this._cancelGlobeReset = null;
@@ -399,11 +401,11 @@ export class LocationNavigation {
}, 4200);
this._resetGlobeBtn?.setAttribute(
'aria-label',
'Resetting to full globe view',
t('Resetting to full globe view'),
);
this._cockpitResetGlobeBtn?.setAttribute(
'aria-label',
'Resetting cockpit to full globe view',
t('Resetting cockpit to full globe view'),
);
const target = flyToGlobeView(this.viewer, {
onComplete: () => finish(false),
+2 -1
View File
@@ -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,7 @@ export function createMapSourceControls({
statusElement.textContent =
state.status === 'switching'
? '...'
: stack?.shortLabel || stack?.label || 'MAP';
: stack?.shortLabel || stack?.label || t('MAP');
statusElement.classList.toggle('warn', !!state.lastError);
}
}
+49 -12
View File
@@ -1,4 +1,5 @@
import { UiLifetime } from './uiLifetime.js';
import { t, msg } from '../i18n/index.js';
import { displayPanelScroller } from './displayPanelScroll.js';
import { PanelPositionControls } from './panelPositionControls.js';
import { PanelLayoutController } from './panelLayoutController.js';
@@ -71,7 +72,8 @@ export class PanelChrome {
variant: this.hud.getVariant(),
}),
scheduleCockpitLayout: () => this.cockpitView?.scheduleContextLayout(),
syncPanelCollapseButton: (panel) => this._syncPanelCollapseButton(panel),
syncPanelCollapseButton: (panel) =>
this._syncRailPanelCollapseButton(panel),
readDisplayScrollTop: () =>
this._displayPortalScrollRestoreOwner === 'standard'
? this._standardDisplayScrollTop
@@ -286,6 +288,19 @@ export class PanelChrome {
return this._panelLayout._scheduleLeftPanelLayout(options0);
}
_syncRailPanelCollapseButton(panelEl) {
// Adaptive rail layout may directly restore an auto-collapsed Context
// without invoking setPanelCollapsed(). Enforce Radio ownership there too.
if (
panelEl?.id === 'global-context-panel' &&
!panelEl.classList.contains('collapsed') &&
this._contextRadioDock?.classList.contains('disclosure-open')
) {
this._setRadioDisclosure?.(false);
}
this._syncPanelCollapseButton(panelEl);
}
_syncPanelCollapseButton(panelEl) {
const isRightRail = [
'pp-toggles',
@@ -310,14 +325,25 @@ 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('panel');
const titleKey = collapsed
? msg('Expand {name}')
: msg('Collapse {name}');
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`);
// Radio keeps the wording of its static button (context.html).
btn.title = t(
collapsed ? msg('Expand Radio') : msg('Collapse Radio'),
);
btn.setAttribute(
'aria-label',
t(
collapsed
? msg('Expand Radio section')
: msg('Collapse Radio section'),
),
);
}
});
const dockToggle = panelEl.querySelector(
@@ -327,11 +353,13 @@ export class PanelChrome {
const panelName =
panelEl
.querySelector('.panel-title, .location-toolbar-label')
?.textContent?.trim() || 'panel';
const action = collapsed ? 'Expand' : 'Collapse';
?.textContent?.trim() || t('panel');
const titleKey = collapsed
? msg('Expand {name}')
: msg('Collapse {name}');
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(
@@ -487,6 +515,15 @@ export class PanelChrome {
syncShare,
});
}
// A repeated Context-open request must clear a stale compact Radio mini
// even when no collapsed class changes.
if (
!nextCollapsed &&
panelId === 'global-context-panel' &&
this._contextRadioDock?.classList.contains('disclosure-open')
) {
this._setRadioDisclosure?.(false);
}
if (
panelEl.classList.contains('collapsed') === nextCollapsed &&
!wasAutoCollapsed
+200
View File
@@ -0,0 +1,200 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { PanelChrome } from './panelChrome.js';
import { PanelLayoutController } from './panelLayoutController.js';
function classList(initial = []) {
const values = new Set(initial);
return {
contains: (value) => values.has(value),
add: (...items) => items.forEach((item) => values.add(item)),
remove: (...items) => items.forEach((item) => values.delete(item)),
toggle(value, force) {
if (force === true) values.add(value);
else if (force === false) values.delete(value);
else if (values.has(value)) values.delete(value);
else values.add(value);
return values.has(value);
},
};
}
test('opening an already-open Context still closes a stale compact Radio disclosure', (t) => {
const priorDocument = globalThis.document;
const contextPanel = {
id: 'global-context-panel',
classList: classList(),
};
globalThis.document = {
documentElement: { dataset: { uiTheme: 'tactical' } },
getElementById(id) {
return id === 'global-context-panel' ? contextPanel : null;
},
};
t.after(() => {
globalThis.document = priorDocument;
});
const radioDock = { classList: classList(['disclosure-open']) };
const calls = [];
const owner = {
_contextRadioDock: radioDock,
_setRadioDisclosure(open) {
calls.push(open);
radioDock.classList.toggle('disclosure-open', open);
},
_leftPanelStack: null,
_rightPanelStack: null,
_panelLayout: {
_leftStackPreferredPanelId: null,
_rightStackPreferredPanelId: null,
},
_syncPanelCollapseButton() {},
_scheduleLeftPanelLayout() {},
_scheduleRightPanelLayout() {},
shareLinkManager: null,
cockpitView: null,
};
PanelChrome.prototype.setPanelCollapsed.call(
owner,
'global-context-panel',
false,
);
assert.deepEqual(calls, [false]);
assert.equal(radioDock.classList.contains('disclosure-open'), false);
});
test('adaptive rail restore hides compact Radio before revealing Context', (t) => {
const priorRaf = globalThis.requestAnimationFrame;
let nextFrame;
globalThis.requestAnimationFrame = (callback) => {
nextFrame = callback;
return 1;
};
t.after(() => {
globalThis.requestAnimationFrame = priorRaf;
});
// Valid starting state: the tactical rail automatically collapsed Context,
// and the user opened the compact Radio controls from its header.
const contextPanel = {
id: 'global-context-panel',
classList: classList(['collapsed', 'layout-auto-collapsed']),
};
const radioDock = { classList: classList(['disclosure-open']) };
const radioMini = { hidden: false };
const calls = [];
const chrome = {
_contextRadioDock: radioDock,
_setRadioDisclosure(open) {
calls.push(`disclosure:${open}`);
radioDock.classList.toggle('disclosure-open', open);
radioMini.hidden = !open;
},
_syncPanelCollapseButton(panel) {
calls.push(`button:${panel.id}`);
},
};
const layout = {
destroyed: false,
_rightStackLayoutFrame: null,
_rightStackReconsiderAutoCollapse: false,
_rightPanelStack: {
querySelectorAll: (selector) =>
selector === '.layout-auto-collapsed' ? [contextPanel] : [],
},
_syncPanelCollapseButton(panel) {
PanelChrome.prototype._syncRailPanelCollapseButton.call(chrome, panel);
},
_syncRightPanelAdaptiveLayout() {
calls.push('layout');
},
};
PanelLayoutController.prototype._scheduleRightPanelLayout.call(layout, {
reconsiderAutoCollapse: true,
});
assert.equal(contextPanel.classList.contains('collapsed'), true);
assert.equal(radioMini.hidden, false);
nextFrame();
assert.equal(contextPanel.classList.contains('collapsed'), false);
assert.equal(contextPanel.classList.contains('layout-auto-collapsed'), false);
assert.equal(radioDock.classList.contains('disclosure-open'), false);
assert.equal(radioMini.hidden, true);
assert.deepEqual(calls, [
'disclosure:false',
'button:global-context-panel',
'layout',
]);
});
test('real PanelChrome constructor wires rail restore to compact Radio owner', (t) => {
const priorDocument = globalThis.document;
const priorRaf = globalThis.requestAnimationFrame;
let nextFrame;
const contextPanel = {
id: 'global-context-panel',
classList: classList(['collapsed', 'layout-auto-collapsed']),
};
const rightRail = {
querySelectorAll(selector) {
return selector === '.layout-auto-collapsed' ? [contextPanel] : [];
},
};
globalThis.document = {
getElementById(id) {
return id === 'right-context-rail' ? rightRail : null;
},
};
globalThis.requestAnimationFrame = (callback) => {
nextFrame = callback;
return 1;
};
t.after(() => {
globalThis.document = priorDocument;
globalThis.requestAnimationFrame = priorRaf;
});
const dock = { classList: classList(['disclosure-open']) };
const calls = [];
const chrome = new PanelChrome({
elements: { _contextRadioDock: dock },
operations: {
_setRadioDisclosure(open) {
calls.push(`disclosure:${open}`);
dock.classList.toggle('disclosure-open', open);
},
_syncPanelCollapseButton(panel) {
calls.push(`button:${panel.id}`);
},
},
readHud: () => ({ visible: true, getVariant: () => 'tactical' }),
readCockpit: () => null,
readShareLinks: () => null,
readInitialShare: () => null,
readScrollRestoreOwner: () => null,
readDisplayScrollTop: () => 0,
});
chrome._panelLayout._syncRightPanelAdaptiveLayout = () => {
calls.push('layout');
};
chrome._panelLayout._scheduleRightPanelLayout({
reconsiderAutoCollapse: true,
});
assert.equal(typeof nextFrame, 'function');
assert.equal(dock.classList.contains('disclosure-open'), true);
nextFrame();
assert.equal(contextPanel.classList.contains('collapsed'), false);
assert.equal(dock.classList.contains('disclosure-open'), false);
assert.deepEqual(calls, [
'disclosure:false',
'button:global-context-panel',
'layout',
]);
});
+7 -5
View File
@@ -1,6 +1,6 @@
/** Own panel position preferences, viewport clamping and drag listeners. */
import { t, msg } from '../i18n/index.js';
import { RESIZE_DIRECTIONS, resizeBox } from './panelResize.js';
/** Versioned localStorage namespace prefix to invalidate stale panel layouts. */
const PANEL_LAYOUT_STORAGE_VERSION = 'v6';
/**
@@ -32,7 +32,7 @@ const VIEWPORT_MARGIN_PX = 6;
*/
const PANEL_OBSTACLE_IDS = ['command-dock', 'gev-voice-control'];
const PANEL_FLOAT_HINT_STORAGE_KEY = `godsEyeView.${PANEL_POSITION_STORAGE_VERSION}.panelFloatHintShown`;
const PANEL_FLOAT_HINT = 'Double-click the header to snap the panel back';
const PANEL_FLOAT_HINT = msg('Double-click the header to snap the panel back');
/** Header children whose own interaction wins over a drag or a snap-back. */
const INTERACTIVE_SELECTOR =
'input, select, option, textarea, button, a, [role="button"]';
@@ -221,7 +221,7 @@ export class PanelPositionControls {
);
if (hadOldPositions) {
this._showToast(
'Panel layout updated — positions reset to new defaults',
t('Panel layout updated — positions reset to new defaults'),
);
}
} catch {
@@ -477,7 +477,7 @@ export class PanelPositionControls {
if (this._floatHintShown) return;
this._floatHintShown = true;
}
this._showToast(PANEL_FLOAT_HINT);
this._showToast(t(PANEL_FLOAT_HINT));
}
/**
@@ -761,7 +761,9 @@ export class PanelPositionControls {
handle.dataset.dir = dir;
handle.setAttribute('aria-hidden', 'true');
if (dir === 'se') {
handle.title = 'Drag to resize · double-click the header to snap back';
handle.title = t(
'Drag to resize · double-click the header to snap back',
);
}
panelEl.appendChild(handle);
this._resizeHandles.push(handle);
+44
View File
@@ -0,0 +1,44 @@
/**
* Preset place names as the page shows them.
*
* The preset cities in src/cityPois.js keep their English names in data:
* those are what voice tools, the AI model, share links and lookups match on.
* This helper translates a known English name at a presentation boundary
* only; any other place (a geocoded address, a name the model chose) is shown
* as given.
*
* Pure: imports only i18n, no DOM.
* @module ui/placeNames
*/
import { msg, t } from '../i18n/index.js';
// The preset cities (CITY_POIS names, kept in step by placeNames.test.mjs),
// and Los Angeles, which voice results name for the analyst's region scopes.
const PLACE_NAME_KEYS = new Map(
[
msg('Austin'),
msg('San Francisco'),
msg('New York'),
msg('Tokyo'),
msg('London'),
msg('Paris'),
msg('Dubai'),
msg('Washington DC'),
msg('Tallinn'),
msg('Los Angeles'),
].map((key) => [key, key]),
);
/**
* A preset place name in the active language; any other text unchanged.
* @param {string} name English place name, e.g. `Tokyo`.
* @returns {string}
*/
export function localizePlaceName(name) {
if (typeof name !== 'string') return name;
const key = PLACE_NAME_KEYS.get(name.trim());
return key ? t(key) : name;
}
/** The English names this module translates (for tests). */
export const KNOWN_PLACE_NAMES = Object.freeze([...PLACE_NAME_KEYS.keys()]);
+82
View File
@@ -0,0 +1,82 @@
// Preset place names show in the UI language at the presentation boundary
// (voice results, location pills, HUD); the preset data stays English.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { setLocale } from '../i18n/index.js';
import { CITY_POIS } from '../cityPois.js';
import { KNOWN_PLACE_NAMES, localizePlaceName } from './placeNames.js';
import { LocationControls } from './locationControls.js';
test.afterEach(() => setLocale('en'));
test('every preset city has a display name entry', () => {
for (const city of Object.values(CITY_POIS))
assert.ok(KNOWN_PLACE_NAMES.includes(city.name), city.name);
});
test('en: place names pass through unchanged', () => {
setLocale('en');
for (const name of KNOWN_PLACE_NAMES)
assert.equal(localizePlaceName(name), name);
});
for (const [locale, tokyo, losAngeles] of [
['ru', 'Токио', 'Лос-Анджелес'],
['es', 'Tokio', 'Los Ángeles'],
]) {
test(`${locale}: preset names are translated, other places shown as given`, () => {
setLocale(locale);
assert.equal(localizePlaceName('Tokyo'), tokyo);
assert.equal(localizePlaceName('Los Angeles'), losAngeles);
assert.equal(localizePlaceName('Ulaanbaatar'), 'Ulaanbaatar');
assert.equal(localizePlaceName(undefined), undefined);
});
}
function element() {
return {
children: [],
dataset: {},
textContent: '',
addEventListener() {},
removeEventListener() {},
appendChild(child) {
this.children.push(child);
},
replaceChildren() {
this.children = [];
},
};
}
test('ru: location pills and the mini-status show the translated city', () => {
setLocale('ru');
const elements = {
pills: element(),
poiRow: element(),
divider: element(),
search: element(),
searchToggle: element(),
resetButtons: [],
statusCity: element(),
statusPoi: element(),
};
const doc = { ...element(), createElement: element };
const cities = { tokyo: CITY_POIS.tokyo };
const controls = new LocationControls({
elements,
cities,
getExpandedCity: () => null,
onCity() {},
onPoi() {},
onSearch() {},
onReset() {},
doc,
requestFrame: () => 0,
cancelFrame() {},
});
assert.equal(elements.pills.children[0].textContent, 'Токио');
controls.renderStatus({ city: CITY_POIS.tokyo, currentPoi: null });
assert.equal(elements.statusCity.textContent, '📍 Токио');
assert.equal(CITY_POIS.tokyo.name, 'Tokyo');
});
+43 -14
View File
@@ -1,3 +1,4 @@
import { t, msg } 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
? msg('Collapse Cockpit display options')
: msg('Expand Cockpit display options'),
),
);
this._cockpitDisplayToggleBtn.title = t(
displayOpen
? msg('Collapse Cockpit display options')
: msg('Expand Cockpit display options'),
);
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
? msg('Collapse Cockpit Radio controls')
: msg('Expand Cockpit Radio controls'),
),
);
this._cockpitRadioToggleBtn.title = t(
radioOpen
? msg('Collapse Cockpit Radio controls')
: msg('Expand Cockpit Radio controls'),
);
this._cockpitRadioToggleBtn.title = `${action} Cockpit Radio controls`;
}
if (!expanded && returnFocus) {
(kind === 'display'
@@ -139,18 +154,25 @@ export function bindRadioControls() {
syncTunerTape(resolvedCoordinate);
if (this._radioTunerValue) {
this._radioTunerValue.textContent = station
? `CH ${String(slot.stationIndex + 1).padStart(2, '0')} / ${String(this._radioTunerStations.length).padStart(2, '0')}`
: 'NO STATIONS';
? t('CH {index} / {total}', {
index: String(slot.stationIndex + 1).padStart(2, '0'),
total: String(this._radioTunerStations.length).padStart(2, '0'),
})
: t('NO STATIONS');
}
if (this._radioTunerStation)
this._radioTunerStation.textContent =
station?.name || 'NO STATION AVAILABLE';
station?.name || t('NO STATION AVAILABLE');
if (this._radioTunerSlider) {
this._radioTunerSlider.setAttribute(
'aria-valuetext',
station
? `${station.name}, station ${slot.stationIndex + 1} of ${this._radioTunerStations.length}`
: 'No station available',
? t('{name}, station {index} of {total}', {
name: station.name,
index: slot.stationIndex + 1,
total: this._radioTunerStations.length,
})
: t('No station available'),
);
}
if (syncStatic)
@@ -301,12 +323,12 @@ export function bindRadioControls() {
this._radioTunerBandPinnedForNavigation = false;
if (result.reason === 'station-unavailable') {
if (this._radioTunerValue)
this._radioTunerValue.textContent = 'OFF AIR';
this._radioTunerValue.textContent = t('OFF AIR');
if (this._radioTunerStation)
this._radioTunerStation.textContent = 'STATION UNAVAILABLE';
this._radioTunerStation.textContent = t('STATION UNAVAILABLE');
this._radioTunerSlider?.setAttribute(
'aria-valuetext',
'Station unavailable after directory refresh',
t('Station unavailable after directory refresh'),
);
}
}
@@ -340,7 +362,14 @@ export function bindRadioControls() {
origin: 'user',
notificationToken,
}),
// contextSessionOrdering.test.mjs pins this English argument; the
// toast shows localizedMessage.
`Radio could not ${enabling ? 'start' : 'stop'} cleanly`,
{
localizedMessage: t('Radio could not {action} cleanly', {
action: enabling ? t('start') : t('stop'),
}),
},
);
if (this.destroyed || toggled === false) return;
if (
+12 -8
View File
@@ -1,3 +1,4 @@
import { t, msg } from '../i18n/index.js';
import { bindRadioControls } from './radioBindings.js';
import { renderRadioState } from './radioPresentation.js';
@@ -178,9 +179,11 @@ export class RadioControls {
'aria-expanded',
String(radioExpanded),
);
const label = radioExpanded
? 'Go to expanded Radio section'
: 'Expand Radio section in Context';
const label = t(
radioExpanded
? msg('Go to expanded Radio section')
: msg('Expand Radio section in Context'),
);
this._contextRadioToggleBtn.setAttribute('aria-label', label);
this._contextRadioToggleBtn.title = label;
return;
@@ -198,12 +201,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
? msg('Close compact Radio controls')
: msg('Open compact Radio controls'),
);
this._contextRadioToggleBtn.title = `${action} compact Radio controls`;
this._contextRadioToggleBtn.setAttribute('aria-label', launcherLabel);
this._contextRadioToggleBtn.title = launcherLabel;
}
destroy() {
+93 -101
View File
@@ -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;
@@ -30,33 +32,36 @@ export function renderRadioState(state) {
}
this._syncContextRadioLauncherState();
this._radioLayerState?.classList.toggle('active', enabled);
const lifecycleLabel =
lifecycleState === 'enabling' ? t('ENABLING') : t('DISABLING');
if (this._radioLayerState) {
this._radioLayerState.textContent = transitioning
? lifecycleState.toUpperCase()
? lifecycleLabel
: uncertain
? 'UNCERTAIN'
? t('UNCERTAIN')
: state.loading
? 'SYNC'
? t('SYNC')
: enabled
? `${state.filteredCount}/${state.stationCount}`
: 'OFF';
: t('OFF');
}
const enableLabel = uncertain
? t('RECONCILE')
: enabled
? t('DISABLE')
: t('ENABLE');
const enableAria = uncertain
? t('Reconcile Radio — lifecycle uncertain')
: enabled
? t('Disable Radio')
: t('Enable Radio');
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 +73,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 +90,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 +143,12 @@ export function renderRadioState(state) {
);
this._radioTunerBandLabel.textContent =
state.filter === 'all'
? 'DIRECTORY BAND'
: `${String(activeCategory?.label || state.filter).toUpperCase()} BAND`;
? t('DIRECTORY BAND')
: t('{category} BAND', {
category: String(
activeCategory?.label || state.filter,
).toUpperCase(),
});
}
this._radioTuner?.classList.toggle('is-static', Boolean(state.tuningStatic));
if (tunerAvailable) this._refreshRadioTunerBand?.();
@@ -176,7 +167,7 @@ export function renderRadioState(state) {
if (this._radioStationName)
this._radioStationName.textContent =
selected?.name || 'NO STATION SELECTED';
selected?.name || t('NO STATION SELECTED');
if (this._radioStationMeta) {
const place = selected
? [selected.state, selected.countryCode].filter(Boolean).join(' · ')
@@ -188,15 +179,15 @@ export function renderRadioState(state) {
: '';
this._radioStationMeta.textContent = selected
? [place, signal].filter(Boolean).join(' / ') ||
'Directory metadata only'
t('Directory metadata only')
: state.loading
? 'Loading station directory…'
: 'Choose a globe marker or use next.';
? t('Loading station directory…')
: t('Choose a globe marker or use next.');
}
if (this._radioStationTags) {
const tags = Array.isArray(selected?.tags) ? selected.tags.slice(0, 8) : [];
this._radioStationTags.textContent = tags.length
? `TAGS · ${tags.join(' · ')}`
? t('TAGS · {tags}', { tags: tags.join(' · ') })
: '';
}
if (this._radioStationHomepage) {
@@ -218,49 +209,41 @@ export function renderRadioState(state) {
this._cockpitRadioPrevBtn.disabled = !interactive || !hasStations;
if (this._cockpitRadioNextBtn)
this._cockpitRadioNextBtn.disabled = !interactive || !hasStations;
const playbackAction = activePlayback
? t('Pause')
: state.audioState === 'paused'
? t('Resume')
: t('Play');
// The transport button is narrow: it gets its own short uppercase labels.
const playbackButtonLabel = activePlayback
? t('PAUSE')
: state.audioState === 'paused'
? t('RESUME')
: t('PLAY');
const playTargetWord = selected ? t('selected') : t('nearest');
const playAria = t('{action} {target} radio station', {
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 = playbackButtonLabel;
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 +278,65 @@ export function renderRadioState(state) {
}
if (this._contextRadioMiniStation) {
this._contextRadioMiniStation.textContent = uncertain
? 'RADIO STATE UNCERTAIN'
: selected?.name || (state.loading ? 'SYNCING DIRECTORY' : 'RADIO READY');
? t('RADIO STATE UNCERTAIN')
: selected?.name ||
(state.loading ? t('SYNCING DIRECTORY') : t('RADIO READY'));
}
if (this._cockpitRadioStation) {
this._cockpitRadioStation.textContent = uncertain
? 'UNCERTAIN'
: selected?.name || (state.loading ? 'SYNCING' : 'READY');
? t('UNCERTAIN')
: selected?.name || (state.loading ? t('SYNCING') : t('READY'));
}
if (this._radioPlaybackState) {
const stationName = selected?.name || t('station');
const catalogSuffix = state.degraded
? state.stale
? ' · stale/degraded directory'
: ' · degraded directory'
? t(' · stale/degraded directory')
: t(' · degraded directory')
: state.stale
? ' · stale directory'
? t(' · stale directory')
: '';
const outsideFilter =
selected && state.selectedIndex < 0 ? ' · outside current filter' : '';
selected && state.selectedIndex < 0 ? t(' · outside current filter') : '';
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('Ready — playback starts only from your action')
: t('Radio off'),
loading: t('Connecting directly to broadcaster…'),
buffering: t('Buffering broadcaster stream…'),
playing: t('Playing {station}', { station: stationName }),
paused: t('Paused {station}', { station: stationName }),
// Playback keeps its messages in English (msg()); shown in the UI language.
error: state.audioError
? t(state.audioError)
: t('Broadcaster stream unavailable'),
};
const voiceSuffix = state.voiceDucked
? ' · muted during voice interaction'
? t(' · muted during voice interaction')
: state.voiceRestoring
? ' · restoring volume after voice'
? t(' · restoring volume after voice')
: '';
const tuningSuffix = state.tuningAwaitingStationId
? state.audioState === 'error'
? ' · static indicates no broadcaster audio'
: ' · tuning static until broadcaster starts'
? t(' · static indicates no broadcaster audio')
: t(' · tuning static until broadcaster starts')
: '';
const unavailable = state.tuningUnavailableStationId
? 'Station unavailable after directory refresh — choose another channel'
? t(
'Station unavailable after directory refresh — choose another channel',
)
: null;
const lifecycleMessage = transitioning
? lifecycleState === 'enabling'
? 'Radio is enabling…'
: 'Radio is disabling…'
? t('Radio is enabling…')
: t('Radio is disabling…')
: null;
const uncertainMessage = uncertain
? 'Radio lifecycle is uncertain — use Enable or Disable to reconcile'
? t('Radio lifecycle is uncertain — use Enable or Disable to reconcile')
: null;
this._radioPlaybackState.textContent = `${uncertainMessage || unavailable || lifecycleMessage || state.error || messages[state.audioState] || 'Ready'}${tuningSuffix}${voiceSuffix}${catalogSuffix}${outsideFilter}`;
// Directory errors are msg() literals from the radio layer's ingestion.
const directoryError = state.error ? t(state.error) : null;
this._radioPlaybackState.textContent = `${uncertainMessage || unavailable || lifecycleMessage || directoryError || messages[state.audioState] || t('Ready')}${tuningSuffix}${voiceSuffix}${catalogSuffix}${outsideFilter}`;
this._radioPlaybackState.classList.toggle(
'error',
Boolean(
@@ -0,0 +1,71 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { setLocale } from '../i18n/index.js';
import { renderRadioState } from './radioPresentation.js';
function el() {
const classes = new Set();
return {
textContent: '',
classList: {
toggle(name, on) {
on ? classes.add(name) : classes.delete(name);
},
contains: (name) => classes.has(name),
},
};
}
function render(audioError) {
globalThis.document = { getElementById: () => null, activeElement: null };
const host = {
destroyed: false,
_radioPanel: el(),
_radioPlaybackState: el(),
_syncContextRadioLauncherState() {},
actions: {
getLifecycle: () => null,
preservePanelStateDuringClear: () => false,
setPanelCollapsed() {},
scheduleLayout() {},
},
};
renderRadioState.call(host, {
enabled: true,
audioState: 'error',
audioError,
filteredCount: 1,
stationCount: 1,
volume: 1,
});
return host._radioPlaybackState.textContent;
}
const PLAYBACK_ERRORS = [
'Playback requires a direct click or tap.',
'Broadcaster stream could not be started.',
'Broadcaster stream is unavailable or blocked by the browser.',
];
for (const locale of ['es', 'ru']) {
test(`radio playback errors are localized in ${locale}`, () => {
setLocale(locale);
try {
for (const message of PLAYBACK_ERRORS) {
assert.notEqual(render(message), message, `${locale}: ${message}`);
}
// Control: the fallback (no audioError) is translated.
assert.notEqual(render(null), 'Broadcaster stream unavailable');
} finally {
setLocale('en');
}
});
}
test('radio playback errors stay English', () => {
setLocale('en');
for (const message of PLAYBACK_ERRORS) {
assert.equal(render(message), message);
}
assert.equal(render(null), 'Broadcaster stream unavailable');
});
@@ -0,0 +1,65 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { renderRadioState } from './radioPresentation.js';
function el() {
const attrs = new Map();
return {
textContent: '',
hidden: false,
disabled: false,
style: {},
dataset: {},
classList: { toggle() {}, add() {}, remove() {}, contains: () => false },
setAttribute: (k, v) => attrs.set(k, String(v)),
getAttribute: (k) => attrs.get(k),
attrs,
};
}
for (const lifecycleState of ['enabling', 'disabling']) {
test(`Radio render survives the ${lifecycleState} transition`, () => {
const prior = globalThis.document;
globalThis.document = { getElementById: () => null };
try {
const enable = el();
const ctx = {
destroyed: false,
_radioPanel: el(),
_radioEnableBtn: enable,
_syncContextRadioLauncherState() {},
actions: {
getLifecycle: () => ({
lifecycleState,
enabled: lifecycleState === 'disabling',
uncertain: false,
}),
},
};
const state = {
enabled: false,
categories: [],
filteredCount: 0,
stationCount: 0,
audioState: 'idle',
};
try {
renderRadioState.call(ctx, state);
} catch (error) {
// Anything thrown after the transitional label is a different
// missing fixture field, not the bug under test.
assert.ok(
!(
error instanceof ReferenceError &&
/lifecycleLabel/.test(error.message)
),
`render threw: ${error.message}`,
);
}
assert.equal(enable.attrs.get('aria-busy'), 'true');
assert.equal(enable.textContent, lifecycleState.toUpperCase());
} finally {
globalThis.document = prior;
}
});
}
+12 -3
View File
@@ -1,5 +1,7 @@
import { t } from '../i18n/index.js';
import { syncChipGroup } from './chipGroup.js';
import { syncRowList } from './rowList.js';
import { formatScaleLabel, unitLabel } from './unitLabels.js';
const set = (node, key, value) => {
if (node[key] !== value) node[key] = value;
@@ -13,6 +15,13 @@ const order = (parent, nodes) =>
parent.insertBefore(node, parent.children[i] || null);
});
// "960 — 1050+ гПа": the unit stays with the last number.
const scaleText = (low, high, units) => {
const unit = unitLabel(units);
const range = `${formatScaleLabel(low)} — ${formatScaleLabel(high)}`;
return unit ? `${range}\u00a0${unit}` : range;
};
/** Optional, ordered blocks without disclosure state. All controls are keyed. */
export function createRailCardBlocks({ container, cardId, onParams }) {
const document = container.ownerDocument;
@@ -159,8 +168,8 @@ export function createRailCardBlocks({ container, cardId, onParams }) {
block.clear.type = 'button';
block.clear.dataset.actionId = 'clear';
block.clear.textContent = '×';
block.clear.setAttribute('aria-label', 'Clear reading');
block.clear.title = 'Clear reading';
block.clear.setAttribute('aria-label', t('Clear reading'));
block.clear.title = t('Clear reading');
bind((event) => {
if (event.target === block.clear) dispatch(block.props.clear);
});
@@ -300,7 +309,7 @@ export function createRailCardBlocks({ container, cardId, onParams }) {
block.scale,
'textContent',
visible
? `${legend.labels[0]} — ${legend.labels.at(-1)} ${legend.units || ''}`.trim()
? scaleText(legend.labels[0], legend.labels.at(-1), legend.units)
: '',
);
} else if (props.type === 'actions') {
+24 -10
View File
@@ -1,9 +1,19 @@
import { formatUtcDayMonth, t } from '../i18n/index.js';
const set = (node, key, value) => {
if (node[key] !== value) node[key] = value;
};
/**
* "HH:MM UTC" of a time, or '' when it is not one. Computed on its own rather
* than cut from the dated readout: the localized day and month vary in length
* ("3/9" in Spanish, "09-03" in English).
*/
export const utcClock = (tick) =>
Number.isFinite(Date.parse(tick))
? `${new Date(tick).toISOString().slice(11, 16)} UTC`
: '';
const utc = (tick) =>
Number.isFinite(Date.parse(tick))
? `${new Date(tick).toISOString().slice(5, 16).replace('T', ' ')} UTC`
? `${formatUtcDayMonth(tick)} ${utcClock(tick)}`
: '';
/** Stable native transport with local preview and a coalesced 150 ms drag commit. */
@@ -32,7 +42,7 @@ export function createRailTimeline({
parent.appendChild(node);
return node;
};
const previous = makeButton('‹', 'Earlier observation', row);
const previous = makeButton('‹', t('Earlier observation'), row);
const slider = document.createElement('input');
slider.type = 'range';
slider.className = `gev-quantitative-slider${sliderClassName ? ` ${sliderClassName}` : ''}`;
@@ -40,19 +50,23 @@ export function createRailTimeline({
slider.max = '0';
slider.step = '1';
slider.value = '0';
slider.setAttribute('aria-label', 'Observed history');
slider.setAttribute('aria-label', t('Observed history'));
row.appendChild(slider);
const next = makeButton('›', 'Later observation', row);
const next = makeButton('›', t('Later observation'), row);
const controls = document.createElement('div');
controls.className = 'rail-timeline-controls';
const latest = makeButton('Latest', 'Newest frame per product', controls);
const play = makeButton('Play', 'Replay observed history', controls);
const latest = makeButton(
t('Latest'),
t('Newest frame per product'),
controls,
);
const play = makeButton(t('Play'), t('Replay observed history'), controls);
const readout = document.createElement('span');
readout.className = 'rail-timeline-readout';
controls.appendChild(readout);
const label = document.createElement('div');
label.className = 'panel-title';
label.textContent = 'Observed history';
label.textContent = t('Observed history');
const endpoints = document.createElement('div');
endpoints.className = 'rail-timeline-endpoints';
const oldest = document.createElement('span');
@@ -110,8 +124,8 @@ export function createRailTimeline({
String(Math.max(0, Math.min(props.index, ticks.length - 1))),
);
valueText(ticks[Number(slider.value)]);
set(oldest, 'textContent', utc(ticks[0]).slice(6));
set(newest, 'textContent', utc(ticks.at(-1)).slice(6));
set(oldest, 'textContent', utcClock(ticks[0]));
set(newest, 'textContent', utcClock(ticks.at(-1)));
set(readout, 'textContent', props.readout || '');
}
for (const node of [slider, latest, play])
@@ -128,7 +142,7 @@ export function createRailTimeline({
`data-toggle-chip${props.mode === 'latest' ? ' active' : ''}`,
);
set(play, 'className', `data-toggle-chip${props.playing ? ' active' : ''}`);
set(play, 'textContent', props.playing ? 'Pause' : 'Play');
set(play, 'textContent', props.playing ? t('Pause') : t('Play'));
for (const [node, active] of [
[latest, props.mode === 'latest'],
[play, props.playing],
+234 -110
View File
@@ -8,40 +8,98 @@
* strip and selection bar are this adapter's own keyed DOM. Source toggles
* live on the data-panel row.
*/
import { formatNumber, msg, t, tn, translateKnown } from '../i18n/index.js';
import { createRailCardBlocks } from './railCardBlocks.js';
import { createRailCards } from './railCards.js';
import { createImagerySplit } from './imagerySplit.js';
import {
PRODUCTS,
hhmm,
shortDay,
wvsSnapshotUrl,
} from '../layers/recentImagery/model.js';
import { displayDay, displayMetres } from '../layers/recentImagery/display.js';
import { formatQuantity } from './unitLabels.js';
import { pointerOwnerName } from '../data/pointerOwnerNames.js';
const PANEL_ID = 'recent-imagery-panel';
const OVERVIEW_MIN_BOX_KM = 25;
const ESRI_NOTE = 'Imagery on Esri · Google 3D returns when cleared';
const ESRI_NOTE = msg('Imagery on Esri · Google 3D returns when cleared');
// The layer keeps its notices and box refusals in English (AI tools read the
// same snapshot); translate the ones we write, show anything else as it came.
const LAYER_NOTICES = new Set([
ESRI_NOTE,
msg('Box and images cleared'),
msg('Esri map unavailable · no swipe'),
msg('Swipe needs a globe map'),
msg('Imagery stays on Esri · CLEAR to use Google 3D'),
// Box refusals the layer writes itself (layers/recentImagery/index.js).
msg('Point the camera at the ground to use the view'),
msg('That point cannot anchor a box'),
]);
// Refusals with a value in them (layers/recentImagery/model.js and
// imageryBoxTool.js); fixed refusals there are msg() text for translateKnown.
const TOO_LARGE = /^(Box|View) is ([\d,]+) km wide · limit ([\d,]+) km$/;
const POLAR = /^Imagery stops at ±([\d.]+)° latitude$/;
const POINTER_BUSY = /^(.+) is using the pointer — close it first$/;
/**
* The notice line in the UI language: notices and box refusals arrive in
* English. Exported for tests.
* @param {string} text
* @returns {string}
*/
export function localizeNotice(text) {
if (!text) return '';
if (LAYER_NOTICES.has(text)) return t(text);
const polar = POLAR.exec(text);
if (polar)
return t('Imagery stops at ±{limit}° latitude', {
limit: formatNumber(Number(polar[1]), {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}),
});
const busy = POINTER_BUSY.exec(text);
if (busy)
return t('{owner} is using the pointer — close it first', {
owner: translateKnown(pointerOwnerName(busy[1])),
});
const tooLarge = TOO_LARGE.exec(text);
if (!tooLarge) return translateKnown(text);
const values = {
size: formatQuantity(Number(tooLarge[2].replace(/,/g, '')), 'km', {
useGrouping: true,
}),
limit: formatQuantity(Number(tooLarge[3].replace(/,/g, '')), 'km', {
useGrouping: true,
}),
};
return tooLarge[1] === 'Box'
? t('Box is {size} wide · limit {limit}', values)
: t('View is {size} wide · limit {limit}', values);
}
const MODE_LABELS = [
['image', 'IMAGE'],
['basemap', 'VS BASEMAP'],
['ab', 'A / B'],
['image', msg('IMAGE')],
['basemap', msg('VS BASEMAP')],
['ab', msg('A / B')],
];
const START_HERE_TITLES = {
clear: 'Newest low-cloud day for this box · scene cloud, not box cloud',
cloudy: 'Newest day covering this box · cloudier than 20%',
partial: 'Newest day with imagery for this box · partial coverage',
overview: 'Newest daily overview for this box',
clear: msg('Newest low-cloud day for this box · scene cloud, not box cloud'),
cloudy: msg('Newest day covering this box · cloudier than 20%'),
partial: msg('Newest day with imagery for this box · partial coverage'),
overview: msg('Newest daily overview for this box'),
};
const START_HERE_REASONS = {
clear: 'newest clear day',
cloudy: 'newest day (cloudy)',
partial: 'newest day (partial coverage)',
overview: 'newest overview',
clear: msg('newest clear day'),
cloudy: msg('newest day (cloudy)'),
partial: msg('newest day (partial coverage)'),
overview: msg('newest overview'),
};
const PLACEHOLDERS = {
empty: 'No imagery',
error: 'Unavailable',
present: 'Loading',
empty: msg('No imagery'),
error: msg('Unavailable'),
present: msg('Loading'),
};
const KEY_STEPS = {
ArrowLeft: (index) => index - 1,
@@ -55,48 +113,57 @@ const appearedDocuments = new WeakSet();
const sensorLine = (product) =>
PRODUCTS[product]
? `${PRODUCTS[product].name} · ${PRODUCTS[product].resolutionM} m`
? `${t(PRODUCTS[product].name)} · ${displayMetres(PRODUCTS[product].resolutionM)}`
: product || '';
const utcTime = (iso) => (hhmm(iso) ? `${hhmm(iso)}Z` : '');
const longerSideKm = (size) =>
Math.max(Number(size?.width) || 0, Number(size?.height) || 0);
const kmText = (km) =>
!Number.isFinite(km)
? '?'
: km >= 100
? Math.round(km).toLocaleString('en-US')
: km.toFixed(1);
const kmText = (km) => {
if (!Number.isFinite(km)) return '?';
const digits = km >= 100 ? 0 : 1;
return formatNumber(km, {
minimumFractionDigits: digits,
maximumFractionDigits: digits,
});
};
function coverageText(coverage) {
if (coverage === 'full') return t('full', { context: 'coverage' });
if (coverage === 'partial') return t('partial', { context: 'coverage' });
return t('unknown');
}
function cloudText(candidate) {
if (candidate.thumbnail?.status === 'empty') return 'no imagery';
if (!candidate.cloud) return 'cloud unknown';
if (candidate.thumbnail?.status === 'empty') return t('no imagery');
if (!candidate.cloud) return t('cloud unknown');
const min = Math.round(candidate.cloud.min);
const max = Math.round(candidate.cloud.max);
return min === max ? `${min}% cloud` : `${min}–${max}% cloud`;
return min === max
? t('{percent}% cloud', { percent: min })
: t('{min}–{max}% cloud', { min, max });
}
function countText(snapshot) {
if (!snapshot.box) return 'NO BOX';
if (snapshot.searching) return 'SEARCHING';
const count = snapshot.candidates.length;
return `${count} DAY${count === 1 ? '' : 'S'}`;
if (!snapshot.box) return t('NO BOX');
if (snapshot.searching) return t('SEARCHING');
return tn(snapshot.candidates.length, '{count} DAY', '{count} DAYS');
}
/** The one-line hint under the strip: the next thing to do, per state. */
function hintText(snapshot) {
if (snapshot.zoomToFit) return 'Zoom in or draw a smaller box';
if (!snapshot.box) return 'Select a box or use the view';
if (snapshot.searching) return 'Searching the last 30 days';
if (!snapshot.candidates.length) return 'No days to show for this box';
if (snapshot.zoomToFit) return t('Zoom in or draw a smaller box');
if (!snapshot.box) return t('Select a box or use the view');
if (snapshot.searching) return t('Searching the last 30 days');
if (!snapshot.candidates.length) return t('No days to show for this box');
const { mode, pins, shown } = snapshot;
if (shown.swipe !== 'none') return 'Drag the divider · SWAP trades sides';
if (shown.swipe !== 'none') return t('Drag the divider · SWAP trades sides');
if (mode === 'ab') {
if (!pins.a.key && !pins.b.key)
return '← → preview · A or B pins the focused day';
return '← → preview the other side · A or B pins it';
return t('← → preview · A or B pins the focused day');
return t('← → preview the other side · A or B pins it');
}
if (pins.a.key) return 'S on another day replaces it · × unpins';
return '← → preview · S shows the focused day';
if (pins.a.key) return t('S on another day replaces it · × unpins');
return t('← → preview · S shows the focused day');
}
/**
@@ -173,7 +240,7 @@ export function createRecentImageryPanel({
// ---- build: every block exists from the start, in its final order ----
const root = el('section', 'recent-imagery-readout');
root.setAttribute('aria-label', 'Recent imagery');
root.setAttribute('aria-label', t('Recent imagery'));
const actionsHost = el('div', 'ri-block ri-actions-row', root);
actionsHost.id = 'ri-actions';
@@ -191,10 +258,15 @@ export function createRecentImageryPanel({
noticeText.id = 'ri-notice-text';
noticeText.setAttribute('role', 'status');
noticeText.setAttribute('aria-live', 'polite');
const zoomIn = el('button', 'data-toggle-chip ri-zoom-in', notice, 'ZOOM IN');
const zoomIn = el(
'button',
'data-toggle-chip ri-zoom-in',
notice,
t('ZOOM IN'),
);
zoomIn.id = 'ri-zoom-in';
zoomIn.type = 'button';
zoomIn.title = 'Fly in until the view fits the 1,000 km limit';
zoomIn.title = t('Fly in until the view fits the 1,000 km limit');
const strip = el('div', 'ri-block ri-strip', root);
strip.id = 'ri-strip';
@@ -203,7 +275,7 @@ export function createRecentImageryPanel({
strip.setAttribute('aria-orientation', 'horizontal');
strip.setAttribute(
'aria-label',
'Imagery days, newest first · arrows move, S or A and B pin',
t('Imagery days, newest first · arrows move, S or A and B pin'),
);
strip.setAttribute('aria-keyshortcuts', 'A B S');
const stripEmpty = el('div', 'ri-strip-empty', strip);
@@ -213,16 +285,16 @@ export function createRecentImageryPanel({
const selection = el('section', 'ri-block ri-selection', root);
selection.id = 'ri-selection';
selection.setAttribute('aria-label', 'On the map');
selection.setAttribute('aria-label', t('On the map'));
const modeGroup = el('div', 'ri-mode', selection);
modeGroup.id = 'ri-mode';
modeGroup.setAttribute('role', 'radiogroup');
modeGroup.setAttribute('aria-label', 'Mode');
modeGroup.setAttribute('aria-label', t('Mode'));
const modeButtons = new Map(
MODE_LABELS.map(([mode, label]) => {
const node = el('button', 'data-toggle-chip ri-mode-btn', modeGroup);
node.type = 'button';
node.textContent = label;
node.textContent = t(label);
node.dataset.mode = mode;
node.setAttribute('role', 'radio');
return [mode, node];
@@ -244,7 +316,7 @@ export function createRecentImageryPanel({
const controls = el('div', 'ri-block ri-controls', root);
controls.id = 'ri-controls';
const opacityLabel = el('label', 'ri-opacity-label', controls);
el('span', '', opacityLabel, 'OPACITY');
el('span', '', opacityLabel, t('OPACITY'));
const opacityValue = el('span', 'ri-opacity-value', opacityLabel, '100%');
const opacity = el('input', 'ri-opacity', controls);
opacity.id = 'ri-opacity';
@@ -255,13 +327,13 @@ export function createRecentImageryPanel({
step: '1',
value: '100',
});
opacity.setAttribute('aria-label', 'Imagery opacity');
opacity.setAttribute('aria-label', t('Imagery opacity'));
opacityLabel.htmlFor = 'ri-opacity';
// Its slot is always there; it is only usable while a swipe is live.
const swap = el('button', 'data-toggle-chip ri-swap', controls, 'SWAP');
const swap = el('button', 'data-toggle-chip ri-swap', controls, t('SWAP'));
swap.id = 'ri-swap';
swap.type = 'button';
swap.title = 'Trade the two sides of the divider';
swap.title = t('Trade the two sides of the divider');
const exportHost = el('div', 'ri-exports', controls);
const exports = createRailCardBlocks({
container: exportHost,
@@ -272,7 +344,6 @@ export function createRecentImageryPanel({
const detailsHost = el('div', 'ri-details', root);
detailsHost.id = 'ri-details';
let detailsOpen = false;
let detailsShownOpen = false;
const details = createRailCards({
container: detailsHost,
document,
@@ -280,6 +351,8 @@ export function createRecentImageryPanel({
onOpen: () => {
detailsOpen = !detailsOpen;
render();
// After the render: preserveScroll() puts the old position back.
if (detailsOpen) revealCard(container, detailsHost.children[0]);
},
});
@@ -318,8 +391,8 @@ export function createRecentImageryPanel({
actions: [
{
id: 'select-box',
label: 'SELECT BOX',
title: 'Drag a box on the map (Esc cancels)',
label: t('SELECT BOX'),
title: t('Drag a box on the map (Esc cancels)'),
onClick: () => {
exportError = null;
if (tool?.isActive()) tool.cancel('toggle');
@@ -328,8 +401,8 @@ export function createRecentImageryPanel({
},
{
id: 'use-view',
label: 'USE VIEW',
title: 'Use the current view as the box',
label: t('USE VIEW'),
title: t('Use the current view as the box'),
onClick: () => {
exportError = null;
layer.useCurrentView(viewer);
@@ -337,8 +410,8 @@ export function createRecentImageryPanel({
},
{
id: 'clear',
label: 'CLEAR',
title: 'Forget the box and its images',
label: t('CLEAR', { context: 'action' }),
title: t('Forget the box and its images'),
disabled: !snapshot.box && !snapshot.boxError,
onClick: () => {
exportError = null;
@@ -365,8 +438,9 @@ export function createRecentImageryPanel({
// ---- notice -----------------------------------------------------------
function renderNotice() {
const warning = snapshot.boxError || snapshot.error || exportError;
const text =
warning || snapshot.notice || (snapshot.borrowedEsri ? ESRI_NOTE : '');
const text = localizeNotice(
warning || snapshot.notice || (snapshot.borrowedEsri ? ESRI_NOTE : ''),
);
set(noticeText, 'textContent', text);
attribute(noticeText, 'title', text);
toggle(notice, 'warn', Boolean(warning));
@@ -388,7 +462,7 @@ export function createRecentImageryPanel({
entry.thumb = el('div', 'ri-thumb', card);
entry.placeholder = el('span', 'ri-thumb-text', entry.thumb);
entry.flag = el('span', 'ri-card-flag', entry.thumb);
entry.start = el('span', 'ri-card-start', entry.thumb, 'START HERE');
entry.start = el('span', 'ri-card-start', entry.thumb, t('START HERE'));
entry.date = el('div', 'ri-card-date', card);
entry.sensor = el('div', 'ri-card-sensor', card);
entry.cloud = el('div', 'ri-card-cloud', card);
@@ -409,7 +483,7 @@ export function createRecentImageryPanel({
const entry = cards.get(candidate.key);
if (!entry) return;
const { card } = entry;
const day = shortDay(candidate.day);
const day = displayDay(candidate.day);
const ab = snapshot.mode === 'ab';
set(card, 'id', `ri-card-${index}`);
const focused = index === snapshot.focusIndex;
@@ -432,13 +506,17 @@ export function createRecentImageryPanel({
}
if (entry.image) set(entry.image, 'hidden', !shown);
set(entry.placeholder, 'hidden', shown);
set(entry.placeholder, 'textContent', PLACEHOLDERS[status] || 'Checking');
set(
entry.placeholder,
'textContent',
PLACEHOLDERS[status] ? t(PLACEHOLDERS[status]) : t('Checking'),
);
if (entry.thumb.dataset.status !== status)
entry.thumb.dataset.status = status;
const flag = candidate.preview
? 'PREVIEW'
? t('PREVIEW')
: candidate.pending
? 'LOADING'
? t('LOADING')
: '';
set(entry.flag, 'textContent', flag);
set(entry.flag, 'hidden', !flag);
@@ -448,8 +526,10 @@ export function createRecentImageryPanel({
set(
entry.start,
'title',
START_HERE_TITLES[snapshot.recommended.reason] ||
'Newest day with imagery for this box',
t(
START_HERE_TITLES[snapshot.recommended.reason] ||
msg('Newest day with imagery for this box'),
),
);
set(entry.date, 'textContent', day);
set(entry.sensor, 'textContent', sensorLine(candidate.product));
@@ -459,7 +539,7 @@ export function createRecentImageryPanel({
const chip = entry.chips[slotId];
const live = slotId === 'a' || ab;
const on = candidate.pinned === slotId && live;
const label = slotId === 'a' ? (ab ? 'A' : 'SHOW') : 'B';
const label = slotId === 'a' ? (ab ? 'A' : t('SHOW')) : 'B';
set(chip, 'textContent', label);
set(chip, 'disabled', empty || !live);
toggle(chip, 'active', on);
@@ -469,22 +549,29 @@ export function createRecentImageryPanel({
attribute(
chip,
'aria-label',
ab ? `Pin ${day} as ${slotId.toUpperCase()}` : `Show ${day}`,
ab
? t('Pin {day} as {slot}', { day, slot: slotId.toUpperCase() })
: t('Show {day}', { day }),
);
}
const state = candidate.pinned
? ab
? ` · pinned ${candidate.pinned.toUpperCase()}`
? t('pinned {slot}', { slot: candidate.pinned.toUpperCase() })
: candidate.pinned === 'a'
? ' · shown'
? t('shown')
: ''
: candidate.preview
? ' · preview'
? t('preview')
: '';
attribute(
card,
'aria-label',
`${day} · ${sensorLine(candidate.product)} · ${cloudText(candidate)}${recommended ? ' · start here' : ''}${state}`,
[
day,
sensorLine(candidate.product),
cloudText(candidate),
...[recommended ? t('start here') : '', state].filter(Boolean),
].join(' · '),
);
}
@@ -512,14 +599,14 @@ export function createRecentImageryPanel({
candidates.length
? ''
: !snapshot.box
? 'No box'
? t('No box')
: snapshot.searching
? 'Searching'
? t('Searching')
: snapshot.hiddenCount
? 'Every day is empty here'
? t('Every day is empty here')
: !snapshot.sources.hls && !snapshot.sources.viirs
? 'Sources off'
: 'No imagery',
? t('Sources off')
: t('No imagery'),
);
set(stripEmpty, 'hidden', candidates.length > 0);
if (candidates[snapshot.focusIndex])
@@ -596,18 +683,22 @@ export function createRecentImageryPanel({
set(
tag,
'textContent',
ab ? slotId.toUpperCase() : slotId === 'a' ? 'IMAGE' : 'VS',
ab ? slotId.toUpperCase() : slotId === 'a' ? t('IMAGE') : t('VS'),
);
let text = 'Not set';
if (basemapRow) text = 'Basemap';
let text = t('Not set');
if (basemapRow) text = t('Basemap');
else if (image?.label)
text = `${image.sourceOff ? 'Source off · ' : ''}${image.label}${
text = [
image.sourceOff ? t('Source off') : '',
image.label,
image.preview
? ' · preview'
? t('preview')
: image.candidate && !image.drapable && !image.sourceOff
? ' · loading'
: ''
}`;
? t('loading')
: '',
]
.filter(Boolean)
.join(' · ');
set(value, 'textContent', text);
attribute(value, 'title', text);
const lit = basemapRow
@@ -625,7 +716,9 @@ export function createRecentImageryPanel({
attribute(
unpin,
'aria-label',
ab ? `Unpin ${slotId.toUpperCase()}` : 'Unpin the image',
ab
? t('Unpin {slot}', { slot: slotId.toUpperCase() })
: t('Unpin the image'),
);
set(unpin, 'title', unpin.getAttribute('aria-label'));
}
@@ -658,8 +751,12 @@ export function createRecentImageryPanel({
type: 'actions',
actions: ['a', 'b'].map((slotId) => ({
id: `export-${slotId}`,
label: ab ? `EXPORT ${slotId.toUpperCase()}` : 'EXPORT',
title: `Download ${ab ? slotId.toUpperCase() : 'the image'} as a PNG`,
label: ab
? t('EXPORT {slot}', { slot: slotId.toUpperCase() })
: t('EXPORT'),
title: ab
? t('Download {slot} as a PNG', { slot: slotId.toUpperCase() })
: t('Download the image as a PNG'),
disabled: !exportTarget(slotId) || exporting.has(slotId),
onClick: () => void exportImage(slotId),
})),
@@ -682,7 +779,10 @@ export function createRecentImageryPanel({
if (snapshot.box)
lines.push({
id: 'box',
text: `Box ${kmText(snapshot.boxSizeKm?.width)} × ${kmText(snapshot.boxSizeKm?.height)} km`,
text: t('Box {width} × {height} km', {
width: kmText(snapshot.boxSizeKm?.width),
height: kmText(snapshot.boxSizeKm?.height),
}),
});
if (focus) {
lines.push({ id: 'readout', text: snapshot.readout || '' });
@@ -690,14 +790,24 @@ export function createRecentImageryPanel({
if (focus.timeRange?.start) {
const start = utcTime(focus.timeRange.start);
const end = utcTime(focus.timeRange.end || focus.timeRange.start);
times.push(`Acquired ${start === end ? start : `${start}–${end}`}`);
times.push(
t('Acquired {time}', {
time: start === end ? start : `${start}–${end}`,
}),
);
} else if (focus.thumbnail?.acquisitionTime)
times.push(`Acquired ${utcTime(focus.thumbnail.acquisitionTime)}`);
times.push(`Coverage ${focus.coverage || 'unknown'}`);
times.push(
t('Acquired {time}', {
time: utcTime(focus.thumbnail.acquisitionTime),
}),
);
times.push(
t('Coverage {coverage}', { coverage: coverageText(focus.coverage) }),
);
lines.push({ id: 'acquired', text: times.join(' · ') });
(focus.granules || []).forEach((granule, index) => {
const cloud = Number.isFinite(granule.cloud)
? ` · ${Math.round(granule.cloud)}% cloud`
? ` · ${t('{percent}% cloud', { percent: Math.round(granule.cloud) })}`
: '';
lines.push({
id: `granule-${index}`,
@@ -707,7 +817,11 @@ export function createRecentImageryPanel({
});
}
const reason = START_HERE_REASONS[snapshot.recommended?.reason];
if (reason) lines.push({ id: 'start', text: `START HERE · ${reason}` });
if (reason)
lines.push({
id: 'start',
text: t('START HERE · {reason}', { reason: t(reason) }),
});
for (const [index, note] of snapshot.notes.entries())
lines.push({ id: `note-${index}`, text: note, muted: true });
if (
@@ -718,12 +832,14 @@ export function createRecentImageryPanel({
)
lines.push({
id: 'overview-scale',
text: 'Daily overview shows little detail in a box this small',
text: t('Daily overview shows little detail in a box this small'),
muted: true,
});
lines.push({
id: 'credit',
text: 'Imagery: NASA GIBS and Worldview · HLS (Sentinel-2, Landsat 8/9) and VIIRS',
text: t(
'Imagery: NASA GIBS and Worldview · HLS (Sentinel-2, Landsat 8/9) and VIIRS',
),
muted: true,
});
return lines;
@@ -735,9 +851,11 @@ export function createRecentImageryPanel({
details.update([
{
id: 'details',
title: 'Details',
title: t('Details'),
open: detailsOpen,
compact: snapshot.focus ? snapshot.readout : 'Times, coverage, sources',
compact: snapshot.focus
? snapshot.readout
: t('Times, coverage, sources'),
blocks: [
{ id: 'lines', type: 'lines', lines: detailLines() },
{
@@ -747,9 +865,9 @@ export function createRecentImageryPanel({
{
id: 'toggle-empty',
label: showing
? 'HIDE EMPTY DAYS'
: `SHOW EMPTY DAYS · ${hidden}`,
title: 'Days the probe found empty in this box',
? t('HIDE EMPTY DAYS')
: t('SHOW EMPTY DAYS · {count}', { count: hidden }),
title: t('Days the probe found empty in this box'),
disabled: !showing && !hidden,
onClick: () => layer.setShowUnavailable(!showing),
},
@@ -758,9 +876,6 @@ export function createRecentImageryPanel({
],
},
]);
if (detailsOpen && !detailsShownOpen)
revealCard(container, detailsHost.children[0]);
detailsShownOpen = detailsOpen;
}
// ---- divider ----------------------------------------------------------
@@ -773,8 +888,10 @@ export function createRecentImageryPanel({
const describe = (c) => (c ? `${sensorLine(c.product)} · ${c.day}` : '');
// The two sides as [label, title, spoken name]; SWAP trades them.
const sides = [
basemap ? ['IMAGE', describe(a), 'image'] : ['A', describe(a), 'A'],
basemap ? ['BASEMAP', 'The basemap', 'basemap'] : ['B', describe(b), 'B'],
basemap ? [t('IMAGE'), describe(a), t('image')] : ['A', describe(a), 'A'],
basemap
? [t('BASEMAP'), t('The basemap'), t('basemap')]
: ['B', describe(b), 'B'],
];
const [before, after] = swapped ? [sides[1], sides[0]] : sides;
const signature = live
@@ -799,10 +916,15 @@ export function createRecentImageryPanel({
beforeTitle: before[1],
afterTitle: after[1],
ariaLabel: basemap
? 'Recent imagery against the basemap divider'
: 'Recent imagery A and B divider',
? t('Recent imagery against the basemap divider')
: t('Recent imagery A and B divider'),
formatValueText: (leftPercent, rightPercent) =>
`${before[2][0].toUpperCase()}${before[2].slice(1)} ${leftPercent} percent, ${after[2]} ${rightPercent} percent`,
t('{before} {left} percent, {after} {right} percent', {
before: `${before[2][0].toUpperCase()}${before[2].slice(1)}`,
left: leftPercent,
after: after[2],
right: rightPercent,
}),
getViewportWidth: () =>
Number(viewer?.scene?.canvas?.clientWidth) ||
Number(document.documentElement?.clientWidth) ||
@@ -895,7 +1017,9 @@ export function createRecentImageryPanel({
return true;
} catch (error) {
if (!destroyed)
exportError = `Export failed · ${error?.message || error}`;
exportError = t('Export failed · {error}', {
error: error?.message || error,
});
return false;
} finally {
exporting.delete(slotId);
+46 -11
View File
@@ -646,7 +646,10 @@ test('cards carry thumbnail, date, sensor, cloud, START HERE and PREVIEW; the ch
f.layer.focus(1);
const s18 = f.card(S18);
assert.equal(f.part(s18, 'ri-card-date').textContent, 'Sep 18');
assert.equal(f.part(s18, 'ri-card-sensor').textContent, 'Sentinel-2 · 30 m');
assert.equal(
f.part(s18, 'ri-card-sensor').textContent,
'Sentinel-2 · 30\u00a0m',
);
assert.equal(f.part(s18, 'ri-card-cloud').textContent, '12% cloud');
assert.equal(f.part(s18, 'ri-card-start').hidden, false);
assert.match(f.part(s18, 'ri-card-start').title, /Newest low-cloud day/);
@@ -658,11 +661,11 @@ test('cards carry thumbnail, date, sensor, cloud, START HERE and PREVIEW; the ch
assert.equal(f.part(f.card(V21), 'ri-thumb-text').textContent, 'Checking');
assert.equal(
f.part(f.card(V21), 'ri-card-sensor').textContent,
'Daily overview · 250 m',
'Daily overview · 250\u00a0m',
);
assert.equal(
f.part(f.card(L16), 'ri-card-sensor').textContent,
'Landsat 8/9 · 30 m',
'Landsat 8/9 · 30\u00a0m',
);
// IMAGE: one SHOW chip; the B chip keeps its box but is invisible.
const [a, b] = [f.chip(S18, 'a'), f.chip(S18, 'b')];
@@ -734,7 +737,10 @@ test('the selection bar reads the mode, both rows and a preview, with × reserve
['A / B', 'radio', 'false'],
],
);
assert.equal(rowText(f, 'a'), 'IMAGE | Sep 18 · Sentinel-2 · 30 m · preview');
assert.equal(
rowText(f, 'a'),
'IMAGE | Sep 18 · Sentinel-2 · 30\u00a0m · preview',
);
assert.equal(f.byId('ri-slot-a').dataset.state, 'preview');
assert.equal(rowText(f, 'b'), 'VS | Basemap');
assert.equal(f.byId('ri-slot-b').classList.contains('lit'), false, 'dimmed');
@@ -748,7 +754,7 @@ test('the selection bar reads the mode, both rows and a preview, with × reserve
[true, true, '×'],
);
f.key('s');
assert.equal(rowText(f, 'a'), 'IMAGE | Sep 18 · Sentinel-2 · 30 m');
assert.equal(rowText(f, 'a'), 'IMAGE | Sep 18 · Sentinel-2 · 30\u00a0m');
assert.equal(f.byId('ri-slot-a').dataset.state, 'pinned');
assert.equal(unpinA.disabled, false);
assert.equal(unpinA.getAttribute('aria-label'), 'Unpin the image');
@@ -757,12 +763,12 @@ test('the selection bar reads the mode, both rows and a preview, with × reserve
assert.equal(modes[1].getAttribute('aria-checked'), 'true');
assert.equal(f.byId('ri-slot-b').classList.contains('lit'), true, 'lit');
modes[2].click();
assert.equal(rowText(f, 'a'), 'A | Sep 18 · Sentinel-2 · 30 m');
assert.equal(rowText(f, 'a'), 'A | Sep 18 · Sentinel-2 · 30\u00a0m');
assert.equal(rowText(f, 'b'), 'B | Not set');
assert.equal(f.byId('ri-slot-b').dataset.state, 'empty');
assert.equal(f.byId('ri-unpin-b').classList.contains('is-reserved'), true);
f.chip(L16, 'b').click();
assert.equal(rowText(f, 'b'), 'B | Sep 16 · Landsat 8/9 · 30 m');
assert.equal(rowText(f, 'b'), 'B | Sep 16 · Landsat 8/9 · 30\u00a0m');
f.byId('ri-unpin-b').click();
assert.equal(f.snap().pins.b.key, null);
unpinA.click();
@@ -879,7 +885,10 @@ test('the notice line shows the refusal, then errors, then CLEAR, then the Esri
);
assert.equal(notice.classList.contains('info'), true);
f.layer.setBox({ west: 0, south: 0, east: 20, north: 20 });
assert.equal(noticeText.textContent, 'Box is 2,226 km wide · limit 1,000 km');
assert.equal(
noticeText.textContent,
'Box is 2,226\u00a0km wide · limit 1,000\u00a0km',
);
assert.equal(notice.classList.contains('warn'), true);
assert.equal(hint.textContent, 'Zoom in or draw a smaller box');
f.layer.setBox(BOX);
@@ -929,7 +938,7 @@ test('an oversized box offers ZOOM IN in a fixed slot at the end of the notice l
assert.deepEqual(reserved(), [false, false, 'false', true]);
assert.equal(
f.byId('ri-notice-text').textContent,
'Box is 2,226 km wide · limit 1,000 km',
'Box is 2,226\u00a0km wide · limit 1,000\u00a0km',
);
assert.equal(f.byId('ri-hint').textContent, 'Zoom in or draw a smaller box');
assert.equal(zoom.title, 'Fly in until the view fits the 1,000 km limit');
@@ -998,7 +1007,10 @@ test('the divider exists only while a swipe is live, labelled A / B or IMAGE / B
[split.options.beforeLabel, split.options.afterLabel],
['A', 'B'],
);
assert.equal(split.options.afterTitle, 'Landsat 8/9 · 30 m · 2026-09-16');
assert.equal(
split.options.afterTitle,
'Landsat 8/9 · 30\u00a0m · 2026-09-16',
);
split.options.onChange(0.3);
assert.equal(f.snap().split, 0.3);
assert.deepEqual(directions(), ['left', 'right']);
@@ -1015,7 +1027,7 @@ test('the divider exists only while a swipe is live, labelled A / B or IMAGE / B
assert.deepEqual(labels(), ['B', 'A']);
assert.equal(
f.splits.at(-1).options.beforeTitle,
'Landsat 8/9 · 30 m · 2026-09-16',
'Landsat 8/9 · 30\u00a0m · 2026-09-16',
);
assert.equal(f.splits.at(-1).options.initialValue, 0.3);
assert.deepEqual(directions(), ['right', 'left']);
@@ -1135,6 +1147,29 @@ test('the body scroll position survives renders even when DOM mutations reset it
assert.equal(f.body.scrollTop, 120);
});
test('opening DETAILS scrolls its card into view, and closing it does not scroll', async () => {
const f = fixture();
await f.ready();
// The body is 100 px high and scrolled 40 px; the card opens 150 px down.
f.body.scrollTop = 40;
f.body.clientHeight = 100;
f.body.getBoundingClientRect = () => ({ top: 0 });
const header = f
.byId('ri-details')
.find((node) => node.classList.contains('rail-card-header'));
const card = header.parentNode;
card.getBoundingClientRect = () => ({
top: 150,
height: card.dataset.open === 'true' ? 60 : 0,
});
header.click();
assert.equal(card.dataset.open, 'true');
assert.equal(f.body.scrollTop, 150);
header.click();
assert.equal(card.dataset.open, 'false');
assert.equal(f.body.scrollTop, 150);
});
test('destroy returns every listener, the divider and the panel state', async () => {
const f = fixture();
await f.ready();
+103
View File
@@ -0,0 +1,103 @@
// Box-tool and model refusals on the Recent Imagery notice line are
// shown in the UI language; the layer keeps them in English.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { setLocale } from '../i18n/index.js';
import { validateBox } from '../layers/recentImagery/model.js';
import { localizeNotice } from './recentImagery.js';
const REFUSALS = [
'Drag to size the box', // imageryBoxTool.js, click without drag
'Press on the ground, not the sky', // imageryBoxTool.js
'draw is using the pointer — close it first', // imageryBoxTool.js, pointer busy
'Point the camera at the ground to use the view', // layers/recentImagery/index.js
'That point cannot anchor a box', // layers/recentImagery/index.js
...[
{ west: 170, south: 10, east: -170, north: 20 }, // dateline
{ west: 10, south: 80, east: 20, north: 89 }, // polar
{ west: 10, south: 10, east: 10, north: 20 }, // degenerate
{ west: 10, south: 10, east: 200, north: 20 }, // longitude range
{ west: Number.NaN, south: 10, east: 20, north: 20 }, // not finite
].map((box) => {
const result = validateBox(box);
assert.equal(result.ok, false);
return result.message;
}),
];
// The pointer-busy refusal names the owner by its internal id; the notice
// shows the tool's name instead, in English too.
const SHOWN_EN = new Map([
[
'draw is using the pointer — close it first',
'Draw is using the pointer — close it first',
],
]);
test('en: refusals stay English', () => {
setLocale('en');
for (const text of REFUSALS)
assert.equal(localizeNotice(text), SHOWN_EN.get(text) ?? text);
assert.ok(REFUSALS.includes('Imagery stops at ±85.05° latitude'));
});
test('E3/E12: the pointer owner is named by its tool, never by its id', () => {
const owners = {
en: {
draw: 'Draw',
directions: 'Directions',
'recent-imagery-box': 'Recent imagery',
},
ru: {
draw: 'Рисование',
directions: 'Маршруты',
'recent-imagery-box': 'Свежие снимки',
},
es: {
draw: 'Dibujo',
directions: 'Indicaciones',
'recent-imagery-box': 'Imágenes recientes',
},
};
try {
for (const [locale, names] of Object.entries(owners)) {
setLocale(locale);
for (const [id, name] of Object.entries(names)) {
const shown = localizeNotice(
`${id} is using the pointer — close it first`,
);
assert.ok(shown.includes(name), `${locale} ${id}: ${shown}`);
assert.ok(!shown.includes(id), `${locale} ${id} leaked: ${shown}`);
}
}
setLocale('en');
assert.equal(
localizeNotice('measure is using the pointer — close it first'),
'measure is using the pointer — close it first',
'an unregistered owner falls back to its id',
);
} finally {
setLocale('en');
}
});
for (const locale of ['es', 'ru']) {
test(`${locale}: box-tool and model refusals are translated`, () => {
setLocale(locale);
try {
for (const text of REFUSALS)
assert.notEqual(localizeNotice(text), text, `untranslated: ${text}`);
assert.match(
localizeNotice('Imagery stops at ±85.05° latitude'),
/85,05/,
);
assert.doesNotMatch(
localizeNotice('draw is using the pointer — close it first'),
/\bdraw\b/,
);
} finally {
setLocale('en');
}
});
}
+19 -8
View File
@@ -1,4 +1,5 @@
/** Own Scene panel listeners and presentation through project reads and actions. */
import { t, msg } from '../i18n/index.js';
import {
sceneElements,
renderSceneOptions,
@@ -45,7 +46,7 @@ export class SceneControls {
this.listen(elements.start, 'click', () =>
this.run('start', this.read().selectedSceneId),
);
this.listen(elements.stop, 'click', () => this.run('stop', 'Stopped'));
this.listen(elements.stop, 'click', () => this.run('stop', msg('Stopped')));
this.listen(elements.import, 'click', () => elements.file?.click());
this.listen(elements.file, 'change', async () => {
const file = elements.file?.files?.[0];
@@ -57,7 +58,7 @@ export class SceneControls {
if (!this.destroyed) elements.file.value = '';
});
if (!subscribe) {
this.updateStatus('Ready');
this.updateStatus(t('Ready'));
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('Scene action failed'));
};
try {
const result = this.actions[action](...args);
@@ -140,8 +141,8 @@ export class SceneControls {
createScene() {
if (this.destroyed) return;
const name = window.prompt(
'New scene name',
`Scene ${this.read().scenes.length + 1}`,
t('New scene name'),
t('Scene {n}', { n: this.read().scenes.length + 1 }),
);
if (name) this.run('create', name);
}
@@ -152,7 +153,12 @@ export class SceneControls {
const scene = state.scenes.find(
(item) => item.id === state.selectedSceneId,
);
if (scene && window.confirm(`Delete scene "${scene.title}" and all shots?`))
if (
scene &&
window.confirm(
t('Delete scene "{scene}" and all shots?', { scene: scene.title }),
)
)
this.run('deleteScene');
}
@@ -160,7 +166,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('Delete shot "{shot}"?', { shot: shot.displayTitle || shot.title }),
)
)
this.run('deleteShot', sceneId, shotId);
}
@@ -215,7 +226,7 @@ export class SceneControls {
} else if (!this.destroyed && !this.playbackKeyRemover) {
const onKeyDown = (event) => {
if (!this.destroyed && event.key === 'Escape' && this.read().running)
this.run('stop', 'Stopped (Esc)');
this.run('stop', msg('Stopped (Esc)'));
};
document.addEventListener('keydown', onKeyDown);
this.playbackKeyRemover = () =>
+53 -10
View File
@@ -1,4 +1,26 @@
/** Scene panel elements and text-only project presentation. */
import { formatNumber, msg, t } from '../i18n/index.js';
// Labels for what a shot stores; anything else shows as stored. Style names
// match STYLE_STATUS_LABELS in visualPresets.js, which this package cannot
// import (it pulls in the shaders).
const STYLE_LABELS = Object.freeze({
normal: msg('NORMAL'),
retro: msg('CRT'),
surveillance: msg('NVG'),
thermal: msg('FLIR'),
anime: msg('ANIME'),
noir: msg('NOIR'),
snow: msg('SNOW'),
});
const DETECTION_MODE_LABELS = Object.freeze({
OFF: msg('OFF'),
SPARSE: msg('SPARSE'),
BALANCED: msg('BALANCED'),
DENSE: msg('DENSE'),
});
export function sceneElements(root = document) {
const ids = {
panel: 'scene-panel',
@@ -47,11 +69,16 @@ export function renderSceneShots(
if (!scene || scene.shots.length === 0) {
const empty = document.createElement('div');
empty.className = 'scene-shot-empty';
empty.textContent = 'No shots yet. Use CAPTURE SHOT to save current look.';
empty.textContent = t(
'No shots yet. Use CAPTURE SHOT to save current look.',
);
element.appendChild(empty);
return;
}
for (const shot of scene.shots) {
// Built-in titles arrive localized as displayTitle; the stored title
// stays English, so a rename happens only when the shown text changes.
const shownTitle = shot.displayTitle || shot.title;
const row = document.createElement('div');
row.className = 'scene-shot-row';
row.dataset.sceneShotId = shot.id;
@@ -60,23 +87,23 @@ export function renderSceneShots(
top.className = 'scene-shot-top';
const label = document.createElement('div');
label.className = 'scene-shot-label';
label.textContent = shot.title;
label.title = 'Double-click to rename';
label.textContent = shownTitle;
label.title = t('Double-click to rename');
listen(label, 'click', () => select(shot.id));
listen(label, 'dblclick', () => {
if (label.children.length) return;
const input = document.createElement('input');
input.type = 'text';
input.className = 'scene-shot-rename';
input.setAttribute('aria-label', 'Shot name');
input.value = shot.title;
input.setAttribute('aria-label', t('Shot name'));
input.value = shownTitle;
let finished = false;
const finish = (save) => {
if (finished) return;
finished = true;
const title = input.value.trim();
label.textContent = shot.title;
if (save && title && title !== shot.title)
label.textContent = shownTitle;
if (save && title && title !== shownTitle)
rename(scene.id, shot.id, title);
};
listen(input, 'keydown', (event) => {
@@ -96,11 +123,11 @@ export function renderSceneShots(
actions.className = 'scene-shot-actions';
const loadButton = document.createElement('button');
loadButton.className = 'scene-shot-btn';
loadButton.textContent = 'LOAD';
loadButton.textContent = t('LOAD');
listen(loadButton, 'click', () => load(scene.id, shot.id));
const deleteButton = document.createElement('button');
deleteButton.className = 'scene-shot-btn scene-shot-danger';
deleteButton.textContent = 'DEL';
deleteButton.textContent = t('DEL');
listen(deleteButton, 'click', () => remove(scene.id, shot.id));
actions.appendChild(loadButton);
actions.appendChild(deleteButton);
@@ -110,7 +137,23 @@ export function renderSceneShots(
meta.className = 'scene-shot-meta';
const mode = shot.visual?.detection?.mode || 'OFF';
const style = shot.visual?.style || 'normal';
meta.textContent = `${style.toUpperCase()} · ${mode} · ${shot.durationSec.toFixed(1)}s + ${shot.holdSec.toFixed(1)}s`;
const styleLabel = STYLE_LABELS[style]
? t(STYLE_LABELS[style])
: style.toUpperCase();
const modeLabel = DETECTION_MODE_LABELS[mode]
? t(DETECTION_MODE_LABELS[mode])
: mode;
const seconds = (value) =>
formatNumber(value, {
minimumFractionDigits: 1,
maximumFractionDigits: 1,
});
// English keeps its compact "4.0s + 0.9s"; other languages space the unit.
const timing = t('{duration}s + {hold}s', {
duration: seconds(shot.durationSec),
hold: seconds(shot.holdSec),
});
meta.textContent = `${styleLabel} · ${modeLabel} · ${timing}`;
row.appendChild(top);
row.appendChild(meta);
element.appendChild(row);
+67 -3
View File
@@ -1,3 +1,5 @@
import { t, tn } from '../i18n/index.js';
/** Small owned modal for scene authoring and import review; text never becomes markup. */
export function createSceneDialog(title, onClose) {
const dialog = document.createElement('dialog');
@@ -79,10 +81,72 @@ export function createSceneDialog(title, onClose) {
});
if (multiline) node.rows = 9;
wrapper.append(node);
if (type === 'file') fileChooser(wrapper, node);
body.append(wrapper);
return node;
}
button('Cancel', onClose);
/**
* The browser draws a file input's button and "no file" text in its own
* language. Stretch the native input, transparent, over the whole row (so
* click, focus and drops stay native) and draw translated text under it.
*/
function fileChooser(wrapper, node) {
wrapper.style.position = 'relative';
Object.assign(node.style, {
position: 'absolute',
inset: '0',
width: '100%',
height: '100%',
margin: '0',
padding: '0',
border: '0',
opacity: '0',
cursor: 'pointer',
zIndex: '1',
});
const row = document.createElement('span');
row.setAttribute('aria-hidden', 'true');
Object.assign(row.style, {
display: 'flex',
alignItems: 'center',
gap: '10px',
margin: '6px 0 14px',
padding: '7px',
background: '#10242e',
border: '1px solid #397080',
});
const chooser = document.createElement('span');
chooser.textContent = t('Choose files');
Object.assign(chooser.style, {
padding: '3px 9px',
border: '1px solid #397080',
borderRadius: '3px',
background: '#08141e',
cursor: 'pointer',
});
const chosen = document.createElement('span');
const showChosen = () => {
const count = node.files?.length || 0;
// One file shows its name, like the browser's own picker did.
chosen.textContent =
count === 1
? node.files[0].name
: count
? tn(count, '{count} file selected', '{count} files selected')
: t('No file chosen');
};
showChosen();
row.append(chooser, chosen);
wrapper.append(row);
listen(node, 'change', showChosen);
listen(node, 'focus', () => {
chooser.style.outline = '2px solid #e3faff';
});
listen(node, 'blur', () => {
chooser.style.outline = '';
});
}
button(t('Cancel'), onClose);
listen(dialog, 'cancel', (event) => {
event.preventDefault();
onClose();
@@ -116,8 +180,8 @@ export function mountSceneSharing(
bar.dataset.directorAuthoring = '';
bar.className = 'scene-controls';
const entries = [
['EDIT DETAILS', edit],
['SHARE SCENE', share],
[t('EDIT DETAILS'), edit],
[t('SHARE SCENE'), share],
];
const removers = [];
for (const [text, fn] of entries) {
+55 -9
View File
@@ -1,8 +1,56 @@
import { LayerStateCoordinator } from '../data/layerStateCoordinator.js';
import { stampInitialShareGesture } from '../navigationPolicy.js';
import { canPresentDeferredStatusNotice } from '../loadingFeedback.js';
import { t, msg } from '../i18n/index.js';
import { UiLifetime } from './uiLifetime.js';
// One ready-made sentence per restore policy (SHARE_TRACKING_RESTORE_POLICIES
// label) and outcome: a label dropped into a template cannot take the case or
// article the sentence needs in every language. The generic template stays
// for a label this table does not know.
const SHARE_RESTORE_NOTICES = Object.freeze({
expired: Object.freeze({
flight: msg('Shared flight follow expired'),
'military flight': msg('Shared military flight follow expired'),
satellite: msg('Shared satellite follow expired'),
}),
'source-unavailable': Object.freeze({
flight: msg('Shared flight could not be restored — feed unavailable'),
'military flight': msg(
'Shared military flight could not be restored — feed unavailable',
),
satellite: msg('Shared satellite could not be restored — feed unavailable'),
}),
unavailable: Object.freeze({
flight: msg('Shared flight is unavailable'),
'military flight': msg('Shared military flight is unavailable'),
satellite: msg('Shared satellite is unavailable'),
}),
});
/**
* The status notice for a terminal share-tracking restore result.
* @param {{classification?: string, label?: string}} result
* @returns {string}
*/
export function shareTrackingRestoreNotice(result) {
const outcome =
result?.classification === 'expired' ||
result?.classification === 'source-unavailable'
? result.classification
: 'unavailable';
const ready = SHARE_RESTORE_NOTICES[outcome][result?.label];
if (ready) return t(ready);
const subject = result?.label ? t(result.label) : t('entity');
return outcome === 'expired'
? t('Shared {subject} follow expired', { subject })
: outcome === 'source-unavailable'
? t('Shared {subject} could not be restored — feed unavailable', {
subject,
})
: t('Shared {subject} is unavailable', { subject });
}
/** Own initial share restoration, durable layer state and restoration notices. */
export class ShareRestoration {
constructor({
@@ -188,9 +236,13 @@ export class ShareRestoration {
if (result.classification === 'pending') {
this._shareTrackingNoticeGeneration += 1;
this._shareTrackingAcquiringKey = trackingKey;
this.showStatus('ACQUIRING', {
this.showStatus(t('ACQUIRING'), {
state: 'acquiring',
detail: `SHARED ${String(result.label || 'SUBJECT').toUpperCase()}`,
detail: t('SHARED {subject}', {
subject: String(
result.label ? t(result.label) : t('subject'),
).toUpperCase(),
}),
persistent: true,
});
return;
@@ -214,13 +266,7 @@ export class ShareRestoration {
const noticeGeneration = ownsAcquiringNotice
? this._shareTrackingNoticeGeneration
: ++this._shareTrackingNoticeGeneration;
const subject = result.label || 'entity';
const message =
result.classification === 'expired'
? `Shared ${subject} follow expired`
: result.classification === 'source-unavailable'
? `Shared ${subject} could not be restored — feed unavailable`
: `Shared ${subject} is unavailable`;
const message = shareTrackingRestoreNotice(result);
const showAfterStartupCover = () => {
this._lifetime.frame(() => {
if (
+78
View File
@@ -0,0 +1,78 @@
// Share-tracking restore notices are ready-made sentences per policy: a label
// dropped into a template cannot take the case (ru) or article (es) needed.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { setLocale } from '../i18n/index.js';
import { SHARE_TRACKING_RESTORE_POLICIES } from '../data/layerState.js';
import { shareTrackingRestoreNotice } from './shareRestoration.js';
const { flights, military, satellites } = SHARE_TRACKING_RESTORE_POLICIES;
test.afterEach(() => setLocale('en'));
test('en: the notices for every policy and outcome', () => {
setLocale('en');
const notice = (policy, classification) =>
shareTrackingRestoreNotice({ ...policy, classification });
assert.equal(notice(flights, 'expired'), 'Shared flight follow expired');
assert.equal(
notice(military, 'expired'),
'Shared military flight follow expired',
);
assert.equal(
notice(satellites, 'source-unavailable'),
'Shared satellite could not be restored — feed unavailable',
);
assert.equal(
notice(flights, 'source-unavailable'),
'Shared flight could not be restored — feed unavailable',
);
assert.equal(
notice(military, 'unavailable'),
'Shared military flight is unavailable',
);
assert.equal(
notice(satellites, 'unavailable'),
'Shared satellite is unavailable',
);
// Unknown label and classification: the generic template still applies.
assert.equal(
shareTrackingRestoreNotice({ label: 'contact', classification: 'odd' }),
'Shared contact is unavailable',
);
assert.equal(
shareTrackingRestoreNotice({ classification: 'expired' }),
'Shared entity follow expired',
);
});
test('ru: the restore notices do not glue the nominative label into a genitive slot', () => {
setLocale('ru');
for (const policy of [flights, military, satellites]) {
for (const classification of [
'expired',
'source-unavailable',
'unavailable',
]) {
const message = shareTrackingRestoreNotice({ ...policy, classification });
// "Срок отслеживания" takes the genitive: рейса / спутника, never the
// nominative label as stored in the policy.
assert.doesNotMatch(
message,
/отслеживания (военный )?(рейс|спутник) /,
message,
);
assert.doesNotMatch(message, /Shared|\{subject\}/, message);
}
}
});
test('es: the restore notices read with an article before the subject', () => {
setLocale('es');
const message = shareTrackingRestoreNotice({
...flights,
classification: 'expired',
});
assert.doesNotMatch(message, /seguimiento de vuelo/, message);
assert.doesNotMatch(message, /Shared/, message);
});
+117 -6
View File
@@ -9,6 +9,112 @@ import {
reduceTrafficSyncFeedback,
} from '../loadingFeedback.js';
import { setSplitFlapText, disposeSplitFlap } from '../splitFlap.js';
import { getLocale, t, msg } from '../i18n/index.js';
import { localizeLayerName } from './layerPanel.js';
import { translateFeedError } from './statusText.js';
// The server also loads loadingFeedback.js (through the HUD summary), so its
// labels stay English. Translate the known ones; pass anything else (a live
// provider error) through.
const STATUS_LABEL_KEYS = Object.freeze({
'LOADING LIVE DATA': msg('LOADING LIVE DATA'),
'LOAD COMPLETE': msg('LOAD COMPLETE'),
'LOAD CANCELLED': msg('LOAD CANCELLED'),
'LOAD FAILED': msg('LOAD FAILED'),
'LIVE DATA OFF': msg('LIVE DATA OFF'),
'MAPPED SITES LOADED': msg('MAPPED SITES LOADED'),
'RETRYING MAPPED SITES': msg('RETRYING MAPPED SITES'),
'FETCHING MAPPED SITES': msg('FETCHING MAPPED SITES'),
'RETRYING ALPR CAMERAS': msg('RETRYING ALPR CAMERAS'),
'FETCHING ALPR CAMERAS': msg('FETCHING ALPR CAMERAS'),
'OVERPASS TEMPORARILY UNAVAILABLE': msg('OVERPASS TEMPORARILY UNAVAILABLE'),
// The other mapped-site failure reasons (src/data/installationFeedback.js),
// upper-cased by presentGlobalLoadingStatus.
'OVERPASS RATE-LIMITED': msg('OVERPASS RATE-LIMITED'),
'OVERPASS TIMED OUT': msg('OVERPASS TIMED OUT'),
'OVERPASS COULD NOT COMPLETE THE QUERY': msg(
'OVERPASS COULD NOT COMPLETE THE QUERY',
),
'MAP TILES TEMPORARILY UNAVAILABLE': msg('MAP TILES TEMPORARILY UNAVAILABLE'),
'MAPPED NAMES TEMPORARILY UNAVAILABLE': msg(
'MAPPED NAMES TEMPORARILY UNAVAILABLE',
),
'TURNING OFF LIVE DATA': msg('TURNING OFF LIVE DATA'),
'REFRESHING LIVE DATA': msg('REFRESHING LIVE DATA'),
'syncing road network': msg('syncing road network'),
});
// The ALPR retry chip shows the layer's English error upper-cased
// (loadingFeedback.js). These are the camera and vector tile failures it can
// carry, by their upper-cased text; each is translated through its own
// sentence-case key, the one the layer row shows, and upper-cased again.
const CHIP_ERROR_KEYS = new Map(
[
msg('Vector tile metadata unavailable'),
msg('Vector tiles unavailable'),
msg('Vector tile request timed out'),
msg('Camera coverage unavailable'),
msg('Camera source temporarily unavailable'),
msg('Overpass returned an incomplete camera response'),
].map((key) => [key.toUpperCase(), key]),
);
const CHIP_TILE_HTTP_RE = /^VECTOR TILES UNAVAILABLE \(HTTP (\d+)\)$/;
function localizeStatusLabel(label) {
if (typeof label !== 'string' || !label) return label;
const text = label.trim();
const key = STATUS_LABEL_KEYS[text];
if (key) return t(key);
const errorKey = CHIP_ERROR_KEYS.get(text);
const http = errorKey ? null : CHIP_TILE_HTTP_RE.exec(text);
if (!errorKey && !http) return label;
return translateFeedError(
errorKey || `Vector tiles unavailable (HTTP ${http[1]})`,
).toLocaleUpperCase(getLocale());
}
// A retry detail is "retrying in Ns" or "retry pending", optionally prefixed
// by the ALPR layer name (loadingFeedback.js). The prefix is kept in every
// language: it is what tells the chip apart from a mapped-sites retry.
const RETRY_DETAIL_RE =
/^(?:(ALPR cameras) · )?(?:retrying in (\d+)s|(retry pending))$/;
function localizeStatusDetail(detail) {
if (typeof detail !== 'string' || !detail) return detail;
const match = detail.trim().match(RETRY_DETAIL_RE);
if (match) {
const [, prefix, seconds, pending] = match;
const retry = pending
? t('retry pending')
: t('retrying in {seconds}s', { seconds });
return prefix ? `${t('ALPR cameras')} · ${retry}` : retry;
}
return localizeLayerRoster(detail);
}
// The loading detail is the active layers' English names joined by " · ",
// with " +N" when more are loading. Translate each known layer name; anything
// else (a provider such as "OpenStreetMap · Overpass") passes through.
function localizeLayerRoster(detail) {
const [, names, suffix = ''] = detail.match(/^(.*?)( \+\d+)?$/s) || [];
if (!names) return detail;
return (
names
.split(' · ')
.map((name) => localizeLayerName(name))
.join(' · ') + suffix
);
}
/** The chip's label and detail in the UI language (exported for tests). */
export 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 +182,14 @@ 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,
});
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 +230,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 +242,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 = {}) {
@@ -0,0 +1,139 @@
// The global loading chip in the UI language: the status vocabulary, the ALPR
// retry prefix and every mapped-site failure reason.
import test from 'node:test';
import assert from 'node:assert/strict';
import { setLocale, t } from '../i18n/index.js';
import {
aggregateLayerLoading,
createLoadingFeedbackState,
presentLoadingFeedback,
reduceLoadingFeedback,
} from '../loadingFeedback.js';
const elements = {
'global-loading-status': { hidden: true, dataset: {} },
'global-loading-detail': { textContent: '' },
};
globalThis.document ??= {
getElementById: (id) => elements[id] ?? null,
addEventListener() {},
removeEventListener() {},
hidden: false,
};
const { ShellFeedback, localizeLoadingPresentation } =
await import('./shellFeedback.js');
function alprChipDetail(locale, retryInMs) {
setLocale(locale);
const layer = {
id: 'alpr-cameras',
name: 'ALPR cameras',
enabled: true,
stats: {
status: 'unavailable',
error: 'Unavailable',
retryAt: Date.now() + retryInMs,
},
};
const feedback = new ShellFeedback({ readLayers: () => [layer] });
feedback._armLoadingFeedbackTicker = () => {};
feedback._updateGlobalLoadingFeedback(0);
return elements['global-loading-detail'].textContent;
}
test.afterEach(() => setLocale('en'));
test('English ALPR retry chip keeps the layer name (as on main)', () => {
assert.equal(alprChipDetail('en', 12000), 'ALPR cameras · retrying in 12s');
assert.equal(alprChipDetail('en', -1000), 'ALPR cameras · retry pending');
});
test('Spanish ALPR retry chip keeps the layer name', () => {
assert.match(alprChipDetail('es', 12000), /ALPR.* · reintento en 12 s/);
});
test('Spanish ALPR retry-pending chip is translated', () => {
const detail = alprChipDetail('es', -1000);
assert.match(detail, /ALPR/);
assert.doesNotMatch(detail, /retry pending/);
});
test('English presentation passes through unchanged', () => {
setLocale('en');
for (const label of [
'OVERPASS TEMPORARILY UNAVAILABLE',
'OVERPASS RATE-LIMITED',
'OVERPASS TIMED OUT',
'OVERPASS COULD NOT COMPLETE THE QUERY',
'MAP TILES TEMPORARILY UNAVAILABLE',
'MAPPED NAMES TEMPORARILY UNAVAILABLE',
]) {
assert.deepEqual(
localizeLoadingPresentation({
state: 'retry',
label,
detail: 'retrying in 12s',
}),
{ state: 'retry', label, detail: 'retrying in 12s' },
);
}
});
for (const locale of ['es', 'ru']) {
test(`${locale}: every mapped-site retry label is translated with its detail`, () => {
setLocale(locale);
for (const label of [
'OVERPASS TEMPORARILY UNAVAILABLE',
'OVERPASS RATE-LIMITED',
'OVERPASS TIMED OUT',
'OVERPASS COULD NOT COMPLETE THE QUERY',
'MAP TILES TEMPORARILY UNAVAILABLE',
'MAPPED NAMES TEMPORARILY UNAVAILABLE',
]) {
const localized = localizeLoadingPresentation({
state: 'retry',
label,
detail: 'retrying in 12s',
});
assert.notEqual(localized.label, label, `label stays English: ${label}`);
assert.notEqual(localized.detail, 'retrying in 12s');
}
});
test(`${locale}: a live provider error label passes through verbatim`, () => {
setLocale(locale);
const localized = localizeLoadingPresentation({
state: 'retry',
label: 'HTTP 503 FROM OVERPASS-API.DE',
detail: 'retry pending',
});
assert.equal(localized.label, 'HTTP 503 FROM OVERPASS-API.DE');
assert.notEqual(localized.detail, 'retry pending');
});
// An incomplete Overpass answer (alpr/source.js) on the ALPR retry chip,
// which shows the layer's error upper-cased.
test(`${locale}: the ALPR retry chip translates an incomplete Overpass answer`, () => {
setLocale(locale);
const error = 'Overpass returned an incomplete camera response';
assert.notEqual(t(error), error, 'the catalog has the entry');
const now = Date.now();
const layer = {
id: 'alpr-cameras',
name: 'ALPR cameras',
enabled: true,
stats: { status: 'unavailable', error, retryAt: now + 12000 },
};
const summary = aggregateLayerLoading([layer]);
const state = reduceLoadingFeedback(
createLoadingFeedbackState(),
summary,
now,
);
const shown = localizeLoadingPresentation(
presentLoadingFeedback(state, summary, now),
);
assert.equal(shown.state, 'retry', 'the ALPR retry chip is up');
assert.equal(shown.label, t(error).toLocaleUpperCase(locale));
});
}
+104
View File
@@ -0,0 +1,104 @@
// Shell toasts, prompts and launcher labels: pin that they are marked in the
// code, present in every shipped catalog, and that no bare literal is left at
// the call sites (`window.prompt/confirm` and constants are invisible to the
// unmarked-text checker).
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import {
extractMessages,
readCatalog,
shippedLocales,
} from '../../scripts/i18n.mjs';
import { setLocale } from '../i18n/index.js';
import {
beginDeferredNavigation,
reassertNavigationHandoff,
runExplicitNavigation,
} from '../navigationPolicy.js';
const KEYS = [
'Tilted view',
'Straight-down view',
'North up',
'Exit cockpit to fly to a camera',
'Exit cockpit to fly to a location',
'Exit cockpit to fly to a photo',
'Exit cockpit to fly to a shared view',
'Go to expanded Radio section',
'Expand Radio section in Context',
'Double-click the header to snap the panel back',
'New scene name',
'Delete scene "{scene}" and all shots?',
'Detailed outline unavailable',
];
const messages = extractMessages();
for (const key of KEYS) {
test(`marked for translation: ${key}`, () => {
assert.ok(messages.has(key), `not marked with t()/msg(): ${key}`);
});
for (const locale of shippedLocales()) {
test(`${locale} catalog translates: ${key}`, () => {
const value = readCatalog(locale)[key];
assert.ok(value, `missing in ${locale}.json`);
assert.notEqual(value, key);
});
}
}
test('no bare literal reaches showToast in the orientation and navigation code', () => {
for (const file of [
'../ui/cameraOrientationControls.js',
'../navigationPolicy.js',
'../ui/locationNavigation.js',
'../ui/panelPositionControls.js',
]) {
const source = readFileSync(
fileURLToPath(new URL(file, import.meta.url)),
'utf8',
);
assert.doesNotMatch(
source,
/_?showToast\??\.?\(\s*['"`]/,
`${file} passes a bare literal to showToast`,
);
}
});
test.afterEach(() => setLocale('en'));
test('the Cockpit refusal keeps its English wording per noun', () => {
setLocale('en');
const log = [];
const showToast = (text) => log.push(text);
runExplicitNavigation({ cockpitActive: true, noun: 'camera', showToast });
beginDeferredNavigation({ cockpitActive: true, showToast });
reassertNavigationHandoff({
generation: 1,
currentGeneration: 1,
cockpitActive: true,
showToast,
});
assert.deepEqual(log, [
'Exit cockpit to fly to a camera',
'Exit cockpit to fly to a location',
'Exit cockpit to fly to a location',
]);
});
for (const locale of ['es', 'ru']) {
test(`${locale}: the Cockpit refusal is translated`, () => {
setLocale(locale);
const log = [];
runExplicitNavigation({
cockpitActive: true,
noun: 'camera',
showToast: (text) => log.push(text),
});
assert.equal(log.length, 1);
assert.doesNotMatch(log[0], /Exit cockpit/);
});
}
+233
View File
@@ -0,0 +1,233 @@
/**
* Layer status texts in the UI language.
*
* Layers report errors, loading labels and status notes as English data
* (stats, analyst records, what the model receives); the Layers panel, the
* loading chip, layer cards and the voice card show them translated through
* here, by their shape.
*
* Pure: imports only i18n and layer names, no DOM.
* @module ui/statusText
*/
import { sourceLabel } from './layerNames.js';
import { formatNumber, msg, t, tn, translateKnown } from '../i18n/index.js';
const STATUS_SEPARATOR = ' · ';
// Live-source failures arrive as English data (src/sources/live/contract.js,
// standalone.js, flights and military ingestion), some with a source name or a
// number in them; the row shows them in the UI language. Keep these in step
// with the producers. Anything else shows its catalog entry, or as it came.
const FEED_ERRORS = new Set([
msg('OpenSky OAuth client missing/invalid'),
msg('OpenSky OAuth rejected credentials'),
msg('OpenSky username/password rejected'),
msg('OpenSky auth missing'),
msg('OpenSky auth required'),
msg('OpenSky auth invalid'),
msg('OpenSky auth failed'),
]);
const FEED_ERROR_PATTERNS = [
// Street Level provider errors (providers/{kartaview,panoramax}/source.js),
// ahead of the generic "<source> HTTP <status>".
[
/^KartaView answered HTTP (\d+)$/,
([, status]) => t('KartaView answered HTTP {status}', { status }),
],
[
/^Panoramax answered HTTP (\d+)$/,
([, status]) => t('Panoramax answered HTTP {status}', { status }),
],
[
/^KartaView refused the request \(code (-?\d+)\)$/,
([, code]) => t('KartaView refused the request (code {code})', { code }),
],
// Weather imagery (weather/source.js, infraredImage.js) and vector tile
// (sources/vectorTiles.js) failures carry their own wording; the generic
// pattern below would take "Weather image" for a source name.
[
/^Weather (image )?HTTP (\d+)$/,
([, image, status]) =>
image
? t('Weather image HTTP {status}', { status })
: t('Weather HTTP {status}', { status }),
],
[
/^Vector tiles unavailable \(HTTP (\d+)\)$/,
([, status]) => t('Vector tiles unavailable (HTTP {status})', { status }),
],
// Layer statuses with a number or a feed name in them: GNSS
// (gnss/source.js), CelesTrak groups (satellites/ingestion.js) and transit
// feeds (transit/ingestion.js).
[
/^GNSS feed unavailable \(HTTP (\d{3})\)$/,
([, status]) => t('GNSS feed unavailable (HTTP {status})', { status }),
],
[
/^(\d+) CelesTrak groups? unavailable$/,
([, groups]) =>
tn(
Number(groups),
'{count} CelesTrak group unavailable',
'{count} CelesTrak groups unavailable',
{ count: formatNumber(Number(groups)) },
),
],
[
/^(.+) feed unavailable$/,
([, feed]) => t('{feed} feed unavailable', { feed: translateKnown(feed) }),
],
[
/^Source snapshot (\d+) min old$/,
([, minutes]) => t('Source snapshot {minutes} min old', { minutes }),
],
[
/^(.+) rate limited \(anonymous\)$/,
([, source]) =>
t('{source} rate limited (anonymous)', { source: sourceLabel(source) }),
],
[
/^(.+) rate limited$/,
([, source]) => t('{source} rate limited', { source: sourceLabel(source) }),
],
[
/^(.+) HTTP (\d{3})$/,
([, source, status]) =>
t('{source} HTTP {status}', { source: sourceLabel(source), status }),
],
[
/^(.+) network error$/,
([, source]) =>
t('{source} network error', { source: sourceLabel(source) }),
],
[
/^Malformed (.+) aircraft rows$/,
([, source]) =>
t('Malformed {source} aircraft rows', { source: sourceLabel(source) }),
],
[
/^Malformed (.+) response$/,
([, source]) =>
t('Malformed {source} response', { source: sourceLabel(source) }),
],
];
/** One error text in the UI language, or null when nothing matches. */
function feedErrorText(text) {
if (FEED_ERRORS.has(text)) return t(text);
const known = translateKnown(text);
if (known !== text) return known;
for (const [pattern, format] of FEED_ERROR_PATTERNS) {
const match = pattern.exec(text);
if (match) return format(match);
}
return null;
}
/**
* A layer's error text in the UI language (see FEED_ERRORS). An error made of
* several statuses joined by " · " (Local ADS-B) translates piece by piece,
* and so does a lone status piece ("feed 1090 unreachable").
*/
export function translateFeedError(text) {
if (typeof text !== 'string') return text;
const whole = feedErrorText(text);
if (whole !== null) return whole;
// feedErrorText has already looked the text up in the catalog.
if (!text.includes(STATUS_SEPARATOR)) return statusPatternText(text) ?? text;
return text
.split(STATUS_SEPARATOR)
.map((piece) => feedErrorText(piece) ?? statusPatternText(piece) ?? piece)
.join(STATUS_SEPARATOR);
}
// A decoder feed with no band is named by the server's "entry N" (its place
// in LOCAL_RECEIVER_FEEDS, server/providers/local-receivers.js).
const FEED_ENTRY = /^entry (\d+)$/;
function shownFeedName(name) {
const entry = FEED_ENTRY.exec(name);
return entry ? t('entry {n}', { n: entry[1] }) : name;
}
// Feed states named in a Local ADS-B feed problem ("feeds 1090, 978 stale").
const FEED_PROBLEM_FORMS = Object.freeze({
stale: (count, names) =>
tn(count, 'feed {names} stale', 'feeds {names} stale', { names }),
unreachable: (count, names) =>
tn(count, 'feed {names} unreachable', 'feeds {names} unreachable', {
names,
}),
invalid: (count, names) =>
tn(count, 'feed {names} invalid', 'feeds {names} invalid', { names }),
});
// Working texts with a number or a name in them arrive as English data, one
// piece of a " · " joined line each: transit (transit/queries.js) and Local
// ADS-B (localAdsb/status.js). The Layers panel and the voice card's analyst
// notes show them through here. Keep these in step with the producers.
const STATUS_PIECE_PATTERNS = [
[/^Loading (.+)$/, ([, feeds]) => t('Loading {feeds}', { feeds })],
[
/^(\d+) heard$/,
([, heard]) =>
tn(Number(heard), '{count} heard', '{count} heard', {
count: formatNumber(Number(heard)),
}),
],
[
/^(USB )?(\d+(?:\.\d+)?) msg\/s$/,
([, usb, rate]) =>
usb
? t('USB {rate} msg/s', { rate: formatNumber(Number(rate)) })
: t('{rate} msg/s', { rate: formatNumber(Number(rate)) }),
],
[
/^(\d+) feeds? live$/,
([, live]) =>
tn(Number(live), '{count} feed live', '{count} feeds live', {
count: formatNumber(Number(live)),
}),
],
[
/^feeds? (.+) (stale|unreachable|invalid)$/,
([, names, status]) => {
const list = names.split(', ');
return FEED_PROBLEM_FORMS[status](
list.length,
list.map(shownFeedName).join(', '),
);
},
],
// A quiet transit feed in the coverage line, "TTC 0 (95s old)"
// (transit/queries.js); the feed name and the numbers stay as they came.
[
/^(.+) (\d+) \((\d+)s old\)$/,
([, feed, count, seconds]) =>
t('{feed} {count} ({seconds}s old)', { feed, count, seconds }),
],
];
/** A status piece by its shape in the UI language, or null. */
function statusPatternText(piece) {
for (const [pattern, format] of STATUS_PIECE_PATTERNS) {
const match = pattern.exec(piece);
if (match) return format(match);
}
return null;
}
/** One piece of a status line in the UI language, or as it came. */
export function translateStatusPiece(piece) {
const known = translateKnown(piece);
if (known !== piece) return known;
return statusPatternText(piece) ?? piece;
}
/** A status line of " · " joined pieces in the UI language, piece by piece. */
export function translateStatusLine(text) {
return text
.split(STATUS_SEPARATOR)
.map(translateStatusPiece)
.join(STATUS_SEPARATOR);
}
+77
View File
@@ -0,0 +1,77 @@
// Layer status texts arrive as English data and show in the UI language by
// their shape; English shows exactly as it came.
import assert from 'node:assert/strict';
import test from 'node:test';
import { setLocale, t } from '../i18n/index.js';
import { translateFeedError, translateStatusLine } from './statusText.js';
function inLocale(locale, run) {
setLocale(locale);
try {
return run();
} finally {
setLocale('en');
}
}
const STATUS_LINES = [
'feed 1090 unreachable',
'feeds 1090, entry 2 invalid',
'3 heard · USB 5.8 msg/s · feed 1090 stale',
'2 feeds live · 14 heard',
'TTC 0 (95s old)',
'TTC 0 · GO Transit 4 (95s old)',
'Loading TTC, MiWay',
];
test('en: status lines and errors show as they came', () => {
setLocale('en');
for (const text of [
...STATUS_LINES,
'ECCC HTTP 503',
'GNSS feed unavailable (HTTP 503)',
'Upstream response too large',
]) {
assert.equal(translateFeedError(text), text);
assert.equal(translateStatusLine(text), text);
}
});
for (const locale of ['es', 'ru']) {
test(`${locale}: a lone feed problem translates like a joined one`, () => {
inLocale(locale, () => {
const lone = translateFeedError('feed 1090 unreachable');
assert.doesNotMatch(lone, /unreachable/, lone);
assert.equal(
translateFeedError('feed 1090 unreachable · 2 heard').split(' · ')[0],
lone,
);
});
});
test(`${locale}: a feed with no band is named in the UI language`, () => {
inLocale(locale, () => {
const shown = translateFeedError('feeds 1090, entry 2 invalid');
assert.doesNotMatch(shown, /\b(entry|invalid)\b/, shown);
assert.ok(shown.includes(t('entry {n}', { n: 2 })), shown);
assert.ok(shown.includes('1090'), shown);
});
});
test(`${locale}: a quiet transit feed keeps its name and numbers`, () => {
inLocale(locale, () => {
const shown = translateStatusLine('TTC 0 · GO Transit 4 (95s old)');
assert.doesNotMatch(shown, /\bold\b/, shown);
assert.ok(shown.startsWith('TTC 0 · GO Transit 4 '), shown);
assert.ok(shown.includes('95'), shown);
});
});
test(`${locale}: a known error shows its catalog entry`, () => {
inLocale(locale, () => {
const text = 'Upstream response too large';
assert.notEqual(t(text), text, 'the catalog has the entry');
assert.equal(translateFeedError(text), t(text));
});
});
}
+13 -7
View File
@@ -5,6 +5,7 @@ import {
SINCE_STOPS,
} from './streetLevelPresentation.js';
import { t, translateKnown } from '../i18n/index.js';
const RENDER_MODE_KEY = 'gev:street-level:render-mode';
const FOCUSABLE =
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
@@ -128,7 +129,7 @@ export class StreetLevelControls {
const sinceDays = () =>
SINCE_STOPS[Number(el.sinceRange?.value) || 0]?.days ?? 0;
this.listen(el.sinceRange, 'input', () => {
const label = SINCE_STOPS[Number(el.sinceRange.value) || 0].label;
const label = t(SINCE_STOPS[Number(el.sinceRange.value) || 0].label);
if (el.sinceLabel) el.sinceLabel.textContent = label;
el.sinceRange.setAttribute('aria-valuetext', label);
});
@@ -229,7 +230,8 @@ export class StreetLevelControls {
await this.actions.setEnabled?.(true);
} catch (error) {
this.actions.showToast?.(
error?.message || 'Street Level could not start',
translateKnown(error?.message || '') ||
t('Street Level could not start'),
);
return false;
}
@@ -241,7 +243,9 @@ export class StreetLevelControls {
try {
await this.actions.setEnabled?.(!enabled);
} catch (error) {
this.actions.showToast?.(error?.message || 'Street Level toggle failed');
this.actions.showToast?.(
translateKnown(error?.message || '') || t('Street Level toggle failed'),
);
}
}
@@ -273,7 +277,9 @@ export class StreetLevelControls {
try {
await this.actions.setEnabled?.(false);
} catch (error) {
this.actions.showToast?.(error?.message || 'Street Level toggle failed');
this.actions.showToast?.(
translateKnown(error?.message || '') || t('Street Level toggle failed'),
);
}
}
@@ -325,7 +331,7 @@ export class StreetLevelControls {
wrap.classList.add('sl-viewer-wrap-expanded');
wrap.setAttribute('role', 'dialog');
wrap.setAttribute('aria-modal', 'true');
wrap.setAttribute('aria-label', 'Street-level image');
wrap.setAttribute('aria-label', t('Street-level image'));
wrap.tabIndex = -1;
wrap.focus({ preventScroll: true });
this._watchModal(true);
@@ -357,9 +363,9 @@ export class StreetLevelControls {
const icon = button.querySelector('.sl-btn-icon');
const text = button.querySelector('.sl-btn-text');
if (icon) icon.textContent = on ? '⤡' : '⤢';
if (text) text.textContent = on ? 'SHRINK' : 'EXPAND';
if (text) text.textContent = on ? t('SHRINK') : t('EXPAND');
button.setAttribute('aria-pressed', String(on));
button.setAttribute('aria-label', on ? 'Shrink' : 'Expand');
button.setAttribute('aria-label', on ? t('Shrink') : t('Expand'));
}
if (!on && dock) this.actions.dockPanel?.();
this._requestResize();
+31
View File
@@ -0,0 +1,31 @@
// The Street Level meta-line test must not depend on the system locale: the
// code formats the count in the app language (formatNumber), so a contributor
// whose terminal sets LANG=de_DE or es_ES must see the same green run as CI's
// C.UTF-8. Runs the presentation test file under three process locales.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import path from 'node:path';
const here = path.dirname(fileURLToPath(import.meta.url));
const target = path.join(here, 'streetLevelPresentation.test.mjs');
for (const locale of ['de_DE.UTF-8', 'es_ES.UTF-8', 'C.UTF-8']) {
test(`streetLevelPresentation tests pass under LC_ALL=${locale}`, () => {
// Drop the node:test child markers so the nested run is a real top-level run.
const env = { ...process.env, LC_ALL: locale, LANG: locale };
delete env.NODE_TEST_CONTEXT;
delete env.NODE_TEST_WORKER_ID;
const result = spawnSync(process.execPath, ['--test', target], {
encoding: 'utf8',
env,
});
const failed = result.stdout.match(/^ℹ fail (\d+)/m)?.[1] ?? '?';
assert.equal(
result.status,
0,
`exit status ${result.status}, ${failed} failing test(s) under ${locale}\n${result.stdout.slice(-700)}`,
);
});
}
+146 -50
View File
@@ -1,7 +1,5 @@
import {
keySetupRequirement,
keySetupWithAlternatives,
} from '../keySetupCore.mjs';
import { formatNumber, msg, t, tn, translateKnown } from '../i18n/index.js';
import { keyGuidanceText, keyRequirementText } from './keyGuidance.js';
// Street Level UI state to panel strings and flags. Pure, so it is testable.
@@ -9,15 +7,15 @@ const DAY_MS = 86_400_000;
/** SINCE slider stops, in relative days so a share link keeps its meaning. */
export const SINCE_STOPS = Object.freeze([
Object.freeze({ days: 0, label: 'ANY DATE' }),
Object.freeze({ days: 3652, label: 'LAST 10 YEARS' }),
Object.freeze({ days: 1826, label: 'LAST 5 YEARS' }),
Object.freeze({ days: 1095, label: 'LAST 3 YEARS' }),
Object.freeze({ days: 730, label: 'LAST 2 YEARS' }),
Object.freeze({ days: 365, label: 'LAST YEAR' }),
Object.freeze({ days: 182, label: 'LAST 6 MONTHS' }),
Object.freeze({ days: 91, label: 'LAST 3 MONTHS' }),
Object.freeze({ days: 30, label: 'LAST MONTH' }),
Object.freeze({ days: 0, label: msg('ANY DATE') }),
Object.freeze({ days: 3652, label: msg('LAST 10 YEARS') }),
Object.freeze({ days: 1826, label: msg('LAST 5 YEARS') }),
Object.freeze({ days: 1095, label: msg('LAST 3 YEARS') }),
Object.freeze({ days: 730, label: msg('LAST 2 YEARS') }),
Object.freeze({ days: 365, label: msg('LAST YEAR') }),
Object.freeze({ days: 182, label: msg('LAST 6 MONTHS') }),
Object.freeze({ days: 91, label: msg('LAST 3 MONTHS') }),
Object.freeze({ days: 30, label: msg('LAST MONTH') }),
]);
/** The exact stop for a day count, else the nearest one. */
@@ -37,13 +35,19 @@ export function sinceStopIndex(days) {
function presentSince(days, now) {
const value = Number(days) || 0;
const index = sinceStopIndex(value);
if (value <= 0) return { index, days: 0, label: 'ANY DATE' };
if (value <= 0) return { index, days: 0, label: t('ANY DATE') };
const stop = SINCE_STOPS[index];
const window = stop.days === value ? stop.label : `LAST ${value} DAYS`;
const window =
stop.days === value
? t(stop.label)
: tn(value, 'LAST {count} DAY', 'LAST {count} DAYS');
return {
index,
days: value,
label: `${window} · SINCE ${formatDate(now - value * DAY_MS)}`,
label: t('{window} · SINCE {date}', {
window,
date: formatDate(now - value * DAY_MS),
}),
};
}
@@ -64,9 +68,9 @@ function formatDate(ms, precision = 'day') {
function keyGuidance(state) {
const needs = (state.providers || [])
.filter((p) => p.on && p.keyRequired && p.requiresKeyId)
.map((p) => keySetupRequirement(p.requiresKeyId))
.map((p) => keyRequirementText(p.requiresKeyId))
.filter(Boolean);
return keySetupWithAlternatives(
return keyGuidanceText(
[...new Set(needs)].join('; '),
state.keyAlternatives || [],
);
@@ -74,21 +78,56 @@ function keyGuidance(state) {
function presentStatus(state) {
const pressed = state.enabled === true;
const title = pressed ? 'Turn Street Level off' : 'Turn Street Level on';
const title = pressed
? t('Turn Street Level off')
: t('Turn Street Level on');
if (state.keyRejected)
return { text: 'KEY REJECTED', tone: 'warn', pressed, title };
return { text: t('KEY REJECTED'), tone: 'warn', pressed, title };
if (state.keyRequired)
return {
text: 'KEY REQUIRED',
text: t('KEY REQUIRED'),
tone: 'warn',
pressed,
title: keyGuidance(state) || title,
};
if (state.coverage.loading)
return { text: 'LOADING', tone: 'busy', pressed, title };
return { text: t('LOADING'), tone: 'busy', pressed, title };
return pressed
? { text: 'ON', tone: 'on', pressed, title }
: { text: 'OFF', tone: '', pressed, title };
? { text: t('ON'), tone: 'on', pressed, title }
: { text: t('OFF'), tone: '', pressed, title };
}
// Provider refusals with a number stay English data (kartaview and panoramax
// source.js); they go back onto a placeholder here.
const ERROR_PATTERNS = [
[
/^KartaView answered HTTP (\d+)$/,
([, status]) => t('KartaView answered HTTP {status}', { status }),
],
[
/^Panoramax answered HTTP (\d+)$/,
([, status]) => t('Panoramax answered HTTP {status}', { status }),
],
[
/^KartaView refused the request \(code (-?\d+)\)$/,
([, code]) => t('KartaView refused the request (code {code})', { code }),
],
];
/**
* A provider or viewer error in the UI language. Exported for tests.
* @param {string} text English error text from a provider.
* @returns {string}
*/
export function translateStreetLevelError(text) {
if (typeof text !== 'string' || !text) return text;
const known = translateKnown(text);
if (known !== text) return known;
for (const [pattern, format] of ERROR_PATTERNS) {
const match = pattern.exec(text);
if (match) return format(match);
}
return text;
}
/** A chip is lit only while the layer and its provider are both on. */
@@ -97,15 +136,18 @@ function presentProviders(state) {
return (state.providers || []).map((provider) => {
const keyRequired = provider.keyRequired === true;
const on = enabled && provider.on === true;
let title = `${provider.name} imagery ${on ? 'on' : 'off'}`;
let title = on
? t('{name} imagery on', { name: provider.name })
: t('{name} imagery off', { name: provider.name });
if (provider.keyRejected && provider.error)
title = `${provider.name}: ${provider.error}`;
title = `${provider.name}: ${translateStreetLevelError(provider.error)}`;
else if (keyRequired && provider.requiresKeyId)
title = `${provider.name}: ${keySetupRequirement(provider.requiresKeyId)}`;
else if (provider.error) title = `${provider.name}: ${provider.error}`;
title = `${provider.name}: ${keyRequirementText(provider.requiresKeyId)}`;
else if (provider.error)
title = `${provider.name}: ${translateStreetLevelError(provider.error)}`;
return {
id: provider.id,
label: provider.label,
label: translateKnown(provider.label),
color: provider.color || null,
title,
active: on,
@@ -132,7 +174,8 @@ function presentViewer(state) {
right.push(formatDate(street.capturedAt, street.capturedAtPrecision));
const left = [];
if (street.title) left.push(street.title);
if (street.creator) left.push(`Image by ${street.creator}`);
if (street.creator)
left.push(t('Image by {creator}', { creator: street.creator }));
if (street.license) left.push(street.license);
return {
open: street.open === true,
@@ -142,14 +185,20 @@ function presentViewer(state) {
captionLeft: left.join(' · '),
captionRight: right.join(' · '),
link: street.externalUrl || null,
linkLabel: street.providerLabel ? `${street.providerLabel} ↗` : '',
linkLabel: street.providerLabel
? `${translateKnown(street.providerLabel)} ↗`
: '',
follow: {
pressed: street.follow === true,
disabled: street.open !== true || street.followAvailable !== true,
title:
street.followAvailable === true
? 'Camera follows view: move the globe camera wherever the street-level view looks'
: 'Camera follow needs the Google 3D map: choose Google 3D under MAP SOURCE',
? t(
'Camera follows view: move the globe camera wherever the street-level view looks',
)
: t(
'Camera follow needs the Google 3D map: choose Google 3D under MAP SOURCE',
),
},
};
}
@@ -168,40 +217,85 @@ function presentNearest(state) {
disabled: true,
providerIds: [],
};
const names = targets.map((provider) => provider.name).join(' or ');
const names = targets
.map((provider) => translateKnown(provider.name))
.join(t(' or '));
const labels = targets
.map((provider) => translateKnown(provider.label))
.join(' / ');
const ready = state.groundClickReady === true;
return {
visible: true,
label: `OPEN ${targets.map((provider) => provider.label).join(' / ')}`,
label: t('OPEN {labels}', { labels }),
title: ready
? `Open ${names} at the centre of the view`
: `Zoom in to a street to open ${names} here`,
? t('Open {names} at the centre of the view', { names })
: t('Zoom in to a street to open {names} here', { names }),
disabled: !ready || state.street.loading === true,
providerIds: targets.map((provider) => provider.id),
};
}
/** Provider hints are English data joined by " · "; translate each part. */
function translateHint(hint) {
return hint ? hint.split(' · ').map(translateKnown).join(' · ') : '';
}
function presentMeta(state) {
if (state.providers.length === 0) return state.coverage.hint || '';
if (!state.enabled) return 'Switch a provider on to draw its coverage.';
if (state.providers.length === 0) return translateHint(state.coverage.hint);
if (!state.enabled) return t('Switch a provider on to draw its coverage.');
// Key-gated with another source ready: say how to use it.
if (state.keyRequired && state.keyAlternatives?.length)
return keyGuidance(state);
if (state.sequence.selectedId)
return state.sequence.loading
? 'Loading this sequence…'
: `${state.sequence.images.toLocaleString()} images in this sequence · Esc clears`;
const hint = state.coverage.hint || '';
if (state.coverage.count > 0)
return [
`${state.coverage.count.toLocaleString()} ${state.coverage.label?.noun || 'sequences'} in view · ${state.coverage.label?.action || 'click a line for its photos'}`,
hint,
]
.filter(Boolean)
.join(' · ');
? t('Loading this sequence…')
: tn(
state.sequence.images,
'{n} image in this sequence · Esc clears',
'{n} images in this sequence · Esc clears',
{ n: formatNumber(state.sequence.images) },
);
const hint = translateHint(state.coverage.hint);
if (state.coverage.count > 0) {
const countLine = coverageCountLine(state.coverage);
return [countLine, hint].filter(Boolean).join(' · ');
}
return hint;
}
/**
* The coverage count line. Providers carry their wording as data
* (coverageLabel noun/action); each combination needs its own literal tn
* pair so every language gets real plural forms — the noun cannot inflect
* through a placeholder in Russian. New wording: add a pair here and to the
* catalogs (es.json/ru.json).
* @param {{count: number, label?: {noun?: string, action?: string}|null}} coverage
*/
function coverageCountLine(coverage) {
const count = coverage.count;
const values = { n: formatNumber(count) };
if (coverage.label?.action === 'click a line for its nearest photo')
return coverage.label.noun === 'streets'
? tn(
count,
'{n} street in view · click a line for its nearest photo',
'{n} streets in view · click a line for its nearest photo',
values,
)
: tn(
count,
'{n} sequence in view · click a line for its nearest photo',
'{n} sequences in view · click a line for its nearest photo',
values,
);
return tn(
count,
'{n} sequence in view · click a line for its photos',
'{n} sequences in view · click a line for its photos',
values,
);
}
/** @param {{now?: number}} [options] Clock for the SINCE readout. */
export function presentStreetLevelPanel(state, { now = Date.now() } = {}) {
const enabled = state.enabled === true;
@@ -218,7 +312,9 @@ export function presentStreetLevelPanel(state, { now = Date.now() } = {}) {
providers: presentProviders(state),
error: keyMissing
? null
: state.street.error || state.coverage.error || null,
: translateStreetLevelError(
state.street.error || state.coverage.error || '',
) || null,
filter: { pano: filter.pano, sinceDays: Number(filter.sinceDays) || 0 },
filterControls: {
pano: state.filterControls?.pano !== false,
+3 -1
View File
@@ -446,9 +446,11 @@ test('the meta line keeps the sequence count and adds the other providers’ hin
},
}),
);
// The count follows the app locale (formatNumber under the default 'en'),
// not the system locale of the Node process running the tests.
assert.equal(
view.meta,
`${(2514).toLocaleString()} sequences in view · click a line for its photos · click a street to open Street View`,
'2,514 sequences in view · click a line for its photos · click a street to open Street View',
);
});
+17 -4
View File
@@ -1,3 +1,13 @@
import { formatNumber, t } from '../i18n/index.js';
/** A slider value in the UI language: 0.85 in English, 0,85 in Spanish. */
const formatParam = (value, digits) =>
formatNumber(value, {
minimumFractionDigits: digits,
maximumFractionDigits: digits,
useGrouping: false,
});
/**
* Own generated parameter rows and their input listeners.
* The caller supplies uniform metadata/value access and rendering/persistence
@@ -30,11 +40,11 @@ export function createStyleParameters({
row.className = 'param-slider-row';
const label = documentRef.createElement('span');
label.className = 'param-label';
label.textContent = metadata.label;
label.textContent = t(metadata.label);
const slider = documentRef.createElement('input');
slider.type = 'range';
slider.className = 'param-slider';
slider.setAttribute('aria-label', metadata.label);
slider.setAttribute('aria-label', t(metadata.label));
slider.min = metadata.min;
slider.max = metadata.max;
slider.step = metadata.max <= 1 ? '0.01' : '0.1';
@@ -42,11 +52,14 @@ export function createStyleParameters({
const digits = metadata.max <= 1 ? 2 : 1;
const valueDisplay = documentRef.createElement('span');
valueDisplay.className = 'param-value';
valueDisplay.textContent = parseFloat(slider.value).toFixed(digits);
valueDisplay.textContent = formatParam(
parseFloat(slider.value),
digits,
);
const onInput = () => {
const value = parseFloat(slider.value);
writeValue(name, value);
valueDisplay.textContent = value.toFixed(digits);
valueDisplay.textContent = formatParam(value, digits);
onChange();
};
slider.addEventListener('input', onInput);
+126 -17
View File
@@ -12,7 +12,9 @@
background: var(--glass-bg);
border: 1px solid var(--glass-border);
border-radius: 6px;
box-shadow: 0 18px 52px rgba(0, 0, 0, 0.48), inset 0 0 28px rgba(14, 78, 92, 0.12);
box-shadow:
0 18px 52px rgba(0, 0, 0, 0.48),
inset 0 0 28px rgba(14, 78, 92, 0.12);
backdrop-filter: blur(24px) saturate(1.4);
font-family: var(--font-mono);
letter-spacing: 0;
@@ -153,33 +155,69 @@
}
@keyframes bhote-embedded-anchor-enter {
from { opacity: 0; transform: scale(0.2); }
to { opacity: 1; transform: scale(1); }
from {
opacity: 0;
transform: scale(0.2);
}
to {
opacity: 1;
transform: scale(1);
}
}
@keyframes bhote-embedded-leader-enter {
from { opacity: 0; stroke-dashoffset: 1; }
to { opacity: 1; stroke-dashoffset: 0; }
from {
opacity: 0;
stroke-dashoffset: 1;
}
to {
opacity: 1;
stroke-dashoffset: 0;
}
}
@keyframes bhote-embedded-body-enter {
from { opacity: 0; transform: translateY(8px) scale(0.98); }
to { opacity: 1; transform: translateY(0) scale(1); }
from {
opacity: 0;
transform: translateY(8px) scale(0.98);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes bhote-embedded-body-exit {
from { opacity: 1; transform: translateY(0) scale(1); }
to { opacity: 0; transform: translateY(6px) scale(0.985); }
from {
opacity: 1;
transform: translateY(0) scale(1);
}
to {
opacity: 0;
transform: translateY(6px) scale(0.985);
}
}
@keyframes bhote-embedded-leader-exit {
from { opacity: 1; stroke-dashoffset: 0; }
to { opacity: 0; stroke-dashoffset: 1; }
from {
opacity: 1;
stroke-dashoffset: 0;
}
to {
opacity: 0;
stroke-dashoffset: 1;
}
}
@keyframes bhote-embedded-anchor-exit {
from { opacity: 1; transform: scale(1); }
to { opacity: 0; transform: scale(0.2); }
from {
opacity: 1;
transform: scale(1);
}
to {
opacity: 0;
transform: scale(0.2);
}
}
@media (prefers-reduced-motion: reduce) {
@@ -267,6 +305,27 @@
text-decoration: none;
}
/* The title takes the full card width and up to two lines, so a translated
title keeps its place name; the link sits beside the status below it. */
html:not([lang='en']) .bhote-embedded-callout footer strong {
grid-column: 1 / -1;
display: -webkit-box;
text-overflow: clip;
white-space: normal;
overflow-wrap: anywhere;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
html:not([lang='en']) .bhote-embedded-callout footer small {
align-self: center;
}
html:not([lang='en']) .bhote-embedded-callout footer a {
grid-row: 2;
white-space: nowrap;
}
.bhote-embedded-callout footer a:hover,
.bhote-embedded-callout footer a:focus-visible {
text-decoration: underline;
@@ -354,6 +413,12 @@
color: rgba(222, 240, 245, 0.88);
}
/* A longer translated heading wraps; the readout beside it stays one piece. */
html:not([lang='en']) .bhote-event-section-head span:last-child {
flex: none;
white-space: nowrap;
}
.bhote-event-range {
width: 100%;
height: 4px;
@@ -372,6 +437,12 @@
color: rgba(174, 208, 216, 0.62);
}
/* Keep a translated date ("16 OCT 2021") together so the year never wraps
alone. */
html:not([lang='en']) .bhote-event-date {
white-space: nowrap;
}
.bhote-event-story-nav {
display: grid;
grid-template-columns: 34px minmax(0, 1fr) 34px;
@@ -388,7 +459,11 @@
border-radius: 4px;
color: var(--accent);
background: rgba(7, 25, 35, 0.9);
font: 22px/1 'JetBrains Mono', 'SFMono-Regular', Consolas, monospace;
font:
22px/1 'JetBrains Mono',
'SFMono-Regular',
Consolas,
monospace;
cursor: pointer;
}
@@ -428,6 +503,31 @@
white-space: nowrap;
}
html:not([lang='en']) .bhote-event-story-current {
padding: 5px 10px;
}
/* Up to two lines: a translated beat title keeps its subject. Two title lines
and one meta line still fit the 54px arrow buttons, so the controls stay put. */
html:not([lang='en']) .bhote-event-story-current strong {
display: -webkit-box;
text-overflow: clip;
white-space: normal;
overflow-wrap: break-word;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
/* Phase / time / sources run long in translation: allow a second line. */
html:not([lang='en']) .bhote-event-story-current small {
display: -webkit-box;
margin-top: 3px;
text-overflow: clip;
white-space: normal;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.bhote-event-actions {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
@@ -458,6 +558,15 @@
white-space: nowrap;
}
/* A longer translation wraps to a second line instead of being cut. */
html:not([lang='en']) .bhote-event-actions button {
padding: 3px 5px;
line-height: 1.2;
text-overflow: clip;
white-space: normal;
overflow-wrap: break-word;
}
.bhote-event-actions button:disabled,
.bhote-event-story-nav > button:disabled {
opacity: 0.3;
@@ -469,7 +578,7 @@
border-color: rgba(88, 196, 218, 0.36);
}
.bhote-event-actions button[data-action="cinematic"][aria-pressed="true"] {
.bhote-event-actions button[data-action='cinematic'][aria-pressed='true'] {
color: #071017;
border-color: var(--accent);
background: var(--accent);
@@ -593,7 +702,6 @@
max-height: 48vh;
overflow-y: auto;
}
}
* {
@@ -602,7 +710,8 @@
box-sizing: border-box;
}
html, body {
html,
body {
width: 100%;
height: 100%;
overflow: hidden;
+52 -4
View File
@@ -70,6 +70,18 @@
margin-bottom: 0;
}
/* Spanish labels overflow the nowrap .scene-btn row of the fixed-width
panel; wrap the row under es only, English stays as is. */
html[lang='es'] .cctv-controls {
flex-wrap: wrap;
}
/* A label longer than its button wraps inside it, not past the panel edge. */
html:not([lang='en']) .cctv-controls .scene-btn {
white-space: normal;
overflow-wrap: break-word;
}
.cctv-calibration-block {
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 9px;
@@ -157,6 +169,9 @@
padding: 6px 8px;
font-family: var(--font-sans);
font-size: 12px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
outline: none;
}
@@ -260,12 +275,12 @@
animation: cctv-frame-shimmer 1.4s linear infinite;
}
/* "Acquiring" caption only when no previous frame is showing underneath */
.cctv-frame-wrap.loading:not(.has-frame)::before {
content: 'ACQUIRING FRAME';
/* "Acquiring" caption only when no previous frame is showing underneath.
The text lives in the markup (data-i18n) so it is translated. */
.cctv-frame-caption {
display: none;
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-family: var(--font-mono);
@@ -275,6 +290,10 @@
pointer-events: none;
}
.cctv-frame-wrap.loading:not(.has-frame) .cctv-frame-caption {
display: flex;
}
@keyframes cctv-frame-shimmer {
to {
transform: translateX(100%);
@@ -365,3 +384,32 @@
color: #a8f7ff;
text-shadow: 0 0 10px rgba(0, 212, 255, 0.18);
}
/* Tighter calibration chips keep the seven values to two rows, so
SAVE / RESET stay in view. */
html:not([lang='en']) .cctv-cal-readout {
gap: 4px;
}
html:not([lang='en']) .cctv-cal-value {
flex: 1 1 auto;
white-space: nowrap;
letter-spacing: 0.3px;
padding: 4px;
}
html:not([lang='en']) #cctv-camera-select {
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.04em;
}
/* The camera name gets its own row; PREV / NEXT share the row below it. */
html:not([lang='en']) .cctv-controls:has(#cctv-camera-select) {
flex-wrap: wrap;
}
html:not([lang='en']) #cctv-camera-select {
flex: 1 1 100%;
order: -1;
}
+36
View File
@@ -575,6 +575,11 @@ body.cockpit-mode:has(#left-panel-stack > .panel-collapsible:not(.collapsed))
text-align: center;
vertical-align: -0.08em;
}
/* Grouping and decimal separators (a space or comma in Spanish and Russian)
get a narrow cell; English keeps the full 0.56em cell. */
html:not([lang='en']) .cockpit-roll-token.is-separator {
min-width: 0.3em;
}
.cockpit-roll-token.is-rolling {
color: color-mix(in srgb, currentColor 92%, var(--cockpit-accent));
text-shadow: 0 0 10px
@@ -1506,6 +1511,14 @@ body.cockpit-mode #view-switcher button {
display: grid;
gap: 2px;
}
html:not([lang='en']) .cockpit-signal-header > div {
min-width: 0;
}
html:not([lang='en']) .cockpit-signal-kicker {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.cockpit-signal-header strong {
color: #fff;
font-size: 8px;
@@ -1532,6 +1545,11 @@ body.cockpit-mode #view-switcher button {
align-items: center;
gap: 3px;
}
/* A longer translated state label widens the cycle button; the other
controls keep their size instead of being squeezed. */
html:not([lang='en']) .cockpit-brief-actions > * {
flex-shrink: 0;
}
.cockpit-brief-actions button {
display: grid;
width: 23px;
@@ -1559,6 +1577,12 @@ body.cockpit-mode #view-switcher button {
font: 700 6px/1 var(--font-mono);
letter-spacing: 0.05em;
}
html:not([lang='en']) #cockpit-brief-auto {
width: auto;
min-width: 50px;
padding: 0 4px;
white-space: nowrap;
}
#cockpit-brief-auto[aria-pressed='true'] {
border-color: var(--cockpit-accent);
background: color-mix(
@@ -1871,6 +1895,18 @@ body.cockpit-mode #view-switcher button {
display: flex;
gap: 3px;
}
/* Translated tab names are longer: the tabs keep their words whole and the
footer text beside them gives way. */
html:not([lang='en']) .cockpit-signal-footer > span {
flex: 1 1 auto;
min-width: 0;
}
html:not([lang='en']) .cockpit-brief-tabs {
flex: 0 0 auto;
}
html:not([lang='en']) .cockpit-brief-tabs button {
white-space: nowrap;
}
.cockpit-brief-tabs button {
padding: 2px 4px;
border: 0;
+5
View File
@@ -647,6 +647,11 @@
white-space: nowrap;
}
/* The ru/es push-to-talk hint is longer than the tray: wrap it instead of clipping. */
html:not([lang='en']) #gev-voice-control .gev-voice-help-detail {
white-space: normal;
}
#gev-voice-control:not([data-status='error']):hover .gev-voice-help-tray,
#gev-voice-control:not([data-status='error'])
#gev-voice-button:focus-visible
+6
View File
@@ -392,6 +392,12 @@
transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
/* The side wings sit at z-index 100 (controls.css, location.css), so their
open trays would cover the alert; lift the voice column while it shows. */
#command-dock > #gev-voice-control[data-status='error']:not(.error-dismissed) {
z-index: 101;
}
#command-dock
#gev-voice-control[data-status='error']:not(.error-dismissed)
.gev-voice-error-tray {
+51
View File
@@ -221,6 +221,18 @@
white-space: nowrap;
}
/* A longer translated title in a collapsed rail launcher tightens its
tracking rather than pushing the disclosure button out of the card. */
html:not([lang='en'])
#right-context-rail
.panel-collapsible.collapsed
.panel-title {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
letter-spacing: 1.5px;
}
.panel-divider {
flex: 1;
height: 1px;
@@ -244,6 +256,11 @@
color var(--transition-fast);
}
/* A long translated title must not squeeze the toggle into a sliver. */
html:not([lang='en']) .panel-collapse-btn {
flex-shrink: 0;
}
.panel-collapse-btn:hover {
border-color: var(--glass-border-hover);
color: var(--text-primary);
@@ -770,6 +787,14 @@
white-space: nowrap;
}
/* One label column across the Display rows: --pp-label-chars is the longest
label of the active language (set in applicationShell.js); the labels are
monospace. 0.125em is the 1px letter-spacing at 8px. */
html:not([lang='en']) .pp-slider-row > .pp-slider-mini-label {
flex: 0 0 auto;
min-width: calc(var(--pp-label-chars, 0) * (1ch + 0.125em));
}
.detection-slider-row {
min-width: 170px;
padding: 5px 8px 6px;
@@ -966,6 +991,11 @@
#draw-mode-row {
min-width: 184px;
}
/* Equal thirds with room around the label, so a long translated shape name
never runs edge to edge in its segment. */
html:not([lang='en']) #draw-mode-row .pp-mode-btn {
padding: 0 4px;
}
#draw-label-row {
flex-wrap: wrap;
row-gap: 6px;
@@ -990,6 +1020,11 @@
.draw-color-select {
flex: 0 0 74px;
}
/* Sized to its longest colour name so a translated one is never clipped. */
html:not([lang='en']) .draw-color-select {
flex: 0 0 auto;
min-width: 74px;
}
.draw-clear-btn {
flex: 0 0 auto;
padding: 0 8px;
@@ -1319,6 +1354,22 @@ body.ui-clean-view #scene-runtime.active {
margin-bottom: 0;
}
/* One label column for every parameter, as wide as the longest translated
label, so all slider tracks start and end at the same x. */
html:not([lang='en']) #param-sliders {
display: grid;
grid-template-columns: max-content minmax(0, 1fr) max-content;
align-items: center;
gap: 10px;
}
html:not([lang='en']) .param-slider-row {
display: grid;
grid-column: 1 / -1;
grid-template-columns: subgrid;
margin-bottom: 0;
}
.param-label {
font-family: var(--font-mono);
font-size: 9px;
+8 -1
View File
@@ -174,6 +174,8 @@
.context-mode-button,
.map-stack-chip,
.key-setup-chip,
.locale-picker-button,
.locale-picker-popup,
.first-run-card
) {
border-color: var(--glass-border);
@@ -1834,9 +1836,11 @@
display: none;
}
/* Sits above the language picker row in the bottom-right corner
(locale-picker.css: 0.9rem inset + one ~2rem button + a gap). */
#intel-hud[data-variant='cyber'] .hud-bottom-right {
right: 28px;
bottom: 28px;
bottom: 3.5rem;
}
}
@@ -2733,6 +2737,9 @@
:root[data-ui-theme='cyber'] body.cockpit-mode .cockpit-position-readout {
right: 30px;
/* Clears the language picker row it shares the corner with, as in the
other themes (locale-picker.css). */
bottom: 3.5rem;
}
:root[data-ui-theme='cyber'] body.cockpit-mode .cockpit-help {
+1
View File
@@ -3,6 +3,7 @@
body.ui-embed #clean-view-exit,
body.ui-embed #intel-hud,
body.ui-embed #key-setup-chip,
body.ui-embed #corner-actions,
body.ui-embed #key-setup,
body.ui-embed #first-run-launcher,
body.ui-embed #command-dock,
+218
View File
@@ -0,0 +1,218 @@
// Layout rules written for long translations must not change the English
// rendering: every rule checked here has no `html:not([lang='en'])` guard,
// so it applies to English too.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const here = dirname(fileURLToPath(import.meta.url));
const css = (name) => readFileSync(join(here, name), 'utf8');
/** Declarations of every rule whose (unguarded) selector list is `selector`. */
function unguardedRules(source, selector) {
const norm = (s) => s.replace(/\s+/g, ' ').trim();
const want = norm(selector);
const out = [];
const re = /([^{}]+)\{([^{}]*)\}/g;
let m;
while ((m = re.exec(source))) {
// The captured "selector" may carry the tail of a preceding block
// (comments, an @media prelude); keep what follows the last `}` or `*/`.
const raw = m[1].split(/\*\/|\}/).pop();
const sel = norm(raw);
if (sel === want && !sel.includes("html:not([lang='en'])")) out.push(m[2]);
}
return out;
}
function declaration(block, prop) {
const m = block.match(new RegExp(`(?:^|;)\\s*${prop}\\s*:\\s*([^;]+)`));
return m ? m[1].replace(/\s+/g, ' ').trim() : undefined;
}
test('layer status chip keeps its English width (82px)', () => {
const rules = unguardedRules(
css('radio.css'),
`.data-toggle-btn.feed-loading, .data-toggle-btn.feed-degraded,
.data-toggle-btn.feed-stale, .data-toggle-btn.feed-partial,
.data-toggle-btn.feed-fallback, .data-toggle-btn.feed-unavailable`,
);
assert.equal(rules.length, 1);
assert.equal(declaration(rules[0], 'min-width'), '82px');
});
test('Directions steps keep one line with an ellipsis in English', () => {
const [rule] = unguardedRules(css('radio.css'), '.data-row-list-text');
assert.ok(rule);
assert.equal(declaration(rule, 'white-space'), 'nowrap');
assert.equal(declaration(rule, 'text-overflow'), 'ellipsis');
});
test('count-line layout is not reachable from an unguarded selector', () => {
const source = css('radio.css');
assert.equal(
unguardedRules(source, '.data-toggle-row--count-line .data-count').length,
0,
);
});
test('weather status line keeps its fixed English height', () => {
const [rule] = unguardedRules(
css('weather.css'),
".weather-card [data-line-id='status']",
);
assert.ok(rule);
assert.equal(declaration(rule, 'height'), '1.45em');
});
test('cockpit number separators keep the English cell width', () => {
assert.equal(
unguardedRules(css('cockpit.css'), '.cockpit-roll-token.is-separator')
.length,
0,
);
});
test('Bhote Koshi callout keeps its English footer layout', () => {
const source = css('bhote-koshi.css');
const [strong] = unguardedRules(
source,
'.bhote-embedded-callout footer strong',
);
assert.equal(declaration(strong, 'white-space'), 'nowrap');
const [link] = unguardedRules(source, '.bhote-embedded-callout footer a');
assert.equal(declaration(link, 'grid-row'), '1 / span 2');
});
test('SCENES actions stay on one line in English', () => {
assert.equal(
unguardedRules(css('scenes.css'), '#scene-panel .scene-controls .scene-btn')
.length,
0,
);
const [select] = unguardedRules(css('scenes.css'), '#scene-select');
assert.equal(declaration(select, 'white-space'), undefined);
});
test('Display and parameter rows keep their English layout', () => {
const source = css('controls.css');
assert.equal(
unguardedRules(source, '.pp-slider-row > .pp-slider-mini-label').length,
0,
);
assert.equal(unguardedRules(source, '#param-sliders').length, 0);
const [row] = unguardedRules(source, '.param-slider-row');
assert.equal(declaration(row, 'display'), 'flex');
const [color] = unguardedRules(source, '.draw-color-select');
assert.equal(declaration(color, 'flex'), '0 0 74px');
});
test('weather setting rows keep the 56px English label column', () => {
const source = css('weather.css');
assert.equal(unguardedRules(source, '.rail-card-settings').length, 0);
const [row] = unguardedRules(source, '.rail-card-setting');
assert.equal(
declaration(row, 'grid-template-columns'),
'56px minmax(0, 1fr)',
);
});
test('cockpit brief controls keep their English sizes', () => {
const source = css('cockpit.css');
const [auto] = unguardedRules(source, '#cockpit-brief-auto');
assert.equal(declaration(auto, 'width'), '50px');
assert.equal(unguardedRules(source, '.cockpit-brief-actions > *').length, 0);
});
test('the count-line class is set only outside English', async () => {
const { setLocale } = await import('../../i18n/index.js');
const { LayerPanel } = await import('../layerPanel.js');
const classes = () => {
const toggled = new Map();
const node = () => ({
textContent: '',
classList: { toggle: (name, on) => toggled.set(name, on) },
appendChild() {},
});
LayerPanel.prototype._syncCount.call(LayerPanel.prototype, node(), node(), {
countLabel: '693 nearby · 93 on screen',
});
return toggled.get('data-toggle-row--count-line');
};
globalThis.document ??= { createElement: () => ({}) };
try {
setLocale('en');
assert.equal(classes(), false);
setLocale('es');
assert.equal(classes(), true);
} finally {
setLocale('en');
}
});
test('Recent Imagery readout keeps its English layout', () => {
const source = css('recent-imagery.css');
const one = (selector) => {
const rules = unguardedRules(source, selector);
assert.equal(rules.length, 1, selector);
return rules[0];
};
// The SHOW chip shares its row with the reserved B half.
assert.equal(
unguardedRules(
source,
".recent-imagery-readout:not([data-mode='ab']) .ri-card-chips .ri-chip[data-slot='a']",
).length,
0,
);
assert.equal(
unguardedRules(
source,
".recent-imagery-readout:not([data-mode='ab']) .ri-card-chips .ri-chip[data-slot='b']",
).length,
0,
);
// Slot rows: their own fixed 44px tag column, no subgrid.
const selection = one('.recent-imagery-readout .ri-selection');
assert.equal(declaration(selection, 'grid-template-columns'), undefined);
assert.equal(declaration(selection, 'column-gap'), undefined);
assert.equal(
declaration(one('.recent-imagery-readout .ri-mode'), 'grid-column'),
undefined,
);
const slot = one('.recent-imagery-readout .ri-slot');
assert.equal(
declaration(slot, 'grid-template-columns'),
'44px minmax(0, 1fr) 22px',
);
assert.equal(declaration(slot, 'grid-column'), undefined);
assert.equal(declaration(slot, 'height'), '20px');
assert.equal(
declaration(one('.recent-imagery-readout .ri-slot-tag'), 'padding'),
undefined,
);
// Opacity / SWAP / export row: fixed English columns.
assert.equal(
declaration(
one('.recent-imagery-readout .ri-controls'),
'grid-template-columns',
),
'76px minmax(0, 1fr) 40px 128px',
);
const label = one('.recent-imagery-readout .ri-opacity-label');
assert.equal(declaration(label, 'flex-wrap'), undefined);
assert.equal(declaration(label, 'gap'), '4px');
assert.equal(declaration(label, 'line-height'), undefined);
const swap = one('.recent-imagery-readout .ri-swap');
assert.equal(declaration(swap, 'width'), '40px');
assert.equal(declaration(swap, 'min-width'), undefined);
});
test('CCTV control buttons stay on one line in English', () => {
assert.equal(
unguardedRules(css('cctv.css'), '.cctv-controls .scene-btn').length,
0,
);
});
+3 -1
View File
@@ -1371,10 +1371,12 @@
padding: 2px 4px;
}
/* ALPR keeps its nearby count in the legend while a wider status chip is shown. */
/* ALPR keeps its nearby count in the legend while a wider status chip
(including ENABLING / DISABLING) is shown. */
.data-toggle-row[data-layer-id='alpr-cameras']
.data-toggle-right:has(
.data-toggle-btn:is(
.transitioning,
.feed-loading,
.feed-degraded,
.feed-stale,
+299
View File
@@ -0,0 +1,299 @@
/* ============================================================
Bottom-right corner row: the POWER UP chip (provider-settings.css)
and, in the very corner, the interface language picker
(src/ui/localePicker.js). Logical properties throughout, so a
right-to-left page mirrors the row and the list.
============================================================ */
#corner-actions {
position: fixed;
inset-inline-end: 0.9rem;
inset-block-end: 0.9rem;
/* Above the dock (145), the cockpit HUD and panel stacks (146–147);
below the first-run launcher (175) and Provider Settings (190). */
z-index: 150;
display: flex;
align-items: stretch;
gap: 0.4rem;
pointer-events: none;
}
#corner-actions > * {
pointer-events: auto;
}
.locale-picker {
position: relative;
display: flex;
}
/* Same HUD frame as #key-setup-chip, so the two read as one group. */
.locale-picker-button {
display: flex;
align-items: center;
gap: 0.35rem;
margin: 0;
padding: 0.45rem 0.6rem;
color: rgba(225, 244, 248, 0.92);
font-family: var(--font-mono);
font-size: 0.6rem;
letter-spacing: 0.09rem;
line-height: 1;
background: linear-gradient(
145deg,
rgba(10, 27, 37, 0.92),
rgba(7, 13, 22, 0.9)
);
border: 1px solid rgba(54, 220, 255, 0.38);
border-radius: 0.42rem;
box-shadow:
0 0.4rem 1.6rem rgba(0, 0, 0, 0.5),
0 0 1.2rem rgba(0, 212, 255, 0.07);
backdrop-filter: blur(18px) saturate(1.2);
-webkit-backdrop-filter: blur(18px) saturate(1.2);
cursor: pointer;
transition:
border-color var(--transition-fast),
background var(--transition-fast),
transform var(--transition-fast);
}
.locale-picker-button:hover,
.locale-picker-button:focus-visible,
.locale-picker.is-open .locale-picker-button {
background: linear-gradient(
145deg,
rgba(14, 38, 51, 0.95),
rgba(9, 18, 28, 0.93)
);
border-color: rgba(54, 220, 255, 0.7);
outline: none;
}
.locale-picker-button:hover,
.locale-picker-button:focus-visible {
transform: translateY(-1px);
}
.locale-picker-globe {
flex: none;
width: 0.95rem;
height: 0.95rem;
color: #55dff5;
}
.locale-picker-caret {
flex: none;
width: 0.62rem;
height: 0.62rem;
margin-inline-start: -0.1rem;
opacity: 0.75;
transition: transform var(--transition-fast);
}
.locale-picker.is-open .locale-picker-caret {
transform: rotate(180deg);
}
/* Opens upward, aligned to the corner edge. */
.locale-picker-popup {
position: absolute;
inset-block-end: calc(100% + 0.4rem);
inset-inline-end: 0;
display: flex;
flex-direction: column;
box-sizing: border-box;
width: max-content;
min-width: 14rem;
max-width: calc(100vw - 1.8rem);
max-height: min(26rem, calc(100vh - 4.5rem));
max-height: min(26rem, calc(100dvh - 4.5rem));
padding: 0.45rem;
color: var(--text-primary);
background:
linear-gradient(145deg, rgba(10, 27, 37, 0.97), rgba(7, 13, 22, 0.95)),
rgba(7, 17, 25, 0.96);
border: 1px solid rgba(54, 220, 255, 0.42);
border-radius: 0.55rem;
box-shadow:
0 1rem 3rem rgba(0, 0, 0, 0.65),
0 0 2rem rgba(0, 212, 255, 0.08),
inset 0 0 0 1px rgba(255, 255, 255, 0.025);
backdrop-filter: blur(24px) saturate(1.25);
-webkit-backdrop-filter: blur(24px) saturate(1.25);
}
.locale-picker-popup[hidden] {
display: none;
}
.locale-picker-heading {
padding: 0.2rem 0.4rem 0.45rem;
color: rgba(85, 223, 245, 0.85);
font-family: var(--font-mono);
font-size: 0.55rem;
letter-spacing: 0.14rem;
text-transform: uppercase;
}
.locale-picker-search {
box-sizing: border-box;
width: 100%;
margin: 0 0 0.4rem;
padding: 0.45rem 0.55rem;
color: var(--text-primary);
font-family: var(--font-mono);
font-size: 0.7rem;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(54, 220, 255, 0.28);
border-radius: 0.35rem;
outline: none;
}
.locale-picker-search:focus {
border-color: rgba(54, 220, 255, 0.7);
}
.locale-picker-list {
margin: 0;
padding: 0;
overflow-y: auto;
list-style: none;
outline: none;
overscroll-behavior: contain;
}
.locale-picker-option {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.45rem 0.5rem;
border: 1px solid transparent;
border-radius: 0.35rem;
cursor: pointer;
}
.locale-picker-option.is-active {
background: rgba(54, 220, 255, 0.09);
border-color: rgba(54, 220, 255, 0.28);
}
/* Keyboard focus lives on the list (aria-activedescendant): show it. */
.locale-picker-list:focus-visible .locale-picker-option.is-active,
.locale-picker-search:focus
~ .locale-picker-list
.locale-picker-option.is-active {
border-color: rgba(54, 220, 255, 0.75);
}
.locale-picker-text {
display: flex;
flex-direction: column;
gap: 0.15rem;
min-width: 0;
}
.locale-picker-native {
color: var(--text-primary);
font-family: var(--font-sans);
font-size: 0.85rem;
line-height: 1.2;
}
.locale-picker-option[aria-selected='true'] .locale-picker-native {
color: #55dff5;
}
.locale-picker-meta {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
color: rgba(199, 225, 231, 0.6);
font-family: var(--font-mono);
font-size: 0.55rem;
letter-spacing: 0.05rem;
}
.locale-picker-check {
flex: none;
width: 0.95rem;
height: 0.95rem;
margin-inline-start: auto;
color: #55dff5;
}
.locale-picker-empty {
margin: 0;
padding: 0.5rem;
color: rgba(199, 225, 231, 0.62);
font-family: var(--font-mono);
font-size: 0.6rem;
}
.locale-picker-empty[hidden] {
display: none;
}
/* The surfaces that hide the rest of the chrome hide the corner too. The
cockpit keeps the language picker (provider-settings.css hides the chip). */
body.ui-clean-view #corner-actions,
body.recording-mode #corner-actions,
body.scene-playback-mode #corner-actions {
display: none;
}
/* The cockpit's position readout shares this corner: it steps up to clear
the picker row (0.9rem inset + one ~2rem button + a gap). */
body.cockpit-mode .cockpit-position-readout {
bottom: max(clamp(22px, 4vh, 42px), 3.5rem);
}
/* Below this width the full chip label runs into the centred dock: the chip
keeps its bolt, and its count stays in its title and aria-label. */
@media (max-width: 1180px) {
#corner-actions #key-setup-chip [data-key-setup-chip-label] {
display: none;
}
}
@media (max-width: 767.98px) {
/* Phone: the corner beside the dock, clear of the attribution above it.
There is room for one button across, so the chip stacks on the globe. */
#corner-actions {
inset-inline-end: 10px;
inset-block-end: max(16px, env(safe-area-inset-bottom));
flex-direction: column;
}
#corner-actions #key-setup-chip {
justify-content: center;
padding-inline: 0.5rem;
}
/* Phone: the globe alone; the label stays in aria-label. */
.locale-picker-code,
.locale-picker-caret {
display: none;
}
.locale-picker-button {
padding-inline: 0.5rem;
}
/* The list becomes a bottom sheet across the screen. */
.locale-picker-popup {
position: fixed;
inset-inline: 16px;
inset-block-end: 16px;
width: auto;
min-width: 0;
max-width: none;
max-height: calc(100vh - 32px);
max-height: calc(100dvh - 32px);
padding: 0.6rem;
}
.locale-picker-option {
padding-block: 0.7rem;
}
}
+2 -11
View File
@@ -4,11 +4,9 @@
mono kickers. The chip retires once every key is configured.
============================================================ */
/* The chip sits in the bottom-right corner row (#corner-actions,
locale-picker.css), which owns the position. */
#key-setup-chip {
position: fixed;
right: 0.9rem;
bottom: 0.9rem;
z-index: 60;
display: flex;
align-items: center;
gap: 0.4rem;
@@ -389,13 +387,6 @@ body.scene-playback-mode #key-setup {
display: none;
}
@media (max-width: 620px) {
/* Clear the dock. Desktop-first app; this is a courtesy, not a layout. */
#key-setup-chip {
bottom: 5.8rem;
}
}
/* Provider Settings additions: externally-managed badge + per-row remove. */
.key-setup-external {
padding: 0.05rem 0.3rem;
+123
View File
@@ -260,6 +260,13 @@
letter-spacing: 0.06em;
cursor: pointer;
}
/* A longer translated action stays on one line, cut with an ellipsis. */
html:not([lang='en']) .radio-transport button {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.radio-transport button:hover:not(:disabled),
.radio-transport button:focus-visible:not(:disabled) {
border-color: #65f7ff;
@@ -644,6 +651,89 @@
text-align: right;
}
/* Longer translations below; English keeps the layout above. */
/* The name takes the room the status column leaves; a long word wraps inside
it instead of pushing the toggle out of line. */
html:not([lang='en']) .data-toggle-left {
flex: 1 1 auto;
min-width: 0;
}
/* A fixed icon box, so every name (and the meta line under it, 26px in)
starts at the same x whatever the glyph's width. */
html:not([lang='en']) .data-toggle-left .data-icon {
flex: none;
width: 18px;
text-align: center;
}
/* Translated layer names are longer: break between words, and hyphenate a
word that still does not fit instead of splitting it anywhere. */
html:not([lang='en']) .data-name {
min-width: 0;
overflow-wrap: break-word;
hyphens: auto;
/* No two-letter pieces such as "es-paciales": a word hyphenates only when
at least four letters stay on each side. */
hyphenate-limit-chars: 8 4 4;
}
html:not([lang='en']) .data-count {
min-width: auto;
text-wrap: balance;
}
/* Row text pieces that must not break inside: a hyphenated code (ADS-B) and
the space before a " · " separator (see setRowText in layerPanel.js). */
html:not([lang='en']) .data-keep {
white-space: nowrap;
}
/* A count phrase ("24 nearby · 2 on screen") takes its own line under the
name instead of squeezing it beside the toggle. layerPanel.js sets the
class only outside English. */
html:not([lang='en']) .data-toggle-row--count-line .data-toggle-top {
flex-wrap: wrap;
row-gap: 2px;
}
html:not([lang='en']) .data-toggle-row--count-line .data-toggle-right {
display: contents;
}
/* A zero basis, so a long name wraps beside the toggle instead of pushing the
toggle onto the count's line. */
html:not([lang='en']) .data-toggle-row--count-line .data-toggle-left {
flex-basis: 0;
}
html:not([lang='en']) .data-toggle-row--count-line .data-count {
order: 1;
flex: 1 0 100%;
padding-left: 26px;
font-size: 10px;
text-align: left;
}
/* The "—" placeholder gives way to a wide status chip, so the name keeps the
room it needs. */
html:not([lang='en'])
.data-toggle-right:has(
.data-toggle-btn:is(
.transitioning,
.feed-loading,
.feed-degraded,
.feed-stale,
.feed-partial,
.feed-fallback,
.feed-unavailable
)
)
.data-count-empty {
display: none;
}
.data-toggle-btn {
padding: 3px 10px;
font-family: var(--font-mono);
@@ -666,6 +756,11 @@
text-align: center;
}
html:not([lang='en']) .data-toggle-btn {
flex: none;
white-space: nowrap;
}
/* An inset ring stays visible inside the scrolling layer list. */
.data-toggle-btn:focus-visible {
outline: 2px solid var(--text-primary);
@@ -682,6 +777,20 @@
letter-spacing: 0.8px;
}
/* Status chips up to eight characters share one width; a longer word grows
its own chip rather than widening every chip and squeezing every name. */
html:not([lang='en'])
.data-toggle-btn:is(
.feed-loading,
.feed-degraded,
.feed-stale,
.feed-partial,
.feed-fallback,
.feed-unavailable
) {
min-width: 72px;
}
.data-toggle-btn:hover {
border-color: var(--glass-border-hover);
background: rgba(255, 255, 255, 0.06);
@@ -893,6 +1002,20 @@
white-space: nowrap;
}
/* A translated instruction wraps under itself rather than losing its tail:
"Gira a la derecha hacia …" says nothing. */
html:not([lang='en']) .data-row-list-lead {
white-space: nowrap;
}
html:not([lang='en']) .data-row-list-text {
min-width: 0;
overflow: visible;
text-overflow: clip;
white-space: normal;
overflow-wrap: anywhere;
}
/* A list line that states a limit rather than offering an action. */
.data-row-list-item.note {
cursor: default;
+54
View File
@@ -293,6 +293,21 @@
height: 18px;
font-weight: 700;
}
/* Outside A / B there is one chip (SHOW): in a translation it takes the
whole row, so a long label is not cut to a stub. English keeps the
reserved B half. */
html:not([lang='en'])
.recent-imagery-readout:not([data-mode='ab'])
.ri-card-chips
.ri-chip[data-slot='a'] {
grid-column: 1 / -1;
}
html:not([lang='en'])
.recent-imagery-readout:not([data-mode='ab'])
.ri-card-chips
.ri-chip[data-slot='b'] {
display: none;
}
/* 4. The selection bar: mode, then two rows that are always there. */
.recent-imagery-readout .ri-selection {
@@ -347,6 +362,28 @@
color: var(--text-secondary);
font-style: italic;
}
/* The slot rows share the bar's columns (subgrid), so the tag column fits
the longest tag and both rows align; a long slot line (date · source ·
resolution · state) takes a second line instead of losing the state. */
html:not([lang='en']) .recent-imagery-readout .ri-selection {
grid-template-columns: minmax(44px, max-content) minmax(0, 1fr) 22px;
column-gap: 6px;
}
html:not([lang='en']) .recent-imagery-readout .ri-mode {
grid-column: 1 / -1;
}
html:not([lang='en']) .recent-imagery-readout .ri-slot {
grid-column: 1 / -1;
grid-template-columns: subgrid;
height: auto;
min-height: 20px;
}
html:not([lang='en']) .recent-imagery-readout .ri-slot-tag {
padding: 0 3px;
}
html:not([lang='en']) .recent-imagery-readout .ri-slot-value {
white-space: normal;
}
.recent-imagery-readout .ri-unpin {
width: 22px;
padding: 0;
@@ -371,6 +408,19 @@
letter-spacing: 1px;
white-space: nowrap;
}
/* In a translation a label longer than its 76px column widens it to the
label's longest word, and the value wraps under the label. */
html:not([lang='en']) .recent-imagery-readout .ri-controls {
grid-template-columns:
minmax(76px, min-content) minmax(0, 1fr) minmax(40px, max-content)
128px;
}
html:not([lang='en']) .recent-imagery-readout .ri-opacity-label {
flex-wrap: wrap;
align-content: center;
row-gap: 0;
line-height: 10px;
}
.recent-imagery-readout .ri-opacity-value {
font-variant-numeric: tabular-nums;
color: var(--text-secondary);
@@ -388,6 +438,10 @@
width: 40px;
padding: 0 2px;
}
html:not([lang='en']) .recent-imagery-readout .ri-swap {
width: auto;
min-width: 40px;
}
.recent-imagery-readout .ri-exports .rail-card-actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
+20
View File
@@ -163,6 +163,26 @@
outline: none;
}
/* A translated scene name needs the width: the select takes its own row,
NEW / DEL share the next one. */
html:not([lang='en']) #scene-select {
flex: 1 1 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.04em;
}
/* A translated action longer than its button wraps inside the button instead
of running past it. English keeps its one-line buttons ("EXPORT PRESETS"
included). */
html:not([lang='en']) #scene-panel .scene-controls .scene-btn {
white-space: normal;
overflow-wrap: break-word;
}
.scene-btn {
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.14);
+8 -46
View File
@@ -123,6 +123,11 @@
text-overflow: ellipsis;
white-space: nowrap;
}
/* Translated retry details ("cámaras ALPR · reintento en 53 s") run longer
than the English one; English keeps its 220px. */
html:not([lang='en']) #global-loading-detail {
max-width: min(300px, 46vw);
}
#global-loading-status:is(
[data-state='complete'],
[data-state='cancelled'],
@@ -384,17 +389,10 @@
/* ── Toast Notification ──────────────────────── */
#toast {
--toast-safe-top: 74px;
position: fixed;
bottom: 100px;
left: 0;
right: 0;
margin-inline: auto;
width: max-content;
max-width: calc(100vw - 32px);
box-sizing: border-box;
overflow-wrap: anywhere;
transform: translateY(20px);
left: 50%;
transform: translateX(-50%) translateY(20px);
z-index: 200;
padding: 10px 24px;
background: var(--glass-bg);
@@ -420,41 +418,5 @@
#toast.visible {
opacity: 1;
visibility: visible;
transform: translateY(0);
}
/* Keep transient feedback clear of the raised voice chooser and bottom dock. */
body:has(#gev-voice-control:hover) #toast,
body:has(#gev-voice-control:focus-within) #toast,
body:has(#command-dock.dock-has-pinned-tray) #toast {
top: calc(env(safe-area-inset-top, 0px) + var(--toast-safe-top));
bottom: auto;
transform: translateY(-20px);
}
body:has(#gev-voice-control:hover) #toast.visible,
body:has(#gev-voice-control:focus-within) #toast.visible,
body:has(#command-dock.dock-has-pinned-tray) #toast.visible {
transform: translateY(0);
}
@media (max-width: 900px), (max-height: 640px) {
#toast {
top: calc(env(safe-area-inset-top, 0px) + var(--toast-safe-top));
bottom: auto;
letter-spacing: 1px;
transform: translateY(-20px);
}
#toast.visible {
transform: translateY(0);
}
}
/* On a narrow portrait viewport the collapsed layer and scene headers occupy
the first two rows. Keep feedback below them, clear of the voice controls. */
@media (max-width: 600px) and (orientation: portrait) {
#toast {
--toast-safe-top: 214px;
}
transform: translateX(-50%) translateY(0);
}
+13
View File
@@ -216,6 +216,19 @@ button.sl-status:focus-visible {
display: none;
}
/* When the hint and the button do not both fit, the button takes its own line. */
html:not([lang='en']) .sl-meta-row {
flex-wrap: wrap;
}
html:not([lang='en']) .sl-meta-row .sl-meta {
flex: 1 1 14em;
}
html:not([lang='en']) .sl-meta-row .sl-nearest-btn {
flex: 1 0 auto;
}
/* ── Errors ──────────────────────────────────────────────────────────────── */
.sl-error {
font-family: var(--font-sans);
+3
View File
@@ -231,6 +231,7 @@
font-family: var(--font-sans);
font-size: 0.78rem;
font-weight: 600;
overflow-wrap: break-word;
}
.gev-voice-card-chips {
@@ -271,6 +272,8 @@
font-size: 0.66rem;
line-height: 1.4;
color: rgba(223, 245, 250, 0.82);
/* A whole error line or a long headline wraps inside the card. */
overflow-wrap: break-word;
}
.gev-voice-card-referents {
+74 -9
View File
@@ -83,7 +83,65 @@
display: none;
}
#command-dock #gev-voice-control[data-provider] {
/* The heading row is only as wide as the voice column: the cost cluster
keeps its size and a long state (ПОДКЛЮЧЕНИЕ, CONECTANDO) ends in an
ellipsis. Dropping the kicker returns its gap to the state label; the
negative margin reaches over the gutter beside the mic ring, which has
curved away at heading height. */
html:not([lang='en']) #command-dock .gev-voice-heading {
gap: 0.2rem;
margin-left: -0.6rem;
}
html:not([lang='en']) #command-dock .gev-voice-heading > .gev-voice-kicker {
display: none;
}
html:not([lang='en']) #command-dock .gev-voice-heading > #gev-voice-status {
flex: 0 1 auto;
min-width: 0;
overflow: hidden;
letter-spacing: 0;
text-overflow: ellipsis;
white-space: nowrap;
}
html:not([lang='en']) #command-dock .gev-voice-cost {
gap: 0.16rem;
padding-left: 0.1rem;
}
html:not([lang='en']) #command-dock .gev-voice-tier-btn {
min-width: 0;
padding: 0 0.2rem;
}
html:not([lang='en']) #command-dock .gev-voice-cost-value {
letter-spacing: 0;
}
/* Live hints and the tier switch ("СТАНДАРТ — со следующей сессии") run
longer: a second line from the spare space under the readout, with no
tracking so the tier line does not need a third. */
html:not([lang='en'])
#command-dock
#gev-voice-control:is(
[data-status='connecting'],
[data-status='listening'],
[data-status='executing']
)
#gev-voice-detail {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
line-clamp: 2;
line-height: 1.15;
letter-spacing: 0;
white-space: normal;
overflow-wrap: anywhere;
}
#gev-voice-control[data-provider-choice='true'] {
--gev-provider-lift: 0px;
}
@@ -125,12 +183,17 @@
opacity: 1;
pointer-events: auto;
}
#command-dock #gev-voice-control[data-provider] .gev-voice-help-tray,
#command-dock #gev-voice-control[data-provider] .gev-voice-card,
#command-dock #gev-voice-control[data-provider] .gev-voice-error-tray {
#command-dock
#gev-voice-control[data-provider-choice='true']
.gev-voice-help-tray,
#command-dock #gev-voice-control[data-provider-choice='true'] .gev-voice-card,
#command-dock
#gev-voice-control[data-provider-choice='true']
.gev-voice-error-tray {
bottom: calc(100% + 4.8rem + var(--gev-provider-lift));
}
#command-dock.dock-has-pinned-tray #gev-voice-control[data-provider] {
#command-dock.dock-has-pinned-tray
#gev-voice-control[data-provider-choice='true'] {
--gev-provider-lift: calc(1.7rem + var(--dock-pinned-stack-height));
}
/* Pinned trays separate the chooser from its hover owner. Keep it reachable
@@ -146,18 +209,20 @@
opacity: 1;
pointer-events: auto;
}
#command-dock #gev-voice-control[data-provider] {
#command-dock #gev-voice-control[data-provider-choice='true'] {
--gev-provider-lift: 3rem;
}
#command-dock.dock-has-pinned-tray #gev-voice-control[data-provider] {
#command-dock.dock-has-pinned-tray
#gev-voice-control[data-provider-choice='true'] {
--gev-provider-lift: calc(3.5rem + var(--dock-pinned-stack-height));
}
}
@media (max-width: 720px) {
#command-dock #gev-voice-control[data-provider] {
#command-dock #gev-voice-control[data-provider-choice='true'] {
--gev-provider-lift: calc(2vh + 2.5rem);
}
#command-dock.dock-has-pinned-tray #gev-voice-control[data-provider] {
#command-dock.dock-has-pinned-tray
#gev-voice-control[data-provider-choice='true'] {
--gev-provider-lift: calc(2vh + 3rem + var(--dock-pinned-stack-height));
}
}
+22
View File
@@ -152,6 +152,13 @@
overflow-y: auto;
color: var(--accent);
}
/* One line stays reserved so the card does not jump when a status appears;
a translated status runs longer and wraps instead of being cut. */
html:not([lang='en']) .weather-card [data-line-id='status'] {
height: auto;
min-height: 1.45em;
overflow-y: visible;
}
.rail-card-ramp {
position: relative;
height: 4px;
@@ -261,6 +268,9 @@ body.recording-mode .gev-wind-inspection-marker {
font: 9px/1.45 var(--font-mono);
color: var(--text-dim);
}
html:not([lang='en']) .rail-card-zero::after {
white-space: nowrap;
}
.weather-observed-group {
border: 1px solid var(--weather-card-border);
@@ -318,6 +328,18 @@ body.recording-mode .gev-wind-inspection-marker {
gap: 6px;
margin-top: 6px;
}
/* One label column per card, as wide as its longest label: chips line up
across rows, and a long translated label never runs under its chips. */
html:not([lang='en']) .rail-card-settings {
display: grid;
grid-template-columns: minmax(56px, max-content) minmax(0, 1fr);
column-gap: 6px;
}
html:not([lang='en']) .rail-card-setting {
grid-column: 1 / -1;
grid-template-columns: minmax(56px, max-content) minmax(0, 1fr);
grid-template-columns: subgrid;
}
.rail-card-setting-label {
color: var(--text-dim);
font: 9px var(--font-mono);
+93 -93
View File
@@ -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>
<!-- 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></span><small data-i18n>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>
<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>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>OPTICAL PLANE · 01</div>
<div class="cockpit-visor-status cockpit-visor-status-right" data-i18n>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>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>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,38 +112,38 @@
</div>
<div class="cockpit-topline">
<div>
<span class="cockpit-kicker">FIRST PERSON</span>
<strong id="cockpit-callsign">AIRCRAFT</strong>
<span id="cockpit-aircraft-meta" class="cockpit-aircraft-meta">LIVE TRACK · COURSE ALIGNED</span>
<span class="cockpit-kicker" data-i18n>FIRST PERSON</span>
<strong id="cockpit-callsign" data-i18n>AIRCRAFT</strong>
<span id="cockpit-aircraft-meta" class="cockpit-aircraft-meta" data-i18n>LIVE TRACK · COURSE ALIGNED</span>
</div>
<div class="cockpit-vision-controls" role="group" aria-label="Cockpit vision style">
<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>
<button id="cockpit-vision-previous" class="cockpit-vision-nav" type="button" aria-label="Previous cockpit vision style" title="Previous vision style" data-i18n-aria-label data-i18n-title>
<span class="material-symbols-outlined" aria-hidden="true">chevron_left</span>
<small>PREV</small>
<small data-i18n>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>
<strong id="cockpit-vision-current-label" aria-live="polite">NORMAL</strong>
<button id="cockpit-vision-current" class="cockpit-vision-current" type="button" data-cockpit-vision="optical" aria-label="Current cockpit vision style: NORMAL. Activate for next style." title="Current style: NORMAL — click for next" data-i18n-aria-label data-i18n-title>
<small data-i18n>CURRENT</small>
<strong id="cockpit-vision-current-label" aria-live="polite" data-i18n>NORMAL</strong>
</button>
<button id="cockpit-vision-next" class="cockpit-vision-nav" type="button" aria-label="Next cockpit vision style" title="Next vision style">
<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 data-i18n-title>
<small data-i18n>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>
<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>GROUND SPEED</span>
<strong id="cockpit-speed-value">---</strong>
<span class="cockpit-readout-unit">KTS</span>
<span class="cockpit-readout-unit" data-i18n>KTS</span>
</div>
<div class="cockpit-heading-value"><span id="cockpit-heading-value">---</span><small>°</small></div>
<div class="cockpit-readout cockpit-altitude">
<span class="cockpit-readout-label">ALTITUDE</span>
<span class="cockpit-readout-label" data-i18n>ALTITUDE</span>
<strong id="cockpit-altitude-value">-----</strong>
<span class="cockpit-readout-unit">FT</span>
<span class="cockpit-readout-unit" data-i18n>FT</span>
</div>
</div>
<span class="cockpit-heading-caret material-symbols-outlined" aria-hidden="true">arrow_drop_down</span>
@@ -153,85 +153,85 @@
<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>
<header class="cockpit-context-header">
<div>
<span class="cockpit-context-kicker">CONTACT</span>
<strong id="cockpit-context-subject">CONTACTS · 250 KM</strong>
<span data-i18n class="cockpit-context-kicker">CONTACT</span>
<strong id="cockpit-context-subject" data-i18n>CONTACTS · 250 KM</strong>
</div>
<nav class="cockpit-context-actions" aria-label="Contact navigation">
<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>
<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 data-i18n-title>
<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 data-i18n-title>
<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 data-i18n-title>
<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>CONTEXT ONLY</span>
</header>
<div class="cockpit-context-cohorts" role="list" aria-label="Nearby cohort counts">
<div role="listitem" data-context-cohort="flights"><span>FLT</span><strong>—</strong><small>OpenSky</small></div>
<div role="listitem" data-context-cohort="military"><span>MIL</span><strong>—</strong><small>adsb.lol</small></div>
<div role="listitem" data-context-cohort="ais-live-vessels"><span>AIS</span><strong>—</strong><small>AISStream</small></div>
<div role="listitem" data-context-cohort="military-installations"><span>SITE</span><strong>—</strong><small>OpenStreetMap</small></div>
<div class="cockpit-context-cohorts" role="list" aria-label="Nearby cohort counts" data-i18n-aria-label>
<div role="listitem" data-context-cohort="flights"><span data-i18n>FLT</span><strong>—</strong><small translate="no">OpenSky</small></div>
<div role="listitem" data-context-cohort="military"><span data-i18n>MIL</span><strong>—</strong><small translate="no">adsb.lol</small></div>
<div role="listitem" data-context-cohort="ais-live-vessels"><span translate="no">AIS</span><strong>—</strong><small translate="no">AISStream</small></div>
<div role="listitem" data-context-cohort="military-installations"><span data-i18n>SITE</span><strong>—</strong><small translate="no">OpenStreetMap</small></div>
</div>
<div class="cockpit-context-nearest">
<span id="cockpit-context-direction" class="material-symbols-outlined" aria-hidden="true">navigation</span>
<div>
<small>NEAREST OBSERVED / MAPPED</small>
<strong id="cockpit-context-nearest-label">NO AVAILABLE EXAMPLE</strong>
<small data-i18n>NEAREST OBSERVED / MAPPED</small>
<strong id="cockpit-context-nearest-label" data-i18n>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>AVAILABLE INPUTS ONLY · NOT AN ALL-CLEAR</span>
<button
id="cockpit-weather-toggle"
class="cockpit-context-weather"
type="button"
aria-label="Enable cockpit weather effects"
aria-pressed="false"
title="Enable cockpit weather effects"
title="Enable cockpit weather effects" data-i18n-aria-label data-i18n-title
>
<span>WX</span>
<strong id="cockpit-weather-state">OFF</strong>
<span data-i18n>WX</span>
<strong data-i18n id="cockpit-weather-state">OFF</strong>
</button>
<time id="cockpit-context-updated">--:--:--Z</time>
</footer>
</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>
<section id="cockpit-route" class="cockpit-route-card" aria-label="Estimated flight plan" hidden data-i18n-aria-label>
<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>ESTIMATED FLIGHT PLAN</span>
<small id="cockpit-route-status" data-i18n>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>FROM</span>
<strong id="cockpit-route-from" data-i18n>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>TO</span>
<strong id="cockpit-route-to" data-i18n>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>
<span id="cockpit-brief-kicker" class="cockpit-signal-kicker" data-i18n-runtime><i aria-hidden="true"></i> LIVE SIGNALS</span>
<strong id="cockpit-brief-subtitle" data-i18n>OBSERVED / MAPPED PINGS</strong>
</div>
<nav class="cockpit-brief-actions" aria-label="Cockpit briefing controls">
<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>
<button id="cockpit-brief-previous" type="button" aria-label="Previous briefing page" title="Previous briefing page" data-i18n-aria-label data-i18n-title>
<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 data-i18n-title>
<span class="material-symbols-outlined" aria-hidden="true">chevron_right</span>
</button>
<button
@@ -239,52 +239,52 @@
type="button"
aria-label="CYCLE OFF"
aria-pressed="false"
title="Cycle briefing pages automatically every 9 seconds (Signals → News → Local). Pauses while you hover or focus the panel. Live signal data refreshes continuously either way."
title="Cycle briefing pages automatically every 9 seconds (Signals → News → Local). Pauses while you hover or focus the panel. Live signal data refreshes continuously either way." data-i18n-title data-i18n-aria-label data-i18n
>CYCLE OFF</button>
<button id="cockpit-signal-toggle" type="button" aria-label="Collapse cockpit briefing panel" aria-expanded="true" title="Collapse briefing panel">
<button id="cockpit-signal-toggle" type="button" aria-label="Collapse cockpit briefing panel" aria-expanded="true" title="Collapse briefing panel" data-i18n-aria-label data-i18n-title>
<span class="material-symbols-outlined" aria-hidden="true">right_panel_close</span>
</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>
<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>
<div id="cockpit-news-status" class="cockpit-brief-status" data-i18n>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>
<div class="cockpit-local-place">
<strong id="cockpit-local-place">RESOLVING REGION</strong>
<strong id="cockpit-local-place" data-i18n>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><span data-i18n>TEMP</span><strong id="cockpit-local-temperature">—</strong><small translate="no">OPEN-METEO</small></div>
<div><span data-i18n>WIND</span><strong id="cockpit-local-wind">—</strong><small id="cockpit-local-wind-direction">—</small></div>
<div><span data-i18n>SKY</span><strong id="cockpit-local-condition" data-i18n>UNKNOWN</strong><small id="cockpit-local-cloud">—</small></div>
<div><span data-i18n>PRECIP</span><strong id="cockpit-local-precipitation">—</strong><small data-i18n>MM</small></div>
</div>
<a class="cockpit-weather-credit" href="https://open-meteo.com/" target="_blank" rel="noopener">Weather data by Open-Meteo.com</a>
<a class="cockpit-weather-credit" href="https://open-meteo.com/" target="_blank" rel="noopener" data-i18n>Weather data by Open-Meteo.com</a>
</section>
</div>
<footer class="cockpit-signal-footer">
<span id="cockpit-brief-source">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>
<span id="cockpit-brief-source" data-i18n>SOURCE-BACKED EVENTS · NO SYNTHETIC NEWS</span>
<nav class="cockpit-brief-tabs" aria-label="Cockpit briefing pages" data-i18n-aria-label>
<button type="button" data-cockpit-brief-index="0" aria-label="Show Live Signals" aria-current="true" data-i18n-aria-label data-i18n>SIG</button>
<button type="button" data-cockpit-brief-index="1" aria-label="Show Regional News" data-i18n-aria-label data-i18n>NEWS</button>
<button type="button" data-cockpit-brief-index="2" aria-label="Show Local Info" data-i18n-aria-label data-i18n>LOCAL</button>
</nav>
</footer>
</aside>
<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>
<div class="cockpit-utility-control">
<div class="cockpit-utility-launcher" data-cockpit-launcher="display">
<span>DISPLAY</span>
<span data-i18n>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 data-i18n-title>◀</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>
<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,36 +293,36 @@
</div>
<div class="cockpit-utility-control">
<div class="cockpit-utility-launcher" data-cockpit-launcher="radio">
<span>RADIO</span>
<span data-i18n>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 data-i18n-title>◀</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>
<strong id="cockpit-radio-station" class="cockpit-radio-station" data-i18n>READY</strong>
<button id="cockpit-radio-enable-btn" class="panel-layer-toggle" type="button" aria-pressed="false" data-i18n>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>◀◀</button>
<button id="cockpit-radio-play-btn" type="button" aria-label="Play selected radio station" disabled data-i18n-aria-label>▶</button>
<button id="cockpit-radio-next-btn" type="button" aria-label="Next filtered radio station" disabled data-i18n-aria-label>▶▶</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>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 />
<output id="cockpit-radio-volume-value" class="gev-slider-value" for="cockpit-radio-volume">80%</output>
</label>
</div>
</div>
</aside>
<div class="cockpit-help">ESC EXIT <span>·</span> C TOGGLE</div>
<div class="cockpit-help"><bdi data-i18n>ESC EXIT</bdi> <span>·</span> <bdi data-i18n>C TOGGLE</bdi></div>
</section>
<nav id="view-switcher" aria-label="View switcher">
<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>
<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 data-i18n-title>
<span class="material-symbols-outlined" aria-hidden="true">public</span>
<span>RESET</span>
<span data-i18n>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 data-i18n-title>
<span class="material-symbols-outlined" aria-hidden="true">close_fullscreen</span>
<span>EXIT COCKPIT</span>
<span data-i18n>EXIT COCKPIT</span>
</button>
</nav>
+30 -30
View File
@@ -1,65 +1,65 @@
<!-- 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>
<!-- 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">
<span class="panel-title"><span class="dock-label-icon dock-label-icon-presets" aria-hidden="true"></span>VISUAL PRESETS</span>
aria-controls="control-panel-popover" aria-label="Expand Visual Presets" data-i18n-aria-label>
<span class="panel-title" data-i18n-runtime><span class="dock-label-icon dock-label-icon-presets" aria-hidden="true"></span>VISUAL PRESETS</span>
<span class="panel-divider"></span>
</button>
<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 data-i18n-title><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>
<span class="btn-icon">◯</span>
<span class="btn-label">Normal</span>
<span class="btn-label" data-i18n>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>
<span class="btn-icon">▦</span>
<span class="btn-label">CRT</span>
<span class="btn-label" data-i18n>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>
<span class="btn-icon">🌙</span>
<span class="btn-label">NVG</span>
<span class="btn-label" data-i18n>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>
<span class="btn-icon">🌡️</span>
<span class="btn-label">FLIR</span>
<span class="btn-label" data-i18n>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>
<span class="btn-icon">✦</span>
<span class="btn-label">Anime</span>
<span class="btn-label" data-i18n>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>
<span class="btn-icon">◐</span>
<span class="btn-label">Noir</span>
<span class="btn-label" data-i18n>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>
<span class="btn-icon">❄</span>
<span class="btn-label">Snow</span>
<span class="btn-label" data-i18n>Snow</span>
<span class="btn-key">7</span>
</button>
</div>
<section class="map-source-section" aria-labelledby="map-source-label">
<div class="map-source-heading">
<span id="map-source-label">MAP SOURCE</span>
<span data-i18n id="map-source-label">MAP SOURCE</span>
<span id="map-stack-status" class="pp-stack-status" aria-live="polite">3D</span>
</div>
<div id="map-stack-chips" class="map-stack-chip-row" role="group" aria-label="Map source"></div>
<div id="map-stack-chips" class="map-stack-chip-row" role="group" aria-label="Map source" data-i18n-aria-label></div>
</section>
</div>
<div id="style-mini-status" class="panel-mini-status" aria-live="polite">
<span class="panel-mini-label">Style</span>
<span id="style-mini-value" class="panel-mini-value">NORMAL</span>
<span class="panel-mini-label" data-i18n>Style</span>
<span id="style-mini-value" class="panel-mini-value" data-i18n>NORMAL</span>
</div>
</div>
</div>
@@ -69,26 +69,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">
<span class="location-toolbar-label"><span class="dock-label-icon dock-label-icon-location" aria-hidden="true"></span>LOCATION</span>
<button id="location-bar-toggle" class="dock-tray-toggle" type="button" data-dock-toggle-target="location-bar" aria-controls="location-bar-popover" aria-expanded="false" aria-label="Expand LOCATION" title="Expand LOCATION" data-i18n-aria-label data-i18n-title>
<span class="location-toolbar-label" data-i18n-runtime><span class="dock-label-icon dock-label-icon-location" aria-hidden="true"></span>LOCATION</span>
</button>
<button class="panel-collapse-btn" data-collapse-target="location-bar" title="Collapse panel">+</button>
<button class="panel-collapse-btn" data-collapse-target="location-bar" title="Collapse panel" data-i18n-title>+</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>📍 Location: --</div>
<div id="location-mini-poi" class="location-mini-poi" data-i18n>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 data-i18n-title><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>
<span>&#x1F50E;</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 data-i18n-placeholder />
</div>
</div>
</div>
Loaded 100 of 655 files, more files were not shown because too many files have changed in this diff. Show more