feat(i18n): presentation extraction — t() call sites, data-i18n templates, locale selector

Route every application-owned presentation string through the i18n
core: t() call sites across the app/layers/ui/data/voice surfaces,
data-i18n* attributes in the src/ui/templates/*.html markup (applied at
boot by applyDocumentTranslations from src/standalone/application.js),
and the dock locale selector (runtime-rendered from the offered pair —
a single EN button while only English ships).

Rebased onto current main, where the extraction follows the module
splits: the shell wiring now lands in the owner modules
(panelChrome, visualSettings, locationNavigation, shareRestoration,
shellFeedback, applicationShell) and the voice status corpus in the
realtime* modules; scene status lives in director.js +
playbackAdapter.js.

Modules shared with the node runtime (loadingFeedback,
installationFeedback, regionalModel, keySetupCore, vessels ingestion)
stay environment-neutral English and localize at their browser render
sites (chip maps in shellFeedback/installations controls, the weather
table in cockpitBriefing, keySetupRequirementEnvVars compositions), so
the package-boundary graph stays clean; the browser boundary groups
now own the i18n modules they reach (scripts/package-boundaries.json).
This commit is contained in:
Meir Michanie committed 2026-10-01 12:41:20 +02:00
1 parent bde1e430ef
commit 284c4d4601
98 files changed
+3474 -1625

No files matched your search

+12 -10
View File
@@ -3,6 +3,7 @@ import {
formatAwarenessDistance,
AWARENESS_RADIUS_M,
} from '../../data/militaryAwarenessEngine.js';
import { t } from '../../i18n/index.js';
import { AWARENESS_PAGE_SIZE, AWARENESS_PAGE_ROTATE_MS } from './policy.js';
export function createPanel({ state: layerState, services, parts, source }) {
@@ -47,8 +48,8 @@ export function createPanel({ state: layerState, services, parts, source }) {
function hidePanel() {
if (layerState.panel) {
const markup = `<div class="military-awareness-standby">
<strong>${layerState.enabled ? 'CONTEXT READY' : 'GLOBAL CONTEXT OFF'}</strong>
<span>${layerState.enabled ? 'SELECT A FLIGHT, VESSEL, OR MAPPED INSTALLATION' : 'ENABLE TO LOAD OBSERVED / MAPPED PROXIMITY'}</span>
<strong>${layerState.enabled ? t('layers.awareness.standbyReady') : t('layers.awareness.standbyOff')}</strong>
<span>${layerState.enabled ? t('layers.awareness.standbySelect') : t('layers.awareness.standbyEnable')}</span>
</div>`;
layerState.panel.hidden = false;
if (layerState.panelMarkup !== markup) {
@@ -69,10 +70,11 @@ export function createPanel({ state: layerState, services, parts, source }) {
const label = formatAwarenessLabel(item);
const targetId = item.icao24 || item.mmsi || item.id;
if (!targetId) {
return `<li><span class="military-awareness-target unavailable" aria-label="Unavailable">${escapeHtml(label)} <span>${formatAwarenessDistance(item.distanceM)}</span></span></li>`;
return `<li><span class="military-awareness-target unavailable" aria-label="${t('layers.awareness.unavailableAria')}">${escapeHtml(label)} <span>${formatAwarenessDistance(item.distanceM)}</span></span></li>`;
}
const accessibleLabel = label === '—' ? 'Unavailable' : label;
return `<li><button type="button" class="military-awareness-target" data-awareness-layer="${escapeHtml(cohort.id)}" data-awareness-id="${escapeHtml(targetId)}" aria-label="Focus ${escapeHtml(accessibleLabel)}">${escapeHtml(label)} <span>${formatAwarenessDistance(item.distanceM)}</span></button>${namedAreasHtml(item.memberNames)}</li>`;
const accessibleLabel =
label === '—' ? t('layers.awareness.unavailableAria') : label;
return `<li><button type="button" class="military-awareness-target" data-awareness-layer="${escapeHtml(cohort.id)}" data-awareness-id="${escapeHtml(targetId)}" aria-label="${escapeHtml(t('layers.awareness.focusAria', { label: accessibleLabel }))}">${escapeHtml(label)} <span>${formatAwarenessDistance(item.distanceM)}</span></button>${namedAreasHtml(item.memberNames)}</li>`;
})
.join('');
const pageCount = Math.max(
@@ -93,10 +95,10 @@ export function createPanel({ state: layerState, services, parts, source }) {
function navigationControlsHtml() {
const canPrevious = layerState.navigationIndex > 0;
return `<div class="military-awareness-controls" role="group" aria-label="Global Context navigation">
<button type="button" data-awareness-action="previous" title="Previous — prior visited contact in the 250 km window"${canPrevious ? '' : ' disabled'}>PREVIOUS</button>
<button type="button" data-awareness-action="focus">FOCUS</button>
<button type="button" data-awareness-action="next" title="Next — nearest unvisited contact in the 250 km window"${parts.navigation.canNavigateNext() ? '' : ' disabled'}>NEXT</button>
return `<div class="military-awareness-controls" role="group" aria-label="${t('layers.awareness.controlsAria')}">
<button type="button" data-awareness-action="previous" title="${t('layers.awareness.previousTitle')}"${canPrevious ? '' : ' disabled'}>${t('layers.awareness.previous')}</button>
<button type="button" data-awareness-action="focus">${t('layers.awareness.focus')}</button>
<button type="button" data-awareness-action="next" title="${t('layers.awareness.nextTitle')}"${parts.navigation.canNavigateNext() ? '' : ' disabled'}>${t('layers.awareness.next')}</button>
</div>`;
}
@@ -178,7 +180,7 @@ export function createPanel({ state: layerState, services, parts, source }) {
${namedAreasHtml(subject.memberNames)}
${navigationControlsHtml()}
${cohorts.map(rowHtml).join('')}
<p class="military-awareness-note" tabindex="-1" data-awareness-focus-continuation>Open-source mapped/observed context. Missing broadcasts, unloaded map areas, or unmapped sites are not evidence of absence.</p>`;
<p class="military-awareness-note" tabindex="-1" data-awareness-focus-continuation>${t('layers.awareness.note')}</p>`;
panel.hidden = false;
if (layerState.panelMarkup !== markup) {
const focusSnapshot = captureAwarenessPanelFocus(panel);
+11 -4
View File
@@ -1,4 +1,5 @@
import * as Cesium from 'cesium';
import { t } from '../../i18n/index.js';
import {
createCyberSonarSampler,
isCyberContactSonarActive as isCyberSonarActive,
@@ -137,7 +138,9 @@ export function createRendering({
'--compass-rotation',
`${-headingDeg}deg`,
);
layerState.compassHeading.textContent = `HDG ${String(headingDeg).padStart(3, '0')}°`;
layerState.compassHeading.textContent = t('layers.awareness.hdg', {
value: String(headingDeg).padStart(3, '0'),
});
layerState.compassHeading.style.left = `${geometry.centerX}px`;
layerState.compassHeading.style.top = `${geometry.centerY - compassRadius + 39}px`;
for (const label of layerState.compassLabels) {
@@ -221,10 +224,14 @@ export function createRendering({
)
: null;
const bearingText = Number.isFinite(bearing)
? `BRG ${String(Math.round(bearing)).padStart(3, '0')}°`
: 'BRG —';
? t('layers.awareness.brg', {
value: String(Math.round(bearing)).padStart(3, '0'),
})
: t('layers.awareness.brgUnknown');
const courseText = Number.isFinite(item.track)
? ` · CRS ${String(Math.round(item.track)).padStart(3, '0')}°`
? ` · ${t('layers.awareness.crs', {
value: String(Math.round(item.track)).padStart(3, '0'),
})}`
: '';
marker._label.textContent = `${label} · ${formatAwarenessDistance(item.distanceM)}\n${bearingText}${courseText}`;
marker.hidden = false;
+22 -5
View File
@@ -3,6 +3,7 @@ import {
AWARENESS_RELATIONSHIP,
} from '../../data/militaryAwarenessEngine.js';
import * as Cesium from 'cesium';
import { t } from '../../i18n/index.js';
import {
AWARENESS_QUERY_LIMIT,
SOURCE_LABEL,
@@ -10,6 +11,21 @@ import {
AWARENESS_PAGE_SIZE,
} from './policy.js';
// installations/controls.js getStats composes coverageLabel inside the layer
// model (machine-composed English, pinned by militaryInstallations.test.mjs).
// This is the presentation boundary: map the closed vocabulary to the
// catalog, passing anything unknown through verbatim (docs/TRANSLATORS.md).
const WITHIN_KM_RE = /^WITHIN (\d+) KM$/;
function localizeCoverageLabel(label) {
if (typeof label !== 'string' || !label) return label;
if (label === 'CURRENT VIEWPORT ONLY')
return t('layers.awareness.coverage.viewport');
const within = WITHIN_KM_RE.exec(label);
if (within) return t('layers.awareness.coverage.within', { km: within[1] });
return label;
}
export function createSubject({ state: layerState, services, parts, source }) {
const aisLiveVesselsLayer = services.vessels;
const militaryInstallationsLayer = services.installations;
@@ -66,7 +82,7 @@ export function createSubject({ state: layerState, services, parts, source }) {
cohorts: [
{
id: 'flights',
label: 'Flights',
label: t('layers.awareness.cohort.flights'),
source: flightsState.stats.source || SOURCE_LABEL.flights,
summary: parts.navigation.summarizeAwarenessCohortForNavigation(
flights,
@@ -75,7 +91,7 @@ export function createSubject({ state: layerState, services, parts, source }) {
},
{
id: 'military',
label: 'Military flights',
label: t('layers.awareness.cohort.military'),
source: militaryState.stats.source || SOURCE_LABEL.military,
summary: parts.navigation.summarizeAwarenessCohortForNavigation(
military,
@@ -84,7 +100,7 @@ export function createSubject({ state: layerState, services, parts, source }) {
},
{
id: 'ais-live-vessels',
label: 'AIS vessels',
label: t('layers.awareness.cohort.vessels'),
source: vesselsState.stats.source || SOURCE_LABEL['ais-live-vessels'],
summary: parts.navigation.summarizeAwarenessCohortForNavigation(
vessels,
@@ -93,12 +109,13 @@ export function createSubject({ state: layerState, services, parts, source }) {
},
{
id: 'military-installations',
label: 'Mapped installations',
label: t('layers.awareness.cohort.installations'),
source:
installationsState.stats.source ||
SOURCE_LABEL['military-installations'],
coverage:
installationsState.stats.coverageLabel || 'CURRENT VIEWPORT ONLY',
localizeCoverageLabel(installationsState.stats.coverageLabel) ||
t('layers.awareness.coverage.viewport'),
summary: parts.queries.summarizeInstallationViewport(
installations,
installationsState,
+5 -2
View File
@@ -1,3 +1,4 @@
import { t } from '../../i18n/index.js';
import { STATUS_POLL_MS } from './policy.js';
export function createControls({ state: layerState, services, parts, source }) {
@@ -34,8 +35,10 @@ export function createControls({ state: layerState, services, parts, source }) {
if (layerState._loading) {
stats.loadingLabel =
layerState._activeCityIds.size > 0
? `syncing ${layerState._activeCityIds.size} city feeds...`
: 'scanning nearby systems...';
? t('layers.bike.loadingSyncing', {
count: layerState._activeCityIds.size,
})
: t('layers.bike.loadingScanning');
}
if (layerState._error) stats.error = layerState._error;
return stats;
+11 -5
View File
@@ -1,4 +1,5 @@
import * as Cesium from 'cesium';
import { t } from '../../i18n/index.js';
import { isPointerFree } from '../../data/inputOwnership.js';
import {
BIKESHARE_SELECTED_OVERLAY_SOURCE_ID,
@@ -22,7 +23,9 @@ export function createSelection({
const stationName = String(record?.stationName || '').trim();
const stationLabel =
stationName ||
(record?.stationId ? `Station ${record.stationId}` : 'Station');
(record?.stationId
? t('layers.bike.stationWithId', { id: record.stationId })
: t('layers.bike.stationFallback'));
const bikes = Number.isFinite(record?.bikesAvailable)
? record.bikesAvailable
: '?';
@@ -33,14 +36,17 @@ export function createSelection({
const lines = [
stationLabel,
`🚲 ${bikes} avail · ${docks} docks · ${capacity} cap`,
t('layers.bike.card.capacity', { bikes, docks, capacity }),
];
// Append warnings for stations that are offline or partially non-operational
const abnormal = [];
if (record?.isInstalled === false) abnormal.push('⚠️ Not installed');
if (record?.isRenting === false) abnormal.push('⚠️ Not renting');
if (record?.isReturning === false) abnormal.push('⚠️ Not returning');
if (record?.isInstalled === false)
abnormal.push(t('layers.bike.warning.notInstalled'));
if (record?.isRenting === false)
abnormal.push(t('layers.bike.warning.notRenting'));
if (record?.isReturning === false)
abnormal.push(t('layers.bike.warning.notReturning'));
if (abnormal.length > 0) {
lines.push(abnormal.join(' · '));
}
+7 -1
View File
@@ -1,3 +1,5 @@
import { t } from '../../i18n/index.js';
/**
* @module headingConfidence
*
@@ -43,5 +45,9 @@ export function isHeadingEstimated(camera) {
*/
export function headingHudToken(camera) {
const hdg = Math.round(Number(camera?.headingDeg) || 0);
return `HDG ${hdg}°${isHeadingEstimated(camera) ? ' (ESTIMATED)' : ''}`;
// The token copy lives in the layers catalog (en is byte-identical to the
// literals these replace); presentation stays here, translation at read.
return isHeadingEstimated(camera)
? t('layers.cctv.summary.hdgEstimated', { value: hdg })
: t('layers.cctv.summary.hdg', { value: hdg });
}
+31 -12
View File
@@ -1,4 +1,5 @@
import { CCTV_AMBIENT_CARD_MAX } from '../../data/cctvLod.js';
import { t } from '../../i18n/index.js';
import { ACTIVE_FRAME_REFRESH_MS, IDLE_FRAME_REFRESH_MS } from './policy.js';
import { headingHudToken, isHeadingEstimated } from './headingConfidence.js';
@@ -19,8 +20,10 @@ export function createPresentation({
const active = parts.selection.getActiveRecord();
if (!active) {
return layerState._records.length
? `${layerState._records.length} CAMERAS STANDING BY · NO CAMERA SELECTED · CLICK A CAMERA TO ACTIVATE`
: 'No cameras available in catalog.';
? t('layers.cctv.summary.standingBy', {
count: layerState._records.length,
})
: t('layers.cctv.summary.empty');
}
const area = parts.model.sectorAreaKm2(
@@ -34,21 +37,37 @@ export function createPresentation({
const calBadge = parts.calibration.deriveCalBadge(active.camera);
return [
`${active.camera.city.toUpperCase()} CCTV`,
t('layers.cctv.summary.city', {
city: active.camera.city.toUpperCase(),
}),
`${active.camera.name.toUpperCase()}`,
// A synthetic bearing (headingConfidence 'low', no human calibration)
// is tagged so a hashed guess never reads as a surveyed facing (#639).
headingHudToken(active.camera),
`FOV ${Math.round(active.camera.fovDeg)}°`,
`COVERAGE ${area.toFixed(2)}km²`,
overlapCount > 0 ? `OVERLAP ${overlapCount} cams` : 'ISOLATED VIEW',
`PROJ ${layerState._showProjection ? 'MONITOR' : 'OFF'}`,
layerState._coverageMode === 'viewshed' ? 'VIEWSHED' : null,
`CAL ${calBadge.replace('-', ' ').toUpperCase()}`,
t('layers.cctv.summary.fov', {
value: Math.round(active.camera.fovDeg),
}),
t('layers.cctv.summary.coverage', { value: area.toFixed(2) }),
overlapCount > 0
? t('layers.cctv.summary.overlap', { count: overlapCount })
: t('layers.cctv.summary.isolated'),
layerState._showProjection
? t('layers.cctv.summary.projMonitor')
: t('layers.cctv.summary.projOff'),
layerState._coverageMode === 'viewshed'
? t('layers.cctv.summary.viewshed')
: null,
t('layers.cctv.summary.cal', {
value: calBadge.replace('-', ' ').toUpperCase(),
}),
health?.sourceKind
? `SRC ${String(health.sourceKind).toUpperCase()}`
: `SRC ${String(active.camera.feedType || 'image').toUpperCase()}`,
`${viewBand.toUpperCase()} CONTEXT`,
? t('layers.cctv.summary.src', {
value: String(health.sourceKind).toUpperCase(),
})
: t('layers.cctv.summary.src', {
value: String(active.camera.feedType || 'image').toUpperCase(),
}),
t('layers.cctv.summary.context', { value: viewBand.toUpperCase() }),
]
.filter(Boolean)
.join(' · ');
+5 -1
View File
@@ -1,4 +1,5 @@
import * as Cesium from 'cesium';
import { t } from '../../i18n/index.js';
import {
FIRMS_OVERLAY_SOURCE_ID,
FIRMS_AMBIENT_COHORT_LIMIT,
@@ -115,7 +116,10 @@ export function createCards({
if (!card.interactive) return card;
return {
...card,
accessibilityLabel: `Focus fire detection ${card.title}, ${card.details.join(', ')}`,
accessibilityLabel: t('layers.firms.card.focusAria', {
title: card.title,
details: card.details.join(', '),
}),
activate: () => {
const fire = layerState._fireByCardId.get(card.id);
if (!fire) return false;
+27 -13
View File
@@ -1,4 +1,5 @@
import * as Cesium from 'cesium';
import { t } from '../../i18n/index.js';
import {
satelliteShortName,
fireDetectionKey,
@@ -327,14 +328,20 @@ export function createModel({
*/
function buildSelectedFireCard(fire, nowMs) {
const meta = [`${confidenceBucket(fire.confidence)} conf`];
const meta = [
t('layers.firms.card.confSuffix', {
value: confidenceBucket(fire.confidence),
}),
];
if (fire.acqMs > 0) {
const age = formatAge(nowMs - fire.acqMs);
if (age) meta.push(`${age} ago`);
if (age) meta.push(t('layers.firms.card.ageSuffix', { value: age }));
}
const sat = satelliteShortName(fire.satellite);
meta.push(
sat ? `${fire.sensor || 'VIIRS'} ${sat}` : fire.sensor || 'sensor n/a',
sat
? `${fire.sensor || 'VIIRS'} ${sat}`
: fire.sensor || t('layers.firms.card.sensorUnavailable'),
);
return {
id: `selected-fire:${fireDetectionKey(fire)}`,
@@ -344,10 +351,11 @@ export function createModel({
cullPosition: fireCullPosition(fire),
gapPx: frpPixelSize(fire.frp),
accent: accentForSeverity(detectionColorStop(fire).name),
title: `FIRE · ${formatFrp(fire.frp)} MW`,
title: t('layers.firms.card.fire', { frp: formatFrp(fire.frp) }),
details: [
meta.join(' · '),
formatLatLon(fire.lat, fire.lon) + (fire.night ? ' · NIGHT' : ''),
formatLatLon(fire.lat, fire.lon) +
(fire.night ? ` · ${t('layers.firms.card.night')}` : ''),
],
selected: true,
priority: Number.MAX_SAFE_INTEGER,
@@ -380,7 +388,7 @@ export function createModel({
cullPosition: candidate.cullPosition || candidate.position,
gapPx: frpPixelSize(fire.frp),
accent: accentForSeverity(detectionColorStop(fire).name),
title: `▲ ${formatFrp(fire.frp)} MW`,
title: t('layers.firms.card.ambient', { frp: formatFrp(fire.frp) }),
details: [meta.join(' · ')],
selected: false,
priority: Number(fire.frp) || 0,
@@ -398,11 +406,16 @@ export function createModel({
function buildCellCard(candidate, nowMs) {
const cell = candidate.cell;
const noun = cell.count === 1 ? 'FIRE' : 'FIRES';
const parts = [`max ${formatFrp(cell.maxFrp)} MW`];
const noun =
cell.count === 1
? t('layers.firms.card.fireNoun')
: t('layers.firms.card.firesNoun');
const parts = [
t('layers.firms.card.maxFrp', { value: formatFrp(cell.maxFrp) }),
];
if (cell.newestAcqMs > 0) {
const age = formatAge(nowMs - cell.newestAcqMs);
if (age) parts.push(`new ${age}`);
if (age) parts.push(t('layers.firms.card.newAge', { value: age }));
}
return {
id: `cell:${cell.latCell ?? 'x'}:${cell.lonCell ?? 'x'}`,
@@ -488,11 +501,12 @@ export function createModel({
/** Millisecond delta → "<1m ago" / "Xm ago" / "Xh ago" (fresh-feed readout). */
function formatAgoMinutes(deltaMs) {
if (!Number.isFinite(deltaMs) || deltaMs < 0) return 'just now';
if (!Number.isFinite(deltaMs) || deltaMs < 0)
return t('layers.meta.justNow');
const minutes = Math.floor(deltaMs / 60000);
if (minutes < 1) return '<1m ago';
if (minutes < 90) return `${minutes}m ago`;
return `${Math.round(minutes / 60)}h ago`;
if (minutes < 1) return t('layers.firms.underMinuteAgo');
if (minutes < 90) return t('layers.meta.minutesAgo', { count: minutes });
return t('layers.meta.hoursAgo', { count: Math.round(minutes / 60) });
}
/** Normalized 0..1 confidence → low/nominal/high display bucket. */
+13 -7
View File
@@ -1,4 +1,5 @@
import { fireDetectionKey } from '../../data/firmsLabels.js';
import { t } from '../../i18n/index.js';
import { REFRESH_INTERVAL_MS } from './policy.js';
export function createQueries({
@@ -59,21 +60,26 @@ export function createQueries({
getStats() {
const now = Date.now();
const staleText = layerState._lastUpdate
? `STALE · cached ${components.model.formatAge(now - layerState._lastUpdate) || '<1h'}`
: 'STALE';
? t('layers.firms.staleCached', {
age:
components.model.formatAge(now - layerState._lastUpdate) || '<1h',
})
: t('layers.status.stale');
let loadingLabel = '';
if (layerState._loading) {
loadingLabel = layerState._fires.length
? 'refreshing...'
: 'loading...';
? t('layers.meta.refreshing')
: t('layers.meta.loading');
} else if (layerState._keyRequired) {
loadingLabel = 'KEY REQUIRED';
loadingLabel = t('layers.firms.keyRequired');
} else if (layerState._stale) {
loadingLabel = staleText;
} else if (layerState._error) {
loadingLabel = layerState._error;
} else if (layerState._lastUpdate) {
loadingLabel = `LIVE · updated ${components.model.formatAgoMinutes(now - layerState._lastUpdate)}`;
loadingLabel = t('layers.firms.liveUpdated', {
age: components.model.formatAgoMinutes(now - layerState._lastUpdate),
});
}
return {
count: layerState._count,
@@ -87,7 +93,7 @@ export function createQueries({
// the operator can take.
keyRequired: layerState._keyRequired,
error: layerState._keyRequired
? 'KEY REQUIRED'
? t('layers.firms.keyRequired')
: layerState._stale
? staleText
: layerState._error,
+2 -1
View File
@@ -1,4 +1,5 @@
import * as Cesium from 'cesium';
import { t } from '../../i18n/index.js';
import { nextCockpitNearContacts } from '../../data/cockpitAirLod.js';
import { trackedModelZoomActive } from '../../data/trackedModelRegime.js';
import {
@@ -755,7 +756,7 @@ export function createTracking({
const spd = info.velocity ? `${Math.round(info.velocity * 1.944)} kts` : '';
const stale =
flightState.records.missingPolls.get(icao24) || flightState.feed._backoff
? 'STALE'
? t('layers.status.stale')
: '';
const lines = [[cs, fl, spd, stale].filter(Boolean).join(' · ')];
// Converted contacts report their class as TR-3B and nothing else — the
+55 -6
View File
@@ -5,6 +5,53 @@ import {
DISTANCE_PREFILTER_MARGIN_M,
ANCHOR_REFRESH_M,
} from './policy.js';
import { t } from '../../i18n/index.js';
// installationFeedback.js composes its guidance inside the portable
// node/browser graph (the server-side HUD summary reads the same module), so
// its copy stays English there. getStats is the browser presentation
// boundary: map the closed message vocabulary to the catalog, passing any
// live provider error through verbatim (docs/TRANSLATORS.md).
const INSTALLATION_FEEDBACK_KEYS = Object.freeze({
'Retrying mapped sites…': 'layers.installations.feedback.retrying',
'Fetching mapped sites…': 'layers.installations.feedback.fetching',
'Zoom in to search mapped installations':
'layers.installations.feedback.zoomIn',
'Showing cached mapped sites': 'layers.installations.feedback.cached',
'Mapped sites not loaded': 'layers.installations.feedback.notLoaded',
'Mapped sites loaded': 'layers.installations.feedback.loaded',
'Overpass rate-limited': 'layers.installations.feedback.reason.rateLimited',
'Overpass timed out': 'layers.installations.feedback.reason.timeout',
'Overpass could not complete the query':
'layers.installations.feedback.reason.queryFailed',
'Overpass temporarily unavailable':
'layers.installations.feedback.reason.unavailable',
});
const INSTALLATION_RETRY_IN_RE = /^(.*) — retrying in (\d+)s$/;
const INSTALLATION_RETRY_PENDING_RE = /^(.*) — retry pending$/;
function localizeInstallationFeedback(message) {
if (typeof message !== 'string' || !message) return message;
const key = INSTALLATION_FEEDBACK_KEYS[message];
if (key) return t(key);
const retryIn = message.match(INSTALLATION_RETRY_IN_RE);
if (retryIn) {
const reasonKey = INSTALLATION_FEEDBACK_KEYS[retryIn[1]];
return t('layers.installations.feedback.retryIn', {
reason: reasonKey ? t(reasonKey) : retryIn[1],
seconds: retryIn[2],
});
}
const pending = message.match(INSTALLATION_RETRY_PENDING_RE);
if (pending) {
const reasonKey = INSTALLATION_FEEDBACK_KEYS[pending[1]];
return t('layers.installations.feedback.retryPending', {
reason: reasonKey ? t(reasonKey) : pending[1],
});
}
return message;
}
export function createControls({ state: layerState, services, parts, source }) {
const methods = {
@@ -196,13 +243,15 @@ export function createControls({ state: layerState, services, parts, source }) {
retryAt: layerState.retryAt,
retrying: layerState.loading && Boolean(layerState.failureReason),
failureReason: layerState.failureReason,
statusMessage: installationFeedback({
...layerState,
count: layerState.lastUpdate ? mappedCount : undefined,
retrying: layerState.loading && Boolean(layerState.failureReason),
}),
statusMessage: localizeInstallationFeedback(
installationFeedback({
...layerState,
count: layerState.lastUpdate ? mappedCount : undefined,
retrying: layerState.loading && Boolean(layerState.failureReason),
}),
),
loadingLabel: layerState.loading
? 'loading mapped installation context'
? t('layers.installations.loading')
: '',
coverage: layerState.coverage,
coverageLabel:
+2 -1
View File
@@ -1,4 +1,5 @@
import * as Cesium from 'cesium';
import { t } from '../../i18n/index.js';
import { LAYER_ID, MAX_RENDERED, COLOR_BY_CLASS } from './policy.js';
export function createRendering({
@@ -270,7 +271,7 @@ export function createRendering({
entity.gevTrackedId = `installations:${record.id}`;
entity.gevDisplayPosition = () => displayPosition;
entity.gevLabelModel = {
title: record.name || 'MAPPED INSTALLATION',
title: record.name || t('layers.installations.fallbackTitle'),
details: [
String(record.class || 'installation')
.replaceAll('_', ' ')
+25 -11
View File
@@ -1,4 +1,5 @@
import * as Cesium from 'cesium';
import { t } from '../../i18n/index.js';
import {
createCyberSonarSampler,
isCyberContactSonarActive as isCyberSonarActive,
@@ -119,8 +120,10 @@ export function createOverlays({ state: layerState, services, parts, source }) {
const details = selected
? [
siteName
? `LAUNCH SITE · ${shortMissionLabel(siteName, 20).toUpperCase()}`
: 'LAUNCH SITE',
? t('layers.missions.launchSiteValue', {
site: shortMissionLabel(siteName, 20).toUpperCase(),
})
: t('layers.missions.launchSite'),
]
: [];
return {
@@ -586,27 +589,38 @@ export function createOverlays({ state: layerState, services, parts, source }) {
const mission = shortMissionLabel(launch.name, 22).toUpperCase();
const siteName = compactLaunchSiteName(launch.launchSite);
const siteCallout = siteName
? `LAUNCH SITE · ${shortMissionLabel(siteName, 20).toUpperCase()}`
: 'LAUNCH SITE';
? t('layers.missions.launchSiteValue', {
site: shortMissionLabel(siteName, 20).toUpperCase(),
})
: t('layers.missions.launchSite');
let title = mission;
let detail = siteCallout;
if (mode === 'countdown') {
title = `T−${String(state.countdownSeconds).padStart(2, '0')} · ${mission}`;
detail = `LAUNCH STANDBY\n${siteCallout}`;
title = t('layers.missions.replay.countdown', {
time: String(state.countdownSeconds).padStart(2, '0'),
mission,
});
detail = `${t('layers.missions.replay.standbyDetail')}\n${siteCallout}`;
} else if (mode === 'ascent') {
title =
state.elapsedSinceStart < 1
? `LIFTOFF · ${mission}`
: `${launch.trajectory.length > 1 ? 'ASCENT REPLAY' : 'ASCENT ESTIMATE'} · ${mission}`;
? t('layers.missions.replay.liftoff', { mission })
: t(
launch.trajectory.length > 1
? 'layers.missions.replay.ascentReplay'
: 'layers.missions.replay.ascentEstimate',
{ mission },
);
detail = parts.policyHelpers.formatMissionEventTime(state.eventTime);
} else if (mode === 'recovery') {
title = `STAGE RE-ENTRY / RECOVERY · ${mission}`;
title = t('layers.missions.replay.recovery', { mission });
detail = parts.policyHelpers.formatMissionEventTime(state.eventTime);
} else if (mode === 'orbit') {
title = `ORBIT REPLAY · ${mission}`;
title = t('layers.missions.replay.orbit', { mission });
detail = parts.policyHelpers.formatMissionEventTime(state.eventTime);
}
if (layerState._replayPaused) title = `PAUSED · ${title}`;
if (layerState._replayPaused)
title = t('layers.missions.replay.pausedPrefix', { title });
const nextText = `${title}\n${detail}`;
if (nextText !== layerState._replayVehicleOverlayText) {
layerState._replayVehicleOverlayText = nextText;
+44 -21
View File
@@ -1,4 +1,5 @@
import * as Cesium from 'cesium';
import { t } from '../../i18n/index.js';
import {
MISSION_CLOSE_VIEW_RANGE_M,
MISSION_GLOBE_VIEW_RANGE_M,
@@ -219,34 +220,42 @@ export function createPanel({ state: layerState, services, parts, source }) {
]
.filter(Boolean)
.join(' · ');
return `<tr><td>${escapeMissionText(payload.name)}${payload.amount > 1 ? ` ×${payload.amount}` : ''}${detail ? `<small>${escapeMissionText(detail)}</small>` : ''}</td><td>${escapeMissionText(payload.type || 'UNSPECIFIED')}</td><td>${escapeMissionText(payload.destination || launch.orbit?.name || 'UNAVAILABLE')}</td></tr>`;
return `<tr><td>${escapeMissionText(payload.name)}${payload.amount > 1 ? ` ×${payload.amount}` : ''}${detail ? `<small>${escapeMissionText(detail)}</small>` : ''}</td><td>${escapeMissionText(payload.type || t('layers.missions.rows.unspecified'))}</td><td>${escapeMissionText(payload.destination || launch.orbit?.name || t('layers.missions.rows.unavailable'))}</td></tr>`;
})
: [];
if (launch.payloads.length > 5) {
payloadRows.push(
`<tr><td colspan="3" class="mission-table-empty">+${launch.payloads.length - 5} additional payload records</td></tr>`,
`<tr><td colspan="3" class="mission-table-empty">${t('layers.missions.rows.additionalPayloads', { count: launch.payloads.length - 5 })}</td></tr>`,
);
}
layerState._missionPanel.querySelector(
'[data-mission-payloads]',
).innerHTML = missionTableRows(payloadRows, 3, 'PAYLOAD DATA UNAVAILABLE');
).innerHTML = missionTableRows(
payloadRows,
3,
t('layers.missions.rows.classified'),
);
const stageRows = launch.recoveryStages.map((stage) => {
const endpoint = stage.endpoint;
const destination =
stage.destination ||
(endpoint?.accuracy === 'PAD / RTLS'
? launch.launchSite
: 'UNAVAILABLE');
: t('layers.missions.rows.unavailable'));
const position = endpoint
? `${endpoint.lat.toFixed(2)}, ${endpoint.lon.toFixed(2)} · ${endpoint.accuracy}`
: stage.downrangeKm > 0
? `${stage.downrangeKm.toLocaleString()} KM DOWNRANGE`
: 'POSITION UNAVAILABLE';
? t('layers.missions.rows.kmDownrange', {
value: stage.downrangeKm.toLocaleString(),
})
: t('layers.missions.rows.positionUnavailable');
const stageDetail = [
Number.isFinite(stage.flightNumber)
? `FLIGHT ${stage.flightNumber}`
? t('layers.missions.rows.flightNumber', {
count: stage.flightNumber,
})
: null,
stage.reused ? 'REUSED' : null,
stage.reused ? t('layers.missions.rows.reused') : null,
stage.recoveryType,
]
.filter(Boolean)
@@ -254,7 +263,7 @@ export function createPanel({ state: layerState, services, parts, source }) {
return `<tr><td>${escapeMissionText(stage.name)}${stageDetail ? `<small>${escapeMissionText(stageDetail)}</small>` : ''}</td><td>${escapeMissionText(stage.status)}</td><td>${escapeMissionText(destination)}<small>${escapeMissionText(position)}</small></td></tr>`;
});
layerState._missionPanel.querySelector('[data-mission-stages]').innerHTML =
missionTableRows(stageRows, 3, 'NO STAGE RE-ENTRY / RECOVERY DATA');
missionTableRows(stageRows, 3, t('layers.missions.rows.noStageData'));
const stageSection = layerState._missionPanel.querySelector(
'[data-mission-stages-section]',
);
@@ -281,7 +290,10 @@ export function createPanel({ state: layerState, services, parts, source }) {
const count = layerState._missionRoster.querySelector(
'[data-mission-roster-count]',
);
if (count) count.textContent = `${layerState._launches.length} / 30D`;
if (count)
count.textContent = t('layers.missions.roster.count', {
count: layerState._launches.length,
});
if (!list) return;
const focusSnapshot = captureMissionRosterFocus(list);
layerState._missionRosterPreviewOwnership?.reset();
@@ -289,8 +301,7 @@ export function createPanel({ state: layerState, services, parts, source }) {
layerState._launches,
);
if (!entries.length) {
list.innerHTML =
'<div class="space-mission-roster-empty">NO MISSIONS AVAILABLE IN THE CURRENT 30-DAY WINDOW</div>';
list.innerHTML = `<div class="space-mission-roster-empty">${t('layers.missions.roster.empty')}</div>`;
restoreMissionRosterFocus(
list,
focusSnapshot,
@@ -303,12 +314,15 @@ export function createPanel({ state: layerState, services, parts, source }) {
list.innerHTML = entries
.map(({ launch, index }) => {
const color = parts.model.missionMarkerColor(launch).toCssColorString();
const date = launch.launchTime?.slice(0, 10) || 'DATE UNAVAILABLE';
const provider = launch.provider || 'UNSPECIFIED OPERATOR';
const date =
launch.launchTime?.slice(0, 10) ||
t('layers.missions.roster.dateUnavailable');
const provider =
launch.provider || t('layers.missions.roster.unspecifiedOperator');
const label = parts.overlays
.shortMissionLabel(launch.name, 27)
.toUpperCase();
return `<button type="button" class="space-mission-roster-item" data-mission-roster-index="${index}" data-mission-roster-id="${escapeMissionText(launch.id)}" aria-label="Select ${escapeMissionText(label)}"><span class="space-mission-roster-marker" style="--mission-roster-color:${color}" aria-hidden="true"></span><span class="space-mission-roster-copy"><strong>${escapeMissionText(label)}</strong><small>${escapeMissionText(provider)} · ${escapeMissionText(date)}</small></span><span class="space-mission-roster-chevron" aria-hidden="true">›</span></button>`;
return `<button type="button" class="space-mission-roster-item" data-mission-roster-index="${index}" data-mission-roster-id="${escapeMissionText(launch.id)}" aria-label="${escapeMissionText(t('layers.missions.roster.selectAria', { mission: label }))}"><span class="space-mission-roster-marker" style="--mission-roster-color:${color}" aria-hidden="true"></span><span class="space-mission-roster-copy"><strong>${escapeMissionText(label)}</strong><small>${escapeMissionText(provider)} · ${escapeMissionText(date)}</small></span><span class="space-mission-roster-chevron" aria-hidden="true">›</span></button>`;
})
.join('');
list.querySelectorAll('[data-mission-roster-index]').forEach((button) => {
@@ -365,7 +379,11 @@ export function createPanel({ state: layerState, services, parts, source }) {
setMissionPanelField(
'[data-mission-distance]',
Number.isFinite(altitudeM)
? `${Math.max(0, altitudeM / 1000).toLocaleString(undefined, { maximumFractionDigits: 0 })} KM`
? t('layers.missions.telemetry.km', {
value: Math.max(0, altitudeM / 1000).toLocaleString(undefined, {
maximumFractionDigits: 0,
}),
})
: null,
);
const speedMps = layerState._satelliteTelemetry.get(
@@ -374,7 +392,12 @@ export function createPanel({ state: layerState, services, parts, source }) {
setMissionPanelField(
'[data-mission-speed]',
Number.isFinite(speedMps)
? `${(speedMps / 1000).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })} KM/S`
? t('layers.missions.telemetry.kmPerSecond', {
value: (speedMps / 1000).toLocaleString(undefined, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}),
})
: null,
Number.isFinite(speedMps)
? `${(speedMps * 3.6).toLocaleString(undefined, { maximumFractionDigits: 0 })} km/h`
@@ -450,16 +473,16 @@ export function createPanel({ state: layerState, services, parts, source }) {
layerState._missionPanel.className = 'context-space-mission-detail';
layerState._missionPanel.setAttribute(
'aria-label',
'Selected Space Mission',
t('layers.missions.panel.selectedHeader'),
);
layerState._missionPanel.innerHTML = `<div class="space-mission-view-header"><span>SELECTED SPACE MISSION</span><button type="button" data-mission-close title="Show all missions" aria-label="Deselect mission">×</button></div><div class="space-mission-detail"><strong data-mission-title>MISSION</strong><span data-mission-field data-mission-provider></span><span data-mission-field>STATUS · <b data-mission-status></b></span><span data-mission-field>LAUNCH SITE · <b data-mission-site></b></span><span data-mission-field>LAUNCH TIME · <b data-mission-time></b></span><span data-mission-field>ORBIT · <b data-mission-orbit></b></span><span>ASCENT PATH · <b data-mission-ascent-source></b></span><span data-mission-field>CURRENT DISTANCE FROM EARTH · <b data-mission-distance></b></span><span data-mission-field>SATELLITE SPEED · <b data-mission-speed></b></span></div><section class="mission-data-section"><h4>PAYLOAD</h4><div class="mission-table-scroll"><table class="mission-data-table"><thead><tr><th>NAME</th><th>TYPE</th><th>DESTINATION</th></tr></thead><tbody data-mission-payloads></tbody></table></div></section><section class="mission-data-section" data-mission-stages-section><h4>STAGE / RE-ENTRY / RECOVERY</h4><div class="mission-table-scroll"><table class="mission-data-table"><thead><tr><th>STAGE</th><th>STATUS</th><th>FINAL POSITION</th></tr></thead><tbody data-mission-stages></tbody></table></div></section><div class="mission-replay-speed-control"><div class="mission-replay-speed-header"><label for="space-mission-replay-speed">REPLAY SPEED</label><output class="gev-slider-value" for="space-mission-replay-speed" data-mission-replay-speed-output>1×</output></div><input id="space-mission-replay-speed" class="gev-quantitative-slider" type="range" min="0.25" max="4" step="0.25" value="1" data-mission-replay-speed aria-label="Replay speed multiplier"><div class="mission-replay-speed-scale" aria-hidden="true"><span>0.25×</span><span>1×</span><span>4×</span></div></div><div class="mission-action-row"><button type="button" class="mission-focus-button" data-mission-focus>FOCUS</button><button type="button" class="mission-replay-button" data-mission-replay aria-pressed="false">REPLAY ASCENT</button></div><div class="space-mission-nav"><button type="button" class="mission-nav-button" data-mission-prev title="Previous mission"><span aria-hidden="true">‹</span> PREV</button><span class="mission-nav-index" data-mission-index>—</span><button type="button" class="mission-nav-button" data-mission-next title="Next mission">NEXT <span aria-hidden="true">›</span></button></div><button type="button" class="panel-layer-toggle" data-mission-show-all>SHOW ALL / DESELECT</button>`;
layerState._missionPanel.innerHTML = `<div class="space-mission-view-header"><span>${t('layers.missions.panel.selectedHeader')}</span><button type="button" data-mission-close title="${t('layers.missions.panel.showAllTitle')}" aria-label="${t('layers.missions.panel.deselectAria')}">×</button></div><div class="space-mission-detail"><strong data-mission-title>${t('layers.missions.panel.mission')}</strong><span data-mission-field data-mission-provider></span><span data-mission-field>${t('layers.missions.panel.status')}<b data-mission-status></b></span><span data-mission-field>${t('layers.missions.panel.launchSite')}<b data-mission-site></b></span><span data-mission-field>${t('layers.missions.panel.launchTime')}<b data-mission-time></b></span><span data-mission-field>${t('layers.missions.panel.orbit')}<b data-mission-orbit></b></span><span>${t('layers.missions.panel.ascentPath')}<b data-mission-ascent-source></b></span><span data-mission-field>${t('layers.missions.panel.distance')}<b data-mission-distance></b></span><span data-mission-field>${t('layers.missions.panel.speed')}<b data-mission-speed></b></span></div><section class="mission-data-section"><h4>${t('layers.missions.panel.payload')}</h4><div class="mission-table-scroll"><table class="mission-data-table"><thead><tr><th>${t('layers.missions.panel.colName')}</th><th>${t('layers.missions.panel.colType')}</th><th>${t('layers.missions.panel.colDestination')}</th></tr></thead><tbody data-mission-payloads></tbody></table></div></section><section class="mission-data-section" data-mission-stages-section><h4>${t('layers.missions.panel.stagesSection')}</h4><div class="mission-table-scroll"><table class="mission-data-table"><thead><tr><th>${t('layers.missions.panel.colStage')}</th><th>${t('layers.missions.panel.colStatus')}</th><th>${t('layers.missions.panel.colFinalPosition')}</th></tr></thead><tbody data-mission-stages></tbody></table></div></section><div class="mission-replay-speed-control"><div class="mission-replay-speed-header"><label for="space-mission-replay-speed">${t('layers.missions.replay.speedLabel')}</label><output class="gev-slider-value" for="space-mission-replay-speed" data-mission-replay-speed-output>1×</output></div><input id="space-mission-replay-speed" class="gev-quantitative-slider" type="range" min="0.25" max="4" step="0.25" value="1" data-mission-replay-speed aria-label="${t('layers.missions.replay.speedAria')}"><div class="mission-replay-speed-scale" aria-hidden="true"><span>0.25×</span><span>1×</span><span>4×</span></div></div><div class="mission-action-row"><button type="button" class="mission-focus-button" data-mission-focus>${t('layers.missions.panel.focus')}</button><button type="button" class="mission-replay-button" data-mission-replay aria-pressed="false">${t('layers.missions.replay.start')}</button></div><div class="space-mission-nav"><button type="button" class="mission-nav-button" data-mission-prev title="${t('layers.missions.panel.prevTitle')}"><span aria-hidden="true">‹</span> ${t('layers.missions.panel.prev')}</button><span class="mission-nav-index" data-mission-index>—</span><button type="button" class="mission-nav-button" data-mission-next title="${t('layers.missions.panel.nextTitle')}">${t('layers.missions.panel.next')} <span aria-hidden="true">›</span></button></div><button type="button" class="panel-layer-toggle" data-mission-show-all>${t('layers.missions.panel.showAll')}</button>`;
layerState._missionPanel
.querySelector('.mission-action-row')
.insertAdjacentHTML(
'beforeend',
`<div class="mission-replay-transport" data-mission-replay-transport hidden>
<button type="button" data-mission-replay-toggle title="Pause replay" aria-label="Pause replay">Ⅱ</button>
<button type="button" class="cancel" data-mission-replay-cancel title="Cancel replay" aria-label="Cancel replay"><span aria-hidden="true">×</span></button>
<button type="button" data-mission-replay-toggle title="${t('layers.missions.replay.pause')}" aria-label="${t('layers.missions.replay.pause')}">Ⅱ</button>
<button type="button" class="cancel" data-mission-replay-cancel title="${t('layers.missions.replay.cancel')}" aria-label="${t('layers.missions.replay.cancel')}"><span aria-hidden="true">×</span></button>
</div>`,
);
host.appendChild(layerState._missionPanel);
+2 -1
View File
@@ -1,4 +1,5 @@
import * as Cesium from 'cesium';
import { t } from '../../i18n/index.js';
import {
EARTH_ROTATION_RAD_PER_SEC,
PROJECTED_ASCENT_ROTATION_SEC,
@@ -521,7 +522,7 @@ export function createPaths({ state: layerState, services, parts, source }) {
return {
lat: Cesium.Math.toDegrees(lat),
lon: Cesium.Math.toDegrees(Cesium.Math.negativePiToPi(lon)),
accuracy: 'EST. DOWNRANGE',
accuracy: t('layers.missions.estDownrange'),
};
}
+7 -5
View File
@@ -1,4 +1,5 @@
import * as Cesium from 'cesium';
import { t } from '../../i18n/index.js';
import {
SATELLITE_STANDALONE_DEFAULTS,
LAUNCH_PAD_ZONE_MAX_CAMERA_HEIGHT_M,
@@ -76,14 +77,14 @@ export function createPolicyHelpers({
: 0;
return {
orbit: orbitName
? `${orbitAllowed ? '' : 'PLANNED · '}${orbitName}`
? `${orbitAllowed ? '' : t('layers.missions.rows.plannedPrefix')}${orbitName}`
: null,
ascent:
suppliedTrajectoryPoints > 1
? 'SUPPLIED TRAJECTORY POINTS'
? t('layers.missions.rows.suppliedTrajectory')
: replayAvailable
? 'RECONSTRUCTED ESTIMATE'
: 'UNAVAILABLE',
? t('layers.missions.rows.reconstructedEstimate')
: t('layers.missions.rows.unavailable'),
replayAvailable: Boolean(replayAvailable),
};
}
@@ -196,7 +197,8 @@ export function createPolicyHelpers({
function formatMissionEventTime(launchTime) {
const date = new Date(launchTime);
if (!launchTime || !Number.isFinite(date.getTime())) return 'UNAVAILABLE';
if (!launchTime || !Number.isFinite(date.getTime()))
return t('layers.missions.rows.unavailable');
return `${date.toISOString().slice(0, 10)}\n${date.toISOString().slice(11, 19)} UTC`;
}
+16 -11
View File
@@ -1,4 +1,5 @@
import * as Cesium from 'cesium';
import { t } from '../../i18n/index.js';
import {
REPLAY_ASCENT_FALLBACK_SEC,
REPLAY_ASCENT_MIN_SEC,
@@ -200,10 +201,10 @@ export function createReplay({ state: layerState, services, parts, source }) {
if (speedControl) speedControl.hidden = !replayAvailable;
button.hidden = active || !replayAvailable;
button.disabled = !replayAvailable;
button.textContent = 'REPLAY ASCENT';
button.textContent = t('layers.missions.replay.start');
button.classList.remove('active');
button.setAttribute('aria-pressed', String(active));
button.title = 'Replay the estimated ascent with a following camera';
button.title = t('layers.missions.replay.startTitle');
if (transport) {
transport.hidden = !active;
transport.classList.toggle(
@@ -218,11 +219,13 @@ export function createReplay({ state: layerState, services, parts, source }) {
toggleButton.textContent = layerState._replayPaused ? '▶' : 'Ⅱ';
toggleButton.setAttribute(
'aria-label',
layerState._replayPaused ? 'Resume replay' : 'Pause replay',
layerState._replayPaused
? t('layers.missions.replay.resume')
: t('layers.missions.replay.pause'),
);
toggleButton.title = layerState._replayPaused
? 'Resume replay'
: 'Pause replay';
? t('layers.missions.replay.resume')
: t('layers.missions.replay.pause');
}
}
}
@@ -233,17 +236,19 @@ export function createReplay({ state: layerState, services, parts, source }) {
);
if (!transport || !layerState._replayCameraLaunchId) return;
const phase = state.countdownActive
? `T minus ${state.countdownSeconds}`
? t('layers.missions.replay.phaseCountdown', {
seconds: state.countdownSeconds,
})
: state.preCountdownActive
? 'Preparing launch site'
? t('layers.missions.replay.phasePreparing')
: state.elapsedSinceStart < 1
? 'Liftoff'
? t('layers.missions.replay.phaseLiftoff')
: state.ascending
? 'Ascent replay'
: 'Orbit replay';
? t('layers.missions.replay.phaseAscent')
: t('layers.missions.replay.phaseOrbit');
transport.setAttribute(
'aria-label',
`${phase}${layerState._replayPaused ? ', paused' : ''}`,
`${phase}${layerState._replayPaused ? t('layers.missions.replay.pausedSuffix') : ''}`,
);
}
+2 -1
View File
@@ -1,4 +1,5 @@
import { displayedKinematics } from '../../data/motionModel.js';
import { t } from '../../i18n/index.js';
import * as Cesium from 'cesium';
import { isExplicitLayerStateOrigin } from '../../data/layerState.js';
import { aircraftIncludedInNearby } from '../../data/aircraftNearbyPolicy.js';
@@ -54,7 +55,7 @@ export function createQueries({
*/
function _formatAltitude(altitudeFt) {
if (!Number.isFinite(altitudeFt)) return 'Alt unknown';
if (!Number.isFinite(altitudeFt)) return t('layers.readout.altUnknown');
return `${Math.round(altitudeFt)} ft`;
}
+7 -4
View File
@@ -1,4 +1,5 @@
import * as Cesium from 'cesium';
import { t } from '../../i18n/index.js';
import { nextCockpitNearContacts } from '../../data/cockpitAirLod.js';
import { aircraftIcon, TRACKED_ICON_PX } from '../../data/aircraftIcons.js';
import { trackedModelZoomActive } from '../../data/trackedModelRegime.js';
@@ -219,7 +220,7 @@ export function createTracking({
function _buildTrackedLabel(info, icao24) {
const stale =
flightState.records.missingPolls.get(icao24) || flightState.feed._backoff
? ' · STALE'
? ` · ${t('layers.status.stale')}`
: '';
const callsign =
(parts.queries._toCleanText(info?.callsign) ||
@@ -229,12 +230,14 @@ export function createTracking({
// what the Easter egg replaces the real type with.
const type = tr3bTypeLabel(
icao24,
parts.queries._toCleanText(info?.type) || 'Type unknown',
parts.queries._toCleanText(info?.type) || t('layers.readout.typeUnknown'),
);
const registration =
parts.queries._toCleanText(info?.registration) || 'Reg unknown';
parts.queries._toCleanText(info?.registration) ||
t('layers.readout.regUnknown');
const operator =
parts.queries._toCleanText(info?.operator) || 'Operator unknown';
parts.queries._toCleanText(info?.operator) ||
t('layers.readout.operatorUnknown');
const altitude = parts.queries._formatAltitude(info?.altitudeFt);
const speedKt = info?.speedMps ? Math.round(info.speedMps * 1.944) : null;
const tail = speedKt ? `${altitude} · ${speedKt} kt` : altitude;
+16 -9
View File
@@ -1,4 +1,5 @@
import { MUSIC_GENRES, CATEGORY_MATCHERS } from './policy.js';
import { t } from '../../i18n/index.js';
export function createCategories({
state: layerState,
@@ -69,14 +70,20 @@ export function createCategories({
function buildRadioCategories(stations) {
const rows = Array.isArray(stations) ? stations : [];
const categories = [
{ id: 'all', label: 'All' },
{ id: 'news', label: 'News' },
{ id: 'talk', label: 'Talk' },
{ id: 'weather', label: 'Weather / Emergency' },
{ id: 'public-safety', label: 'Public Safety' },
{ id: 'aviation-marine', label: 'Aviation / Marine' },
{ id: 'traffic-transit', label: 'Traffic / Transit' },
{ id: 'music', label: 'Music' },
{ id: 'all', label: t('layers.radio.filterAll') },
{ id: 'news', label: t('layers.radio.category.news') },
{ id: 'talk', label: t('layers.radio.category.talk') },
{ id: 'weather', label: t('layers.radio.category.weather') },
{ id: 'public-safety', label: t('layers.radio.category.publicSafety') },
{
id: 'aviation-marine',
label: t('layers.radio.category.aviationMarine'),
},
{
id: 'traffic-transit',
label: t('layers.radio.category.trafficTransit'),
},
{ id: 'music', label: t('layers.radio.category.music') },
];
for (const [genre, label] of MUSIC_GENRES) {
@@ -85,7 +92,7 @@ export function createCategories({
categories.push({ id, label });
}
}
categories.push({ id: 'other', label: 'Other' });
categories.push({ id: 'other', label: t('layers.radio.category.other') });
return categories.map((category) => ({
...category,
color: parts.model.radioCategoryColor(category.id),
+12 -8
View File
@@ -4,6 +4,7 @@ import {
satelliteClassLegend,
} from '../../data/satelliteClass.js';
import * as Cesium from 'cesium';
import { t } from '../../i18n/index.js';
import { ISS_NORAD, POINT_STYLES } from './policy.js';
export function createControls({ state: layerState, services, parts, source }) {
@@ -493,19 +494,22 @@ export function createControls({ state: layerState, services, parts, source }) {
const active =
layerState._params.catalog === 'dense' &&
layerState._denseStatus === 'ready';
let title =
'Add the full Starlink broadband shell (thousands of extra points)';
if (loading) title = 'Loading the Starlink shell…';
let title = t('layers.satellites.denseTitleAdd');
if (loading) title = t('layers.satellites.denseTitleLoading');
else if (failed)
title = `Starlink ${layerState._denseError || 'load failed'} — click to retry`;
else if (active)
title =
'Showing the full Starlink shell — click for the core catalog only';
title = t('layers.satellites.denseTitleFailed', {
detail: layerState._denseError || t('layers.satellites.loadFailed'),
});
else if (active) title = t('layers.satellites.denseTitleActive');
return {
chips: [
{
id: 'catalog',
label: loading ? 'DENSE ···' : failed ? 'DENSE ✕' : 'DENSE',
label: loading
? t('layers.satellites.denseChipLoading')
: failed
? t('layers.satellites.denseChipFailed')
: t('layers.satellites.denseChip'),
active,
busy: loading,
disabled: loading,
+2 -1
View File
@@ -1,3 +1,4 @@
import { t } from '../../i18n/index.js';
import {
featureLabel,
featureReference,
@@ -11,7 +12,7 @@ export function createIngestion({ state, parts, source }) {
state._loading = true;
state._error = null;
state._loadingLabel = 'loading...';
state._loadingLabel = t('layers.meta.loading');
// Ownership token (the militaryAwareness activationId pattern): a
// disable/abort followed by a fresh enable starts a NEWER load while this
// one is still settling. The stale load must bail after every await and
+29 -10
View File
@@ -1,5 +1,6 @@
import * as Cesium from 'cesium';
import { roadSurfaceChunks } from './surface.js';
import { t } from '../../i18n/index.js';
import {
flowDensityMult,
flowBucket,
@@ -260,10 +261,14 @@ export function createModel({ state: layerState, services, parts, source }) {
mode,
error: null,
loadingLabel: fetching
? 'Syncing flow · Roads: TomTom'
? t('layers.traffic.syncingRoads', { source: roadSource })
: coveragePct > 0
? 'LIVE · Roads: TomTom · Roads without flow hidden'
: 'LIVE · Roads: TomTom · No flow roads in view',
? t('layers.traffic.liveRoadsFlowHidden', {
source: roadSource,
})
: t('layers.traffic.liveRoadsNoFlowInView', {
source: roadSource,
}),
};
}
if (liveMode && roadSource === 'TomTom + OpenStreetMap') {
@@ -271,8 +276,16 @@ export function createModel({ state: layerState, services, parts, source }) {
mode,
error: null,
loadingLabel: fetching
? `Syncing flow · Roads: ${roadSource}`
: `${coveragePct > 0 ? 'LIVE' : 'SIMULATED'} · Roads: ${roadSource} · Flow ${coveragePct}%`,
? t('layers.traffic.syncingRoads', { source: roadSource })
: coveragePct > 0
? t('layers.traffic.roadsFlowLive', {
source: roadSource,
percent: coveragePct,
})
: t('layers.traffic.roadsFlowSimulated', {
source: roadSource,
percent: coveragePct,
}),
};
}
if (liveMode) {
@@ -280,10 +293,16 @@ export function createModel({ state: layerState, services, parts, source }) {
mode,
error: null,
loadingLabel: fetching
? 'Syncing flow · Roads: OpenStreetMap · Flow: TomTom · Unmatched: simulated'
? t('layers.traffic.loadingSyncing')
: coveragePct > 0
? `LIVE · Roads: OpenStreetMap · Flow: TomTom · ${coveragePct}% cov${coveragePct < 100 ? ' · Unmatched: simulated' : ''}`
: 'SIMULATED · Roads: OpenStreetMap · Flow: TomTom (no matches)',
? coveragePct < 100
? t('layers.traffic.liveCoverageUnmatched', {
percent: coveragePct,
})
: t('layers.traffic.liveCoverage', {
percent: coveragePct,
})
: t('layers.traffic.simulatedNoMatches'),
};
}
// Keyless simulation — one terse line that names the mode and the remedy
@@ -293,8 +312,8 @@ export function createModel({ state: layerState, services, parts, source }) {
mode,
error: null,
loadingLabel: statusUnavailable
? 'SIMULATED — traffic service unreachable'
: 'SIMULATED — add TomTom key for live',
? t('layers.traffic.simUnavailable')
: t('layers.traffic.simKeyless'),
};
}
+15 -10
View File
@@ -2,6 +2,7 @@ import {
accentForVesselType,
normalizeVesselType,
} from '../../data/vesselLabels.js';
import { t } from '../../i18n/index.js';
export function createCards({
vesselState,
@@ -20,9 +21,13 @@ export function createCards({
const stale = (record.missedRefreshes || 0) > 0;
el.classList.add('active');
el.textContent = [
`AIS: ${trimHudValue(record.name, 32)}`,
`${trimHudValue(record.type || 'VESSEL', 24)} SPD: ${formatSpeed(record.speed)} HDG: ${formatHeading(record.heading ?? record.course)}`,
`MMSI: ${record.mmsi || '--'} ${formatPositionTime(record)}${stale ? ' · STALE' : ''}`,
t('layers.vessel.hudName', { name: trimHudValue(record.name, 32) }),
t('layers.vessel.hudTypeLine', {
type: trimHudValue(record.type || t('layers.vessel.fallback'), 24),
speed: formatSpeed(record.speed),
heading: formatHeading(record.heading ?? record.course),
}),
`MMSI: ${record.mmsi || '--'} ${formatPositionTime(record)}${stale ? ` · ${t('layers.status.stale')}` : ''}`,
].join('\n');
}
@@ -30,7 +35,7 @@ export function createCards({
const el = document.getElementById('hud-ais-vessel');
if (!el) return;
el.classList.remove('active');
el.textContent = 'AIS: --';
el.textContent = t('layers.vessel.hudIdle');
}
function trimHudValue(value, maxLength) {
@@ -85,7 +90,7 @@ export function createCards({
const direction = record.heading ?? record.course;
const details = [
[
vesselTypeShort(record) || 'VESSEL',
vesselTypeShort(record) || t('layers.vessel.fallback'),
formatSpeed(record.speed),
Number.isFinite(direction) ? `${Math.round(direction)}°` : '--°',
].join(' · '),
@@ -94,7 +99,7 @@ export function createCards({
if (destination) details.push(`→ ${trimHudValue(destination, 24)}`);
const stale = (record.missedRefreshes || 0) > 0;
details.push(
`MMSI ${record.mmsi || '--'} · ${formatPositionTime(record)}${stale ? ' · STALE' : ''}`,
`MMSI ${record.mmsi || '--'} · ${formatPositionTime(record)}${stale ? ` · ${t('layers.status.stale')}` : ''}`,
);
return {
id: vesselOverlayEntryId(record),
@@ -151,7 +156,7 @@ export function createCards({
function displayVesselName(record) {
const name = String(record.name || '').trim();
if (name && name !== 'VESSEL' && name !== record.mmsi) return name;
return record.mmsi ? `MMSI ${record.mmsi}` : 'VESSEL';
return record.mmsi ? `MMSI ${record.mmsi}` : t('layers.vessel.fallback');
}
function formatSpeed(speed) {
@@ -163,10 +168,10 @@ export function createCards({
}
function formatPositionTime(record) {
if (!record.lastPositionUtc) return 'POS: LIVE';
if (!record.lastPositionUtc) return t('layers.vessel.posLive');
const date = new Date(record.lastPositionUtc);
if (Number.isNaN(date.getTime())) return 'POS: LIVE';
return `POS: ${date.toISOString().slice(11, 19)}Z`;
if (Number.isNaN(date.getTime())) return t('layers.vessel.posLive');
return t('layers.vessel.posAt', { time: date.toISOString().slice(11, 19) });
}
return {
updateSelectedVesselHud,
+5 -8
View File
@@ -1,9 +1,6 @@
import { VESSEL_OVERLAY_SOURCE_ID } from '../../data/vesselLabels.js';
import {
AIS_FIRST_CONNECT_GRACE_MS,
AIS_FIRST_CONNECT_LABEL,
AIS_HEALTHY_STATUSES,
} from './policy.js';
import { t } from '../../i18n/index.js';
import { AIS_FIRST_CONNECT_GRACE_MS, AIS_HEALTHY_STATUSES } from './policy.js';
export function createLifecycle({
vesselState,
@@ -41,7 +38,7 @@ export function createLifecycle({
state.feed.firstConnectStartedAt = startedAt;
state.feed.firstConnectDeadline = startedAt + AIS_FIRST_CONNECT_GRACE_MS;
state.feed.error = null;
state.feed.loadingLabel = AIS_FIRST_CONNECT_LABEL;
state.feed.loadingLabel = t('layers.vessel.awaitingFirstPosition');
scheduleFirstConnectExpiry(sessionId, AIS_FIRST_CONNECT_GRACE_MS);
}
@@ -63,8 +60,8 @@ export function createLifecycle({
state.feed.firstConnectPhase = 'unavailable';
state.feed.loadingLabel = '';
state.feed.error = state.feed.lastMessageAt
? 'awaiting usable AIS positions…'
: 'awaiting first AIS message…';
? t('layers.vessel.awaitingPositions')
: t('layers.vessel.awaitingFirstMessage');
state.feed.stale = state.feed.count > 0;
}, delayMs);
}
+22 -6
View File
@@ -1,11 +1,27 @@
import * as Cesium from 'cesium';
import { t } from '../../i18n/index.js';
// The vessels ingestion owner is a portable module graph (it also runs in the
// server-side source builds), so it writes stable English feed labels into
// state. Localize those known labels here, at the presentation read; anything
// else (a bespoke manager label) passes through verbatim.
const FEED_STATE_LABEL_KEYS = Object.freeze({
'refreshing...': 'layers.meta.refreshing',
'loading...': 'layers.meta.loading',
'awaiting first AIS position…': 'layers.vessel.awaitingFirstPosition',
});
function localizeFeedLabel(raw) {
if (typeof raw !== 'string' || !raw.trim()) return raw;
const key = FEED_STATE_LABEL_KEYS[raw.trim()];
return key ? t(key) : raw;
}
import {
AIS_DEGRADED_STATUSES,
AIS_HEALTHY_STATUSES,
AIS_STATUS_REASON,
REFRESH_MS,
FOCUS_EVIDENCE_DEV,
AIS_FIRST_CONNECT_LABEL,
} from './policy.js';
export function createQueries({
@@ -82,8 +98,8 @@ export function createQueries({
if (acceptedRowCount > 0) return null; // accepted rows may be stale while reconnecting, but remain usable
if (AIS_HEALTHY_STATUSES.has(status)) {
return payload?.lastMessageAt
? 'awaiting usable AIS positions…'
: 'awaiting first AIS message…';
? t('layers.vessel.awaitingPositions')
: t('layers.vessel.awaitingFirstMessage');
}
if (!status) return null;
const detail =
@@ -132,7 +148,7 @@ export function createQueries({
acceptedRowCount,
error:
deriveAisFeedError(payload, acceptedRowCount) ||
(acceptedRowCount === 0 ? 'awaiting usable AIS positions…' : null),
(acceptedRowCount === 0 ? t('layers.vessel.awaitingPositions') : null),
};
}
@@ -504,8 +520,8 @@ export function createQueries({
lastUpdate: state.feed.lastUpdate,
loading: state.feed.loading || waitingForFirstPosition,
loadingLabel: waitingForFirstPosition
? AIS_FIRST_CONNECT_LABEL
: state.feed.loadingLabel,
? t('layers.vessel.awaitingFirstPosition')
: localizeFeedLabel(state.feed.loadingLabel),
error: state.feed.error,
stale: state.feed.stale,
partial: state.feed.partial,