mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
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:
1 parent
bde1e430ef
commit
284c4d4601
98 files changed
+3474
-1625
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
Vendored
+5
-2
@@ -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;
|
||||
|
||||
@@ -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(' · '));
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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(' · ');
|
||||
|
||||
@@ -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
@@ -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. */
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Vendored
+55
-6
@@ -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:
|
||||
|
||||
@@ -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('_', ' ')
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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'),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
|
||||
|
||||
@@ -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') : ''}`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
|
||||
Vendored
+12
-8
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user