mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
feat(i18n): translate the whole interface
Mark every UI string the unmarked-text check reported (534) plus text it cannot see (helpers, joined labels, runtime-built panels), and translate the result to Spanish and Russian: 1,679 messages each. - Kept on purpose, with the reason where the text lives: provider and product names, place and camera names, codes and units, dB values, the HUD terminal readouts, demo shot titles that saved projects look up by title, and text for AI tools and the voice model. - Modules shared with the server or AI tools stay free of the i18n import (local msg); the wind reading and voice session stay English and are translated where the page shows them. - npm test now runs the unmarked-text check. - Fixes on the way: a duplicated radio privacy paragraph, the contact panel toggle saying Expand while open, the CSS filter mistaking & for CSS. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
ee72133574
commit
3129e531e6
99 files changed
+4670
-1294
No files matched your search
@@ -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;
|
||||
|
||||
+11
-11
@@ -1,4 +1,4 @@
|
||||
import { t } from '../i18n/index.js';
|
||||
import { 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,
|
||||
@@ -101,7 +101,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 +176,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} --`;
|
||||
? `${t(field.label)} ${Number(value).toFixed(field.decimals)}${field.unit}`
|
||||
: `${t(field.label)} --`;
|
||||
chip.disabled = !canCalibrate;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -267,7 +267,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) {
|
||||
|
||||
@@ -53,11 +53,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 +104,18 @@ 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`
|
||||
? t('{value} KM', {
|
||||
value:
|
||||
distanceM < 10000
|
||||
? (distanceM / 1000).toFixed(1)
|
||||
: Math.round(distanceM / 1000),
|
||||
})
|
||||
: '—';
|
||||
this.contextDistance.setAttribute(
|
||||
'aria-label',
|
||||
Number.isFinite(distanceM)
|
||||
? this.contextDistance.textContent
|
||||
: 'Unavailable',
|
||||
: t('Unavailable'),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -147,12 +147,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');
|
||||
|
||||
@@ -232,17 +232,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'),
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)}`;
|
||||
|
||||
+19
-8
@@ -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'],
|
||||
},
|
||||
];
|
||||
@@ -251,11 +261,12 @@ 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;
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -1,10 +1,26 @@
|
||||
import { localReceiverFeedName } from '../layers/localAdsb/feedNames.js';
|
||||
import { msg, t, tn } from '../i18n/index.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();
|
||||
}
|
||||
|
||||
function formatGain(gain) {
|
||||
return gain === 'auto' || !Number.isFinite(gain) ? 'auto' : gain.toFixed(1);
|
||||
}
|
||||
@@ -20,10 +36,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 +52,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 +79,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 +95,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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { t } from '../i18n/index.js';
|
||||
import { syncChipGroup } from './chipGroup.js';
|
||||
import { syncRowList } from './rowList.js';
|
||||
|
||||
@@ -98,8 +99,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);
|
||||
});
|
||||
|
||||
+12
-7
@@ -1,3 +1,4 @@
|
||||
import { t } from '../i18n/index.js';
|
||||
const set = (node, key, value) => {
|
||||
if (node[key] !== value) node[key] = value;
|
||||
};
|
||||
@@ -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],
|
||||
|
||||
+157
-97
@@ -8,6 +8,7 @@
|
||||
* strip and selection bar are this adapter's own keyed DOM. Source toggles
|
||||
* live on the data-panel row.
|
||||
*/
|
||||
import { t, tn, msg } from '../i18n/index.js';
|
||||
import { createRailCardBlocks } from './railCardBlocks.js';
|
||||
import { createRailCards } from './railCards.js';
|
||||
import { createImagerySplit } from './imagerySplit.js';
|
||||
@@ -20,28 +21,28 @@ import {
|
||||
|
||||
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');
|
||||
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,7 +56,7 @@ const appearedDocuments = new WeakSet();
|
||||
|
||||
const sensorLine = (product) =>
|
||||
PRODUCTS[product]
|
||||
? `${PRODUCTS[product].name} · ${PRODUCTS[product].resolutionM} m`
|
||||
? `${t(PRODUCTS[product].name)} · ${PRODUCTS[product].resolutionM} m`
|
||||
: product || '';
|
||||
const utcTime = (iso) => (hhmm(iso) ? `${hhmm(iso)}Z` : '');
|
||||
const longerSideKm = (size) =>
|
||||
@@ -68,35 +69,36 @@ const kmText = (km) =>
|
||||
: km.toFixed(1);
|
||||
|
||||
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 +175,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 +193,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 +210,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 +220,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 +251,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 +262,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 +325,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 +335,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 +344,8 @@ export function createRecentImageryPanel({
|
||||
},
|
||||
{
|
||||
id: 'clear',
|
||||
label: 'CLEAR',
|
||||
title: 'Forget the box and its images',
|
||||
label: t('CLEAR'),
|
||||
title: t('Forget the box and its images'),
|
||||
disabled: !snapshot.box && !snapshot.boxError,
|
||||
onClick: () => {
|
||||
exportError = null;
|
||||
@@ -366,7 +373,7 @@ export function createRecentImageryPanel({
|
||||
function renderNotice() {
|
||||
const warning = snapshot.boxError || snapshot.error || exportError;
|
||||
const text =
|
||||
warning || snapshot.notice || (snapshot.borrowedEsri ? ESRI_NOTE : '');
|
||||
warning || snapshot.notice || (snapshot.borrowedEsri ? t(ESRI_NOTE) : '');
|
||||
set(noticeText, 'textContent', text);
|
||||
attribute(noticeText, 'title', text);
|
||||
toggle(notice, 'warn', Boolean(warning));
|
||||
@@ -388,7 +395,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);
|
||||
@@ -432,13 +439,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 +459,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 +472,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 +482,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 +532,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 +616,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 +649,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 +684,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 +712,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 +723,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: focus.coverage || t('unknown') }),
|
||||
);
|
||||
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 +750,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 +765,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 +784,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 +798,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 +824,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 +852,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 +953,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);
|
||||
|
||||
@@ -1,4 +1,26 @@
|
||||
/** Scene panel elements and text-only project presentation. */
|
||||
import { 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,7 +69,9 @@ 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;
|
||||
}
|
||||
@@ -61,14 +85,14 @@ export function renderSceneShots(
|
||||
const label = document.createElement('div');
|
||||
label.className = 'scene-shot-label';
|
||||
label.textContent = shot.title;
|
||||
label.title = 'Double-click to rename';
|
||||
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.setAttribute('aria-label', t('Shot name'));
|
||||
input.value = shot.title;
|
||||
let finished = false;
|
||||
const finish = (save) => {
|
||||
@@ -96,11 +120,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 +134,13 @@ 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;
|
||||
meta.textContent = `${styleLabel} · ${modeLabel} · ${shot.durationSec.toFixed(1)}s + ${shot.holdSec.toFixed(1)}s`;
|
||||
row.appendChild(top);
|
||||
row.appendChild(meta);
|
||||
element.appendChild(row);
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { t } 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');
|
||||
@@ -116,8 +118,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) {
|
||||
|
||||
@@ -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('entity'),
|
||||
).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 })
|
||||
|
||||
@@ -25,6 +25,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'),
|
||||
|
||||
@@ -123,7 +123,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);
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 & orbital assets</span>
|
||||
<span data-i18n-runtime>CONTACTS — nearest planes · vessels · sites<br>SPACE MISSIONS — launches & orbital assets</span>
|
||||
</div>
|
||||
<section id="context-flights-view" class="context-mode-view" role="tabpanel" aria-labelledby="global-context-flights-btn" hidden>
|
||||
<div class="global-context-actions" aria-label="Contact Context actions" 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>
|
||||
|
||||
@@ -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>◀</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">◎</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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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 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" />
|
||||
<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>
|
||||
</div>
|
||||
</fieldset>
|
||||
<ul id="sl-legend" class="sl-legend" aria-label="Coverage colours" data-i18n-aria-label></ul>
|
||||
|
||||
@@ -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">🔗</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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
+12
-8
@@ -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'),
|
||||
};
|
||||
@@ -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() {
|
||||
|
||||
+33
-18
@@ -1,3 +1,4 @@
|
||||
import { t } from '../i18n/index.js';
|
||||
import { createRailCards } from './railCards.js';
|
||||
import { createRailTimeline } from './railTimeline.js';
|
||||
|
||||
@@ -15,18 +16,24 @@ 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';
|
||||
: 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,
|
||||
|
||||
Reference in new issue
Block a user