mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
Merge origin/i18n-spanish: whole-interface translation meets KartaView
This branch moved twice at once: upstream merged KartaView (#1121, a third keyless street-level provider sharing a new lineCoverage.js), and the remote i18n-spanish gained the whole-interface translation work (1993 catalog entries, translateKnown, data-i18n-runtime, the unmarked- string detector in npm run i18n) built on the Street View merge. Integration keeps the remote's mechanisms and re-adds KartaView on top: - presentMeta: remote's translateHint for coverage hints, plus the KartaView count line as its own literal tn pair (Russian plurals need the noun inside the template; keyed off coverageLabel.noun with the sequences pair as the default). - uiState: upstream's highlightsSequence legend gate (KartaView's lines do not highlight) with t('Selected'). - layer-panels.html: remote's data-i18n/data-i18n-runtime hooks plus the sl-since-note wiring KartaView's upload-date SINCE note needs. - kartaview viewer: the photo-navigation aria-label and img alt go through t(), as the remote's unmarked-string detector requires. - catalogs: remote's 1987-entry base (its refined keys supersede this branch's earlier Street View wording) plus the KartaView entries: coverage hints, the upload-date note, the streets count line (with Russian one/few/many/other forms) and the viewer strings. npm run i18n: complete, no unmarked strings. npm test: 6520/6522 (the one failure is the pre-existing environmental OPENAI_API_KEY leak in devFreshDotenv). Build passes.
This commit is contained in:
commit
32b11e27de
176 files changed
+9713
-2210
No files matched your search
@@ -569,9 +569,11 @@ export class StyleManager extends ShellFacade {
|
||||
});
|
||||
this._initUI();
|
||||
this._initMapStackControl();
|
||||
// Headers first: panel chrome builds its toggle labels from them.
|
||||
this._applyRuntimeStaticHeaderText();
|
||||
this._initPanelChrome();
|
||||
this._initLocaleSelector();
|
||||
this._applyRuntimeStaticHeaderText();
|
||||
this._sizeDisplayLabelColumn();
|
||||
this._initLeftPanelAdaptiveLayout();
|
||||
this._initRightPanelAdaptiveLayout();
|
||||
this._initRadioPanel();
|
||||
@@ -735,6 +737,30 @@ export class StyleManager extends ShellFacade {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gives every Display-panel row label one shared column, sized in
|
||||
* characters to the longest label of the active language, so the slider
|
||||
* tracks of separate rows start at the same x. The labels are monospace and
|
||||
* often hidden (collapsed rows have no layout), so this counts characters
|
||||
* instead of measuring; the CSS turns the count into a width.
|
||||
* @returns {void}
|
||||
*/
|
||||
_sizeDisplayLabelColumn() {
|
||||
const labels = document.querySelectorAll(
|
||||
'#pp-toggles .pp-slider-row > .pp-slider-mini-label',
|
||||
);
|
||||
let longest = 0;
|
||||
for (const label of labels) {
|
||||
longest = Math.max(longest, label.textContent.trim().length);
|
||||
}
|
||||
if (longest > 0) {
|
||||
document.documentElement.style.setProperty(
|
||||
'--pp-label-chars',
|
||||
String(longest),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrites a header's trailing text node in place, preserving the leading
|
||||
* dock-label-icon span that a textContent write would destroy (the phase-2
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { isPickedWorldPosition } from '../data/scenePick.js';
|
||||
import { t } from '../i18n/index.js';
|
||||
|
||||
function trackedTarget(viewer) {
|
||||
const entity = viewer.trackedEntity;
|
||||
@@ -288,8 +289,8 @@ export function bindCameraOrientationControls({
|
||||
tiltButton?.setAttribute(
|
||||
'aria-label',
|
||||
next.tilted
|
||||
? 'Return map to straight-down view'
|
||||
: 'Tilt map to oblique view',
|
||||
? t('Return map to straight-down view')
|
||||
: t('Tilt map to oblique view'),
|
||||
);
|
||||
}
|
||||
if (!applied || applied.heading !== next.heading) {
|
||||
@@ -297,7 +298,9 @@ export function bindCameraOrientationControls({
|
||||
northButton?.style?.setProperty('--camera-heading', `${next.heading}deg`);
|
||||
northButton?.setAttribute(
|
||||
'aria-label',
|
||||
`Reset map to north up. Current heading ${next.heading} degrees`,
|
||||
t('Reset map to north up. Current heading {heading} degrees', {
|
||||
heading: next.heading,
|
||||
}),
|
||||
);
|
||||
}
|
||||
applied = next;
|
||||
|
||||
+32
-11
@@ -1,4 +1,4 @@
|
||||
import { t } from '../i18n/index.js';
|
||||
import { formatNumber, t, msg } from '../i18n/index.js';
|
||||
|
||||
/** Shortest-wrap signed degrees, for heading offsets typed as absolute values. */
|
||||
const signedNormalizeDeg = (deg) => ((((deg + 180) % 360) + 360) % 360) - 180;
|
||||
@@ -12,7 +12,7 @@ const signedNormalizeDeg = (deg) => ((((deg + 180) % 360) + 360) % 360) - 180;
|
||||
*/
|
||||
const CCTV_CAL_FIELDS = {
|
||||
heading: {
|
||||
label: 'HDG',
|
||||
label: msg('HDG'),
|
||||
unit: '°',
|
||||
decimals: 1,
|
||||
get: (cam) => cam.headingDeg,
|
||||
@@ -21,21 +21,21 @@ const CCTV_CAL_FIELDS = {
|
||||
}),
|
||||
},
|
||||
pitch: {
|
||||
label: 'PITCH',
|
||||
label: msg('PITCH'),
|
||||
unit: '°',
|
||||
decimals: 1,
|
||||
get: (cam) => cam.pitchDeg,
|
||||
toPatch: (value, base) => ({ pitchDeg: value - base.pitchDeg }),
|
||||
},
|
||||
fov: {
|
||||
label: 'FOV',
|
||||
label: msg('FOV'),
|
||||
unit: '°',
|
||||
decimals: 0,
|
||||
get: (cam) => cam.fovDeg,
|
||||
toPatch: (value, base) => ({ fovDeg: value - base.fovDeg }),
|
||||
},
|
||||
range: {
|
||||
label: 'RANGE',
|
||||
label: msg('RANGE'),
|
||||
unit: 'm',
|
||||
decimals: 0,
|
||||
get: (cam) => cam.rangeM,
|
||||
@@ -44,21 +44,21 @@ const CCTV_CAL_FIELDS = {
|
||||
}),
|
||||
},
|
||||
height: {
|
||||
label: 'HGT',
|
||||
label: msg('HGT'),
|
||||
unit: 'm',
|
||||
decimals: 0,
|
||||
get: (cam) => cam.mountHeightM,
|
||||
toPatch: (value, base) => ({ heightM: value - base.mountHeightM }),
|
||||
},
|
||||
north: {
|
||||
label: 'ΔN',
|
||||
label: msg('ΔN'),
|
||||
unit: 'm',
|
||||
decimals: 1,
|
||||
get: (cam) => cam.calibration?.offsetNorthM || 0,
|
||||
toPatch: (value) => ({ offsetNorthM: value }),
|
||||
},
|
||||
east: {
|
||||
label: 'ΔE',
|
||||
label: msg('ΔE'),
|
||||
unit: 'm',
|
||||
decimals: 1,
|
||||
get: (cam) => cam.calibration?.offsetEastM || 0,
|
||||
@@ -66,6 +66,27 @@ const CCTV_CAL_FIELDS = {
|
||||
},
|
||||
};
|
||||
|
||||
/** Chip text for one pose value: locale decimals, translated metre unit. */
|
||||
function calValueText(field, value) {
|
||||
const options = {
|
||||
minimumFractionDigits: field.decimals,
|
||||
maximumFractionDigits: field.decimals,
|
||||
};
|
||||
if (field.unit === 'm') {
|
||||
// English keeps its compact "RANGE 120m"; other languages space the unit.
|
||||
const metres = formatNumber(Number(value), {
|
||||
useGrouping: false,
|
||||
...options,
|
||||
});
|
||||
return `${t(field.label)} ${t('{value}m', { value: metres })}`;
|
||||
}
|
||||
const number = formatNumber(Number(value), {
|
||||
useGrouping: false,
|
||||
...options,
|
||||
});
|
||||
return `${t(field.label)} ${number}${field.unit}`;
|
||||
}
|
||||
|
||||
export function _activeCctvCameraId() {
|
||||
return this._cctvState?.activeCameraId || this._cctvSelect?.value || '';
|
||||
}
|
||||
@@ -101,7 +122,7 @@ export function _beginCctvCalValueEdit(chip) {
|
||||
input.step = field.decimals > 0 ? '0.1' : '1';
|
||||
input.value = Number(startValue).toFixed(field.decimals);
|
||||
input.className = 'cctv-cal-input';
|
||||
chip.textContent = `${field.label} `;
|
||||
chip.textContent = `${t(field.label)} `;
|
||||
chip.appendChild(input);
|
||||
input.focus();
|
||||
input.select();
|
||||
@@ -176,8 +197,8 @@ export function _syncCctvCalReadout(enabled, activeCamera) {
|
||||
if (!field) continue;
|
||||
const value = canCalibrate ? field.get(activeCamera) : null;
|
||||
chip.textContent = Number.isFinite(value)
|
||||
? `${field.label} ${Number(value).toFixed(field.decimals)}${field.unit}`
|
||||
: `${field.label} --`;
|
||||
? calValueText(field, value)
|
||||
: `${t(field.label)} --`;
|
||||
chip.disabled = !canCalibrate;
|
||||
}
|
||||
}
|
||||
|
||||
+80
-4
@@ -1,4 +1,80 @@
|
||||
import { t } from '../i18n/index.js';
|
||||
import { msg, t } from '../i18n/index.js';
|
||||
|
||||
/**
|
||||
* Display text for the CCTV source health the server reports
|
||||
* (server/providers/cctv.js `setHealth`). The server keeps its English codes
|
||||
* and messages; the panel translates them where it renders.
|
||||
*/
|
||||
|
||||
const SOURCE_KIND_LABELS = Object.freeze({
|
||||
snapshot: msg('SNAPSHOT'),
|
||||
live: msg('LIVE'),
|
||||
fallback: msg('FALLBACK'),
|
||||
upstream: msg('UPSTREAM'),
|
||||
synthetic: msg('SYNTHETIC'),
|
||||
configured: msg('CONFIGURED'),
|
||||
seed: msg('SEED'),
|
||||
image: msg('IMAGE'),
|
||||
unknown: msg('UNKNOWN'),
|
||||
});
|
||||
|
||||
const SOURCE_STATUS_LABELS = Object.freeze({
|
||||
degraded: msg('DEGRADED'),
|
||||
error: msg('ERROR'),
|
||||
unknown: msg('UNKNOWN'),
|
||||
});
|
||||
|
||||
const SOURCE_MESSAGES = Object.freeze({
|
||||
'Live HLS connected': msg('Live HLS connected'),
|
||||
'Live HLS unavailable': msg('Live HLS unavailable'),
|
||||
'No stream URL configured': msg('No stream URL configured'),
|
||||
'Live stream connected': msg('Live stream connected'),
|
||||
'Snapshot feed connected': msg('Snapshot feed connected'),
|
||||
'Media fetch failed': msg('Media fetch failed'),
|
||||
'Upstream snapshot active': msg('Upstream snapshot active'),
|
||||
'Fallback Street View frame': msg('Fallback Street View frame'),
|
||||
'Upstream unavailable': msg('Upstream unavailable'),
|
||||
'No source configured': msg('No source configured'),
|
||||
});
|
||||
|
||||
const SOURCE_LABELS = Object.freeze({
|
||||
'Configured source': msg('Configured source'),
|
||||
'Configured Source': msg('Configured Source'),
|
||||
'Synthetic fallback': msg('Synthetic fallback'),
|
||||
});
|
||||
|
||||
/** "SNAPSHOT" / "СНИМОК"; unknown codes (HLS, MJPEG…) stay as uppercase codes. */
|
||||
export function cctvSourceKindLabel(kind) {
|
||||
const key = String(kind || 'unknown').toLowerCase();
|
||||
return SOURCE_KIND_LABELS[key]
|
||||
? t(SOURCE_KIND_LABELS[key])
|
||||
: key.toUpperCase();
|
||||
}
|
||||
|
||||
/** "DEGRADED" / "ПЕРЕБОИ"; OK and unknown codes stay as uppercase codes. */
|
||||
export function cctvSourceStatusLabel(status) {
|
||||
const key = String(status || 'unknown').toLowerCase();
|
||||
return SOURCE_STATUS_LABELS[key]
|
||||
? t(SOURCE_STATUS_LABELS[key])
|
||||
: key.toUpperCase();
|
||||
}
|
||||
|
||||
/** A server health message in the page language; unknown text passes through. */
|
||||
export function cctvSourceMessageText(message) {
|
||||
const text = String(message || '');
|
||||
if (SOURCE_MESSAGES[text]) return t(SOURCE_MESSAGES[text]);
|
||||
const http = /^Upstream HTTP (\d{3})$/.exec(text);
|
||||
if (http) return t('Upstream HTTP {status}', { status: http[1] });
|
||||
const media = /^Unexpected media type (.+)$/.exec(text);
|
||||
if (media) return t('Unexpected media type {type}', { type: media[1] });
|
||||
return text;
|
||||
}
|
||||
|
||||
/** A generic provider label from the server; real provider names pass through. */
|
||||
export function cctvSourceLabelText(label) {
|
||||
const text = String(label || '');
|
||||
return SOURCE_LABELS[text] ? t(SOURCE_LABELS[text]) : text;
|
||||
}
|
||||
|
||||
export function _clearCctvFrame() {
|
||||
this._cctvFrameRequestToken += 1;
|
||||
@@ -104,10 +180,10 @@ export function _syncCctvSourceBadge(activeCamera, enabled) {
|
||||
this._cctvSourceBadge.dataset.frameState = 'error';
|
||||
return;
|
||||
}
|
||||
const kind = String(
|
||||
const kind = cctvSourceKindLabel(
|
||||
activeCamera.sourceKind || activeCamera.feedType || 'unknown',
|
||||
).toUpperCase();
|
||||
const status = String(activeCamera.sourceStatus || 'unknown').toUpperCase();
|
||||
);
|
||||
const status = cctvSourceStatusLabel(activeCamera.sourceStatus || 'unknown');
|
||||
this._cctvSourceBadge.textContent = `${kind} · ${status}`;
|
||||
this._cctvSourceBadge.dataset.frameState = 'ready';
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { t, tn } from '../i18n/index.js';
|
||||
import { cctvSourceLabelText, cctvSourceMessageText } from './cctvFrames.js';
|
||||
|
||||
import { createCctvVideoSurface } from './cctvVideo.js';
|
||||
export function _calBadgeLabel(badge) {
|
||||
@@ -148,12 +149,13 @@ export function _renderCctvState(state) {
|
||||
|
||||
if (this._cctvMeta) {
|
||||
if (activeCamera) {
|
||||
const provider =
|
||||
const provider = cctvSourceLabelText(
|
||||
activeCamera.sourceLabel ||
|
||||
activeCamera.provider ||
|
||||
'Configured Source';
|
||||
activeCamera.provider ||
|
||||
'Configured Source',
|
||||
);
|
||||
const statusMsg = activeCamera.sourceMessage
|
||||
? ` · ${activeCamera.sourceMessage}`
|
||||
? ` · ${cctvSourceMessageText(activeCamera.sourceMessage)}`
|
||||
: '';
|
||||
// A partner-supplied feed inside a pack names its owner here.
|
||||
const credit = activeCamera.credit ? ` · ${activeCamera.credit}` : '';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/** Regional briefing requests, pages and rotation for the Cockpit controller. */
|
||||
import { t, msg } from '../i18n/index.js';
|
||||
import { formatNumber, t, msg } from '../i18n/index.js';
|
||||
import {
|
||||
COCKPIT_BRIEF_ROTATE_MS,
|
||||
COCKPIT_BRIEF_CYCLE_OFF_HELP,
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
COCKPIT_REGIONAL_REFRESH_DISTANCE_M,
|
||||
COCKPIT_BRIEF_PAGES,
|
||||
formatCockpitBriefAge,
|
||||
formatCockpitCoordinate,
|
||||
formatCockpitWindDirection,
|
||||
} from './cockpitPresentation.js';
|
||||
|
||||
@@ -54,7 +55,9 @@ export function showBriefPage(index, { manual = false } = {}) {
|
||||
if (this.briefPosition)
|
||||
this.briefPosition.textContent = `${this.briefPageIndex + 1} / ${count}`;
|
||||
if (this.briefSource)
|
||||
this.briefSource.textContent = t(page.sourceKey ?? page.source);
|
||||
this.briefSource.textContent = page.sourceKey
|
||||
? t(page.sourceKey, { source: page.sourceName })
|
||||
: page.source;
|
||||
if (this.signalStream) this.signalStream.dataset.briefPage = page.id;
|
||||
if (manual && this.briefAutoRotateEnabled)
|
||||
this.startBriefRotation({ reset: true });
|
||||
@@ -134,8 +137,8 @@ export function updateLocalPosition(info) {
|
||||
this.localCoordinates.textContent = t('POSITION UNAVAILABLE');
|
||||
return;
|
||||
}
|
||||
const lat = `${Math.abs(info.latitude).toFixed(3)}°${info.latitude >= 0 ? 'N' : 'S'}`;
|
||||
const lon = `${Math.abs(info.longitude).toFixed(3)}°${info.longitude >= 0 ? 'E' : 'W'}`;
|
||||
const lat = formatCockpitCoordinate(info.latitude, 'N', 'S');
|
||||
const lon = formatCockpitCoordinate(info.longitude, 'E', 'W');
|
||||
this.localCoordinates.textContent = `${lat} · ${lon}`;
|
||||
}
|
||||
|
||||
@@ -267,7 +270,7 @@ export function renderRegionalBrief(payload, info) {
|
||||
}
|
||||
if (this.localWind) {
|
||||
this.localWind.textContent = Number.isFinite(weather?.windKph)
|
||||
? `${Math.round(weather.windKph)} KM/H`
|
||||
? t('{value} KM/H', { value: Math.round(weather.windKph) })
|
||||
: '—';
|
||||
}
|
||||
if (this.localWindDirection) {
|
||||
@@ -293,7 +296,11 @@ export function renderRegionalBrief(payload, info) {
|
||||
this.localPrecipitation.textContent = Number.isFinite(
|
||||
weather?.precipitationMm,
|
||||
)
|
||||
? weather.precipitationMm.toFixed(1)
|
||||
? formatNumber(weather.precipitationMm, {
|
||||
useGrouping: false,
|
||||
minimumFractionDigits: 1,
|
||||
maximumFractionDigits: 1,
|
||||
})
|
||||
: '—';
|
||||
}
|
||||
if (this.signalStream)
|
||||
|
||||
+17
-14
@@ -1,12 +1,25 @@
|
||||
/** Contact readouts and nearby-signal presentation for the Cockpit controller. */
|
||||
import * as Cesium from 'cesium';
|
||||
import { t, tn } from '../i18n/index.js';
|
||||
import { formatNumber, t, tn } from '../i18n/index.js';
|
||||
import { unitLabel } from './unitLabels.js';
|
||||
import {
|
||||
bearingBetweenCoordinates,
|
||||
formatCockpitContextScope,
|
||||
relativeBearing,
|
||||
resolveCockpitContextReadout,
|
||||
} from '../cockpitMath.js';
|
||||
import { formatCockpitDistance } from './cockpitPresentation.js';
|
||||
|
||||
/** The Context scope line in the active language (see cockpitMath.js). */
|
||||
const LOCALIZED_CONTEXT_SCOPE = Object.freeze({
|
||||
window: ({ label, radiusKm }) =>
|
||||
t('{label} · {radius} AIR/SEA WINDOW', {
|
||||
label,
|
||||
radius: `${radiusKm === null ? '—' : formatNumber(radiusKm)}\u00a0${unitLabel('km').toUpperCase()}`,
|
||||
}),
|
||||
installations: ({ scope, coverage }) =>
|
||||
t('{scope} · INSTALLATIONS {coverage}', { scope, coverage }),
|
||||
});
|
||||
|
||||
export function updateContext(info, heading) {
|
||||
if (!this.context) return;
|
||||
@@ -37,6 +50,7 @@ export function updateContext(info, heading) {
|
||||
snapshot.subject.label || trackedId,
|
||||
snapshot.radiusM,
|
||||
installationCoverage,
|
||||
LOCALIZED_CONTEXT_SCOPE,
|
||||
);
|
||||
}
|
||||
// Navigation stays wired in every state — the operator must always be able
|
||||
@@ -53,11 +67,6 @@ export function updateContext(info, heading) {
|
||||
const enteringLost = this.context.dataset.state !== 'lost';
|
||||
this.context.dataset.state = 'lost';
|
||||
if (this.contextUncertainty) {
|
||||
// The English literal write is pinned verbatim by cockpitMarkup.test.mjs
|
||||
// ('CONTACT LOST' must appear in this method body); the t() re-write
|
||||
// below is a same-value override that localizes it under other locales.
|
||||
this.contextUncertainty.textContent =
|
||||
'CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR';
|
||||
this.contextUncertainty.textContent = t(
|
||||
'CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR',
|
||||
);
|
||||
@@ -109,13 +118,13 @@ export function updateContext(info, heading) {
|
||||
if (this.contextDistance) {
|
||||
const distanceM = closest?.distanceM;
|
||||
this.contextDistance.textContent = Number.isFinite(distanceM)
|
||||
? `${distanceM < 10000 ? (distanceM / 1000).toFixed(1) : Math.round(distanceM / 1000)} KM`
|
||||
? formatCockpitDistance(distanceM)
|
||||
: '—';
|
||||
this.contextDistance.setAttribute(
|
||||
'aria-label',
|
||||
Number.isFinite(distanceM)
|
||||
? this.contextDistance.textContent
|
||||
: 'Unavailable',
|
||||
: t('Unavailable'),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -147,12 +156,6 @@ export function updateContext(info, heading) {
|
||||
this.contextDirection.classList.toggle('unknown', relative === null);
|
||||
}
|
||||
if (this.contextBearing) {
|
||||
// The 'BRG —' literal is pinned verbatim by cockpitMarkup.test.mjs; the
|
||||
// t() re-write below is a same-value override that localizes it.
|
||||
if (relative === null) this.contextBearing.textContent = 'BRG —';
|
||||
else if (Math.abs(relative) < 8) this.contextBearing.textContent = 'AHEAD';
|
||||
else
|
||||
this.contextBearing.textContent = `${relative < 0 ? 'L' : 'R'} ${String(Math.round(Math.abs(relative))).padStart(3, '0')}°`;
|
||||
if (relative === null) this.contextBearing.textContent = t('BRG —');
|
||||
else if (Math.abs(relative) < 8)
|
||||
this.contextBearing.textContent = t('AHEAD');
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/** Instrument values, routes and vision controls for the Cockpit controller. */
|
||||
import { t } from '../i18n/index.js';
|
||||
import { formatNumber, t } from '../i18n/index.js';
|
||||
import {
|
||||
COCKPIT_VISION_MODES,
|
||||
normalizeCockpitVisionMode,
|
||||
@@ -20,6 +20,8 @@ import {
|
||||
} from '../cockpitMath.js';
|
||||
import {
|
||||
COCKPIT_CONTEXT_UPDATE_MS,
|
||||
compassPointLabel,
|
||||
formatCockpitCoordinate,
|
||||
setCockpitRollingValue,
|
||||
} from './cockpitPresentation.js';
|
||||
|
||||
@@ -67,7 +69,7 @@ export function updateHud(
|
||||
setCockpitRollingValue(
|
||||
this.altitude,
|
||||
displayedAltitudeFt !== null
|
||||
? displayedAltitudeFt.toLocaleString('en-US')
|
||||
? formatNumber(displayedAltitudeFt)
|
||||
: '-----',
|
||||
displayedAltitudeFt,
|
||||
{ immediate: forceContext },
|
||||
@@ -110,7 +112,7 @@ export function updateHud(
|
||||
this.compassTape.innerHTML = divisions
|
||||
.map((division, index) => {
|
||||
const slot = index - 3;
|
||||
return `<span class="${slot === 0 ? 'active' : ''}" style="--slot:${slot};--depth:${Math.abs(slot)}">${formatCompassDivision(division)}</span>`;
|
||||
return `<span class="${slot === 0 ? 'active' : ''}" style="--slot:${slot};--depth:${Math.abs(slot)}">${compassPointLabel(formatCompassDivision(division))}</span>`;
|
||||
})
|
||||
.join('');
|
||||
}
|
||||
@@ -119,10 +121,10 @@ export function updateHud(
|
||||
this.clock.textContent = new Date().toISOString().slice(11, 19) + 'Z';
|
||||
if (this.position) {
|
||||
const lat = Number.isFinite(info.latitude)
|
||||
? `${Math.abs(info.latitude).toFixed(3)}°${info.latitude >= 0 ? 'N' : 'S'}`
|
||||
? formatCockpitCoordinate(info.latitude, 'N', 'S')
|
||||
: '--';
|
||||
const lon = Number.isFinite(info.longitude)
|
||||
? `${Math.abs(info.longitude).toFixed(3)}°${info.longitude >= 0 ? 'E' : 'W'}`
|
||||
? formatCockpitCoordinate(info.longitude, 'E', 'W')
|
||||
: '--';
|
||||
this.position.textContent = `${lat} · ${lon}`;
|
||||
}
|
||||
@@ -232,17 +234,17 @@ export function setVisionMode(mode, { revealParameters = false } = {}) {
|
||||
const next = normalizeCockpitVisionMode(mode);
|
||||
this.visionMode = next;
|
||||
const labels = {
|
||||
optical: 'NORMAL',
|
||||
crt: 'CRT',
|
||||
nvg: 'NVG',
|
||||
thermal: 'FLIR',
|
||||
anime: 'ANIME',
|
||||
noir: 'NOIR',
|
||||
snow: 'SNOW',
|
||||
optical: t('NORMAL'),
|
||||
crt: t('CRT'),
|
||||
nvg: t('NVG'),
|
||||
thermal: t('FLIR'),
|
||||
anime: t('ANIME'),
|
||||
noir: t('NOIR'),
|
||||
snow: t('SNOW'),
|
||||
};
|
||||
const names = {
|
||||
optical: 'Normal',
|
||||
crt: 'CRT',
|
||||
optical: t('Normal'),
|
||||
crt: t('CRT'),
|
||||
nvg: t('Night vision'),
|
||||
thermal: t('Thermal'),
|
||||
anime: t('Anime'),
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/** Pure formatting and fixed presentation constants used by Cockpit components. */
|
||||
import { t, msg } from '../i18n/index.js';
|
||||
import { formatNumber, t, msg } from '../i18n/index.js';
|
||||
import { formatQuantity } from './unitLabels.js';
|
||||
|
||||
export const COCKPIT_HEADING_SLEW_DPS = 28;
|
||||
|
||||
@@ -54,8 +55,9 @@ export const COCKPIT_BRIEF_PAGES = [
|
||||
id: 'news',
|
||||
kickerKey: msg('REGIONAL NEWS'),
|
||||
subtitleKey: msg('LATEST LOCATION-MATCHED REPORTING'),
|
||||
// RSS provider attribution is a keep-English boundary string.
|
||||
source: 'GOOGLE NEWS RSS · LOCATION QUERY · RECENT',
|
||||
// The RSS provider name stays English; the rest of the line translates.
|
||||
sourceKey: msg('{source} · LOCATION QUERY · RECENT'),
|
||||
sourceName: 'GOOGLE NEWS RSS', // i18n-ignore: provider name
|
||||
},
|
||||
{
|
||||
id: 'local',
|
||||
@@ -93,11 +95,79 @@ export function formatCockpitBriefAge(value) {
|
||||
: t('{count}D AGO', { count: Math.round(hours / 24) });
|
||||
}
|
||||
|
||||
/** Compass point codes in display order and the label shown for each. */
|
||||
const COMPASS_POINT_LABELS = Object.freeze({
|
||||
N: msg('N'),
|
||||
NE: msg('NE'),
|
||||
E: msg('E'),
|
||||
SE: msg('SE'),
|
||||
S: msg('S'),
|
||||
SW: msg('SW'),
|
||||
W: msg('W'),
|
||||
NW: msg('NW'),
|
||||
});
|
||||
|
||||
/**
|
||||
* The shown label for a compass point code (`N`, `SW`…): С, ЮЗ… in Russian.
|
||||
* Anything else (a degree reading such as `030`) passes through unchanged.
|
||||
*/
|
||||
export function compassPointLabel(code) {
|
||||
return Object.hasOwn(COMPASS_POINT_LABELS, code)
|
||||
? t(COMPASS_POINT_LABELS[code])
|
||||
: code;
|
||||
}
|
||||
|
||||
/** `30.203°N` in the active language: `30,203°С`. */
|
||||
export function formatCockpitCoordinate(value, positive, negative) {
|
||||
const amount = formatNumber(Math.abs(value), {
|
||||
useGrouping: false,
|
||||
minimumFractionDigits: 3,
|
||||
maximumFractionDigits: 3,
|
||||
});
|
||||
const code = value >= 0 ? positive : negative;
|
||||
// Russian writes the hemisphere out ("30,203° с. ш."): a lone «С» after a
|
||||
// degree sign reads as Celsius.
|
||||
switch (code) {
|
||||
case 'N':
|
||||
return t('{value}°N', { value: amount });
|
||||
case 'S':
|
||||
return t('{value}°S', { value: amount });
|
||||
case 'E':
|
||||
return t('{value}°E', { value: amount });
|
||||
case 'W':
|
||||
return t('{value}°W', { value: amount });
|
||||
default:
|
||||
return `${amount}°${compassPointLabel(code)}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A contact distance such as `4.2 KM` / `44 KM` in the active language
|
||||
* (`4,2 КМ`): one decimal under 10 km, whole kilometres beyond.
|
||||
*/
|
||||
export function formatCockpitDistance(distanceM) {
|
||||
const digits = distanceM < 10000 ? 1 : 0;
|
||||
return formatQuantity(distanceM / 1000, 'km', {
|
||||
minimumFractionDigits: digits,
|
||||
maximumFractionDigits: digits,
|
||||
}).toUpperCase();
|
||||
}
|
||||
|
||||
export function formatCockpitWindDirection(value) {
|
||||
if (!Number.isFinite(value)) return t('DIR UNKNOWN');
|
||||
const labels = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW'];
|
||||
const labels = Object.keys(COMPASS_POINT_LABELS);
|
||||
const normalized = ((value % 360) + 360) % 360;
|
||||
return `${labels[Math.round(normalized / 45) % labels.length]} · ${Math.round(normalized)}°`;
|
||||
return `${compassPointLabel(labels[Math.round(normalized / 45) % labels.length])} · ${Math.round(normalized)}°`;
|
||||
}
|
||||
|
||||
// Digit grouping and decimal marks (`26,175`, `26 175`, `26.175`) take a
|
||||
// narrow cell; a full digit cell would open a gap in the middle of a number.
|
||||
const ROLL_SEPARATOR = /^[,.\u00a0\u202f]$/;
|
||||
|
||||
function rollTokenClass(character) {
|
||||
return ROLL_SEPARATOR.test(character)
|
||||
? 'cockpit-roll-token is-separator'
|
||||
: 'cockpit-roll-token';
|
||||
}
|
||||
|
||||
export function setCockpitRollingValue(
|
||||
@@ -136,7 +206,7 @@ export function setCockpitRollingValue(
|
||||
element.replaceChildren(
|
||||
...Array.from(nextText, (character) => {
|
||||
const token = document.createElement('span');
|
||||
token.className = 'cockpit-roll-token';
|
||||
token.className = rollTokenClass(character);
|
||||
token.setAttribute('aria-hidden', 'true');
|
||||
token.textContent = character;
|
||||
return token;
|
||||
@@ -165,7 +235,7 @@ export function setCockpitRollingValue(
|
||||
const previousCharacter = from[index];
|
||||
const nextCharacter = to[index];
|
||||
const token = document.createElement('span');
|
||||
token.className = 'cockpit-roll-token';
|
||||
token.className = rollTokenClass(nextCharacter);
|
||||
token.setAttribute('aria-hidden', 'true');
|
||||
|
||||
if (
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/** Reconcile actionable signal rows without disturbing native keyboard focus. */
|
||||
import { t, tn } from '../i18n/index.js';
|
||||
import { formatCockpitDistance } from './cockpitPresentation.js';
|
||||
|
||||
export function renderCockpitSignals() {
|
||||
if (!this.signalList) return;
|
||||
@@ -157,7 +158,7 @@ export function updateCockpitSignals(snapshot, unknownCount) {
|
||||
? t('MILITARY FLIGHT')
|
||||
: t('COMMERCIAL FLIGHT'),
|
||||
distance: Number.isFinite(item.distanceM)
|
||||
? `${item.distanceM < 10000 ? (item.distanceM / 1000).toFixed(1) : Math.round(item.distanceM / 1000)} KM`
|
||||
? formatCockpitDistance(item.distanceM)
|
||||
: t('DISTANCE UNKNOWN'),
|
||||
}),
|
||||
target: { layerId: cohort.id, id: String(id) },
|
||||
|
||||
@@ -69,6 +69,7 @@ export function _handleContextLayerChange(change) {
|
||||
);
|
||||
return true;
|
||||
},
|
||||
'Space Missions cancellation could not restore the previous layer state', // i18n-ignore: English message; the toast shows localizedMessage
|
||||
{
|
||||
localizedMessage: t(
|
||||
'Space Missions cancellation could not restore the previous layer state',
|
||||
|
||||
@@ -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)}`;
|
||||
|
||||
+36
-32
@@ -1,45 +1,49 @@
|
||||
/**
|
||||
* Key guidance in the UI language. keySetupCore.mjs builds the same sentences
|
||||
* in English for the server and the AI tools; these are the ones people read.
|
||||
*/
|
||||
import {
|
||||
keySetupAlternativeKeyNames,
|
||||
KEY_SETUP_KEYS,
|
||||
keySetupRequirementEnvVars,
|
||||
} from '../keySetupCore.mjs';
|
||||
import { t } from '../i18n/index.js';
|
||||
import { t, translateKnown } from '../i18n/index.js';
|
||||
|
||||
// Localized key-guidance sentences. keySetupCore owns the machine values
|
||||
// (env-var names, registry titles) because it also runs in node-side
|
||||
// key-setup builds that stay English; this presentation boundary owns the
|
||||
// sentences, composed with placeholders so word order can differ per language.
|
||||
|
||||
/** The Provider Settings hint for a missing key, or '' for a hidden key. */
|
||||
export function keyRequirementText(requiresKeyId) {
|
||||
const envVars = keySetupRequirementEnvVars(requiresKeyId);
|
||||
/** "Needs X — add it in Provider Settings", or '' for an unknown or hidden key. */
|
||||
export function keyRequirementText(id) {
|
||||
const envVars = id ? keySetupRequirementEnvVars(id) : null;
|
||||
return envVars
|
||||
? t('Needs {envVars} — add it in Provider Settings', { envVars })
|
||||
: '';
|
||||
}
|
||||
|
||||
/** The key's product name in title case: GOOGLE MAPS → Google Maps. */
|
||||
function keyName(id) {
|
||||
const entry = KEY_SETUP_KEYS.find((candidate) => candidate.id === id);
|
||||
return entry
|
||||
? entry.title
|
||||
.toLowerCase()
|
||||
.replace(/\b\w/g, (letter) => letter.toUpperCase())
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A requirement sentence, plus the switched-off sources that would draw with
|
||||
* a key already set.
|
||||
* @param {string} requirement What `keyRequirementText` said ('' allowed).
|
||||
* @param {Array<{label: string, requiresKeyId?: string|null}>} [alternatives]
|
||||
* The requirement, then the switched-off sources that would draw with a key
|
||||
* already set (see keySetupWithAlternatives).
|
||||
*
|
||||
* @param {string} requirement
|
||||
* @param {Array<{label: string, requiresKeyId: string|null}>} [alternatives]
|
||||
*/
|
||||
export function withKeyAlternatives(requirement, alternatives = []) {
|
||||
if (!alternatives.length) return requirement;
|
||||
const named = keySetupAlternativeKeyNames(alternatives);
|
||||
const option = ({ label, name }) =>
|
||||
name
|
||||
? t('light {label} to use your {name} key', { label, name })
|
||||
: t('light {label}', { label });
|
||||
export function keyGuidanceText(requirement, alternatives = []) {
|
||||
const options = alternatives.map(({ label, requiresKeyId }) => {
|
||||
const name = keyName(requiresKeyId);
|
||||
const source = translateKnown(label);
|
||||
return name
|
||||
? t('light {source} to use your {name} key', { source, name })
|
||||
: t('light {source}', { source });
|
||||
});
|
||||
if (!options.length) return requirement;
|
||||
const offer = options.join(t(', or '));
|
||||
if (requirement)
|
||||
return t('{requirement}, or {offer}', {
|
||||
requirement,
|
||||
offer: named.map(option).join(t(', or ')),
|
||||
});
|
||||
// No requirement: the offer stands alone, so it opens with a capital.
|
||||
const { label, name } = named[0];
|
||||
const lead = name
|
||||
? t('Light {label} to use your {name} key', { label, name })
|
||||
: t('Light {label}', { label });
|
||||
const tail = named.slice(1).map(option).join(t(', or '));
|
||||
return tail ? t('{lead}, or {tail}', { lead, tail }) : lead;
|
||||
return t('{requirement}, or {offer}', { requirement, offer });
|
||||
return offer.charAt(0).toLocaleUpperCase() + offer.slice(1);
|
||||
}
|
||||
+197
-28
@@ -3,9 +3,9 @@ import { syncRowList } from './rowList.js';
|
||||
import { layerFeedState } from '../data/feedState.js';
|
||||
export { layerFeedState } from '../data/feedState.js';
|
||||
import { GUIDANCE_STATUSES } from '../loadingFeedback.js';
|
||||
import { keyRequirementText, withKeyAlternatives } from './keyGuidance.js';
|
||||
import { keyGuidanceText, keyRequirementText } from './keyGuidance.js';
|
||||
import { createWeatherPanel } from './weatherPanel.js';
|
||||
import { t, msg } from '../i18n/index.js';
|
||||
import { formatNumber, msg, t, translateKnown } from '../i18n/index.js';
|
||||
|
||||
// Feed-state enum VALUES are machine identifiers (layerFeedState contract, QA
|
||||
// hooks, CSS feed-* classes); only the final label value translates, at this
|
||||
@@ -40,6 +40,16 @@ const LAYER_NAME_KEYS = Object.freeze({
|
||||
'local-dams': msg('Dams'),
|
||||
'telegeography-submarine-cables': msg('Submarine Cables'),
|
||||
'local-firms': msg('FIRMS Active Fires'),
|
||||
'local-adsb': msg('Local ADS-B'),
|
||||
transit: msg('Transit'),
|
||||
'recent-imagery': msg('Recent Imagery'),
|
||||
'fire-perimeters': msg('Fire Perimeters'),
|
||||
wind: msg('Wind'),
|
||||
'weather-radar': msg('Rain radar'),
|
||||
'weather-satellite': msg('Satellite clouds'),
|
||||
'weather-lightning': msg('Lightning density'),
|
||||
'weather-cyclones': msg('Cyclone advisories'),
|
||||
directions: msg('Directions'),
|
||||
});
|
||||
|
||||
function lifecycleStateLabel(lifecycleState) {
|
||||
@@ -58,7 +68,7 @@ const FEED_STATE_LABELS = Object.freeze({
|
||||
// Presentation order is independent of catalog registration and startup order.
|
||||
const PANEL_GROUPS = [
|
||||
{
|
||||
label: 'Movement',
|
||||
label: msg('Movement'),
|
||||
ids: [
|
||||
'satellites',
|
||||
'flights',
|
||||
@@ -71,11 +81,11 @@ const PANEL_GROUPS = [
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Cameras',
|
||||
label: msg('Cameras'),
|
||||
ids: ['cctv', 'recent-imagery', 'street-level'],
|
||||
},
|
||||
{
|
||||
label: 'Infrastructure',
|
||||
label: msg('Infrastructure'),
|
||||
ids: [
|
||||
'alpr-cameras',
|
||||
'military-installations',
|
||||
@@ -85,11 +95,11 @@ const PANEL_GROUPS = [
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Events',
|
||||
label: msg('Events'),
|
||||
ids: ['rocket-launches', 'earthquakes', 'local-firms', 'fire-perimeters'],
|
||||
},
|
||||
{
|
||||
label: 'Weather',
|
||||
label: msg('Weather'),
|
||||
ids: [
|
||||
'wind',
|
||||
'weather-radar',
|
||||
@@ -99,7 +109,7 @@ const PANEL_GROUPS = [
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Utilities',
|
||||
label: msg('Utilities'),
|
||||
ids: ['directions', 'radio'],
|
||||
},
|
||||
];
|
||||
@@ -119,10 +129,68 @@ const PANEL_LABELS = {
|
||||
'local-firms': msg('Active Fires'),
|
||||
};
|
||||
|
||||
function englishPanelLabel(layer) {
|
||||
return PANEL_LABELS[layer.id] || LAYER_NAME_KEYS[layer.id] || layer.name;
|
||||
}
|
||||
|
||||
// The panel's own English names win, as before i18n; module names otherwise.
|
||||
function panelLabel(layer) {
|
||||
if (PANEL_LABELS[layer.id]) return t(PANEL_LABELS[layer.id]);
|
||||
const key = LAYER_NAME_KEYS[layer.id];
|
||||
return key ? t(key) : layer.name;
|
||||
}
|
||||
|
||||
// English module names (layer.name) that other surfaces print, such as the
|
||||
// loading chip's layer roster. Looked up by the English text so that surface
|
||||
// keeps its English output and only the presentation translates.
|
||||
const MODULE_NAME_KEYS = new Map(
|
||||
[
|
||||
...Object.values(LAYER_NAME_KEYS),
|
||||
...Object.values(PANEL_LABELS),
|
||||
msg('ALPR Cameras'),
|
||||
].map((key) => [key, key]),
|
||||
);
|
||||
|
||||
/**
|
||||
* Translate a layer's English module name for display; any other text
|
||||
* (a provider name, an unknown layer) is returned unchanged.
|
||||
* @param {string} name English `layer.name`.
|
||||
* @returns {string}
|
||||
*/
|
||||
export function localizeLayerName(name) {
|
||||
const key = MODULE_NAME_KEYS.get(String(name ?? '').trim());
|
||||
return key ? t(key) : name;
|
||||
}
|
||||
|
||||
// Layer-declared source names stay English in layer state, share links and AI
|
||||
// tool output; only the row's source line translates them. Provider names
|
||||
// (USGS, CelesTrak, GTFS-RT…) are not listed and pass through unchanged.
|
||||
const SOURCE_LABEL_KEYS = new Map(
|
||||
[
|
||||
msg('Flights'),
|
||||
msg('Military aircraft'),
|
||||
msg('Vessels'),
|
||||
msg('CCTV + Street View fallback'),
|
||||
msg('OpenStreetMap + optional Google Maps Places'),
|
||||
msg('GFS / ECMWF IFS · FORECAST'),
|
||||
msg('NOAA nowCOAST · OBSERVED'),
|
||||
msg('NASA FIRMS · LIVE'),
|
||||
msg('OSM routing'),
|
||||
msg('Mapped camera locations'),
|
||||
msg('OpenStreetMap · community mapped'),
|
||||
].map((key) => [key, key]),
|
||||
);
|
||||
|
||||
function sourceLabel(source) {
|
||||
if (typeof source !== 'string') return source;
|
||||
if (source === 'Local') return t('Local', { context: 'data source' });
|
||||
const key = SOURCE_LABEL_KEYS.get(source);
|
||||
if (key) return t(key);
|
||||
return PANEL_LABELS[layer.id] ? t(PANEL_LABELS[layer.id]) : layer.name;
|
||||
// A layer with several providers joins their names ("Mapillary · Google
|
||||
// Street View"); a name with a translation shows it.
|
||||
return source.includes(' · ')
|
||||
? source.split(' · ').map(translateKnown).join(' · ')
|
||||
: translateKnown(source);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -143,12 +211,79 @@ export function layerKeyRequirementTooltip(layer = {}) {
|
||||
if (layer?.stats?.keyRequired !== true) return '';
|
||||
const requiresKeyId = String(layer.requiresKeyId || '').trim();
|
||||
// A layer with several sources also names the ones it could use instead.
|
||||
return withKeyAlternatives(
|
||||
return keyGuidanceText(
|
||||
requiresKeyId ? keyRequirementText(requiresKeyId) : '',
|
||||
layer.stats.keyAlternatives || [],
|
||||
);
|
||||
}
|
||||
|
||||
const ROW_TEXT_SEPARATOR = ' · ';
|
||||
|
||||
/** A loading label is English data, at times several joined by " · ". */
|
||||
function translateLoadingLabel(label) {
|
||||
return label
|
||||
.trim()
|
||||
.split(ROW_TEXT_SEPARATOR)
|
||||
.map(translateKnown)
|
||||
.join(ROW_TEXT_SEPARATOR);
|
||||
}
|
||||
// A short Latin code joined by hyphens. Longer runs (a URL, a token in an
|
||||
// error) keep their break opportunities so they cannot overflow the rail.
|
||||
const HYPHENATED_CODE = /[A-Za-z0-9]+(?:-[A-Za-z0-9]+)+/g;
|
||||
const HYPHENATED_CODE_MAX = 16;
|
||||
|
||||
/** A span for one run of row text; `keep` stops the browser breaking inside it. */
|
||||
function rowTextSpan(text, keep) {
|
||||
const span = document.createElement('span');
|
||||
if (keep) span.className = 'data-keep';
|
||||
span.textContent = text;
|
||||
return span;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a row's name, count or meta line so that a narrow column wraps it well
|
||||
* in every language: a Latin code with hyphens (ADS-B, RTL-SDR, GTFS-RT) never
|
||||
* splits at its hyphen, and a " · " separator stays at the end of the line it
|
||||
* closes, so no line starts with "·". The element's textContent is still the
|
||||
* plain string; text with neither feature is set directly.
|
||||
*
|
||||
* @param {HTMLElement} element
|
||||
* @param {string} text
|
||||
*/
|
||||
export function setRowText(element, text) {
|
||||
const value = String(text ?? '');
|
||||
// Refreshes run every second; leave unchanged text (and its spans) alone.
|
||||
if (element.textContent === value) return;
|
||||
const codes = [...value.matchAll(HYPHENATED_CODE)].filter(
|
||||
(match) => match[0].length <= HYPHENATED_CODE_MAX,
|
||||
);
|
||||
if (!codes.length && !value.includes(ROW_TEXT_SEPARATOR)) {
|
||||
element.textContent = value;
|
||||
return;
|
||||
}
|
||||
const spans = [];
|
||||
const pushPlain = (from, to) => {
|
||||
if (to <= from) return;
|
||||
const pieces = value.slice(from, to).split(ROW_TEXT_SEPARATOR);
|
||||
pieces.forEach((piece, index) => {
|
||||
// The space before the dot belongs to a no-wrap span, so the only break
|
||||
// opportunity around a separator is the space after it.
|
||||
if (index > 0) spans.push(rowTextSpan(' ·', true));
|
||||
const textPiece = index > 0 ? ` ${piece}` : piece;
|
||||
if (textPiece) spans.push(rowTextSpan(textPiece, false));
|
||||
});
|
||||
};
|
||||
let cursor = 0;
|
||||
for (const match of codes) {
|
||||
pushPlain(cursor, match.index);
|
||||
spans.push(rowTextSpan(match[0], true));
|
||||
cursor = match.index + match[0].length;
|
||||
}
|
||||
pushPlain(cursor, value.length);
|
||||
element.textContent = '';
|
||||
for (const span of spans) element.appendChild(span);
|
||||
}
|
||||
|
||||
/** Layer row presentation over supplied state and actions; no layer imports. */
|
||||
export class LayerPanel {
|
||||
constructor({
|
||||
@@ -250,17 +385,20 @@ export class LayerPanel {
|
||||
for (const layer of layers) {
|
||||
if (!layer.showInTogglePanel) continue;
|
||||
const group =
|
||||
PANEL_ORDER[PANEL_POSITIONS.get(layer.id)]?.label ?? 'Other layers';
|
||||
PANEL_ORDER[PANEL_POSITIONS.get(layer.id)]?.label ??
|
||||
msg('Other layers');
|
||||
if (group && group !== previousGroup) {
|
||||
const heading = document.createElement('h3');
|
||||
heading.className = 'data-layer-group-heading';
|
||||
heading.textContent = group;
|
||||
heading.textContent = t(group);
|
||||
this._toggleContainer.appendChild(heading);
|
||||
}
|
||||
previousGroup = group;
|
||||
const row = document.createElement('div');
|
||||
row.className = 'data-toggle-row';
|
||||
row.dataset.layerId = layer.id;
|
||||
// English name for voice tools, which must not receive translated text.
|
||||
row.dataset.layerName = englishPanelLabel(layer);
|
||||
|
||||
const topRow = document.createElement('div');
|
||||
topRow.className = 'data-toggle-top';
|
||||
@@ -273,7 +411,7 @@ export class LayerPanel {
|
||||
icon.textContent = layer.icon;
|
||||
const name = document.createElement('span');
|
||||
name.className = 'data-name';
|
||||
name.textContent = panelLabel(layer);
|
||||
setRowText(name, panelLabel(layer));
|
||||
left.appendChild(icon);
|
||||
left.appendChild(name);
|
||||
|
||||
@@ -282,7 +420,7 @@ export class LayerPanel {
|
||||
|
||||
const count = document.createElement('span');
|
||||
count.className = 'data-count';
|
||||
count.textContent = this._layerCountText(layer.stats);
|
||||
this._syncCount(row, count, layer.stats);
|
||||
|
||||
const toggle = document.createElement('button');
|
||||
toggle.type = 'button';
|
||||
@@ -321,7 +459,7 @@ export class LayerPanel {
|
||||
|
||||
const bottomRow = document.createElement('div');
|
||||
bottomRow.className = 'data-toggle-meta';
|
||||
bottomRow.textContent = this._buildMetaText(layer);
|
||||
setRowText(bottomRow, this._buildMetaText(layer));
|
||||
|
||||
row.appendChild(topRow);
|
||||
row.appendChild(bottomRow);
|
||||
@@ -424,6 +562,21 @@ export class LayerPanel {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a row's count. A count that is a phrase carrying its own number
|
||||
* ("24 nearby · 2 on screen") moves to its own line under the name: beside
|
||||
* the toggle it squeezes the name to a word per line in any language. The
|
||||
* "—" placeholder is marked so it can give way to a wide status chip.
|
||||
*/
|
||||
_syncCount(row, count, stats) {
|
||||
const text = this._layerCountText(stats);
|
||||
setRowText(count, text);
|
||||
const phrase =
|
||||
typeof stats.countLabel === 'string' && /\d/.test(stats.countLabel);
|
||||
row.classList.toggle('data-toggle-row--count-line', phrase);
|
||||
count.classList.toggle('data-count-empty', text === '—');
|
||||
}
|
||||
|
||||
/** Qualify a loaded count when it does not mean items currently on screen. */
|
||||
_layerCountText(stats) {
|
||||
if (typeof stats.countLabel === 'string' && stats.countLabel.trim())
|
||||
@@ -547,12 +700,12 @@ export class LayerPanel {
|
||||
|
||||
const count = row.querySelector('.data-count');
|
||||
if (count) {
|
||||
count.textContent = this._layerCountText(layer.stats);
|
||||
this._syncCount(row, count, layer.stats);
|
||||
}
|
||||
|
||||
const meta = row.querySelector('.data-toggle-meta');
|
||||
if (meta) {
|
||||
meta.textContent = this._buildMetaText(layer);
|
||||
setRowText(meta, this._buildMetaText(layer));
|
||||
}
|
||||
|
||||
this._syncRowControls(
|
||||
@@ -568,7 +721,7 @@ export class LayerPanel {
|
||||
const stats = layer.stats || {};
|
||||
const feedState = layerFeedState(stats);
|
||||
const stateLabel = t(FEED_STATE_LABEL_KEYS[feedState]);
|
||||
const source = stats.source || layer.source;
|
||||
const source = sourceLabel(stats.source || layer.source);
|
||||
const lifecycleState =
|
||||
layer.lifecycleState || (layer.enabled ? 'enabled' : 'disabled');
|
||||
if (lifecycleState === 'enabling' || lifecycleState === 'disabling') {
|
||||
@@ -583,8 +736,9 @@ export class LayerPanel {
|
||||
{ source },
|
||||
);
|
||||
}
|
||||
const presentedError =
|
||||
stats.error || stats.lastError || stats.managerRefreshError;
|
||||
const presentedError = translateKnown(
|
||||
stats.error || stats.lastError || stats.managerRefreshError,
|
||||
);
|
||||
if (presentedError) {
|
||||
if (typeof stats.retryInSec === 'number' && stats.retryInSec > 0) {
|
||||
return t('{state} · {source} · {detail} · retry {seconds}s', {
|
||||
@@ -613,14 +767,14 @@ export class LayerPanel {
|
||||
if (stats.loading) {
|
||||
const loadingLabel =
|
||||
typeof stats.loadingLabel === 'string' && stats.loadingLabel.trim()
|
||||
? stats.loadingLabel.trim()
|
||||
? translateLoadingLabel(stats.loadingLabel)
|
||||
: t('loading...');
|
||||
return t('{source} · {loadingLabel}', { source, loadingLabel });
|
||||
}
|
||||
if (feedState === 'fallback') {
|
||||
const detail =
|
||||
typeof stats.loadingLabel === 'string' && stats.loadingLabel.trim()
|
||||
? stats.loadingLabel.trim()
|
||||
? translateLoadingLabel(stats.loadingLabel)
|
||||
: stats.coverage || ago;
|
||||
return t('{state} · {source} · {detail}', {
|
||||
state: stateLabel,
|
||||
@@ -665,7 +819,7 @@ export class LayerPanel {
|
||||
if (typeof stats.loadingLabel === 'string' && stats.loadingLabel.trim()) {
|
||||
return t('{source} · {loadingLabel}', {
|
||||
source,
|
||||
loadingLabel: stats.loadingLabel.trim(),
|
||||
loadingLabel: translateLoadingLabel(stats.loadingLabel),
|
||||
});
|
||||
}
|
||||
return t('{source} · {ago}', { source, ago });
|
||||
@@ -707,15 +861,25 @@ export class LayerPanel {
|
||||
String(transitioning || (unavailable && !layer.enabled)),
|
||||
);
|
||||
button.setAttribute('aria-busy', String(transitioning));
|
||||
button.textContent = transitioning
|
||||
const showsFeedState =
|
||||
!transitioning && !uncertain && (layer.enabled || unavailable);
|
||||
const stateText = transitioning
|
||||
? lifecycleStateLabel(layer.lifecycleState)
|
||||
: uncertain
|
||||
? t('UNCERTAIN')
|
||||
: layer.enabled || unavailable
|
||||
: showsFeedState
|
||||
? t(FEED_STATE_LABEL_KEYS[feedState])
|
||||
: t('OFF');
|
||||
// The chip may abbreviate where a language's word is wider than the
|
||||
// status column; the meta line and the accessible name keep the full word.
|
||||
button.textContent =
|
||||
showsFeedState && feedState === 'unavailable'
|
||||
? t('UNAVAILABLE', { context: 'status chip' })
|
||||
: stateText;
|
||||
const keyGuidance = unavailable
|
||||
? String(layer.stats.error || t('Data source unavailable'))
|
||||
? String(
|
||||
translateKnown(layer.stats.error) || t('Data source unavailable'),
|
||||
)
|
||||
: layerKeyRequirementTooltip(layer);
|
||||
// Name the missing key on the control itself: a row reading KEY REQUIRED
|
||||
// without saying WHICH key leaves a dead control and no next step. Empty
|
||||
@@ -723,7 +887,7 @@ export class LayerPanel {
|
||||
button.title = keyGuidance;
|
||||
const ariaLabel = t('{name}: {state}', {
|
||||
name: panelLabel(layer),
|
||||
state: button.textContent,
|
||||
state: stateText,
|
||||
});
|
||||
button.setAttribute(
|
||||
'aria-label',
|
||||
@@ -732,7 +896,12 @@ export class LayerPanel {
|
||||
}
|
||||
|
||||
_formatCount(n) {
|
||||
if (n >= 1000) return `${(n / 1000).toFixed(1)}K`;
|
||||
if (n >= 1000)
|
||||
return `${formatNumber(n / 1000, {
|
||||
minimumFractionDigits: 1,
|
||||
maximumFractionDigits: 1,
|
||||
useGrouping: false,
|
||||
})}K`;
|
||||
return String(n);
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,9 @@ test('panel presentation places Transit between Street Traffic and Bike Share in
|
||||
source.indexOf('const PANEL_POSITIONS ='),
|
||||
);
|
||||
const order = JSON.parse(
|
||||
runInNewContext(`${declarations}\nJSON.stringify(PANEL_ORDER)`),
|
||||
runInNewContext(
|
||||
`const msg = (text) => text;\n${declarations}\nJSON.stringify(PANEL_ORDER)`,
|
||||
),
|
||||
);
|
||||
assert.deepEqual(
|
||||
order.filter(({ label }) => label === 'Movement').map(({ id }) => id),
|
||||
@@ -258,3 +260,72 @@ test('an omitted source is visibly unavailable and cannot be toggled on', async
|
||||
'an active layer can always be switched off',
|
||||
);
|
||||
});
|
||||
|
||||
test('row text keeps a hyphenated code whole and never starts a line with the separator', async () => {
|
||||
const { setRowText } = await import('./layerPanel.js');
|
||||
const previousDocument = globalThis.document;
|
||||
const element = () => ({
|
||||
className: '',
|
||||
textContent: '',
|
||||
children: [],
|
||||
appendChild(child) {
|
||||
this.children.push(child);
|
||||
return child;
|
||||
},
|
||||
});
|
||||
globalThis.document = { createElement: element };
|
||||
try {
|
||||
const text = (node) =>
|
||||
node.children.length
|
||||
? node.children.map((child) => child.textContent).join('')
|
||||
: node.textContent;
|
||||
const kept = (node) =>
|
||||
node.children
|
||||
.filter((child) => child.className === 'data-keep')
|
||||
.map((child) => child.textContent);
|
||||
|
||||
const name = element();
|
||||
setRowText(name, 'Локальный ADS-B');
|
||||
assert.equal(text(name), 'Локальный ADS-B');
|
||||
assert.deepEqual(kept(name), ['ADS-B']);
|
||||
|
||||
const meta = element();
|
||||
setRowText(meta, 'UNAVAILABLE · NASA FIRMS · LIVE · KEY REQUIRED');
|
||||
assert.equal(text(meta), 'UNAVAILABLE · NASA FIRMS · LIVE · KEY REQUIRED');
|
||||
assert.deepEqual(kept(meta), [' ·', ' ·', ' ·']);
|
||||
assert.ok(
|
||||
meta.children.every((child) => !child.textContent.startsWith('·')),
|
||||
'the space before each dot is held to the piece it closes',
|
||||
);
|
||||
|
||||
const plain = element();
|
||||
setRowText(plain, 'Satellites');
|
||||
assert.equal(plain.textContent, 'Satellites');
|
||||
assert.equal(plain.children.length, 0);
|
||||
|
||||
const long = element();
|
||||
setRowText(long, 'see https-proxy-endpoint-config-x');
|
||||
assert.deepEqual(kept(long), [], 'a long hyphenated run may still wrap');
|
||||
} finally {
|
||||
globalThis.document = previousDocument;
|
||||
}
|
||||
});
|
||||
|
||||
test('the unavailable chip may abbreviate, but its accessible name keeps the full state', async () => {
|
||||
const { LayerPanel } = await import('./layerPanel.js');
|
||||
const attrs = new Map();
|
||||
const button = {
|
||||
classList: { toggle() {} },
|
||||
dataset: {},
|
||||
setAttribute: (key, value) => attrs.set(key, value),
|
||||
};
|
||||
LayerPanel.prototype._syncToggleButton(button, {
|
||||
id: 'local-firms',
|
||||
name: 'FIRMS Active Fires',
|
||||
enabled: false,
|
||||
stats: { sourceUnavailable: true, error: 'KEY REQUIRED' },
|
||||
});
|
||||
assert.equal(button.textContent, 'UNAVAILABLE');
|
||||
assert.equal(button.dataset.feedState, 'unavailable');
|
||||
assert.match(attrs.get('aria-label'), /^Active Fires: UNAVAILABLE\./);
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
localSdrCardView,
|
||||
localSdrFeedLine,
|
||||
localSdrFmAudioActive,
|
||||
localSdrGainOptionLabel,
|
||||
} from './localSdrPresentation.js';
|
||||
|
||||
const ELEMENT_IDS = Object.freeze({
|
||||
@@ -77,6 +78,7 @@ export class LocalSdrControls {
|
||||
this._renderFrame = null;
|
||||
this._destroyed = false;
|
||||
if (!this.elements.card) return;
|
||||
this._labelGainOptions();
|
||||
this._bind();
|
||||
this._receiverUnsubscribe = receiver.subscribe((state) =>
|
||||
this._onReceiverState(state),
|
||||
@@ -95,6 +97,14 @@ export class LocalSdrControls {
|
||||
return Boolean(this._state?.connected);
|
||||
}
|
||||
|
||||
/** Numeric gain options read in the page language; values stay numeric. */
|
||||
_labelGainOptions() {
|
||||
for (const option of this.elements.gain?.options || []) {
|
||||
if (option.value !== 'auto')
|
||||
option.textContent = localSdrGainOptionLabel(option.value);
|
||||
}
|
||||
}
|
||||
|
||||
_listen(element, type, handler) {
|
||||
element?.addEventListener(type, handler, {
|
||||
signal: this._listeners.signal,
|
||||
|
||||
@@ -1,10 +1,40 @@
|
||||
import { localReceiverFeedName } from '../layers/localAdsb/feedNames.js';
|
||||
import { msg, t, tn } from '../i18n/index.js';
|
||||
import { formatQuantity } from './unitLabels.js';
|
||||
|
||||
/**
|
||||
* Pure presentation of the Local RTL-SDR card in the Radio panel. Receives the
|
||||
* receiver session snapshot and returns what each control should show.
|
||||
*/
|
||||
|
||||
// Receiver status codes shown on the connection chip and the connect button.
|
||||
const STATUS_LABELS = Object.freeze({
|
||||
idle: msg('IDLE'),
|
||||
connecting: msg('CONNECTING'),
|
||||
tuning: msg('TUNING'),
|
||||
streaming: msg('STREAMING'),
|
||||
error: msg('ERROR'),
|
||||
unsupported: msg('UNSUPPORTED'),
|
||||
});
|
||||
|
||||
function statusLabel(status) {
|
||||
const key = String(status || 'idle');
|
||||
return STATUS_LABELS[key] ? t(STATUS_LABELS[key]) : key.toUpperCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Text for a numeric gain option: the option value stays the machine value
|
||||
* ("28.0"); only the label follows the locale ("28,0 дБ").
|
||||
*/
|
||||
export function localSdrGainOptionLabel(value) {
|
||||
const gain = Number(value);
|
||||
if (!Number.isFinite(gain)) return String(value);
|
||||
return formatQuantity(gain, 'dB', {
|
||||
minimumFractionDigits: 1,
|
||||
maximumFractionDigits: 1,
|
||||
});
|
||||
}
|
||||
|
||||
function formatGain(gain) {
|
||||
return gain === 'auto' || !Number.isFinite(gain) ? 'auto' : gain.toFixed(1);
|
||||
}
|
||||
@@ -20,10 +50,15 @@ function statusText(state) {
|
||||
const iqStatus =
|
||||
state.samplesPerSecond > 0
|
||||
? `${(state.samplesPerSecond / 1_000_000).toFixed(2)} MS/s IQ`
|
||||
: 'waiting for IQ';
|
||||
: t('waiting for IQ');
|
||||
const device = state.deviceLabel ? ` · ${state.deviceLabel}` : '';
|
||||
if (state.connected && !fmActive) {
|
||||
return `${state.message}${device} · ${iqStatus} · ${state.decodedMessages} messages`;
|
||||
const decoded = tn(
|
||||
state.decodedMessages,
|
||||
'{count} message',
|
||||
'{count} messages',
|
||||
);
|
||||
return `${state.message}${device} · ${iqStatus} · ${decoded}`;
|
||||
}
|
||||
const detail = state.seeking
|
||||
? state.seekMessage
|
||||
@@ -31,13 +66,14 @@ function statusText(state) {
|
||||
if (state.connected && fmActive) {
|
||||
const workerStatus =
|
||||
state.workerBlocks > 0
|
||||
? `DSP ${state.workerBlocks} blocks`
|
||||
: 'DSP waiting';
|
||||
? tn(state.workerBlocks, 'DSP {count} block', 'DSP {count} blocks')
|
||||
: t('DSP waiting');
|
||||
const rfStatus = `RF ${formatIq(state)}`;
|
||||
const audioSignal = Number.isFinite(state.audioLevelDbfs)
|
||||
? `${state.audioLevelDbfs.toFixed(1)} dBFS audio`
|
||||
: 'audio signal --';
|
||||
return `${detail}${device} · ${iqStatus} · ${workerStatus} · ${rfStatus} · ${audioSignal} · audio ${state.audioState}`;
|
||||
? t('{level} dBFS audio', { level: state.audioLevelDbfs.toFixed(1) })
|
||||
: t('audio signal --');
|
||||
const audio = t('audio {state}', { state: state.audioState });
|
||||
return `${detail}${device} · ${iqStatus} · ${workerStatus} · ${rfStatus} · ${audioSignal} · ${audio}`;
|
||||
}
|
||||
return detail;
|
||||
}
|
||||
@@ -57,15 +93,15 @@ export function localSdrCardView(state) {
|
||||
return {
|
||||
connectionLabel: state.connected
|
||||
? adsbActive
|
||||
? `${state.aircraftHeard || 0} HEARD`
|
||||
: 'STREAMING'
|
||||
: String(state.status || 'idle').toUpperCase(),
|
||||
? tn(state.aircraftHeard || 0, '{count} HEARD', '{count} HEARD')
|
||||
: t('STREAMING')
|
||||
: statusLabel(state.status),
|
||||
connectionActive: Boolean(state.connected),
|
||||
connectLabel: transitional
|
||||
? String(state.status).toUpperCase()
|
||||
? statusLabel(state.status)
|
||||
: state.connected
|
||||
? 'DISCONNECT'
|
||||
: 'CONNECT',
|
||||
? t('DISCONNECT')
|
||||
: t('CONNECT'),
|
||||
connectDisabled: transitional || !state.webUsbSupported,
|
||||
connectPressed: Boolean(state.connected),
|
||||
fmActive,
|
||||
@@ -73,10 +109,10 @@ export function localSdrCardView(state) {
|
||||
modeDisabled: transitional,
|
||||
locateLabel:
|
||||
state.locationStatus === 'requesting'
|
||||
? 'LOCATING…'
|
||||
? t('LOCATING…')
|
||||
: state.locationStatus === 'ready'
|
||||
? 'LOCATED'
|
||||
: 'LOCATE',
|
||||
? t('LOCATED')
|
||||
: t('LOCATE'),
|
||||
locateDisabled: state.locationStatus === 'requesting',
|
||||
locateActive: state.locationStatus === 'ready',
|
||||
changeDeviceDisabled: transitional || !state.webUsbSupported,
|
||||
|
||||
@@ -6,7 +6,9 @@ import {
|
||||
localSdrCardView,
|
||||
localSdrFeedLine,
|
||||
localSdrFmAudioActive,
|
||||
localSdrGainOptionLabel,
|
||||
} from './localSdrPresentation.js';
|
||||
import { setLocale } from '../i18n/index.js';
|
||||
|
||||
const idle = Object.freeze({
|
||||
webUsbSupported: true,
|
||||
@@ -153,3 +155,15 @@ test('two feeds on one band are told apart by their ordinal labels', () => {
|
||||
'Decoder feeds: 1090 · 978 MHz UAT #1 · 978 MHz UAT #2 · read while Local ADS-B is on',
|
||||
);
|
||||
});
|
||||
|
||||
test('gain option labels follow the page language; values stay numeric', () => {
|
||||
try {
|
||||
setLocale('en');
|
||||
assert.equal(localSdrGainOptionLabel('28.0'), '28.0\u00a0dB');
|
||||
assert.equal(localSdrGainOptionLabel('0.9'), '0.9\u00a0dB');
|
||||
setLocale('ru');
|
||||
assert.match(localSdrGainOptionLabel('28.0'), /^28,0\u00a0\S+$/);
|
||||
} finally {
|
||||
setLocale('en');
|
||||
}
|
||||
});
|
||||
@@ -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'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -215,6 +215,12 @@ export function renderRadioState(state) {
|
||||
: state.audioState === 'paused'
|
||||
? t('Resume')
|
||||
: t('Play');
|
||||
// The transport button is narrow: it gets its own short uppercase labels.
|
||||
const playbackButtonLabel = activePlayback
|
||||
? t('PAUSE')
|
||||
: state.audioState === 'paused'
|
||||
? t('RESUME')
|
||||
: t('PLAY');
|
||||
const playTargetWord = selected ? t('selected') : t('nearest');
|
||||
const playAria = t('{action} {target} radio station', {
|
||||
action: playbackAction,
|
||||
@@ -223,7 +229,7 @@ export function renderRadioState(state) {
|
||||
if (this._radioPlayBtn) {
|
||||
this._radioPlayBtn.disabled = !interactive || !hasStations;
|
||||
this._radioPlayBtn.classList.toggle('active', activePlayback);
|
||||
this._radioPlayBtn.textContent = playbackAction.toUpperCase();
|
||||
this._radioPlayBtn.textContent = playbackButtonLabel;
|
||||
this._radioPlayBtn.setAttribute('aria-label', playAria);
|
||||
}
|
||||
if (this._contextRadioMiniPlayBtn) {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { t } from '../i18n/index.js';
|
||||
import { syncChipGroup } from './chipGroup.js';
|
||||
import { syncRowList } from './rowList.js';
|
||||
import { formatScaleLabel, unitLabel } from './unitLabels.js';
|
||||
|
||||
const set = (node, key, value) => {
|
||||
if (node[key] !== value) node[key] = value;
|
||||
@@ -13,6 +15,13 @@ const order = (parent, nodes) =>
|
||||
parent.insertBefore(node, parent.children[i] || null);
|
||||
});
|
||||
|
||||
// "960 — 1050+ гПа": the unit stays with the last number.
|
||||
const scaleText = (low, high, units) => {
|
||||
const unit = unitLabel(units);
|
||||
const range = `${formatScaleLabel(low)} — ${formatScaleLabel(high)}`;
|
||||
return unit ? `${range}\u00a0${unit}` : range;
|
||||
};
|
||||
|
||||
/** Optional, ordered blocks without disclosure state. All controls are keyed. */
|
||||
export function createRailCardBlocks({ container, cardId, onParams }) {
|
||||
const document = container.ownerDocument;
|
||||
@@ -98,8 +107,8 @@ export function createRailCardBlocks({ container, cardId, onParams }) {
|
||||
block.clear.type = 'button';
|
||||
block.clear.dataset.actionId = 'clear';
|
||||
block.clear.textContent = '×';
|
||||
block.clear.setAttribute('aria-label', 'Clear reading');
|
||||
block.clear.title = 'Clear reading';
|
||||
block.clear.setAttribute('aria-label', t('Clear reading'));
|
||||
block.clear.title = t('Clear reading');
|
||||
bind((event) => {
|
||||
if (event.target === block.clear) dispatch(block.props.clear);
|
||||
});
|
||||
@@ -197,7 +206,7 @@ export function createRailCardBlocks({ container, cardId, onParams }) {
|
||||
block.scale,
|
||||
'textContent',
|
||||
visible
|
||||
? `${legend.labels[0]} — ${legend.labels.at(-1)} ${legend.units || ''}`.trim()
|
||||
? scaleText(legend.labels[0], legend.labels.at(-1), legend.units)
|
||||
: '',
|
||||
);
|
||||
} else if (props.type === 'actions') {
|
||||
|
||||
+13
-8
@@ -1,9 +1,10 @@
|
||||
import { formatUtcDayMonth, t } from '../i18n/index.js';
|
||||
const set = (node, key, value) => {
|
||||
if (node[key] !== value) node[key] = value;
|
||||
};
|
||||
const utc = (tick) =>
|
||||
Number.isFinite(Date.parse(tick))
|
||||
? `${new Date(tick).toISOString().slice(5, 16).replace('T', ' ')} UTC`
|
||||
? `${formatUtcDayMonth(tick)} ${new Date(tick).toISOString().slice(11, 16)} UTC`
|
||||
: '';
|
||||
|
||||
/** Stable native transport with local preview and a coalesced 150 ms drag commit. */
|
||||
@@ -32,7 +33,7 @@ export function createRailTimeline({
|
||||
parent.appendChild(node);
|
||||
return node;
|
||||
};
|
||||
const previous = makeButton('‹', 'Earlier observation', row);
|
||||
const previous = makeButton('‹', t('Earlier observation'), row);
|
||||
const slider = document.createElement('input');
|
||||
slider.type = 'range';
|
||||
slider.className = `gev-quantitative-slider${sliderClassName ? ` ${sliderClassName}` : ''}`;
|
||||
@@ -40,19 +41,23 @@ export function createRailTimeline({
|
||||
slider.max = '0';
|
||||
slider.step = '1';
|
||||
slider.value = '0';
|
||||
slider.setAttribute('aria-label', 'Observed history');
|
||||
slider.setAttribute('aria-label', t('Observed history'));
|
||||
row.appendChild(slider);
|
||||
const next = makeButton('›', 'Later observation', row);
|
||||
const next = makeButton('›', t('Later observation'), row);
|
||||
const controls = document.createElement('div');
|
||||
controls.className = 'rail-timeline-controls';
|
||||
const latest = makeButton('Latest', 'Newest frame per product', controls);
|
||||
const play = makeButton('Play', 'Replay observed history', controls);
|
||||
const latest = makeButton(
|
||||
t('Latest'),
|
||||
t('Newest frame per product'),
|
||||
controls,
|
||||
);
|
||||
const play = makeButton(t('Play'), t('Replay observed history'), controls);
|
||||
const readout = document.createElement('span');
|
||||
readout.className = 'rail-timeline-readout';
|
||||
controls.appendChild(readout);
|
||||
const label = document.createElement('div');
|
||||
label.className = 'panel-title';
|
||||
label.textContent = 'Observed history';
|
||||
label.textContent = t('Observed history');
|
||||
const endpoints = document.createElement('div');
|
||||
endpoints.className = 'rail-timeline-endpoints';
|
||||
const oldest = document.createElement('span');
|
||||
@@ -128,7 +133,7 @@ export function createRailTimeline({
|
||||
`data-toggle-chip${props.mode === 'latest' ? ' active' : ''}`,
|
||||
);
|
||||
set(play, 'className', `data-toggle-chip${props.playing ? ' active' : ''}`);
|
||||
set(play, 'textContent', props.playing ? 'Pause' : 'Play');
|
||||
set(play, 'textContent', props.playing ? t('Pause') : t('Play'));
|
||||
for (const [node, active] of [
|
||||
[latest, props.mode === 'latest'],
|
||||
[play, props.playing],
|
||||
|
||||
+205
-106
@@ -8,40 +8,71 @@
|
||||
* strip and selection bar are this adapter's own keyed DOM. Source toggles
|
||||
* live on the data-panel row.
|
||||
*/
|
||||
import { formatNumber, msg, t, tn, translateKnown } from '../i18n/index.js';
|
||||
import { createRailCardBlocks } from './railCardBlocks.js';
|
||||
import { createRailCards } from './railCards.js';
|
||||
import { createImagerySplit } from './imagerySplit.js';
|
||||
import {
|
||||
PRODUCTS,
|
||||
hhmm,
|
||||
shortDay,
|
||||
wvsSnapshotUrl,
|
||||
} from '../layers/recentImagery/model.js';
|
||||
import { displayDay, displayMetres } from '../layers/recentImagery/display.js';
|
||||
import { formatQuantity } from './unitLabels.js';
|
||||
|
||||
const PANEL_ID = 'recent-imagery-panel';
|
||||
const OVERVIEW_MIN_BOX_KM = 25;
|
||||
const ESRI_NOTE = 'Imagery on Esri · Google 3D returns when cleared';
|
||||
const ESRI_NOTE = msg('Imagery on Esri · Google 3D returns when cleared');
|
||||
|
||||
// The layer keeps its notices and box refusals in English (AI tools read the
|
||||
// same snapshot); translate the ones we write, show anything else as it came.
|
||||
const LAYER_NOTICES = new Set([
|
||||
ESRI_NOTE,
|
||||
msg('Box and images cleared'),
|
||||
msg('Esri map unavailable · no swipe'),
|
||||
msg('Swipe needs a globe map'),
|
||||
msg('Imagery stays on Esri · CLEAR to use Google 3D'),
|
||||
]);
|
||||
const TOO_LARGE = /^(Box|View) is ([\d,]+) km wide · limit ([\d,]+) km$/;
|
||||
|
||||
function localizeNotice(text) {
|
||||
if (!text) return '';
|
||||
if (LAYER_NOTICES.has(text)) return t(text);
|
||||
const tooLarge = TOO_LARGE.exec(text);
|
||||
if (!tooLarge) return translateKnown(text);
|
||||
const values = {
|
||||
size: formatQuantity(Number(tooLarge[2].replace(/,/g, '')), 'km', {
|
||||
useGrouping: true,
|
||||
}),
|
||||
limit: formatQuantity(Number(tooLarge[3].replace(/,/g, '')), 'km', {
|
||||
useGrouping: true,
|
||||
}),
|
||||
};
|
||||
return tooLarge[1] === 'Box'
|
||||
? t('Box is {size} wide · limit {limit}', values)
|
||||
: t('View is {size} wide · limit {limit}', values);
|
||||
}
|
||||
const MODE_LABELS = [
|
||||
['image', 'IMAGE'],
|
||||
['basemap', 'VS BASEMAP'],
|
||||
['ab', 'A / B'],
|
||||
['image', msg('IMAGE')],
|
||||
['basemap', msg('VS BASEMAP')],
|
||||
['ab', msg('A / B')],
|
||||
];
|
||||
const START_HERE_TITLES = {
|
||||
clear: 'Newest low-cloud day for this box · scene cloud, not box cloud',
|
||||
cloudy: 'Newest day covering this box · cloudier than 20%',
|
||||
partial: 'Newest day with imagery for this box · partial coverage',
|
||||
overview: 'Newest daily overview for this box',
|
||||
clear: msg('Newest low-cloud day for this box · scene cloud, not box cloud'),
|
||||
cloudy: msg('Newest day covering this box · cloudier than 20%'),
|
||||
partial: msg('Newest day with imagery for this box · partial coverage'),
|
||||
overview: msg('Newest daily overview for this box'),
|
||||
};
|
||||
const START_HERE_REASONS = {
|
||||
clear: 'newest clear day',
|
||||
cloudy: 'newest day (cloudy)',
|
||||
partial: 'newest day (partial coverage)',
|
||||
overview: 'newest overview',
|
||||
clear: msg('newest clear day'),
|
||||
cloudy: msg('newest day (cloudy)'),
|
||||
partial: msg('newest day (partial coverage)'),
|
||||
overview: msg('newest overview'),
|
||||
};
|
||||
const PLACEHOLDERS = {
|
||||
empty: 'No imagery',
|
||||
error: 'Unavailable',
|
||||
present: 'Loading',
|
||||
empty: msg('No imagery'),
|
||||
error: msg('Unavailable'),
|
||||
present: msg('Loading'),
|
||||
};
|
||||
const KEY_STEPS = {
|
||||
ArrowLeft: (index) => index - 1,
|
||||
@@ -55,48 +86,57 @@ const appearedDocuments = new WeakSet();
|
||||
|
||||
const sensorLine = (product) =>
|
||||
PRODUCTS[product]
|
||||
? `${PRODUCTS[product].name} · ${PRODUCTS[product].resolutionM} m`
|
||||
? `${t(PRODUCTS[product].name)} · ${displayMetres(PRODUCTS[product].resolutionM)}`
|
||||
: product || '';
|
||||
const utcTime = (iso) => (hhmm(iso) ? `${hhmm(iso)}Z` : '');
|
||||
const longerSideKm = (size) =>
|
||||
Math.max(Number(size?.width) || 0, Number(size?.height) || 0);
|
||||
const kmText = (km) =>
|
||||
!Number.isFinite(km)
|
||||
? '?'
|
||||
: km >= 100
|
||||
? Math.round(km).toLocaleString('en-US')
|
||||
: km.toFixed(1);
|
||||
const kmText = (km) => {
|
||||
if (!Number.isFinite(km)) return '?';
|
||||
const digits = km >= 100 ? 0 : 1;
|
||||
return formatNumber(km, {
|
||||
minimumFractionDigits: digits,
|
||||
maximumFractionDigits: digits,
|
||||
});
|
||||
};
|
||||
|
||||
function coverageText(coverage) {
|
||||
if (coverage === 'full') return t('full', { context: 'coverage' });
|
||||
if (coverage === 'partial') return t('partial', { context: 'coverage' });
|
||||
return t('unknown');
|
||||
}
|
||||
|
||||
function cloudText(candidate) {
|
||||
if (candidate.thumbnail?.status === 'empty') return 'no imagery';
|
||||
if (!candidate.cloud) return 'cloud unknown';
|
||||
if (candidate.thumbnail?.status === 'empty') return t('no imagery');
|
||||
if (!candidate.cloud) return t('cloud unknown');
|
||||
const min = Math.round(candidate.cloud.min);
|
||||
const max = Math.round(candidate.cloud.max);
|
||||
return min === max ? `${min}% cloud` : `${min}–${max}% cloud`;
|
||||
return min === max
|
||||
? t('{percent}% cloud', { percent: min })
|
||||
: t('{min}–{max}% cloud', { min, max });
|
||||
}
|
||||
|
||||
function countText(snapshot) {
|
||||
if (!snapshot.box) return 'NO BOX';
|
||||
if (snapshot.searching) return 'SEARCHING';
|
||||
const count = snapshot.candidates.length;
|
||||
return `${count} DAY${count === 1 ? '' : 'S'}`;
|
||||
if (!snapshot.box) return t('NO BOX');
|
||||
if (snapshot.searching) return t('SEARCHING');
|
||||
return tn(snapshot.candidates.length, '{count} DAY', '{count} DAYS');
|
||||
}
|
||||
|
||||
/** The one-line hint under the strip: the next thing to do, per state. */
|
||||
function hintText(snapshot) {
|
||||
if (snapshot.zoomToFit) return 'Zoom in or draw a smaller box';
|
||||
if (!snapshot.box) return 'Select a box or use the view';
|
||||
if (snapshot.searching) return 'Searching the last 30 days';
|
||||
if (!snapshot.candidates.length) return 'No days to show for this box';
|
||||
if (snapshot.zoomToFit) return t('Zoom in or draw a smaller box');
|
||||
if (!snapshot.box) return t('Select a box or use the view');
|
||||
if (snapshot.searching) return t('Searching the last 30 days');
|
||||
if (!snapshot.candidates.length) return t('No days to show for this box');
|
||||
const { mode, pins, shown } = snapshot;
|
||||
if (shown.swipe !== 'none') return 'Drag the divider · SWAP trades sides';
|
||||
if (shown.swipe !== 'none') return t('Drag the divider · SWAP trades sides');
|
||||
if (mode === 'ab') {
|
||||
if (!pins.a.key && !pins.b.key)
|
||||
return '← → preview · A or B pins the focused day';
|
||||
return '← → preview the other side · A or B pins it';
|
||||
return t('← → preview · A or B pins the focused day');
|
||||
return t('← → preview the other side · A or B pins it');
|
||||
}
|
||||
if (pins.a.key) return 'S on another day replaces it · × unpins';
|
||||
return '← → preview · S shows the focused day';
|
||||
if (pins.a.key) return t('S on another day replaces it · × unpins');
|
||||
return t('← → preview · S shows the focused day');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -173,7 +213,7 @@ export function createRecentImageryPanel({
|
||||
|
||||
// ---- build: every block exists from the start, in its final order ----
|
||||
const root = el('section', 'recent-imagery-readout');
|
||||
root.setAttribute('aria-label', 'Recent imagery');
|
||||
root.setAttribute('aria-label', t('Recent imagery'));
|
||||
|
||||
const actionsHost = el('div', 'ri-block ri-actions-row', root);
|
||||
actionsHost.id = 'ri-actions';
|
||||
@@ -191,10 +231,15 @@ export function createRecentImageryPanel({
|
||||
noticeText.id = 'ri-notice-text';
|
||||
noticeText.setAttribute('role', 'status');
|
||||
noticeText.setAttribute('aria-live', 'polite');
|
||||
const zoomIn = el('button', 'data-toggle-chip ri-zoom-in', notice, 'ZOOM IN');
|
||||
const zoomIn = el(
|
||||
'button',
|
||||
'data-toggle-chip ri-zoom-in',
|
||||
notice,
|
||||
t('ZOOM IN'),
|
||||
);
|
||||
zoomIn.id = 'ri-zoom-in';
|
||||
zoomIn.type = 'button';
|
||||
zoomIn.title = 'Fly in until the view fits the 1,000 km limit';
|
||||
zoomIn.title = t('Fly in until the view fits the 1,000 km limit');
|
||||
|
||||
const strip = el('div', 'ri-block ri-strip', root);
|
||||
strip.id = 'ri-strip';
|
||||
@@ -203,7 +248,7 @@ export function createRecentImageryPanel({
|
||||
strip.setAttribute('aria-orientation', 'horizontal');
|
||||
strip.setAttribute(
|
||||
'aria-label',
|
||||
'Imagery days, newest first · arrows move, S or A and B pin',
|
||||
t('Imagery days, newest first · arrows move, S or A and B pin'),
|
||||
);
|
||||
strip.setAttribute('aria-keyshortcuts', 'A B S');
|
||||
const stripEmpty = el('div', 'ri-strip-empty', strip);
|
||||
@@ -213,16 +258,16 @@ export function createRecentImageryPanel({
|
||||
|
||||
const selection = el('section', 'ri-block ri-selection', root);
|
||||
selection.id = 'ri-selection';
|
||||
selection.setAttribute('aria-label', 'On the map');
|
||||
selection.setAttribute('aria-label', t('On the map'));
|
||||
const modeGroup = el('div', 'ri-mode', selection);
|
||||
modeGroup.id = 'ri-mode';
|
||||
modeGroup.setAttribute('role', 'radiogroup');
|
||||
modeGroup.setAttribute('aria-label', 'Mode');
|
||||
modeGroup.setAttribute('aria-label', t('Mode'));
|
||||
const modeButtons = new Map(
|
||||
MODE_LABELS.map(([mode, label]) => {
|
||||
const node = el('button', 'data-toggle-chip ri-mode-btn', modeGroup);
|
||||
node.type = 'button';
|
||||
node.textContent = label;
|
||||
node.textContent = t(label);
|
||||
node.dataset.mode = mode;
|
||||
node.setAttribute('role', 'radio');
|
||||
return [mode, node];
|
||||
@@ -244,7 +289,7 @@ export function createRecentImageryPanel({
|
||||
const controls = el('div', 'ri-block ri-controls', root);
|
||||
controls.id = 'ri-controls';
|
||||
const opacityLabel = el('label', 'ri-opacity-label', controls);
|
||||
el('span', '', opacityLabel, 'OPACITY');
|
||||
el('span', '', opacityLabel, t('OPACITY'));
|
||||
const opacityValue = el('span', 'ri-opacity-value', opacityLabel, '100%');
|
||||
const opacity = el('input', 'ri-opacity', controls);
|
||||
opacity.id = 'ri-opacity';
|
||||
@@ -255,13 +300,13 @@ export function createRecentImageryPanel({
|
||||
step: '1',
|
||||
value: '100',
|
||||
});
|
||||
opacity.setAttribute('aria-label', 'Imagery opacity');
|
||||
opacity.setAttribute('aria-label', t('Imagery opacity'));
|
||||
opacityLabel.htmlFor = 'ri-opacity';
|
||||
// Its slot is always there; it is only usable while a swipe is live.
|
||||
const swap = el('button', 'data-toggle-chip ri-swap', controls, 'SWAP');
|
||||
const swap = el('button', 'data-toggle-chip ri-swap', controls, t('SWAP'));
|
||||
swap.id = 'ri-swap';
|
||||
swap.type = 'button';
|
||||
swap.title = 'Trade the two sides of the divider';
|
||||
swap.title = t('Trade the two sides of the divider');
|
||||
const exportHost = el('div', 'ri-exports', controls);
|
||||
const exports = createRailCardBlocks({
|
||||
container: exportHost,
|
||||
@@ -318,8 +363,8 @@ export function createRecentImageryPanel({
|
||||
actions: [
|
||||
{
|
||||
id: 'select-box',
|
||||
label: 'SELECT BOX',
|
||||
title: 'Drag a box on the map (Esc cancels)',
|
||||
label: t('SELECT BOX'),
|
||||
title: t('Drag a box on the map (Esc cancels)'),
|
||||
onClick: () => {
|
||||
exportError = null;
|
||||
if (tool?.isActive()) tool.cancel('toggle');
|
||||
@@ -328,8 +373,8 @@ export function createRecentImageryPanel({
|
||||
},
|
||||
{
|
||||
id: 'use-view',
|
||||
label: 'USE VIEW',
|
||||
title: 'Use the current view as the box',
|
||||
label: t('USE VIEW'),
|
||||
title: t('Use the current view as the box'),
|
||||
onClick: () => {
|
||||
exportError = null;
|
||||
layer.useCurrentView(viewer);
|
||||
@@ -337,8 +382,8 @@ export function createRecentImageryPanel({
|
||||
},
|
||||
{
|
||||
id: 'clear',
|
||||
label: 'CLEAR',
|
||||
title: 'Forget the box and its images',
|
||||
label: t('CLEAR', { context: 'action' }),
|
||||
title: t('Forget the box and its images'),
|
||||
disabled: !snapshot.box && !snapshot.boxError,
|
||||
onClick: () => {
|
||||
exportError = null;
|
||||
@@ -365,8 +410,9 @@ export function createRecentImageryPanel({
|
||||
// ---- notice -----------------------------------------------------------
|
||||
function renderNotice() {
|
||||
const warning = snapshot.boxError || snapshot.error || exportError;
|
||||
const text =
|
||||
warning || snapshot.notice || (snapshot.borrowedEsri ? ESRI_NOTE : '');
|
||||
const text = localizeNotice(
|
||||
warning || snapshot.notice || (snapshot.borrowedEsri ? ESRI_NOTE : ''),
|
||||
);
|
||||
set(noticeText, 'textContent', text);
|
||||
attribute(noticeText, 'title', text);
|
||||
toggle(notice, 'warn', Boolean(warning));
|
||||
@@ -388,7 +434,7 @@ export function createRecentImageryPanel({
|
||||
entry.thumb = el('div', 'ri-thumb', card);
|
||||
entry.placeholder = el('span', 'ri-thumb-text', entry.thumb);
|
||||
entry.flag = el('span', 'ri-card-flag', entry.thumb);
|
||||
entry.start = el('span', 'ri-card-start', entry.thumb, 'START HERE');
|
||||
entry.start = el('span', 'ri-card-start', entry.thumb, t('START HERE'));
|
||||
entry.date = el('div', 'ri-card-date', card);
|
||||
entry.sensor = el('div', 'ri-card-sensor', card);
|
||||
entry.cloud = el('div', 'ri-card-cloud', card);
|
||||
@@ -409,7 +455,7 @@ export function createRecentImageryPanel({
|
||||
const entry = cards.get(candidate.key);
|
||||
if (!entry) return;
|
||||
const { card } = entry;
|
||||
const day = shortDay(candidate.day);
|
||||
const day = displayDay(candidate.day);
|
||||
const ab = snapshot.mode === 'ab';
|
||||
set(card, 'id', `ri-card-${index}`);
|
||||
const focused = index === snapshot.focusIndex;
|
||||
@@ -432,13 +478,17 @@ export function createRecentImageryPanel({
|
||||
}
|
||||
if (entry.image) set(entry.image, 'hidden', !shown);
|
||||
set(entry.placeholder, 'hidden', shown);
|
||||
set(entry.placeholder, 'textContent', PLACEHOLDERS[status] || 'Checking');
|
||||
set(
|
||||
entry.placeholder,
|
||||
'textContent',
|
||||
PLACEHOLDERS[status] ? t(PLACEHOLDERS[status]) : t('Checking'),
|
||||
);
|
||||
if (entry.thumb.dataset.status !== status)
|
||||
entry.thumb.dataset.status = status;
|
||||
const flag = candidate.preview
|
||||
? 'PREVIEW'
|
||||
? t('PREVIEW')
|
||||
: candidate.pending
|
||||
? 'LOADING'
|
||||
? t('LOADING')
|
||||
: '';
|
||||
set(entry.flag, 'textContent', flag);
|
||||
set(entry.flag, 'hidden', !flag);
|
||||
@@ -448,8 +498,10 @@ export function createRecentImageryPanel({
|
||||
set(
|
||||
entry.start,
|
||||
'title',
|
||||
START_HERE_TITLES[snapshot.recommended.reason] ||
|
||||
'Newest day with imagery for this box',
|
||||
t(
|
||||
START_HERE_TITLES[snapshot.recommended.reason] ||
|
||||
msg('Newest day with imagery for this box'),
|
||||
),
|
||||
);
|
||||
set(entry.date, 'textContent', day);
|
||||
set(entry.sensor, 'textContent', sensorLine(candidate.product));
|
||||
@@ -459,7 +511,7 @@ export function createRecentImageryPanel({
|
||||
const chip = entry.chips[slotId];
|
||||
const live = slotId === 'a' || ab;
|
||||
const on = candidate.pinned === slotId && live;
|
||||
const label = slotId === 'a' ? (ab ? 'A' : 'SHOW') : 'B';
|
||||
const label = slotId === 'a' ? (ab ? 'A' : t('SHOW')) : 'B';
|
||||
set(chip, 'textContent', label);
|
||||
set(chip, 'disabled', empty || !live);
|
||||
toggle(chip, 'active', on);
|
||||
@@ -469,22 +521,29 @@ export function createRecentImageryPanel({
|
||||
attribute(
|
||||
chip,
|
||||
'aria-label',
|
||||
ab ? `Pin ${day} as ${slotId.toUpperCase()}` : `Show ${day}`,
|
||||
ab
|
||||
? t('Pin {day} as {slot}', { day, slot: slotId.toUpperCase() })
|
||||
: t('Show {day}', { day }),
|
||||
);
|
||||
}
|
||||
const state = candidate.pinned
|
||||
? ab
|
||||
? ` · pinned ${candidate.pinned.toUpperCase()}`
|
||||
? t('pinned {slot}', { slot: candidate.pinned.toUpperCase() })
|
||||
: candidate.pinned === 'a'
|
||||
? ' · shown'
|
||||
? t('shown')
|
||||
: ''
|
||||
: candidate.preview
|
||||
? ' · preview'
|
||||
? t('preview')
|
||||
: '';
|
||||
attribute(
|
||||
card,
|
||||
'aria-label',
|
||||
`${day} · ${sensorLine(candidate.product)} · ${cloudText(candidate)}${recommended ? ' · start here' : ''}${state}`,
|
||||
[
|
||||
day,
|
||||
sensorLine(candidate.product),
|
||||
cloudText(candidate),
|
||||
...[recommended ? t('start here') : '', state].filter(Boolean),
|
||||
].join(' · '),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -512,14 +571,14 @@ export function createRecentImageryPanel({
|
||||
candidates.length
|
||||
? ''
|
||||
: !snapshot.box
|
||||
? 'No box'
|
||||
? t('No box')
|
||||
: snapshot.searching
|
||||
? 'Searching'
|
||||
? t('Searching')
|
||||
: snapshot.hiddenCount
|
||||
? 'Every day is empty here'
|
||||
? t('Every day is empty here')
|
||||
: !snapshot.sources.hls && !snapshot.sources.viirs
|
||||
? 'Sources off'
|
||||
: 'No imagery',
|
||||
? t('Sources off')
|
||||
: t('No imagery'),
|
||||
);
|
||||
set(stripEmpty, 'hidden', candidates.length > 0);
|
||||
if (candidates[snapshot.focusIndex])
|
||||
@@ -596,18 +655,22 @@ export function createRecentImageryPanel({
|
||||
set(
|
||||
tag,
|
||||
'textContent',
|
||||
ab ? slotId.toUpperCase() : slotId === 'a' ? 'IMAGE' : 'VS',
|
||||
ab ? slotId.toUpperCase() : slotId === 'a' ? t('IMAGE') : t('VS'),
|
||||
);
|
||||
let text = 'Not set';
|
||||
if (basemapRow) text = 'Basemap';
|
||||
let text = t('Not set');
|
||||
if (basemapRow) text = t('Basemap');
|
||||
else if (image?.label)
|
||||
text = `${image.sourceOff ? 'Source off · ' : ''}${image.label}${
|
||||
text = [
|
||||
image.sourceOff ? t('Source off') : '',
|
||||
image.label,
|
||||
image.preview
|
||||
? ' · preview'
|
||||
? t('preview')
|
||||
: image.candidate && !image.drapable && !image.sourceOff
|
||||
? ' · loading'
|
||||
: ''
|
||||
}`;
|
||||
? t('loading')
|
||||
: '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ');
|
||||
set(value, 'textContent', text);
|
||||
attribute(value, 'title', text);
|
||||
const lit = basemapRow
|
||||
@@ -625,7 +688,9 @@ export function createRecentImageryPanel({
|
||||
attribute(
|
||||
unpin,
|
||||
'aria-label',
|
||||
ab ? `Unpin ${slotId.toUpperCase()}` : 'Unpin the image',
|
||||
ab
|
||||
? t('Unpin {slot}', { slot: slotId.toUpperCase() })
|
||||
: t('Unpin the image'),
|
||||
);
|
||||
set(unpin, 'title', unpin.getAttribute('aria-label'));
|
||||
}
|
||||
@@ -658,8 +723,12 @@ export function createRecentImageryPanel({
|
||||
type: 'actions',
|
||||
actions: ['a', 'b'].map((slotId) => ({
|
||||
id: `export-${slotId}`,
|
||||
label: ab ? `EXPORT ${slotId.toUpperCase()}` : 'EXPORT',
|
||||
title: `Download ${ab ? slotId.toUpperCase() : 'the image'} as a PNG`,
|
||||
label: ab
|
||||
? t('EXPORT {slot}', { slot: slotId.toUpperCase() })
|
||||
: t('EXPORT'),
|
||||
title: ab
|
||||
? t('Download {slot} as a PNG', { slot: slotId.toUpperCase() })
|
||||
: t('Download the image as a PNG'),
|
||||
disabled: !exportTarget(slotId) || exporting.has(slotId),
|
||||
onClick: () => void exportImage(slotId),
|
||||
})),
|
||||
@@ -682,7 +751,10 @@ export function createRecentImageryPanel({
|
||||
if (snapshot.box)
|
||||
lines.push({
|
||||
id: 'box',
|
||||
text: `Box ${kmText(snapshot.boxSizeKm?.width)} × ${kmText(snapshot.boxSizeKm?.height)} km`,
|
||||
text: t('Box {width} × {height} km', {
|
||||
width: kmText(snapshot.boxSizeKm?.width),
|
||||
height: kmText(snapshot.boxSizeKm?.height),
|
||||
}),
|
||||
});
|
||||
if (focus) {
|
||||
lines.push({ id: 'readout', text: snapshot.readout || '' });
|
||||
@@ -690,14 +762,24 @@ export function createRecentImageryPanel({
|
||||
if (focus.timeRange?.start) {
|
||||
const start = utcTime(focus.timeRange.start);
|
||||
const end = utcTime(focus.timeRange.end || focus.timeRange.start);
|
||||
times.push(`Acquired ${start === end ? start : `${start}–${end}`}`);
|
||||
times.push(
|
||||
t('Acquired {time}', {
|
||||
time: start === end ? start : `${start}–${end}`,
|
||||
}),
|
||||
);
|
||||
} else if (focus.thumbnail?.acquisitionTime)
|
||||
times.push(`Acquired ${utcTime(focus.thumbnail.acquisitionTime)}`);
|
||||
times.push(`Coverage ${focus.coverage || 'unknown'}`);
|
||||
times.push(
|
||||
t('Acquired {time}', {
|
||||
time: utcTime(focus.thumbnail.acquisitionTime),
|
||||
}),
|
||||
);
|
||||
times.push(
|
||||
t('Coverage {coverage}', { coverage: coverageText(focus.coverage) }),
|
||||
);
|
||||
lines.push({ id: 'acquired', text: times.join(' · ') });
|
||||
(focus.granules || []).forEach((granule, index) => {
|
||||
const cloud = Number.isFinite(granule.cloud)
|
||||
? ` · ${Math.round(granule.cloud)}% cloud`
|
||||
? ` · ${t('{percent}% cloud', { percent: Math.round(granule.cloud) })}`
|
||||
: '';
|
||||
lines.push({
|
||||
id: `granule-${index}`,
|
||||
@@ -707,7 +789,11 @@ export function createRecentImageryPanel({
|
||||
});
|
||||
}
|
||||
const reason = START_HERE_REASONS[snapshot.recommended?.reason];
|
||||
if (reason) lines.push({ id: 'start', text: `START HERE · ${reason}` });
|
||||
if (reason)
|
||||
lines.push({
|
||||
id: 'start',
|
||||
text: t('START HERE · {reason}', { reason: t(reason) }),
|
||||
});
|
||||
for (const [index, note] of snapshot.notes.entries())
|
||||
lines.push({ id: `note-${index}`, text: note, muted: true });
|
||||
if (
|
||||
@@ -718,12 +804,14 @@ export function createRecentImageryPanel({
|
||||
)
|
||||
lines.push({
|
||||
id: 'overview-scale',
|
||||
text: 'Daily overview shows little detail in a box this small',
|
||||
text: t('Daily overview shows little detail in a box this small'),
|
||||
muted: true,
|
||||
});
|
||||
lines.push({
|
||||
id: 'credit',
|
||||
text: 'Imagery: NASA GIBS and Worldview · HLS (Sentinel-2, Landsat 8/9) and VIIRS',
|
||||
text: t(
|
||||
'Imagery: NASA GIBS and Worldview · HLS (Sentinel-2, Landsat 8/9) and VIIRS',
|
||||
),
|
||||
muted: true,
|
||||
});
|
||||
return lines;
|
||||
@@ -735,9 +823,11 @@ export function createRecentImageryPanel({
|
||||
details.update([
|
||||
{
|
||||
id: 'details',
|
||||
title: 'Details',
|
||||
title: t('Details'),
|
||||
open: detailsOpen,
|
||||
compact: snapshot.focus ? snapshot.readout : 'Times, coverage, sources',
|
||||
compact: snapshot.focus
|
||||
? snapshot.readout
|
||||
: t('Times, coverage, sources'),
|
||||
blocks: [
|
||||
{ id: 'lines', type: 'lines', lines: detailLines() },
|
||||
{
|
||||
@@ -747,9 +837,9 @@ export function createRecentImageryPanel({
|
||||
{
|
||||
id: 'toggle-empty',
|
||||
label: showing
|
||||
? 'HIDE EMPTY DAYS'
|
||||
: `SHOW EMPTY DAYS · ${hidden}`,
|
||||
title: 'Days the probe found empty in this box',
|
||||
? t('HIDE EMPTY DAYS')
|
||||
: t('SHOW EMPTY DAYS · {count}', { count: hidden }),
|
||||
title: t('Days the probe found empty in this box'),
|
||||
disabled: !showing && !hidden,
|
||||
onClick: () => layer.setShowUnavailable(!showing),
|
||||
},
|
||||
@@ -773,8 +863,10 @@ export function createRecentImageryPanel({
|
||||
const describe = (c) => (c ? `${sensorLine(c.product)} · ${c.day}` : '');
|
||||
// The two sides as [label, title, spoken name]; SWAP trades them.
|
||||
const sides = [
|
||||
basemap ? ['IMAGE', describe(a), 'image'] : ['A', describe(a), 'A'],
|
||||
basemap ? ['BASEMAP', 'The basemap', 'basemap'] : ['B', describe(b), 'B'],
|
||||
basemap ? [t('IMAGE'), describe(a), t('image')] : ['A', describe(a), 'A'],
|
||||
basemap
|
||||
? [t('BASEMAP'), t('The basemap'), t('basemap')]
|
||||
: ['B', describe(b), 'B'],
|
||||
];
|
||||
const [before, after] = swapped ? [sides[1], sides[0]] : sides;
|
||||
const signature = live
|
||||
@@ -799,10 +891,15 @@ export function createRecentImageryPanel({
|
||||
beforeTitle: before[1],
|
||||
afterTitle: after[1],
|
||||
ariaLabel: basemap
|
||||
? 'Recent imagery against the basemap divider'
|
||||
: 'Recent imagery A and B divider',
|
||||
? t('Recent imagery against the basemap divider')
|
||||
: t('Recent imagery A and B divider'),
|
||||
formatValueText: (leftPercent, rightPercent) =>
|
||||
`${before[2][0].toUpperCase()}${before[2].slice(1)} ${leftPercent} percent, ${after[2]} ${rightPercent} percent`,
|
||||
t('{before} {left} percent, {after} {right} percent', {
|
||||
before: `${before[2][0].toUpperCase()}${before[2].slice(1)}`,
|
||||
left: leftPercent,
|
||||
after: after[2],
|
||||
right: rightPercent,
|
||||
}),
|
||||
getViewportWidth: () =>
|
||||
Number(viewer?.scene?.canvas?.clientWidth) ||
|
||||
Number(document.documentElement?.clientWidth) ||
|
||||
@@ -895,7 +992,9 @@ export function createRecentImageryPanel({
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (!destroyed)
|
||||
exportError = `Export failed · ${error?.message || error}`;
|
||||
exportError = t('Export failed · {error}', {
|
||||
error: error?.message || error,
|
||||
});
|
||||
return false;
|
||||
} finally {
|
||||
exporting.delete(slotId);
|
||||
|
||||
@@ -646,7 +646,10 @@ test('cards carry thumbnail, date, sensor, cloud, START HERE and PREVIEW; the ch
|
||||
f.layer.focus(1);
|
||||
const s18 = f.card(S18);
|
||||
assert.equal(f.part(s18, 'ri-card-date').textContent, 'Sep 18');
|
||||
assert.equal(f.part(s18, 'ri-card-sensor').textContent, 'Sentinel-2 · 30 m');
|
||||
assert.equal(
|
||||
f.part(s18, 'ri-card-sensor').textContent,
|
||||
'Sentinel-2 · 30\u00a0m',
|
||||
);
|
||||
assert.equal(f.part(s18, 'ri-card-cloud').textContent, '12% cloud');
|
||||
assert.equal(f.part(s18, 'ri-card-start').hidden, false);
|
||||
assert.match(f.part(s18, 'ri-card-start').title, /Newest low-cloud day/);
|
||||
@@ -658,11 +661,11 @@ test('cards carry thumbnail, date, sensor, cloud, START HERE and PREVIEW; the ch
|
||||
assert.equal(f.part(f.card(V21), 'ri-thumb-text').textContent, 'Checking');
|
||||
assert.equal(
|
||||
f.part(f.card(V21), 'ri-card-sensor').textContent,
|
||||
'Daily overview · 250 m',
|
||||
'Daily overview · 250\u00a0m',
|
||||
);
|
||||
assert.equal(
|
||||
f.part(f.card(L16), 'ri-card-sensor').textContent,
|
||||
'Landsat 8/9 · 30 m',
|
||||
'Landsat 8/9 · 30\u00a0m',
|
||||
);
|
||||
// IMAGE: one SHOW chip; the B chip keeps its box but is invisible.
|
||||
const [a, b] = [f.chip(S18, 'a'), f.chip(S18, 'b')];
|
||||
@@ -734,7 +737,10 @@ test('the selection bar reads the mode, both rows and a preview, with × reserve
|
||||
['A / B', 'radio', 'false'],
|
||||
],
|
||||
);
|
||||
assert.equal(rowText(f, 'a'), 'IMAGE | Sep 18 · Sentinel-2 · 30 m · preview');
|
||||
assert.equal(
|
||||
rowText(f, 'a'),
|
||||
'IMAGE | Sep 18 · Sentinel-2 · 30\u00a0m · preview',
|
||||
);
|
||||
assert.equal(f.byId('ri-slot-a').dataset.state, 'preview');
|
||||
assert.equal(rowText(f, 'b'), 'VS | Basemap');
|
||||
assert.equal(f.byId('ri-slot-b').classList.contains('lit'), false, 'dimmed');
|
||||
@@ -748,7 +754,7 @@ test('the selection bar reads the mode, both rows and a preview, with × reserve
|
||||
[true, true, '×'],
|
||||
);
|
||||
f.key('s');
|
||||
assert.equal(rowText(f, 'a'), 'IMAGE | Sep 18 · Sentinel-2 · 30 m');
|
||||
assert.equal(rowText(f, 'a'), 'IMAGE | Sep 18 · Sentinel-2 · 30\u00a0m');
|
||||
assert.equal(f.byId('ri-slot-a').dataset.state, 'pinned');
|
||||
assert.equal(unpinA.disabled, false);
|
||||
assert.equal(unpinA.getAttribute('aria-label'), 'Unpin the image');
|
||||
@@ -757,12 +763,12 @@ test('the selection bar reads the mode, both rows and a preview, with × reserve
|
||||
assert.equal(modes[1].getAttribute('aria-checked'), 'true');
|
||||
assert.equal(f.byId('ri-slot-b').classList.contains('lit'), true, 'lit');
|
||||
modes[2].click();
|
||||
assert.equal(rowText(f, 'a'), 'A | Sep 18 · Sentinel-2 · 30 m');
|
||||
assert.equal(rowText(f, 'a'), 'A | Sep 18 · Sentinel-2 · 30\u00a0m');
|
||||
assert.equal(rowText(f, 'b'), 'B | Not set');
|
||||
assert.equal(f.byId('ri-slot-b').dataset.state, 'empty');
|
||||
assert.equal(f.byId('ri-unpin-b').classList.contains('is-reserved'), true);
|
||||
f.chip(L16, 'b').click();
|
||||
assert.equal(rowText(f, 'b'), 'B | Sep 16 · Landsat 8/9 · 30 m');
|
||||
assert.equal(rowText(f, 'b'), 'B | Sep 16 · Landsat 8/9 · 30\u00a0m');
|
||||
f.byId('ri-unpin-b').click();
|
||||
assert.equal(f.snap().pins.b.key, null);
|
||||
unpinA.click();
|
||||
@@ -879,7 +885,10 @@ test('the notice line shows the refusal, then errors, then CLEAR, then the Esri
|
||||
);
|
||||
assert.equal(notice.classList.contains('info'), true);
|
||||
f.layer.setBox({ west: 0, south: 0, east: 20, north: 20 });
|
||||
assert.equal(noticeText.textContent, 'Box is 2,226 km wide · limit 1,000 km');
|
||||
assert.equal(
|
||||
noticeText.textContent,
|
||||
'Box is 2,226\u00a0km wide · limit 1,000\u00a0km',
|
||||
);
|
||||
assert.equal(notice.classList.contains('warn'), true);
|
||||
assert.equal(hint.textContent, 'Zoom in or draw a smaller box');
|
||||
f.layer.setBox(BOX);
|
||||
@@ -929,7 +938,7 @@ test('an oversized box offers ZOOM IN in a fixed slot at the end of the notice l
|
||||
assert.deepEqual(reserved(), [false, false, 'false', true]);
|
||||
assert.equal(
|
||||
f.byId('ri-notice-text').textContent,
|
||||
'Box is 2,226 km wide · limit 1,000 km',
|
||||
'Box is 2,226\u00a0km wide · limit 1,000\u00a0km',
|
||||
);
|
||||
assert.equal(f.byId('ri-hint').textContent, 'Zoom in or draw a smaller box');
|
||||
assert.equal(zoom.title, 'Fly in until the view fits the 1,000 km limit');
|
||||
@@ -998,7 +1007,10 @@ test('the divider exists only while a swipe is live, labelled A / B or IMAGE / B
|
||||
[split.options.beforeLabel, split.options.afterLabel],
|
||||
['A', 'B'],
|
||||
);
|
||||
assert.equal(split.options.afterTitle, 'Landsat 8/9 · 30 m · 2026-09-16');
|
||||
assert.equal(
|
||||
split.options.afterTitle,
|
||||
'Landsat 8/9 · 30\u00a0m · 2026-09-16',
|
||||
);
|
||||
split.options.onChange(0.3);
|
||||
assert.equal(f.snap().split, 0.3);
|
||||
assert.deepEqual(directions(), ['left', 'right']);
|
||||
@@ -1015,7 +1027,7 @@ test('the divider exists only while a swipe is live, labelled A / B or IMAGE / B
|
||||
assert.deepEqual(labels(), ['B', 'A']);
|
||||
assert.equal(
|
||||
f.splits.at(-1).options.beforeTitle,
|
||||
'Landsat 8/9 · 30 m · 2026-09-16',
|
||||
'Landsat 8/9 · 30\u00a0m · 2026-09-16',
|
||||
);
|
||||
assert.equal(f.splits.at(-1).options.initialValue, 0.3);
|
||||
assert.deepEqual(directions(), ['right', 'left']);
|
||||
|
||||
@@ -163,7 +163,9 @@ export class SceneControls {
|
||||
const shot = scene?.shots.find((item) => item.id === shotId);
|
||||
if (
|
||||
shot &&
|
||||
window.confirm(t('Delete shot "{shot}"?', { shot: shot.title }))
|
||||
window.confirm(
|
||||
t('Delete shot "{shot}"?', { shot: shot.displayTitle || shot.title }),
|
||||
)
|
||||
)
|
||||
this.run('deleteShot', sceneId, shotId);
|
||||
}
|
||||
|
||||
+53
-10
@@ -1,4 +1,26 @@
|
||||
/** Scene panel elements and text-only project presentation. */
|
||||
import { formatNumber, msg, t } from '../i18n/index.js';
|
||||
|
||||
// Labels for what a shot stores; anything else shows as stored. Style names
|
||||
// match STYLE_STATUS_LABELS in visualPresets.js, which this package cannot
|
||||
// import (it pulls in the shaders).
|
||||
const STYLE_LABELS = Object.freeze({
|
||||
normal: msg('NORMAL'),
|
||||
retro: msg('CRT'),
|
||||
surveillance: msg('NVG'),
|
||||
thermal: msg('FLIR'),
|
||||
anime: msg('ANIME'),
|
||||
noir: msg('NOIR'),
|
||||
snow: msg('SNOW'),
|
||||
});
|
||||
|
||||
const DETECTION_MODE_LABELS = Object.freeze({
|
||||
OFF: msg('OFF'),
|
||||
SPARSE: msg('SPARSE'),
|
||||
BALANCED: msg('BALANCED'),
|
||||
DENSE: msg('DENSE'),
|
||||
});
|
||||
|
||||
export function sceneElements(root = document) {
|
||||
const ids = {
|
||||
panel: 'scene-panel',
|
||||
@@ -47,11 +69,16 @@ export function renderSceneShots(
|
||||
if (!scene || scene.shots.length === 0) {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'scene-shot-empty';
|
||||
empty.textContent = 'No shots yet. Use CAPTURE SHOT to save current look.';
|
||||
empty.textContent = t(
|
||||
'No shots yet. Use CAPTURE SHOT to save current look.',
|
||||
);
|
||||
element.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
for (const shot of scene.shots) {
|
||||
// Built-in titles arrive localized as displayTitle; the stored title
|
||||
// stays English, so a rename happens only when the shown text changes.
|
||||
const shownTitle = shot.displayTitle || shot.title;
|
||||
const row = document.createElement('div');
|
||||
row.className = 'scene-shot-row';
|
||||
row.dataset.sceneShotId = shot.id;
|
||||
@@ -60,23 +87,23 @@ export function renderSceneShots(
|
||||
top.className = 'scene-shot-top';
|
||||
const label = document.createElement('div');
|
||||
label.className = 'scene-shot-label';
|
||||
label.textContent = shot.title;
|
||||
label.title = 'Double-click to rename';
|
||||
label.textContent = shownTitle;
|
||||
label.title = t('Double-click to rename');
|
||||
listen(label, 'click', () => select(shot.id));
|
||||
listen(label, 'dblclick', () => {
|
||||
if (label.children.length) return;
|
||||
const input = document.createElement('input');
|
||||
input.type = 'text';
|
||||
input.className = 'scene-shot-rename';
|
||||
input.setAttribute('aria-label', 'Shot name');
|
||||
input.value = shot.title;
|
||||
input.setAttribute('aria-label', t('Shot name'));
|
||||
input.value = shownTitle;
|
||||
let finished = false;
|
||||
const finish = (save) => {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
const title = input.value.trim();
|
||||
label.textContent = shot.title;
|
||||
if (save && title && title !== shot.title)
|
||||
label.textContent = shownTitle;
|
||||
if (save && title && title !== shownTitle)
|
||||
rename(scene.id, shot.id, title);
|
||||
};
|
||||
listen(input, 'keydown', (event) => {
|
||||
@@ -96,11 +123,11 @@ export function renderSceneShots(
|
||||
actions.className = 'scene-shot-actions';
|
||||
const loadButton = document.createElement('button');
|
||||
loadButton.className = 'scene-shot-btn';
|
||||
loadButton.textContent = 'LOAD';
|
||||
loadButton.textContent = t('LOAD');
|
||||
listen(loadButton, 'click', () => load(scene.id, shot.id));
|
||||
const deleteButton = document.createElement('button');
|
||||
deleteButton.className = 'scene-shot-btn scene-shot-danger';
|
||||
deleteButton.textContent = 'DEL';
|
||||
deleteButton.textContent = t('DEL');
|
||||
listen(deleteButton, 'click', () => remove(scene.id, shot.id));
|
||||
actions.appendChild(loadButton);
|
||||
actions.appendChild(deleteButton);
|
||||
@@ -110,7 +137,23 @@ export function renderSceneShots(
|
||||
meta.className = 'scene-shot-meta';
|
||||
const mode = shot.visual?.detection?.mode || 'OFF';
|
||||
const style = shot.visual?.style || 'normal';
|
||||
meta.textContent = `${style.toUpperCase()} · ${mode} · ${shot.durationSec.toFixed(1)}s + ${shot.holdSec.toFixed(1)}s`;
|
||||
const styleLabel = STYLE_LABELS[style]
|
||||
? t(STYLE_LABELS[style])
|
||||
: style.toUpperCase();
|
||||
const modeLabel = DETECTION_MODE_LABELS[mode]
|
||||
? t(DETECTION_MODE_LABELS[mode])
|
||||
: mode;
|
||||
const seconds = (value) =>
|
||||
formatNumber(value, {
|
||||
minimumFractionDigits: 1,
|
||||
maximumFractionDigits: 1,
|
||||
});
|
||||
// English keeps its compact "4.0s + 0.9s"; other languages space the unit.
|
||||
const timing = t('{duration}s + {hold}s', {
|
||||
duration: seconds(shot.durationSec),
|
||||
hold: seconds(shot.holdSec),
|
||||
});
|
||||
meta.textContent = `${styleLabel} · ${modeLabel} · ${timing}`;
|
||||
row.appendChild(top);
|
||||
row.appendChild(meta);
|
||||
element.appendChild(row);
|
||||
|
||||
+66
-3
@@ -1,3 +1,5 @@
|
||||
import { t, tn } from '../i18n/index.js';
|
||||
|
||||
/** Small owned modal for scene authoring and import review; text never becomes markup. */
|
||||
export function createSceneDialog(title, onClose) {
|
||||
const dialog = document.createElement('dialog');
|
||||
@@ -79,10 +81,71 @@ export function createSceneDialog(title, onClose) {
|
||||
});
|
||||
if (multiline) node.rows = 9;
|
||||
wrapper.append(node);
|
||||
if (type === 'file') fileChooser(wrapper, node);
|
||||
body.append(wrapper);
|
||||
return node;
|
||||
}
|
||||
button('Cancel', onClose);
|
||||
/**
|
||||
* The browser draws a file input's button and "no file" text in its own
|
||||
* language. Hide it visually (it stays focusable and is still opened by its
|
||||
* label) and show a translated button and selection line instead.
|
||||
*/
|
||||
function fileChooser(wrapper, node) {
|
||||
wrapper.style.position = 'relative';
|
||||
Object.assign(node.style, {
|
||||
position: 'absolute',
|
||||
width: '1px',
|
||||
height: '1px',
|
||||
margin: '0',
|
||||
padding: '0',
|
||||
overflow: 'hidden',
|
||||
clip: 'rect(0 0 0 0)',
|
||||
whiteSpace: 'nowrap',
|
||||
border: '0',
|
||||
});
|
||||
const row = document.createElement('span');
|
||||
row.setAttribute('aria-hidden', 'true');
|
||||
Object.assign(row.style, {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '10px',
|
||||
margin: '6px 0 14px',
|
||||
padding: '7px',
|
||||
background: '#10242e',
|
||||
border: '1px solid #397080',
|
||||
});
|
||||
const chooser = document.createElement('span');
|
||||
chooser.textContent = t('Choose files');
|
||||
Object.assign(chooser.style, {
|
||||
padding: '3px 9px',
|
||||
border: '1px solid #397080',
|
||||
borderRadius: '3px',
|
||||
background: '#08141e',
|
||||
cursor: 'pointer',
|
||||
});
|
||||
const chosen = document.createElement('span');
|
||||
const showChosen = () => {
|
||||
const count = node.files?.length || 0;
|
||||
// One file shows its name, like the browser's own picker did.
|
||||
chosen.textContent =
|
||||
count === 1
|
||||
? node.files[0].name
|
||||
: count
|
||||
? tn(count, '{count} file selected', '{count} files selected')
|
||||
: t('No file chosen');
|
||||
};
|
||||
showChosen();
|
||||
row.append(chooser, chosen);
|
||||
wrapper.append(row);
|
||||
listen(node, 'change', showChosen);
|
||||
listen(node, 'focus', () => {
|
||||
chooser.style.outline = '2px solid #e3faff';
|
||||
});
|
||||
listen(node, 'blur', () => {
|
||||
chooser.style.outline = '';
|
||||
});
|
||||
}
|
||||
button(t('Cancel'), onClose);
|
||||
listen(dialog, 'cancel', (event) => {
|
||||
event.preventDefault();
|
||||
onClose();
|
||||
@@ -116,8 +179,8 @@ export function mountSceneSharing(
|
||||
bar.dataset.directorAuthoring = '';
|
||||
bar.className = 'scene-controls';
|
||||
const entries = [
|
||||
['EDIT DETAILS', edit],
|
||||
['SHARE SCENE', share],
|
||||
[t('EDIT DETAILS'), edit],
|
||||
[t('SHARE SCENE'), share],
|
||||
];
|
||||
const removers = [];
|
||||
for (const [text, fn] of entries) {
|
||||
|
||||
@@ -192,7 +192,9 @@ export class ShareRestoration {
|
||||
this.showStatus(t('ACQUIRING'), {
|
||||
state: 'acquiring',
|
||||
detail: t('SHARED {subject}', {
|
||||
subject: String(result.label || t('entity')).toUpperCase(),
|
||||
subject: String(
|
||||
result.label ? t(result.label) : t('subject'),
|
||||
).toUpperCase(),
|
||||
}),
|
||||
persistent: true,
|
||||
});
|
||||
@@ -217,7 +219,7 @@ export class ShareRestoration {
|
||||
const noticeGeneration = ownsAcquiringNotice
|
||||
? this._shareTrackingNoticeGeneration
|
||||
: ++this._shareTrackingNoticeGeneration;
|
||||
const subject = result.label || t('entity');
|
||||
const subject = result.label ? t(result.label) : t('entity');
|
||||
const message =
|
||||
result.classification === 'expired'
|
||||
? t('Shared {subject} follow expired', { subject })
|
||||
|
||||
+27
-10
@@ -10,6 +10,7 @@ import {
|
||||
} from '../loadingFeedback.js';
|
||||
import { setSplitFlapText, disposeSplitFlap } from '../splitFlap.js';
|
||||
import { t, msg } from '../i18n/index.js';
|
||||
import { localizeLayerName } from './layerPanel.js';
|
||||
|
||||
// loadingFeedback.js composes the shared status copy inside the portable
|
||||
// node/browser graph (the server-side HUD summary reads the same modules), so
|
||||
@@ -25,6 +26,9 @@ const STATUS_LABEL_KEYS = Object.freeze({
|
||||
'MAPPED SITES LOADED': msg('MAPPED SITES LOADED'),
|
||||
'RETRYING MAPPED SITES': msg('RETRYING MAPPED SITES'),
|
||||
'FETCHING MAPPED SITES': msg('FETCHING MAPPED SITES'),
|
||||
'RETRYING ALPR CAMERAS': msg('RETRYING ALPR CAMERAS'),
|
||||
'FETCHING ALPR CAMERAS': msg('FETCHING ALPR CAMERAS'),
|
||||
'OVERPASS TEMPORARILY UNAVAILABLE': msg('OVERPASS TEMPORARILY UNAVAILABLE'),
|
||||
'TURNING OFF LIVE DATA': msg('TURNING OFF LIVE DATA'),
|
||||
'REFRESHING LIVE DATA': msg('REFRESHING LIVE DATA'),
|
||||
'syncing road network': msg('syncing road network'),
|
||||
@@ -43,7 +47,21 @@ function localizeStatusDetail(detail) {
|
||||
const match = detail.match(RETRY_DETAIL_RE);
|
||||
if (match) return t('retrying in {seconds}s', { seconds: match[1] });
|
||||
if (detail.trim() === 'retry pending') return t('retry pending');
|
||||
return detail;
|
||||
return localizeLayerRoster(detail);
|
||||
}
|
||||
|
||||
// The loading detail is the active layers' English names joined by " · ",
|
||||
// with " +N" when more are loading. Translate each known layer name; anything
|
||||
// else (a provider such as "OpenStreetMap · Overpass") passes through.
|
||||
function localizeLayerRoster(detail) {
|
||||
const [, names, suffix = ''] = detail.match(/^(.*?)( \+\d+)?$/s) || [];
|
||||
if (!names) return detail;
|
||||
return (
|
||||
names
|
||||
.split(' · ')
|
||||
.map((name) => localizeLayerName(name))
|
||||
.join(' · ') + suffix
|
||||
);
|
||||
}
|
||||
|
||||
function localizeLoadingPresentation(presentation) {
|
||||
@@ -121,15 +139,14 @@ export class ShellFeedback {
|
||||
// the flap keeps textContent equal to the settled label throughout, so
|
||||
// this stays a no-op on the repeat ticks exactly as it did before.
|
||||
if (presentation.label)
|
||||
if (presentation.label)
|
||||
setSplitFlapText(
|
||||
this._trafficSyncLabel,
|
||||
localizeStatusLabel(presentation.label),
|
||||
{
|
||||
// Completion is a timing signal, not another animated loading stage.
|
||||
immediate: !presentation.busy,
|
||||
},
|
||||
);
|
||||
setSplitFlapText(
|
||||
this._trafficSyncLabel,
|
||||
localizeStatusLabel(presentation.label),
|
||||
{
|
||||
// Completion is a timing signal, not another animated loading stage.
|
||||
immediate: !presentation.busy,
|
||||
},
|
||||
);
|
||||
// Written on every change INCLUDING the empty settled value — the reducer
|
||||
// clears the progress number once the sync lands, and a truthiness guard
|
||||
// here would strand the last "..." beside the settled label.
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
SINCE_STOPS,
|
||||
} from './streetLevelPresentation.js';
|
||||
|
||||
import { t } from '../i18n/index.js';
|
||||
import { t, translateKnown } from '../i18n/index.js';
|
||||
const RENDER_MODE_KEY = 'gev:street-level:render-mode';
|
||||
const FOCUSABLE =
|
||||
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
||||
@@ -129,7 +129,7 @@ export class StreetLevelControls {
|
||||
const sinceDays = () =>
|
||||
SINCE_STOPS[Number(el.sinceRange?.value) || 0]?.days ?? 0;
|
||||
this.listen(el.sinceRange, 'input', () => {
|
||||
const label = SINCE_STOPS[Number(el.sinceRange.value) || 0].label;
|
||||
const label = t(SINCE_STOPS[Number(el.sinceRange.value) || 0].label);
|
||||
if (el.sinceLabel) el.sinceLabel.textContent = label;
|
||||
el.sinceRange.setAttribute('aria-valuetext', label);
|
||||
});
|
||||
@@ -230,7 +230,8 @@ export class StreetLevelControls {
|
||||
await this.actions.setEnabled?.(true);
|
||||
} catch (error) {
|
||||
this.actions.showToast?.(
|
||||
error?.message || t('Street Level could not start'),
|
||||
translateKnown(error?.message || '') ||
|
||||
t('Street Level could not start'),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
@@ -243,7 +244,7 @@ export class StreetLevelControls {
|
||||
await this.actions.setEnabled?.(!enabled);
|
||||
} catch (error) {
|
||||
this.actions.showToast?.(
|
||||
error?.message || t('Street Level toggle failed'),
|
||||
translateKnown(error?.message || '') || t('Street Level toggle failed'),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -277,7 +278,7 @@ export class StreetLevelControls {
|
||||
await this.actions.setEnabled?.(false);
|
||||
} catch (error) {
|
||||
this.actions.showToast?.(
|
||||
error?.message || t('Street Level toggle failed'),
|
||||
translateKnown(error?.message || '') || t('Street Level toggle failed'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { keyRequirementText, withKeyAlternatives } from './keyGuidance.js';
|
||||
import { formatNumber, msg, t, tn } from '../i18n/index.js';
|
||||
import { formatNumber, msg, t, tn, translateKnown } from '../i18n/index.js';
|
||||
import { keyGuidanceText, keyRequirementText } from './keyGuidance.js';
|
||||
|
||||
// Street Level UI state to panel strings and flags. Pure, so it is testable.
|
||||
|
||||
@@ -70,7 +70,7 @@ function keyGuidance(state) {
|
||||
.filter((p) => p.on && p.keyRequired && p.requiresKeyId)
|
||||
.map((p) => keyRequirementText(p.requiresKeyId))
|
||||
.filter(Boolean);
|
||||
return withKeyAlternatives(
|
||||
return keyGuidanceText(
|
||||
[...new Set(needs)].join('; '),
|
||||
state.keyAlternatives || [],
|
||||
);
|
||||
@@ -107,13 +107,14 @@ function presentProviders(state) {
|
||||
? t('{name} imagery on', { name: provider.name })
|
||||
: t('{name} imagery off', { name: provider.name });
|
||||
if (provider.keyRejected && provider.error)
|
||||
title = `${provider.name}: ${provider.error}`;
|
||||
title = `${provider.name}: ${translateKnown(provider.error)}`;
|
||||
else if (keyRequired && provider.requiresKeyId)
|
||||
title = `${provider.name}: ${keyRequirementText(provider.requiresKeyId)}`;
|
||||
else if (provider.error) title = `${provider.name}: ${provider.error}`;
|
||||
else if (provider.error)
|
||||
title = `${provider.name}: ${translateKnown(provider.error)}`;
|
||||
return {
|
||||
id: provider.id,
|
||||
label: provider.label,
|
||||
label: translateKnown(provider.label),
|
||||
color: provider.color || null,
|
||||
title,
|
||||
active: on,
|
||||
@@ -140,7 +141,8 @@ function presentViewer(state) {
|
||||
right.push(formatDate(street.capturedAt, street.capturedAtPrecision));
|
||||
const left = [];
|
||||
if (street.title) left.push(street.title);
|
||||
if (street.creator) left.push(t('Image by {creator}', { creator: street.creator }));
|
||||
if (street.creator)
|
||||
left.push(t('Image by {creator}', { creator: street.creator }));
|
||||
return {
|
||||
open: street.open === true,
|
||||
loading: street.loading === true && !street.imageId,
|
||||
@@ -149,7 +151,9 @@ function presentViewer(state) {
|
||||
captionLeft: left.join(' · '),
|
||||
captionRight: right.join(' · '),
|
||||
link: street.externalUrl || null,
|
||||
linkLabel: street.providerLabel ? `${street.providerLabel} ↗` : '',
|
||||
linkLabel: street.providerLabel
|
||||
? `${translateKnown(street.providerLabel)} ↗`
|
||||
: '',
|
||||
follow: {
|
||||
pressed: street.follow === true,
|
||||
disabled: street.open !== true || street.followAvailable !== true,
|
||||
@@ -179,13 +183,16 @@ function presentNearest(state) {
|
||||
disabled: true,
|
||||
providerIds: [],
|
||||
};
|
||||
const names = targets.map((provider) => provider.name).join(t(' or '));
|
||||
const names = targets
|
||||
.map((provider) => translateKnown(provider.name))
|
||||
.join(t(' or '));
|
||||
const labels = targets
|
||||
.map((provider) => translateKnown(provider.label))
|
||||
.join(' / ');
|
||||
const ready = state.groundClickReady === true;
|
||||
return {
|
||||
visible: true,
|
||||
label: t('OPEN {labels}', {
|
||||
labels: targets.map((provider) => provider.label).join(' / '),
|
||||
}),
|
||||
label: t('OPEN {labels}', { labels }),
|
||||
title: ready
|
||||
? t('Open {names} at the centre of the view', { names })
|
||||
: t('Zoom in to a street to open {names} here', { names }),
|
||||
@@ -194,9 +201,13 @@ function presentNearest(state) {
|
||||
};
|
||||
}
|
||||
|
||||
/** Provider hints are English data joined by " · "; translate each part. */
|
||||
function translateHint(hint) {
|
||||
return hint ? hint.split(' · ').map(translateKnown).join(' · ') : '';
|
||||
}
|
||||
|
||||
function presentMeta(state) {
|
||||
if (state.providers.length === 0)
|
||||
return state.coverage.hint ? t(state.coverage.hint) : '';
|
||||
if (state.providers.length === 0) return translateHint(state.coverage.hint);
|
||||
if (!state.enabled) return t('Switch a provider on to draw its coverage.');
|
||||
// Key-gated with another source ready: say how to use it.
|
||||
if (state.keyRequired && state.keyAlternatives?.length)
|
||||
@@ -210,26 +221,25 @@ function presentMeta(state) {
|
||||
'{n} images in this sequence · Esc clears',
|
||||
{ n: formatNumber(state.sequence.images) },
|
||||
);
|
||||
const hint = state.coverage.hint ? t(state.coverage.hint) : '';
|
||||
const hint = translateHint(state.coverage.hint);
|
||||
if (state.coverage.count > 0) {
|
||||
// A provider whose lines are not sequences carries its wording as data
|
||||
// (coverageLabel); its count line needs its own tn pair so every language
|
||||
// gets real plural forms. New wording: add a pair here and to the catalogs.
|
||||
const count = state.coverage.count;
|
||||
const n = () => formatNumber(count);
|
||||
const countLine =
|
||||
state.coverage.label?.noun === 'streets'
|
||||
? tn(
|
||||
count,
|
||||
'{n} street in view · click a line for its nearest photo',
|
||||
'{n} streets in view · click a line for its nearest photo',
|
||||
{ n: n() },
|
||||
{ n: formatNumber(count) },
|
||||
)
|
||||
: tn(
|
||||
count,
|
||||
'{n} sequence in view · click a line for its photos',
|
||||
'{n} sequences in view · click a line for its photos',
|
||||
{ n: n() },
|
||||
{ n: formatNumber(count) },
|
||||
);
|
||||
return [countLine, hint].filter(Boolean).join(' · ');
|
||||
}
|
||||
@@ -252,7 +262,8 @@ export function presentStreetLevelPanel(state, { now = Date.now() } = {}) {
|
||||
providers: presentProviders(state),
|
||||
error: keyMissing
|
||||
? null
|
||||
: state.street.error || state.coverage.error || null,
|
||||
: translateKnown(state.street.error || state.coverage.error || '') ||
|
||||
null,
|
||||
filter: { pano: filter.pano, sinceDays: Number(filter.sinceDays) || 0 },
|
||||
filterControls: {
|
||||
pano: state.filterControls?.pano !== false,
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import { t } from '../i18n/index.js';
|
||||
import { formatNumber, t } from '../i18n/index.js';
|
||||
|
||||
/** A slider value in the UI language: 0.85 in English, 0,85 in Spanish. */
|
||||
const formatParam = (value, digits) =>
|
||||
formatNumber(value, {
|
||||
minimumFractionDigits: digits,
|
||||
maximumFractionDigits: digits,
|
||||
useGrouping: false,
|
||||
});
|
||||
|
||||
/**
|
||||
* Own generated parameter rows and their input listeners.
|
||||
@@ -44,11 +52,14 @@ export function createStyleParameters({
|
||||
const digits = metadata.max <= 1 ? 2 : 1;
|
||||
const valueDisplay = documentRef.createElement('span');
|
||||
valueDisplay.className = 'param-value';
|
||||
valueDisplay.textContent = parseFloat(slider.value).toFixed(digits);
|
||||
valueDisplay.textContent = formatParam(
|
||||
parseFloat(slider.value),
|
||||
digits,
|
||||
);
|
||||
const onInput = () => {
|
||||
const value = parseFloat(slider.value);
|
||||
writeValue(name, value);
|
||||
valueDisplay.textContent = value.toFixed(digits);
|
||||
valueDisplay.textContent = formatParam(value, digits);
|
||||
onChange();
|
||||
};
|
||||
slider.addEventListener('input', onInput);
|
||||
|
||||
@@ -12,7 +12,9 @@
|
||||
background: var(--glass-bg);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 18px 52px rgba(0, 0, 0, 0.48), inset 0 0 28px rgba(14, 78, 92, 0.12);
|
||||
box-shadow:
|
||||
0 18px 52px rgba(0, 0, 0, 0.48),
|
||||
inset 0 0 28px rgba(14, 78, 92, 0.12);
|
||||
backdrop-filter: blur(24px) saturate(1.4);
|
||||
font-family: var(--font-mono);
|
||||
letter-spacing: 0;
|
||||
@@ -153,33 +155,69 @@
|
||||
}
|
||||
|
||||
@keyframes bhote-embedded-anchor-enter {
|
||||
from { opacity: 0; transform: scale(0.2); }
|
||||
to { opacity: 1; transform: scale(1); }
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.2);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes bhote-embedded-leader-enter {
|
||||
from { opacity: 0; stroke-dashoffset: 1; }
|
||||
to { opacity: 1; stroke-dashoffset: 0; }
|
||||
from {
|
||||
opacity: 0;
|
||||
stroke-dashoffset: 1;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
stroke-dashoffset: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes bhote-embedded-body-enter {
|
||||
from { opacity: 0; transform: translateY(8px) scale(0.98); }
|
||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px) scale(0.98);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes bhote-embedded-body-exit {
|
||||
from { opacity: 1; transform: translateY(0) scale(1); }
|
||||
to { opacity: 0; transform: translateY(6px) scale(0.985); }
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: translateY(6px) scale(0.985);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes bhote-embedded-leader-exit {
|
||||
from { opacity: 1; stroke-dashoffset: 0; }
|
||||
to { opacity: 0; stroke-dashoffset: 1; }
|
||||
from {
|
||||
opacity: 1;
|
||||
stroke-dashoffset: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
stroke-dashoffset: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes bhote-embedded-anchor-exit {
|
||||
from { opacity: 1; transform: scale(1); }
|
||||
to { opacity: 0; transform: scale(0.2); }
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: scale(0.2);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
@@ -243,16 +281,22 @@
|
||||
border-top: 3px solid #6be8ff;
|
||||
}
|
||||
|
||||
/* The title takes the full card width and up to two lines, so a translated
|
||||
title keeps its place name; the link sits beside the status below it. */
|
||||
.bhote-embedded-callout footer strong {
|
||||
grid-column: 1 / -1;
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
font-size: 11px;
|
||||
line-height: 1.25;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
overflow-wrap: anywhere;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
|
||||
.bhote-embedded-callout footer small {
|
||||
grid-column: 1;
|
||||
align-self: center;
|
||||
color: rgba(151, 211, 224, 0.7);
|
||||
font-size: 8px;
|
||||
line-height: 1.35;
|
||||
@@ -260,8 +304,9 @@
|
||||
|
||||
.bhote-embedded-callout footer a {
|
||||
grid-column: 2;
|
||||
grid-row: 1 / span 2;
|
||||
grid-row: 2;
|
||||
align-self: center;
|
||||
white-space: nowrap;
|
||||
color: #6be8ff;
|
||||
font-size: 8px;
|
||||
text-decoration: none;
|
||||
@@ -354,6 +399,12 @@
|
||||
color: rgba(222, 240, 245, 0.88);
|
||||
}
|
||||
|
||||
/* A longer translated heading wraps; the readout beside it stays one piece. */
|
||||
html:not([lang='en']) .bhote-event-section-head span:last-child {
|
||||
flex: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.bhote-event-range {
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
@@ -372,6 +423,11 @@
|
||||
color: rgba(174, 208, 216, 0.62);
|
||||
}
|
||||
|
||||
/* Keep "16 OCT 2021" together so the year never wraps alone. */
|
||||
.bhote-event-date {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.bhote-event-story-nav {
|
||||
display: grid;
|
||||
grid-template-columns: 34px minmax(0, 1fr) 34px;
|
||||
@@ -388,7 +444,11 @@
|
||||
border-radius: 4px;
|
||||
color: var(--accent);
|
||||
background: rgba(7, 25, 35, 0.9);
|
||||
font: 22px/1 'JetBrains Mono', 'SFMono-Regular', Consolas, monospace;
|
||||
font:
|
||||
22px/1 'JetBrains Mono',
|
||||
'SFMono-Regular',
|
||||
Consolas,
|
||||
monospace;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -398,7 +458,7 @@
|
||||
align-content: center;
|
||||
column-gap: 9px;
|
||||
min-width: 0;
|
||||
padding: 7px 10px;
|
||||
padding: 5px 10px;
|
||||
border: 1px solid rgba(88, 196, 218, 0.18);
|
||||
background: rgba(6, 19, 27, 0.72);
|
||||
}
|
||||
@@ -410,22 +470,28 @@
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
/* Up to two lines: a translated beat title keeps its subject. Two title lines
|
||||
and one meta line still fit the 54px arrow buttons, so the controls stay put. */
|
||||
.bhote-event-story-current strong {
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
color: var(--text-primary);
|
||||
font-size: 11px;
|
||||
line-height: 1.25;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
overflow-wrap: break-word;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
|
||||
/* Phase / time / sources run long in translation: allow a second line. */
|
||||
.bhote-event-story-current small {
|
||||
display: -webkit-box;
|
||||
grid-column: 1 / -1;
|
||||
margin-top: 4px;
|
||||
margin-top: 3px;
|
||||
overflow: hidden;
|
||||
color: rgba(116, 216, 235, 0.7);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
|
||||
.bhote-event-actions {
|
||||
@@ -448,14 +514,15 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* One line in English; a longer translation wraps to a second line. */
|
||||
.bhote-event-actions button {
|
||||
min-width: 0;
|
||||
min-height: 30px;
|
||||
padding: 0 5px;
|
||||
padding: 3px 5px;
|
||||
overflow: hidden;
|
||||
font-size: 8px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
line-height: 1.2;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.bhote-event-actions button:disabled,
|
||||
@@ -469,7 +536,7 @@
|
||||
border-color: rgba(88, 196, 218, 0.36);
|
||||
}
|
||||
|
||||
.bhote-event-actions button[data-action="cinematic"][aria-pressed="true"] {
|
||||
.bhote-event-actions button[data-action='cinematic'][aria-pressed='true'] {
|
||||
color: #071017;
|
||||
border-color: var(--accent);
|
||||
background: var(--accent);
|
||||
@@ -593,7 +660,6 @@
|
||||
max-height: 48vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
* {
|
||||
@@ -602,7 +668,8 @@
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html, body {
|
||||
html,
|
||||
body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
|
||||
@@ -78,6 +78,14 @@ html[lang='es'] .cctv-controls {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* A translated label longer than its button wraps inside the button instead
|
||||
of running past the panel edge. English labels fit on one line, so the
|
||||
English layout is unchanged. */
|
||||
.cctv-controls .scene-btn {
|
||||
white-space: normal;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.cctv-calibration-block {
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 9px;
|
||||
@@ -165,6 +173,9 @@ html[lang='es'] .cctv-controls {
|
||||
padding: 6px 8px;
|
||||
font-family: var(--font-sans);
|
||||
font-size: 12px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
@@ -373,3 +384,33 @@ html[lang='es'] .cctv-controls {
|
||||
color: #a8f7ff;
|
||||
text-shadow: 0 0 10px rgba(0, 212, 255, 0.18);
|
||||
}
|
||||
|
||||
/* Longer translated labels and units: tighter calibration chips keep the
|
||||
seven values to two rows so SAVE / RESET stay in view, and the camera
|
||||
select uses the compact mono face. English keeps its original look. */
|
||||
html:not([lang='en']) .cctv-cal-readout {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
html:not([lang='en']) .cctv-cal-value {
|
||||
flex: 1 1 auto;
|
||||
white-space: nowrap;
|
||||
letter-spacing: 0.3px;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
html:not([lang='en']) #cctv-camera-select {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
/* The camera name gets its own row; PREV / NEXT share the row below it. */
|
||||
html:not([lang='en']) .cctv-controls:has(#cctv-camera-select) {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
html:not([lang='en']) #cctv-camera-select {
|
||||
flex: 1 1 100%;
|
||||
order: -1;
|
||||
}
|
||||
@@ -569,6 +569,9 @@ body.cockpit-mode:has(#left-panel-stack > .panel-collapsible:not(.collapsed))
|
||||
text-align: center;
|
||||
vertical-align: -0.08em;
|
||||
}
|
||||
.cockpit-roll-token.is-separator {
|
||||
min-width: 0.3em;
|
||||
}
|
||||
.cockpit-roll-token.is-rolling {
|
||||
color: color-mix(in srgb, currentColor 92%, var(--cockpit-accent));
|
||||
text-shadow: 0 0 10px
|
||||
@@ -1499,6 +1502,12 @@ body.cockpit-mode #view-switcher button {
|
||||
.cockpit-signal-header > div {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
.cockpit-signal-kicker {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.cockpit-signal-header strong {
|
||||
color: #fff;
|
||||
@@ -1526,6 +1535,11 @@ body.cockpit-mode #view-switcher button {
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
}
|
||||
/* A longer translated state label widens the cycle button; the other
|
||||
controls keep their size instead of being squeezed. */
|
||||
.cockpit-brief-actions > * {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.cockpit-brief-actions button {
|
||||
display: grid;
|
||||
width: 23px;
|
||||
@@ -1548,10 +1562,13 @@ body.cockpit-mode #view-switcher button {
|
||||
font-size: 14px;
|
||||
}
|
||||
#cockpit-brief-auto {
|
||||
width: 50px;
|
||||
width: auto;
|
||||
min-width: 50px;
|
||||
padding: 0 4px;
|
||||
color: rgba(244, 251, 255, 0.42);
|
||||
font: 700 6px/1 var(--font-mono);
|
||||
letter-spacing: 0.05em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
#cockpit-brief-auto[aria-pressed='true'] {
|
||||
border-color: var(--cockpit-accent);
|
||||
@@ -1857,14 +1874,20 @@ body.cockpit-mode #view-switcher button {
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
.cockpit-signal-footer > span {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.cockpit-brief-tabs {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
gap: 3px;
|
||||
}
|
||||
.cockpit-brief-tabs button {
|
||||
white-space: nowrap;
|
||||
}
|
||||
.cockpit-brief-tabs button {
|
||||
padding: 2px 4px;
|
||||
border: 0;
|
||||
|
||||
@@ -696,3 +696,54 @@
|
||||
rgba(232, 234, 237, 0.24)
|
||||
);
|
||||
}
|
||||
|
||||
/* Language switch under the Visual Presets title: small mono chips, the
|
||||
active language in accent like the other dock toggles. */
|
||||
#command-dock #control-panel .dock-locale-switch {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.2rem;
|
||||
margin-top: 0.22rem;
|
||||
}
|
||||
|
||||
#command-dock .dock-locale-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
min-width: 1.35rem;
|
||||
height: 0.95rem;
|
||||
margin: 0;
|
||||
padding: 0 0.22rem;
|
||||
color: var(--text-secondary);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 0.18rem;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.46rem;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.06rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
color var(--transition-fast),
|
||||
border-color var(--transition-fast),
|
||||
background var(--transition-fast);
|
||||
}
|
||||
|
||||
#command-dock .dock-locale-btn:hover {
|
||||
color: var(--text-primary);
|
||||
border-color: var(--glass-border-hover);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
#command-dock .dock-locale-btn:focus-visible {
|
||||
outline: 1px solid var(--text-primary);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
#command-dock .dock-locale-btn.active {
|
||||
color: var(--accent);
|
||||
border-color: rgba(0, 212, 255, 0.55);
|
||||
background: var(--accent-dim);
|
||||
}
|
||||
@@ -392,6 +392,12 @@
|
||||
transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||
}
|
||||
|
||||
/* The side wings sit at z-index 100 (controls.css, location.css), so their
|
||||
open trays would cover the alert; lift the voice column while it shows. */
|
||||
#command-dock > #gev-voice-control[data-status='error']:not(.error-dismissed) {
|
||||
z-index: 101;
|
||||
}
|
||||
|
||||
#command-dock
|
||||
#gev-voice-control[data-status='error']:not(.error-dismissed)
|
||||
.gev-voice-error-tray {
|
||||
|
||||
@@ -221,6 +221,18 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* A longer translated title in a collapsed rail launcher tightens its
|
||||
tracking rather than pushing the disclosure button out of the card. */
|
||||
html:not([lang='en'])
|
||||
#right-context-rail
|
||||
.panel-collapsible.collapsed
|
||||
.panel-title {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
letter-spacing: 1.5px;
|
||||
}
|
||||
|
||||
.panel-divider {
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
@@ -228,6 +240,8 @@
|
||||
}
|
||||
|
||||
.panel-collapse-btn {
|
||||
/* A long translated title must not squeeze the toggle into a sliver. */
|
||||
flex-shrink: 0;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 6px;
|
||||
@@ -770,6 +784,15 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* One label column across the Display rows: --pp-label-chars is the longest
|
||||
label of the active language (set by applicationShell), and the labels are
|
||||
monospace, so every slider track starts at the same x whatever the
|
||||
language. 0.125em is the 1px letter-spacing at 8px. */
|
||||
.pp-slider-row > .pp-slider-mini-label {
|
||||
flex: 0 0 auto;
|
||||
min-width: calc(var(--pp-label-chars, 0) * (1ch + 0.125em));
|
||||
}
|
||||
|
||||
.detection-slider-row {
|
||||
min-width: 170px;
|
||||
padding: 5px 8px 6px;
|
||||
@@ -966,6 +989,11 @@
|
||||
#draw-mode-row {
|
||||
min-width: 184px;
|
||||
}
|
||||
/* Equal thirds with room around the label, so a long shape name never runs
|
||||
edge to edge in its segment. */
|
||||
#draw-mode-row .pp-mode-btn {
|
||||
padding: 0 4px;
|
||||
}
|
||||
#draw-label-row {
|
||||
flex-wrap: wrap;
|
||||
row-gap: 6px;
|
||||
@@ -987,8 +1015,11 @@
|
||||
.pp-text-input:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
/* Sized to its longest colour name so a translated one is never clipped;
|
||||
74px keeps the English width. */
|
||||
.draw-color-select {
|
||||
flex: 0 0 74px;
|
||||
flex: 0 0 auto;
|
||||
min-width: 74px;
|
||||
}
|
||||
.draw-clear-btn {
|
||||
flex: 0 0 auto;
|
||||
@@ -1234,15 +1265,20 @@ body.ui-clean-view #scene-runtime.active {
|
||||
);
|
||||
}
|
||||
|
||||
.param-slider-row {
|
||||
display: flex;
|
||||
/* One label column for every parameter, as wide as the longest label in the
|
||||
active language, so all slider tracks start and end at the same x. */
|
||||
#param-sliders {
|
||||
display: grid;
|
||||
grid-template-columns: max-content minmax(0, 1fr) max-content;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.param-slider-row:last-child {
|
||||
margin-bottom: 0;
|
||||
.param-slider-row {
|
||||
display: grid;
|
||||
grid-column: 1 / -1;
|
||||
grid-template-columns: subgrid;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.param-label {
|
||||
|
||||
@@ -1371,10 +1371,12 @@
|
||||
padding: 2px 4px;
|
||||
}
|
||||
|
||||
/* ALPR keeps its nearby count in the legend while a wider status chip is shown. */
|
||||
/* ALPR keeps its nearby count in the legend while a wider status chip
|
||||
(including ENABLING / DISABLING) is shown. */
|
||||
.data-toggle-row[data-layer-id='alpr-cameras']
|
||||
.data-toggle-right:has(
|
||||
.data-toggle-btn:is(
|
||||
.transitioning,
|
||||
.feed-loading,
|
||||
.feed-degraded,
|
||||
.feed-stale,
|
||||
|
||||
+88
-5
@@ -250,8 +250,12 @@
|
||||
gap: 5px;
|
||||
}
|
||||
.radio-transport button {
|
||||
min-width: 0;
|
||||
min-height: 30px;
|
||||
padding: 6px 3px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
border: 1px solid rgba(65, 220, 231, 0.28);
|
||||
border-radius: 5px;
|
||||
color: #8feaf2;
|
||||
@@ -610,10 +614,14 @@
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* The name takes the room the status column leaves; a long word wraps inside
|
||||
it instead of pushing the toggle out of line. */
|
||||
.data-toggle-left {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.data-icon {
|
||||
@@ -621,16 +629,37 @@
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* A fixed icon box, so every name (and the meta line under it, 26px in)
|
||||
starts at the same x whatever the glyph's width. */
|
||||
.data-toggle-left .data-icon {
|
||||
flex: none;
|
||||
width: 18px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.data-name {
|
||||
min-width: 0;
|
||||
font-family: var(--font-sans);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
letter-spacing: 0.3px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* Translated layer names are longer: break between words, and hyphenate a
|
||||
word that still does not fit instead of splitting it anywhere. */
|
||||
html:not([lang='en']) .data-name {
|
||||
overflow-wrap: break-word;
|
||||
hyphens: auto;
|
||||
/* No two-letter pieces such as "es-paciales": a word hyphenates only when
|
||||
at least four letters stay on each side. */
|
||||
hyphenate-limit-chars: 8 4 4;
|
||||
}
|
||||
|
||||
.data-toggle-right {
|
||||
display: flex;
|
||||
flex: 0 1 auto;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
@@ -640,8 +669,56 @@
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--text-secondary);
|
||||
min-width: 30px;
|
||||
text-align: right;
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
/* Row text pieces that must not break inside: a hyphenated code (ADS-B) and
|
||||
the space before a " · " separator (see setRowText in layerPanel.js). */
|
||||
.data-keep {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* A count phrase ("24 nearby · 2 on screen") takes its own line under the
|
||||
name instead of squeezing it beside the toggle. */
|
||||
.data-toggle-row--count-line .data-toggle-top {
|
||||
flex-wrap: wrap;
|
||||
row-gap: 2px;
|
||||
}
|
||||
|
||||
.data-toggle-row--count-line .data-toggle-right {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
/* A zero basis, so a long name wraps beside the toggle instead of pushing the
|
||||
toggle onto the count's line. */
|
||||
.data-toggle-row--count-line .data-toggle-left {
|
||||
flex-basis: 0;
|
||||
}
|
||||
|
||||
.data-toggle-row--count-line .data-count {
|
||||
order: 1;
|
||||
flex: 1 0 100%;
|
||||
padding-left: 26px;
|
||||
font-size: 10px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
/* The "—" placeholder gives way to a wide status chip, so the name keeps the
|
||||
room it needs. */
|
||||
.data-toggle-right:has(
|
||||
.data-toggle-btn:is(
|
||||
.transitioning,
|
||||
.feed-loading,
|
||||
.feed-degraded,
|
||||
.feed-stale,
|
||||
.feed-partial,
|
||||
.feed-fallback,
|
||||
.feed-unavailable
|
||||
)
|
||||
)
|
||||
.data-count-empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.data-toggle-btn {
|
||||
@@ -662,8 +739,10 @@
|
||||
min-width var(--transition-fast),
|
||||
letter-spacing var(--transition-fast);
|
||||
outline: none;
|
||||
flex: none;
|
||||
min-width: 38px;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* An inset ring stays visible inside the scrolling layer list. */
|
||||
@@ -672,13 +751,15 @@
|
||||
outline-offset: -3px;
|
||||
}
|
||||
|
||||
/* Status chips up to eight characters share one width; a longer word grows
|
||||
its own chip rather than widening every chip and squeezing every name. */
|
||||
.data-toggle-btn.feed-loading,
|
||||
.data-toggle-btn.feed-degraded,
|
||||
.data-toggle-btn.feed-stale,
|
||||
.data-toggle-btn.feed-partial,
|
||||
.data-toggle-btn.feed-fallback,
|
||||
.data-toggle-btn.feed-unavailable {
|
||||
min-width: 82px;
|
||||
min-width: 72px;
|
||||
letter-spacing: 0.8px;
|
||||
}
|
||||
|
||||
@@ -883,14 +964,16 @@
|
||||
.data-row-list-lead {
|
||||
min-width: 34px;
|
||||
flex: none;
|
||||
white-space: nowrap;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
/* The whole instruction wraps under itself rather than losing its tail:
|
||||
"Turn right onto …" says nothing, and Russian or Spanish run longer. */
|
||||
.data-row-list-text {
|
||||
flex: 1 1 auto;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* A list line that states a limit rather than offering an action. */
|
||||
|
||||
@@ -293,17 +293,34 @@
|
||||
height: 18px;
|
||||
font-weight: 700;
|
||||
}
|
||||
/* Outside A / B there is one chip (SHOW): it takes the whole row, so a long
|
||||
translation is not cut to a stub. */
|
||||
.recent-imagery-readout:not([data-mode='ab'])
|
||||
.ri-card-chips
|
||||
.ri-chip[data-slot='a'] {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.recent-imagery-readout:not([data-mode='ab'])
|
||||
.ri-card-chips
|
||||
.ri-chip[data-slot='b'] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 4. The selection bar: mode, then two rows that are always there. */
|
||||
/* 4. The selection bar: mode, then two rows that are always there. The slot
|
||||
rows share the bar's columns (subgrid), so the tag column fits the longest
|
||||
tag in either row and both rows stay aligned. */
|
||||
.recent-imagery-readout .ri-selection {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(44px, max-content) minmax(0, 1fr) 22px;
|
||||
grid-template-rows: 20px 20px 20px;
|
||||
column-gap: 6px;
|
||||
row-gap: 4px;
|
||||
padding: 5px;
|
||||
border: 1px solid var(--weather-card-border);
|
||||
border-radius: 6px;
|
||||
}
|
||||
.recent-imagery-readout .ri-mode {
|
||||
grid-column: 1 / -1;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 4px;
|
||||
@@ -312,14 +329,16 @@
|
||||
height: 20px;
|
||||
}
|
||||
.recent-imagery-readout .ri-slot {
|
||||
grid-column: 1 / -1;
|
||||
display: grid;
|
||||
grid-template-columns: 44px minmax(0, 1fr) 22px;
|
||||
grid-template-columns: subgrid;
|
||||
align-items: center;
|
||||
column-gap: 6px;
|
||||
height: 20px;
|
||||
}
|
||||
.recent-imagery-readout .ri-slot-tag {
|
||||
height: 16px;
|
||||
padding: 0 3px;
|
||||
line-height: 16px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 3px;
|
||||
@@ -347,6 +366,15 @@
|
||||
color: var(--text-secondary);
|
||||
font-style: italic;
|
||||
}
|
||||
/* A translated slot line (date · source · resolution · state) is longer:
|
||||
let it take a second line rather than cut off the state at the end. */
|
||||
html:not([lang='en']) .recent-imagery-readout .ri-slot {
|
||||
height: auto;
|
||||
min-height: 20px;
|
||||
}
|
||||
html:not([lang='en']) .recent-imagery-readout .ri-slot-value {
|
||||
white-space: normal;
|
||||
}
|
||||
.recent-imagery-readout .ri-unpin {
|
||||
width: 22px;
|
||||
padding: 0;
|
||||
@@ -355,19 +383,25 @@
|
||||
}
|
||||
|
||||
/* 5. Opacity (both images), SWAP and export, one row. SWAP keeps its slot
|
||||
while no swipe is live. */
|
||||
while no swipe is live. A label longer than its 76px column widens it to
|
||||
the label's longest word, and the value wraps under the label. */
|
||||
.recent-imagery-readout .ri-controls {
|
||||
display: grid;
|
||||
grid-template-columns: 76px minmax(0, 1fr) 40px 128px;
|
||||
grid-template-columns:
|
||||
minmax(76px, min-content) minmax(0, 1fr) minmax(40px, max-content)
|
||||
128px;
|
||||
align-items: center;
|
||||
column-gap: 6px;
|
||||
height: 22px;
|
||||
}
|
||||
.recent-imagery-readout .ri-opacity-label {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
gap: 4px;
|
||||
align-content: center;
|
||||
column-gap: 4px;
|
||||
font-size: 8px;
|
||||
line-height: 10px;
|
||||
letter-spacing: 1px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -385,7 +419,7 @@
|
||||
opacity: 0.35;
|
||||
}
|
||||
.recent-imagery-readout .ri-swap {
|
||||
width: 40px;
|
||||
min-width: 40px;
|
||||
padding: 0 2px;
|
||||
}
|
||||
.recent-imagery-readout .ri-exports .rail-card-actions {
|
||||
|
||||
@@ -160,9 +160,20 @@
|
||||
padding: 6px 8px;
|
||||
font-family: var(--font-sans);
|
||||
font-size: 12px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* A translated action longer than its button wraps inside the button instead
|
||||
of running past it. English labels fit on one line, so the English layout
|
||||
is unchanged. */
|
||||
#scene-panel .scene-controls .scene-btn {
|
||||
white-space: normal;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.scene-btn {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
@@ -454,3 +465,15 @@
|
||||
.director-sharing-dialog::backdrop {
|
||||
background: rgba(0, 8, 14, 0.65);
|
||||
}
|
||||
|
||||
html:not([lang='en']) #scene-select {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
/* A translated scene name needs the width: the select takes its own row and
|
||||
NEW / DELETE share the next one. */
|
||||
html:not([lang='en']) #scene-select {
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
@@ -213,6 +213,20 @@ button.sl-status:focus-visible {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Longer translations: rather than squeeze the hint into a narrow column
|
||||
beside the button, put the button on its own line when both do not fit. */
|
||||
html:not([lang='en']) .sl-meta-row {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
html:not([lang='en']) .sl-meta-row .sl-meta {
|
||||
flex: 1 1 14em;
|
||||
}
|
||||
|
||||
html:not([lang='en']) .sl-meta-row .sl-nearest-btn {
|
||||
flex: 1 0 auto;
|
||||
}
|
||||
|
||||
/* ── Errors ──────────────────────────────────────────────────────────────── */
|
||||
.sl-error {
|
||||
font-family: var(--font-sans);
|
||||
|
||||
@@ -82,3 +82,63 @@
|
||||
.gev-voice-cost-value[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* The dock heading row is only as wide as the voice column, so the cost
|
||||
cluster keeps its size and the state label gives way: a long translated
|
||||
state (ПОДКЛЮЧЕНИЕ, CONECTANDO) ends in an ellipsis instead of pushing the
|
||||
cost past the card edge. The kicker already collapses to zero width here;
|
||||
taking it out of the row returns its gap to the state label. The row also
|
||||
reaches left over the gutter beside the mic ring: at heading height the
|
||||
ring has already curved away. English fits as it is, so this applies to
|
||||
the other languages only. */
|
||||
html:not([lang='en']) #command-dock .gev-voice-heading {
|
||||
gap: 0.2rem;
|
||||
margin-left: -0.6rem;
|
||||
}
|
||||
|
||||
html:not([lang='en']) #command-dock .gev-voice-heading > .gev-voice-kicker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
html:not([lang='en']) #command-dock .gev-voice-heading > #gev-voice-status {
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
letter-spacing: 0;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
html:not([lang='en']) #command-dock .gev-voice-cost {
|
||||
gap: 0.16rem;
|
||||
padding-left: 0.1rem;
|
||||
}
|
||||
|
||||
html:not([lang='en']) #command-dock .gev-voice-tier-btn {
|
||||
min-width: 0;
|
||||
padding: 0 0.2rem;
|
||||
}
|
||||
|
||||
html:not([lang='en']) #command-dock .gev-voice-cost-value {
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
/* Live hints ("Ask or command", "Requesting microphone") run longer in other
|
||||
languages; give them a second line from the spare space under the readout
|
||||
rather than cutting them mid-word. */
|
||||
html:not([lang='en'])
|
||||
#command-dock
|
||||
#gev-voice-control:is(
|
||||
[data-status='connecting'],
|
||||
[data-status='listening'],
|
||||
[data-status='executing']
|
||||
)
|
||||
#gev-voice-detail {
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
line-height: 1.15;
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
@@ -147,9 +147,10 @@
|
||||
.rail-card-line {
|
||||
white-space: pre-line;
|
||||
}
|
||||
/* One line stays reserved so the card does not jump when a status appears;
|
||||
a long (or translated) status wraps instead of being cut. */
|
||||
.weather-card [data-line-id='status'] {
|
||||
height: 1.45em;
|
||||
overflow-y: auto;
|
||||
min-height: 1.45em;
|
||||
color: var(--accent);
|
||||
}
|
||||
.rail-card-ramp {
|
||||
@@ -258,6 +259,7 @@ body.recording-mode .gev-wind-inspection-marker {
|
||||
top: 8px;
|
||||
left: 0;
|
||||
transform: translateX(-50%);
|
||||
white-space: nowrap;
|
||||
font: 9px/1.45 var(--font-mono);
|
||||
color: var(--text-dim);
|
||||
}
|
||||
@@ -311,9 +313,18 @@ body.recording-mode .gev-wind-inspection-marker {
|
||||
.rail-card-compact.status {
|
||||
color: var(--accent);
|
||||
}
|
||||
/* One label column per card, as wide as its longest label: chips line up
|
||||
across rows, and a long translated label never runs under its chips. */
|
||||
.rail-card-settings {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(56px, max-content) minmax(0, 1fr);
|
||||
column-gap: 6px;
|
||||
}
|
||||
.rail-card-setting {
|
||||
display: grid;
|
||||
grid-template-columns: 56px minmax(0, 1fr);
|
||||
grid-column: 1 / -1;
|
||||
grid-template-columns: minmax(56px, max-content) minmax(0, 1fr);
|
||||
grid-template-columns: subgrid;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
margin-top: 6px;
|
||||
|
||||
@@ -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 id="sl-since-row" class="sl-since">
|
||||
<label class="cctv-summary-label" for="sl-since" data-i18n>SINCE</label>
|
||||
<input id="sl-since" class="sl-range" type="range" min="0" max="8" step="1" value="0" aria-valuetext="ANY DATE" aria-describedby="sl-since-label sl-since-note" />
|
||||
<output id="sl-since-label" class="sl-since-label" for="sl-since">ANY DATE</output>
|
||||
<output id="sl-since-label" class="sl-since-label" for="sl-since" data-i18n>ANY DATE</output>
|
||||
<small id="sl-since-note" class="sl-since-note" hidden></small>
|
||||
</div>
|
||||
</fieldset>
|
||||
@@ -137,7 +137,7 @@
|
||||
<!-- The hint says how to open imagery; the button does it without a pointer. -->
|
||||
<div class="sl-meta-row">
|
||||
<div id="sl-coverage-meta" class="sl-meta"></div>
|
||||
<button id="sl-nearest-btn" class="scene-btn sl-nearest-btn" type="button" hidden data-i18n>OPEN STREET VIEW</button>
|
||||
<button id="sl-nearest-btn" class="scene-btn sl-nearest-btn" type="button" hidden data-i18n-runtime>OPEN STREET VIEW</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { formatNumber, msg, t } from '../i18n/index.js';
|
||||
|
||||
// Unit ids stay English in data, params and AI-tool readings; only the label
|
||||
// shown on the page is translated. dBZ and °C read the same in every language.
|
||||
const UNIT_LABELS = Object.freeze({
|
||||
'km/h': msg('km/h'),
|
||||
'm/s': msg('m/s'),
|
||||
mph: msg('mph'),
|
||||
hPa: msg('hPa'),
|
||||
kt: msg('kt'),
|
||||
kts: msg('kts'),
|
||||
ft: msg('ft'),
|
||||
km: msg('km'),
|
||||
m: msg('m'),
|
||||
h: msg('h'),
|
||||
min: msg('min'),
|
||||
s: msg('s'),
|
||||
dB: msg('dB'),
|
||||
});
|
||||
|
||||
/** The display label for a unit id; unknown units pass through unchanged. */
|
||||
export function unitLabel(unit) {
|
||||
return Object.hasOwn(UNIT_LABELS, unit) ? t(UNIT_LABELS[unit]) : unit || '';
|
||||
}
|
||||
|
||||
/**
|
||||
* A number and its unit that never break apart: "22,0 км/ч". Readings carry
|
||||
* no digit grouping ("1008 hPa"), as on weather charts.
|
||||
*/
|
||||
export function formatQuantity(value, unit, options) {
|
||||
const label = unitLabel(unit);
|
||||
const number = formatNumber(value, { useGrouping: false, ...options });
|
||||
return label ? `${number}\u00a0${label}` : number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A legend stop such as "0.1", "-40" or "300+" in the active language.
|
||||
* Anything that is not a plain number is shown as given.
|
||||
*/
|
||||
export function formatScaleLabel(label) {
|
||||
const match = /^(-?\d+(?:\.\d+)?)(\+?)$/.exec(String(label ?? ''));
|
||||
if (!match) return String(label ?? '');
|
||||
const decimals = match[1].split('.')[1]?.length || 0;
|
||||
return `${formatNumber(Number(match[1]), {
|
||||
useGrouping: false,
|
||||
minimumFractionDigits: decimals,
|
||||
maximumFractionDigits: decimals,
|
||||
})}${match[2]}`;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { setLocale } from '../i18n/index.js';
|
||||
import { formatQuantity, formatScaleLabel, unitLabel } from './unitLabels.js';
|
||||
|
||||
test('English readings and scales read exactly as the unit ids', () => {
|
||||
setLocale('en');
|
||||
assert.equal(unitLabel('km/h'), 'km/h');
|
||||
assert.equal(unitLabel('°C'), '°C');
|
||||
assert.equal(unitLabel('dBZ'), 'dBZ');
|
||||
assert.equal(unitLabel(''), '');
|
||||
assert.equal(formatQuantity(95, 'kt'), '95 kt');
|
||||
assert.equal(formatQuantity(1006, 'hPa'), '1006 hPa');
|
||||
assert.equal(
|
||||
formatQuantity(22, 'km/h', {
|
||||
minimumFractionDigits: 1,
|
||||
maximumFractionDigits: 1,
|
||||
}),
|
||||
'22.0 km/h',
|
||||
);
|
||||
assert.deepEqual(
|
||||
['0.1', '-40', '1050+', '300+', 'Calm'].map(formatScaleLabel),
|
||||
['0.1', '-40', '1050+', '300+', 'Calm'],
|
||||
);
|
||||
});
|
||||
|
||||
test('other languages get their decimal separator and no digit grouping', () => {
|
||||
try {
|
||||
setLocale('ru');
|
||||
assert.match(
|
||||
formatQuantity(22, 'km/h', {
|
||||
minimumFractionDigits: 1,
|
||||
maximumFractionDigits: 1,
|
||||
}),
|
||||
/^22,0 \S+$/,
|
||||
);
|
||||
assert.match(formatQuantity(1050, 'hPa'), /^1050 /);
|
||||
assert.deepEqual(['0.1', '-40', '1050+'].map(formatScaleLabel), [
|
||||
'0,1',
|
||||
'-40',
|
||||
'1050+',
|
||||
]);
|
||||
} finally {
|
||||
setLocale('en');
|
||||
}
|
||||
});
|
||||
+12
-8
@@ -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() {
|
||||
|
||||
+34
-19
@@ -1,3 +1,4 @@
|
||||
import { formatUtcDayMonth, t } from '../i18n/index.js';
|
||||
import { createRailCards } from './railCards.js';
|
||||
import { createRailTimeline } from './railTimeline.js';
|
||||
|
||||
@@ -14,19 +15,25 @@ const ORDER = [
|
||||
const OBSERVED = new Set(ORDER.slice(2));
|
||||
const utc = (time) =>
|
||||
Number.isFinite(Date.parse(time))
|
||||
? `${new Date(time).toISOString().slice(5, 16).replace('T', ' ')} UTC`
|
||||
: 'Unavailable';
|
||||
? `${formatUtcDayMonth(time)} ${new Date(time).toISOString().slice(11, 16)} UTC`
|
||||
: t('Unavailable');
|
||||
const age = (time) => {
|
||||
const minutes = Math.max(
|
||||
0,
|
||||
Math.floor((Date.now() - Date.parse(time)) / 60_000),
|
||||
);
|
||||
return minutes >= 60
|
||||
? `${Math.floor(minutes / 60)}h ${minutes % 60}m ago`
|
||||
: `${minutes}m ago`;
|
||||
? t('{hours}h {minutes}m ago', {
|
||||
hours: Math.floor(minutes / 60),
|
||||
minutes: minutes % 60,
|
||||
})
|
||||
: t('{minutes}m ago', { minutes });
|
||||
};
|
||||
const historyTime = (time) =>
|
||||
`${utc(time).slice(6)} · ${Math.max(0, Math.floor((Date.now() - Date.parse(time)) / 60_000))} min ago`;
|
||||
t('{time} · {minutes} min ago', {
|
||||
time: utc(time).slice(6),
|
||||
minutes: Math.max(0, Math.floor((Date.now() - Date.parse(time)) / 60_000)),
|
||||
});
|
||||
const dated = (time) => `${utc(time)} · ${age(time)}`;
|
||||
const set = (node, key, value) => {
|
||||
if (node[key] !== value) node[key] = value;
|
||||
@@ -63,7 +70,7 @@ export function createWeatherPanel({
|
||||
const root = document.createElement('section');
|
||||
root.className = 'weather-readout';
|
||||
root.hidden = true;
|
||||
root.setAttribute('aria-label', 'Active weather');
|
||||
root.setAttribute('aria-label', t('Active weather'));
|
||||
const timelineHost = document.createElement('div');
|
||||
timelineHost.className = 'weather-timeline-block';
|
||||
timelineHost.hidden = true;
|
||||
@@ -71,10 +78,10 @@ export function createWeatherPanel({
|
||||
cardsHost.className = 'weather-cards';
|
||||
const observedGroup = document.createElement('section');
|
||||
observedGroup.className = 'weather-observed-group';
|
||||
observedGroup.setAttribute('aria-label', 'Observed history');
|
||||
observedGroup.setAttribute('aria-label', t('Observed history'));
|
||||
const heading = document.createElement('h3');
|
||||
heading.className = 'panel-title';
|
||||
heading.textContent = 'Observed history';
|
||||
heading.textContent = t('Observed history');
|
||||
const scope = document.createElement('div');
|
||||
scope.className = 'weather-observed-scope';
|
||||
const observedCardsHost = document.createElement('div');
|
||||
@@ -132,9 +139,9 @@ export function createWeatherPanel({
|
||||
const showTimeline = observed.length > 0;
|
||||
set(observedGroup, 'hidden', !showTimeline);
|
||||
const names = {
|
||||
'weather-radar': 'Rain radar',
|
||||
'weather-satellite': 'Satellite clouds',
|
||||
'weather-lightning': 'Lightning density',
|
||||
'weather-radar': t('Rain radar'),
|
||||
'weather-satellite': t('Satellite clouds'),
|
||||
'weather-lightning': t('Lightning density'),
|
||||
};
|
||||
set(scope, 'textContent', observed.map(({ id }) => names[id]).join(' · '));
|
||||
set(timelineHost, 'hidden', !showTimeline);
|
||||
@@ -155,7 +162,7 @@ export function createWeatherPanel({
|
||||
disabled: !showTimeline || state.timeline.length < 2,
|
||||
readout:
|
||||
state.mode === 'latest'
|
||||
? 'LATEST · newest per product'
|
||||
? t('LATEST · newest per product')
|
||||
: historyTime(state.target),
|
||||
});
|
||||
const models = active.map(({ id, icon, summary, legend = [], list }) => {
|
||||
@@ -165,18 +172,26 @@ export function createWeatherPanel({
|
||||
const shown = product?.shown ?? summary.shownTime;
|
||||
if (state.mode === 'history' && product?.selected === null) {
|
||||
const gap = summary.maxGapMinutes || 30;
|
||||
detail = `No frame within ${gap < 60 ? `${gap} min` : `${gap / 60} h`} of ${utc(state.target).slice(6)}`;
|
||||
const time = utc(state.target).slice(6);
|
||||
detail =
|
||||
gap < 60
|
||||
? t('No frame within {gap} min of {time}', { gap, time })
|
||||
: t('No frame within {gap} h of {time}', { gap: gap / 60, time });
|
||||
} else if (shown) {
|
||||
detail = dated(shown);
|
||||
if (state.mode === 'history')
|
||||
detail +=
|
||||
detail =
|
||||
Date.parse(shown) === Date.parse(state.target)
|
||||
? ' · synced'
|
||||
: ' · nearest';
|
||||
? t('{detail} · synced', { detail })
|
||||
: t('{detail} · nearest', { detail });
|
||||
}
|
||||
} else if (id === 'wind') {
|
||||
detail = `Forecast · valid ${utc(summary.validTime)} · issued ${utc(summary.issuedTime)}`;
|
||||
if (state.mode === 'history') detail += ' · Does not follow history';
|
||||
detail = t('Forecast · valid {valid} · issued {issued}', {
|
||||
valid: utc(summary.validTime),
|
||||
issued: utc(summary.issuedTime),
|
||||
});
|
||||
if (state.mode === 'history')
|
||||
detail = t('{detail} · Does not follow history', { detail });
|
||||
}
|
||||
const lines = [
|
||||
{ id: 'time', text: detail, muted: true },
|
||||
@@ -222,7 +237,7 @@ export function createWeatherPanel({
|
||||
summary.status ||
|
||||
summary.compact ||
|
||||
(id === 'wind'
|
||||
? `Forecast · valid ${utc(summary.validTime)}`
|
||||
? t('Forecast · valid {valid}', { valid: utc(summary.validTime) })
|
||||
: detail),
|
||||
compactStatus: Boolean(summary.status),
|
||||
blocks,
|
||||
|
||||
Reference in new issue
Block a user