fix(ui): verify real PanelChrome rail ownership of compact Radio

Carry reviewed #816 onto current main without conflicts; preserve i18n updates, replace obsolete changelog edit with a fragment, and add a constructor-wiring regression alongside the owner and rail tests.

Credit: raysung-2026 (report), asturksever (review), quanticsoul4772 (Windows diagnosis).
This commit is contained in:
Kevin Rajan committed 2026-10-17 04:56:07 -05:00
commit c1dd4171db
702 files changed
+68262 -6015

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)
+83 -36
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,
@@ -12,6 +13,7 @@ import {
compassDivisions,
formatAltitudeRulerTick,
formatCompassDivision,
formatCockpitEmergency,
formatSpeedRulerTick,
normalizeHeading,
relativeBearing,
@@ -19,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(),
@@ -31,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
@@ -66,7 +86,7 @@ export function updateHud(
setCockpitRollingValue(
this.altitude,
displayedAltitudeFt !== null
? displayedAltitudeFt.toLocaleString('en-US')
? formatNumber(displayedAltitudeFt)
: '-----',
displayedAltitudeFt,
{ immediate: forceContext },
@@ -109,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('');
}
@@ -118,22 +138,41 @@ 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';
this.aircraftMeta.textContent = `${info.layerId === 'military' ? 'MILITARY' : 'COMMERCIAL'} · ${feedState} · COURSE ALIGNED`;
? 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
? t('{aircraftClass} · {emergency} · {feedState}', {
aircraftClass,
emergency,
feedState,
})
: t('{aircraftClass} · {feedState} · COURSE ALIGNED', {
aircraftClass,
feedState,
});
this.aircraftMeta.classList.toggle(
'emergency',
info.emergency?.severity === 'emergency',
);
}
this.updateRoute(info);
if (
@@ -158,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 (
@@ -198,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')}°`,
});
}
}
@@ -206,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');
}
});
}
+122
View File
@@ -0,0 +1,122 @@
/** Frame the complete finite footprint of a session-local file. */
import * as Cesium from 'cesium';
import { guardCameraAboveGround } from '../cameraGroundGuard.js';
import { rangeForBoundingSphere } from '../data/cameraFraming.js';
import { cancelCameraArrival } from '../data/cameraArrival.js';
/** All visible positions, path vertices, polygon rings, walls and overlay bounds. */
export function dataSourcePoints(dataSource, time = Cesium.JulianDate.now()) {
const points = [];
const add = (position, grounded = false) => {
if (
!position ||
![position.x, position.y, position.z].every(Number.isFinite)
)
return;
const c = Cesium.Cartographic.fromCartesian(position);
if (c && Number.isFinite(c.longitude) && Number.isFinite(c.latitude))
points.push([
Cesium.Math.toDegrees(c.longitude),
Cesium.Math.toDegrees(c.latitude),
grounded ? 0 : c.height,
]);
};
if (dataSource?.show === false) return points;
for (const entity of dataSource?.importedEntities ??
dataSource?.entities?.values ??
[]) {
if (entity.isShowing === false) continue;
add(
entity.position?.getValue?.(time),
entity.billboard?.heightReference?.getValue?.(time) ===
Cesium.HeightReference.CLAMP_TO_GROUND,
);
for (const graphic of [entity.polyline, entity.wall])
for (const position of graphic?.positions?.getValue?.(time) || [])
add(position, graphic.clampToGround?.getValue?.(time) === true);
const rings = [entity.polygon?.hierarchy?.getValue?.(time)];
while (rings.length) {
const ring = rings.pop();
for (const position of ring?.positions || [])
add(
position,
entity.polygon?.perPositionHeight?.getValue?.(time) !== true,
);
for (const hole of ring?.holes || []) rings.push(hole);
}
const rectangle = entity.rectangle?.coordinates?.getValue?.(time);
if (rectangle) {
const bounds = Cesium.RectangleGeometry.computeRectangle({
rectangle,
rotation: entity.rectangle.rotation?.getValue?.(time) || 0,
});
for (const corner of [
'southwest',
'southeast',
'northwest',
'northeast',
]) {
const c = Cesium.Rectangle[corner](bounds);
if (Number.isFinite(c.longitude) && Number.isFinite(c.latitude))
points.push([
Cesium.Math.toDegrees(c.longitude),
Cesium.Math.toDegrees(c.latitude),
entity.rectangle.height?.getValue?.(time) || 0,
]);
}
}
}
return points;
}
/** Fly synchronously to the footprint; later navigation always owns its camera. */
export function flyToDataSource(
viewer,
dataSource,
{ duration = 1.5, ground = null } = {},
) {
const points = dataSourcePoints(dataSource);
if (!points.length) return false;
cancelCameraArrival(viewer);
const positions = points.map(([lon, lat, height]) =>
Cesium.Cartesian3.fromDegrees(lon, lat, height),
);
const sphere = Cesium.BoundingSphere.fromPoints(positions);
const center =
Cesium.Cartographic.fromCartesian(sphere.center) ||
Cesium.Cartographic.fromDegrees(points[0][0], points[0][1]);
const lat = Cesium.Math.toDegrees(center.latitude),
lon = Cesium.Math.toDegrees(center.longitude);
const terrain = viewer.scene?.globe?.getHeight?.(center);
const cached = ground?.cachedGroundFloor?.(lat, lon);
const height =
Number.isFinite(terrain) && terrain > 0
? terrain
: Number.isFinite(cached)
? cached
: Number.isFinite(terrain)
? terrain
: 0;
ground?.warmGroundFloor?.([{ lat, lon }]);
sphere.center = Cesium.Cartesian3.fromRadians(
center.longitude,
center.latitude,
Math.max(0, height, center.height),
);
sphere.radius = 0;
for (const position of positions)
sphere.radius = Math.max(
sphere.radius,
Cesium.Cartesian3.distance(sphere.center, position),
);
viewer.camera?.flyToBoundingSphere?.(sphere, {
offset: new Cesium.HeadingPitchRange(
viewer.camera.heading || 0,
-Math.PI / 2,
Math.max(800, rangeForBoundingSphere(viewer, sphere.radius)),
),
duration,
complete: () => guardCameraAboveGround(viewer, { lat, lon }),
});
return true;
}
+163
View File
@@ -0,0 +1,163 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { cancelCameraArrival } from '../data/cameraArrival.js';
import { dataSourcePoints, flyToDataSource } from './flyToDataSource.js';
function source() {
const ds = new Cesium.CustomDataSource('t');
ds.entities.add({
position: Cesium.Cartesian3.fromDegrees(-122.4862, 37.7694, -2667),
billboard: { heightReference: Cesium.HeightReference.CLAMP_TO_GROUND },
});
ds.entities.add({
polyline: {
positions: Cesium.Cartesian3.fromDegreesArray([
-122.49, 37.77, -122.48, 37.771,
]),
},
});
ds.entities.add({
polygon: {
hierarchy: Cesium.Cartesian3.fromDegreesArray([
-122.5, 37.76, -122.49, 37.76, -122.49, 37.765,
]),
},
});
return ds;
}
test('points come from positions, lines and polygons, as lon/lat only', () => {
const points = dataSourcePoints(source());
assert.equal(points.length, 6);
assert.ok(Math.abs(points[0][0] + 122.4862) < 1e-6);
assert.ok(Math.abs(points[0][1] - 37.7694) < 1e-6);
});
test('the camera frames the footprint at the surface, never a buried center', () => {
const calls = [];
const viewer = {
camera: {
heading: 0,
flyToBoundingSphere: (sphere, options) => calls.push({ sphere, options }),
},
flyTo: () => calls.push('viewer.flyTo'),
};
assert.equal(flyToDataSource(viewer, source()), true);
const center = Cesium.Cartographic.fromCartesian(calls[0].sphere.center);
assert.ok(Math.abs(center.height) < 50, `center height ${center.height}`);
assert.ok(calls[0].options.offset.range >= 800);
assert.equal(
typeof calls[0].options.complete,
'function',
'the ground guard runs on landing',
);
assert.equal(
flyToDataSource(viewer, new Cesium.CustomDataSource('empty')),
false,
);
assert.equal(
calls.length,
1,
'empty sources never start deferred navigation',
);
});
test('a very long track is framed without spreading it into a call', () => {
// 400k vertices: `push(...line)` throws RangeError (too many arguments)
// well below this; the walk keeps a bounded sample and both ends.
const n = 400_000;
const line = new Array(n);
for (let i = 0; i < n; i++)
line[i] = Cesium.Cartesian3.fromDegrees(-120 + (i / n) * 2, 40);
const ds = new Cesium.CustomDataSource('long');
ds.entities.add({ polyline: { positions: line } });
ds.entities.add({ position: Cesium.Cartesian3.fromDegrees(-110, 41) });
const points = dataSourcePoints(ds);
assert.equal(points.length, n + 1, 'every vertex contributes to framing');
const lons = points.map(([lon]) => lon);
assert.ok(
lons.reduce((a, b) => Math.min(a, b), Infinity) < -119.99 &&
lons.reduce((a, b) => Math.max(a, b), -Infinity) > -110.01,
);
assert.ok(lons.some((lon) => Math.abs(lon - -118.000005) < 0.01));
});
test('framing includes a sparse interior outlier, walls and rotated overlays', () => {
const ds = new Cesium.CustomDataSource('bounds');
const line = Array.from({ length: 200031 }, () =>
Cesium.Cartesian3.fromDegrees(0, 0),
);
line[12345] = Cesium.Cartesian3.fromDegrees(20, 10);
ds.entities.add({ polyline: { positions: line } });
ds.entities.add({
wall: { positions: Cesium.Cartesian3.fromDegreesArray([30, 10, 31, 11]) },
});
ds.entities.add({
rectangle: {
coordinates: Cesium.Rectangle.fromDegrees(40, 0, 41, 1),
rotation: Math.PI / 4,
},
});
const points = dataSourcePoints(ds);
assert.ok(points.some(([lon]) => Math.abs(lon - 20) < 1e-6));
assert.ok(points.some(([lon]) => Math.abs(lon - 31) < 1e-6));
assert.ok(points.some(([lon]) => lon > 41));
});
test('portrait framing accounts for FOV, high ground and symmetric extents', () => {
const ds = new Cesium.CustomDataSource('view');
ds.entities.add({
polyline: {
positions: Cesium.Cartesian3.fromDegreesArray([-105, 39, -104.9, 39]),
},
});
const calls = [];
const viewer = {
scene: { globe: { getHeight: () => 2600 } },
camera: {
frustum: { fov: Math.PI / 3, aspectRatio: 390 / 844 },
flyToBoundingSphere: (sphere, options) => calls.push({ sphere, options }),
},
};
flyToDataSource(viewer, ds);
assert.ok(
Math.abs(
Cesium.Cartographic.fromCartesian(calls[0].sphere.center).height - 2600,
) < 1e-6,
);
assert.equal(calls[0].options.offset.pitch, -Math.PI / 2);
viewer.camera.frustum.aspectRatio = 16 / 9;
flyToDataSource(viewer, ds);
assert.ok(calls[0].options.offset.range > calls[1].options.offset.range);
const symmetric = {
entities: {
values: [
{ position: { getValue: () => new Cesium.Cartesian3(6378137, 0, 0) } },
{ position: { getValue: () => new Cesium.Cartesian3(-6378137, 0, 0) } },
],
},
};
assert.doesNotThrow(() => flyToDataSource(viewer, symmetric));
assert.ok(Number.isFinite(calls.at(-1).options.offset.range));
assert.doesNotThrow(() => calls.at(-1).options.complete());
cancelCameraArrival(viewer);
});
test('non-finite positions and hidden features never enter the camera footprint', () => {
const position = (p) => ({ position: { getValue: () => p } });
const points = dataSourcePoints({
entities: {
values: [
position(new Cesium.Cartesian3(NaN, 1, 2)),
{
...position(Cesium.Cartesian3.fromDegrees(10, 10)),
isShowing: false,
},
position(Cesium.Cartesian3.fromDegrees(20, 20)),
],
},
});
assert.equal(points.length, 1);
assert.ok(Math.abs(points[0][0] - 20) < 1e-6);
});
+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)}`;
+499
View File
@@ -0,0 +1,499 @@
/** Session-local file controls, serial loading and application-owned navigation. */
import {
GEO_FILE_ACCEPT,
GeoImportError,
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.
const COLORS = ['#39d0ff', '#ffb547', '#5dff9f', '#ff6b6b', '#8be9ff'];
/**
* Wire the Import control.
* @param {{viewer: object, load?: Function, document?: Document}} deps
* `load(file, {viewer, color})` defaults to `loadGeoFile`; tests pass a double.
* @returns {{destroy: Function, importFiles: Function, list: Function,
* setVisible: Function, flyTo: Function, remove: Function,
* diagnostics: Function}|null}
*/
export function initGeoFileImport({
viewer,
load = loadGeoFile,
document: doc = globalThis.document,
flyToSource = flyToDataSource,
navigation = null,
ground = null,
subscribeMapSource = null,
}) {
const button = doc?.getElementById('geo-import-button');
const input = doc?.getElementById('geo-import-input');
const listEl = doc?.getElementById('geo-import-list');
const hint = doc?.getElementById('geo-import-hint');
if (!viewer || !button || !input || !listEl) return null;
const imports = [];
const listeners = [];
let queue = Promise.resolve();
let seq = 0;
let colorAt = 0;
let destroyed = false;
let dragDepth = 0;
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;
target.addEventListener(type, listener, options);
listeners.push([target, type, listener, options]);
};
const setHint = (text) => {
if (hint) hint.textContent = text || '';
};
const render = () => viewer.scene?.requestRender?.();
let groundTimer,
groundQueued = false,
groundAgain = false;
const groundRemovers = [];
const scheduleGround = () => {
if (destroyed || !imports.length) return;
if (groundQueued) {
groundAgain = true;
return;
}
clearTimeout(groundTimer);
groundTimer = setTimeout(() => {
groundQueued = true;
queue = queue
.catch(() => {})
.then(async () => {
try {
for (const item of imports) {
if (destroyed) return;
if (item.visible) await item.refreshGround?.(lifetime.signal);
}
render();
} catch {
if (!destroyed)
setHint(
t(
'Ground heights could not be refreshed. Try the file again when the map has loaded.',
),
);
} finally {
groundQueued = false;
if (groundAgain) {
groundAgain = false;
scheduleGround();
}
}
});
}, 200);
};
removeMapSource = subscribeMapSource?.(scheduleGround);
const ownEvent = (event, listener) => {
const remove = event?.addEventListener?.(listener);
if (remove) groundRemovers.push(remove);
};
ownEvent(viewer.camera?.moveEnd, scheduleGround);
ownEvent(viewer.scene?.globe?.tileLoadProgressEvent, (pending) => {
if (!pending) scheduleGround();
});
/** Which row button has focus, so a re-render can give it back. */
function focusedAction() {
const active = doc.activeElement;
if (!active || !listEl.contains?.(active)) return null;
const row = active.closest?.('[data-import-id]');
return row
? { id: row.dataset.importId, action: active.dataset?.action }
: null;
}
function restoreFocus(was) {
if (!was) return;
const rows = [...listEl.querySelectorAll('[data-import-id]')];
let row = rows.find((r) => r.dataset.importId === was.id);
// The row is gone (removed): the next row's same control, else the
// previous row's, else the Import button.
if (!row && was.index != null)
row = rows[Math.min(was.index, rows.length - 1)];
const target =
row?.querySelector?.(`button[data-action="${was.action}"]`) ||
(row ? row.querySelector?.('button') : button);
target?.focus?.();
}
function renderList(keep = focusedAction()) {
listEl.replaceChildren(
...imports.map((item) => {
const row = doc.createElement('li');
row.className = 'geo-import-row';
row.dataset.importId = item.id;
const swatch = doc.createElement('span');
// KML and KMZ draw with their own styles; their swatch says so
// instead of claiming a color the globe does not show.
swatch.className = `geo-import-swatch${item.ownStyles ? ' own-styles' : ''}`;
if (!item.ownStyles) swatch.style.background = item.color;
const name = doc.createElement('span');
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()}, ${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
? t('Hide {name}', { name: item.name })
: t('Show {name}', { name: item.name }),
'toggle',
],
['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';
btn.className = 'geo-import-action';
btn.dataset.action = action;
btn.title = label;
btn.setAttribute('aria-label', label);
if (action === 'toggle')
btn.setAttribute('aria-pressed', String(item.visible));
const glyph = doc.createElement('span');
glyph.className = 'material-symbols-outlined';
glyph.setAttribute('aria-hidden', 'true');
glyph.textContent = icon;
btn.append(glyph);
return btn;
});
row.append(swatch, name, ...actions);
return row;
}),
);
listEl.classList.toggle('visible', imports.length > 0);
restoreFocus(keep);
}
const find = (id) => imports.find((item) => item.id === id) || null;
/** Show or hide one imported file. */
function setVisible(id, visible) {
const item = find(id);
if (!item) return false;
item.visible = Boolean(visible);
item.dataSource.show = item.visible;
if (item.visible) scheduleGround();
renderList();
render();
return true;
}
// Not viewer.flyTo: it frames ground-clamped points underground until the
// destination's terrain has loaded (see ./flyToDataSource.js).
const fly = (item) => {
if (!item.visible) setVisible(item.id, true);
flyToSource(viewer, item.dataSource, { ground });
return true;
};
/**
* Fly the camera to one imported file: an immediate navigation, so Cockpit
* can refuse it and the current follow/track owner is released first.
*/
function flyTo(id) {
const item = find(id);
if (!item) return false;
if (typeof navigation?.runImmediateNavigation === 'function')
return (
navigation.runImmediateNavigation('file', () => fly(item)) !== false
);
return fly(item);
}
/** Stamp a deferred fly for an import batch; false when refused. */
const beginAutoFly = () =>
typeof navigation?.beginDeferredLocationNavigation === 'function'
? navigation.beginDeferredLocationNavigation()
: null;
/** Re-assert the stamp right before flying; false when superseded. */
const autoFlyStillOwns = (generation) => {
if (generation === false) return false;
if (generation === null) return true;
return typeof navigation?.reassertDeferredLocationNavigation === 'function'
? navigation.reassertDeferredLocationNavigation(generation) === true
: true;
};
/** Remove one imported file from the globe and the list. */
function remove(id) {
const at = imports.findIndex((item) => item.id === id);
if (at < 0) return false;
const keep = focusedAction();
if (keep) keep.index = at;
const [item] = imports.splice(at, 1);
viewer.dataSources?.remove?.(item.dataSource, true);
item.revoke?.();
renderList(keep);
render();
return true;
}
/**
* Import files, one after another. Returns one outcome per file.
* @param {Iterable<File>} files
* @returns {Promise<Array<{name: string, ok: boolean, id?: string,
* removed?: number, error?: string}>>}
*/
function importFiles(files) {
const list = [...(files || [])];
if (destroyed || !list.length) return Promise.resolve([]);
// Capture the user's intent when the files are chosen, including time
// spent waiting for an earlier batch. Later navigation must still win.
const generation = beginAutoFly();
const intent = ++cameraIntent;
const run = queue.then(() => importBatch(list, generation, intent));
queue = run.catch(() => {});
return run;
}
async function importBatch(list, generation, intent) {
const outcomes = [];
if (destroyed || !list.length) return outcomes;
for (const file of list) {
if (destroyed) break;
const fileName = String(file?.name || 'file');
if (imports.length >= MAX_IMPORTED_FILES) {
outcomes.push({
name: fileName,
ok: false,
error: t('At most {count} files at once — remove one first.', {
count: formatNumber(MAX_IMPORTED_FILES),
}),
});
continue;
}
setHint(t('Reading {name}…', { name: fileName }));
const color = COLORS[colorAt % COLORS.length];
let loaded;
try {
loaded = await load(file, { viewer, color, signal: lifetime.signal });
} catch (error) {
outcomes.push({
name: fileName,
ok: false,
error:
error instanceof GeoImportError || error?.name === 'GeoImportError'
? t(error.template || error.message, error.values)
: t('That file could not be read.'),
});
continue;
}
if (destroyed) {
loaded.revoke?.();
break;
}
colorAt += 1;
seq += 1;
const item = {
id: `import-${seq}`,
name: loaded.name || fileName,
format: loaded.format,
ownStyles: loaded.format === 'kml' || loaded.format === 'kmz',
entityCount: loaded.entityCount,
removed: loaded.removed || 0,
color,
visible: true,
dataSource: loaded.dataSource,
revoke: loaded.revoke,
refreshGround: loaded.refreshGround,
};
await viewer.dataSources.add(item.dataSource);
if (destroyed) {
viewer.dataSources.remove?.(item.dataSource, true);
item.revoke?.();
break;
}
loaded.mount?.();
imports.push(item);
renderList();
render();
outcomes.push({
name: fileName,
ok: true,
id: item.id,
removed: item.removed,
});
// Fly to the first file of a batch that lands, so a drop shows its
// result, unless something else took the camera meanwhile.
if (
outcomes.filter((o) => o.ok).length === 1 &&
intent === cameraIntent &&
autoFlyStillOwns(generation)
)
fly(item);
}
if (!destroyed) setHint(summarize(outcomes));
return outcomes;
}
// ---- controls ----------------------------------------------------------
input.accept = GEO_FILE_ACCEPT;
listen(button, 'click', () => input.click());
listen(input, 'change', () => {
const files = [...(input.files || [])];
// Clear the selection so choosing the same file again fires `change`.
input.value = '';
if (files.length) void importFiles(files);
});
listen(listEl, 'click', (event) => {
const btn = event.target?.closest?.('button[data-action]');
const id = btn?.closest?.('[data-import-id]')?.dataset.importId;
if (!btn || !id) return;
const action = btn.dataset.action;
if (action === 'toggle') setVisible(id, !find(id)?.visible);
else if (action === 'fly') flyTo(id);
else if (action === 'remove') remove(id);
});
// ---- drop onto the globe ----------------------------------------------
const container = viewer.container;
const gesture = () => {
cameraIntent += 1;
};
listen(viewer.scene?.canvas || container, 'pointerdown', gesture, {
passive: true,
});
listen(viewer.scene?.canvas || container, 'wheel', gesture, {
passive: true,
});
const hasFiles = (event) =>
[...(event.dataTransfer?.types || [])].includes('Files');
const setDragging = (on) =>
doc.body?.classList?.toggle('gev-geo-import-drag', on);
listen(container, 'dragenter', (event) => {
if (!hasFiles(event)) return;
event.preventDefault();
dragDepth += 1;
setDragging(true);
});
listen(doc, 'dragover', (event) => {
if (!hasFiles(event)) return;
event.preventDefault();
if (event.dataTransfer) event.dataTransfer.dropEffect = 'copy';
});
listen(container, 'dragleave', (event) => {
if (!hasFiles(event)) return;
dragDepth = Math.max(0, dragDepth - 1);
if (!dragDepth) setDragging(false);
});
listen(doc, 'drop', (event) => {
if (!hasFiles(event)) return;
event.preventDefault();
dragDepth = 0;
setDragging(false);
const files = [...(event.dataTransfer?.files || [])];
if (files.length) void importFiles(files);
});
renderList();
const handle = {
importFiles,
list: () =>
imports.map(({ id, name, format, entityCount, removed, visible }) => ({
id,
name,
format,
entityCount,
removed,
visible,
})),
setVisible,
flyTo,
remove,
diagnostics: () => ({
files: imports.length,
entities: imports.reduce((n, item) => n + item.entityCount, 0),
listeners: listeners.length,
destroyed,
}),
destroy() {
if (destroyed) return;
destroyed = true;
unregisterUnsavedWork();
lifetime.abort();
removeMapSource?.();
clearTimeout(groundTimer);
for (const remove of groundRemovers) remove();
for (const item of imports.splice(0)) {
viewer.dataSources?.remove?.(item.dataSource, true);
item.revoke?.();
}
for (const [target, type, listener, options] of listeners.splice(0))
target.removeEventListener(type, listener, options);
setDragging(false);
listEl.replaceChildren();
listEl.classList.remove('visible');
setHint('');
if (globalThis.window?.__gevGeoImport === handle)
delete globalThis.window.__gevGeoImport;
},
};
if (globalThis.window) globalThis.window.__gevGeoImport = handle;
return handle;
}
/**
* One line for the hint after an import batch.
* @param {Array<{name: string, ok: boolean, error?: string, removed?: number}>} outcomes
* @returns {string}
*/
export function summarize(outcomes) {
const failed = outcomes.filter((o) => !o.ok);
const added = outcomes.length - failed.length;
const parts = [];
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(
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(t('Some KML content was left out.'));
return parts.join(' ');
}
+731
View File
@@ -0,0 +1,731 @@
// Behavioral tests for DISPLAY ▸ Import: the controller is driven against
// fake DOM and viewer doubles with an injected loader, and its observable
// effects are asserted — data sources added and removed, list rows, the hint,
// drop handling, the file limit, and what destroy() gives back. Real parsing
// and rendering run in `scripts/qa-geo-import.mjs`.
import test from 'node:test';
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() {
this.names = new Set();
}
add(...n) {
n.forEach((x) => this.names.add(x));
}
remove(...n) {
n.forEach((x) => this.names.delete(x));
}
contains(n) {
return this.names.has(n);
}
toggle(n, force) {
const on = force === undefined ? !this.names.has(n) : Boolean(force);
if (on) this.names.add(n);
else this.names.delete(n);
return on;
}
}
// The one focused element across every fake document (tests run one at a time).
const focus = { active: null };
class FakeElement {
constructor(tagName = 'div', id = '') {
this.tagName = tagName.toUpperCase();
this.id = id;
this.classList = new FakeClassList();
this.dataset = {};
this.style = {};
this.attributes = new Map();
this.children = [];
this.parent = null;
this.listeners = [];
this.textContent = '';
this.value = '';
this.clicks = 0;
}
set className(v) {
this.classList = new FakeClassList();
String(v)
.split(/\s+/)
.filter(Boolean)
.forEach((n) => this.classList.add(n));
}
setAttribute(n, v) {
this.attributes.set(n, String(v));
}
getAttribute(n) {
return this.attributes.get(n) ?? null;
}
append(...kids) {
for (const k of kids) {
k.parent = this;
this.children.push(k);
}
}
replaceChildren(...kids) {
this.children = [];
this.append(...kids);
}
addEventListener(type, listener, options) {
this.listeners.push({ type, listener, options });
}
removeEventListener(type, listener) {
const at = this.listeners.findIndex(
(l) => l.type === type && l.listener === listener,
);
if (at >= 0) this.listeners.splice(at, 1);
}
emit(type, event = {}) {
for (const { listener } of this.listeners.filter((l) => l.type === type))
listener({
target: this,
preventDefault() {
this.prevented = true;
},
...event,
});
}
click() {
this.clicks += 1;
this.emit('click');
}
focus() {
focus.active = this;
}
contains(el) {
for (let at = el; at; at = at.parent) if (at === this) return true;
return false;
}
*walk() {
for (const kid of this.children) {
yield kid;
yield* kid.walk();
}
}
querySelectorAll(selector) {
if (selector !== '[data-import-id]') throw new Error(`fake: ${selector}`);
return [...this.walk()].filter((el) => el.dataset.importId);
}
querySelector(selector) {
const exact = /^button\[data-action="([a-z]+)"\]$/.exec(selector);
for (const el of this.walk()) {
if (el.tagName !== 'BUTTON') continue;
if (selector === 'button' || (exact && el.dataset.action === exact[1]))
return el;
}
return null;
}
closest(selector) {
for (let el = this; el; el = el.parent) {
if (
selector === 'button[data-action]' &&
el.tagName === 'BUTTON' &&
el.dataset.action
)
return el;
if (selector === '[data-import-id]' && el.dataset.importId) return el;
}
return null;
}
}
function setup({ load, navigation, flyToSource, subscribeMapSource } = {}) {
focus.active = null;
const els = {
'geo-import-button': new FakeElement('button', 'geo-import-button'),
'geo-import-input': new FakeElement('input', 'geo-import-input'),
'geo-import-list': new FakeElement('ul', 'geo-import-list'),
'geo-import-hint': new FakeElement('span', 'geo-import-hint'),
};
const document = {
body: new FakeElement('body'),
getElementById: (id) => els[id] ?? null,
createElement: (tag) => new FakeElement(tag),
get activeElement() {
return focus.active;
},
};
Object.setPrototypeOf(document, new FakeElement('document'));
const added = [];
const removed = [];
const flights = [];
let renders = 0;
const viewer = {
container: new FakeElement('div', 'cesiumContainer'),
scene: { requestRender: () => (renders += 1) },
dataSources: {
add: async (ds) => {
added.push(ds);
return ds;
},
remove: (ds, destroy) => {
removed.push({ ds, destroy });
return true;
},
},
flyTo: (target) => flights.push(target),
};
const revoked = [];
const loads = [];
const defaultLoad = async (file, opts) => {
loads.push({ file, opts });
if (file.fail) throw new GeoImportError(file.fail);
if (file.crash) throw new Error('boom');
return {
dataSource: { show: true },
name: file.name,
format: 'geojson',
entityCount: 3,
removed: file.removed || 0,
revoke: () => revoked.push(file.name),
};
};
const tool = initGeoFileImport({
viewer,
load: load || defaultLoad,
document,
...(navigation ? { navigation } : {}),
...(subscribeMapSource ? { subscribeMapSource } : {}),
flyToSource: flyToSource || ((_viewer, ds) => flights.push(ds)),
});
return {
tool,
els,
document,
viewer,
added,
removed,
flights,
revoked,
loads,
renders: () => renders,
};
}
const file = (name, extra = {}) => ({ name, size: 10, ...extra });
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);
assert.equal(
initGeoFileImport({
viewer: null,
document: { getElementById: () => new FakeElement() },
}),
null,
);
});
test('the Import button opens the picker and a pick imports each file', async () => {
const t = setup();
t.els['geo-import-button'].click();
assert.equal(t.els['geo-import-input'].clicks, 1);
assert.equal(
t.els['geo-import-input'].accept,
'.geojson,.json,.kml,.kmz,.gpx',
);
const out = await t.tool.importFiles([file('a.geojson'), file('b.kml')]);
assert.deepEqual(
out.map((o) => o.ok),
[true, true],
);
assert.equal(t.added.length, 2);
assert.notEqual(
t.loads[0].opts.color,
t.loads[1].opts.color,
'each file gets its own color',
);
assert.equal(rows(t.els).length, 2);
assert.ok(t.els['geo-import-list'].classList.contains('visible'));
assert.equal(t.flights.length, 1, 'the first file of a batch is flown to');
assert.equal(t.els['geo-import-hint'].textContent, 'Added 2 files.');
assert.deepEqual(
t.tool.list().map((f) => f.name),
['a.geojson', 'b.kml'],
);
});
test('file names are text, never markup', async () => {
const t = setup();
await t.tool.importFiles([file('<img src=x onerror=alert(1)>.kml')]);
const name = rows(t.els)[0].children.find((c) =>
c.classList.contains('geo-import-name'),
);
assert.equal(name.textContent, '<img src=x onerror=alert(1)>.kml');
assert.equal(name.innerHTML, undefined);
});
test('row buttons hide, show, fly to and remove a file', async () => {
const t = setup();
await t.tool.importFiles([file('a.gpx')]);
const [{ id }] = t.tool.list();
const ds = t.added[0];
// the click lands on the glyph inside the button, as in a browser
const glyphClick = (name) =>
t.els['geo-import-list'].emit('click', {
target: action(rows(t.els)[0], name).children[0],
});
assert.equal(ds.show, true);
glyphClick('toggle');
assert.equal(ds.show, false);
assert.equal(t.tool.list()[0].visible, false);
assert.equal(
action(rows(t.els)[0], 'toggle').getAttribute('aria-pressed'),
'false',
);
glyphClick('fly');
assert.equal(ds.show, true, 'flying to a hidden file shows it');
assert.equal(t.flights.at(-1), ds);
glyphClick('remove');
assert.deepEqual(t.removed, [{ ds, destroy: true }]);
assert.deepEqual(t.revoked, ['a.gpx']);
assert.equal(t.tool.list().length, 0);
assert.ok(!t.els['geo-import-list'].classList.contains('visible'));
assert.equal(t.tool.remove(id), false);
});
test('a refused file says why and does not stop the rest of the batch', async () => {
const t = setup();
const out = await t.tool.importFiles([
file('bad.kml', { fail: 'That KML file is not valid XML.' }),
file('odd.gpx', { crash: true }),
file('good.geojson'),
]);
assert.deepEqual(
out.map((o) => o.ok),
[false, false, true],
);
assert.equal(out[0].error, 'That KML file is not valid XML.');
assert.match(out[1].error, /could not be read\./);
assert.match(
t.els['geo-import-hint'].textContent,
/^Added 1 file\. 2 files could not be added \(bad\.kml: That KML file is not valid XML\.\)/,
);
assert.equal(t.tool.list().length, 1);
});
test('the number of files on the globe is bounded', async () => {
const t = setup();
const many = Array.from({ length: MAX_IMPORTED_FILES + 2 }, (_, i) =>
file(`f${i}.geojson`),
);
const out = await t.tool.importFiles(many);
assert.equal(out.filter((o) => o.ok).length, MAX_IMPORTED_FILES);
assert.match(out.at(-1).error, /At most 12 files/);
assert.equal(
t.loads.length,
MAX_IMPORTED_FILES,
'files over the limit are not even read',
);
});
test('dropping files on the globe imports them; other drags are left alone', async () => {
const t = setup();
const c = t.viewer.container;
const files = { types: ['Files'], files: [file('drop.kmz')] };
let ev = { dataTransfer: { types: ['text/plain'] } };
t.document.emit('dragover', ev);
c.emit('dragenter', { dataTransfer: files });
assert.ok(t.document.body.classList.contains('gev-geo-import-drag'));
c.emit('dragleave', { dataTransfer: files });
assert.ok(!t.document.body.classList.contains('gev-geo-import-drag'));
c.emit('dragenter', { dataTransfer: files });
t.document.emit('drop', { dataTransfer: files });
assert.ok(!t.document.body.classList.contains('gev-geo-import-drag'));
await new Promise((r) => setImmediate(r));
assert.deepEqual(
t.tool.list().map((f) => f.name),
['drop.kmz'],
);
});
test('stripped KML content is mentioned once, for the batch that had it', () => {
assert.equal(
summarize([{ name: 'a.kml', ok: true, removed: 3 }]),
'Added 1 file. Some KML content was left out.',
);
assert.equal(
summarize([{ name: 'b.geojson', ok: true, removed: 0 }]),
'Added 1 file.',
);
});
test('destroy removes every file and listener and the window handle', async () => {
globalThis.window = {};
try {
const t = setup();
assert.equal(globalThis.window.__gevGeoImport, t.tool);
await t.tool.importFiles([file('a.geojson'), file('b.geojson')]);
t.tool.destroy();
assert.equal(t.removed.length, 2);
assert.deepEqual(t.revoked.sort(), ['a.geojson', 'b.geojson']);
for (const el of [
t.viewer.container,
t.els['geo-import-button'],
t.els['geo-import-input'],
t.els['geo-import-list'],
])
assert.equal(el.listeners.length, 0);
assert.equal(rows(t.els).length, 0);
assert.equal(globalThis.window.__gevGeoImport, undefined);
assert.equal(t.tool.diagnostics().destroyed, true);
} finally {
delete globalThis.window;
}
});
test('a file that finishes loading after destroy is released, not added', async () => {
let release;
const gate = new Promise((r) => (release = r));
const revoked = [];
const t = setup({
load: async () => {
await gate;
return {
dataSource: {},
name: 'late',
format: 'kml',
entityCount: 1,
removed: 0,
revoke: () => revoked.push('late'),
};
},
});
const pending = t.tool.importFiles([file('late.kml')]);
// Imports are queued: let the batch start loading before destroy lands.
await new Promise((r) => setImmediate(r));
t.tool.destroy();
release();
await pending;
assert.equal(t.added.length, 0);
assert.deepEqual(revoked, ['late']);
});
test('the application composition wires the import and owns its teardown', () => {
const tools = fs.readFileSync(
new URL('../app/tools.js', import.meta.url),
'utf8',
);
const drawAt = tools.indexOf(
'const drawTool = initDrawTool({ viewer, annotations });',
);
const importAt = tools.indexOf('const geoFileImport = initGeoFileImport({');
assert.ok(importAt > drawAt && drawAt >= 0);
// Fly-to goes through the application shell's navigation policy.
assert.match(
tools.slice(importAt, importAt + 200),
/navigation: styleManager/,
);
assert.ok(tools.indexOf('defer(() => geoFileImport?.destroy());') > importAt);
const markup = fs.readFileSync(
new URL('./templates/display-controls.html', import.meta.url),
'utf8',
);
for (const id of [
'geo-import-button',
'geo-import-input',
'geo-import-list',
'geo-import-hint',
])
assert.ok(
markup.includes(`id="${id}"`),
`${id} missing from the DISPLAY template`,
);
});
test('two imports at once never pass the file cap: batches run one after another', async () => {
const t = setup();
const first = t.tool.importFiles(
Array.from({ length: 8 }, (_, i) => file(`a${i}.geojson`)),
);
const second = t.tool.importFiles(
Array.from({ length: 8 }, (_, i) => file(`b${i}.geojson`)),
);
const [a, b] = await Promise.all([first, second]);
assert.equal(t.tool.list().length, MAX_IMPORTED_FILES);
assert.equal(a.filter((o) => o.ok).length, 8);
assert.equal(b.filter((o) => o.ok).length, MAX_IMPORTED_FILES - 8);
assert.equal(b.filter((o) => !o.ok).length, 16 - MAX_IMPORTED_FILES);
// The second batch's loads started only after the first batch finished.
assert.deepEqual(
t.loads.map((l) => l.file.name.slice(0, 1)).join(''),
'a'.repeat(8) + 'b'.repeat(MAX_IMPORTED_FILES - 8),
);
});
function navigationDouble({ immediate = true, reassert = true } = {}) {
const calls = [];
let generation = 0;
return {
calls,
runImmediateNavigation(noun, navigate) {
calls.push(['immediate', noun]);
return immediate ? navigate(++generation) : false;
},
beginDeferredLocationNavigation() {
calls.push(['begin']);
return ++generation;
},
reassertDeferredLocationNavigation(g) {
calls.push(['reassert', g]);
return reassert;
},
};
}
test('a direct camera gesture cancels the fly-to of a file still being read', async () => {
for (const gesture of ['pointerdown', 'wheel']) {
let release;
const t = setup({
load: async () => {
await new Promise((r) => (release = r));
return {
name: 'slow.geojson',
format: 'geojson',
entityCount: 1,
dataSource: {},
};
},
});
const result = t.tool.importFiles([file('slow.geojson')]);
await new Promise((r) => setImmediate(r));
t.viewer.container.emit(gesture);
release();
await result;
assert.equal(t.added.length, 1);
assert.equal(t.flights.length, 0);
t.tool.destroy();
assert.equal(t.document.listeners.length, 0);
}
});
test('dropping on panel controls is accepted and file drags prevent browser navigation', async () => {
const t = setup();
let prevented = 0;
const event = {
target: t.els['geo-import-button'],
dataTransfer: { types: ['Files'], files: [file('panel.kml')] },
preventDefault: () => prevented++,
};
t.document.emit('dragover', event);
t.document.emit('drop', event);
await new Promise((r) => setImmediate(r));
assert.equal(prevented, 2);
assert.equal(t.tool.list()[0].name, 'panel.kml');
t.tool.destroy();
assert.equal(t.document.listeners.length, 0);
});
test('ground refresh coalesces map changes and releases its subscription on teardown', async () => {
let changed,
unsubscribed = 0,
refreshed = 0;
const t = setup({
subscribeMapSource: (listener) => {
changed = listener;
return () => unsubscribed++;
},
load: async () => ({
name: 'point.geojson',
format: 'geojson',
entityCount: 1,
dataSource: {},
refreshGround: async () => refreshed++,
}),
});
await t.tool.importFiles([file('point.geojson')]);
changed();
changed();
changed();
await new Promise((resolve) => setTimeout(resolve, 300));
assert.equal(refreshed, 1);
t.tool.destroy();
changed();
await new Promise((resolve) => setTimeout(resolve, 250));
assert.equal(refreshed, 1);
assert.equal(unsubscribed, 1);
});
test('Fly to is an immediate navigation that Cockpit can refuse', async () => {
const flown = [];
const nav = navigationDouble({ immediate: false });
const t = setup({ navigation: nav, flyToSource: (v, ds) => flown.push(ds) });
// Batch auto-fly is deferred: stamped at the start, re-asserted before flying.
await t.tool.importFiles([file('a.geojson')]);
assert.deepEqual(nav.calls, [['begin'], ['reassert', 1]]);
assert.equal(flown.length, 1);
// The button goes through the immediate route; refused, nothing flies.
const id = t.tool.list()[0].id;
assert.equal(t.tool.flyTo(id), false);
assert.deepEqual(nav.calls.at(-1), ['immediate', 'file']);
assert.equal(flown.length, 1);
const nav2 = navigationDouble();
const t2 = setup({
navigation: nav2,
flyToSource: (v, ds) => flown.push(ds),
});
await t2.tool.importFiles([file('b.geojson')]);
assert.equal(t2.tool.flyTo(t2.tool.list()[0].id), true);
assert.equal(flown.length, 3);
});
test('a slow import does not fly once something newer took the camera', async () => {
let release;
const gate = new Promise((r) => (release = r));
const flown = [];
const nav = navigationDouble({ reassert: false });
const t = setup({
navigation: nav,
flyToSource: (v, ds) => flown.push(ds),
load: async (f) => {
await gate;
return {
dataSource: {},
name: f.name,
format: 'geojson',
entityCount: 1,
removed: 0,
revoke() {},
};
},
});
const pending = t.tool.importFiles([file('slow.geojson')]);
release();
const [out] = await pending;
assert.equal(out.ok, true);
assert.deepEqual(nav.calls, [['begin'], ['reassert', 1]]);
assert.equal(flown.length, 0);
});
test('a queued import cannot fly over navigation that happened while it waited', async () => {
let release;
const gate = new Promise((resolve) => {
release = resolve;
});
let generation = 0;
const flown = [];
const nav = {
beginDeferredLocationNavigation: () => ++generation,
reassertDeferredLocationNavigation: (stamp) => stamp === generation,
};
const t = setup({
navigation: nav,
flyToSource: (v, ds) => flown.push(ds),
load: async (f) => {
if (f.name === 'first.geojson') await gate;
return {
dataSource: {},
name: f.name,
format: 'geojson',
entityCount: 1,
revoke() {},
};
},
});
const first = t.tool.importFiles([file('first.geojson')]);
await new Promise((resolve) => setImmediate(resolve));
const queued = t.tool.importFiles([file('queued.geojson')]);
generation++; // A newer user camera action while the second batch is waiting.
release();
const results = await Promise.all([first, queued]);
assert.ok(results.every(([outcome]) => outcome.ok));
assert.equal(t.tool.list().length, 2);
assert.equal(flown.length, 0);
});
test('a refused deferred stamp (Cockpit) imports without flying', async () => {
const flown = [];
const nav = navigationDouble();
nav.beginDeferredLocationNavigation = () => false;
nav.reassertDeferredLocationNavigation = () => {
throw new Error('must not re-assert a refused stamp');
};
const t = setup({ navigation: nav, flyToSource: (v, ds) => flown.push(ds) });
const [out] = await t.tool.importFiles([file('a.geojson')]);
assert.equal(out.ok, true);
assert.equal(flown.length, 0);
});
test('keyboard focus stays on the control after hide, show and remove', async () => {
const t = setup();
await t.tool.importFiles([file('a.geojson'), file('b.geojson')]);
// Clicks reach the list's delegated listener, as they bubble in a page.
const press = (btn) =>
t.els['geo-import-list'].emit('click', { target: btn });
const [first] = rows(t.els);
action(first, 'toggle').focus();
press(action(first, 'toggle'));
// The list was rebuilt; focus is on the NEW toggle of the same row.
const again = rows(t.els)[0];
assert.notEqual(again, first);
assert.equal(t.document.activeElement, action(again, 'toggle'));
assert.equal(action(again, 'toggle').getAttribute('aria-pressed'), 'false');
press(action(again, 'toggle'));
assert.equal(t.document.activeElement, action(rows(t.els)[0], 'toggle'));
// Remove the first row: focus moves to the same control on the next row.
action(rows(t.els)[0], 'remove').focus();
press(action(rows(t.els)[0], 'remove'));
assert.equal(rows(t.els).length, 1);
assert.equal(t.document.activeElement, action(rows(t.els)[0], 'remove'));
// Remove the last row: focus goes back to the Import button.
press(action(rows(t.els)[0], 'remove'));
assert.equal(t.document.activeElement, t.els['geo-import-button']);
});
test('KML and KMZ rows say they keep their own styles instead of showing a color', async () => {
const t = setup({
load: async (f) => ({
dataSource: {},
name: f.name,
format: f.name.endsWith('.kml') ? 'kml' : 'gpx',
entityCount: 2,
removed: 0,
revoke() {},
}),
});
await t.tool.importFiles([file('a.kml'), file('b.gpx')]);
const [kml, gpx] = rows(t.els);
assert.ok(kml.children[0].classList.contains('own-styles'));
assert.equal(kml.children[0].style.background, undefined);
assert.match(kml.children[1].title, /drawn with its own styles/);
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');
}
});
}
+237 -78
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,12 +66,19 @@ const PANEL_GROUPS = [
],
},
{
label: 'Events',
ids: ['rocket-launches', 'earthquakes', 'local-firms', 'fire-perimeters'],
label: msg('Events'),
ids: [
'rocket-launches',
'earthquakes',
'local-firms',
'fire-perimeters',
'gnss-integrity',
],
},
{
label: 'Weather',
label: msg('Weather'),
ids: [
'air-quality',
'wind',
'weather-radar',
'weather-satellite',
@@ -62,7 +87,7 @@ const PANEL_GROUPS = [
],
},
{
label: 'Utilities',
label: msg('Utilities'),
ids: ['directions', 'radio'],
},
];
@@ -72,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;
}
/**
@@ -104,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({
@@ -211,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';
@@ -234,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);
@@ -243,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';
@@ -282,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);
@@ -375,6 +460,7 @@ export class LayerPanel {
'weather-satellite',
'weather-lightning',
'weather-cyclones',
'air-quality',
].includes(layer.id),
)
.map((layer) => ({
@@ -385,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())
@@ -508,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(
@@ -528,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.
@@ -553,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;
@@ -577,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) {
@@ -610,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,
@@ -630,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) });
}
}
+100 -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),
@@ -258,3 +260,100 @@ test('an omitted source is visibly unavailable and cannot be toggled on', async
'an active layer can always be switched off',
);
});
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) => ({
id,
icon: '*',
enabled: true,
}));
layers.push({ id: 'weather-radar', icon: '*', enabled: false });
const panel = new LayerPanel({
getLayers: () => layers,
getRowControls: (id) => ({ readout: true, summary: { label: id } }),
});
let entries = null;
panel._weatherPanel = {
update: (next) => {
entries = next;
},
};
panel._refreshWeatherPanel();
assert.deepEqual(
entries.map(({ id, summary }) => [id, summary.label]),
[
['wind', 'wind'],
['air-quality', 'air-quality'],
],
);
});
+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);
}
}
+32 -11
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';
@@ -324,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(
@@ -341,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(
@@ -538,6 +552,13 @@ export class PanelChrome {
this.cockpitView.setContextCollapsed(true);
}
}
if (
!nextCollapsed &&
panelId === 'global-context-panel' &&
this._contextRadioDock?.classList.contains('disclosure-open')
) {
this._setRadioDisclosure?.(false);
}
if (
!nextCollapsed &&
panelId === 'radio-panel' &&
@@ -131,3 +131,70 @@ test('adaptive rail restore hides compact Radio before revealing Context', (t) =
'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;
}
});
}
+117 -5
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;
@@ -30,6 +39,7 @@ export function createRailCardBlocks({ container, cardId, onParams }) {
};
const dispose = (block) => {
block.removeClick?.();
block.removeInput?.();
block.children?.destroy();
block.root.remove();
};
@@ -74,6 +84,66 @@ export function createRailCardBlocks({ container, cardId, onParams }) {
);
block.chips = make('div', 'rail-card-chips', root);
block.chips.setAttribute('role', 'group');
block.rangeRow = make('div', 'rail-card-setting-range-row', root);
block.rangeLabel = make(
'span',
'rail-card-setting-range-label',
block.rangeRow,
);
block.rangeInput = make(
'input',
'rail-card-setting-range',
block.rangeRow,
);
block.rangeInput.type = 'range';
block.rangeValue = make(
'span',
'rail-card-setting-range-value',
block.rangeRow,
);
const rangeParams = () => {
const range = block.props.range;
const value = Number(block.rangeInput.value);
if (
!range?.param ||
range.disabled ||
!Number.isFinite(value) ||
value < Number(range.min) ||
value > Number(range.max)
)
return null;
return { params: { [range.param]: value } };
};
const onInput = () => {
const range = block.props.range;
const value = Number(block.rangeInput.value);
const params = rangeParams();
if (!params) return;
if (range.commitOnChange) block.rangeDraft = { range, value };
set(
block.rangeValue,
'textContent',
`${value}${range.unit || ''}`,
);
attribute(
block.rangeInput,
'aria-valuetext',
`${value}${range.unit || ''}`,
);
if (!range.commitOnChange) dispatch(params);
};
const onChange = () => {
if (!block.props.range?.commitOnChange) return;
const params = rangeParams();
block.rangeDraft = null;
if (params) dispatch(params);
};
block.rangeInput.addEventListener('input', onInput);
block.rangeInput.addEventListener('change', onChange);
block.removeInput = () => {
block.rangeInput.removeEventListener('input', onInput);
block.rangeInput.removeEventListener('change', onChange);
};
bind((event) => {
const button = event.target?.closest?.('[data-chip-id]');
dispatch(
@@ -98,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);
});
@@ -124,17 +194,59 @@ export function createRailCardBlocks({ container, cardId, onParams }) {
blocks.set(props.id, block);
}
block.props = props;
if (props.type === 'settings')
if (props.type === 'settings') {
block.children.update(
(props.settings || []).map((setting) => ({
...setting,
type: 'setting',
})),
);
else if (props.type === 'setting') {
} else if (props.type === 'setting') {
set(block.label, 'textContent', props.label);
attribute(block.chips, 'aria-label', props.label);
syncChipGroup(block.chips, props.chips);
const range = props.range;
set(block.rangeRow, 'hidden', !range);
const draft = block.rangeDraft;
if (
draft &&
(!range ||
range.disabled ||
!range.commitOnChange ||
['id', 'param', 'min', 'max', 'step', 'value'].some(
(key) => range[key] !== draft.range[key],
))
)
block.rangeDraft = null;
if (range) {
const min = Number(range.min);
const max = Number(range.max);
const step = Number(range.step);
const candidate = Number(block.rangeDraft?.value ?? range.value);
const value = Math.min(
max,
Math.max(min, Number.isFinite(candidate) ? candidate : min),
);
set(block.rangeLabel, 'textContent', range.label || '');
set(block.rangeLabel, 'hidden', !range.label);
set(block.rangeInput.dataset, 'rangeId', range.id || props.id);
set(block.rangeInput, 'min', String(min));
set(block.rangeInput, 'max', String(max));
set(block.rangeInput, 'step', String(step));
set(block.rangeInput, 'value', String(value));
set(block.rangeInput, 'disabled', Boolean(range.disabled));
attribute(
block.rangeInput,
'aria-label',
range.ariaLabel || props.label,
);
attribute(
block.rangeInput,
'aria-valuetext',
`${value}${range.unit || ''}`,
);
set(block.rangeValue, 'textContent', `${value}${range.unit || ''}`);
}
} else if (props.type === 'list') syncRowList(block.root, props.list);
else if (props.type === 'result') {
set(block.label, 'textContent', props.label);
@@ -197,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') {
+90
View File
@@ -119,6 +119,96 @@ test('footer buttons retain focus and current callbacks; links use safe new tabs
button.click();
assert.deepEqual(calls, [2]);
});
test('setting ranges stay below presets and commit expensive filters on release', () => {
const f = railFixture();
const calls = [];
const view = createRailCards({
...f,
onParams: (...args) => calls.push(args),
});
const model = (value) => ({
...card,
blocks: [
{
id: 'settings',
type: 'settings',
settings: [
{
id: 'image',
label: 'IMAGE',
chips: [
{
id: 'clouds-only',
label: 'Clouds only',
params: { infrared: 100 },
},
{ id: 'full', label: 'Full', params: { infrared: 0 } },
],
range: {
id: 'cloud-filter',
param: 'infrared',
min: 0,
max: 100,
step: 1,
value,
label: 'FILTER',
unit: '%',
ariaLabel: 'Cloud filter strength',
commitOnChange: true,
},
},
],
},
],
});
view.update([model(100)]);
const setting = f.find((node) => node.dataset.blockId === 'image');
assert.equal(setting.children[1].className, 'rail-card-chips');
assert.equal(setting.children[2].className, 'rail-card-setting-range-row');
assert.equal(setting.children[2].children[0].textContent, 'FILTER');
const slider = f.find((node) => node.dataset.rangeId === 'cloud-filter');
assert.equal(slider.tagName, 'INPUT');
assert.equal(slider.type, 'range');
assert.equal(slider.min, '0');
assert.equal(slider.max, '100');
assert.equal(slider.step, '1');
assert.equal(slider.value, '100');
assert.equal(slider.getAttribute('aria-label'), 'Cloud filter strength');
slider.focus();
slider.value = '53';
slider.dispatchEvent(new Event('input'));
assert.equal(calls.length, 0, 'expensive image work waits until release');
assert.equal(
f.find((node) => node.className === 'rail-card-setting-range-value')
.textContent,
'53%',
);
view.update([model(100)]);
assert.equal(slider.value, '53', 'a status refresh retains the pending drag');
assert.equal(
slider.getAttribute('aria-valuetext'),
'53%',
'the accessible preview also survives a refresh',
);
slider.dispatchEvent(new Event('change'));
assert.deepEqual(calls.at(-1), ['a', { infrared: 53 }]);
view.update([model(53)]);
assert.equal(slider.value, '53');
assert.equal(f.document.activeElement, slider);
slider.value = '41';
slider.dispatchEvent(new Event('input'));
view.update([model(70)]);
assert.equal(
slider.value,
'70',
'an authoritative value supersedes the draft',
);
view.destroy();
slider.value = '41';
slider.dispatchEvent(new Event('change'));
assert.equal(calls.length, 1, 'destroy removes the range listener');
});
test('legend validates colors, preserves the freezing anchor and ignores CSSOM normalization', () => {
let writes = 0;
const f = railFixture(() => writes++);
+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)}`,
);
});
}
+147 -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,9 @@ 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,
loading: street.loading === true && !street.imageId,
@@ -141,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',
),
},
};
}
@@ -167,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;
@@ -217,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,
+25 -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',
);
});
@@ -539,3 +541,25 @@ test('KEY REQUIRED points at Street View when it would draw with the Google key'
assert.equal(plain.status.text, 'KEY REQUIRED');
assert.notEqual(plain.meta, guidance);
});
test('the caption names an image’s own licence after its author, and leaves it out when there is none', () => {
const caption = (street) =>
presentStreetLevelPanel({
enabled: true,
providers: [],
coverage: {},
legend: [],
filter: {},
sequence: {},
street: { open: true, imageId: '1', ...street },
}).viewer.captionLeft;
assert.equal(
caption({ creator: 'Fixture Mapper', license: 'CC BY-SA 4.0' }),
'Image by Fixture Mapper · CC BY-SA 4.0',
);
assert.equal(
caption({ license: 'Licence Ouverte 2.0' }),
'Licence Ouverte 2.0',
);
assert.equal(caption({ creator: 'Someone' }), 'Image by Someone');
});
+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;
}
+42
View File
@@ -43,6 +43,8 @@
#cockpit-hud {
--cockpit-accent: #00dcff;
/* Matches EMERGENCY_TINT_CSS in src/data/aircraftEmergency.js. */
--cockpit-emergency: #ff4444;
--cockpit-side-panel-width: min(340px, 28vw);
/* HUD bottom offset + tallest scaled telemetry block + 20px clear border. */
--cockpit-bottom-rail-safe: calc(clamp(84px, 11vh, 118px) + 100px);
@@ -496,6 +498,10 @@ body.cockpit-mode:has(#left-panel-stack > .panel-collapsible:not(.collapsed))
font-size: 7px;
letter-spacing: 0.16em;
}
.cockpit-aircraft-meta.emergency {
color: var(--cockpit-emergency);
font-weight: 600;
}
.cockpit-mode-chip {
margin-top: 2px;
padding: 7px 11px;
@@ -569,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
@@ -1500,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;
@@ -1526,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;
@@ -1553,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(
@@ -1865,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 {
+125
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;
@@ -1008,6 +1043,80 @@ body.gev-drawing #cesiumContainer canvas {
cursor: crosshair;
}
/* DISPLAY ▸ Import: one row per imported file, and the drop-target outline */
.geo-import-list {
display: none;
flex-direction: column;
gap: 4px;
margin: 6px 0 0;
padding: 0;
list-style: none;
}
.geo-import-list.visible {
display: flex;
}
.geo-import-row {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.geo-import-swatch {
flex: 0 0 8px;
height: 8px;
border-radius: 2px;
}
/* KML/KMZ draw with the styles written in the file, so the swatch shows a
hatch instead of a color the globe does not use. */
.geo-import-swatch.own-styles {
background: repeating-linear-gradient(
45deg,
var(--text-dim) 0 1px,
transparent 1px 3px
);
box-shadow: inset 0 0 0 1px var(--text-dim);
}
.geo-import-name {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.6px;
color: var(--text-primary);
}
.geo-import-action {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
padding: 0;
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 6px;
color: var(--text-dim);
cursor: pointer;
}
.geo-import-action:hover,
.geo-import-action:focus-visible {
color: var(--text-primary);
border-color: var(--accent);
}
.geo-import-action .material-symbols-outlined {
font-size: 14px;
}
.geo-import-hint:empty {
display: none;
}
body.gev-geo-import-drag #cesiumContainer {
outline: 2px dashed var(--accent);
outline-offset: -6px;
}
/* Proximity / All segmented control under the 3D toggle */
#models3d-mode-row {
min-width: 184px;
@@ -1245,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;
+14 -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;
}
}
@@ -2270,6 +2274,12 @@
color: rgba(205, 225, 226, 0.48);
}
:root[data-ui-theme='cyber']
body.cockpit-mode
.cockpit-aircraft-meta.emergency {
color: var(--cyber-red-bright);
}
:root[data-ui-theme='cyber'] body.cockpit-mode .cockpit-vision-controls {
margin-top: 0;
border-color: rgba(105, 203, 209, 0.38);
@@ -2727,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);
+5
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'],
+46 -20
View File
@@ -7,6 +7,9 @@
/* KartaView's purple for lines; a lighter one where it is text (WCAG AA). */
--sl-kartaview: #635bff;
--sl-kartaview-text: #8f8aff;
/* Panoramax's orange; the darker one sits behind white text. */
--sl-panoramax: #ff6f00;
--sl-panoramax-fill: #b84e00;
--sl-text: 0.62rem;
--sl-text-small: 0.6rem;
}
@@ -213,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);
@@ -632,8 +648,11 @@ button.sl-status:focus-visible {
font-size: 1.3rem;
}
/* ── KartaView photo viewer ──────────────────────────────────────────────── */
.sl-kv-viewer {
/* ── Photo viewer (KartaView, Panoramax) ─────────────────────────────────── */
.sl-photo-viewer {
/* The lit source's colour: a focus ring, and behind the white ‹ › glyphs. */
--sl-photo-ring: var(--sl-kartaview-text);
--sl-photo-fill: color-mix(in srgb, var(--sl-kartaview) 85%, transparent);
position: absolute;
inset: 0;
background: #000;
@@ -641,23 +660,29 @@ button.sl-status:focus-visible {
overflow: hidden;
}
.sl-kv-viewer:focus-visible {
.sl-photo-viewer:focus-visible {
outline: none;
}
/* The photo and canvas fill the viewer and would cover a ring drawn on it:
draw the ring on a layer above them (clicks pass through). */
.sl-kv-viewer:focus-visible::after {
.sl-photo-viewer:focus-visible::after {
content: '';
position: absolute;
inset: 0;
z-index: 3;
border: 2px solid var(--sl-kartaview-text);
border: 2px solid var(--sl-photo-ring);
pointer-events: none;
}
.sl-kv-photo,
.sl-kv-pano {
/* White on #ff6f00 is 2.8:1; on the darker orange behind ‹ › it is 5.1:1. */
.sl-photo-viewer[data-provider='panoramax'] {
--sl-photo-ring: var(--sl-panoramax);
--sl-photo-fill: var(--sl-panoramax-fill);
}
.sl-photo-flat,
.sl-photo-pano {
position: absolute;
inset: 0;
width: 100%;
@@ -665,25 +690,25 @@ button.sl-status:focus-visible {
user-select: none;
}
.sl-kv-photo {
.sl-photo-flat {
object-fit: contain;
}
.sl-kv-pano {
.sl-photo-pano {
cursor: grab;
touch-action: none;
}
.sl-kv-pano.sl-kv-dragging {
.sl-photo-pano.sl-photo-dragging {
cursor: grabbing;
}
.sl-kv-photo[hidden],
.sl-kv-pano[hidden] {
.sl-photo-flat[hidden],
.sl-photo-pano[hidden] {
display: none;
}
.sl-kv-step {
.sl-photo-step {
position: absolute;
top: 50%;
z-index: 2;
@@ -699,27 +724,28 @@ button.sl-status:focus-visible {
cursor: pointer;
}
.sl-kv-step-prev {
.sl-photo-step-prev {
left: 6px;
}
.sl-kv-step-next {
.sl-photo-step-next {
right: 6px;
}
.sl-kv-step:hover:not(:disabled),
.sl-kv-step:focus-visible {
background: color-mix(in srgb, var(--sl-kartaview) 85%, transparent);
.sl-photo-step:hover:not(:disabled),
.sl-photo-step:focus-visible {
background: var(--sl-photo-fill);
outline: none;
}
.sl-kv-step:disabled {
.sl-photo-step:disabled {
opacity: 0.3;
cursor: default;
}
/* Nothing to step through until a photo shows. */
.sl-kv-viewer:not([data-kind='flat']):not([data-kind='pano']) .sl-kv-step {
.sl-photo-viewer:not([data-kind='flat']):not([data-kind='pano'])
.sl-photo-step {
display: none;
}
+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 {
+58
View File
@@ -82,3 +82,61 @@
.gev-voice-cost-value[hidden] {
display: none;
}
/* 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;
}
Loaded 100 of 702 files, more files were not shown because too many files have changed in this diff. Show more