Merge origin/i18n-spanish: whole-interface translation meets KartaView

This branch moved twice at once: upstream merged KartaView (#1121, a
third keyless street-level provider sharing a new lineCoverage.js), and
the remote i18n-spanish gained the whole-interface translation work
(1993 catalog entries, translateKnown, data-i18n-runtime, the unmarked-
string detector in npm run i18n) built on the Street View merge.

Integration keeps the remote's mechanisms and re-adds KartaView on top:

- presentMeta: remote's translateHint for coverage hints, plus the
  KartaView count line as its own literal tn pair (Russian plurals need
  the noun inside the template; keyed off coverageLabel.noun with the
  sequences pair as the default).
- uiState: upstream's highlightsSequence legend gate (KartaView's lines
  do not highlight) with t('Selected').
- layer-panels.html: remote's data-i18n/data-i18n-runtime hooks plus the
  sl-since-note wiring KartaView's upload-date SINCE note needs.
- kartaview viewer: the photo-navigation aria-label and img alt go
  through t(), as the remote's unmarked-string detector requires.
- catalogs: remote's 1987-entry base (its refined keys supersede this
  branch's earlier Street View wording) plus the KartaView entries:
  coverage hints, the upload-date note, the streets count line (with
  Russian one/few/many/other forms) and the viewer strings.

npm run i18n: complete, no unmarked strings. npm test: 6520/6522 (the
one failure is the pre-existing environmental OPENAI_API_KEY leak in
devFreshDotenv). Build passes.
This commit is contained in:
Meir Michanie committed 2026-10-14 17:45:57 +02:00
commit 32b11e27de
176 files changed
+9713 -2210

No files matched your search

+27 -1
View File
@@ -569,9 +569,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._applyRuntimeStaticHeaderText();
this._sizeDisplayLabelColumn();
this._initLeftPanelAdaptiveLayout();
this._initRightPanelAdaptiveLayout();
this._initRadioPanel();
@@ -735,6 +737,30 @@ export class StyleManager extends ShellFacade {
}
}
/**
* Gives every Display-panel row label one shared column, sized in
* characters to the longest label of the active language, so the slider
* tracks of separate rows start at the same x. The labels are monospace and
* often hidden (collapsed rows have no layout), so this counts characters
* instead of measuring; the 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 trailing text node in place, preserving the leading
* dock-label-icon span that a textContent write would destroy (the phase-2
+6 -3
View File
@@ -1,5 +1,6 @@
import * as Cesium from 'cesium';
import { isPickedWorldPosition } from '../data/scenePick.js';
import { t } from '../i18n/index.js';
function trackedTarget(viewer) {
const entity = viewer.trackedEntity;
@@ -288,8 +289,8 @@ export function bindCameraOrientationControls({
tiltButton?.setAttribute(
'aria-label',
next.tilted
? 'Return map to straight-down view'
: 'Tilt map to oblique view',
? t('Return map to straight-down view')
: t('Tilt map to oblique view'),
);
}
if (!applied || applied.heading !== next.heading) {
@@ -297,7 +298,9 @@ export function bindCameraOrientationControls({
northButton?.style?.setProperty('--camera-heading', `${next.heading}deg`);
northButton?.setAttribute(
'aria-label',
`Reset map to north up. Current heading ${next.heading} degrees`,
t('Reset map to north up. Current heading {heading} degrees', {
heading: next.heading,
}),
);
}
applied = next;
+32 -11
View File
@@ -1,4 +1,4 @@
import { t } from '../i18n/index.js';
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;
@@ -12,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,
@@ -21,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,
@@ -44,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,
@@ -66,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 || '';
}
@@ -101,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();
@@ -176,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;
}
}
+80 -4
View File
@@ -1,4 +1,80 @@
import { t } from '../i18n/index.js';
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;
@@ -104,10 +180,10 @@ export function _syncCctvSourceBadge(activeCamera, enabled) {
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';
}
+6 -4
View File
@@ -1,4 +1,5 @@
import { t, tn } from '../i18n/index.js';
import { cctvSourceLabelText, cctvSourceMessageText } from './cctvFrames.js';
import { createCctvVideoSurface } from './cctvVideo.js';
export function _calBadgeLabel(badge) {
@@ -148,12 +149,13 @@ 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}` : '';
+13 -6
View File
@@ -1,5 +1,5 @@
/** Regional briefing requests, pages and rotation for the Cockpit controller. */
import { t, msg } from '../i18n/index.js';
import { formatNumber, t, msg } from '../i18n/index.js';
import {
COCKPIT_BRIEF_ROTATE_MS,
COCKPIT_BRIEF_CYCLE_OFF_HELP,
@@ -8,6 +8,7 @@ import {
COCKPIT_REGIONAL_REFRESH_DISTANCE_M,
COCKPIT_BRIEF_PAGES,
formatCockpitBriefAge,
formatCockpitCoordinate,
formatCockpitWindDirection,
} from './cockpitPresentation.js';
@@ -54,7 +55,9 @@ export function showBriefPage(index, { manual = false } = {}) {
if (this.briefPosition)
this.briefPosition.textContent = `${this.briefPageIndex + 1} / ${count}`;
if (this.briefSource)
this.briefSource.textContent = t(page.sourceKey ?? page.source);
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 });
@@ -134,8 +137,8 @@ export function updateLocalPosition(info) {
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}`;
}
@@ -267,7 +270,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) {
@@ -293,7 +296,11 @@ export function renderRegionalBrief(payload, info) {
this.localPrecipitation.textContent = Number.isFinite(
weather?.precipitationMm,
)
? weather.precipitationMm.toFixed(1)
? formatNumber(weather.precipitationMm, {
useGrouping: false,
minimumFractionDigits: 1,
maximumFractionDigits: 1,
})
: '—';
}
if (this.signalStream)
+17 -14
View File
@@ -1,12 +1,25 @@
/** Contact readouts and nearby-signal presentation for the Cockpit controller. */
import * as Cesium from 'cesium';
import { t, tn } from '../i18n/index.js';
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;
@@ -37,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
@@ -53,11 +67,6 @@ export function updateContext(info, heading) {
const enteringLost = this.context.dataset.state !== 'lost';
this.context.dataset.state = 'lost';
if (this.contextUncertainty) {
// The English literal write is pinned verbatim by cockpitMarkup.test.mjs
// ('CONTACT LOST' must appear in this method body); the t() re-write
// below is a same-value override that localizes it under other locales.
this.contextUncertainty.textContent =
'CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR';
this.contextUncertainty.textContent = t(
'CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR',
);
@@ -109,13 +118,13 @@ export function updateContext(info, heading) {
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'),
);
}
@@ -147,12 +156,6 @@ export function updateContext(info, heading) {
this.contextDirection.classList.toggle('unknown', relative === null);
}
if (this.contextBearing) {
// The 'BRG —' literal is pinned verbatim by cockpitMarkup.test.mjs; the
// t() re-write below is a same-value override that localizes it.
if (relative === null) this.contextBearing.textContent = 'BRG —';
else if (Math.abs(relative) < 8) this.contextBearing.textContent = 'AHEAD';
else
this.contextBearing.textContent = `${relative < 0 ? 'L' : 'R'} ${String(Math.round(Math.abs(relative))).padStart(3, '0')}°`;
if (relative === null) this.contextBearing.textContent = t('BRG —');
else if (Math.abs(relative) < 8)
this.contextBearing.textContent = t('AHEAD');
+16 -14
View File
@@ -1,5 +1,5 @@
/** Instrument values, routes and vision controls for the Cockpit controller. */
import { t } from '../i18n/index.js';
import { formatNumber, t } from '../i18n/index.js';
import {
COCKPIT_VISION_MODES,
normalizeCockpitVisionMode,
@@ -20,6 +20,8 @@ import {
} from '../cockpitMath.js';
import {
COCKPIT_CONTEXT_UPDATE_MS,
compassPointLabel,
formatCockpitCoordinate,
setCockpitRollingValue,
} from './cockpitPresentation.js';
@@ -67,7 +69,7 @@ export function updateHud(
setCockpitRollingValue(
this.altitude,
displayedAltitudeFt !== null
? displayedAltitudeFt.toLocaleString('en-US')
? formatNumber(displayedAltitudeFt)
: '-----',
displayedAltitudeFt,
{ immediate: forceContext },
@@ -110,7 +112,7 @@ export function updateHud(
this.compassTape.innerHTML = divisions
.map((division, index) => {
const slot = index - 3;
return `<span class="${slot === 0 ? 'active' : ''}" style="--slot:${slot};--depth:${Math.abs(slot)}">${formatCompassDivision(division)}</span>`;
return `<span class="${slot === 0 ? 'active' : ''}" style="--slot:${slot};--depth:${Math.abs(slot)}">${compassPointLabel(formatCompassDivision(division))}</span>`;
})
.join('');
}
@@ -119,10 +121,10 @@ 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}`;
}
@@ -232,17 +234,17 @@ 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',
optical: t('Normal'),
crt: t('CRT'),
nvg: t('Night vision'),
thermal: t('Thermal'),
anime: t('Anime'),
+2 -9
View File
@@ -36,19 +36,12 @@ export function setContextCollapsed(collapsed) {
if (this.contextToggle) {
const expanded = !this.contextCollapsed;
this.contextToggle.setAttribute('aria-expanded', String(expanded));
// The English aria-label literal is pinned verbatim by
// cockpitMarkup.test.mjs; the t() re-write below is a same-value
// override that localizes it (byte-identical under 'en').
this.contextToggle.setAttribute(
'aria-label',
`${expanded ? 'Collapse' : 'Expand'} Contact panel`,
);
this.contextToggle.setAttribute(
'aria-label',
t(expanded ? msg('Expand Contact panel') : msg('Collapse Contact panel')),
expanded ? t('Collapse Contact panel') : t('Expand Contact panel'),
);
this.contextToggle.title = t(
expanded ? msg('Expand contact panel') : msg('Collapse contact panel'),
expanded ? msg('Collapse contact panel') : msg('Expand contact panel'),
);
const icon = this.contextToggle.querySelector('.material-symbols-outlined');
if (icon) icon.textContent = expanded ? 'chevron_left' : 'chevron_right';
+77 -7
View File
@@ -1,5 +1,6 @@
/** Pure formatting and fixed presentation constants used by Cockpit components. */
import { t, msg } from '../i18n/index.js';
import { formatNumber, t, msg } from '../i18n/index.js';
import { formatQuantity } from './unitLabels.js';
export const COCKPIT_HEADING_SLEW_DPS = 28;
@@ -54,8 +55,9 @@ export const COCKPIT_BRIEF_PAGES = [
id: 'news',
kickerKey: msg('REGIONAL NEWS'),
subtitleKey: msg('LATEST LOCATION-MATCHED REPORTING'),
// RSS provider attribution is a keep-English boundary string.
source: 'GOOGLE NEWS RSS · LOCATION QUERY · RECENT',
// The RSS provider name stays English; the rest of the line translates.
sourceKey: msg('{source} · LOCATION QUERY · RECENT'),
sourceName: 'GOOGLE NEWS RSS', // i18n-ignore: provider name
},
{
id: 'local',
@@ -93,11 +95,79 @@ export function formatCockpitBriefAge(value) {
: 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°С`. */
export function formatCockpitCoordinate(value, positive, negative) {
const amount = formatNumber(Math.abs(value), {
useGrouping: false,
minimumFractionDigits: 3,
maximumFractionDigits: 3,
});
const code = value >= 0 ? positive : negative;
// Russian writes the hemisphere out ("30,203° с. ш."): a lone «С» 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 t('DIR UNKNOWN');
const labels = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW'];
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(
@@ -136,7 +206,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;
@@ -165,7 +235,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 (
+2 -1
View File
@@ -1,5 +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;
@@ -157,7 +158,7 @@ export function updateCockpitSignals(snapshot, unknownCount) {
? t('MILITARY FLIGHT')
: t('COMMERCIAL FLIGHT'),
distance: Number.isFinite(item.distanceM)
? `${item.distanceM < 10000 ? (item.distanceM / 1000).toFixed(1) : Math.round(item.distanceM / 1000)} KM`
? formatCockpitDistance(item.distanceM)
: t('DISTANCE UNKNOWN'),
}),
target: { layerId: cohort.id, id: String(id) },
+1
View File
@@ -69,6 +69,7 @@ export function _handleContextLayerChange(change) {
);
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',
+1 -1
View File
@@ -617,7 +617,7 @@ test('the Display selector, voice schema and final stylesheet expose Cyber', ()
const militaryRendering = read('../layers/military/rendering.js');
const setHud = GEV_ACTION_SCHEMAS.find((schema) => schema.name === 'set_hud');
assert.match(display, /<option value="cyber">Cyber<\/option>/);
assert.match(display, /<option value="cyber" data-i18n>Cyber<\/option>/);
assert.match(display, /id="cyber-sonar-toggle"/);
assert.match(
display,
+5 -1
View File
@@ -1,3 +1,4 @@
import { t } from '../i18n/index.js';
/** Count rendered globe frames while the optional readout is visible. */
export function createFrameRateMonitor({ viewer, documentRef = document }) {
const host = documentRef.getElementById('title-bar');
@@ -7,8 +8,9 @@ export function createFrameRateMonitor({ viewer, documentRef = document }) {
const readout = documentRef.createElement('div');
readout.className = 'frame-rate-readout';
readout.hidden = true;
// i18n-ignore: FPS is a unit code, the same in every language
readout.textContent = 'FPS —';
readout.title = 'Rendered globe frames per second · toggle with `';
readout.title = t('Rendered globe frames per second · toggle with `');
host.appendChild(readout);
let removeFrameListener = null;
let timer = null;
@@ -27,6 +29,7 @@ export function createFrameRateMonitor({ viewer, documentRef = document }) {
function show() {
frames = 0;
startedAt = performance.now();
// i18n-ignore: FPS is a unit code, the same in every language
readout.textContent = 'FPS —';
readout.hidden = false;
removeFrameListener = frameEvent.addEventListener(() => {
@@ -36,6 +39,7 @@ export function createFrameRateMonitor({ viewer, documentRef = document }) {
const now = performance.now();
const elapsed = now - startedAt;
readout.textContent =
// i18n-ignore: FPS is a unit code, the same in every language
documentRef.hidden || elapsed <= 0
? 'FPS —'
: `FPS ${Math.round((frames * 1000) / elapsed)}`;
+36 -32
View File
@@ -1,45 +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 {
keySetupAlternativeKeyNames,
KEY_SETUP_KEYS,
keySetupRequirementEnvVars,
} from '../keySetupCore.mjs';
import { t } from '../i18n/index.js';
import { t, translateKnown } from '../i18n/index.js';
// Localized key-guidance sentences. keySetupCore owns the machine values
// (env-var names, registry titles) because it also runs in node-side
// key-setup builds that stay English; this presentation boundary owns the
// sentences, composed with placeholders so word order can differ per language.
/** The Provider Settings hint for a missing key, or '' for a hidden key. */
export function keyRequirementText(requiresKeyId) {
const envVars = keySetupRequirementEnvVars(requiresKeyId);
/** "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;
}
/**
* A requirement sentence, plus the switched-off sources that would draw with
* a key already set.
* @param {string} requirement What `keyRequirementText` said ('' allowed).
* @param {Array<{label: string, requiresKeyId?: string|null}>} [alternatives]
* 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 withKeyAlternatives(requirement, alternatives = []) {
if (!alternatives.length) return requirement;
const named = keySetupAlternativeKeyNames(alternatives);
const option = ({ label, name }) =>
name
? t('light {label} to use your {name} key', { label, name })
: t('light {label}', { label });
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: named.map(option).join(t(', or ')),
});
// No requirement: the offer stands alone, so it opens with a capital.
const { label, name } = named[0];
const lead = name
? t('Light {label} to use your {name} key', { label, name })
: t('Light {label}', { label });
const tail = named.slice(1).map(option).join(t(', or '));
return tail ? t('{lead}, or {tail}', { lead, tail }) : lead;
return t('{requirement}, or {offer}', { requirement, offer });
return offer.charAt(0).toLocaleUpperCase() + offer.slice(1);
}
+197 -28
View File
@@ -3,9 +3,9 @@ import { syncRowList } from './rowList.js';
import { layerFeedState } from '../data/feedState.js';
export { layerFeedState } from '../data/feedState.js';
import { GUIDANCE_STATUSES } from '../loadingFeedback.js';
import { keyRequirementText, withKeyAlternatives } from './keyGuidance.js';
import { keyGuidanceText, keyRequirementText } from './keyGuidance.js';
import { createWeatherPanel } from './weatherPanel.js';
import { t, msg } from '../i18n/index.js';
import { formatNumber, msg, t, translateKnown } from '../i18n/index.js';
// Feed-state enum VALUES are machine identifiers (layerFeedState contract, QA
// hooks, CSS feed-* classes); only the final label value translates, at this
@@ -40,6 +40,16 @@ const LAYER_NAME_KEYS = Object.freeze({
'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'),
wind: msg('Wind'),
'weather-radar': msg('Rain radar'),
'weather-satellite': msg('Satellite clouds'),
'weather-lightning': msg('Lightning density'),
'weather-cyclones': msg('Cyclone advisories'),
directions: msg('Directions'),
});
function lifecycleStateLabel(lifecycleState) {
@@ -58,7 +68,7 @@ const FEED_STATE_LABELS = Object.freeze({
// Presentation order is independent of catalog registration and startup order.
const PANEL_GROUPS = [
{
label: 'Movement',
label: msg('Movement'),
ids: [
'satellites',
'flights',
@@ -71,11 +81,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',
@@ -85,11 +95,11 @@ const PANEL_GROUPS = [
],
},
{
label: 'Events',
label: msg('Events'),
ids: ['rocket-launches', 'earthquakes', 'local-firms', 'fire-perimeters'],
},
{
label: 'Weather',
label: msg('Weather'),
ids: [
'wind',
'weather-radar',
@@ -99,7 +109,7 @@ const PANEL_GROUPS = [
],
},
{
label: 'Utilities',
label: msg('Utilities'),
ids: ['directions', 'radio'],
},
];
@@ -119,10 +129,68 @@ const PANEL_LABELS = {
'local-firms': msg('Active Fires'),
};
function englishPanelLabel(layer) {
return PANEL_LABELS[layer.id] || LAYER_NAME_KEYS[layer.id] || layer.name;
}
// The panel's own English names win, as before i18n; module names otherwise.
function panelLabel(layer) {
if (PANEL_LABELS[layer.id]) return t(PANEL_LABELS[layer.id]);
const key = LAYER_NAME_KEYS[layer.id];
return key ? t(key) : layer.name;
}
// English module names (layer.name) that other surfaces print, such as the
// loading chip's layer roster. Looked up by the English text so that surface
// keeps its English output and only the presentation translates.
const MODULE_NAME_KEYS = new Map(
[
...Object.values(LAYER_NAME_KEYS),
...Object.values(PANEL_LABELS),
msg('ALPR Cameras'),
].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;
}
// Layer-declared source names stay English in layer state, share links and AI
// tool output; only the row's source line translates them. Provider names
// (USGS, CelesTrak, GTFS-RT…) are not listed and pass through unchanged.
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]),
);
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);
return PANEL_LABELS[layer.id] ? t(PANEL_LABELS[layer.id]) : layer.name;
// 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);
}
/**
@@ -143,12 +211,79 @@ 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 withKeyAlternatives(
return keyGuidanceText(
requiresKeyId ? keyRequirementText(requiresKeyId) : '',
layer.stats.keyAlternatives || [],
);
}
const ROW_TEXT_SEPARATOR = ' · ';
/** A loading label is English data, at times several joined by " · ". */
function translateLoadingLabel(label) {
return label
.trim()
.split(ROW_TEXT_SEPARATOR)
.map(translateKnown)
.join(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({
@@ -250,17 +385,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';
@@ -273,7 +411,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);
@@ -282,7 +420,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';
@@ -321,7 +459,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);
@@ -424,6 +562,21 @@ export class LayerPanel {
);
}
/**
* Write a row's count. A count that is a phrase carrying its own number
* ("24 nearby · 2 on screen") moves to its own line under the name: beside
* the toggle it squeezes the name to a word per line in any language. The
* "—" placeholder is marked so it can give way to a wide status chip.
*/
_syncCount(row, count, stats) {
const text = this._layerCountText(stats);
setRowText(count, text);
const phrase =
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())
@@ -547,12 +700,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(
@@ -568,7 +721,7 @@ export class LayerPanel {
const stats = layer.stats || {};
const feedState = layerFeedState(stats);
const stateLabel = t(FEED_STATE_LABEL_KEYS[feedState]);
const source = stats.source || layer.source;
const source = sourceLabel(stats.source || layer.source);
const lifecycleState =
layer.lifecycleState || (layer.enabled ? 'enabled' : 'disabled');
if (lifecycleState === 'enabling' || lifecycleState === 'disabling') {
@@ -583,8 +736,9 @@ export class LayerPanel {
{ source },
);
}
const presentedError =
stats.error || stats.lastError || stats.managerRefreshError;
const presentedError = translateKnown(
stats.error || stats.lastError || stats.managerRefreshError,
);
if (presentedError) {
if (typeof stats.retryInSec === 'number' && stats.retryInSec > 0) {
return t('{state} · {source} · {detail} · retry {seconds}s', {
@@ -613,14 +767,14 @@ export class LayerPanel {
if (stats.loading) {
const loadingLabel =
typeof stats.loadingLabel === 'string' && stats.loadingLabel.trim()
? stats.loadingLabel.trim()
? translateLoadingLabel(stats.loadingLabel)
: t('loading...');
return t('{source} · {loadingLabel}', { source, loadingLabel });
}
if (feedState === 'fallback') {
const detail =
typeof stats.loadingLabel === 'string' && stats.loadingLabel.trim()
? stats.loadingLabel.trim()
? translateLoadingLabel(stats.loadingLabel)
: stats.coverage || ago;
return t('{state} · {source} · {detail}', {
state: stateLabel,
@@ -665,7 +819,7 @@ export class LayerPanel {
if (typeof stats.loadingLabel === 'string' && stats.loadingLabel.trim()) {
return t('{source} · {loadingLabel}', {
source,
loadingLabel: stats.loadingLabel.trim(),
loadingLabel: translateLoadingLabel(stats.loadingLabel),
});
}
return t('{source} · {ago}', { source, ago });
@@ -707,15 +861,25 @@ export class LayerPanel {
String(transitioning || (unavailable && !layer.enabled)),
);
button.setAttribute('aria-busy', String(transitioning));
button.textContent = transitioning
const showsFeedState =
!transitioning && !uncertain && (layer.enabled || unavailable);
const stateText = transitioning
? lifecycleStateLabel(layer.lifecycleState)
: uncertain
? t('UNCERTAIN')
: layer.enabled || unavailable
: 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 || t('Data source unavailable'))
? String(
translateKnown(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
@@ -723,7 +887,7 @@ export class LayerPanel {
button.title = keyGuidance;
const ariaLabel = t('{name}: {state}', {
name: panelLabel(layer),
state: button.textContent,
state: stateText,
});
button.setAttribute(
'aria-label',
@@ -732,7 +896,12 @@ export class LayerPanel {
}
_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);
}
+72 -1
View File
@@ -13,7 +13,9 @@ test('panel presentation places Transit between Street Traffic and Bike Share in
source.indexOf('const PANEL_POSITIONS ='),
);
const order = JSON.parse(
runInNewContext(`${declarations}\nJSON.stringify(PANEL_ORDER)`),
runInNewContext(
`const msg = (text) => text;\n${declarations}\nJSON.stringify(PANEL_ORDER)`,
),
);
assert.deepEqual(
order.filter(({ label }) => label === 'Movement').map(({ id }) => id),
@@ -258,3 +260,72 @@ 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\./);
});
+10
View File
@@ -3,6 +3,7 @@ import {
localSdrCardView,
localSdrFeedLine,
localSdrFmAudioActive,
localSdrGainOptionLabel,
} from './localSdrPresentation.js';
const ELEMENT_IDS = Object.freeze({
@@ -77,6 +78,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 +97,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,
+52 -16
View File
@@ -1,10 +1,40 @@
import { localReceiverFeedName } from '../layers/localAdsb/feedNames.js';
import { 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();
}
/**
* 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);
}
@@ -20,10 +50,15 @@ function statusText(state) {
const iqStatus =
state.samplesPerSecond > 0
? `${(state.samplesPerSecond / 1_000_000).toFixed(2)} MS/s IQ`
: 'waiting for 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 +66,14 @@ 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: state.audioLevelDbfs.toFixed(1) })
: t('audio signal --');
const audio = t('audio {state}', { state: state.audioState });
return `${detail}${device} · ${iqStatus} · ${workerStatus} · ${rfStatus} · ${audioSignal} · ${audio}`;
}
return detail;
}
@@ -57,15 +93,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 +109,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,
+14
View File
@@ -6,7 +6,9 @@ import {
localSdrCardView,
localSdrFeedLine,
localSdrFmAudioActive,
localSdrGainOptionLabel,
} from './localSdrPresentation.js';
import { setLocale } from '../i18n/index.js';
const idle = Object.freeze({
webUsbSupported: true,
@@ -153,3 +155,15 @@ test('two feeds on one band are told apart by their ordinal labels', () => {
'Decoder feeds: 1090 · 978 MHz UAT #1 · 978 MHz UAT #2 · read while Local ADS-B is on',
);
});
test('gain option labels follow the page language; values stay numeric', () => {
try {
setLocale('en');
assert.equal(localSdrGainOptionLabel('28.0'), '28.0\u00a0dB');
assert.equal(localSdrGainOptionLabel('0.9'), '0.9\u00a0dB');
setLocale('ru');
assert.match(localSdrGainOptionLabel('28.0'), /^28,0\u00a0\S+$/);
} finally {
setLocale('en');
}
});
+3 -1
View File
@@ -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);
+5 -2
View File
@@ -154,7 +154,10 @@ 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')}`
? 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)
@@ -325,7 +328,7 @@ export function bindRadioControls() {
this._radioTunerStation.textContent = t('STATION UNAVAILABLE');
this._radioTunerSlider?.setAttribute(
'aria-valuetext',
'Station unavailable after directory refresh',
t('Station unavailable after directory refresh'),
);
}
}
+7 -1
View File
@@ -215,6 +215,12 @@ export function renderRadioState(state) {
: 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,
@@ -223,7 +229,7 @@ export function renderRadioState(state) {
if (this._radioPlayBtn) {
this._radioPlayBtn.disabled = !interactive || !hasStations;
this._radioPlayBtn.classList.toggle('active', activePlayback);
this._radioPlayBtn.textContent = playbackAction.toUpperCase();
this._radioPlayBtn.textContent = playbackButtonLabel;
this._radioPlayBtn.setAttribute('aria-label', playAria);
}
if (this._contextRadioMiniPlayBtn) {
+12 -3
View File
@@ -1,5 +1,7 @@
import { t } from '../i18n/index.js';
import { syncChipGroup } from './chipGroup.js';
import { syncRowList } from './rowList.js';
import { formatScaleLabel, unitLabel } from './unitLabels.js';
const set = (node, key, value) => {
if (node[key] !== value) node[key] = value;
@@ -13,6 +15,13 @@ const order = (parent, nodes) =>
parent.insertBefore(node, parent.children[i] || null);
});
// "960 — 1050+ гПа": the unit stays with the last number.
const scaleText = (low, high, units) => {
const unit = unitLabel(units);
const range = `${formatScaleLabel(low)} — ${formatScaleLabel(high)}`;
return unit ? `${range}\u00a0${unit}` : range;
};
/** Optional, ordered blocks without disclosure state. All controls are keyed. */
export function createRailCardBlocks({ container, cardId, onParams }) {
const document = container.ownerDocument;
@@ -98,8 +107,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);
});
@@ -197,7 +206,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') {
+13 -8
View File
@@ -1,9 +1,10 @@
import { formatUtcDayMonth, t } from '../i18n/index.js';
const set = (node, key, value) => {
if (node[key] !== value) node[key] = value;
};
const utc = (tick) =>
Number.isFinite(Date.parse(tick))
? `${new Date(tick).toISOString().slice(5, 16).replace('T', ' ')} UTC`
? `${formatUtcDayMonth(tick)} ${new Date(tick).toISOString().slice(11, 16)} UTC`
: '';
/** Stable native transport with local preview and a coalesced 150 ms drag commit. */
@@ -32,7 +33,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 +41,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');
@@ -128,7 +133,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],
+205 -106
View File
@@ -8,40 +8,71 @@
* 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';
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'),
]);
const TOO_LARGE = /^(Box|View) is ([\d,]+) km wide · limit ([\d,]+) km$/;
function localizeNotice(text) {
if (!text) return '';
if (LAYER_NOTICES.has(text)) return t(text);
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 +86,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 +213,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 +231,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 +248,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 +258,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 +289,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 +300,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,
@@ -318,8 +363,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 +373,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 +382,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 +410,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 +434,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 +455,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 +478,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 +498,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 +511,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 +521,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 +571,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 +655,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 +688,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 +723,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 +751,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 +762,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 +789,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 +804,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 +823,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 +837,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),
},
@@ -773,8 +863,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 +891,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 +992,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);
+23 -11
View File
@@ -646,7 +646,10 @@ test('cards carry thumbnail, date, sensor, cloud, START HERE and PREVIEW; the ch
f.layer.focus(1);
const s18 = f.card(S18);
assert.equal(f.part(s18, 'ri-card-date').textContent, 'Sep 18');
assert.equal(f.part(s18, 'ri-card-sensor').textContent, 'Sentinel-2 · 30 m');
assert.equal(
f.part(s18, 'ri-card-sensor').textContent,
'Sentinel-2 · 30\u00a0m',
);
assert.equal(f.part(s18, 'ri-card-cloud').textContent, '12% cloud');
assert.equal(f.part(s18, 'ri-card-start').hidden, false);
assert.match(f.part(s18, 'ri-card-start').title, /Newest low-cloud day/);
@@ -658,11 +661,11 @@ test('cards carry thumbnail, date, sensor, cloud, START HERE and PREVIEW; the ch
assert.equal(f.part(f.card(V21), 'ri-thumb-text').textContent, 'Checking');
assert.equal(
f.part(f.card(V21), 'ri-card-sensor').textContent,
'Daily overview · 250 m',
'Daily overview · 250\u00a0m',
);
assert.equal(
f.part(f.card(L16), 'ri-card-sensor').textContent,
'Landsat 8/9 · 30 m',
'Landsat 8/9 · 30\u00a0m',
);
// IMAGE: one SHOW chip; the B chip keeps its box but is invisible.
const [a, b] = [f.chip(S18, 'a'), f.chip(S18, 'b')];
@@ -734,7 +737,10 @@ test('the selection bar reads the mode, both rows and a preview, with × reserve
['A / B', 'radio', 'false'],
],
);
assert.equal(rowText(f, 'a'), 'IMAGE | Sep 18 · Sentinel-2 · 30 m · preview');
assert.equal(
rowText(f, 'a'),
'IMAGE | Sep 18 · Sentinel-2 · 30\u00a0m · preview',
);
assert.equal(f.byId('ri-slot-a').dataset.state, 'preview');
assert.equal(rowText(f, 'b'), 'VS | Basemap');
assert.equal(f.byId('ri-slot-b').classList.contains('lit'), false, 'dimmed');
@@ -748,7 +754,7 @@ test('the selection bar reads the mode, both rows and a preview, with × reserve
[true, true, '×'],
);
f.key('s');
assert.equal(rowText(f, 'a'), 'IMAGE | Sep 18 · Sentinel-2 · 30 m');
assert.equal(rowText(f, 'a'), 'IMAGE | Sep 18 · Sentinel-2 · 30\u00a0m');
assert.equal(f.byId('ri-slot-a').dataset.state, 'pinned');
assert.equal(unpinA.disabled, false);
assert.equal(unpinA.getAttribute('aria-label'), 'Unpin the image');
@@ -757,12 +763,12 @@ test('the selection bar reads the mode, both rows and a preview, with × reserve
assert.equal(modes[1].getAttribute('aria-checked'), 'true');
assert.equal(f.byId('ri-slot-b').classList.contains('lit'), true, 'lit');
modes[2].click();
assert.equal(rowText(f, 'a'), 'A | Sep 18 · Sentinel-2 · 30 m');
assert.equal(rowText(f, 'a'), 'A | Sep 18 · Sentinel-2 · 30\u00a0m');
assert.equal(rowText(f, 'b'), 'B | Not set');
assert.equal(f.byId('ri-slot-b').dataset.state, 'empty');
assert.equal(f.byId('ri-unpin-b').classList.contains('is-reserved'), true);
f.chip(L16, 'b').click();
assert.equal(rowText(f, 'b'), 'B | Sep 16 · Landsat 8/9 · 30 m');
assert.equal(rowText(f, 'b'), 'B | Sep 16 · Landsat 8/9 · 30\u00a0m');
f.byId('ri-unpin-b').click();
assert.equal(f.snap().pins.b.key, null);
unpinA.click();
@@ -879,7 +885,10 @@ test('the notice line shows the refusal, then errors, then CLEAR, then the Esri
);
assert.equal(notice.classList.contains('info'), true);
f.layer.setBox({ west: 0, south: 0, east: 20, north: 20 });
assert.equal(noticeText.textContent, 'Box is 2,226 km wide · limit 1,000 km');
assert.equal(
noticeText.textContent,
'Box is 2,226\u00a0km wide · limit 1,000\u00a0km',
);
assert.equal(notice.classList.contains('warn'), true);
assert.equal(hint.textContent, 'Zoom in or draw a smaller box');
f.layer.setBox(BOX);
@@ -929,7 +938,7 @@ test('an oversized box offers ZOOM IN in a fixed slot at the end of the notice l
assert.deepEqual(reserved(), [false, false, 'false', true]);
assert.equal(
f.byId('ri-notice-text').textContent,
'Box is 2,226 km wide · limit 1,000 km',
'Box is 2,226\u00a0km wide · limit 1,000\u00a0km',
);
assert.equal(f.byId('ri-hint').textContent, 'Zoom in or draw a smaller box');
assert.equal(zoom.title, 'Fly in until the view fits the 1,000 km limit');
@@ -998,7 +1007,10 @@ test('the divider exists only while a swipe is live, labelled A / B or IMAGE / B
[split.options.beforeLabel, split.options.afterLabel],
['A', 'B'],
);
assert.equal(split.options.afterTitle, 'Landsat 8/9 · 30 m · 2026-09-16');
assert.equal(
split.options.afterTitle,
'Landsat 8/9 · 30\u00a0m · 2026-09-16',
);
split.options.onChange(0.3);
assert.equal(f.snap().split, 0.3);
assert.deepEqual(directions(), ['left', 'right']);
@@ -1015,7 +1027,7 @@ test('the divider exists only while a swipe is live, labelled A / B or IMAGE / B
assert.deepEqual(labels(), ['B', 'A']);
assert.equal(
f.splits.at(-1).options.beforeTitle,
'Landsat 8/9 · 30 m · 2026-09-16',
'Landsat 8/9 · 30\u00a0m · 2026-09-16',
);
assert.equal(f.splits.at(-1).options.initialValue, 0.3);
assert.deepEqual(directions(), ['right', 'left']);
+3 -1
View File
@@ -163,7 +163,9 @@ export class SceneControls {
const shot = scene?.shots.find((item) => item.id === shotId);
if (
shot &&
window.confirm(t('Delete shot "{shot}"?', { shot: shot.title }))
window.confirm(
t('Delete shot "{shot}"?', { shot: shot.displayTitle || shot.title }),
)
)
this.run('deleteShot', sceneId, shotId);
}
+53 -10
View File
@@ -1,4 +1,26 @@
/** Scene panel elements and text-only project presentation. */
import { formatNumber, msg, t } from '../i18n/index.js';
// Labels for what a shot stores; anything else shows as stored. Style names
// match STYLE_STATUS_LABELS in visualPresets.js, which this package cannot
// import (it pulls in the shaders).
const STYLE_LABELS = Object.freeze({
normal: msg('NORMAL'),
retro: msg('CRT'),
surveillance: msg('NVG'),
thermal: msg('FLIR'),
anime: msg('ANIME'),
noir: msg('NOIR'),
snow: msg('SNOW'),
});
const DETECTION_MODE_LABELS = Object.freeze({
OFF: msg('OFF'),
SPARSE: msg('SPARSE'),
BALANCED: msg('BALANCED'),
DENSE: msg('DENSE'),
});
export function sceneElements(root = document) {
const ids = {
panel: 'scene-panel',
@@ -47,11 +69,16 @@ export function renderSceneShots(
if (!scene || scene.shots.length === 0) {
const empty = document.createElement('div');
empty.className = 'scene-shot-empty';
empty.textContent = 'No shots yet. Use CAPTURE SHOT to save current look.';
empty.textContent = t(
'No shots yet. Use CAPTURE SHOT to save current look.',
);
element.appendChild(empty);
return;
}
for (const shot of scene.shots) {
// Built-in titles arrive localized as displayTitle; the stored title
// stays English, so a rename happens only when the shown text changes.
const shownTitle = shot.displayTitle || shot.title;
const row = document.createElement('div');
row.className = 'scene-shot-row';
row.dataset.sceneShotId = shot.id;
@@ -60,23 +87,23 @@ export function renderSceneShots(
top.className = 'scene-shot-top';
const label = document.createElement('div');
label.className = 'scene-shot-label';
label.textContent = shot.title;
label.title = 'Double-click to rename';
label.textContent = shownTitle;
label.title = t('Double-click to rename');
listen(label, 'click', () => select(shot.id));
listen(label, 'dblclick', () => {
if (label.children.length) return;
const input = document.createElement('input');
input.type = 'text';
input.className = 'scene-shot-rename';
input.setAttribute('aria-label', 'Shot name');
input.value = shot.title;
input.setAttribute('aria-label', t('Shot name'));
input.value = shownTitle;
let finished = false;
const finish = (save) => {
if (finished) return;
finished = true;
const title = input.value.trim();
label.textContent = shot.title;
if (save && title && title !== shot.title)
label.textContent = shownTitle;
if (save && title && title !== shownTitle)
rename(scene.id, shot.id, title);
};
listen(input, 'keydown', (event) => {
@@ -96,11 +123,11 @@ export function renderSceneShots(
actions.className = 'scene-shot-actions';
const loadButton = document.createElement('button');
loadButton.className = 'scene-shot-btn';
loadButton.textContent = 'LOAD';
loadButton.textContent = t('LOAD');
listen(loadButton, 'click', () => load(scene.id, shot.id));
const deleteButton = document.createElement('button');
deleteButton.className = 'scene-shot-btn scene-shot-danger';
deleteButton.textContent = 'DEL';
deleteButton.textContent = t('DEL');
listen(deleteButton, 'click', () => remove(scene.id, shot.id));
actions.appendChild(loadButton);
actions.appendChild(deleteButton);
@@ -110,7 +137,23 @@ export function renderSceneShots(
meta.className = 'scene-shot-meta';
const mode = shot.visual?.detection?.mode || 'OFF';
const style = shot.visual?.style || 'normal';
meta.textContent = `${style.toUpperCase()} · ${mode} · ${shot.durationSec.toFixed(1)}s + ${shot.holdSec.toFixed(1)}s`;
const styleLabel = STYLE_LABELS[style]
? t(STYLE_LABELS[style])
: style.toUpperCase();
const modeLabel = DETECTION_MODE_LABELS[mode]
? t(DETECTION_MODE_LABELS[mode])
: mode;
const seconds = (value) =>
formatNumber(value, {
minimumFractionDigits: 1,
maximumFractionDigits: 1,
});
// English keeps its compact "4.0s + 0.9s"; other languages space the unit.
const timing = t('{duration}s + {hold}s', {
duration: seconds(shot.durationSec),
hold: seconds(shot.holdSec),
});
meta.textContent = `${styleLabel} · ${modeLabel} · ${timing}`;
row.appendChild(top);
row.appendChild(meta);
element.appendChild(row);
+66 -3
View File
@@ -1,3 +1,5 @@
import { t, tn } from '../i18n/index.js';
/** Small owned modal for scene authoring and import review; text never becomes markup. */
export function createSceneDialog(title, onClose) {
const dialog = document.createElement('dialog');
@@ -79,10 +81,71 @@ 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. Hide it visually (it stays focusable and is still opened by its
* label) and show a translated button and selection line instead.
*/
function fileChooser(wrapper, node) {
wrapper.style.position = 'relative';
Object.assign(node.style, {
position: 'absolute',
width: '1px',
height: '1px',
margin: '0',
padding: '0',
overflow: 'hidden',
clip: 'rect(0 0 0 0)',
whiteSpace: 'nowrap',
border: '0',
});
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 +179,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) {
+4 -2
View File
@@ -192,7 +192,9 @@ export class ShareRestoration {
this.showStatus(t('ACQUIRING'), {
state: 'acquiring',
detail: t('SHARED {subject}', {
subject: String(result.label || t('entity')).toUpperCase(),
subject: String(
result.label ? t(result.label) : t('subject'),
).toUpperCase(),
}),
persistent: true,
});
@@ -217,7 +219,7 @@ export class ShareRestoration {
const noticeGeneration = ownsAcquiringNotice
? this._shareTrackingNoticeGeneration
: ++this._shareTrackingNoticeGeneration;
const subject = result.label || t('entity');
const subject = result.label ? t(result.label) : t('entity');
const message =
result.classification === 'expired'
? t('Shared {subject} follow expired', { subject })
+27 -10
View File
@@ -10,6 +10,7 @@ import {
} from '../loadingFeedback.js';
import { setSplitFlapText, disposeSplitFlap } from '../splitFlap.js';
import { t, msg } from '../i18n/index.js';
import { localizeLayerName } from './layerPanel.js';
// loadingFeedback.js composes the shared status copy inside the portable
// node/browser graph (the server-side HUD summary reads the same modules), so
@@ -25,6 +26,9 @@ const STATUS_LABEL_KEYS = Object.freeze({
'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'),
'TURNING OFF LIVE DATA': msg('TURNING OFF LIVE DATA'),
'REFRESHING LIVE DATA': msg('REFRESHING LIVE DATA'),
'syncing road network': msg('syncing road network'),
@@ -43,7 +47,21 @@ function localizeStatusDetail(detail) {
const match = detail.match(RETRY_DETAIL_RE);
if (match) return t('retrying in {seconds}s', { seconds: match[1] });
if (detail.trim() === 'retry pending') return t('retry pending');
return detail;
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
);
}
function localizeLoadingPresentation(presentation) {
@@ -121,15 +139,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)
if (presentation.label)
setSplitFlapText(
this._trafficSyncLabel,
localizeStatusLabel(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.
+6 -5
View File
@@ -5,7 +5,7 @@ import {
SINCE_STOPS,
} from './streetLevelPresentation.js';
import { t } from '../i18n/index.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"])';
@@ -129,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);
});
@@ -230,7 +230,8 @@ export class StreetLevelControls {
await this.actions.setEnabled?.(true);
} catch (error) {
this.actions.showToast?.(
error?.message || t('Street Level could not start'),
translateKnown(error?.message || '') ||
t('Street Level could not start'),
);
return false;
}
@@ -243,7 +244,7 @@ export class StreetLevelControls {
await this.actions.setEnabled?.(!enabled);
} catch (error) {
this.actions.showToast?.(
error?.message || t('Street Level toggle failed'),
translateKnown(error?.message || '') || t('Street Level toggle failed'),
);
}
}
@@ -277,7 +278,7 @@ export class StreetLevelControls {
await this.actions.setEnabled?.(false);
} catch (error) {
this.actions.showToast?.(
error?.message || t('Street Level toggle failed'),
translateKnown(error?.message || '') || t('Street Level toggle failed'),
);
}
}
+30 -19
View File
@@ -1,5 +1,5 @@
import { keyRequirementText, withKeyAlternatives } from './keyGuidance.js';
import { formatNumber, msg, t, tn } from '../i18n/index.js';
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.
@@ -70,7 +70,7 @@ function keyGuidance(state) {
.filter((p) => p.on && p.keyRequired && p.requiresKeyId)
.map((p) => keyRequirementText(p.requiresKeyId))
.filter(Boolean);
return withKeyAlternatives(
return keyGuidanceText(
[...new Set(needs)].join('; '),
state.keyAlternatives || [],
);
@@ -107,13 +107,14 @@ function presentProviders(state) {
? 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}: ${translateKnown(provider.error)}`;
else if (keyRequired && provider.requiresKeyId)
title = `${provider.name}: ${keyRequirementText(provider.requiresKeyId)}`;
else if (provider.error) title = `${provider.name}: ${provider.error}`;
else if (provider.error)
title = `${provider.name}: ${translateKnown(provider.error)}`;
return {
id: provider.id,
label: provider.label,
label: translateKnown(provider.label),
color: provider.color || null,
title,
active: on,
@@ -140,7 +141,8 @@ function presentViewer(state) {
right.push(formatDate(street.capturedAt, street.capturedAtPrecision));
const left = [];
if (street.title) left.push(street.title);
if (street.creator) left.push(t('Image by {creator}', { creator: street.creator }));
if (street.creator)
left.push(t('Image by {creator}', { creator: street.creator }));
return {
open: street.open === true,
loading: street.loading === true && !street.imageId,
@@ -149,7 +151,9 @@ 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,
@@ -179,13 +183,16 @@ function presentNearest(state) {
disabled: true,
providerIds: [],
};
const names = targets.map((provider) => provider.name).join(t(' 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: t('OPEN {labels}', {
labels: targets.map((provider) => provider.label).join(' / '),
}),
label: t('OPEN {labels}', { labels }),
title: ready
? t('Open {names} at the centre of the view', { names })
: t('Zoom in to a street to open {names} here', { names }),
@@ -194,9 +201,13 @@ function presentNearest(state) {
};
}
/** 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 ? t(state.coverage.hint) : '';
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)
@@ -210,26 +221,25 @@ function presentMeta(state) {
'{n} images in this sequence · Esc clears',
{ n: formatNumber(state.sequence.images) },
);
const hint = state.coverage.hint ? t(state.coverage.hint) : '';
const hint = translateHint(state.coverage.hint);
if (state.coverage.count > 0) {
// A provider whose lines are not sequences carries its wording as data
// (coverageLabel); its count line needs its own tn pair so every language
// gets real plural forms. New wording: add a pair here and to the catalogs.
const count = state.coverage.count;
const n = () => formatNumber(count);
const countLine =
state.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',
{ n: n() },
{ n: formatNumber(count) },
)
: tn(
count,
'{n} sequence in view · click a line for its photos',
'{n} sequences in view · click a line for its photos',
{ n: n() },
{ n: formatNumber(count) },
);
return [countLine, hint].filter(Boolean).join(' · ');
}
@@ -252,7 +262,8 @@ export function presentStreetLevelPanel(state, { now = Date.now() } = {}) {
providers: presentProviders(state),
error: keyMissing
? null
: state.street.error || state.coverage.error || null,
: translateKnown(state.street.error || state.coverage.error || '') ||
null,
filter: { pano: filter.pano, sinceDays: Number(filter.sinceDays) || 0 },
filterControls: {
pano: state.filterControls?.pano !== false,
+14 -3
View File
@@ -1,4 +1,12 @@
import { t } from '../i18n/index.js';
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.
@@ -44,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);
+96 -29
View File
@@ -12,7 +12,9 @@
background: var(--glass-bg);
border: 1px solid var(--glass-border);
border-radius: 6px;
box-shadow: 0 18px 52px rgba(0, 0, 0, 0.48), inset 0 0 28px rgba(14, 78, 92, 0.12);
box-shadow:
0 18px 52px rgba(0, 0, 0, 0.48),
inset 0 0 28px rgba(14, 78, 92, 0.12);
backdrop-filter: blur(24px) saturate(1.4);
font-family: var(--font-mono);
letter-spacing: 0;
@@ -153,33 +155,69 @@
}
@keyframes bhote-embedded-anchor-enter {
from { opacity: 0; transform: scale(0.2); }
to { opacity: 1; transform: scale(1); }
from {
opacity: 0;
transform: scale(0.2);
}
to {
opacity: 1;
transform: scale(1);
}
}
@keyframes bhote-embedded-leader-enter {
from { opacity: 0; stroke-dashoffset: 1; }
to { opacity: 1; stroke-dashoffset: 0; }
from {
opacity: 0;
stroke-dashoffset: 1;
}
to {
opacity: 1;
stroke-dashoffset: 0;
}
}
@keyframes bhote-embedded-body-enter {
from { opacity: 0; transform: translateY(8px) scale(0.98); }
to { opacity: 1; transform: translateY(0) scale(1); }
from {
opacity: 0;
transform: translateY(8px) scale(0.98);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes bhote-embedded-body-exit {
from { opacity: 1; transform: translateY(0) scale(1); }
to { opacity: 0; transform: translateY(6px) scale(0.985); }
from {
opacity: 1;
transform: translateY(0) scale(1);
}
to {
opacity: 0;
transform: translateY(6px) scale(0.985);
}
}
@keyframes bhote-embedded-leader-exit {
from { opacity: 1; stroke-dashoffset: 0; }
to { opacity: 0; stroke-dashoffset: 1; }
from {
opacity: 1;
stroke-dashoffset: 0;
}
to {
opacity: 0;
stroke-dashoffset: 1;
}
}
@keyframes bhote-embedded-anchor-exit {
from { opacity: 1; transform: scale(1); }
to { opacity: 0; transform: scale(0.2); }
from {
opacity: 1;
transform: scale(1);
}
to {
opacity: 0;
transform: scale(0.2);
}
}
@media (prefers-reduced-motion: reduce) {
@@ -243,16 +281,22 @@
border-top: 3px solid #6be8ff;
}
/* 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. */
.bhote-embedded-callout footer strong {
grid-column: 1 / -1;
display: -webkit-box;
overflow: hidden;
font-size: 11px;
line-height: 1.25;
text-overflow: ellipsis;
white-space: nowrap;
overflow-wrap: anywhere;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.bhote-embedded-callout footer small {
grid-column: 1;
align-self: center;
color: rgba(151, 211, 224, 0.7);
font-size: 8px;
line-height: 1.35;
@@ -260,8 +304,9 @@
.bhote-embedded-callout footer a {
grid-column: 2;
grid-row: 1 / span 2;
grid-row: 2;
align-self: center;
white-space: nowrap;
color: #6be8ff;
font-size: 8px;
text-decoration: none;
@@ -354,6 +399,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 +423,11 @@
color: rgba(174, 208, 216, 0.62);
}
/* Keep "16 OCT 2021" together so the year never wraps alone. */
.bhote-event-date {
white-space: nowrap;
}
.bhote-event-story-nav {
display: grid;
grid-template-columns: 34px minmax(0, 1fr) 34px;
@@ -388,7 +444,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;
}
@@ -398,7 +458,7 @@
align-content: center;
column-gap: 9px;
min-width: 0;
padding: 7px 10px;
padding: 5px 10px;
border: 1px solid rgba(88, 196, 218, 0.18);
background: rgba(6, 19, 27, 0.72);
}
@@ -410,22 +470,28 @@
line-height: 1.25;
}
/* 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. */
.bhote-event-story-current strong {
display: -webkit-box;
overflow: hidden;
color: var(--text-primary);
font-size: 11px;
line-height: 1.25;
text-overflow: ellipsis;
white-space: nowrap;
overflow-wrap: break-word;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
/* Phase / time / sources run long in translation: allow a second line. */
.bhote-event-story-current small {
display: -webkit-box;
grid-column: 1 / -1;
margin-top: 4px;
margin-top: 3px;
overflow: hidden;
color: rgba(116, 216, 235, 0.7);
text-overflow: ellipsis;
white-space: nowrap;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.bhote-event-actions {
@@ -448,14 +514,15 @@
cursor: pointer;
}
/* One line in English; a longer translation wraps to a second line. */
.bhote-event-actions button {
min-width: 0;
min-height: 30px;
padding: 0 5px;
padding: 3px 5px;
overflow: hidden;
font-size: 8px;
text-overflow: ellipsis;
white-space: nowrap;
line-height: 1.2;
overflow-wrap: break-word;
}
.bhote-event-actions button:disabled,
@@ -469,7 +536,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 +660,6 @@
max-height: 48vh;
overflow-y: auto;
}
}
* {
@@ -602,7 +668,8 @@
box-sizing: border-box;
}
html, body {
html,
body {
width: 100%;
height: 100%;
overflow: hidden;
+41
View File
@@ -78,6 +78,14 @@ html[lang='es'] .cctv-controls {
flex-wrap: wrap;
}
/* A translated label longer than its button wraps inside the button instead
of running past the panel edge. English labels fit on one line, so the
English layout is unchanged. */
.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;
@@ -165,6 +173,9 @@ html[lang='es'] .cctv-controls {
padding: 6px 8px;
font-family: var(--font-sans);
font-size: 12px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
outline: none;
}
@@ -373,3 +384,33 @@ html[lang='es'] .cctv-controls {
color: #a8f7ff;
text-shadow: 0 0 10px rgba(0, 212, 255, 0.18);
}
/* Longer translated labels and units: tighter calibration chips keep the
seven values to two rows so SAVE / RESET stay in view, and the camera
select uses the compact mono face. English keeps its original look. */
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;
}
+24 -1
View File
@@ -569,6 +569,9 @@ body.cockpit-mode:has(#left-panel-stack > .panel-collapsible:not(.collapsed))
text-align: center;
vertical-align: -0.08em;
}
.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
@@ -1499,6 +1502,12 @@ body.cockpit-mode #view-switcher button {
.cockpit-signal-header > div {
display: grid;
gap: 2px;
min-width: 0;
}
.cockpit-signal-kicker {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.cockpit-signal-header strong {
color: #fff;
@@ -1526,6 +1535,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. */
.cockpit-brief-actions > * {
flex-shrink: 0;
}
.cockpit-brief-actions button {
display: grid;
width: 23px;
@@ -1548,10 +1562,13 @@ body.cockpit-mode #view-switcher button {
font-size: 14px;
}
#cockpit-brief-auto {
width: 50px;
width: auto;
min-width: 50px;
padding: 0 4px;
color: rgba(244, 251, 255, 0.42);
font: 700 6px/1 var(--font-mono);
letter-spacing: 0.05em;
white-space: nowrap;
}
#cockpit-brief-auto[aria-pressed='true'] {
border-color: var(--cockpit-accent);
@@ -1857,14 +1874,20 @@ body.cockpit-mode #view-switcher button {
letter-spacing: 0.1em;
}
.cockpit-signal-footer > span {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.cockpit-brief-tabs {
display: flex;
flex: 0 0 auto;
gap: 3px;
}
.cockpit-brief-tabs button {
white-space: nowrap;
}
.cockpit-brief-tabs button {
padding: 2px 4px;
border: 0;
+51
View File
@@ -696,3 +696,54 @@
rgba(232, 234, 237, 0.24)
);
}
/* Language switch under the Visual Presets title: small mono chips, the
active language in accent like the other dock toggles. */
#command-dock #control-panel .dock-locale-switch {
display: flex;
justify-content: flex-end;
gap: 0.2rem;
margin-top: 0.22rem;
}
#command-dock .dock-locale-btn {
display: inline-flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
min-width: 1.35rem;
height: 0.95rem;
margin: 0;
padding: 0 0.22rem;
color: var(--text-secondary);
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 0.18rem;
font-family: var(--font-mono);
font-size: 0.46rem;
font-weight: 500;
letter-spacing: 0.06rem;
line-height: 1;
cursor: pointer;
transition:
color var(--transition-fast),
border-color var(--transition-fast),
background var(--transition-fast);
}
#command-dock .dock-locale-btn:hover {
color: var(--text-primary);
border-color: var(--glass-border-hover);
background: rgba(255, 255, 255, 0.06);
}
#command-dock .dock-locale-btn:focus-visible {
outline: 1px solid var(--text-primary);
outline-offset: 1px;
}
#command-dock .dock-locale-btn.active {
color: var(--accent);
border-color: rgba(0, 212, 255, 0.55);
background: var(--accent-dim);
}
+6
View File
@@ -392,6 +392,12 @@
transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
/* The side wings sit at z-index 100 (controls.css, location.css), so their
open trays would cover the alert; lift the voice column while it shows. */
#command-dock > #gev-voice-control[data-status='error']:not(.error-dismissed) {
z-index: 101;
}
#command-dock
#gev-voice-control[data-status='error']:not(.error-dismissed)
.gev-voice-error-tray {
+42 -6
View File
@@ -221,6 +221,18 @@
white-space: nowrap;
}
/* A longer translated title in a collapsed rail launcher tightens its
tracking rather than pushing the disclosure button out of the card. */
html:not([lang='en'])
#right-context-rail
.panel-collapsible.collapsed
.panel-title {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
letter-spacing: 1.5px;
}
.panel-divider {
flex: 1;
height: 1px;
@@ -228,6 +240,8 @@
}
.panel-collapse-btn {
/* A long translated title must not squeeze the toggle into a sliver. */
flex-shrink: 0;
width: 22px;
height: 22px;
border-radius: 6px;
@@ -770,6 +784,15 @@
white-space: nowrap;
}
/* One label column across the Display rows: --pp-label-chars is the longest
label of the active language (set by applicationShell), and the labels are
monospace, so every slider track starts at the same x whatever the
language. 0.125em is the 1px letter-spacing at 8px. */
.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 +989,11 @@
#draw-mode-row {
min-width: 184px;
}
/* Equal thirds with room around the label, so a long shape name never runs
edge to edge in its segment. */
#draw-mode-row .pp-mode-btn {
padding: 0 4px;
}
#draw-label-row {
flex-wrap: wrap;
row-gap: 6px;
@@ -987,8 +1015,11 @@
.pp-text-input:focus {
border-color: var(--accent);
}
/* Sized to its longest colour name so a translated one is never clipped;
74px keeps the English width. */
.draw-color-select {
flex: 0 0 74px;
flex: 0 0 auto;
min-width: 74px;
}
.draw-clear-btn {
flex: 0 0 auto;
@@ -1234,15 +1265,20 @@ body.ui-clean-view #scene-runtime.active {
);
}
.param-slider-row {
display: flex;
/* One label column for every parameter, as wide as the longest label in the
active language, so all slider tracks start and end at the same x. */
#param-sliders {
display: grid;
grid-template-columns: max-content minmax(0, 1fr) max-content;
align-items: center;
gap: 10px;
margin-bottom: 10px;
}
.param-slider-row:last-child {
margin-bottom: 0;
.param-slider-row {
display: grid;
grid-column: 1 / -1;
grid-template-columns: subgrid;
align-items: center;
}
.param-label {
+3 -1
View File
@@ -1371,10 +1371,12 @@
padding: 2px 4px;
}
/* ALPR keeps its nearby count in the legend while a wider status chip is shown. */
/* ALPR keeps its nearby count in the legend while a wider status chip
(including ENABLING / DISABLING) is shown. */
.data-toggle-row[data-layer-id='alpr-cameras']
.data-toggle-right:has(
.data-toggle-btn:is(
.transitioning,
.feed-loading,
.feed-degraded,
.feed-stale,
+88 -5
View File
@@ -250,8 +250,12 @@
gap: 5px;
}
.radio-transport button {
min-width: 0;
min-height: 30px;
padding: 6px 3px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
border: 1px solid rgba(65, 220, 231, 0.28);
border-radius: 5px;
color: #8feaf2;
@@ -610,10 +614,14 @@
gap: 8px;
}
/* The name takes the room the status column leaves; a long word wraps inside
it instead of pushing the toggle out of line. */
.data-toggle-left {
display: flex;
flex: 1 1 auto;
align-items: center;
gap: 8px;
min-width: 0;
}
.data-icon {
@@ -621,16 +629,37 @@
line-height: 1;
}
/* 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. */
.data-toggle-left .data-icon {
flex: none;
width: 18px;
text-align: center;
}
.data-name {
min-width: 0;
font-family: var(--font-sans);
font-size: 13px;
font-weight: 500;
color: var(--text-primary);
letter-spacing: 0.3px;
overflow-wrap: anywhere;
}
/* 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 {
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;
}
.data-toggle-right {
display: flex;
flex: 0 1 auto;
align-items: center;
gap: 10px;
}
@@ -640,8 +669,56 @@
font-size: 11px;
letter-spacing: 0.5px;
color: var(--text-secondary);
min-width: 30px;
text-align: right;
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). */
.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. */
.data-toggle-row--count-line .data-toggle-top {
flex-wrap: wrap;
row-gap: 2px;
}
.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. */
.data-toggle-row--count-line .data-toggle-left {
flex-basis: 0;
}
.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. */
.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 {
@@ -662,8 +739,10 @@
min-width var(--transition-fast),
letter-spacing var(--transition-fast);
outline: none;
flex: none;
min-width: 38px;
text-align: center;
white-space: nowrap;
}
/* An inset ring stays visible inside the scrolling layer list. */
@@ -672,13 +751,15 @@
outline-offset: -3px;
}
/* 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. */
.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 {
min-width: 82px;
min-width: 72px;
letter-spacing: 0.8px;
}
@@ -883,14 +964,16 @@
.data-row-list-lead {
min-width: 34px;
flex: none;
white-space: nowrap;
opacity: 0.85;
}
/* The whole instruction wraps under itself rather than losing its tail:
"Turn right onto …" says nothing, and Russian or Spanish run longer. */
.data-row-list-text {
flex: 1 1 auto;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
overflow-wrap: anywhere;
}
/* A list line that states a limit rather than offering an action. */
+40 -6
View File
@@ -293,17 +293,34 @@
height: 18px;
font-weight: 700;
}
/* Outside A / B there is one chip (SHOW): it takes the whole row, so a long
translation is not cut to a stub. */
.recent-imagery-readout:not([data-mode='ab'])
.ri-card-chips
.ri-chip[data-slot='a'] {
grid-column: 1 / -1;
}
.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. */
/* 4. The selection bar: mode, then two rows that are always there. The slot
rows share the bar's columns (subgrid), so the tag column fits the longest
tag in either row and both rows stay aligned. */
.recent-imagery-readout .ri-selection {
display: grid;
grid-template-columns: minmax(44px, max-content) minmax(0, 1fr) 22px;
grid-template-rows: 20px 20px 20px;
column-gap: 6px;
row-gap: 4px;
padding: 5px;
border: 1px solid var(--weather-card-border);
border-radius: 6px;
}
.recent-imagery-readout .ri-mode {
grid-column: 1 / -1;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 4px;
@@ -312,14 +329,16 @@
height: 20px;
}
.recent-imagery-readout .ri-slot {
grid-column: 1 / -1;
display: grid;
grid-template-columns: 44px minmax(0, 1fr) 22px;
grid-template-columns: subgrid;
align-items: center;
column-gap: 6px;
height: 20px;
}
.recent-imagery-readout .ri-slot-tag {
height: 16px;
padding: 0 3px;
line-height: 16px;
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 3px;
@@ -347,6 +366,15 @@
color: var(--text-secondary);
font-style: italic;
}
/* A translated slot line (date · source · resolution · state) is longer:
let it take a second line rather than cut off the state at the end. */
html:not([lang='en']) .recent-imagery-readout .ri-slot {
height: auto;
min-height: 20px;
}
html:not([lang='en']) .recent-imagery-readout .ri-slot-value {
white-space: normal;
}
.recent-imagery-readout .ri-unpin {
width: 22px;
padding: 0;
@@ -355,19 +383,25 @@
}
/* 5. Opacity (both images), SWAP and export, one row. SWAP keeps its slot
while no swipe is live. */
while no swipe is live. A label longer than its 76px column widens it to
the label's longest word, and the value wraps under the label. */
.recent-imagery-readout .ri-controls {
display: grid;
grid-template-columns: 76px minmax(0, 1fr) 40px 128px;
grid-template-columns:
minmax(76px, min-content) minmax(0, 1fr) minmax(40px, max-content)
128px;
align-items: center;
column-gap: 6px;
height: 22px;
}
.recent-imagery-readout .ri-opacity-label {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: 4px;
align-content: center;
column-gap: 4px;
font-size: 8px;
line-height: 10px;
letter-spacing: 1px;
white-space: nowrap;
}
@@ -385,7 +419,7 @@
opacity: 0.35;
}
.recent-imagery-readout .ri-swap {
width: 40px;
min-width: 40px;
padding: 0 2px;
}
.recent-imagery-readout .ri-exports .rail-card-actions {
+23
View File
@@ -160,9 +160,20 @@
padding: 6px 8px;
font-family: var(--font-sans);
font-size: 12px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
outline: none;
}
/* A translated action longer than its button wraps inside the button instead
of running past it. English labels fit on one line, so the English layout
is unchanged. */
#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);
@@ -454,3 +465,15 @@
.director-sharing-dialog::backdrop {
background: rgba(0, 8, 14, 0.65);
}
html:not([lang='en']) #scene-select {
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.04em;
}
/* A translated scene name needs the width: the select takes its own row and
NEW / DELETE share the next one. */
html:not([lang='en']) #scene-select {
flex: 1 1 100%;
}
+14
View File
@@ -213,6 +213,20 @@ button.sl-status:focus-visible {
display: none;
}
/* Longer translations: rather than squeeze the hint into a narrow column
beside the button, put the button on its own line when both do not fit. */
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);
+60
View File
@@ -82,3 +82,63 @@
.gev-voice-cost-value[hidden] {
display: none;
}
/* The dock heading row is only as wide as the voice column, so the cost
cluster keeps its size and the state label gives way: a long translated
state (ПОДКЛЮЧЕНИЕ, CONECTANDO) ends in an ellipsis instead of pushing the
cost past the card edge. The kicker already collapses to zero width here;
taking it out of the row returns its gap to the state label. The row also
reaches left over the gutter beside the mic ring: at heading height the
ring has already curved away. English fits as it is, so this applies to
the other languages only. */
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 ("Ask or command", "Requesting microphone") run longer in other
languages; give them a second line from the spare space under the readout
rather than cutting them mid-word. */
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;
white-space: normal;
overflow-wrap: anywhere;
}
+14 -3
View File
@@ -147,9 +147,10 @@
.rail-card-line {
white-space: pre-line;
}
/* One line stays reserved so the card does not jump when a status appears;
a long (or translated) status wraps instead of being cut. */
.weather-card [data-line-id='status'] {
height: 1.45em;
overflow-y: auto;
min-height: 1.45em;
color: var(--accent);
}
.rail-card-ramp {
@@ -258,6 +259,7 @@ body.recording-mode .gev-wind-inspection-marker {
top: 8px;
left: 0;
transform: translateX(-50%);
white-space: nowrap;
font: 9px/1.45 var(--font-mono);
color: var(--text-dim);
}
@@ -311,9 +313,18 @@ body.recording-mode .gev-wind-inspection-marker {
.rail-card-compact.status {
color: var(--accent);
}
/* One label column per card, as wide as its longest label: chips line up
across rows, and a long translated label never runs under its chips. */
.rail-card-settings {
display: grid;
grid-template-columns: minmax(56px, max-content) minmax(0, 1fr);
column-gap: 6px;
}
.rail-card-setting {
display: grid;
grid-template-columns: 56px minmax(0, 1fr);
grid-column: 1 / -1;
grid-template-columns: minmax(56px, max-content) minmax(0, 1fr);
grid-template-columns: subgrid;
align-items: baseline;
gap: 6px;
margin-top: 6px;
+22 -22
View File
@@ -113,7 +113,7 @@
<div class="cockpit-topline">
<div>
<span class="cockpit-kicker" data-i18n>FIRST PERSON</span>
<strong id="cockpit-callsign">AIRCRAFT</strong>
<strong id="cockpit-callsign" data-i18n>AIRCRAFT</strong>
<span id="cockpit-aircraft-meta" class="cockpit-aircraft-meta" data-i18n>LIVE TRACK · COURSE ALIGNED</span>
</div>
<div class="cockpit-vision-controls" role="group" aria-label="Cockpit vision style" data-i18n-aria-label>
@@ -123,7 +123,7 @@
</button>
<button id="cockpit-vision-current" class="cockpit-vision-current" type="button" data-cockpit-vision="optical" aria-label="Current cockpit vision style: NORMAL. Activate for next style." title="Current style: NORMAL — click for next" data-i18n-aria-label data-i18n-title>
<small data-i18n>CURRENT</small>
<strong id="cockpit-vision-current-label" aria-live="polite">NORMAL</strong>
<strong id="cockpit-vision-current-label" aria-live="polite" data-i18n>NORMAL</strong>
</button>
<button id="cockpit-vision-next" class="cockpit-vision-nav" type="button" aria-label="Next cockpit vision style" title="Next vision style" data-i18n-aria-label data-i18n-title>
<small data-i18n>NEXT</small>
@@ -137,13 +137,13 @@
<div class="cockpit-readout cockpit-speed">
<span class="cockpit-readout-label" data-i18n>GROUND SPEED</span>
<strong id="cockpit-speed-value">---</strong>
<span class="cockpit-readout-unit">KTS</span>
<span class="cockpit-readout-unit" data-i18n>KTS</span>
</div>
<div class="cockpit-heading-value"><span id="cockpit-heading-value">---</span><small>°</small></div>
<div class="cockpit-readout cockpit-altitude">
<span class="cockpit-readout-label" data-i18n>ALTITUDE</span>
<strong id="cockpit-altitude-value">-----</strong>
<span class="cockpit-readout-unit">FT</span>
<span class="cockpit-readout-unit" data-i18n>FT</span>
</div>
</div>
<span class="cockpit-heading-caret material-symbols-outlined" aria-hidden="true">arrow_drop_down</span>
@@ -156,7 +156,7 @@
<aside id="cockpit-context" class="cockpit-context-window" aria-label="Contact cockpit summary" hidden data-i18n-aria-label>
<header class="cockpit-context-header">
<div>
<span class="cockpit-context-kicker">CONTACT</span>
<span data-i18n class="cockpit-context-kicker">CONTACT</span>
<strong id="cockpit-context-subject" data-i18n>CONTACTS · 250 KM</strong>
</div>
<nav class="cockpit-context-actions" aria-label="Contact navigation" data-i18n-aria-label>
@@ -173,10 +173,10 @@
<span class="cockpit-context-qualifier" data-i18n>CONTEXT ONLY</span>
</header>
<div class="cockpit-context-cohorts" role="list" aria-label="Nearby cohort counts" data-i18n-aria-label>
<div role="listitem" data-context-cohort="flights"><span>FLT</span><strong>—</strong><small>OpenSky</small></div>
<div role="listitem" data-context-cohort="military"><span>MIL</span><strong>—</strong><small>adsb.lol</small></div>
<div role="listitem" data-context-cohort="ais-live-vessels"><span>AIS</span><strong>—</strong><small>AISStream</small></div>
<div role="listitem" data-context-cohort="military-installations"><span>SITE</span><strong>—</strong><small>OpenStreetMap</small></div>
<div role="listitem" data-context-cohort="flights"><span data-i18n>FLT</span><strong>—</strong><small translate="no">OpenSky</small></div>
<div role="listitem" data-context-cohort="military"><span data-i18n>MIL</span><strong>—</strong><small translate="no">adsb.lol</small></div>
<div role="listitem" data-context-cohort="ais-live-vessels"><span translate="no">AIS</span><strong>—</strong><small translate="no">AISStream</small></div>
<div role="listitem" data-context-cohort="military-installations"><span data-i18n>SITE</span><strong>—</strong><small translate="no">OpenStreetMap</small></div>
</div>
<div class="cockpit-context-nearest">
<span id="cockpit-context-direction" class="material-symbols-outlined" aria-hidden="true">navigation</span>
@@ -197,8 +197,8 @@
aria-pressed="false"
title="Enable cockpit weather effects" data-i18n-aria-label data-i18n-title
>
<span>WX</span>
<strong id="cockpit-weather-state">OFF</strong>
<span data-i18n>WX</span>
<strong data-i18n id="cockpit-weather-state">OFF</strong>
</button>
<time id="cockpit-context-updated">--:--:--Z</time>
</footer>
@@ -223,7 +223,7 @@
</section>
<header class="cockpit-signal-header">
<div class="cockpit-brief-heading">
<span id="cockpit-brief-kicker" class="cockpit-signal-kicker"><i aria-hidden="true"></i> LIVE SIGNALS</span>
<span id="cockpit-brief-kicker" class="cockpit-signal-kicker" data-i18n-runtime><i aria-hidden="true"></i> LIVE SIGNALS</span>
<strong id="cockpit-brief-subtitle" data-i18n>OBSERVED / MAPPED PINGS</strong>
</div>
<nav class="cockpit-brief-actions" aria-label="Cockpit briefing controls" data-i18n-aria-label>
@@ -239,7 +239,7 @@
type="button"
aria-label="CYCLE OFF"
aria-pressed="false"
title="Cycle briefing pages automatically every 9 seconds (Signals → News → Local). Pauses while you hover or focus the panel. Live signal data refreshes continuously either way." data-i18n-title
title="Cycle briefing pages automatically every 9 seconds (Signals → News → Local). Pauses while you hover or focus the panel. Live signal data refreshes continuously either way." data-i18n-title data-i18n-aria-label data-i18n
>CYCLE OFF</button>
<button id="cockpit-signal-toggle" type="button" aria-label="Collapse cockpit briefing panel" aria-expanded="true" title="Collapse briefing panel" data-i18n-aria-label data-i18n-title>
<span class="material-symbols-outlined" aria-hidden="true">right_panel_close</span>
@@ -260,20 +260,20 @@
<small id="cockpit-local-coordinates">--</small>
</div>
<div class="cockpit-local-grid">
<div><span data-i18n>TEMP</span><strong id="cockpit-local-temperature">—</strong><small>OPEN-METEO</small></div>
<div><span data-i18n>TEMP</span><strong id="cockpit-local-temperature">—</strong><small translate="no">OPEN-METEO</small></div>
<div><span data-i18n>WIND</span><strong id="cockpit-local-wind">—</strong><small id="cockpit-local-wind-direction">—</small></div>
<div><span data-i18n>SKY</span><strong id="cockpit-local-condition">UNKNOWN</strong><small id="cockpit-local-cloud">—</small></div>
<div><span data-i18n>PRECIP</span><strong id="cockpit-local-precipitation">—</strong><small>MM</small></div>
<div><span data-i18n>SKY</span><strong id="cockpit-local-condition" data-i18n>UNKNOWN</strong><small id="cockpit-local-cloud">—</small></div>
<div><span data-i18n>PRECIP</span><strong id="cockpit-local-precipitation">—</strong><small data-i18n>MM</small></div>
</div>
<a class="cockpit-weather-credit" href="https://open-meteo.com/" target="_blank" rel="noopener">Weather data by Open-Meteo.com</a>
<a class="cockpit-weather-credit" href="https://open-meteo.com/" target="_blank" rel="noopener" data-i18n>Weather data by Open-Meteo.com</a>
</section>
</div>
<footer class="cockpit-signal-footer">
<span id="cockpit-brief-source" data-i18n>SOURCE-BACKED EVENTS · NO SYNTHETIC NEWS</span>
<nav class="cockpit-brief-tabs" aria-label="Cockpit briefing pages">
<button type="button" data-cockpit-brief-index="0" aria-label="Show Live Signals" aria-current="true" data-i18n-aria-label>SIG</button>
<button type="button" data-cockpit-brief-index="1" aria-label="Show Regional News" data-i18n-aria-label>NEWS</button>
<button type="button" data-cockpit-brief-index="2" aria-label="Show Local Info" data-i18n-aria-label>LOCAL</button>
<nav class="cockpit-brief-tabs" aria-label="Cockpit briefing pages" data-i18n-aria-label>
<button type="button" data-cockpit-brief-index="0" aria-label="Show Live Signals" aria-current="true" data-i18n-aria-label data-i18n>SIG</button>
<button type="button" data-cockpit-brief-index="1" aria-label="Show Regional News" data-i18n-aria-label data-i18n>NEWS</button>
<button type="button" data-cockpit-brief-index="2" aria-label="Show Local Info" data-i18n-aria-label data-i18n>LOCAL</button>
</nav>
</footer>
</aside>
@@ -313,7 +313,7 @@
</div>
</div>
</aside>
<div class="cockpit-help">ESC EXIT <span>·</span> C TOGGLE</div>
<div class="cockpit-help"><bdi data-i18n>ESC EXIT</bdi> <span>·</span> <bdi data-i18n>C TOGGLE</bdi></div>
</section>
<nav id="view-switcher" aria-label="View switcher" data-i18n-aria-label>
+5 -5
View File
@@ -7,7 +7,7 @@
<button id="control-panel-toggle" class="panel-header dock-tray-toggle" type="button"
data-dock-toggle-target="control-panel" aria-expanded="false"
aria-controls="control-panel-popover" aria-label="Expand Visual Presets" data-i18n-aria-label>
<span class="panel-title"><span class="dock-label-icon dock-label-icon-presets" aria-hidden="true"></span>VISUAL PRESETS</span>
<span class="panel-title" data-i18n-runtime><span class="dock-label-icon dock-label-icon-presets" aria-hidden="true"></span>VISUAL PRESETS</span>
<span class="panel-divider"></span>
</button>
<!-- Language selector: buttons are rendered by applicationShell _initLocaleSelector(), one per shipped locale. -->
@@ -53,15 +53,15 @@
</div>
<section class="map-source-section" aria-labelledby="map-source-label">
<div class="map-source-heading">
<span id="map-source-label">MAP SOURCE</span>
<span data-i18n id="map-source-label">MAP SOURCE</span>
<span id="map-stack-status" class="pp-stack-status" aria-live="polite">3D</span>
</div>
<div id="map-stack-chips" class="map-stack-chip-row" role="group" aria-label="Map source"></div>
<div id="map-stack-chips" class="map-stack-chip-row" role="group" aria-label="Map source" data-i18n-aria-label></div>
</section>
</div>
<div id="style-mini-status" class="panel-mini-status" aria-live="polite">
<span class="panel-mini-label" data-i18n>Style</span>
<span id="style-mini-value" class="panel-mini-value">NORMAL</span>
<span id="style-mini-value" class="panel-mini-value" data-i18n>NORMAL</span>
</div>
</div>
</div>
@@ -72,7 +72,7 @@
<div class="location-inner">
<div class="location-toolbar">
<button id="location-bar-toggle" class="dock-tray-toggle" type="button" data-dock-toggle-target="location-bar" aria-controls="location-bar-popover" aria-expanded="false" aria-label="Expand LOCATION" title="Expand LOCATION" data-i18n-aria-label data-i18n-title>
<span class="location-toolbar-label"><span class="dock-label-icon dock-label-icon-location" aria-hidden="true"></span>LOCATION</span>
<span class="location-toolbar-label" data-i18n-runtime><span class="dock-label-icon dock-label-icon-location" aria-hidden="true"></span>LOCATION</span>
</button>
<button class="panel-collapse-btn" data-collapse-target="location-bar" title="Collapse panel" data-i18n-title>+</button>
</div>
+65 -66
View File
@@ -4,10 +4,10 @@
<div class="panel-glow"></div>
<div class="weather-panel-inner" data-rail-scroller>
<div class="panel-header">
<span class="panel-title">WEATHER</span>
<span id="weather-panel-count" aria-label="Active weather products">0</span>
<span class="panel-title" data-i18n>WEATHER</span>
<span id="weather-panel-count" aria-label="Active weather products" data-i18n-aria-label>0</span>
<span class="panel-divider"></span>
<button class="panel-collapse-btn" data-collapse-target="weather-panel" type="button" title="Collapse panel">▼</button>
<button class="panel-collapse-btn" data-collapse-target="weather-panel" type="button" title="Collapse panel" data-i18n-title>▼</button>
</div>
<div id="weather-panel-body" data-rail-scroller></div>
</div>
@@ -16,10 +16,10 @@
<div class="panel-glow"></div>
<div class="recent-imagery-panel-inner" data-rail-scroller>
<div class="panel-header">
<span class="panel-title">RECENT IMAGERY</span>
<span id="recent-imagery-panel-count" aria-label="Imagery days in the box"></span>
<span class="panel-title" data-i18n>RECENT IMAGERY</span>
<span id="recent-imagery-panel-count" aria-label="Imagery days in the box" data-i18n-aria-label></span>
<span class="panel-divider"></span>
<button class="panel-collapse-btn" data-collapse-target="recent-imagery-panel" type="button" title="Collapse panel">▼</button>
<button class="panel-collapse-btn" data-collapse-target="recent-imagery-panel" type="button" title="Collapse panel" data-i18n-title>▼</button>
</div>
<div id="recent-imagery-panel-body" data-rail-scroller></div>
</div>
@@ -75,7 +75,7 @@
</div>
<div id="context-mode-standby" class="context-mode-standby">
<strong data-i18n>SELECT CONTEXT</strong>
<span>CONTACTS — nearest planes · vessels · sites<br>SPACE MISSIONS — launches &amp; orbital assets</span>
<span data-i18n-runtime>CONTACTS — nearest planes · vessels · sites<br>SPACE MISSIONS — launches &amp; orbital assets</span>
</div>
<section id="context-flights-view" class="context-mode-view" role="tabpanel" aria-labelledby="global-context-flights-btn" hidden>
<div class="global-context-actions" aria-label="Contact Context actions" data-i18n-aria-label>
@@ -114,7 +114,7 @@
<div class="radio-panel-inner">
<div class="panel-header">
<span class="panel-title" data-i18n>RADIO</span>
<span id="radio-layer-state" class="radio-layer-state">OFF</span>
<span id="radio-layer-state" class="radio-layer-state" data-i18n>OFF</span>
<span class="panel-divider"></span>
<button class="panel-collapse-btn" data-collapse-target="radio-panel" title="Expand Radio" aria-label="Expand Radio section" data-i18n-title data-i18n-aria-label>+</button>
</div>
@@ -132,14 +132,14 @@
</div>
<div id="radio-tuner" class="radio-tuner" hidden>
<div class="radio-tuner-header">
<span id="radio-tuner-band-label">DIRECTORY BAND</span>
<span data-i18n id="radio-tuner-band-label">DIRECTORY BAND</span>
<output id="radio-tuner-value" for="radio-tuner-slider" data-i18n>DRAG TO TUNE</output>
</div>
<div class="radio-tuner-dial">
<div class="radio-tuner-scale" aria-hidden="true"></div>
<div id="radio-tuner-needle" class="radio-tuner-needle" aria-hidden="true"></div>
<input id="radio-tuner-slider" type="range" min="0" max="0" step="1" value="0"
aria-label="Tune available internet radio stations" aria-valuetext="No station available" disabled />
aria-label="Tune available internet radio stations" aria-valuetext="No station available" disabled data-i18n-aria-label />
</div>
<div class="radio-tuner-readout">
<span id="radio-tuner-station" data-i18n>ALL · DRAG THE NEEDLE</span>
@@ -160,81 +160,80 @@
<div id="radio-playback-state" class="radio-playback-state" role="status" aria-live="polite" data-i18n>Radio off</div>
<div class="radio-links">
<a id="radio-station-homepage" href="#" target="_blank" rel="noopener noreferrer" hidden data-i18n>STATION SITE</a>
<a href="https://www.radio-browser.info/" target="_blank" rel="noopener noreferrer">DIRECTORY: RADIO BROWSER</a>
<a href="https://www.radio-browser.info/" target="_blank" rel="noopener noreferrer" data-i18n>DIRECTORY: RADIO BROWSER</a>
</div>
<p class="radio-privacy" data-i18n>Audio connects directly to the broadcaster after you press play. Your IP is visible to that broadcaster.</p>
<p class="radio-privacy">Audio connects directly to the broadcaster after you press play. Your IP is visible to that broadcaster.</p>
<div id="sdr-radio-card" class="sdr-radio-card" role="group" aria-label="Local RTL-SDR receiver">
<div id="sdr-radio-card" class="sdr-radio-card" role="group" aria-label="Local RTL-SDR receiver" data-i18n-aria-label>
<div class="sdr-radio-heading">
<strong>LOCAL RTL-SDR</strong>
<span id="sdr-connection-state">NO USB</span>
<strong data-i18n>LOCAL RTL-SDR</strong>
<span id="sdr-connection-state" data-i18n-runtime>NO USB</span>
</div>
<p>Chrome/Edge · WebUSB · one local tuner shared by FM and the Local ADS-B layer.</p>
<p data-i18n>Chrome/Edge · WebUSB · one local tuner shared by FM and the Local ADS-B layer.</p>
<div class="sdr-radio-actions">
<button id="sdr-connect-btn" class="panel-layer-toggle" type="button" aria-pressed="false">CONNECT</button>
<button id="sdr-locate-btn" type="button" title="Use your location for faster ADS-B position decoding">LOCATE</button>
<button id="sdr-change-device-btn" type="button" title="Choose a different RTL-SDR or receiver channel">CHANGE DEVICE</button>
<button id="sdr-connect-btn" class="panel-layer-toggle" type="button" aria-pressed="false" data-i18n>CONNECT</button>
<button id="sdr-locate-btn" type="button" title="Use your location for faster ADS-B position decoding" data-i18n-title data-i18n>LOCATE</button>
<button id="sdr-change-device-btn" type="button" title="Choose a different RTL-SDR or receiver channel" data-i18n-title data-i18n>CHANGE DEVICE</button>
</div>
<div class="sdr-mode-row" role="group" aria-label="RTL-SDR receiver mode">
<button id="sdr-mode-fm-btn" class="active" type="button" aria-pressed="true">FM</button>
<button id="sdr-mode-adsb-btn" type="button" aria-pressed="false">ADS-B · 1090</button>
<div class="sdr-mode-row" role="group" aria-label="RTL-SDR receiver mode" data-i18n-aria-label>
<button id="sdr-mode-fm-btn" class="active" type="button" aria-pressed="true" translate="no">FM</button>
<button id="sdr-mode-adsb-btn" type="button" aria-pressed="false" translate="no">ADS-B · 1090</button>
</div>
<div class="sdr-gain-row">
<label for="sdr-gain-select">GAIN</label>
<select id="sdr-gain-select" aria-label="RTL-SDR tuner gain for the current mode">
<option value="auto">AUTO</option>
<option value="0.0">0.0 dB</option>
<option value="0.9">0.9 dB</option>
<option value="1.4">1.4 dB</option>
<option value="2.7">2.7 dB</option>
<option value="3.7">3.7 dB</option>
<option value="7.7">7.7 dB</option>
<option value="8.7">8.7 dB</option>
<option value="12.5">12.5 dB</option>
<option value="14.4">14.4 dB</option>
<option value="15.7">15.7 dB</option>
<option value="16.6">16.6 dB</option>
<option value="19.7">19.7 dB</option>
<option value="20.7">20.7 dB</option>
<option value="22.9">22.9 dB</option>
<option value="25.4">25.4 dB</option>
<option value="28.0">28.0 dB</option>
<option value="29.7">29.7 dB</option>
<option value="32.8">32.8 dB</option>
<option value="33.8">33.8 dB</option>
<option value="36.4">36.4 dB</option>
<option value="37.2">37.2 dB</option>
<option value="38.6">38.6 dB</option>
<option value="40.2">40.2 dB</option>
<option value="42.1">42.1 dB</option>
<option value="43.4">43.4 dB</option>
<option value="43.9">43.9 dB</option>
<option value="44.5">44.5 dB</option>
<option value="48.0">48.0 dB</option>
<option value="49.6">49.6 dB</option>
<label for="sdr-gain-select" data-i18n>GAIN</label>
<select id="sdr-gain-select" aria-label="RTL-SDR tuner gain for the current mode" data-i18n-aria-label>
<option value="auto" data-i18n>AUTO</option>
<option value="0.0" translate="no">0.0 dB</option>
<option value="0.9" translate="no">0.9 dB</option>
<option value="1.4" translate="no">1.4 dB</option>
<option value="2.7" translate="no">2.7 dB</option>
<option value="3.7" translate="no">3.7 dB</option>
<option value="7.7" translate="no">7.7 dB</option>
<option value="8.7" translate="no">8.7 dB</option>
<option value="12.5" translate="no">12.5 dB</option>
<option value="14.4" translate="no">14.4 dB</option>
<option value="15.7" translate="no">15.7 dB</option>
<option value="16.6" translate="no">16.6 dB</option>
<option value="19.7" translate="no">19.7 dB</option>
<option value="20.7" translate="no">20.7 dB</option>
<option value="22.9" translate="no">22.9 dB</option>
<option value="25.4" translate="no">25.4 dB</option>
<option value="28.0" translate="no">28.0 dB</option>
<option value="29.7" translate="no">29.7 dB</option>
<option value="32.8" translate="no">32.8 dB</option>
<option value="33.8" translate="no">33.8 dB</option>
<option value="36.4" translate="no">36.4 dB</option>
<option value="37.2" translate="no">37.2 dB</option>
<option value="38.6" translate="no">38.6 dB</option>
<option value="40.2" translate="no">40.2 dB</option>
<option value="42.1" translate="no">42.1 dB</option>
<option value="43.4" translate="no">43.4 dB</option>
<option value="43.9" translate="no">43.9 dB</option>
<option value="44.5" translate="no">44.5 dB</option>
<option value="48.0" translate="no">48.0 dB</option>
<option value="49.6" translate="no">49.6 dB</option>
</select>
</div>
<div id="sdr-adsb-stats" class="sdr-adsb-stats" role="status" aria-live="off" hidden>
<span><output id="sdr-stat-rate">--</output> MSG/S</span>
<span><output id="sdr-stat-heard">0</output> HEARD</span>
<span><output id="sdr-stat-positioned">0</output> POSITIONED</span>
<span>IQ <output id="sdr-stat-iq">--</output></span>
<span><output id="sdr-stat-rate">--</output> <span data-i18n>MSG/S</span></span>
<span><output id="sdr-stat-heard">0</output> <span data-i18n>HEARD</span></span>
<span><output id="sdr-stat-positioned">0</output> <span data-i18n>POSITIONED</span></span>
<span translate="no">IQ <output id="sdr-stat-iq">--</output></span>
</div>
<div class="sdr-frequency-row">
<label for="sdr-frequency-input">FM MHZ</label>
<label for="sdr-frequency-input" data-i18n>FM MHZ</label>
<input id="sdr-frequency-input" type="number" min="87.5" max="108" step="0.1" value="98.5" inputmode="decimal" />
<button id="sdr-tune-btn" type="button">TUNE</button>
<button id="sdr-tune-btn" type="button" data-i18n>TUNE</button>
</div>
<div class="sdr-seek-row" role="group" aria-label="Seek broadcast FM stations">
<button id="sdr-seek-back-btn" type="button" disabled>◀ SEEK</button>
<button id="sdr-seek-forward-btn" type="button" disabled>SEEK ▶</button>
<div class="sdr-seek-row" role="group" aria-label="Seek broadcast FM stations" data-i18n-aria-label>
<button id="sdr-seek-back-btn" type="button" disabled data-i18n>◀ SEEK</button>
<button id="sdr-seek-forward-btn" type="button" disabled data-i18n>SEEK ▶</button>
</div>
<label class="radio-volume-row" for="sdr-volume">
<span>SDR VOL</span>
<input id="sdr-volume" class="gev-quantitative-slider" type="range" min="0" max="100" value="80" aria-label="Local SDR volume" />
<span data-i18n>SDR VOL</span>
<input id="sdr-volume" class="gev-quantitative-slider" type="range" min="0" max="100" value="80" aria-label="Local SDR volume" data-i18n-aria-label />
<output id="sdr-volume-value" class="gev-slider-value" for="sdr-volume">80%</output>
</label>
<div id="sdr-status" class="radio-playback-state" role="status" aria-live="polite">Connect an RTL-SDR to begin.</div>
<div id="sdr-status" class="radio-playback-state" role="status" aria-live="polite" data-i18n>Connect an RTL-SDR to begin.</div>
<div id="sdr-feed-status" class="sdr-feed-status" role="status" aria-live="off" hidden></div>
</div>
</div>
+15 -15
View File
@@ -1,7 +1,7 @@
<!-- Post-processing toggles (top-right) -->
<div id="pp-toggles" class="panel-collapsible collapsed" data-panel-id="pp-toggles">
<div class="compact pp-header-row">
<span class="pp-header-label">DISPLAY</span>
<span data-i18n class="pp-header-label">DISPLAY</span>
<span class="panel-divider" aria-hidden="true"></span>
<button class="panel-collapse-btn pp-collapse-btn" data-collapse-target="pp-toggles" title="Collapse panel" data-i18n-title>&#x25C0;</button>
</div>
@@ -9,7 +9,7 @@
<div class="pp-toggle-group">
<button class="pp-toggle-btn" id="hud-toggle" title="Intelligence HUD (H)" data-i18n-title>
<span class="pp-icon">&#x25CE;</span>
<span class="pp-label">HUD</span>
<span class="pp-label" translate="no">HUD</span>
</button>
<div class="pp-slider-row" id="hud-layout-row">
<span class="pp-slider-mini-label" data-i18n>Layout</span>
@@ -17,37 +17,37 @@
<option value="tactical" data-i18n>Tactical</option>
<option value="operator" data-i18n>Operator</option>
<option value="minimal" data-i18n>Minimal</option>
<option value="cyber">Cyber</option>
<option value="cyber" data-i18n>Cyber</option>
</select>
</div>
<div class="pp-slider-row cyber-sonar-control cyber-sonar-toggle-row" id="cyber-sonar-row">
<span class="pp-slider-mini-label">Sonar</span>
<button class="pp-mode-btn active" id="cyber-sonar-toggle" title="Cyber sonar contact scanner" aria-label="Cyber sonar contact scanner" aria-pressed="true">ON</button>
<span class="pp-slider-mini-label" data-i18n>Sonar</span>
<button class="pp-mode-btn active" id="cyber-sonar-toggle" title="Cyber sonar contact scanner" aria-label="Cyber sonar contact scanner" aria-pressed="true" data-i18n-title data-i18n-aria-label data-i18n>ON</button>
</div>
<output id="cyber-sonar-status" class="cyber-sonar-control" role="status"></output>
<div class="pp-slider-row cyber-sonar-control cyber-sonar-setting" id="cyber-sonar-rings-row">
<label class="pp-slider-mini-label" for="cyber-sonar-rings">Rings</label>
<input type="range" class="pp-slider" id="cyber-sonar-rings" min="3" max="12" step="1" value="8" aria-label="Sonar ring count" />
<label class="pp-slider-mini-label" for="cyber-sonar-rings" data-i18n>Rings</label>
<input type="range" class="pp-slider" id="cyber-sonar-rings" min="3" max="12" step="1" value="8" aria-label="Sonar ring count" data-i18n-aria-label />
<span class="pp-slider-value" id="cyber-sonar-rings-value">8</span>
</div>
<div class="pp-slider-row cyber-sonar-control cyber-sonar-setting" id="cyber-sonar-range-row">
<label class="pp-slider-mini-label" for="cyber-sonar-range">Range</label>
<input type="range" class="pp-slider" id="cyber-sonar-range" min="60" max="120" step="1" value="100" aria-label="Sonar ring range" />
<label class="pp-slider-mini-label" for="cyber-sonar-range" data-i18n>Range</label>
<input type="range" class="pp-slider" id="cyber-sonar-range" min="60" max="120" step="1" value="100" aria-label="Sonar ring range" data-i18n-aria-label />
<span class="pp-slider-value" id="cyber-sonar-range-value">100%</span>
</div>
<div class="pp-slider-row cyber-sonar-control cyber-sonar-setting" id="cyber-sonar-intensity-row">
<label class="pp-slider-mini-label" for="cyber-sonar-intensity">Power</label>
<input type="range" class="pp-slider" id="cyber-sonar-intensity" min="0" max="100" step="1" value="70" aria-label="Sonar intensity" />
<label class="pp-slider-mini-label" for="cyber-sonar-intensity" data-i18n>Power</label>
<input type="range" class="pp-slider" id="cyber-sonar-intensity" min="0" max="100" step="1" value="70" aria-label="Sonar intensity" data-i18n-aria-label />
<span class="pp-slider-value" id="cyber-sonar-intensity-value">70%</span>
</div>
<div class="pp-slider-row cyber-sonar-control cyber-sonar-setting" id="cyber-sonar-opacity-row">
<label class="pp-slider-mini-label" for="cyber-sonar-opacity">Opacity</label>
<input type="range" class="pp-slider" id="cyber-sonar-opacity" min="35" max="100" step="1" value="84" aria-label="Cyber contact and label opacity" title="Minimum opacity for map dots, icons, models, brackets, and labels between sonar passes" />
<label class="pp-slider-mini-label" for="cyber-sonar-opacity" data-i18n>Opacity</label>
<input type="range" class="pp-slider" id="cyber-sonar-opacity" min="35" max="100" step="1" value="84" aria-label="Cyber contact and label opacity" title="Minimum opacity for map dots, icons, models, brackets, and labels between sonar passes" data-i18n-aria-label data-i18n-title />
<span class="pp-slider-value" id="cyber-sonar-opacity-value">84%</span>
</div>
<div class="pp-slider-row cyber-sonar-control cyber-sonar-setting" id="cyber-sonar-sector-row">
<label class="pp-slider-mini-label" for="cyber-sonar-sector">Sector</label>
<input type="range" class="pp-slider" id="cyber-sonar-sector" min="8" max="60" step="1" value="24" aria-label="Sonar sector width" />
<label class="pp-slider-mini-label" for="cyber-sonar-sector" data-i18n>Sector</label>
<input type="range" class="pp-slider" id="cyber-sonar-sector" min="8" max="60" step="1" value="24" aria-label="Sonar sector width" data-i18n-aria-label />
<span class="pp-slider-value" id="cyber-sonar-sector-value">24°</span>
</div>
</div>
+1 -1
View File
@@ -5,7 +5,7 @@
<div id="loading-screen">
<div class="loader-content">
<span class="loader-logo brand-logo" data-logo-gaze data-logo-src="/logo.svg" aria-hidden="true"><img src="/logo.svg" alt="" /></span>
<h2>GOD'S EYE <span class="title-accent">VIEW</span></h2>
<h2 translate="no">GOD'S EYE <span class="title-accent">VIEW</span></h2>
<p class="loader-status" data-i18n>Initializing photorealistic world...</p>
</div>
</div>
+10 -10
View File
@@ -44,7 +44,7 @@
</div>
<div class="cctv-controls">
<button id="cctv-projection-btn" class="scene-btn" data-i18n>PROJECTION ON</button>
<span id="cctv-quality-chip">CAL · --</span>
<span id="cctv-quality-chip" data-i18n-runtime>CAL · --</span>
</div>
<div class="cctv-calibration-block">
<div class="cctv-calibration-header">
@@ -52,11 +52,11 @@
<button id="cctv-adjust-btn" class="scene-btn" title="Drag the camera in the world: rings rotate, arrows move, handles set range/FOV" data-i18n-title data-i18n>ADJUST</button>
</div>
<div id="cctv-cal-readout" class="cctv-cal-readout" aria-label="Camera pose — click a value to type" data-i18n-aria-label>
<button class="cctv-cal-value" data-cal-field="heading" title="Heading (compass °) — click to type" data-i18n-title>HDG --</button>
<button class="cctv-cal-value" data-cal-field="pitch" title="Pitch (° up/down) — click to type" data-i18n-title>PITCH --</button>
<button class="cctv-cal-value" data-cal-field="fov" title="Horizontal FOV (°) — click to type" data-i18n-title>FOV --</button>
<button class="cctv-cal-value" data-cal-field="range" title="Range / monitor-plane distance (m) — click to type" data-i18n-title>RANGE --</button>
<button class="cctv-cal-value" data-cal-field="height" title="Mount height above ground (m) — click to type" data-i18n-title>HGT --</button>
<button class="cctv-cal-value" data-cal-field="heading" title="Heading (compass °) — click to type" data-i18n-title data-i18n-runtime>HDG --</button>
<button class="cctv-cal-value" data-cal-field="pitch" title="Pitch (° up/down) — click to type" data-i18n-title data-i18n-runtime>PITCH --</button>
<button class="cctv-cal-value" data-cal-field="fov" title="Horizontal FOV (°) — click to type" data-i18n-title data-i18n-runtime>FOV --</button>
<button class="cctv-cal-value" data-cal-field="range" title="Range / monitor-plane distance (m) — click to type" data-i18n-title data-i18n-runtime>RANGE --</button>
<button class="cctv-cal-value" data-cal-field="height" title="Mount height above ground (m) — click to type" data-i18n-title data-i18n-runtime>HGT --</button>
<button class="cctv-cal-value" data-cal-field="north" title="North offset from catalog position (m) — click to type" data-i18n-title>ΔN --</button>
<button class="cctv-cal-value" data-cal-field="east" title="East offset from catalog position (m) — click to type" data-i18n-title>ΔE --</button>
</div>
@@ -78,8 +78,8 @@
<div class="panel-header">
<span class="panel-title" data-i18n>STREET LEVEL</span>
<span class="panel-divider"></span>
<button id="sl-status" class="sl-status" type="button" aria-pressed="false" title="Turn Street Level on or off">OFF</button>
<button class="panel-collapse-btn" data-collapse-target="street-level-panel" title="Expand panel">◀</button>
<button id="sl-status" class="sl-status" type="button" aria-pressed="false" title="Turn Street Level on or off" data-i18n-title data-i18n>OFF</button>
<button class="panel-collapse-btn" data-collapse-target="street-level-panel" title="Expand panel" data-i18n-title>◀</button>
</div>
<div id="sl-body" class="sl-body">
<div class="sl-main">
@@ -129,7 +129,7 @@
<div id="sl-since-row" class="sl-since">
<label class="cctv-summary-label" for="sl-since" data-i18n>SINCE</label>
<input id="sl-since" class="sl-range" type="range" min="0" max="8" step="1" value="0" aria-valuetext="ANY DATE" aria-describedby="sl-since-label sl-since-note" />
<output id="sl-since-label" class="sl-since-label" for="sl-since">ANY DATE</output>
<output id="sl-since-label" class="sl-since-label" for="sl-since" data-i18n>ANY DATE</output>
<small id="sl-since-note" class="sl-since-note" hidden></small>
</div>
</fieldset>
@@ -137,7 +137,7 @@
<!-- The hint says how to open imagery; the button does it without a pointer. -->
<div class="sl-meta-row">
<div id="sl-coverage-meta" class="sl-meta"></div>
<button id="sl-nearest-btn" class="scene-btn sl-nearest-btn" type="button" hidden data-i18n>OPEN STREET VIEW</button>
<button id="sl-nearest-btn" class="scene-btn sl-nearest-btn" type="button" hidden data-i18n-runtime>OPEN STREET VIEW</button>
</div>
</div>
</div>
+4 -4
View File
@@ -10,14 +10,14 @@
<!-- Title -->
<div id="title-bar">
<div class="title-glow"></div>
<h1><span class="title-logo brand-logo" data-logo-gaze data-logo-src="/logo.svg" aria-hidden="true"><img src="/logo.svg" alt="" /></span> <span>GOD'S EYE <span class="title-accent">VIEW</span></span></h1>
<h1><span class="title-logo brand-logo" data-logo-gaze data-logo-src="/logo.svg" aria-hidden="true"><img src="/logo.svg" alt="" /></span> <span translate="no">GOD'S EYE <span class="title-accent">VIEW</span></span></h1>
<p class="subtitle" data-i18n>NO PLACE LEFT BEHIND</p>
</div>
<!-- Style indicator -->
<div id="style-indicator">
<span class="indicator-label" data-i18n>ACTIVE STYLE</span>
<span class="indicator-value" id="active-style-name">NORMAL</span>
<span class="indicator-value" id="active-style-name" data-i18n>NORMAL</span>
</div>
<!-- Top-center camera and share actions -->
@@ -28,10 +28,10 @@
<button id="share-btn" type="button" aria-label="Copy share link" title="Copy share link" data-i18n-aria-label data-i18n-title>
<span class="share-icon" aria-hidden="true">&#x1F517;</span>
</button>
<button id="tilt-map-view" type="button" aria-label="Tilt map to oblique view" aria-pressed="false" title="Toggle straight-down and tilted map views">
<button id="tilt-map-view" type="button" aria-label="Tilt map to oblique view" aria-pressed="false" title="Toggle straight-down and tilted map views" data-i18n-aria-label data-i18n-title>
<span class="material-symbols-outlined" aria-hidden="true">view_in_ar</span>
</button>
<button id="north-up-view" type="button" aria-label="Reset map to north up" title="Reset map bearing to north">
<button id="north-up-view" type="button" aria-label="Reset map to north up" title="Reset map bearing to north" data-i18n-aria-label data-i18n-title>
<span class="camera-compass-n" aria-hidden="true">N</span>
<span class="camera-compass-needle material-symbols-outlined" aria-hidden="true">navigation</span>
</button>
+2 -2
View File
@@ -12,7 +12,7 @@
— including the unspaced em dash. The arc it has to carry: this looks
like something you should not have access to → the sources are all
public → the data is real. Tile subcopy stays purely functional. -->
<p id="first-run-description">It feels like a forbidden cockpit—then you realize the sources are public and the data is real.</p>
<p id="first-run-description" data-i18n-runtime>It feels like a forbidden cockpit—then you realize the sources are public and the data is real.</p>
<div class="first-run-choices">
<button type="button" data-first-run-choice="contacts">
<span class="material-symbols-outlined" aria-hidden="true">radar</span>
@@ -30,7 +30,7 @@
<!-- Subcopy names BOTH feeds this tile turns on, because the launcher
optimizes for the fully configured experience (owner ruling,
2026-08-23) — see the mission table in src/firstRunExperience.js. -->
<span><strong data-first-run-environmental-title>ENVIRONMENTAL</strong><small>Live earthquakes and active fires, from USGS and NASA</small></span>
<span data-i18n-runtime><strong data-first-run-environmental-title>ENVIRONMENTAL</strong><small>Live earthquakes and active fires, from USGS and NASA</small></span>
<span class="material-symbols-outlined first-run-arrow" aria-hidden="true">arrow_forward</span>
</button>
<button type="button" class="first-run-explore" data-first-run-choice="explore">
+49
View File
@@ -0,0 +1,49 @@
import { formatNumber, msg, t } from '../i18n/index.js';
// Unit ids stay English in data, params and AI-tool readings; only the label
// shown on the page is translated. dBZ and °C read the same in every language.
const UNIT_LABELS = Object.freeze({
'km/h': msg('km/h'),
'm/s': msg('m/s'),
mph: msg('mph'),
hPa: msg('hPa'),
kt: msg('kt'),
kts: msg('kts'),
ft: msg('ft'),
km: msg('km'),
m: msg('m'),
h: msg('h'),
min: msg('min'),
s: msg('s'),
dB: msg('dB'),
});
/** The display label for a unit id; unknown units pass through unchanged. */
export function unitLabel(unit) {
return Object.hasOwn(UNIT_LABELS, unit) ? t(UNIT_LABELS[unit]) : unit || '';
}
/**
* A number and its unit that never break apart: "22,0 км/ч". Readings carry
* no digit grouping ("1008 hPa"), as on weather charts.
*/
export function formatQuantity(value, unit, options) {
const label = unitLabel(unit);
const number = formatNumber(value, { useGrouping: false, ...options });
return label ? `${number}\u00a0${label}` : number;
}
/**
* A legend stop such as "0.1", "-40" or "300+" in the active language.
* Anything that is not a plain number is shown as given.
*/
export function formatScaleLabel(label) {
const match = /^(-?\d+(?:\.\d+)?)(\+?)$/.exec(String(label ?? ''));
if (!match) return String(label ?? '');
const decimals = match[1].split('.')[1]?.length || 0;
return `${formatNumber(Number(match[1]), {
useGrouping: false,
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
})}${match[2]}`;
}
+46
View File
@@ -0,0 +1,46 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { setLocale } from '../i18n/index.js';
import { formatQuantity, formatScaleLabel, unitLabel } from './unitLabels.js';
test('English readings and scales read exactly as the unit ids', () => {
setLocale('en');
assert.equal(unitLabel('km/h'), 'km/h');
assert.equal(unitLabel('°C'), '°C');
assert.equal(unitLabel('dBZ'), 'dBZ');
assert.equal(unitLabel(''), '');
assert.equal(formatQuantity(95, 'kt'), '95 kt');
assert.equal(formatQuantity(1006, 'hPa'), '1006 hPa');
assert.equal(
formatQuantity(22, 'km/h', {
minimumFractionDigits: 1,
maximumFractionDigits: 1,
}),
'22.0 km/h',
);
assert.deepEqual(
['0.1', '-40', '1050+', '300+', 'Calm'].map(formatScaleLabel),
['0.1', '-40', '1050+', '300+', 'Calm'],
);
});
test('other languages get their decimal separator and no digit grouping', () => {
try {
setLocale('ru');
assert.match(
formatQuantity(22, 'km/h', {
minimumFractionDigits: 1,
maximumFractionDigits: 1,
}),
/^22,0 \S+$/,
);
assert.match(formatQuantity(1050, 'hPa'), /^1050 /);
assert.deepEqual(['0.1', '-40', '1050+'].map(formatScaleLabel), [
'0,1',
'-40',
'1050+',
]);
} finally {
setLocale('en');
}
});
+12 -8
View File
@@ -5,6 +5,7 @@ import { snowShader } from '../styles/snow.js';
import { nightVisionShader } from '../styles/surveillance.js';
import { thermalShader } from '../styles/thermal.js';
import { BLOOM_INTENSITY_DEFAULT } from '../bloom.js';
import { msg } from '../i18n/index.js';
/** Duration (ms) for shader intensity crossfade between style presets. */
export const TRANSITION_DURATION_MS = 500;
@@ -144,13 +145,16 @@ export const SHARPEN_SHADER = /* glsl */ `
}
`;
/** Stable display labels for the active style and inherited Cockpit vision. */
/**
* Stable display labels for the active style and inherited Cockpit vision.
* English source text: render through t().
*/
export const STYLE_STATUS_LABELS = {
normal: 'NORMAL',
retro: 'CRT',
surveillance: 'NVG',
thermal: 'FLIR',
anime: 'ANIME',
noir: 'NOIR',
snow: 'SNOW',
normal: msg('NORMAL'),
retro: msg('CRT'),
surveillance: msg('NVG'),
thermal: msg('FLIR'),
anime: msg('ANIME'),
noir: msg('NOIR'),
snow: msg('SNOW'),
};
+7 -7
View File
@@ -672,7 +672,7 @@ export class VisualSettings {
const settings = applyCyberSonarSettings(readCyberSonarSettings());
this._cyberSonarBtn.classList.toggle('active', enabled);
this._cyberSonarBtn.setAttribute('aria-pressed', String(enabled));
this._cyberSonarBtn.textContent = enabled ? 'ON' : 'OFF';
this._cyberSonarBtn.textContent = enabled ? t('ON') : t('OFF');
for (const [input, output, value, suffix] of [
[this._cyberSonarRings, this._cyberSonarRingsValue, settings.rings, ''],
[this._cyberSonarRange, this._cyberSonarRangeValue, settings.range, '%'],
@@ -1595,9 +1595,9 @@ export class VisualSettings {
});
// Update style indicator
const displayNames = { surveillance: 'NVG', thermal: 'FLIR', retro: 'CRT' };
this._styleIndicator.textContent =
displayNames[styleName] || styleName.toUpperCase();
this._styleIndicator.textContent = STYLE_STATUS_LABELS[styleName]
? t(STYLE_STATUS_LABELS[styleName])
: styleName.toUpperCase();
this._updateStyleMiniStatus(styleName);
// Update parameter sliders
@@ -1629,9 +1629,9 @@ export class VisualSettings {
_updateStyleMiniStatus(styleName = this.activeStyle) {
if (!this._styleMiniValue) return;
this._styleMiniValue.textContent =
STYLE_STATUS_LABELS[styleName] ||
String(styleName || 'normal').toUpperCase();
this._styleMiniValue.textContent = STYLE_STATUS_LABELS[styleName]
? t(STYLE_STATUS_LABELS[styleName])
: String(styleName || 'normal').toUpperCase();
}
_updateHudButtonState() {
+34 -19
View File
@@ -1,3 +1,4 @@
import { formatUtcDayMonth, t } from '../i18n/index.js';
import { createRailCards } from './railCards.js';
import { createRailTimeline } from './railTimeline.js';
@@ -14,19 +15,25 @@ const ORDER = [
const OBSERVED = new Set(ORDER.slice(2));
const utc = (time) =>
Number.isFinite(Date.parse(time))
? `${new Date(time).toISOString().slice(5, 16).replace('T', ' ')} UTC`
: 'Unavailable';
? `${formatUtcDayMonth(time)} ${new Date(time).toISOString().slice(11, 16)} UTC`
: t('Unavailable');
const age = (time) => {
const minutes = Math.max(
0,
Math.floor((Date.now() - Date.parse(time)) / 60_000),
);
return minutes >= 60
? `${Math.floor(minutes / 60)}h ${minutes % 60}m ago`
: `${minutes}m ago`;
? t('{hours}h {minutes}m ago', {
hours: Math.floor(minutes / 60),
minutes: minutes % 60,
})
: t('{minutes}m ago', { minutes });
};
const historyTime = (time) =>
`${utc(time).slice(6)} · ${Math.max(0, Math.floor((Date.now() - Date.parse(time)) / 60_000))} min ago`;
t('{time} · {minutes} min ago', {
time: utc(time).slice(6),
minutes: Math.max(0, Math.floor((Date.now() - Date.parse(time)) / 60_000)),
});
const dated = (time) => `${utc(time)} · ${age(time)}`;
const set = (node, key, value) => {
if (node[key] !== value) node[key] = value;
@@ -63,7 +70,7 @@ export function createWeatherPanel({
const root = document.createElement('section');
root.className = 'weather-readout';
root.hidden = true;
root.setAttribute('aria-label', 'Active weather');
root.setAttribute('aria-label', t('Active weather'));
const timelineHost = document.createElement('div');
timelineHost.className = 'weather-timeline-block';
timelineHost.hidden = true;
@@ -71,10 +78,10 @@ export function createWeatherPanel({
cardsHost.className = 'weather-cards';
const observedGroup = document.createElement('section');
observedGroup.className = 'weather-observed-group';
observedGroup.setAttribute('aria-label', 'Observed history');
observedGroup.setAttribute('aria-label', t('Observed history'));
const heading = document.createElement('h3');
heading.className = 'panel-title';
heading.textContent = 'Observed history';
heading.textContent = t('Observed history');
const scope = document.createElement('div');
scope.className = 'weather-observed-scope';
const observedCardsHost = document.createElement('div');
@@ -132,9 +139,9 @@ export function createWeatherPanel({
const showTimeline = observed.length > 0;
set(observedGroup, 'hidden', !showTimeline);
const names = {
'weather-radar': 'Rain radar',
'weather-satellite': 'Satellite clouds',
'weather-lightning': 'Lightning density',
'weather-radar': t('Rain radar'),
'weather-satellite': t('Satellite clouds'),
'weather-lightning': t('Lightning density'),
};
set(scope, 'textContent', observed.map(({ id }) => names[id]).join(' · '));
set(timelineHost, 'hidden', !showTimeline);
@@ -155,7 +162,7 @@ export function createWeatherPanel({
disabled: !showTimeline || state.timeline.length < 2,
readout:
state.mode === 'latest'
? 'LATEST · newest per product'
? t('LATEST · newest per product')
: historyTime(state.target),
});
const models = active.map(({ id, icon, summary, legend = [], list }) => {
@@ -165,18 +172,26 @@ export function createWeatherPanel({
const shown = product?.shown ?? summary.shownTime;
if (state.mode === 'history' && product?.selected === null) {
const gap = summary.maxGapMinutes || 30;
detail = `No frame within ${gap < 60 ? `${gap} min` : `${gap / 60} h`} of ${utc(state.target).slice(6)}`;
const time = utc(state.target).slice(6);
detail =
gap < 60
? t('No frame within {gap} min of {time}', { gap, time })
: t('No frame within {gap} h of {time}', { gap: gap / 60, time });
} else if (shown) {
detail = dated(shown);
if (state.mode === 'history')
detail +=
detail =
Date.parse(shown) === Date.parse(state.target)
? ' · synced'
: ' · nearest';
? t('{detail} · synced', { detail })
: t('{detail} · nearest', { detail });
}
} else if (id === 'wind') {
detail = `Forecast · valid ${utc(summary.validTime)} · issued ${utc(summary.issuedTime)}`;
if (state.mode === 'history') detail += ' · Does not follow history';
detail = t('Forecast · valid {valid} · issued {issued}', {
valid: utc(summary.validTime),
issued: utc(summary.issuedTime),
});
if (state.mode === 'history')
detail = t('{detail} · Does not follow history', { detail });
}
const lines = [
{ id: 'time', text: detail, muted: true },
@@ -222,7 +237,7 @@ export function createWeatherPanel({
summary.status ||
summary.compact ||
(id === 'wind'
? `Forecast · valid ${utc(summary.validTime)}`
? t('Forecast · valid {valid}', { valid: utc(summary.validTime) })
: detail),
compactStatus: Boolean(summary.status),
blocks,