refactor(i18n): key messages by English text; add Russian

Replace the semantic keys with the English source text, so code reads as the
UI does and English needs no catalog: t('Save keys'), tn(count, one, other)
for plurals, msg() for text stored in data, and bare data-i18n* markup that
translates the element's own English. Other languages are one flat JSON each.

- Simplify src/i18n: drop namespaces, the configurable locale pair
  (GEV_DEFAULT_LOCALE / GEV_SECONDARY_LOCALE) and its build defines. Locale
  comes from ?lang=, the stored choice, then browser languages.
- Add scripts/i18n.mjs (npm run i18n): extracts every message from t/tn/msg
  and markup, and fails on missing, unused, placeholder-mismatched or
  wrongly-shaped plural entries. npm test runs it via catalog.test.mjs.
- Add Russian (src/i18n/locales/ru.json, 833 messages), started from the
  community catalog in #312 and reviewed against the current English.
- Fix data-i18n placed on parents that would wipe their children (cockpit
  LEVEL and local weather cells, first-run EXPLORE MANUALLY tile), and a
  doubled degree sign in the TRACK ACQUIRED signal.
- Rewrite docs/TRANSLATORS.md for the new workflow; drop sections the earlier
  merge resurrected in docs/CURRENT-STATE.md.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
German KhokhlovandClaude Opus 5.5 committed 2026-10-12 13:48:56 +04:00
1 parent dfbaeafbd8
commit 13efd411b6
112 files changed
+4419 -4856

No files matched your search

+10 -11
View File
@@ -57,8 +57,8 @@ export function createPanel({ state: layerState, services, parts, source }) {
function hidePanel() {
if (layerState.panel) {
const markup = `<div class="military-awareness-standby">
<strong>${layerState.enabled ? t('layers.awareness.standbyReady') : t('layers.awareness.standbyOff')}</strong>
<span>${layerState.enabled ? t('layers.awareness.standbySelect') : t('layers.awareness.standbyEnable')}</span>
<strong>${layerState.enabled ? t('CONTEXT READY') : t('GLOBAL CONTEXT OFF')}</strong>
<span>${layerState.enabled ? t('SELECT A FLIGHT, VESSEL, OR MAPPED INSTALLATION') : t('ENABLE TO LOAD OBSERVED / MAPPED PROXIMITY')}</span>
</div>`;
layerState.panel.hidden = false;
if (layerState.panelMarkup !== markup) {
@@ -79,11 +79,10 @@ 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="${t('layers.awareness.unavailableAria')}">${escapeHtml(label)} <span>${formatAwarenessDistance(item.distanceM)}</span></span></li>`;
return `<li><span class="military-awareness-target unavailable" aria-label="${t('Unavailable')}">${escapeHtml(label)} <span>${formatAwarenessDistance(item.distanceM)}</span></span></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>`;
const accessibleLabel = label === '—' ? t('Unavailable') : 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('Focus {label}', { label: accessibleLabel }))}">${escapeHtml(label)} <span>${formatAwarenessDistance(item.distanceM)}</span></button>${namedAreasHtml(item.memberNames)}</li>`;
})
.join('');
const pageCount = Math.max(
@@ -104,10 +103,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="${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>
return `<div class="military-awareness-controls" role="group" aria-label="${t('Global Context navigation')}">
<button type="button" data-awareness-action="previous" title="${t('Previous — prior visited contact in the 250 km window')}"${canPrevious ? '' : ' disabled'}>${t('PREVIOUS')}</button>
<button type="button" data-awareness-action="focus">${t('FOCUS')}</button>
<button type="button" data-awareness-action="next" title="${t('Next — nearest unvisited contact in the 250 km window')}"${parts.navigation.canNavigateNext() ? '' : ' disabled'}>${t('NEXT')}</button>
</div>`;
}
@@ -189,7 +188,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>${t('layers.awareness.note')}</p>`;
<p class="military-awareness-note" tabindex="-1" data-awareness-focus-continuation>${t('Open-source mapped/observed context. Missing broadcasts, unloaded map areas, or unmapped sites are not evidence of absence.')}</p>`;
panel.hidden = false;
if (layerState.panelMarkup !== markup) {
const focusSnapshot = captureAwarenessPanelFocus(panel);
+4 -4
View File
@@ -138,7 +138,7 @@ export function createRendering({
'--compass-rotation',
`${-headingDeg}deg`,
);
layerState.compassHeading.textContent = t('layers.awareness.hdg', {
layerState.compassHeading.textContent = t('HDG {value}°', {
value: String(headingDeg).padStart(3, '0'),
});
layerState.compassHeading.style.left = `${geometry.centerX}px`;
@@ -224,12 +224,12 @@ export function createRendering({
)
: null;
const bearingText = Number.isFinite(bearing)
? t('layers.awareness.brg', {
? t('BRG {value}°', {
value: String(Math.round(bearing)).padStart(3, '0'),
})
: t('layers.awareness.brgUnknown');
: t('BRG —');
const courseText = Number.isFinite(item.track)
? ` · ${t('layers.awareness.crs', {
? ` · ${t('CRS {value}°', {
value: String(Math.round(item.track)).padStart(3, '0'),
})}`
: '';
+7 -8
View File
@@ -19,10 +19,9 @@ 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');
if (label === 'CURRENT VIEWPORT ONLY') return t('CURRENT VIEWPORT ONLY');
const within = WITHIN_KM_RE.exec(label);
if (within) return t('layers.awareness.coverage.within', { km: within[1] });
if (within) return t('WITHIN {km} KM', { km: within[1] });
return label;
}
@@ -94,7 +93,7 @@ export function createSubject({ state: layerState, services, parts, source }) {
cohorts: [
{
id: 'flights',
label: t('layers.awareness.cohort.flights'),
label: t('Flights'),
source: flightsState.stats.source || SOURCE_LABEL.flights,
provenance: retainedProvenance(
'flights',
@@ -113,7 +112,7 @@ export function createSubject({ state: layerState, services, parts, source }) {
},
{
id: 'military',
label: t('layers.awareness.cohort.military'),
label: t('Military flights'),
source: militaryState.stats.source || SOURCE_LABEL.military,
provenance: retainedProvenance(
'military',
@@ -132,7 +131,7 @@ export function createSubject({ state: layerState, services, parts, source }) {
},
{
id: 'ais-live-vessels',
label: t('layers.awareness.cohort.vessels'),
label: t('AIS vessels'),
source: vesselsState.stats.source || SOURCE_LABEL['ais-live-vessels'],
provenance: retainedProvenance(
'ais-live-vessels',
@@ -147,7 +146,7 @@ export function createSubject({ state: layerState, services, parts, source }) {
},
{
id: 'military-installations',
label: t('layers.awareness.cohort.installations'),
label: t('Mapped installations'),
source:
installationsState.stats.source ||
SOURCE_LABEL['military-installations'],
@@ -159,7 +158,7 @@ export function createSubject({ state: layerState, services, parts, source }) {
),
coverage:
localizeCoverageLabel(installationsState.stats.coverageLabel) ||
t('layers.awareness.coverage.viewport'),
t('CURRENT VIEWPORT ONLY'),
summary: parts.queries.summarizeInstallationViewport(
installations,
installationsState,
+2 -2
View File
@@ -68,10 +68,10 @@ export function createControls({ state: layerState, services, parts, source }) {
if (layerState._loading) {
stats.loadingLabel =
layerState._activeCityIds.size > 0
? t('layers.bike.loadingSyncing', {
? t('syncing {count} city feeds...', {
count: layerState._activeCityIds.size,
})
: t('layers.bike.loadingScanning');
: t('scanning nearby systems...');
}
if (layerState._error) stats.error = layerState._error;
return stats;
+10 -9
View File
@@ -24,8 +24,8 @@ export function createSelection({
const stationLabel =
stationName ||
(record?.stationId
? t('layers.bike.stationWithId', { id: record.stationId })
: t('layers.bike.stationFallback'));
? t('Station {id}', { id: record.stationId })
: t('Station'));
const bikes = Number.isFinite(record?.bikesAvailable)
? record.bikesAvailable
: '?';
@@ -36,17 +36,18 @@ export function createSelection({
const lines = [
stationLabel,
t('layers.bike.card.capacity', { bikes, docks, capacity }),
t('🚲 {bikes} avail · {docks} docks · {capacity} cap', {
bikes,
docks,
capacity,
}),
];
// Append warnings for stations that are offline or partially non-operational
const abnormal = [];
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 (record?.isInstalled === false) abnormal.push(t('⚠️ Not installed'));
if (record?.isRenting === false) abnormal.push(t('⚠️ Not renting'));
if (record?.isReturning === false) abnormal.push(t('⚠️ Not returning'));
if (abnormal.length > 0) {
lines.push(abnormal.join(' · '));
}
+2 -2
View File
@@ -48,6 +48,6 @@ export function headingHudToken(camera) {
// 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 });
? t('HDG {value}° (ESTIMATED)', { value: hdg })
: t('HDG {value}°', { value: hdg });
}
+18 -19
View File
@@ -20,10 +20,13 @@ export function createPresentation({
const active = parts.selection.getActiveRecord();
if (!active) {
return layerState._records.length
? t('layers.cctv.summary.standingBy', {
count: layerState._records.length,
})
: t('layers.cctv.summary.empty');
? t(
'{count} CAMERAS STANDING BY · NO CAMERA SELECTED · CLICK A CAMERA TO ACTIVATE',
{
count: layerState._records.length,
},
)
: t('No cameras available in catalog.');
}
const area = parts.model.sectorAreaKm2(
@@ -37,37 +40,33 @@ export function createPresentation({
const calBadge = parts.calibration.deriveCalBadge(active.camera);
return [
t('layers.cctv.summary.city', {
t('{city} CCTV', {
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),
t('layers.cctv.summary.fov', {
t('FOV {value}°', {
value: Math.round(active.camera.fovDeg),
}),
t('layers.cctv.summary.coverage', { value: area.toFixed(2) }),
t('COVERAGE {value}km²', { 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', {
? t('OVERLAP {count} cams', { count: overlapCount })
: t('ISOLATED VIEW'),
layerState._showProjection ? t('PROJ MONITOR') : t('PROJ OFF'),
layerState._coverageMode === 'viewshed' ? t('VIEWSHED') : null,
t('CAL {value}', {
value: calBadge.replace('-', ' ').toUpperCase(),
}),
health?.sourceKind
? t('layers.cctv.summary.src', {
? t('SRC {value}', {
value: String(health.sourceKind).toUpperCase(),
})
: t('layers.cctv.summary.src', {
: t('SRC {value}', {
value: String(active.camera.feedType || 'image').toUpperCase(),
}),
t('layers.cctv.summary.context', { value: viewBand.toUpperCase() }),
t('{value} CONTEXT', { value: viewBand.toUpperCase() }),
]
.filter(Boolean)
.join(' · ');
+1 -1
View File
@@ -116,7 +116,7 @@ export function createCards({
if (!card.interactive) return card;
return {
...card,
accessibilityLabel: t('layers.firms.card.focusAria', {
accessibilityLabel: t('Focus fire detection {title}, {details}', {
title: card.title,
details: card.details.join(', '),
}),
+13 -21
View File
@@ -331,19 +331,17 @@ export function createModel({
function buildSelectedFireCard(fire, nowMs) {
const meta = [
t('layers.firms.card.confSuffix', {
t('{value} conf', {
value: confidenceBucket(fire.confidence),
}),
];
if (fire.acqMs > 0) {
const age = formatAge(nowMs - fire.acqMs);
if (age) meta.push(t('layers.firms.card.ageSuffix', { value: age }));
if (age) meta.push(t('{value} ago', { value: age }));
}
const sat = satelliteShortName(fire.satellite);
meta.push(
sat
? `${fire.sensor || 'VIIRS'} ${sat}`
: fire.sensor || t('layers.firms.card.sensorUnavailable'),
sat ? `${fire.sensor || 'VIIRS'} ${sat}` : fire.sensor || t('sensor n/a'),
);
return {
id: `selected-fire:${fireDetectionKey(fire)}`,
@@ -353,11 +351,11 @@ export function createModel({
cullPosition: fireCullPosition(fire),
gapPx: frpPixelSize(fire.frp),
accent: accentForSeverity(detectionColorStop(fire).name),
title: t('layers.firms.card.fire', { frp: formatFrp(fire.frp) }),
title: t('FIRE · {frp} MW', { frp: formatFrp(fire.frp) }),
details: [
meta.join(' · '),
formatLatLon(fire.lat, fire.lon) +
(fire.night ? ` · ${t('layers.firms.card.night')}` : ''),
(fire.night ? ` · ${t('NIGHT')}` : ''),
],
selected: true,
priority: Number.MAX_SAFE_INTEGER,
@@ -390,7 +388,7 @@ export function createModel({
cullPosition: candidate.cullPosition || candidate.position,
gapPx: frpPixelSize(fire.frp),
accent: accentForSeverity(detectionColorStop(fire).name),
title: t('layers.firms.card.ambient', { frp: formatFrp(fire.frp) }),
title: t('▲ {frp} MW', { frp: formatFrp(fire.frp) }),
details: [meta.join(' · ')],
selected: false,
priority: Number(fire.frp) || 0,
@@ -408,16 +406,11 @@ export function createModel({
function buildCellCard(candidate, nowMs) {
const cell = candidate.cell;
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) }),
];
const noun = cell.count === 1 ? t('FIRE') : t('FIRES');
const parts = [t('max {value} MW', { value: formatFrp(cell.maxFrp) })];
if (cell.newestAcqMs > 0) {
const age = formatAge(nowMs - cell.newestAcqMs);
if (age) parts.push(t('layers.firms.card.newAge', { value: age }));
if (age) parts.push(t('new {value}', { value: age }));
}
return {
id: `cell:${cell.latCell ?? 'x'}:${cell.lonCell ?? 'x'}`,
@@ -503,12 +496,11 @@ export function createModel({
/** Millisecond delta → "<1m ago" / "Xm ago" / "Xh ago" (fresh-feed readout). */
function formatAgoMinutes(deltaMs) {
if (!Number.isFinite(deltaMs) || deltaMs < 0)
return t('layers.meta.justNow');
if (!Number.isFinite(deltaMs) || deltaMs < 0) return t('just now');
const minutes = Math.floor(deltaMs / 60000);
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) });
if (minutes < 1) return t('<1m ago');
if (minutes < 90) return t('{count}m ago', { count: minutes });
return t('{count}h ago', { count: Math.round(minutes / 60) });
}
/** Normalized 0..1 confidence → low/nominal/high display bucket. */
+7 -7
View File
@@ -60,24 +60,24 @@ export function createQueries({
getStats() {
const now = Date.now();
const staleText = layerState._lastUpdate
? t('layers.firms.staleCached', {
? t('STALE · cached {age}', {
age:
components.model.formatAge(now - layerState._lastUpdate) || '<1h',
})
: t('layers.status.stale');
: t('STALE');
let loadingLabel = '';
if (layerState._loading) {
loadingLabel = layerState._fires.length
? t('layers.meta.refreshing')
: t('layers.meta.loading');
? t('refreshing...')
: t('loading...');
} else if (layerState._keyRequired) {
loadingLabel = t('layers.firms.keyRequired');
loadingLabel = t('KEY REQUIRED');
} else if (layerState._stale) {
loadingLabel = staleText;
} else if (layerState._error) {
loadingLabel = layerState._error;
} else if (layerState._lastUpdate) {
loadingLabel = t('layers.firms.liveUpdated', {
loadingLabel = t('LIVE · updated {age}', {
age: components.model.formatAgoMinutes(now - layerState._lastUpdate),
});
}
@@ -93,7 +93,7 @@ export function createQueries({
// the operator can take.
keyRequired: layerState._keyRequired,
error: layerState._keyRequired
? t('layers.firms.keyRequired')
? t('KEY REQUIRED')
: layerState._stale
? staleText
: layerState._error,
+1 -1
View File
@@ -756,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
? t('layers.status.stale')
? t('STALE')
: '';
const lines = [[cs, fl, spd, stale].filter(Boolean).join(' · ')];
// Converted contacts report their class as TR-3B and nothing else — the
+18 -17
View File
@@ -5,7 +5,7 @@ import {
DISTANCE_PREFILTER_MARGIN_M,
ANCHOR_REFRESH_M,
} from './policy.js';
import { t } from '../../i18n/index.js';
import { t, msg } 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
@@ -13,19 +13,20 @@ import { t } from '../../i18n/index.js';
// 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',
'Retrying mapped sites…': msg('Retrying mapped sites…'),
'Fetching mapped sites…': msg('Fetching mapped sites…'),
'Zoom in to search mapped installations': msg(
'Zoom in to search mapped installations',
),
'Showing cached mapped sites': msg('Showing cached mapped sites'),
'Mapped sites not loaded': msg('Mapped sites not loaded'),
'Mapped sites loaded': msg('Mapped sites loaded'),
'Overpass rate-limited': msg('Overpass rate-limited'),
'Overpass timed out': msg('Overpass timed out'),
'Overpass could not complete the query': msg(
'Overpass could not complete the query',
),
'Overpass temporarily unavailable': msg('Overpass temporarily unavailable'),
});
const INSTALLATION_RETRY_IN_RE = /^(.*) — retrying in (\d+)s$/;
@@ -38,7 +39,7 @@ function localizeInstallationFeedback(message) {
const retryIn = message.match(INSTALLATION_RETRY_IN_RE);
if (retryIn) {
const reasonKey = INSTALLATION_FEEDBACK_KEYS[retryIn[1]];
return t('layers.installations.feedback.retryIn', {
return t('{reason} — retrying in {seconds}s', {
reason: reasonKey ? t(reasonKey) : retryIn[1],
seconds: retryIn[2],
});
@@ -46,7 +47,7 @@ function localizeInstallationFeedback(message) {
const pending = message.match(INSTALLATION_RETRY_PENDING_RE);
if (pending) {
const reasonKey = INSTALLATION_FEEDBACK_KEYS[pending[1]];
return t('layers.installations.feedback.retryPending', {
return t('{reason} — retry pending', {
reason: reasonKey ? t(reasonKey) : pending[1],
});
}
@@ -280,7 +281,7 @@ export function createControls({ state: layerState, services, parts, source }) {
}),
),
loadingLabel: layerState.loading
? t('layers.installations.loading')
? t('loading mapped installation context')
: '',
coverage: layerState.coverage,
coverageLabel:
+1 -1
View File
@@ -271,7 +271,7 @@ export function createRendering({
entity.gevTrackedId = `installations:${record.id}`;
entity.gevDisplayPosition = () => displayPosition;
entity.gevLabelModel = {
title: record.name || t('layers.installations.fallbackTitle'),
title: record.name || t('MAPPED INSTALLATION'),
details: [
String(record.class || 'installation')
.replaceAll('_', ' ')
+13 -14
View File
@@ -1,5 +1,5 @@
import * as Cesium from 'cesium';
import { t } from '../../i18n/index.js';
import { t, msg } from '../../i18n/index.js';
import {
createCyberSonarSampler,
isCyberContactSonarActive as isCyberSonarActive,
@@ -120,10 +120,10 @@ export function createOverlays({ state: layerState, services, parts, source }) {
const details = selected
? [
siteName
? t('layers.missions.launchSiteValue', {
? t('LAUNCH SITE · {site}', {
site: shortMissionLabel(siteName, 20).toUpperCase(),
})
: t('layers.missions.launchSite'),
: t('LAUNCH SITE'),
]
: [];
return {
@@ -589,38 +589,37 @@ export function createOverlays({ state: layerState, services, parts, source }) {
const mission = shortMissionLabel(launch.name, 22).toUpperCase();
const siteName = compactLaunchSiteName(launch.launchSite);
const siteCallout = siteName
? t('layers.missions.launchSiteValue', {
? t('LAUNCH SITE · {site}', {
site: shortMissionLabel(siteName, 20).toUpperCase(),
})
: t('layers.missions.launchSite');
: t('LAUNCH SITE');
let title = mission;
let detail = siteCallout;
if (mode === 'countdown') {
title = t('layers.missions.replay.countdown', {
title = t('T−{time} · {mission}', {
time: String(state.countdownSeconds).padStart(2, '0'),
mission,
});
detail = `${t('layers.missions.replay.standbyDetail')}\n${siteCallout}`;
detail = `${t('LAUNCH STANDBY')}\n${siteCallout}`;
} else if (mode === 'ascent') {
title =
state.elapsedSinceStart < 1
? t('layers.missions.replay.liftoff', { mission })
? t('LIFTOFF · {mission}', { mission })
: t(
launch.trajectory.length > 1
? 'layers.missions.replay.ascentReplay'
: 'layers.missions.replay.ascentEstimate',
? msg('ASCENT REPLAY · {mission}')
: msg('ASCENT ESTIMATE · {mission}'),
{ mission },
);
detail = parts.policyHelpers.formatMissionEventTime(state.eventTime);
} else if (mode === 'recovery') {
title = t('layers.missions.replay.recovery', { mission });
title = t('STAGE RE-ENTRY / RECOVERY · {mission}', { mission });
detail = parts.policyHelpers.formatMissionEventTime(state.eventTime);
} else if (mode === 'orbit') {
title = t('layers.missions.replay.orbit', { mission });
title = t('ORBIT REPLAY · {mission}', { mission });
detail = parts.policyHelpers.formatMissionEventTime(state.eventTime);
}
if (layerState._replayPaused)
title = t('layers.missions.replay.pausedPrefix', { title });
if (layerState._replayPaused) title = t('PAUSED · {title}', { title });
const nextText = `${title}\n${detail}`;
if (nextText !== layerState._replayVehicleOverlayText) {
layerState._replayVehicleOverlayText = nextText;
+20 -23
View File
@@ -220,12 +220,12 @@ 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 || t('layers.missions.rows.unspecified'))}</td><td>${escapeMissionText(payload.destination || launch.orbit?.name || t('layers.missions.rows.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('UNSPECIFIED'))}</td><td>${escapeMissionText(payload.destination || launch.orbit?.name || t('UNAVAILABLE'))}</td></tr>`;
})
: [];
if (launch.payloads.length > 5) {
payloadRows.push(
`<tr><td colspan="3" class="mission-table-empty">${t('layers.missions.rows.additionalPayloads', { count: launch.payloads.length - 5 })}</td></tr>`,
`<tr><td colspan="3" class="mission-table-empty">${t('+{count} additional payload records', { count: launch.payloads.length - 5 })}</td></tr>`,
);
}
layerState._missionPanel.querySelector(
@@ -233,7 +233,7 @@ export function createPanel({ state: layerState, services, parts, source }) {
).innerHTML = missionTableRows(
payloadRows,
3,
t('layers.missions.rows.classified'),
t('PAYLOAD DATA UNAVAILABLE'),
);
const stageRows = launch.recoveryStages.map((stage) => {
const endpoint = stage.endpoint;
@@ -241,21 +241,21 @@ export function createPanel({ state: layerState, services, parts, source }) {
stage.destination ||
(endpoint?.accuracy === 'PAD / RTLS'
? launch.launchSite
: t('layers.missions.rows.unavailable'));
: t('UNAVAILABLE'));
const position = endpoint
? `${endpoint.lat.toFixed(2)}, ${endpoint.lon.toFixed(2)} · ${endpoint.accuracy}`
: stage.downrangeKm > 0
? t('layers.missions.rows.kmDownrange', {
? t('{value} KM DOWNRANGE', {
value: stage.downrangeKm.toLocaleString(),
})
: t('layers.missions.rows.positionUnavailable');
: t('POSITION UNAVAILABLE');
const stageDetail = [
Number.isFinite(stage.flightNumber)
? t('layers.missions.rows.flightNumber', {
? t('FLIGHT {count}', {
count: stage.flightNumber,
})
: null,
stage.reused ? t('layers.missions.rows.reused') : null,
stage.reused ? t('REUSED') : null,
stage.recoveryType,
]
.filter(Boolean)
@@ -263,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, t('layers.missions.rows.noStageData'));
missionTableRows(stageRows, 3, t('NO STAGE RE-ENTRY / RECOVERY DATA'));
const stageSection = layerState._missionPanel.querySelector(
'[data-mission-stages-section]',
);
@@ -291,7 +291,7 @@ export function createPanel({ state: layerState, services, parts, source }) {
'[data-mission-roster-count]',
);
if (count)
count.textContent = t('layers.missions.roster.count', {
count.textContent = t('{count} / 30D', {
count: layerState._launches.length,
});
if (!list) return;
@@ -301,7 +301,7 @@ export function createPanel({ state: layerState, services, parts, source }) {
layerState._launches,
);
if (!entries.length) {
list.innerHTML = `<div class="space-mission-roster-empty">${t('layers.missions.roster.empty')}</div>`;
list.innerHTML = `<div class="space-mission-roster-empty">${t('NO MISSIONS AVAILABLE IN THE CURRENT 30-DAY WINDOW')}</div>`;
restoreMissionRosterFocus(
list,
focusSnapshot,
@@ -314,15 +314,12 @@ 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) ||
t('layers.missions.roster.dateUnavailable');
const provider =
launch.provider || t('layers.missions.roster.unspecifiedOperator');
const date = launch.launchTime?.slice(0, 10) || t('DATE UNAVAILABLE');
const provider = launch.provider || t('UNSPECIFIED OPERATOR');
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="${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>`;
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('Select {mission}', { 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) => {
@@ -379,7 +376,7 @@ export function createPanel({ state: layerState, services, parts, source }) {
setMissionPanelField(
'[data-mission-distance]',
Number.isFinite(altitudeM)
? t('layers.missions.telemetry.km', {
? t('{value} KM', {
value: Math.max(0, altitudeM / 1000).toLocaleString(undefined, {
maximumFractionDigits: 0,
}),
@@ -392,7 +389,7 @@ export function createPanel({ state: layerState, services, parts, source }) {
setMissionPanelField(
'[data-mission-speed]',
Number.isFinite(speedMps)
? t('layers.missions.telemetry.kmPerSecond', {
? t('{value} KM/S', {
value: (speedMps / 1000).toLocaleString(undefined, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
@@ -473,16 +470,16 @@ export function createPanel({ state: layerState, services, parts, source }) {
layerState._missionPanel.className = 'context-space-mission-detail';
layerState._missionPanel.setAttribute(
'aria-label',
t('layers.missions.panel.selectedHeader'),
t('SELECTED SPACE MISSION'),
);
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.innerHTML = `<div class="space-mission-view-header"><span>${t('SELECTED SPACE MISSION')}</span><button type="button" data-mission-close title="${t('Show all missions')}" aria-label="${t('Deselect mission')}">×</button></div><div class="space-mission-detail"><strong data-mission-title>${t('MISSION')}</strong><span data-mission-field data-mission-provider></span><span data-mission-field>${t('STATUS · ')}<b data-mission-status></b></span><span data-mission-field>${t('LAUNCH SITE · ')}<b data-mission-site></b></span><span data-mission-field>${t('LAUNCH TIME · ')}<b data-mission-time></b></span><span data-mission-field>${t('ORBIT · ')}<b data-mission-orbit></b></span><span>${t('ASCENT PATH · ')}<b data-mission-ascent-source></b></span><span data-mission-field>${t('CURRENT DISTANCE FROM EARTH · ')}<b data-mission-distance></b></span><span data-mission-field>${t('SATELLITE SPEED · ')}<b data-mission-speed></b></span></div><section class="mission-data-section"><h4>${t('PAYLOAD')}</h4><div class="mission-table-scroll"><table class="mission-data-table"><thead><tr><th>${t('NAME')}</th><th>${t('TYPE')}</th><th>${t('DESTINATION')}</th></tr></thead><tbody data-mission-payloads></tbody></table></div></section><section class="mission-data-section" data-mission-stages-section><h4>${t('STAGE / RE-ENTRY / RECOVERY')}</h4><div class="mission-table-scroll"><table class="mission-data-table"><thead><tr><th>${t('STAGE')}</th><th>${t('STATUS')}</th><th>${t('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">${t('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="${t('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>${t('FOCUS')}</button><button type="button" class="mission-replay-button" data-mission-replay aria-pressed="false">${t('REPLAY ASCENT')}</button></div><div class="space-mission-nav"><button type="button" class="mission-nav-button" data-mission-prev title="${t('Previous mission')}"><span aria-hidden="true">‹</span> ${t('PREV')}</button><span class="mission-nav-index" data-mission-index>—</span><button type="button" class="mission-nav-button" data-mission-next title="${t('Next mission')}">${t('NEXT')} <span aria-hidden="true">›</span></button></div><button type="button" class="panel-layer-toggle" data-mission-show-all>${t('SHOW ALL / DESELECT')}</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="${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>
<button type="button" data-mission-replay-toggle title="${t('Pause replay')}" aria-label="${t('Pause replay')}">Ⅱ</button>
<button type="button" class="cancel" data-mission-replay-cancel title="${t('Cancel replay')}" aria-label="${t('Cancel replay')}"><span aria-hidden="true">×</span></button>
</div>`,
);
host.appendChild(layerState._missionPanel);
+1 -1
View File
@@ -522,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: t('layers.missions.estDownrange'),
accuracy: t('EST. DOWNRANGE'),
};
}
+5 -5
View File
@@ -77,14 +77,14 @@ export function createPolicyHelpers({
: 0;
return {
orbit: orbitName
? `${orbitAllowed ? '' : t('layers.missions.rows.plannedPrefix')}${orbitName}`
? `${orbitAllowed ? '' : t('PLANNED · ')}${orbitName}`
: null,
ascent:
suppliedTrajectoryPoints > 1
? t('layers.missions.rows.suppliedTrajectory')
? t('SUPPLIED TRAJECTORY POINTS')
: replayAvailable
? t('layers.missions.rows.reconstructedEstimate')
: t('layers.missions.rows.unavailable'),
? t('RECONSTRUCTED ESTIMATE')
: t('UNAVAILABLE'),
replayAvailable: Boolean(replayAvailable),
};
}
@@ -198,7 +198,7 @@ export function createPolicyHelpers({
function formatMissionEventTime(launchTime) {
const date = new Date(launchTime);
if (!launchTime || !Number.isFinite(date.getTime()))
return t('layers.missions.rows.unavailable');
return t('UNAVAILABLE');
return `${date.toISOString().slice(0, 10)}\n${date.toISOString().slice(11, 19)} UTC`;
}
+11 -13
View File
@@ -201,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 = t('layers.missions.replay.start');
button.textContent = t('REPLAY ASCENT');
button.classList.remove('active');
button.setAttribute('aria-pressed', String(active));
button.title = t('layers.missions.replay.startTitle');
button.title = t('Replay the estimated ascent with a following camera');
if (transport) {
transport.hidden = !active;
transport.classList.toggle(
@@ -219,13 +219,11 @@ export function createReplay({ state: layerState, services, parts, source }) {
toggleButton.textContent = layerState._replayPaused ? '▶' : 'Ⅱ';
toggleButton.setAttribute(
'aria-label',
layerState._replayPaused
? t('layers.missions.replay.resume')
: t('layers.missions.replay.pause'),
layerState._replayPaused ? t('Resume replay') : t('Pause replay'),
);
toggleButton.title = layerState._replayPaused
? t('layers.missions.replay.resume')
: t('layers.missions.replay.pause');
? t('Resume replay')
: t('Pause replay');
}
}
}
@@ -236,19 +234,19 @@ export function createReplay({ state: layerState, services, parts, source }) {
);
if (!transport || !layerState._replayCameraLaunchId) return;
const phase = state.countdownActive
? t('layers.missions.replay.phaseCountdown', {
? t('T minus {seconds}', {
seconds: state.countdownSeconds,
})
: state.preCountdownActive
? t('layers.missions.replay.phasePreparing')
? t('Preparing launch site')
: state.elapsedSinceStart < 1
? t('layers.missions.replay.phaseLiftoff')
? t('Liftoff')
: state.ascending
? t('layers.missions.replay.phaseAscent')
: t('layers.missions.replay.phaseOrbit');
? t('Ascent replay')
: t('Orbit replay');
transport.setAttribute(
'aria-label',
`${phase}${layerState._replayPaused ? t('layers.missions.replay.pausedSuffix') : ''}`,
`${phase}${layerState._replayPaused ? t(', paused') : ''}`,
);
}
+1 -1
View File
@@ -55,7 +55,7 @@ export function createQueries({
*/
function _formatAltitude(altitudeFt) {
if (!Number.isFinite(altitudeFt)) return t('layers.readout.altUnknown');
if (!Number.isFinite(altitudeFt)) return t('Alt unknown');
return `${Math.round(altitudeFt)} ft`;
}
+4 -6
View File
@@ -220,7 +220,7 @@ export function createTracking({
function _buildTrackedLabel(info, icao24) {
const stale =
flightState.records.missingPolls.get(icao24) || flightState.feed._backoff
? ` · ${t('layers.status.stale')}`
? ` · ${t('STALE')}`
: '';
const callsign =
(parts.queries._toCleanText(info?.callsign) ||
@@ -230,14 +230,12 @@ export function createTracking({
// what the Easter egg replaces the real type with.
const type = tr3bTypeLabel(
icao24,
parts.queries._toCleanText(info?.type) || t('layers.readout.typeUnknown'),
parts.queries._toCleanText(info?.type) || t('Type unknown'),
);
const registration =
parts.queries._toCleanText(info?.registration) ||
t('layers.readout.regUnknown');
parts.queries._toCleanText(info?.registration) || t('Reg unknown');
const operator =
parts.queries._toCleanText(info?.operator) ||
t('layers.readout.operatorUnknown');
parts.queries._toCleanText(info?.operator) || t('Operator unknown');
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;
+9 -9
View File
@@ -70,20 +70,20 @@ export function createCategories({
function buildRadioCategories(stations) {
const rows = Array.isArray(stations) ? stations : [];
const categories = [
{ 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: 'all', label: t('All') },
{ id: 'news', label: t('News') },
{ id: 'talk', label: t('Talk') },
{ id: 'weather', label: t('Weather / Emergency') },
{ id: 'public-safety', label: t('Public Safety') },
{
id: 'aviation-marine',
label: t('layers.radio.category.aviationMarine'),
label: t('Aviation / Marine'),
},
{
id: 'traffic-transit',
label: t('layers.radio.category.trafficTransit'),
label: t('Traffic / Transit'),
},
{ id: 'music', label: t('layers.radio.category.music') },
{ id: 'music', label: t('Music') },
];
for (const [genre, label] of MUSIC_GENRES) {
@@ -92,7 +92,7 @@ export function createCategories({
categories.push({ id, label });
}
}
categories.push({ id: 'other', label: t('layers.radio.category.other') });
categories.push({ id: 'other', label: t('Other') });
return categories.map((category) => ({
...category,
color: parts.model.radioCategoryColor(category.id),
+13 -8
View File
@@ -494,22 +494,27 @@ export function createControls({ state: layerState, services, parts, source }) {
const active =
layerState._params.catalog === 'dense' &&
layerState._denseStatus === 'ready';
let title = t('layers.satellites.denseTitleAdd');
if (loading) title = t('layers.satellites.denseTitleLoading');
let title = t(
'Add the full Starlink broadband shell (thousands of extra points)',
);
if (loading) title = t('Loading the Starlink shell…');
else if (failed)
title = t('layers.satellites.denseTitleFailed', {
detail: layerState._denseError || t('layers.satellites.loadFailed'),
title = t('Starlink {detail} — click to retry', {
detail: layerState._denseError || t('load failed'),
});
else if (active) title = t('layers.satellites.denseTitleActive');
else if (active)
title = t(
'Showing the full Starlink shell — click for the core catalog only',
);
return {
chips: [
{
id: 'catalog',
label: loading
? t('layers.satellites.denseChipLoading')
? t('DENSE ···')
: failed
? t('layers.satellites.denseChipFailed')
: t('layers.satellites.denseChip'),
? t('DENSE ✕')
: t('DENSE'),
active,
busy: loading,
disabled: loading,
+1 -1
View File
@@ -12,7 +12,7 @@ export function createIngestion({ state, parts, source }) {
state._loading = true;
state._error = null;
state._loadingLabel = t('layers.meta.loading');
state._loadingLabel = t('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
+24 -16
View File
@@ -261,12 +261,12 @@ export function createModel({ state: layerState, services, parts, source }) {
mode,
error: null,
loadingLabel: fetching
? t('layers.traffic.syncingRoads', { source: roadSource })
? t('Syncing flow · Roads: {source}', { source: roadSource })
: coveragePct > 0
? t('layers.traffic.liveRoadsFlowHidden', {
? t('LIVE · Roads: {source} · Roads without flow hidden', {
source: roadSource,
})
: t('layers.traffic.liveRoadsNoFlowInView', {
: t('LIVE · Roads: {source} · No flow roads in view', {
source: roadSource,
}),
};
@@ -276,13 +276,13 @@ export function createModel({ state: layerState, services, parts, source }) {
mode,
error: null,
loadingLabel: fetching
? t('layers.traffic.syncingRoads', { source: roadSource })
? t('Syncing flow · Roads: {source}', { source: roadSource })
: coveragePct > 0
? t('layers.traffic.roadsFlowLive', {
? t('LIVE · Roads: {source} · Flow {percent}%', {
source: roadSource,
percent: coveragePct,
})
: t('layers.traffic.roadsFlowSimulated', {
: t('SIMULATED · Roads: {source} · Flow {percent}%', {
source: roadSource,
percent: coveragePct,
}),
@@ -293,16 +293,24 @@ export function createModel({ state: layerState, services, parts, source }) {
mode,
error: null,
loadingLabel: fetching
? t('layers.traffic.loadingSyncing')
? t(
'Syncing flow · Roads: OpenStreetMap · Flow: TomTom · Unmatched: simulated',
)
: coveragePct > 0
? coveragePct < 100
? t('layers.traffic.liveCoverageUnmatched', {
percent: coveragePct,
})
: t('layers.traffic.liveCoverage', {
percent: coveragePct,
})
: t('layers.traffic.simulatedNoMatches'),
? t(
'LIVE · Roads: OpenStreetMap · Flow: TomTom · {percent}% cov · Unmatched: simulated',
{
percent: coveragePct,
},
)
: t(
'LIVE · Roads: OpenStreetMap · Flow: TomTom · {percent}% cov',
{
percent: coveragePct,
},
)
: t('SIMULATED · Roads: OpenStreetMap · Flow: TomTom (no matches)'),
};
}
// Keyless simulation — one terse line that names the mode and the remedy
@@ -312,8 +320,8 @@ export function createModel({ state: layerState, services, parts, source }) {
mode,
error: null,
loadingLabel: statusUnavailable
? t('layers.traffic.simUnavailable')
: t('layers.traffic.simKeyless'),
? t('SIMULATED — traffic service unreachable')
: t('SIMULATED — add TomTom key for live'),
};
}
+11 -11
View File
@@ -21,13 +21,13 @@ export function createCards({
const stale = (record.missedRefreshes || 0) > 0;
el.classList.add('active');
el.textContent = [
t('layers.vessel.hudName', { name: trimHudValue(record.name, 32) }),
t('layers.vessel.hudTypeLine', {
type: trimHudValue(record.type || t('layers.vessel.fallback'), 24),
t('AIS: {name}', { name: trimHudValue(record.name, 32) }),
t('{type} SPD: {speed} HDG: {heading}', {
type: trimHudValue(record.type || t('VESSEL'), 24),
speed: formatSpeed(record.speed),
heading: formatHeading(record.heading ?? record.course),
}),
`MMSI: ${record.mmsi || '--'} ${formatPositionTime(record)}${stale ? ` · ${t('layers.status.stale')}` : ''}`,
`MMSI: ${record.mmsi || '--'} ${formatPositionTime(record)}${stale ? ` · ${t('STALE')}` : ''}`,
].join('\n');
}
@@ -35,7 +35,7 @@ export function createCards({
const el = document.getElementById('hud-ais-vessel');
if (!el) return;
el.classList.remove('active');
el.textContent = t('layers.vessel.hudIdle');
el.textContent = t('AIS: --');
}
function trimHudValue(value, maxLength) {
@@ -90,7 +90,7 @@ export function createCards({
const direction = record.heading ?? record.course;
const details = [
[
vesselTypeShort(record) || t('layers.vessel.fallback'),
vesselTypeShort(record) || t('VESSEL'),
formatSpeed(record.speed),
Number.isFinite(direction) ? `${Math.round(direction)}°` : '--°',
].join(' · '),
@@ -99,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 ? ` · ${t('layers.status.stale')}` : ''}`,
`MMSI ${record.mmsi || '--'} · ${formatPositionTime(record)}${stale ? ` · ${t('STALE')}` : ''}`,
);
return {
id: vesselOverlayEntryId(record),
@@ -156,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}` : t('layers.vessel.fallback');
return record.mmsi ? `MMSI ${record.mmsi}` : t('VESSEL');
}
function formatSpeed(speed) {
@@ -168,10 +168,10 @@ export function createCards({
}
function formatPositionTime(record) {
if (!record.lastPositionUtc) return t('layers.vessel.posLive');
if (!record.lastPositionUtc) return t('POS: LIVE');
const date = new Date(record.lastPositionUtc);
if (Number.isNaN(date.getTime())) return t('layers.vessel.posLive');
return t('layers.vessel.posAt', { time: date.toISOString().slice(11, 19) });
if (Number.isNaN(date.getTime())) return t('POS: LIVE');
return t('POS: {time}Z', { time: date.toISOString().slice(11, 19) });
}
return {
updateSelectedVesselHud,
+3 -3
View File
@@ -38,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 = t('layers.vessel.awaitingFirstPosition');
state.feed.loadingLabel = t('awaiting first AIS position…');
scheduleFirstConnectExpiry(sessionId, AIS_FIRST_CONNECT_GRACE_MS);
}
@@ -60,8 +60,8 @@ export function createLifecycle({
state.feed.firstConnectPhase = 'unavailable';
state.feed.loadingLabel = '';
state.feed.error = state.feed.lastMessageAt
? t('layers.vessel.awaitingPositions')
: t('layers.vessel.awaitingFirstMessage');
? t('awaiting usable AIS positions…')
: t('awaiting first AIS message…');
state.feed.stale = state.feed.count > 0;
}, delayMs);
}
+8 -10
View File
@@ -1,14 +1,14 @@
import * as Cesium from 'cesium';
import { t } from '../../i18n/index.js';
import { t, msg } 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',
'refreshing...': msg('refreshing...'),
'loading...': msg('loading...'),
'awaiting first AIS position…': msg('awaiting first AIS position…'),
});
function localizeFeedLabel(raw) {
@@ -98,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
? t('layers.vessel.awaitingPositions')
: t('layers.vessel.awaitingFirstMessage');
? t('awaiting usable AIS positions…')
: t('awaiting first AIS message…');
}
if (!status) return null;
const detail =
@@ -158,9 +158,7 @@ export function createQueries({
error: emptyCoverage
? null
: deriveAisFeedError(payload, acceptedRowCount) ||
(acceptedRowCount === 0
? t('layers.vessel.awaitingPositions')
: null),
(acceptedRowCount === 0 ? t('awaiting usable AIS positions…') : null),
};
}
@@ -532,7 +530,7 @@ export function createQueries({
lastUpdate: state.feed.lastUpdate,
loading: state.feed.loading || waitingForFirstPosition,
loadingLabel: waitingForFirstPosition
? t('layers.vessel.awaitingFirstPosition')
? t('awaiting first AIS position…')
: localizeFeedLabel(state.feed.loadingLabel),
error: state.feed.error,
stale: state.feed.stale,