mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
fix(i18n): fix Russian layouts and the gaps the screenshot review found
A review of the Russian UI screenshots found ~90 places where longer words
broke the layout or text was still English. Fixed so they hold for any
language, then shortened where a control is short by design.
- Layout: label columns that size to their text (weather and wind settings,
Recent Imagery slots and opacity, style sliders, display panel), layer
list names and icons, buttons that wrap instead of overflowing (CCTV,
scenes, Bhote Koshi), voice header that always shows the cost, styled
EN | ES | RU chips, voice error tray above the dock.
- t() context: t('CLEAR', { context: 'action' }) is «СБРОСИТЬ» while the sky
stays «ЯСНО»; also used for radio categories, voice states and directions.
- Units and dates follow the locale through src/ui/unitLabels.js
(formatQuantity: localized number, translated unit, non-breaking space):
wind, pressure, cyclones, directions, CCTV, SDR gain, scene timing, flight
tooltip, Recent Imagery dates and resolution.
- Newly translated: directions turn-by-turn steps (built from the maneuver;
English matches the server word for word), HUD mode and summary, voice
card states, radio categories on the globe, CCTV status, layer source
lines, scene dialogs (Cancel, file pickers), Bhote Koshi statuses, titles,
comparison dates and caveat, Recent Imagery notices. Stored data and text
for AI tools stay English.
- es and ru: 1,885 messages each.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
3129e531e6
commit
2b6ccad732
71 files changed
+2435
-370
No files matched your search
@@ -571,6 +571,7 @@ export class StyleManager extends ShellFacade {
|
||||
this._initMapStackControl();
|
||||
this._initPanelChrome();
|
||||
this._initLocaleSelector();
|
||||
this._sizeDisplayLabelColumn();
|
||||
this._applyRuntimeStaticHeaderText();
|
||||
this._initLeftPanelAdaptiveLayout();
|
||||
this._initRightPanelAdaptiveLayout();
|
||||
@@ -735,6 +736,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,4 +1,5 @@
|
||||
import { t, msg } from '../i18n/index.js';
|
||||
import { formatNumber, t, msg } from '../i18n/index.js';
|
||||
import { formatQuantity } from './unitLabels.js';
|
||||
|
||||
/** Shortest-wrap signed degrees, for heading offsets typed as absolute values. */
|
||||
const signedNormalizeDeg = (deg) => ((((deg + 180) % 360) + 360) % 360) - 180;
|
||||
@@ -66,6 +67,21 @@ 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')
|
||||
return `${t(field.label)} ${formatQuantity(Number(value), 'm', options)}`;
|
||||
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 || '';
|
||||
}
|
||||
@@ -176,7 +192,7 @@ export function _syncCctvCalReadout(enabled, activeCamera) {
|
||||
if (!field) continue;
|
||||
const value = canCalibrate ? field.get(activeCamera) : null;
|
||||
chip.textContent = Number.isFinite(value)
|
||||
? `${t(field.label)} ${Number(value).toFixed(field.decimals)}${field.unit}`
|
||||
? 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}` : '';
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/** 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,
|
||||
@@ -8,6 +9,17 @@ import {
|
||||
resolveCockpitContextReadout,
|
||||
} from '../cockpitMath.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;
|
||||
const snapshot =
|
||||
@@ -37,6 +49,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
|
||||
|
||||
@@ -20,6 +20,8 @@ import {
|
||||
} from '../cockpitMath.js';
|
||||
import {
|
||||
COCKPIT_CONTEXT_UPDATE_MS,
|
||||
compassPointLabel,
|
||||
formatCockpitCoordinate,
|
||||
setCockpitRollingValue,
|
||||
} from './cockpitPresentation.js';
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/** 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';
|
||||
|
||||
export const COCKPIT_HEADING_SLEW_DPS = 28;
|
||||
|
||||
@@ -54,8 +54,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 +94,43 @@ 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,
|
||||
});
|
||||
return `${amount}°${compassPointLabel(value >= 0 ? positive : negative)}`;
|
||||
}
|
||||
|
||||
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)}°`;
|
||||
}
|
||||
|
||||
export function setCockpitRollingValue(
|
||||
|
||||
+47
-1
@@ -135,6 +135,52 @@ function panelLabel(layer) {
|
||||
return PANEL_LABELS[layer.id] ? t(PANEL_LABELS[layer.id]) : 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'),
|
||||
].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);
|
||||
return key ? t(key) : source;
|
||||
}
|
||||
|
||||
/**
|
||||
* Guidance for a control a missing provider key is holding back.
|
||||
*
|
||||
@@ -580,7 +626,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') {
|
||||
|
||||
@@ -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,5 +1,6 @@
|
||||
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
|
||||
@@ -21,6 +22,19 @@ function statusLabel(status) {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
@@ -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,6 +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;
|
||||
@@ -14,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;
|
||||
@@ -198,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') {
|
||||
|
||||
+53
-14
@@ -8,20 +8,50 @@
|
||||
* strip and selection bar are this adapter's own keyed DOM. Source toggles
|
||||
* live on the data-panel row.
|
||||
*/
|
||||
import { t, tn, msg } from '../i18n/index.js';
|
||||
import { t, tn, msg, formatNumber } 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 = 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 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', msg('IMAGE')],
|
||||
['basemap', msg('VS BASEMAP')],
|
||||
@@ -56,17 +86,25 @@ const appearedDocuments = new WeakSet();
|
||||
|
||||
const sensorLine = (product) =>
|
||||
PRODUCTS[product]
|
||||
? `${t(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 t('no imagery');
|
||||
@@ -344,7 +382,7 @@ export function createRecentImageryPanel({
|
||||
},
|
||||
{
|
||||
id: 'clear',
|
||||
label: t('CLEAR'),
|
||||
label: t('CLEAR', { context: 'action' }),
|
||||
title: t('Forget the box and its images'),
|
||||
disabled: !snapshot.box && !snapshot.boxError,
|
||||
onClick: () => {
|
||||
@@ -372,8 +410,9 @@ export function createRecentImageryPanel({
|
||||
// ---- notice -----------------------------------------------------------
|
||||
function renderNotice() {
|
||||
const warning = snapshot.boxError || snapshot.error || exportError;
|
||||
const text =
|
||||
warning || snapshot.notice || (snapshot.borrowedEsri ? t(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));
|
||||
@@ -416,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;
|
||||
@@ -735,7 +774,7 @@ export function createRecentImageryPanel({
|
||||
}),
|
||||
);
|
||||
times.push(
|
||||
t('Coverage {coverage}', { coverage: focus.coverage || t('unknown') }),
|
||||
t('Coverage {coverage}', { coverage: coverageText(focus.coverage) }),
|
||||
);
|
||||
lines.push({ id: 'acquired', text: times.join(' · ') });
|
||||
(focus.granules || []).forEach((granule, index) => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/** Scene panel elements and text-only project presentation. */
|
||||
import { msg, t } from '../i18n/index.js';
|
||||
import { formatQuantity } from './unitLabels.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
|
||||
@@ -76,6 +77,9 @@ export function renderSceneShots(
|
||||
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;
|
||||
@@ -84,7 +88,7 @@ export function renderSceneShots(
|
||||
top.className = 'scene-shot-top';
|
||||
const label = document.createElement('div');
|
||||
label.className = 'scene-shot-label';
|
||||
label.textContent = shot.title;
|
||||
label.textContent = shownTitle;
|
||||
label.title = t('Double-click to rename');
|
||||
listen(label, 'click', () => select(shot.id));
|
||||
listen(label, 'dblclick', () => {
|
||||
@@ -93,14 +97,14 @@ export function renderSceneShots(
|
||||
input.type = 'text';
|
||||
input.className = 'scene-shot-rename';
|
||||
input.setAttribute('aria-label', t('Shot name'));
|
||||
input.value = shot.title;
|
||||
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) => {
|
||||
@@ -140,7 +144,12 @@ export function renderSceneShots(
|
||||
const modeLabel = DETECTION_MODE_LABELS[mode]
|
||||
? t(DETECTION_MODE_LABELS[mode])
|
||||
: mode;
|
||||
meta.textContent = `${styleLabel} · ${modeLabel} · ${shot.durationSec.toFixed(1)}s + ${shot.holdSec.toFixed(1)}s`;
|
||||
const seconds = (value) =>
|
||||
formatQuantity(value, 's', {
|
||||
minimumFractionDigits: 1,
|
||||
maximumFractionDigits: 1,
|
||||
});
|
||||
meta.textContent = `${styleLabel} · ${modeLabel} · ${seconds(shot.durationSec)} + ${seconds(shot.holdSec)}`;
|
||||
row.appendChild(top);
|
||||
row.appendChild(meta);
|
||||
element.appendChild(row);
|
||||
|
||||
+59
-2
@@ -1,4 +1,4 @@
|
||||
import { t } from '../i18n/index.js';
|
||||
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) {
|
||||
@@ -81,10 +81,67 @@ 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;
|
||||
chosen.textContent = 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();
|
||||
|
||||
+16
-1
@@ -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
|
||||
@@ -46,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) {
|
||||
|
||||
@@ -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,20 @@
|
||||
border-top: 3px solid #6be8ff;
|
||||
}
|
||||
|
||||
/* Up to two lines, so a translated title is not cut to a stub. */
|
||||
.bhote-embedded-callout footer strong {
|
||||
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;
|
||||
}
|
||||
|
||||
/* The status takes the full width under the title and link. */
|
||||
.bhote-embedded-callout footer small {
|
||||
grid-column: 1;
|
||||
grid-column: 1 / -1;
|
||||
color: rgba(151, 211, 224, 0.7);
|
||||
font-size: 8px;
|
||||
line-height: 1.35;
|
||||
@@ -260,8 +302,9 @@
|
||||
|
||||
.bhote-embedded-callout footer a {
|
||||
grid-column: 2;
|
||||
grid-row: 1 / span 2;
|
||||
grid-row: 1;
|
||||
align-self: center;
|
||||
white-space: nowrap;
|
||||
color: #6be8ff;
|
||||
font-size: 8px;
|
||||
text-decoration: none;
|
||||
@@ -372,6 +415,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 +436,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;
|
||||
}
|
||||
|
||||
@@ -419,13 +471,15 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 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;
|
||||
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 +502,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 +524,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 +648,6 @@
|
||||
max-height: 48vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
* {
|
||||
@@ -602,7 +656,8 @@
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html, body {
|
||||
html,
|
||||
body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
|
||||
+14
-2
@@ -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;
|
||||
@@ -163,8 +171,12 @@ html[lang='es'] .cctv-controls {
|
||||
border-radius: 7px;
|
||||
color: var(--text-primary);
|
||||
padding: 6px 8px;
|
||||
font-family: var(--font-sans);
|
||||
font-size: 12px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.04em;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
|
||||
@@ -1499,6 +1499,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 +1532,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 +1559,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 +1871,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 {
|
||||
|
||||
@@ -228,6 +228,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 +772,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;
|
||||
@@ -987,8 +998,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 +1248,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,
|
||||
|
||||
+27
-4
@@ -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,27 @@
|
||||
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;
|
||||
}
|
||||
|
||||
.data-toggle-right {
|
||||
display: flex;
|
||||
flex: 0 1 auto;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
@@ -640,8 +659,8 @@
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--text-secondary);
|
||||
min-width: 30px;
|
||||
text-align: right;
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
.data-toggle-btn {
|
||||
@@ -662,8 +681,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. */
|
||||
@@ -883,14 +904,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;
|
||||
@@ -355,19 +374,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 +410,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 {
|
||||
|
||||
@@ -158,11 +158,23 @@
|
||||
border-radius: 7px;
|
||||
color: var(--text-primary);
|
||||
padding: 6px 8px;
|
||||
font-family: var(--font-sans);
|
||||
font-size: 12px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.04em;
|
||||
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);
|
||||
|
||||
@@ -82,3 +82,61 @@
|
||||
.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. */
|
||||
#command-dock .gev-voice-heading {
|
||||
gap: 0.2rem;
|
||||
margin-left: -0.6rem;
|
||||
}
|
||||
|
||||
#command-dock .gev-voice-heading > .gev-voice-kicker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#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;
|
||||
}
|
||||
|
||||
#command-dock .gev-voice-cost {
|
||||
gap: 0.16rem;
|
||||
padding-left: 0.1rem;
|
||||
}
|
||||
|
||||
#command-dock .gev-voice-tier-btn {
|
||||
min-width: 0;
|
||||
padding: 0 0.2rem;
|
||||
}
|
||||
|
||||
#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. */
|
||||
#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;
|
||||
}
|
||||
@@ -258,6 +258,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 +312,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;
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user