mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
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:
commit
c1dd4171db
702 files changed
+68262
-6015
No files matched your search
+156
-2
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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';
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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');
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"`);
|
||||
}
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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)}`;
|
||||
|
||||
@@ -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(' ');
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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'],
|
||||
],
|
||||
);
|
||||
});
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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
@@ -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}`;
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
@@ -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 = '';
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
@@ -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',
|
||||
]);
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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()]);
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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') {
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
@@ -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));
|
||||
});
|
||||
}
|
||||
@@ -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/);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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)}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
Reference in new issue
Block a user