Merge branch 'main' into fix/geo-distance-dateline

# Conflicts:
#	CHANGELOG.md
This commit is contained in:
Sameh Khamis committed 2026-10-16 14:59:29 -07:00
commit c37e301ab7
440 files changed
+33136 -4670

No files matched your search

@@ -0,0 +1,107 @@
// The Air quality layer (AQHI) reads in the UI language: layer name, Weather
// card (title, coverage, status, legend, risk) and its own failure text.
import assert from 'node:assert/strict';
import test from 'node:test';
import { setLocale, t } from '../../i18n/index.js';
import { layerNameForId, localizeLayerName } from '../../ui/layerNames.js';
import { LayerPanel } from '../../ui/layerPanel.js';
import { translateFeedError } from '../../ui/statusText.js';
import { createAirQualityLayer } from './index.js';
function inLocale(locale, run) {
setLocale(locale);
try {
return run();
} finally {
setLocale('en');
}
}
function harness(source) {
const layer = createAirQualityLayer({
source,
overlayHost: { setEntries() {}, setVisible() {}, clearSource() {} },
});
const viewer = { dataSources: { add() {}, remove() {} } };
layer.init(viewer);
layer.enable(viewer);
return layer;
}
const reading = {
id: 'eccc:A',
provider: 'eccc',
scale: 'AQHI',
value: 2,
band: 'low',
stationId: 'A',
name: 'Toronto',
zone: 'ont',
lat: 43.65,
lon: -79.38,
observedMs: 1_700_000_000_000,
color: '#4cc9f0',
label: '2',
risk: 'Low health risk',
note: null,
};
for (const locale of ['es', 'ru']) {
test(`${locale}: the layer name is translated`, () => {
inLocale(locale, () => {
assert.notEqual(t('Air quality'), 'Air quality', 'catalog has the entry');
assert.equal(layerNameForId('air-quality'), t('Air quality'));
assert.equal(localizeLayerName('Air quality'), t('Air quality'));
});
});
test(`${locale}: the Weather card of Air quality is translated`, async () => {
const layer = harness({ getSnapshot: async () => [reading] });
await layer.update();
const controls = inLocale(locale, () => layer.getRowControls());
const english = [
controls.summary.label,
controls.summary.coverage,
controls.summary.status,
...controls.legend.map((entry) => entry.label),
...controls.list.items.map((item) => item.text),
].filter((text) =>
/\b(Air quality|Canada only|stations reporting|Low|Moderate|High|health risk)\b/.test(
text,
),
);
assert.deepEqual(english, [], `English left in the ${locale} card`);
});
test(`${locale}: the Air quality failure text is translated in the row`, () => {
const row = inLocale(locale, () =>
LayerPanel.prototype._buildMetaText({
id: 'air-quality',
enabled: true,
stats: {
count: 0,
error: 'Air quality request failed',
source: 'ECCC',
lastUpdate: null,
},
}),
);
assert.ok(!row.includes('Air quality request failed'), row);
});
}
for (const locale of ['en', 'es', 'ru']) {
test(`${locale}: the Weather card shows an HTTP failure as the row does`, async () => {
const layer = harness({
getSnapshot: async () => {
throw new Error('ECCC HTTP 503');
},
});
await layer.update();
const card = inLocale(locale, () => layer.getRowControls().summary.status);
assert.equal(
card,
inLocale(locale, () => translateFeedError('ECCC HTTP 503')),
);
});
}
+19 -10
View File
@@ -1,4 +1,6 @@
import * as Cesium from 'cesium';
import { msg, t, tn } from '../../i18n/index.js';
import { translateFeedError } from '../../ui/statusText.js';
import {
AIR_QUALITY_BANDS,
AIR_QUALITY_OVERLAY_SOURCE_ID,
@@ -109,7 +111,7 @@ export function createAirQualityLayer({ source, overlayHost } = {}) {
return true;
} catch (error) {
if (request.signal.aborted || _request !== request) return false;
_lastError = error?.message || 'Air quality request failed';
_lastError = error?.message || msg('Air quality request failed');
console.warn('[Data:AirQuality] Refresh failed:', _lastError);
notify();
return false;
@@ -214,34 +216,41 @@ export function createAirQualityLayer({ source, overlayHost } = {}) {
(max, reading) => Math.max(max, reading.observedMs || 0),
0,
);
// Readings and errors stay English data (analyst records, stats); the
// card translates them as the Layers panel row does. A provider's own
// error text has no catalog entry and shows as it came.
return {
readout: true,
summary: {
label: provider.label,
coverage: provider.coverage,
label: t(provider.label),
coverage: t(provider.coverage),
validTime: newest ? new Date(newest).toISOString() : undefined,
detail: `${provider.network} · ${provider.scale}`,
status: _lastError
? _lastError
? translateFeedError(_lastError)
: _count === 0
? _lastUpdate === null
? 'No readings available'
: 'No current readings'
: `${_count} stations reporting`,
? t('No readings available')
: t('No current readings')
: tn(
_count,
'{count} station reporting',
'{count} stations reporting',
),
units: provider.scale,
},
legend: AIR_QUALITY_BANDS.map((band) => ({
color: band.color,
label: band.label,
label: t(band.label),
})),
legendCategorical: true,
list: {
ariaLabel: 'Highest air-quality readings in view',
ariaLabel: t('Highest air-quality readings in view'),
items: inView.map((reading, index) => ({
id: reading.id,
ordinal: index + 1,
lead: `${reading.scale} ${reading.label}`,
text: `${reading.name} · ${reading.risk}`,
text: `${reading.name} · ${t(reading.risk)}`,
accent: reading.color,
})),
},
+30 -8
View File
@@ -15,13 +15,36 @@
* Only ECCC's AQHI is implemented here; `AIR_QUALITY_PROVIDERS` is the seam a
* later provider registers against.
*/
// A portable export (package.json): no i18n here. The labels stay English data;
// the Weather card (index.js) shows them translated.
const msg = (text) => text;
/** Band identifiers shared across providers, in ascending severity. */
export const AIR_QUALITY_BANDS = Object.freeze([
Object.freeze({ id: 'low', label: 'Low', color: '#4cc9f0' }),
Object.freeze({ id: 'moderate', label: 'Moderate', color: '#ffd43b' }),
Object.freeze({ id: 'high', label: 'High', color: '#ff6b35' }),
Object.freeze({ id: 'very-high', label: 'Very High', color: '#c1121f' }),
Object.freeze({
id: 'low',
label: msg('Low'),
risk: msg('Low health risk'),
color: '#4cc9f0',
}),
Object.freeze({
id: 'moderate',
label: msg('Moderate'),
risk: msg('Moderate health risk'),
color: '#ffd43b',
}),
Object.freeze({
id: 'high',
label: msg('High'),
risk: msg('High health risk'),
color: '#ff6b35',
}),
Object.freeze({
id: 'very-high',
label: msg('Very High'),
risk: msg('Very High health risk'),
color: '#c1121f',
}),
]);
const BAND_BY_ID = new Map(AIR_QUALITY_BANDS.map((band) => [band.id, band]));
@@ -89,9 +112,9 @@ export const AIR_QUALITY_PROVIDERS = Object.freeze({
eccc: Object.freeze({
id: 'eccc',
scale: 'AQHI',
label: 'Air quality',
label: msg('Air quality'),
network: 'Environment and Climate Change Canada',
coverage: 'Canada only · ECCC AQHI network',
coverage: msg('Canada only · ECCC AQHI network'),
/** ECCC's published band thresholds, evaluated in order. */
bands: Object.freeze([
Object.freeze({ id: 'low', max: 3 }),
@@ -161,8 +184,7 @@ export function valueLabel(providerId, value) {
/** Plain-language risk phrase for a band id. */
export function bandRiskText(bandId) {
const band = airQualityBandById(bandId);
return band ? `${band.label} health risk` : null;
return airQualityBandById(bandId)?.risk ?? null;
}
/**
+10 -4
View File
@@ -1,6 +1,10 @@
import { readResponseJsonCapped } from '../../sources/httpBody.js';
import { normalizeAqhiStations, normalizeAqhiObservations } from './model.js';
// A portable export (package.json): no i18n here. Failures stay English data;
// the Layers panel and the Weather card show them translated.
const msg = (text) => text;
const COLLECTIONS_ROOT = 'https://api.weather.gc.ca/collections';
const STATIONS_URL = `${COLLECTIONS_ROOT}/aqhi-stations/items`;
const OBSERVATIONS_URL = `${COLLECTIONS_ROOT}/aqhi-observations-realtime/items`;
@@ -47,7 +51,7 @@ export function createEcccAirQualitySource({
const abort = () => controller.abort(signal.reason);
signal?.addEventListener('abort', abort, { once: true });
const timer = setTimeout(
() => controller.abort(new Error('ECCC request timed out')),
() => controller.abort(new Error(msg('ECCC request timed out'))),
timeoutMs,
);
try {
@@ -82,14 +86,15 @@ export function createEcccAirQualitySource({
signal,
);
const next = normalizeAqhiStations(payload);
if (!next) throw new Error('Malformed AQHI station response');
if (!next) throw new Error(msg('Malformed AQHI station response'));
// An empty refresh must not wipe a good catalog.
if (next.size > 0) {
stations = next;
stationsFetchedAt = clock;
}
}
if (stations.size === 0) throw new Error('No AQHI stations available');
if (stations.size === 0)
throw new Error(msg('No AQHI stations available'));
const payload = await getJson(
`${OBSERVATIONS_URL}?f=json&latest=true&limit=${OBSERVATION_LIMIT}`,
@@ -97,7 +102,8 @@ export function createEcccAirQualitySource({
signal,
);
const readings = normalizeAqhiObservations(payload, stations, clock);
if (!readings) throw new Error('Malformed AQHI observation response');
if (!readings)
throw new Error(msg('Malformed AQHI observation response'));
return readings;
},
};
+34 -20
View File
@@ -18,6 +18,7 @@ import {
alprCreditMarkup,
} from './model.js';
import { createAlprPresentation } from './presentation.js';
import { t, tn, msg } from '../../i18n/index.js';
/**
* Own one layer's requests, records, display and viewer subscriptions.
@@ -275,7 +276,7 @@ export function createAlprCamerasLayer({ source, services } = {}) {
state.stale = Boolean(state.lastUpdate);
setAlprStatus(
'unavailable',
error?.message || 'Camera source temporarily unavailable',
error?.message || msg('Camera source temporarily unavailable'),
);
if (!isUnavailableCapability(error)) scheduleUnavailableRetry();
} finally {
@@ -293,7 +294,8 @@ export function createAlprCamerasLayer({ source, services } = {}) {
id: LAYER_ID,
name: 'ALPR Cameras',
icon: '📷',
source: source.label || 'Mapped camera locations',
// English in layer state; the layer row translates it.
source: source.label || msg('Mapped camera locations'),
updateInterval: 0,
statsRefreshInterval: 1000,
init(viewer) {
@@ -372,10 +374,12 @@ export function createAlprCamerasLayer({ source, services } = {}) {
chips: [
{
id: 'find-camera',
label: 'SHOW NEAREST',
label: t('SHOW NEAREST'),
title: state.viewer?.trackedEntity
? 'Stop following the current object before navigating to a camera'
: 'Move to the nearest loaded camera and show its details',
? t(
'Stop following the current object before navigating to a camera',
)
: t('Move to the nearest loaded camera and show its details'),
disabled:
!state.enabled || !count || Boolean(state.viewer?.trackedEntity),
onClick: focusNearest,
@@ -383,11 +387,12 @@ export function createAlprCamerasLayer({ source, services } = {}) {
],
legend: [
{
label: 'Camera badges',
label: t('Camera badges'),
color: ALPR_COLOR,
count,
blurb:
blurb: t(
'Cyan cameras turn coral when selected. Wedges illustrate mapped direction, not measured coverage. Nearby cameras may be outside the screen.',
),
},
],
};
@@ -428,11 +433,18 @@ export function createAlprCamerasLayer({ source, services } = {}) {
count: state.dataSource?.entities.values.length || 0,
countLabel:
state.enabled && !state.noCoverage && state.status !== 'zoom-in'
? `${state.dataSource?.entities.values.length || 0} nearby${
Number.isInteger(state.onScreen)
? ` · ${state.onScreen} on screen`
: ''
}`
? Number.isInteger(state.onScreen)
? tn(
state.dataSource?.entities.values.length || 0,
'{count} nearby · {onScreen} on screen',
'{count} nearby · {onScreen} on screen',
{ onScreen: state.onScreen },
)
: tn(
state.dataSource?.entities.values.length || 0,
'{count} nearby',
'{count} nearby',
)
: '',
noCoverage: Boolean(state.noCoverage),
lastUpdate: state.lastUpdate,
@@ -451,20 +463,22 @@ export function createAlprCamerasLayer({ source, services } = {}) {
: 0,
loadingLabel: state.loading
? state.retrying
? 'retrying mapped ALPR cameras'
: 'loading mapped ALPR cameras'
? t('retrying mapped ALPR cameras')
: t('loading mapped ALPR cameras')
: state.noCoverage
? 'No ALPR data for this area — US and Canada only'
? t('No ALPR data for this area — US and Canada only')
: state.status === 'zoom-in'
? 'Zoom in to load mapped cameras'
? t('Zoom in to load mapped cameras')
: [
state.stale ? 'Showing cached locations' : '',
state.stale ? t('Showing cached locations') : '',
state.saturated || state.renderSaturated
? 'Coverage limited — zoom in'
? t('Coverage limited — zoom in')
: '',
state.status === 'empty'
? t('No ALPR data for this area')
: '',
state.status === 'empty' ? 'No ALPR data for this area' : '',
state.status === 'ready' && state.onScreen === 0
? 'None on screen — nearby cameras are outside the view'
? t('None on screen — nearby cameras are outside the view')
: '',
]
.filter(Boolean)
+2 -1
View File
@@ -13,6 +13,7 @@ import {
MARKER_SCALE_BY_DISTANCE,
} from './policy.js';
import { boxContains } from './model.js';
import { t } from '../../i18n/index.js';
import { createAlprFloorResolver } from './floors.js';
import { createAlprOverlay } from './overlay.js';
import {
@@ -451,7 +452,7 @@ export function createAlprPresentation({ state, services, source }) {
layerName: 'ALPR Cameras',
source:
source.attribution?.description || source.label || 'Camera source',
label: 'ALPR camera',
label: t('ALPR camera'),
latitude: record.latitude,
longitude: record.longitude,
properties: {
+7 -4
View File
@@ -16,6 +16,9 @@ import {
} from '../../sources/vectorTiles.js';
import { buildOverpassQuery, normalizeAlprNode } from './records.js';
import { decodeAlprTile } from './tileRecords.js';
// Marks text for i18n extraction; the UI translates it with t(). Kept local
// because AI tool services import this module outside the browser.
const msg = (text) => text;
/**
* Finest attributed extract zoom whose tiles for `box` fit the per-view cap,
@@ -136,7 +139,7 @@ export function createAlprTileSource({
const metadata = await source.getMetadata(signal);
const [west, south, east, north] = metadata.bounds || [];
if (![west, south, east, north].every(Number.isFinite))
throw new Error('Camera coverage unavailable');
throw new Error(msg('Camera coverage unavailable'));
if (
box.east < west ||
box.west > east ||
@@ -184,7 +187,7 @@ export function createAlprTileSource({
for (const source of sources) source.clear();
precision.clear();
},
label: 'OpenStreetMap · community mapped',
label: msg('OpenStreetMap · community mapped'),
attribution: {
name: 'OpenStreetMap',
description: '© OpenStreetMap contributors, ODbL',
@@ -242,7 +245,7 @@ export function createOverpassAlprSource({
// The shared proxy already rejects query errors. Validate here too so a
// malformed or partial response never becomes an authoritative empty map.
if (!Array.isArray(payload?.elements) || payload.remark) {
throw new Error('Overpass returned an incomplete camera response');
throw new Error(msg('Overpass returned an incomplete camera response'));
}
return {
records: [
@@ -260,7 +263,7 @@ export function createOverpassAlprSource({
}
return {
fetch: fetchAlprNodes,
label: 'OpenStreetMap · community mapped',
label: msg('OpenStreetMap · community mapped'),
attribution: {
name: 'OpenStreetMap',
description: 'OpenStreetMap contributors (ODbL 1.0; community mapped)',
+43 -15
View File
@@ -1,8 +1,9 @@
import {
formatAwarenessLabel,
formatAwarenessDistance,
AWARENESS_RADIUS_M,
} from '../../data/militaryAwarenessEngine.js';
import { formatNumber, t, translateKnown } from '../../i18n/index.js';
import { formatQuantity } from '../../ui/unitLabels.js';
import { AWARENESS_PAGE_SIZE, AWARENESS_PAGE_ROTATE_MS } from './policy.js';
/** Format a cohort total without presenting a retained cap as exact. */
@@ -14,6 +15,33 @@ export function formatAwarenessCount(summary = {}) {
: count;
}
/** The count as the panel shows it, in the UI language. */
function presentAwarenessCount(summary = {}) {
if (!Number.isFinite(summary.count)) return '?';
const count = formatNumber(summary.count);
return summary.complete === false || summary.truncated === true
? t('At least {count}', { count })
: count;
}
/** A distance as the panel shows it: 850 m, 4.6 km, 120 km, in the UI language. */
function presentAwarenessDistance(meters) {
if (!Number.isFinite(meters) || meters < 0) return '—';
if (meters < 1000) return formatQuantity(Math.round(meters), 'm');
const digits = meters < 10000 ? 1 : 0;
return formatQuantity(meters / 1000, 'km', {
minimumFractionDigits: digits,
maximumFractionDigits: digits,
});
}
/** The cohort's reason, kept English in data, shown in the UI language. */
function presentAwarenessReason(summary = {}) {
return summary.reasonTemplate
? t(summary.reasonTemplate, summary.reasonValues)
: translateKnown(summary.reason || '');
}
export function createPanel({ state: layerState, services, parts, source }) {
function ensurePanel() {
if (layerState.panel) return layerState.panel;
@@ -56,8 +84,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('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) {
@@ -70,7 +98,7 @@ export function createPanel({ state: layerState, services, parts, source }) {
function rowHtml(cohort) {
const summary = cohort.summary;
const count = formatAwarenessCount(summary);
const count = presentAwarenessCount(summary);
const page = layerState.cohortPages.get(cohort.id) || 0;
const nearest = summary.nearest
.slice(page, page + AWARENESS_PAGE_SIZE)
@@ -78,10 +106,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="Unavailable">${escapeHtml(label)} <span>${formatAwarenessDistance(item.distanceM)}</span></span></li>`;
return `<li><span class="military-awareness-target unavailable" aria-label="${t('Unavailable')}">${escapeHtml(label)} <span>${presentAwarenessDistance(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('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>${presentAwarenessDistance(item.distanceM)}</span></button>${namedAreasHtml(item.memberNames)}</li>`;
})
.join('');
const pageCount = Math.max(
@@ -95,17 +123,17 @@ export function createPanel({ state: layerState, services, parts, source }) {
const coverage = cohort.coverage ? ` · ${escapeHtml(cohort.coverage)}` : '';
return `<section class="military-awareness-row ${summary.relationship.toLowerCase()}">
<div><strong>${escapeHtml(cohort.label)}</strong><b aria-live="polite">${count}${pageLabel}</b></div>
<small>${escapeHtml(cohort.source)}${coverage} · ${escapeHtml(summary.reason)}</small>
<small>${escapeHtml(cohort.source)}${coverage} · ${escapeHtml(presentAwarenessReason(summary))}</small>
${nearest ? `<ul>${nearest}</ul>` : ''}
</section>`;
}
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('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>`;
}
@@ -175,7 +203,7 @@ export function createPanel({ state: layerState, services, parts, source }) {
function namedAreasHtml(names) {
return names?.length
? `<details class="military-awareness-names"><summary>Named areas (${names.length})</summary><div>${names.map((name) => escapeHtml(name)).join('<br>')}</div></details>`
? `<details class="military-awareness-names"><summary>${escapeHtml(t('Named areas ({n})', { n: formatNumber(names.length) }))}</summary><div>${names.map((name) => escapeHtml(name)).join('<br>')}</div></details>`
: '';
}
@@ -183,11 +211,11 @@ export function createPanel({ state: layerState, services, parts, source }) {
if (!layerState.enabled || !layerState.results) return hidePanel();
const panel = ensurePanel();
const { subject, cohorts } = layerState.results;
const markup = `<div class="military-awareness-subject">${escapeHtml(subject.label)} · ${formatAwarenessDistance(AWARENESS_RADIUS_M)} FLIGHT / VESSEL WINDOW</div>
const markup = `<div class="military-awareness-subject">${escapeHtml(subject.label)} · ${escapeHtml(t('{distance} FLIGHT / VESSEL WINDOW', { distance: presentAwarenessDistance(AWARENESS_RADIUS_M) }))}</div>
${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('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);
+25 -12
View File
@@ -1,4 +1,16 @@
import { AWARENESS_RADIUS_M } from '../../data/militaryAwarenessEngine.js';
// The reason stays English for the voice analyst; the Contacts panel shows it
// in the UI language from `reasonTemplate`.
const msg = (text) => text;
const fillTemplate = (template, values = {}) =>
template.replace(/\{(\w+)\}/g, (_, name) => String(values[name]));
const withReason = (summary, template, values) => ({
...summary,
reason: fillTemplate(template, values),
reasonTemplate: template,
reasonValues: values,
});
import { DEPENDENCIES, AWARENESS_QUERY_LIMIT } from './policy.js';
export function createQueries({ state: layerState, services, parts, source }) {
@@ -119,19 +131,20 @@ export function createQueries({ state: layerState, services, parts, source }) {
// A subject window is its own bounded area: name it instead of the viewport.
if (coverage?.kind === 'subject' && Number.isFinite(coverage.radiusM)) {
const km = Math.round(coverage.radiusM / 1000);
return {
...summary,
reason: summary.count
? `mapped matches within ${km} km of the subject`
: `none mapped within ${km} km; not a complete 250 km survey`,
};
return withReason(
summary,
summary.count
? msg('mapped matches within {km} km of the subject')
: msg('none mapped within {km} km; not a complete 250 km survey'),
{ km },
);
}
return {
...summary,
reason: summary.count
? 'mapped matches from the loaded viewport'
: 'viewport feed is not a complete 250 km survey',
};
return withReason(
summary,
summary.count
? msg('mapped matches from the loaded viewport')
: msg('viewport feed is not a complete 250 km survey'),
);
}
/**
+11 -4
View File
@@ -1,4 +1,5 @@
import * as Cesium from 'cesium';
import { t } from '../../i18n/index.js';
import {
createCyberSonarSampler,
isCyberContactSonarActive as isCyberSonarActive,
@@ -137,7 +138,9 @@ export function createRendering({
'--compass-rotation',
`${-headingDeg}deg`,
);
layerState.compassHeading.textContent = `HDG ${String(headingDeg).padStart(3, '0')}°`;
layerState.compassHeading.textContent = t('HDG {value}°', {
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('BRG {value}°', {
value: String(Math.round(bearing)).padStart(3, '0'),
})
: t('BRG —');
const courseText = Number.isFinite(item.track)
? ` · CRS ${String(Math.round(item.track)).padStart(3, '0')}°`
? ` · ${t('CRS {value}°', {
value: String(Math.round(item.track)).padStart(3, '0'),
})}`
: '';
marker._label.textContent = `${label} · ${formatAwarenessDistance(item.distanceM)}\n${bearingText}${courseText}`;
marker.hidden = false;
+20 -5
View File
@@ -3,6 +3,7 @@ import {
AWARENESS_RELATIONSHIP,
} from '../../data/militaryAwarenessEngine.js';
import * as Cesium from 'cesium';
import { t } from '../../i18n/index.js';
import {
AWARENESS_QUERY_LIMIT,
SOURCE_LABEL,
@@ -10,6 +11,19 @@ import {
AWARENESS_PAGE_SIZE,
} from './policy.js';
// coverageLabel is machine-composed English from installations/controls.js
// (pinned by src/data/militaryInstallations.test.mjs); translate the known
// forms here, pass anything else through.
const WITHIN_KM_RE = /^WITHIN (\d+) KM$/;
function localizeCoverageLabel(label) {
if (typeof label !== 'string' || !label) return label;
if (label === 'CURRENT VIEWPORT ONLY') return t('CURRENT VIEWPORT ONLY');
const within = WITHIN_KM_RE.exec(label);
if (within) return t('WITHIN {km} KM', { km: within[1] });
return label;
}
export function createSubject({ state: layerState, services, parts, source }) {
const aisLiveVesselsLayer = services.vessels;
const militaryInstallationsLayer = services.installations;
@@ -78,7 +92,7 @@ export function createSubject({ state: layerState, services, parts, source }) {
cohorts: [
{
id: 'flights',
label: 'Flights',
label: t('Flights'),
source: flightsState.stats.source || SOURCE_LABEL.flights,
provenance: retainedProvenance(
'flights',
@@ -97,7 +111,7 @@ export function createSubject({ state: layerState, services, parts, source }) {
},
{
id: 'military',
label: 'Military flights',
label: t('Military flights'),
source: militaryState.stats.source || SOURCE_LABEL.military,
provenance: retainedProvenance(
'military',
@@ -116,7 +130,7 @@ export function createSubject({ state: layerState, services, parts, source }) {
},
{
id: 'ais-live-vessels',
label: 'AIS vessels',
label: t('AIS vessels'),
source: vesselsState.stats.source || SOURCE_LABEL['ais-live-vessels'],
provenance: retainedProvenance(
'ais-live-vessels',
@@ -131,7 +145,7 @@ export function createSubject({ state: layerState, services, parts, source }) {
},
{
id: 'military-installations',
label: 'Mapped installations',
label: t('Mapped installations'),
source:
installationsState.stats.source ||
SOURCE_LABEL['military-installations'],
@@ -142,7 +156,8 @@ export function createSubject({ state: layerState, services, parts, source }) {
SOURCE_LABEL['military-installations'],
),
coverage:
installationsState.stats.coverageLabel || 'CURRENT VIEWPORT ONLY',
localizeCoverageLabel(installationsState.stats.coverageLabel) ||
t('CURRENT VIEWPORT ONLY'),
summary: parts.queries.summarizeInstallationViewport(
installations,
installationsState,
+6 -3
View File
@@ -1,3 +1,4 @@
import { msg, t } from '../../i18n/index.js';
import { STATUS_POLL_MS } from './policy.js';
export function createControls({ state: layerState, services, parts, source }) {
@@ -67,8 +68,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('syncing {count} city feeds...', {
count: layerState._activeCityIds.size,
})
: t('scanning nearby systems...');
}
// A failed city is only the layer's failure when it left nothing on the
// map. While other cities still render, the partial failure reads
@@ -76,7 +79,7 @@ export function createControls({ state: layerState, services, parts, source }) {
// points remain, they read STALE.
if (layerState._failedCityIds.size > 0) {
if (layerState._stationRenderMap.size === 0) {
stats.error = 'GBFS fetch error';
stats.error = msg('GBFS fetch error');
} else {
stats.degraded = true;
const allFailed = Array.from(layerState._activeCityIds).every(
+4 -1
View File
@@ -97,7 +97,10 @@ export function createIngestion({
const payload = await fetchGbfsJson(city.stationInformationUrl, {
signal: controller.signal,
});
const stationMap = parts.model.parseStationInformation(payload);
const stationMap = parts.model.stationsWithinCity(
city,
parts.model.parseStationInformation(payload),
);
if (stationMap.size === 0) {
throw new Error(`No station information for ${city.city}`);
}
@@ -0,0 +1,174 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { CITY_BY_ID } from './registry.js';
import { createIngestion } from './ingestion.js';
import { createModel } from './model.js';
import { createViewport } from './viewport.js';
import { isAllowedGbfsHost, isAllowedGbfsPath } from '../../data/gbfsSource.js';
// Each system's PBSC host, the station farthest from its registered centre,
// and the depots its live feed lists on other continents (checked 2026-10-16).
const CITIES = {
'bogota-tembici': {
host: 'bogota.publicbikesystem.net',
provider: 'Tembici Bogotá',
stations: [
{ name: '067 - KR 7B Bis con CL 152A', lat: 4.727729, lon: -74.026271 },
],
depots: [
{
station_id: '316',
name: 'Calle 13 - 316',
lat: -23.4008649,
lon: -46.3972181,
},
],
},
'santiago-bike-itau': {
host: 'santiago.publicbikesystem.net',
provider: 'Bike Itaú Santiago',
stations: [
{ name: 'LC077 - Mall Sport', lat: -33.3707813, lon: -70.5073122 },
],
depots: [
{
station_id: '459',
name: 'Galpon - 459',
lat: 40.4167747,
lon: -3.7037899,
},
{
station_id: '440',
name: 'Galpon - 440',
lat: -23.5690975,
lon: -46.697366,
},
],
},
};
// The production range check, with no viewer or fetches behind it.
const parts = {
model: createModel({ state: {}, services: {}, parts: {}, source: null }),
};
const { computeInRangeCities } = createViewport({
state: {},
services: { render: { governorRequestRender() {} } },
parts,
source: null,
});
test('Bogotá and Santiago are registered and readable through the proxy', () => {
for (const [id, expected] of Object.entries(CITIES)) {
const city = CITY_BY_ID.get(id);
assert.ok(city, `${id} is registered`);
assert.equal(city.provider, expected.provider);
assert.deepEqual(city.hosts, [expected.host]);
for (const feed of [city.stationInformationUrl, city.stationStatusUrl]) {
const url = new URL(feed);
assert.ok(isAllowedGbfsHost(url.hostname), `${id} host ${url.hostname}`);
assert.ok(isAllowedGbfsPath(url.pathname), `${id} path ${url.pathname}`);
}
}
});
test('a camera over each outermost station activates its system', () => {
for (const [id, expected] of Object.entries(CITIES)) {
for (const station of expected.stations) {
assert.ok(
computeInRangeCities({ lat: station.lat, lon: station.lon }).has(id),
`${station.name} activates ${id}`,
);
}
}
});
test('each load radius reaches its outermost stations', () => {
// The range check floors every radius at 100 km; the station filter and
// get_bike_share use the radius as registered.
for (const [id, expected] of Object.entries(CITIES)) {
const city = CITY_BY_ID.get(id);
for (const station of expected.stations) {
const distance = parts.model.haversineKm(
city.centerLat,
city.centerLon,
station.lat,
station.lon,
);
assert.ok(
distance <= city.loadRadiusKm,
`${station.name} is ${distance.toFixed(1)} km from the ${id} centre`,
);
}
}
});
test('stations a feed lists on another continent are not loaded', async () => {
for (const [id, expected] of Object.entries(CITIES)) {
const city = CITY_BY_ID.get(id);
const outermost = expected.stations[0];
const payload = {
data: {
stations: [
{
station_id: 'local',
name: outermost.name,
lat: outermost.lat,
lon: outermost.lon,
},
...expected.depots,
],
},
};
const state = {
_stationInfoCache: new Map(),
_inFlightInfo: new Map(),
_inFlightStatus: new Map(),
_loadingOps: 0,
_loading: false,
};
const ingestion = createIngestion({
state,
services: {},
parts,
source: {
async getStations(url) {
assert.equal(url, city.stationInformationUrl);
return payload;
},
},
});
const stations = await ingestion.loadCityStationInfo(id, 1);
assert.deepEqual(
[...stations.keys()],
['local'],
`${id} keeps only local stations`,
);
assert.equal(state._stationInfoCache.get(id), stations);
}
});
test('a feed with only far-away stations reports no station information', async () => {
const city = CITY_BY_ID.get('santiago-bike-itau');
const ingestion = createIngestion({
state: {
_stationInfoCache: new Map(),
_inFlightInfo: new Map(),
_inFlightStatus: new Map(),
_loadingOps: 0,
_loading: false,
},
services: {},
parts,
source: {
async getStations() {
return { data: { stations: CITIES['santiago-bike-itau'].depots } };
},
},
});
await assert.rejects(
ingestion.loadCityStationInfo('santiago-bike-itau', 1),
new RegExp(`No station information for ${city.city}`),
);
});
+25
View File
@@ -39,11 +39,36 @@ export function createModel({ state: layerState, services, parts, source }) {
return 6371 * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}
/**
* Drop stations outside a city's load radius. Some operators list depots on
* other continents as in-service stations in a city's feed (Bogotá's and
* Santiago's PBSC feeds carry warehouses in São Paulo and Madrid), which
* would otherwise draw as live docks thousands of kilometres away.
* @param {{ centerLat: number, centerLon: number, loadRadiusKm: number }} city
* @param {Map<string, Object>} stationMap - Parsed station info map.
* @returns {Map<string, Object>} Stations within the city's load radius.
*/
function stationsWithinCity(city, stationMap) {
const kept = new Map();
for (const [stationId, station] of stationMap) {
const distance = haversineKm(
city.centerLat,
city.centerLon,
station.lat,
station.lon,
);
if (distance <= city.loadRadiusKm) kept.set(stationId, station);
}
return kept;
}
return {
normalizeGbfsBool,
toNonNegativeInteger,
stationKey,
haversineKm,
stationsWithinCity,
extractStationsArray,
parseStationInformation,
parseStationStatus,
+24
View File
@@ -330,6 +330,30 @@ const RAW_GBFS_CITY_REGISTRY = [
'https://curitiba.publicbikesystem.net/customer/gbfs/v2/en/station_status.json',
provider: 'Bike Itaú',
},
{
id: 'bogota-tembici',
city: 'Bogotá, Colombia',
centerLat: 4.6533,
centerLon: -74.0836,
loadRadiusKm: 90,
stationInformationUrl:
'https://bogota.publicbikesystem.net/customer/gbfs/v2/en/station_information.json',
stationStatusUrl:
'https://bogota.publicbikesystem.net/customer/gbfs/v2/en/station_status.json',
provider: 'Tembici Bogotá',
},
{
id: 'santiago-bike-itau',
city: 'Santiago, Chile',
centerLat: -33.4372,
centerLon: -70.6506,
loadRadiusKm: 90,
stationInformationUrl:
'https://santiago.publicbikesystem.net/customer/gbfs/v2/en/station_information.json',
stationStatusUrl:
'https://santiago.publicbikesystem.net/customer/gbfs/v2/en/station_status.json',
provider: 'Bike Itaú Santiago',
},
{
id: 'toronto-bike-share',
city: 'Toronto, ON',
+12 -5
View File
@@ -1,4 +1,5 @@
import * as Cesium from 'cesium';
import { t } from '../../i18n/index.js';
import { isPointerFree } from '../../data/inputOwnership.js';
import {
BIKESHARE_SELECTED_OVERLAY_SOURCE_ID,
@@ -22,7 +23,9 @@ export function createSelection({
const stationName = String(record?.stationName || '').trim();
const stationLabel =
stationName ||
(record?.stationId ? `Station ${record.stationId}` : 'Station');
(record?.stationId
? t('Station {id}', { id: record.stationId })
: t('Station'));
const bikes = Number.isFinite(record?.bikesAvailable)
? record.bikesAvailable
: '?';
@@ -33,14 +36,18 @@ export function createSelection({
const lines = [
stationLabel,
`🚲 ${bikes} avail · ${docks} docks · ${capacity} cap`,
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('⚠️ 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('⚠️ 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(' · '));
}
+5 -1
View File
@@ -1,3 +1,5 @@
import { t } from '../../i18n/index.js';
/**
* @module headingConfidence
*
@@ -43,5 +45,7 @@ export function isHeadingEstimated(camera) {
*/
export function headingHudToken(camera) {
const hdg = Math.round(Number(camera?.headingDeg) || 0);
return `HDG ${hdg}°${isHeadingEstimated(camera) ? ' (ESTIMATED)' : ''}`;
return isHeadingEstimated(camera)
? t('HDG {value}° (ESTIMATED)', { value: hdg })
: t('HDG {value}°', { value: hdg });
}
+21 -2
View File
@@ -66,6 +66,7 @@ const RECORDS = [
type: 'bullet',
url: 'https://www.cctv-database.com/camera/hikvision-ds-2cd2087g3-li2uy/',
fov_deg: '108.8 horizontal (2.8mm) / 93.3 horizontal (4mm)',
// i18n-ignore: datasheet label
resolution: { mp: 8, w: 3840, h: 2160, label: '8MP (4K)' },
night_vision: { type: 'color', range_m: 40 },
environment: ['outdoor'],
@@ -78,6 +79,7 @@ const RECORDS = [
type: 'dome',
url: 'https://www.cctv-database.com/camera/axis-f4105-lre/',
fov_deg: '110 horizontal, 60 vertical',
// i18n-ignore: datasheet label
resolution: { mp: 2, w: 1920, h: 1080, label: '1080p HD' },
night_vision: { type: 'ir', range_m: 10 },
environment: ['outdoor'],
@@ -95,6 +97,7 @@ const RECORDS = [
mp: 8,
w: 1920,
h: 1080,
// i18n-ignore: datasheet label
label: '4x 2MP (8MP total; 4x 1080p)',
},
lens_count: 4,
@@ -117,6 +120,7 @@ const RECORDS = [
mp: 8,
w: 1920,
h: 1080,
// i18n-ignore: datasheet label
label: '4x 2MP (8MP total; 4x 1080p)',
},
lens_count: 4,
@@ -130,6 +134,7 @@ const RECORDS = [
url: 'https://www.cctv-database.com/camera/axis-q6054-e/',
aliases: ['AXIS Q6054-E', 'Q6054-E 50 Hz', 'Q6054-E 60 Hz'],
fov_deg: '62.9-2.2 horizontal',
// i18n-ignore: datasheet label
resolution: { mp: 1, w: 1280, h: 720, label: '720p HD' },
ptz: true,
autotracking: true,
@@ -144,6 +149,7 @@ const RECORDS = [
url: 'https://www.cctv-database.com/camera/axis-q6155-e/',
aliases: ['AXIS Q6155-E', 'Q6155-E 50 Hz', 'Q6155-E 60 Hz'],
fov_deg: '66.7-2.36 horizontal',
// i18n-ignore: datasheet label
resolution: { mp: 2, w: 1920, h: 1080, label: '1080p HD' },
ptz: true,
autotracking: true,
@@ -165,6 +171,7 @@ const RECORDS = [
'VG4-500',
],
fov_deg: '57.8-1.7 horizontal',
// i18n-ignore: datasheet label
resolution: { mp: 0.4, label: '540 TVL (NTSC/PAL)' },
ptz: true,
autotracking: true,
@@ -179,6 +186,7 @@ const RECORDS = [
url: 'https://www.cctv-database.com/camera/bosch-autodome-ip-dynamic-7000-hd/',
aliases: ['AUTODOME IP dynamic 7000 HD', 'AUTODOME 7000 dynamic HD'],
fov_deg: '65-2.3 horizontal',
// i18n-ignore: datasheet label
resolution: { mp: 2, w: 1920, h: 1080, label: '1080p HD (2MP)' },
ptz: true,
autotracking: true,
@@ -198,6 +206,7 @@ const RECORDS = [
'VG5-7130',
],
fov_deg: '59-2.1 horizontal',
// i18n-ignore: datasheet label
resolution: { mp: 1, w: 1280, h: 720, label: '720p HD (1.3MP sensor)' },
ptz: true,
autotracking: true,
@@ -217,6 +226,7 @@ const RECORDS = [
'NDP-5502-30',
],
fov_deg: '60.9-2.4 horizontal',
// i18n-ignore: datasheet label
resolution: { mp: 2, w: 1920, h: 1080, label: '1080p HD (2MP)' },
ptz: true,
varifocal: true,
@@ -235,6 +245,7 @@ const RECORDS = [
'NDP-7512-Z30K',
],
fov_deg: '64.7-2.3 horizontal',
// i18n-ignore: datasheet label
resolution: { mp: 2, w: 1920, h: 1080, label: '1080p HD (2MP)' },
ptz: true,
autotracking: true,
@@ -268,6 +279,7 @@ const RECORDS = [
'iDome 3925',
'Cohu iDome 3920 Series',
],
// i18n-ignore: datasheet label
resolution: { mp: 0.4, label: 'NTSC / 470 TVL' },
ptz: true,
varifocal: true,
@@ -280,6 +292,7 @@ const RECORDS = [
type: 'ptz',
url: 'https://www.cctv-database.com/camera/cohu-3950-iview/',
aliases: ['3950', '3950 Series', 'Cohu 3950 Series iVIEW'],
// i18n-ignore: datasheet label
resolution: { mp: 0.4, label: 'NTSC / 470 TVL' },
ptz: true,
varifocal: true,
@@ -292,6 +305,7 @@ const RECORDS = [
type: 'ptz',
url: 'https://www.cctv-database.com/camera/cohu-3960/',
aliases: ['3960 Series', '3964', '3965', 'Cohu 3960 iView'],
// i18n-ignore: datasheet label
resolution: { mp: 0.4, label: 'NTSC/PAL / 470 TVL' },
ptz: true,
varifocal: true,
@@ -500,16 +514,21 @@ export function isDatasheetFov(camera) {
* HUD token for a datasheet FOV that describes capability rather than the
* camera's current state, e.g. 'SPEC FOV 2.4–58° (CAPABILITY)'.
* @param {Object} camera - Catalog camera (see applyModelEnrichment).
* @param {(span: string) => string} [format] Builds the token from the
* degree span; the HUD passes a translated one.
* @returns {string|null} Null when the datasheet FOV already IS the camera's
* FOV (fixed lens) or no datasheet range is known.
*/
export function specFovCapabilityToken(camera) {
export function specFovCapabilityToken(
camera,
format = (span) => `SPEC FOV ${span}° (CAPABILITY)`,
) {
if (!camera || camera.fovSource === 'datasheet') return null;
const { specFovMinDeg: min, specFovMaxDeg: max } = camera;
if (!Number.isFinite(min) || !Number.isFinite(max)) return null;
const fmt = (deg) => (deg < 10 ? deg.toFixed(1) : String(Math.round(deg)));
const span = min === max ? fmt(max) : `${fmt(min)}–${fmt(max)}`;
return `SPEC FOV ${span}° (CAPABILITY)`;
return format(span);
}
/**
+18 -18
View File
@@ -179,7 +179,7 @@ export const CAMERA_SEEDS = [
id: 'nyc-midtown-w',
cityId: 'nyc',
poiIndex: 1,
label: 'Midtown West @ 34th',
label: 'Midtown West @ 34th', // i18n-ignore: camera name
offsetNorthM: 120,
offsetEastM: -70,
headingDeg: 206,
@@ -191,7 +191,7 @@ export const CAMERA_SEEDS = [
id: 'nyc-wtc-n',
cityId: 'nyc',
poiIndex: 2,
label: 'WTC North Plaza',
label: 'WTC North Plaza', // i18n-ignore: camera name
offsetNorthM: 95,
offsetEastM: 34,
headingDeg: 164,
@@ -203,7 +203,7 @@ export const CAMERA_SEEDS = [
id: 'nyc-times-square-ne',
cityId: 'nyc',
poiIndex: 1,
label: 'Times Sq Northeast',
label: 'Times Sq Northeast', // i18n-ignore: camera name
offsetNorthM: 230,
offsetEastM: 120,
headingDeg: 218,
@@ -216,7 +216,7 @@ export const CAMERA_SEEDS = [
id: 'sf-market-5th',
cityId: 'sf',
poiIndex: 2,
label: 'Market & 5th',
label: 'Market & 5th', // i18n-ignore: camera name
offsetNorthM: -160,
offsetEastM: 80,
headingDeg: 320,
@@ -228,7 +228,7 @@ export const CAMERA_SEEDS = [
id: 'sf-financial-district',
cityId: 'sf',
poiIndex: 1,
label: 'SF Financial Core',
label: 'SF Financial Core', // i18n-ignore: camera name
offsetNorthM: 110,
offsetEastM: 52,
headingDeg: 205,
@@ -241,7 +241,7 @@ export const CAMERA_SEEDS = [
id: 'tokyo-shibuya-scramble',
cityId: 'tokyo',
poiIndex: 4,
label: 'Shibuya Crossing',
label: 'Shibuya Crossing', // i18n-ignore: camera name
offsetNorthM: 180,
offsetEastM: 46,
headingDeg: 18,
@@ -253,7 +253,7 @@ export const CAMERA_SEEDS = [
id: 'tokyo-ginza-core',
cityId: 'tokyo',
poiIndex: 0,
label: 'Ginza Core',
label: 'Ginza Core', // i18n-ignore: camera name
offsetNorthM: -180,
offsetEastM: 150,
headingDeg: 245,
@@ -265,7 +265,7 @@ export const CAMERA_SEEDS = [
id: 'tokyo-asakusa-n',
cityId: 'tokyo',
poiIndex: 3,
label: 'Asakusa North Gate',
label: 'Asakusa North Gate', // i18n-ignore: camera name
offsetNorthM: 110,
offsetEastM: -65,
headingDeg: 192,
@@ -278,7 +278,7 @@ export const CAMERA_SEEDS = [
id: 'london-city-a1',
cityId: 'london',
poiIndex: 4,
label: 'City Cluster A1',
label: 'City Cluster A1', // i18n-ignore: camera name
offsetNorthM: 80,
offsetEastM: 65,
headingDeg: 220,
@@ -290,7 +290,7 @@ export const CAMERA_SEEDS = [
id: 'london-soho-core',
cityId: 'london',
poiIndex: 2,
label: 'Soho Core',
label: 'Soho Core', // i18n-ignore: camera name
offsetNorthM: 210,
offsetEastM: 120,
headingDeg: 206,
@@ -303,7 +303,7 @@ export const CAMERA_SEEDS = [
id: 'paris-rivoli',
cityId: 'paris',
poiIndex: 4,
label: 'Rue de Rivoli',
label: 'Rue de Rivoli', // i18n-ignore: camera name
offsetNorthM: 55,
offsetEastM: 85,
headingDeg: 248,
@@ -315,7 +315,7 @@ export const CAMERA_SEEDS = [
id: 'paris-champs-n',
cityId: 'paris',
poiIndex: 1,
label: 'Champs-Élysées North',
label: 'Champs-Élysées North', // i18n-ignore: camera name
offsetNorthM: 130,
offsetEastM: -38,
headingDeg: 175,
@@ -328,7 +328,7 @@ export const CAMERA_SEEDS = [
id: 'dc-mall-center',
cityId: 'dc',
poiIndex: 1,
label: 'National Mall Center',
label: 'National Mall Center', // i18n-ignore: camera name
offsetNorthM: 120,
offsetEastM: 20,
headingDeg: 258,
@@ -340,7 +340,7 @@ export const CAMERA_SEEDS = [
id: 'dc-pentagon-s',
cityId: 'dc',
poiIndex: 3,
label: 'Pentagon South',
label: 'Pentagon South', // i18n-ignore: camera name
offsetNorthM: -100,
offsetEastM: 92,
headingDeg: 14,
@@ -353,7 +353,7 @@ export const CAMERA_SEEDS = [
id: 'dubai-difc-loop',
cityId: 'dubai',
poiIndex: 4,
label: 'DIFC Loop',
label: 'DIFC Loop', // i18n-ignore: camera name
offsetNorthM: 92,
offsetEastM: -45,
headingDeg: 196,
@@ -365,7 +365,7 @@ export const CAMERA_SEEDS = [
id: 'dubai-downtown-east',
cityId: 'dubai',
poiIndex: 0,
label: 'Downtown East',
label: 'Downtown East', // i18n-ignore: camera name
offsetNorthM: -130,
offsetEastM: 190,
headingDeg: 322,
@@ -378,7 +378,7 @@ export const CAMERA_SEEDS = [
id: 'austin-congress-s',
cityId: 'austin',
poiIndex: 0,
label: 'Congress Southbound',
label: 'Congress Southbound', // i18n-ignore: camera name
offsetNorthM: -165,
offsetEastM: 40,
headingDeg: 12,
@@ -390,7 +390,7 @@ export const CAMERA_SEEDS = [
id: 'austin-downtown-west',
cityId: 'austin',
poiIndex: 1,
label: 'Downtown West',
label: 'Downtown West', // i18n-ignore: camera name
offsetNorthM: -120,
offsetEastM: -160,
headingDeg: 120,
+40 -14
View File
@@ -1,4 +1,5 @@
import { CCTV_AMBIENT_CARD_MAX } from '../../data/cctvLod.js';
import { t } from '../../i18n/index.js';
import { ACTIVE_FRAME_REFRESH_MS, IDLE_FRAME_REFRESH_MS } from './policy.js';
import { headingHudToken, isHeadingEstimated } from './headingConfidence.js';
import {
@@ -24,8 +25,13 @@ 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(
'{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(
@@ -39,29 +45,49 @@ export function createPresentation({
const calBadge = parts.calibration.deriveCalBadge(active.camera);
return [
`${active.camera.city.toUpperCase()} CCTV`,
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),
// Identified hardware labels its FOV as datasheet-sourced (catalog.js)
// only while no manual calibration offset has moved it.
`FOV ${Math.round(active.camera.fovDeg)}°${isDatasheetFov(active.camera) ? ' (DATASHEET)' : ''}`,
isDatasheetFov(active.camera)
? t('FOV {value}° (DATASHEET)', {
value: Math.round(active.camera.fovDeg),
})
: t('FOV {value}°', {
value: Math.round(active.camera.fovDeg),
}),
// A PTZ/varifocal datasheet range is what the lens can do, not its
// current zoom, so it is shown beside the estimate, never as it.
specFovCapabilityToken(active.camera),
`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()}`,
specFovCapabilityToken(active.camera, (span) =>
t('SPEC FOV {span}° (CAPABILITY)', { span }),
),
t('COVERAGE {value}km²', { value: area.toFixed(2) }),
overlapCount > 0
? 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
? `SRC ${String(health.sourceKind).toUpperCase()}`
: `SRC ${String(active.camera.feedType || 'image').toUpperCase()}`,
`${viewBand.toUpperCase()} CONTEXT`,
? t('SRC {value}', {
value: String(health.sourceKind).toUpperCase(),
})
: t('SRC {value}', {
value: String(active.camera.feedType || 'image').toUpperCase(),
}),
t('{value} CONTEXT', { value: viewBand.toUpperCase() }),
// Identified hardware: append the model's datasheet specs + attribution.
active.camera.spec
? `${specSummary(active.camera.spec).toUpperCase()} · SPECS: CCTV-DATABASE.COM`
? t('{spec} · SPECS: CCTV-DATABASE.COM', {
spec: specSummary(active.camera.spec).toUpperCase(),
})
: null,
]
.filter(Boolean)
+134
View File
@@ -0,0 +1,134 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createCyclonesLayer } from './index.js';
import { setLocale, t } from '../../i18n/index.js';
// The exact string the server sends (COVERAGE in server/providers/cyclones.js).
const SERVER_COVERAGE =
'Atlantic and eastern/central North Pacific; not worldwide cyclone coverage.';
const time = '2026-09-16T03:00:00.000Z';
const storm = {
id: 'ep152026',
name: 'Fifteen-E',
classification: 'PTC',
basin: 'EP',
position: { longitude: -125.8, latitude: 15.5 },
positionAt: time,
issuedAt: time,
advisoryNumber: '10',
windKt: 25,
pressureHpa: 1006,
geometryStatus: 'pending',
geometryAdvisoryNumber: null,
forecastPoints: [],
track: null,
cone: null,
advisoryUrl: 'https://www.nhc.noaa.gov/text/MIATCMEP5.shtml',
};
const snapshot = (storms, extra = {}) => ({
schemaVersion: 1,
storms,
unavailable: false,
stale: false,
fetchedAt: Date.parse(time),
coverage: SERVER_COVERAGE,
reason: null,
...extra,
});
function harness(feed) {
const rendering = {
async setSnapshot() {
return true;
},
setSelection() {},
getFocusSphere: () => ({ radius: 500000 }),
clear() {},
destroy() {},
getDiagnostics: () => ({ entities: 0, timerActive: false }),
};
const layer = createCyclonesLayer({
feed,
createRendering: () => rendering,
matchMedia: () => ({ matches: false }),
openLink: () => {},
});
layer.init({ camera: { flyToBoundingSphere() {} } });
layer.attachShellServices({ runNavigation: (fn) => fn() });
return layer;
}
const translated = () => t(SERVER_COVERAGE);
test('es: server snapshot without storms -> coverage line in Spanish', async () => {
setLocale('es');
try {
assert.notEqual(translated(), SERVER_COVERAGE, 'es.json has a translation');
const layer = harness({ getSnapshot: async () => snapshot([]) });
layer.enable();
await layer.update();
const info = layer.getRowControls().info;
assert.ok(
info.endsWith(translated()),
`expected Spanish coverage, got:\n${info}`,
);
layer.destroy();
} finally {
setLocale('en');
}
});
test('es: server snapshot with a storm -> coverage line in Spanish', async () => {
setLocale('es');
try {
const layer = harness({ getSnapshot: async () => snapshot([storm]) });
layer.enable();
await layer.update();
const info = layer.getRowControls().info;
assert.ok(
info.endsWith(translated()),
`expected Spanish coverage, got:\n${info}`,
);
layer.destroy();
} finally {
setLocale('en');
}
});
test('es: unavailable without reason -> fallback status not the English literal', async () => {
setLocale('es');
try {
const layer = harness({
getSnapshot: async () =>
snapshot([], { unavailable: true, reason: null }),
});
layer.enable();
await layer.update();
const status = layer.getRowControls().summary.status;
assert.notEqual(
status,
'Cyclone advisories unavailable',
`fallback shown in English: ${status}`,
);
layer.destroy();
} finally {
setLocale('en');
}
});
test('en: coverage and fallback status read as before', async () => {
const layer = harness({ getSnapshot: async () => snapshot([]) });
layer.enable();
await layer.update();
assert.ok(layer.getRowControls().info.endsWith(SERVER_COVERAGE));
layer.destroy();
const down = harness({
getSnapshot: async () => snapshot([], { unavailable: true, reason: null }),
});
down.enable();
await down.update();
assert.equal(
down.getRowControls().summary.status,
'Cyclone advisories unavailable',
);
down.destroy();
});
+97 -39
View File
@@ -14,23 +14,47 @@ import {
CYCLONE_OVERLAY_SOURCE_ID,
cycloneStormIdFromEntryId,
} from './labels.js';
import {
formatUtcDayMonth,
t,
tn,
msg,
translateKnown,
} from '../../i18n/index.js';
import { formatQuantity } from '../../ui/unitLabels.js';
// "10-12 15:00 UTC" never breaks across lines: no-break spaces, and a word
// joiner after the hyphen (JetBrains Mono has no U+2011 non-breaking hyphen).
const utc = (value) =>
value ? `${value.slice(5, 16).replace('T', ' ')} UTC` : 'Unavailable';
const COVERAGE =
'Atlantic and eastern/central North Pacific; not worldwide cyclone coverage.';
value
? `${formatUtcDayMonth(value).replace('-', '-\u2060')}\u00a0${value.slice(11, 16)}\u00a0UTC`
: t('Unavailable');
const COVERAGE = msg(
'Atlantic and eastern/central North Pacific; not worldwide cyclone coverage.',
);
// The server sends the same English coverage (server/providers/cyclones.js);
// data stays English and is translated here, where it is shown.
const coverageText = (coverage) =>
!coverage || coverage === COVERAGE ? t(COVERAGE) : translateKnown(coverage);
const ADVISORIES_UNAVAILABLE = msg('Cyclone advisories unavailable');
// Reason the server gives with an unavailable snapshot, translated when shown.
const SERVER_REASONS = new Set([msg('Cyclone data unavailable')]);
const errorText = (error) =>
error && (SERVER_REASONS.has(error) ? t(error) : translateKnown(error));
const CLASSIFICATION_NAMES = Object.freeze({
PTC: 'Potential tropical cyclone',
HU: 'Hurricane',
TS: 'Tropical storm',
TD: 'Tropical depression',
SS: 'Subtropical storm',
SD: 'Subtropical depression',
PTC: msg('Potential tropical cyclone'),
HU: msg('Hurricane'),
TS: msg('Tropical storm'),
TD: msg('Tropical depression'),
SS: msg('Subtropical storm'),
SD: msg('Subtropical depression'),
});
const classificationName = (code) =>
Object.hasOwn(CLASSIFICATION_NAMES, code) ? CLASSIFICATION_NAMES[code] : code;
Object.hasOwn(CLASSIFICATION_NAMES, code)
? t(CLASSIFICATION_NAMES[code])
: code;
const number = (value, unit) =>
value === null ? 'Unavailable' : `${value} ${unit}`;
value === null ? t('Unavailable') : formatQuantity(value, unit);
/** Advisory status and coherent forecast geometry; selected through the shared row list. */
export function createCyclonesLayer({
@@ -229,7 +253,7 @@ export function createCyclonesLayer({
snapshot = next;
selectedId = null;
++navigationGeneration;
error = next.reason || 'Cyclone advisories unavailable';
error = next.reason || ADVISORIES_UNAVAILABLE;
return true;
}
const applied = await rendering.setSnapshot(next, {
@@ -254,7 +278,7 @@ export function createCyclonesLayer({
return true;
} catch (cause) {
if (controller.signal.aborted || request !== controller) return false;
error = cause?.message || 'Cyclone advisories unavailable';
error = cause?.message || ADVISORIES_UNAVAILABLE;
// Failed acquisition has no bounded last-good age guarantee at this layer.
rendering?.clear();
snapshot = null;
@@ -316,41 +340,68 @@ export function createCyclonesLayer({
const geometry =
storm &&
(coherentCycloneGeometry(storm)
? 'Track and cone match this advisory'
? t('Track and cone match this advisory')
: storm.geometryStatus === 'pending'
? `Track/cone awaiting advisory ${storm.advisoryNumber}`
: 'Track/cone unavailable');
? t('Track/cone awaiting advisory {number}', {
number: storm.advisoryNumber,
})
: t('Track/cone unavailable'));
const status =
error ||
errorText(error) ||
(snapshot?.stale
? 'Cached advisory · stale source'
? t('Cached advisory · stale source')
: loading
? 'Loading advisories…'
? t('Loading advisories…')
: storm && !coherentCycloneGeometry(storm)
? geometry
: null);
const stormCount = snapshot?.storms.length || 0;
const activeStorms = tn(
stormCount,
'{count} active storm',
'{count} active storms',
);
const position = storm
? t('Position as of {time}', { time: utc(storm.positionAt) })
: '';
const intensity = storm
? t('Maximum sustained wind: {wind} · Pressure: {pressure}', {
wind: number(storm.windKt, 'kt'),
pressure: number(storm.pressureHpa, 'hPa'),
})
: '';
const detail = storm
? `${storm.name} · ${classificationName(storm.classification)} · Advisory ${storm.advisoryNumber} · issued ${utc(storm.issuedAt)}`
? t('{name} · {classification} · Advisory {number} · issued {time}', {
name: storm.name,
classification: classificationName(storm.classification),
number: storm.advisoryNumber,
time: utc(storm.issuedAt),
})
: empty
? 'No active NHC/CPHC systems'
: snapshot?.storms.length
? `${snapshot.storms.length} active storm${snapshot.storms.length === 1 ? '' : 's'}`
? t('No active NHC/CPHC systems')
: stormCount
? activeStorms
: loading
? 'Loading advisories…'
: 'Advisories unavailable';
? t('Loading advisories…')
: t('Advisories unavailable');
const controls = {
readout: true,
summary: {
label: 'Cyclones · NHC / CPHC',
coverage: 'Atlantic · E/C Pacific',
compact: snapshot?.storms.length
? `${snapshot.storms.length} active storm${snapshot.storms.length === 1 ? '' : 's'}${storm ? ` · ${storm.name} selected` : ''}`
label: t('Cyclones · NHC / CPHC'),
coverage: t('Atlantic · E/C Pacific'),
compact: stormCount
? storm
? t('{storms} · {name} selected', {
storms: activeStorms,
name: storm.name,
})
: activeStorms
: detail,
actions: storm?.advisoryUrl
? [
{
id: 'advisory',
label: 'Official advisory ↗',
label: t('Official advisory ↗'),
href: storm.advisoryUrl,
},
]
@@ -360,12 +411,12 @@ export function createCyclonesLayer({
? [
{
id: 'position',
text: `Position as of ${utc(storm.positionAt)}`,
text: position,
muted: true,
},
{
id: 'intensity',
text: `Maximum sustained wind: ${number(storm.windKt, 'kt')} · Pressure: ${number(storm.pressureHpa, 'hPa')}`,
text: intensity,
},
{ id: 'geometry', text: geometry, muted: true },
]
@@ -375,12 +426,12 @@ export function createCyclonesLayer({
units: 'kt',
},
list: {
ariaLabel: 'Active NHC and CPHC cyclone advisories',
ariaLabel: t('Active NHC and CPHC cyclone advisories'),
items: (snapshot?.storms || []).map((item, index) => ({
id: item.id,
ordinal: index + 1,
lead: item.basin,
text: `${item.name} · ${classificationName(item.classification)} · ${item.windKt === null ? 'Wind unavailable' : `${item.windKt} kt`}`,
text: `${item.name} · ${classificationName(item.classification)} · ${item.windKt === null ? t('Wind unavailable') : formatQuantity(item.windKt, 'kt')}`,
active: item.id === selectedId,
params: { stormId: item.id, focus: true },
})),
@@ -389,15 +440,22 @@ export function createCyclonesLayer({
legendCategorical: true,
legend: storm
? [
{ label: 'Advisory center / forecast track', color: '#7fe6ed' },
{ label: 'Center-track uncertainty cone', color: '#7fe6ed44' },
{
label: t('Advisory center / forecast track'),
color: '#7fe6ed',
},
{
label: t('Center-track uncertainty cone'),
color: '#7fe6ed44',
},
]
: [],
info: storm
? `${detail}\nPosition as of ${utc(storm.positionAt)}\nMaximum sustained wind: ${number(storm.windKt, 'kt')} · Pressure: ${number(storm.pressureHpa, 'hPa')}\n${geometry}${status && status !== geometry ? '\n' + status : ''}\n${snapshot.coverage}`
: `${detail}${status ? '\n' + status : ''}\n${snapshot?.coverage || COVERAGE}`,
infoTitle:
? `${detail}\n${position}\n${intensity}\n${geometry}${status && status !== geometry ? '\n' + status : ''}\n${coverageText(snapshot.coverage)}`
: `${detail}${status ? '\n' + status : ''}\n${coverageText(snapshot?.coverage)}`,
infoTitle: t(
'Select a storm on the map, or choose a storm in the list to select it and move the camera. Click empty map space to clear the selection. NOAA NHC/CPHC advisory context. The cone describes forecast center-track uncertainty, not storm size or the full hazard area. Forecast point labels are source lead hours, not times computed from advisory issuance. Geometry follows the surface; height is not weather altitude. Consult the official advisory.',
),
};
return controls;
},
+3 -1
View File
@@ -565,7 +565,9 @@ test('advisory selection uses accessible row descriptors and shared camera hando
1,
);
assert.equal(controls.summary.status, 'Track/cone awaiting advisory 10');
assert.match(controls.info, /09-16 03:00 UTC/);
// No line break inside the date: a word joiner holds the hyphen.
assert.match(controls.info, /issued 09-\u206016\u00a003:00\u00a0UTC/);
assert.match(controls.info, /: 25\u00a0kt · .*: 1006\u00a0hPa/);
assert.match(controls.infoTitle, /not storm size/);
assert.match(controls.infoTitle, /follows the surface/);
assert.equal(controls.list.items[1].params.focus, true);
+381 -53
View File
@@ -17,10 +17,10 @@
*/
import * as Cesium from 'cesium';
import {
formatRouteDistance,
formatRouteDuration,
} from '../../data/routeSteps.js';
import { roadLabel } from '../../data/routeSteps.js';
import { t, msg, formatNumber, translateKnown } from '../../i18n/index.js';
import { pointerOwnerName } from '../../data/pointerOwnerNames.js';
import { formatQuantity } from '../../ui/unitLabels.js';
export const DIRECTIONS_STEP_OVERLAY_SOURCE_ID = 'directions-step';
export const DIRECTIONS_STEP_OVERLAY_SOURCE_OPTIONS = Object.freeze({
@@ -31,12 +31,42 @@ export const DIRECTIONS_STEP_OVERLAY_SOURCE_OPTIONS = Object.freeze({
/** Travel modes, keyed by the `/api/route` profile name. */
export const DIRECTIONS_MODES = Object.freeze({
car: Object.freeze({ chip: 'DRIVE', word: 'Drive', icon: '🚗' }),
foot: Object.freeze({ chip: 'WALK', word: 'Walk', icon: '🚶' }),
bike: Object.freeze({ chip: 'BIKE', word: 'Bike', icon: '🚲' }),
car: Object.freeze({ chip: msg('DRIVE'), word: msg('Drive'), icon: '🚗' }),
foot: Object.freeze({ chip: msg('WALK'), word: msg('Walk'), icon: '🚶' }),
bike: Object.freeze({ chip: msg('BIKE'), word: msg('Bike'), icon: '🚲' }),
});
export const DEFAULT_DIRECTIONS_MODE = 'car';
/**
* The route proxy's refusal texts (server/providers/places/routes.js). They
* stay English on the wire and in the AI tools; the row shows them translated.
*/
const ROUTING_REFUSALS = new Set([
msg('rate limited'),
msg('routing service is rate limited'),
msg('route leg too long'),
msg('route too long'),
]);
/** A refusal text from the route proxy in the language; unknown ones as given. */
export function presentRoutingRefusal(text) {
return ROUTING_REFUSALS.has(text) ? t(text) : text;
}
/** The row text for a failed route request (createDirectionsSource errors). */
export function presentRoutingFailure(error) {
if (error?.status === 429) {
return error.refusal
? t('{error} — try again in a moment', {
error: presentRoutingRefusal(error.refusal),
})
: t('Routing is rate limited — try again in a moment');
}
if (Number.isFinite(error?.status) && error.status > 0)
return t('Routing unavailable (HTTP {status})', { status: error.status });
return error?.message || t('Routing unavailable');
}
/** Route colour — the annotation palette's cyan, so voice routes and Directions match. */
export const DIRECTIONS_ROUTE_COLOR = '#39d0ff';
const MARKER_A_COLOR = Cesium.Color.fromCssColorString('#5dff9f');
@@ -68,6 +98,269 @@ export const STEP_ANCHOR_FAST_ATTEMPTS = 5;
/** How often the row repaints the highlighted step while FLY is running (ms). */
export const FLIGHT_PROGRESS_MS = 250;
// ---------------------------------------------------------------------------
// Display text in the page's language. The proxy's English `instruction` stays
// the machine value; steps are phrased from their OSRM type/modifier/road.
// ---------------------------------------------------------------------------
/**
* Localized "850 m", "1.2 km", "21 km" (the rounding of `formatRouteDistance`
* in src/data/routeSteps.js); number and unit never break apart.
* @param {number} meters
* @returns {string}
*/
export function routeDistanceText(meters) {
if (!Number.isFinite(meters) || meters < 0) return '';
if (meters < 100) return formatQuantity(Math.round(meters), 'm');
if (meters < 1000) return formatQuantity(Math.round(meters / 10) * 10, 'm');
const km = meters / 1000;
return km < 10
? formatQuantity(km, 'km', {
minimumFractionDigits: 1,
maximumFractionDigits: 1,
})
: formatQuantity(Math.round(km), 'km');
}
/**
* Localized "40 s", "12 min", "1 h 5 min" (the rounding of
* `formatRouteDuration` in src/data/routeSteps.js); never broken apart.
* @param {number} seconds
* @returns {string}
*/
export function routeDurationText(seconds) {
if (!Number.isFinite(seconds) || seconds < 0) return '';
if (seconds < 60) return formatQuantity(Math.round(seconds), 's');
const minutes = Math.round(seconds / 60);
if (minutes < 60) return formatQuantity(minutes, 'min');
const hours = formatQuantity(Math.floor(minutes / 60), 'h');
const rest = minutes % 60;
return rest ? `${hours}\u00a0${formatQuantity(rest, 'min')}` : hours;
}
/** "left", "slightly right", … as it reads inside a maneuver sentence. */
function directionWord(modifier) {
switch (modifier) {
case 'left':
return t('left', { context: 'direction' });
case 'right':
return t('right', { context: 'direction' });
case 'slight left':
return t('slightly left', { context: 'direction' });
case 'slight right':
return t('slightly right', { context: 'direction' });
case 'sharp left':
return t('sharply left', { context: 'direction' });
case 'sharp right':
return t('sharply right', { context: 'direction' });
case 'straight':
return t('straight', { context: 'direction' });
case 'uturn':
return t('around', { context: 'direction' });
default:
return modifier;
}
}
/**
* The side to keep at a fork. English reuses the turn words; a language may
* need another form here ("держитесь левее", not "держитесь налево").
*/
function forkWord(modifier) {
switch (modifier) {
case 'left':
return t('left', { context: 'fork direction' });
case 'right':
return t('right', { context: 'fork direction' });
case 'slight left':
return t('slightly left', { context: 'fork direction' });
case 'slight right':
return t('slightly right', { context: 'fork direction' });
case 'sharp left':
return t('sharply left', { context: 'fork direction' });
case 'sharp right':
return t('sharply right', { context: 'fork direction' });
case 'straight':
return t('straight', { context: 'fork direction' });
default:
return directionWord(modifier);
}
}
/** The lane to use, as an adjective where a language needs one ("левую полосу"). */
function laneWord(modifier) {
switch (modifier) {
case 'left':
return t('left', { context: 'lane direction' });
case 'right':
return t('right', { context: 'lane direction' });
case 'slight left':
return t('slightly left', { context: 'lane direction' });
case 'slight right':
return t('slightly right', { context: 'lane direction' });
case 'sharp left':
return t('sharply left', { context: 'lane direction' });
case 'sharp right':
return t('sharply right', { context: 'lane direction' });
case 'straight':
return t('straight', { context: 'lane direction' });
default:
return directionWord(modifier);
}
}
/** "1st", "2nd", … for a roundabout exit. */
function exitOrdinal(exit) {
const number = formatNumber(exit);
switch (new Intl.PluralRules('en', { type: 'ordinal' }).select(exit)) {
case 'one':
return t('{number}st', { context: 'ordinal', number });
case 'two':
return t('{number}nd', { context: 'ordinal', number });
case 'few':
return t('{number}rd', { context: 'ordinal', number });
default:
return t('{number}th', { context: 'ordinal', number });
}
}
/**
* One maneuver in the page's language, from the step's OSRM type, modifier,
* exit and road. English matches `instructionFor` in src/data/routeSteps.js
* word for word. A step without a type (an older payload) keeps the proxy's
* English instruction.
* @param {{type?:string, modifier?:string|null, exit?:number|null, name?:string, ref?:string|null, instruction?:string}} step
* @returns {string}
*/
export function directionsInstruction(step) {
const type =
typeof step?.type === 'string' ? step.type.trim().toLowerCase() : '';
if (!type)
return typeof step?.instruction === 'string' ? step.instruction : '';
const modifier =
typeof step.modifier === 'string' ? step.modifier.trim().toLowerCase() : '';
const road = roadLabel(step);
const direction = directionWord(modifier);
const values = { road, direction };
switch (type) {
case 'depart':
return road ? t('Head out on {road}', values) : t('Head out');
case 'arrive':
if (modifier === 'left')
return t('Arrive at your destination, on the left');
if (modifier === 'right')
return t('Arrive at your destination, on the right');
return t('Arrive at your destination');
case 'turn':
if (modifier === 'uturn')
return road
? t('Make a U-turn onto {road}', values)
: t('Make a U-turn');
if (modifier === 'straight')
return road
? t('Continue straight onto {road}', values)
: t('Continue straight');
if (direction)
return road
? t('Turn {direction} onto {road}', values)
: t('Turn {direction}', values);
return road
? t('Turn onto {road}', values)
: t('Turn', { context: 'maneuver' });
case 'continue':
if (modifier === 'uturn')
return road
? t('Make a U-turn onto {road}', values)
: t('Make a U-turn');
if (direction && modifier !== 'straight')
return road
? t('Continue {direction} onto {road}', values)
: t('Continue {direction}', values);
return road
? t('Continue straight onto {road}', values)
: t('Continue straight');
case 'end of road':
if (direction)
return road
? t('At the end of the road, turn {direction} onto {road}', values)
: t('At the end of the road, turn {direction}', values);
return road
? t('At the end of the road, continue onto {road}', values)
: t('At the end of the road, continue');
case 'fork':
if (direction) {
const fork = { road, direction: forkWord(modifier) };
return road
? t('Keep {direction} at the fork onto {road}', fork)
: t('Keep {direction} at the fork', fork);
}
return road
? t('Continue at the fork onto {road}', values)
: t('Continue at the fork');
case 'merge':
if (direction)
return road
? t('Merge {direction} onto {road}', values)
: t('Merge {direction}', values);
return road
? t('Merge onto {road}', values)
: t('Merge', { context: 'maneuver' });
case 'on ramp':
if (direction)
return road
? t('Take the ramp on the {direction} onto {road}', values)
: t('Take the ramp on the {direction}', values);
return road ? t('Take the ramp onto {road}', values) : t('Take the ramp');
case 'off ramp':
if (direction)
return road
? t('Take the exit on the {direction} onto {road}', values)
: t('Take the exit on the {direction}', values);
return road ? t('Take the exit onto {road}', values) : t('Take the exit');
case 'roundabout':
case 'rotary':
if (Number.isInteger(step.exit) && step.exit >= 1) {
const exit = exitOrdinal(step.exit);
return road
? t('At the roundabout, take the {exit} exit onto {road}', {
exit,
road,
})
: t('At the roundabout, take the {exit} exit', { exit });
}
return road
? t('Enter the roundabout onto {road}', values)
: t('Enter the roundabout');
case 'roundabout turn':
if (direction)
return road
? t('At the roundabout, turn {direction} onto {road}', values)
: t('At the roundabout, turn {direction}', values);
return road
? t('At the roundabout, continue onto {road}', values)
: t('At the roundabout, continue');
case 'exit roundabout':
case 'exit rotary':
return road
? t('Exit the roundabout onto {road}', values)
: t('Exit the roundabout');
case 'use lane':
if (direction) {
const lane = { road, direction: laneWord(modifier) };
return road
? t('Use the {direction} lane onto {road}', lane)
: t('Use the {direction} lane', lane);
}
return road
? t('Continue onto {road}', values)
: t('Continue', { context: 'maneuver' });
default:
return road
? t('Continue onto {road}', values)
: t('Continue', { context: 'maneuver' });
}
}
// ---------------------------------------------------------------------------
// Pure helpers (exported for tests)
// ---------------------------------------------------------------------------
@@ -107,32 +400,35 @@ export function directionsRowControls(state) {
const routing = status === 'routing';
const chips = Object.entries(DIRECTIONS_MODES).map(([id, spec]) => ({
id: `mode-${id}`,
label: spec.chip,
label: t(spec.chip),
active: mode === id,
state: mode === id ? 'active' : 'idle',
title: `${spec.word} — reroute for ${spec.word.toLowerCase()}`,
title: t('{mode} — reroute for {modeLower}', {
mode: t(spec.word),
modeLower: t(spec.word).toLowerCase(),
}),
params: { mode: id },
}));
chips.push({
id: 'set-a',
label: armed === 'a' ? 'CLICK MAP' : a ? 'A ✓' : 'SET A',
label: armed === 'a' ? t('CLICK MAP') : a ? 'A ✓' : t('SET A'),
active: armed === 'a',
state: armed === 'a' ? 'active' : 'idle',
title:
armed === 'a'
? 'Click a spot on the globe to place A (click again to cancel)'
: 'Then click the globe to place the start',
? t('Click a spot on the globe to place A (click again to cancel)')
: t('Then click the globe to place the start'),
params: { arm: armed === 'a' ? null : 'a' },
});
chips.push({
id: 'set-b',
label: armed === 'b' ? 'CLICK MAP' : b ? 'B ✓' : 'SET B',
label: armed === 'b' ? t('CLICK MAP') : b ? 'B ✓' : t('SET B'),
active: armed === 'b',
state: armed === 'b' ? 'active' : 'idle',
title:
armed === 'b'
? 'Click a spot on the globe to place B (click again to cancel)'
: 'Then click the globe to place the destination',
? t('Click a spot on the globe to place B (click again to cancel)')
: t('Then click the globe to place the destination'),
params: { arm: armed === 'b' ? null : 'b' },
});
chips.push({
@@ -140,26 +436,28 @@ export function directionsRowControls(state) {
label: '⇄',
disabled: !(a && b) || routing,
state: 'idle',
title: 'Swap A and B',
title: t('Swap A and B'),
params: { swap: true },
});
const flying = Number.isInteger(flightStep);
chips.push({
id: 'fly',
label: routing ? 'FLY ···' : flying ? 'FLYING' : 'FLY',
label: routing ? t('FLY ···') : flying ? t('FLYING') : t('FLY'),
disabled: !route || routing,
busy: routing || flying,
active: flying,
state: routing || flying ? 'loading' : 'idle',
title: route ? 'Fly the camera along the route' : 'Place A and B first',
title: route
? t('Fly the camera along the route')
: t('Place A and B first'),
params: { fly: true },
});
chips.push({
id: 'clear',
label: 'CLEAR',
label: t('CLEAR', { context: 'action' }),
disabled: !a && !b && !route,
state: 'idle',
title: 'Remove the route and both markers',
title: t('Remove the route and both markers'),
params: { clear: true },
});
return {
@@ -179,8 +477,8 @@ export function directionsRowControls(state) {
*/
export const POINTER_TOOL_EXITS = Object.freeze({
draw: Object.freeze({
name: 'Draw',
leave: 'press Escape twice to leave Draw',
name: msg('Draw'),
leave: msg('press Escape twice to leave Draw'),
}),
});
@@ -201,9 +499,16 @@ export function pointerBlockedMessage(owner, which) {
if (!id || id === DIRECTIONS_POINTER_OWNER) return null;
const endpoint = which === 'b' ? 'B' : 'A';
const known = POINTER_TOOL_EXITS[id];
const name = known?.name || id;
const leave = known?.leave || `turn ${name} off`;
return `${name} is active — ${leave}, then set ${endpoint}`;
if (!known)
return t('{name} is active — turn {name} off, then set {endpoint}', {
name: translateKnown(pointerOwnerName(id)),
endpoint,
});
return t('{name} is active — {leave}, then set {endpoint}', {
name: t(known.name),
leave: t(known.leave),
endpoint,
});
}
/**
@@ -272,8 +577,8 @@ export function directionsStepList({
const items = steps.map((step, index) => ({
id: `step-${index}`,
ordinal: index + 1,
lead: formatRouteDistance(step.distanceM) || '—',
text: step.instruction,
lead: routeDistanceText(step.distanceM) || '—',
text: directionsInstruction(step),
active: index === active,
current: Number.isInteger(flightStep) && index === flightStep,
params: { step: index },
@@ -285,13 +590,15 @@ export function directionsStepList({
id: 'step-truncated',
ordinal: items.length + 1,
lead: '',
text: `Only the first ${steps.length} turns of this route are shown`,
text: t('Only the first {count} turns of this route are shown', {
count: steps.length,
}),
active: false,
current: false,
disabled: true,
});
}
return { ariaLabel: 'Turn-by-turn directions', items };
return { ariaLabel: t('Turn-by-turn directions'), items };
}
/**
@@ -331,12 +638,12 @@ export function directionsStats(state) {
armed,
pointerBlocked,
} = state;
const source = 'OSM routing';
const source = 'OSM routing'; // English in layer state; the layer row translates it
if (pointerBlocked) {
return {
count: route?.steps.length || 0,
lastUpdate,
error: 'Another map tool is using clicks — close it, then SET A again',
error: t('Another map tool is using clicks — close it, then SET A again'),
status: 'empty',
source,
};
@@ -347,7 +654,7 @@ export function directionsStats(state) {
lastUpdate,
error: null,
loading: true,
loadingLabel: 'Routing…',
loadingLabel: t('Routing…'),
source,
};
}
@@ -355,7 +662,7 @@ export function directionsStats(state) {
return {
count: 0,
lastUpdate,
error: error || 'No route found',
error: error || t('No route found'),
status: 'empty',
source,
};
@@ -364,10 +671,17 @@ export function directionsStats(state) {
// set (not only while loading), so the route summary and the placement
// guidance ride on it; `coverage` carries the same text for stats readers.
if (route) {
const word = DIRECTIONS_MODES[mode]?.word || mode;
const summary =
`${formatRouteDistance(route.distanceM)} · ${formatRouteDuration(route.durationS)} · ${word}` +
(route.stepsTruncated ? ` · first ${route.steps.length} turns` : '');
const spec = DIRECTIONS_MODES[mode];
const word = spec ? t(spec.word) : mode;
const values = {
distance: routeDistanceText(route.distanceM),
duration: routeDurationText(route.durationS),
mode: word,
count: route.steps.length,
};
const summary = route.stepsTruncated
? t('{distance} · {duration} · {mode} · first {count} turns', values)
: t('{distance} · {duration} · {mode}', values);
return {
count: route.steps.length,
lastUpdate,
@@ -378,10 +692,13 @@ export function directionsStats(state) {
};
}
let coverage;
if (armed) coverage = `Click the globe to place ${armed.toUpperCase()}`;
else if (a && !b) coverage = 'SET B, then click the globe';
else if (!a && b) coverage = 'SET A, then click the globe';
else coverage = 'SET A, then click the globe';
if (armed)
coverage = t('Click the globe to place {endpoint}', {
endpoint: armed.toUpperCase(),
});
else if (a && !b) coverage = t('SET B, then click the globe');
else if (!a && b) coverage = t('SET A, then click the globe');
else coverage = t('SET A, then click the globe');
return {
count: 0,
lastUpdate,
@@ -403,14 +720,23 @@ export function directionsStepCopy(steps, index) {
const step = steps[index];
const details = [];
const leg = [];
if (step.distanceM > 0) leg.push(formatRouteDistance(step.distanceM));
if (step.durationS > 0) leg.push(formatRouteDuration(step.durationS));
if (step.distanceM > 0) leg.push(routeDistanceText(step.distanceM));
if (step.durationS > 0) leg.push(routeDurationText(step.durationS));
const position = { step: index + 1, total: steps.length };
details.push(
`Step ${index + 1} of ${steps.length}${leg.length ? ` · then ${leg.join(' · ')}` : ''}`,
leg.length
? t('Step {step} of {total} · then {leg}', {
...position,
leg: leg.join(' · '),
})
: t('Step {step} of {total}', position),
);
const next = steps[index + 1];
if (next) details.push(`Then: ${next.instruction}`);
return { title: step.instruction, details };
if (next)
details.push(
t('Then: {instruction}', { instruction: directionsInstruction(next) }),
);
return { title: directionsInstruction(step), details };
}
/**
@@ -819,10 +1145,12 @@ export function createDirectionsLayer({ services, source }) {
_status = 'error';
_error =
payload?.error === 'no route found'
? 'No route found between A and B'
? t('No route found between A and B')
: payload?.error
? `Routing failed: ${payload.error}`
: 'No route found between A and B';
? t('Routing failed: {error}', {
error: presentRoutingRefusal(payload.error),
})
: t('No route found between A and B');
return;
}
_route = route;
@@ -839,10 +1167,10 @@ export function createDirectionsLayer({ services, source }) {
// happened instead.
_error =
error?.name === 'AbortError'
? 'Routing timed out'
? t('Routing timed out')
: error?.name === 'TypeError'
? 'Routing unavailable — no response from the routing service'
: error?.message || 'Routing unavailable';
? t('Routing unavailable — no response from the routing service')
: presentRoutingFailure(error);
} finally {
clearTimeout(timer);
if (_routeAbort === controller) _routeAbort = null;
@@ -1037,7 +1365,7 @@ export function createDirectionsLayer({ services, source }) {
[
{
type: 'route',
label: 'Directions',
label: t('Directions'),
path: _route.geometry.map(([lon, lat]) => ({ lon, lat, height: 0 })),
},
],
+20 -4
View File
@@ -1,3 +1,7 @@
// A source module (no browser globals, see check:boundaries), so no i18n
// import: failures stay English here and the layer translates them on show.
const msg = (text) => text;
/** Build the existing route-proxy query from geographic endpoints. */
export function directionsRequestUrl(mode, a, b) {
const coords = `${a.lon.toFixed(6)},${a.lat.toFixed(6)};${b.lon.toFixed(6)},${b.lat.toFixed(6)}`;
@@ -19,15 +23,27 @@ export function createDirectionsSource({
});
const payload = await response.json();
signal?.throwIfAborted();
// Failures keep their English message and carry the HTTP status and the
// server's refusal text, so the layer can show them in the language.
if (response.status === 429) {
throw new Error(
const refusal =
typeof payload?.error === 'string' && payload.error
? `${payload.error} — try again in a moment`
: 'Routing is rate limited — try again in a moment',
? payload.error
: null;
throw Object.assign(
new Error(
refusal
? `${refusal} — try again in a moment`
: msg('Routing is rate limited — try again in a moment'),
),
{ status: 429, refusal },
);
}
if (!response.ok && !payload?.error)
throw new Error(`Routing unavailable (HTTP ${response.status})`);
throw Object.assign(
new Error(`Routing unavailable (HTTP ${response.status})`),
{ status: response.status },
);
return payload;
},
};
+5 -1
View File
@@ -11,6 +11,10 @@ import {
export * from './model.js';
export { createUsgsEarthquakeSource } from './source.js';
// A portable package (no i18n): the error stays English data and the layer
// panel shows it translated.
const msg = (text) => text;
/** Own one earthquake display and its refresh lifecycle. */
export function createEarthquakesLayer({ source, overlayHost } = {}) {
if (typeof source?.getSnapshot !== 'function')
@@ -154,7 +158,7 @@ export function createEarthquakesLayer({ source, overlayHost } = {}) {
if (request.signal.aborted || _request !== request || !_enabled)
return false;
console.warn('[Data:Earthquakes] Fetch error:', e);
_lastError = e?.message || 'Earthquake source unavailable';
_lastError = e?.message || msg('Earthquake source unavailable');
return false;
} finally {
if (_request === request) _request = null;
+84
View File
@@ -0,0 +1,84 @@
// The AIS degradation chip and the traffic "SIMULATED — <reason>" text read in
// the UI language; English keeps its wording.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { setLocale, translateKnown } from '../i18n/index.js';
import { deriveAisFeedError } from '../data/aisLiveVessels.js';
import { createModel } from './traffic/model.js';
test('AIS degraded-feed reasons are localized in es', () => {
setLocale('es');
try {
const cases = [
[
{ status: 'down', reconnectAttempt: 3 },
'feed down — retrying slowly (attempt 3)',
],
[{ status: 'missing-key' }, 'AISSTREAM_API_KEY not set'],
[
{ status: 'stale', silentForMs: 90000 },
'feed silent 90s — no AIS data',
],
[{ status: 'auth-failed' }, 'API key rejected — check AISSTREAM_API_KEY'],
[{ status: 'connecting' }, 'connecting to feed…'],
[{ status: 'closed' }, 'feed disconnected'],
];
for (const [payload, english] of cases) {
// The layer panel runs stats.error through translateKnown(); emulate it.
const shown = translateKnown(deriveAisFeedError(payload, 0));
assert.notEqual(shown, english, `status ${payload.status} still English`);
}
} finally {
setLocale('en');
}
});
test('traffic "SIMULATED — <reason>" is localized in es', () => {
setLocale('es');
try {
const { trafficFeedPresentation } = createModel({
state: {},
services: {},
parts: {},
source: {},
});
const degraded = trafficFeedPresentation({
liveMode: true,
flowError: 'TomTom daily budget reached',
});
const shown = translateKnown(degraded.error);
assert.notEqual(shown, 'SIMULATED — TomTom daily budget reached');
assert.ok(!shown.startsWith('SIMULATED'), `still English: ${shown}`);
} finally {
setLocale('en');
}
});
test('English degraded-feed wording is unchanged', () => {
setLocale('en');
assert.equal(
deriveAisFeedError({ status: 'down', reconnectAttempt: 3 }, 0),
'feed down — retrying slowly (attempt 3)',
);
assert.equal(
deriveAisFeedError({ status: 'reconnecting' }, 0),
'reconnecting to feed…',
);
assert.equal(
deriveAisFeedError({ status: 'missing-key' }, 0),
'AISSTREAM_API_KEY not set',
);
const { trafficFeedPresentation } = createModel({
state: {},
services: {},
parts: {},
source: {},
});
assert.equal(
trafficFeedPresentation({
liveMode: true,
flowError: 'TomTom daily budget reached',
}).error,
'SIMULATED — TomTom daily budget reached',
);
});
+5 -1
View File
@@ -1,4 +1,5 @@
import * as Cesium from 'cesium';
import { t } from '../../i18n/index.js';
import {
FIRMS_OVERLAY_SOURCE_ID,
FIRMS_AMBIENT_COHORT_LIMIT,
@@ -115,7 +116,10 @@ export function createCards({
if (!card.interactive) return card;
return {
...card,
accessibilityLabel: `Focus fire detection ${card.title}, ${card.details.join(', ')}`,
accessibilityLabel: t('Focus fire detection {title}, {details}', {
title: card.title,
details: card.details.join(', '),
}),
activate: () => {
const fire = layerState._fireByCardId.get(card.id);
if (!fire) return false;
+3 -1
View File
@@ -1,5 +1,6 @@
import { adaptFirmsRecords } from '../../data/firmsAdapt.js';
import { fireDetectionKey } from '../../data/firmsLabels.js';
import { msg } from '../../i18n/index.js';
export function createIngestion({
layerState,
@@ -72,7 +73,8 @@ export function createIngestion({
)
return;
console.warn(`[Data:${id}] FIRMS live load failed:`, error);
layerState._error = 'live feed unavailable';
// English data (stats reach the voice tools); the panel translates it.
layerState._error = msg('live feed unavailable');
} finally {
if (layerState.request === request) {
layerState.request = null;
+80 -17
View File
@@ -1,4 +1,5 @@
import * as Cesium from 'cesium';
import { formatNumber, t } from '../../i18n/index.js';
import {
satelliteShortName,
fireDetectionKey,
@@ -329,14 +330,18 @@ export function createModel({
*/
function buildSelectedFireCard(fire, nowMs) {
const meta = [`${confidenceBucket(fire.confidence)} conf`];
const meta = [
t('{value} conf', {
value: confidenceLabel(confidenceBucket(fire.confidence)),
}),
];
if (fire.acqMs > 0) {
const age = formatAge(nowMs - fire.acqMs);
if (age) meta.push(`${age} ago`);
const age = formatAgeLabel(nowMs - fire.acqMs);
if (age) meta.push(t('{value} ago', { 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('sensor n/a'),
);
return {
id: `selected-fire:${fireDetectionKey(fire)}`,
@@ -346,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('FIRE · {frp} MW', { frp: formatFrpLabel(fire.frp) }),
details: [
meta.join(' · '),
formatLatLon(fire.lat, fire.lon) + (fire.night ? ' · NIGHT' : ''),
formatLatLonLabel(fire.lat, fire.lon) +
(fire.night ? ` · ${t('NIGHT')}` : ''),
],
selected: true,
priority: Number.MAX_SAFE_INTEGER,
@@ -368,9 +374,9 @@ export function createModel({
function buildFireCard(candidate, nowMs) {
const fire = candidate.fire;
const meta = [confidenceBucket(fire.confidence)];
const meta = [confidenceLabel(confidenceBucket(fire.confidence))];
if (fire.acqMs > 0) {
const age = formatAge(nowMs - fire.acqMs);
const age = formatAgeLabel(nowMs - fire.acqMs);
if (age) meta.push(age);
}
const sat = satelliteShortName(fire.satellite) || fire.sensor;
@@ -382,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('▲ {frp} MW', { frp: formatFrpLabel(fire.frp) }),
details: [meta.join(' · ')],
selected: false,
priority: Number(fire.frp) || 0,
@@ -400,11 +406,11 @@ 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('FIRE') : t('FIRES');
const parts = [t('max {value} MW', { value: formatFrpLabel(cell.maxFrp) })];
if (cell.newestAcqMs > 0) {
const age = formatAge(nowMs - cell.newestAcqMs);
if (age) parts.push(`new ${age}`);
const age = formatAgeLabel(nowMs - cell.newestAcqMs);
if (age) parts.push(t('new {value}', { value: age }));
}
return {
id: `cell:${cell.latCell ?? 'x'}:${cell.lonCell ?? 'x'}`,
@@ -477,6 +483,37 @@ export function createModel({
return frp >= 10 ? frp.toFixed(0) : frp.toFixed(1);
}
/** formatFrp for a card, with the locale's decimal separator ("4,9"). */
function formatFrpLabel(frp) {
const digits = frp >= 10 ? 0 : 1;
return formatNumber(frp, {
minimumFractionDigits: digits,
maximumFractionDigits: digits,
useGrouping: false,
});
}
/** formatLatLon for a card: "8,389° ю. ш. 44,162° з. д." in Russian. */
function formatLatLonLabel(lat, lon) {
const amount = (value) =>
formatNumber(Math.abs(value), {
minimumFractionDigits: 3,
maximumFractionDigits: 3,
useGrouping: false,
});
const latPart =
lat >= 0
? t('{value}°N', { value: amount(lat) })
: t('{value}°S', { value: amount(lat) });
const lonPart =
lon >= 0
? t('{value}°E', { value: amount(lon) })
: t('{value}°W', { value: amount(lon) });
return `${latPart} ${lonPart}`;
}
/** Millisecond delta → "<1h" / "Xh" / "Xd", or '' for invalid input. */
function formatAge(deltaMs) {
@@ -490,11 +527,37 @@ 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('just now');
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('<1m ago');
if (minutes < 90) return t('{count}m ago', { count: minutes });
return t('{count}h ago', { count: Math.round(minutes / 60) });
}
/** A confidence bucket as a card shows it, in the UI language. */
function confidenceLabel(bucket) {
switch (bucket) {
case 'high':
return t('high', { context: 'fire confidence' });
case 'nominal':
return t('nominal', { context: 'fire confidence' });
case 'low':
return t('low', { context: 'fire confidence' });
default:
return bucket;
}
}
/** formatAge for a card: "<1h", "24h", "3d" in the UI language ("24 ч"). */
function formatAgeLabel(deltaMs) {
if (!Number.isFinite(deltaMs) || deltaMs < 0) return '';
const hours = deltaMs / 3600000;
if (hours < 1) return t('<1h', { context: 'age' });
if (hours < 48)
return t('{count}h', { context: 'age', count: Math.round(hours) });
return t('{count}d', { context: 'age', count: Math.round(hours / 24) });
}
/** Normalized 0..1 confidence → low/nominal/high display bucket. */
+19 -9
View File
@@ -1,4 +1,5 @@
import { fireDetectionKey } from '../../data/firmsLabels.js';
import { t } from '../../i18n/index.js';
import { REFRESH_INTERVAL_MS } from './policy.js';
export function createQueries({
@@ -17,7 +18,9 @@ export function createQueries({
if (!fire) return null;
return {
id: fireDetectionKey(fire),
label: `Fire · FRP ${components.model.formatFrp(fire.frp)} MW`,
label: t('Fire · FRP {frp} MW', {
frp: components.model.formatFrp(fire.frp),
}),
latitude: fire.lat,
longitude: fire.lon,
frp: fire.frp,
@@ -59,21 +62,26 @@ export function createQueries({
getStats() {
const now = Date.now();
const staleText = layerState._lastUpdate
? `STALE · cached ${components.model.formatAge(now - layerState._lastUpdate) || '<1h'}`
: 'STALE';
? t('STALE · cached {age}', {
age:
components.model.formatAge(now - layerState._lastUpdate) || '<1h',
})
: t('STALE');
let loadingLabel = '';
if (layerState._loading) {
loadingLabel = layerState._fires.length
? 'refreshing...'
: 'loading...';
? t('refreshing...')
: t('loading...');
} else if (layerState._keyRequired) {
loadingLabel = 'KEY REQUIRED';
loadingLabel = t('KEY REQUIRED');
} 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('LIVE · updated {age}', {
age: components.model.formatAgoMinutes(now - layerState._lastUpdate),
});
}
return {
count: layerState._count,
@@ -87,7 +95,7 @@ export function createQueries({
// the operator can take.
keyRequired: layerState._keyRequired,
error: layerState._keyRequired
? 'KEY REQUIRED'
? t('KEY REQUIRED')
: layerState._stale
? staleText
: layerState._error,
@@ -109,7 +117,9 @@ export function createQueries({
latitude: strongest.lat,
longitude: strongest.lon,
frp: strongest.frp,
label: `Fire · FRP ${components.model.formatFrp(strongest.frp)} MW`,
label: t('Fire · FRP {frp} MW', {
frp: components.model.formatFrp(strongest.frp),
}),
};
},
+5 -2
View File
@@ -5,6 +5,7 @@ import {
import * as Cesium from 'cesium';
import { isPointerFree } from '../../data/inputOwnership.js';
import { CONTEXT_TOP_N } from './policy.js';
import { t } from '../../i18n/index.js';
export function createSelection({
layerState,
@@ -80,7 +81,7 @@ export function createSelection({
requestWorldFocus({
kind: 'fire',
id: fireDetectionKey(fire),
label: 'FIRE',
label: t('FIRE'),
position: components.model.firePosition(fire),
});
}
@@ -192,7 +193,9 @@ export function createSelection({
layerName: name,
source: 'NASA FIRMS',
dataSource: layerState._dataSource,
label: `Fire · FRP ${components.model.formatFrp(fire.frp)} MW`,
label: t('Fire · FRP {frp} MW', {
frp: components.model.formatFrp(fire.frp),
}),
latitude: fire.lat,
longitude: fire.lon,
properties: {
+9 -2
View File
@@ -1,5 +1,9 @@
import { ERROR_BACKOFF_INTERVAL } from './recordPolicy.js';
// Marks English the Layers panel translates when it shows the error. Kept
// local: this module stays free of browser globals (package boundaries).
const msg = (text) => text;
/** Own acquisition, cancellation, freshness and backoff independently of rendering. */
export function createIngestion({
feed,
@@ -41,9 +45,10 @@ export function createIngestion({
const sourceStale = snapshot.stale || snapshot.freshness === 'unknown';
feed._backoff = sourceStale;
feed._retryAt = 0;
// i18n-ignore: English data; the panel shows it through FEED_ERROR_PATTERNS (ui/statusText.js)
feed._lastError = sourceStale
? sourceAgeMs == null
? 'Source snapshot time unavailable'
? msg('Source snapshot time unavailable')
: `Source snapshot ${Math.max(2, Math.round(sourceAgeMs / 60_000))} min old`
: null;
feed._lastSource = snapshot.source;
@@ -77,7 +82,9 @@ export function createIngestion({
setSourceLabel(feed._lastSource);
}
feed._lastError =
e?.name === 'LiveSourceError' ? e.message : 'Live data unavailable';
e?.name === 'LiveSourceError'
? e.message
: msg('Live data unavailable');
} finally {
feed._activeUpdateControllers.delete(resourceController);
}
+13 -3
View File
@@ -1,4 +1,6 @@
import * as Cesium from 'cesium';
import { formatNumber, t } from '../../i18n/index.js';
import { unitLabel } from '../../ui/unitLabels.js';
import { nextCockpitNearContacts } from '../../data/cockpitAirLod.js';
import { trackedModelZoomActive } from '../../data/trackedModelRegime.js';
import {
@@ -130,6 +132,7 @@ export function createTracking({
icao24,
// Honesty cue: the contact is coasting on dead reckoning, so the
// narrated position/velocity are last-known rather than live.
// i18n-ignore: voice-context data, English for the model
status: described.stale ? 'stale (missed polls)' : 'live',
},
};
@@ -751,11 +754,18 @@ export function createTracking({
// the tail number rather than raw hex.
const cs = parts.queries._contactLabel(icao24, info);
const altFt = Math.round((info.altitude || 0) * 3.28084);
const fl = altFt >= 18000 ? `FL${Math.round(altFt / 100)}` : `${altFt} ft`;
const spd = info.velocity ? `${Math.round(info.velocity * 1.944)} kts` : '';
// Flight levels are an aviation code; feet and knots read in the active
// language ("0 фт · 1 уз").
const quantity = (value, unit) =>
`${formatNumber(value, { useGrouping: false })} ${unitLabel(unit)}`;
const fl =
altFt >= 18000 ? `FL${Math.round(altFt / 100)}` : quantity(altFt, 'ft');
const spd = info.velocity
? quantity(Math.round(info.velocity * 1.944), 'kts')
: '';
const stale =
flightState.records.missingPolls.get(icao24) || flightState.feed._backoff
? 'STALE'
? t('STALE')
: '';
const lines = [[cs, fl, spd, stale].filter(Boolean).join(' · ')];
// Converted contacts report their class as TR-3B and nothing else — the
+24 -11
View File
@@ -1,4 +1,6 @@
import * as Cesium from 'cesium';
import { formatNumber, msg, t } from '../../i18n/index.js';
import { translateFeedError } from '../../ui/statusText.js';
import {
GNSS_LEVEL_COLORS,
GNSS_MIN_AIRCRAFT,
@@ -25,9 +27,9 @@ class GnssGroundFill extends Cesium.GroundPrimitive {
/** Low stays visible over land so coverage reads apart from no data. */
const FILL_ALPHA = Object.freeze({ low: 0.3, medium: 0.42, high: 0.55 });
const LEVEL_LABELS = Object.freeze({
low: 'Low-accuracy score < 2%',
medium: 'Low-accuracy score 2–10%',
high: 'Low-accuracy score > 10%',
low: msg('Low-accuracy score < 2%'),
medium: msg('Low-accuracy score 2–10%'),
high: msg('Low-accuracy score > 10%'),
});
/** Degrees at the centre of the current view, or null before the camera settles. */
@@ -320,7 +322,7 @@ export function createGnssIntegrityLayer({
// Only acquisition failures are kept enabled for recovery.
if (received) throw e;
console.warn('[Data:GNSS] Fetch error:', e);
_lastError = e?.message || 'GNSS integrity source unavailable';
_lastError = e?.message || msg('GNSS integrity source unavailable');
// Keep the last cells only while their evidence is inside the window.
expire();
if (e?.retryable === false) return false;
@@ -372,23 +374,34 @@ export function createGnssIntegrityLayer({
return {
chips: [],
info:
'Regional sample · 250 nm radius · cells from visited views over 30 min' +
t(
'Regional sample · 250 nm radius · cells from visited views over 30 min',
) +
(_lastError && _retryAt
? ` · ${_lastError} · Retrying automatically${
? ` · ${translateFeedError(_lastError)} · ${
_retryAt <= 8640000000000000
? ` at ${new Date(_retryAt).toISOString().slice(11, 19)} UTC`
: ' after provider cooldown'
? t('Retrying automatically at {time} UTC', {
time: new Date(_retryAt).toISOString().slice(11, 19),
})
: t('Retrying automatically after provider cooldown')
}`
: ''),
infoTitle:
infoTitle: t(
'Updates automatically after pan or zoom and once a minute. The public feed covers a 250-nautical-mile circle around the view centre; zooming out does not make it a worldwide snapshot. Blank cells are outside sampled coverage or have insufficient observations.',
),
legend: ['high', 'medium', 'low'].map((level, index) => ({
label: LEVEL_LABELS[level],
label: t(LEVEL_LABELS[level]),
color: GNSS_LEVEL_COLORS[level],
count: _cells.filter((cell) => cell.level === level).length,
...(index === 0
? {
blurb: `Low-accuracy score = 100 × max(0, bad − 1) / total: one aircraft is discounted, so this is not the raw share. Cells need ${GNSS_MIN_AIRCRAFT} aircraft over ${provenance.window.minutes} minutes. Navigation-integrity anomalies are not a detection of jamming or spoofing.`,
blurb: t(
'Low-accuracy score = 100 × max(0, bad − 1) / total: one aircraft is discounted, so this is not the raw share. Cells need {aircraft} aircraft over {minutes} minutes. Navigation-integrity anomalies are not a detection of jamming or spoofing.',
{
aircraft: formatNumber(GNSS_MIN_AIRCRAFT),
minutes: formatNumber(provenance.window.minutes),
},
),
}
: {}),
})),
+9 -4
View File
@@ -1,5 +1,9 @@
import { readResponseJsonCapped } from '../../sources/httpBody.js';
// A portable export (package.json): no i18n here. Failures stay English data;
// the Layers panel shows them translated.
const msg = (text) => text;
function retryDelay(raw, nowMs) {
if (!raw) return null;
const seconds = Number(raw);
@@ -21,7 +25,7 @@ export function createAdsbGnssSource({
async getSnapshot({ latitude, longitude } = {}, { signal } = {}) {
signal?.throwIfAborted();
if (!Number.isFinite(latitude) || !Number.isFinite(longitude))
throw new Error('GNSS snapshot needs a view anchor');
throw new Error(msg('GNSS snapshot needs a view anchor'));
const params = new URLSearchParams({
lat: String(Math.round(latitude)),
lon: String(Math.round(longitude)),
@@ -37,8 +41,9 @@ export function createAdsbGnssSource({
);
throw Object.assign(
new Error(
// HTTP errors are matched by FEED_ERROR_PATTERNS in ui/statusText.js.
limited
? 'GNSS feed rate limited (HTTP 429)'
? msg('GNSS feed rate limited (HTTP 429)')
: `GNSS feed unavailable (HTTP ${response.status})`,
),
{
@@ -55,12 +60,12 @@ export function createAdsbGnssSource({
);
signal?.throwIfAborted();
if (!Array.isArray(payload?.rows))
throw new Error('Malformed GNSS integrity snapshot');
throw new Error(msg('Malformed GNSS integrity snapshot'));
// The snapshot's age (measured on the proxy's clock) dates every row in
// the rolling window; an undated snapshot could only be treated as new,
// so it is refused.
if (!Number.isFinite(payload.ageMs) || payload.ageMs < 0)
throw new Error('GNSS integrity snapshot has no observation time');
throw new Error(msg('GNSS integrity snapshot has no observation time'));
return {
rows: payload.rows,
ageMs: payload.ageMs,
@@ -0,0 +1,54 @@
// The GNSS source's own failures are English data (source.js); the
// card's info line shows them in the UI language, the HTTP form included.
import assert from 'node:assert/strict';
import test from 'node:test';
import * as Cesium from 'cesium';
import { setLocale } from '../../i18n/index.js';
import { createGnssIntegrityLayer } from './index.js';
async function failedLayer(message) {
const layer = createGnssIntegrityLayer({
source: {
async getSnapshot() {
throw Object.assign(new Error(message), {
retryable: true,
retryAfterMs: 60_000,
});
},
},
viewAnchor: () => ({ latitude: 50, longitude: 30 }),
now: () => 1_000,
monotonicNow: () => 1_000,
});
layer.init({
camera: { moveEnd: new Cesium.Event(), changed: new Cesium.Event() },
scene: {
globe: { show: true },
groundPrimitives: { add() {}, remove() {} },
},
});
layer.enable();
await layer.update();
return layer;
}
for (const locale of ['es', 'ru']) {
test(`${locale}: the GNSS source failure is translated on the card`, async (t) => {
t.mock.timers.enable({ apis: ['setTimeout', 'setInterval'] });
for (const message of [
'GNSS feed unavailable (HTTP 503)',
'GNSS feed rate limited (HTTP 429)',
]) {
const layer = await failedLayer(message);
setLocale(locale);
try {
const { info } = layer.getRowControls();
assert.ok(info.includes('HTTP'), info);
assert.doesNotMatch(info, /feed (unavailable|rate limited)/, info);
} finally {
setLocale('en');
layer.destroy();
}
}
});
}
+48
View File
@@ -0,0 +1,48 @@
import { t } from '../../i18n/index.js';
import { UNNAMED_PLACE_NAMES } from '../../data/installationNames.js';
const UNNAMED_NAMES = new Set(UNNAMED_PLACE_NAMES);
/**
* An installation's card title: a mapped name as it is; the English label
* the data gives an unnamed feature ("Firing range", "Military area") in the
* UI language. The record keeps the English name.
* @param {string} name
* @returns {string} Empty when the record has no name.
*/
export function installationTitle(name) {
const text = String(name ?? '');
return UNNAMED_NAMES.has(text.trim()) ? t(text.trim()) : text;
}
/**
* An installation class as a map card shows it: AIRFIELD, NAVAL BASE…, in
* the UI language. The class itself stays an English identifier in data.
* @param {string} klass
* @returns {string}
*/
export function installationClassLabel(klass) {
const key = String(klass || 'installation');
switch (key) {
case 'airfield':
return t('AIRFIELD', { context: 'installation class' });
case 'naval_base':
return t('NAVAL BASE', { context: 'installation class' });
case 'range':
return t('RANGE', { context: 'installation class' });
case 'military_land':
return t('MILITARY LAND', { context: 'installation class' });
case 'base':
return t('BASE', { context: 'installation class' });
case 'barracks':
return t('BARRACKS', { context: 'installation class' });
case 'training_area':
return t('TRAINING AREA', { context: 'installation class' });
case 'places_candidate':
return t('PLACES CANDIDATE', { context: 'installation class' });
case 'installation':
return t('INSTALLATION', { context: 'installation class' });
default:
return key.replaceAll('_', ' ').toUpperCase();
}
}
@@ -0,0 +1,36 @@
// Every class the bundled OSM names pack can put on a record has a
// catalog entry, so the selected card shows it in the UI language.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { setLocale, getCatalog } from '../../i18n/index.js';
import { installationClassLabel } from './classLabel.js';
const pack = JSON.parse(
readFileSync(
new URL(
'../../data/local_data/osm_military_names/names.json',
import.meta.url,
),
'utf8',
),
);
setLocale('en');
const englishLabel = Object.fromEntries(
pack.classes.map((c) => [c, installationClassLabel(c)]),
);
for (const locale of ['es', 'ru']) {
test(`every names-pack class is in the ${locale} catalog`, () => {
const catalog = getCatalog(locale);
const missing = pack.classes.filter(
(c) => catalog[`installation class|${englishLabel[c]}`] === undefined,
);
assert.deepEqual(
missing,
[],
`no ${locale} entry for: ${missing.join(', ')}`,
);
});
}
+36 -7
View File
@@ -1,10 +1,37 @@
import * as Cesium from 'cesium';
import { installationFeedback } from '../../data/installationFeedback.js';
import { describeInstallationFeedback } from '../../data/installationFeedback.js';
import {
LAYER_ID,
DISTANCE_PREFILTER_MARGIN_M,
ANCHOR_REFRESH_M,
} from './policy.js';
import { formatNumber, t, tn } from '../../i18n/index.js';
// installationFeedback.js is shared with the HUD (src/loadingFeedback.js) and
// hands back msg()-marked English plus values; getStats translates them here.
function localizeInstallationFeedback(feedback) {
if (feedback.text !== undefined) {
const values = {};
if (feedback.reason !== undefined) values.reason = t(feedback.reason);
if (feedback.seconds !== undefined) values.seconds = feedback.seconds;
return t(feedback.text, values);
}
const { count, km } = feedback;
if (km) {
const kmText = formatNumber(km);
return count === 0
? t('No mapped sites within {km} km of the contact', { km: kmText })
: tn(
count,
'{count} mapped site within {km} km of the contact',
'{count} mapped sites within {km} km of the contact',
{ km: kmText },
);
}
return count === 0
? t('No mapped sites in view')
: tn(count, '{count} mapped site in view', '{count} mapped sites in view');
}
export function createControls({ state: layerState, services, parts, source }) {
const methods = {
@@ -225,13 +252,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(
describeInstallationFeedback({
...layerState,
count: layerState.lastUpdate ? mappedCount : undefined,
retrying: layerState.loading && Boolean(layerState.failureReason),
}),
),
loadingLabel: layerState.loading
? 'loading mapped installation context'
? t('loading mapped installation context')
: '',
coverage: layerState.coverage,
coverageLabel:
+3 -2
View File
@@ -1,5 +1,6 @@
import { militaryOsmKey } from '../../data/militaryNames.js';
import { isUnavailableCapability } from '../../sources/capability.js';
import { msg } from '../../i18n/index.js';
/** Keep named point coverage until its matching polygon takes over. */
export function retainNamedHandoff(previous, records, box) {
const named = new Map(
@@ -256,7 +257,7 @@ export function createIngestion({
payload.status === 'stale'
? `Serving cached mapped context · ${payload.records[0]?.retrievedAt || 'date unknown'}`
: saturated
? 'Too many mapped sites in view to list them all'
? msg('Too many mapped sites in view to list them all')
: placesError,
);
parts.rendering.renderRecords();
@@ -292,7 +293,7 @@ export function createIngestion({
layerState.failureReason = error?.failureReason || 'unavailable';
setInstallationStatus(
'unavailable',
error?.message || 'Installation context unavailable',
error?.message || msg('Installation context unavailable'),
);
if (!isUnavailableCapability(error))
parts.viewport.scheduleUnavailableRetry(error?.retryAfterMs);
@@ -0,0 +1,79 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { setLocale, translateKnown } from '../../i18n/index.js';
import { createControls } from './controls.js';
function stats(state) {
const layerState = { records: [], enabled: true, ...state };
return createControls({
state: layerState,
services: {},
parts: {},
source: {},
}).methods.getStats();
}
for (const locale of ['es', 'ru']) {
test(`${locale}: installation feedback is in the UI language`, () => {
setLocale(locale);
const empty = stats({ status: 'empty', lastUpdate: 1 }).statusMessage;
assert.notEqual(empty, 'No mapped sites in view');
const found = stats({
status: 'ready',
lastUpdate: 1,
records: [{ kind: 'installation' }, { kind: 'installation' }],
}).statusMessage;
assert.notEqual(found, '2 mapped sites in view');
const tiles = stats({
status: 'unavailable',
failureReason: 'tiles_unavailable',
}).statusMessage;
assert.notEqual(tiles, 'Map tiles temporarily unavailable');
const retry = stats({
status: 'unavailable',
failureReason: 'names_unavailable',
retryAt: Date.now() + 5000,
}).statusMessage;
assert.doesNotMatch(retry, /^Mapped names temporarily unavailable/);
for (const text of [
'Too many mapped sites in view to list them all',
'Installation context unavailable',
]) {
assert.notEqual(translateKnown(text), text);
}
});
}
test('en: installation feedback keeps its English wording', () => {
setLocale('en');
assert.equal(
stats({ status: 'empty', lastUpdate: 1 }).statusMessage,
'No mapped sites in view',
);
assert.equal(
stats({
status: 'ready',
lastUpdate: 1,
records: [{ kind: 'installation' }],
}).statusMessage,
'1 mapped site in view',
);
assert.equal(
stats({
status: 'ready',
lastUpdate: 1,
wide: true,
coverage: { kind: 'subject', radiusM: 25000 },
records: [{ kind: 'installation' }, { kind: 'installation' }],
}).statusMessage,
'2 mapped sites within 25 km of the contact',
);
assert.equal(
stats({
status: 'unavailable',
failureReason: 'names_unavailable',
retryAt: Date.now() + 5000,
}).statusMessage,
'Mapped names temporarily unavailable — retrying in 5s',
);
});
+4 -5
View File
@@ -1,3 +1,4 @@
import { installationClassLabel, installationTitle } from './classLabel.js';
import * as Cesium from 'cesium';
import {
MILITARY_LABEL_CAP,
@@ -162,7 +163,7 @@ export function createNamedMarkers({ state, services, parts, overlayHost }) {
id: record.id,
source: LAYER_ID,
position,
title: record.name,
title: installationTitle(record.name),
priority: record.areaM2 || 0,
accent: COLOR_BY_CLASS[record.class] || '#9ca6b0',
}),
@@ -177,14 +178,12 @@ export function createNamedMarkers({ state, services, parts, overlayHost }) {
marker.entry.selected = record.id === state.selectedId;
marker.entry.details = marker.entry.selected
? [
String(record.class || 'installation')
.replaceAll('_', ' ')
.toUpperCase(),
installationClassLabel(record.class),
...(record.memberNames || []).slice(0, 3),
]
: [];
marker.entry.position = position;
marker.entry.title = record.name;
marker.entry.title = installationTitle(record.name);
marker.entry.priority = record.areaM2 || 0;
entries.push(marker.entry);
ordered.push(marker);
+4 -4
View File
@@ -1,4 +1,6 @@
import { installationClassLabel, installationTitle } from './classLabel.js';
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,11 +272,9 @@ export function createRendering({
entity.gevTrackedId = `installations:${record.id}`;
entity.gevDisplayPosition = () => displayPosition;
entity.gevLabelModel = {
title: record.name || 'MAPPED INSTALLATION',
title: installationTitle(record.name) || t('MAPPED INSTALLATION'),
details: [
String(record.class || 'installation')
.replaceAll('_', ' ')
.toUpperCase(),
installationClassLabel(record.class),
...(record.memberNames || []).slice(0, 3),
],
accent: COLOR_BY_CLASS[record.class] || '#9ca6b0',
@@ -0,0 +1,85 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { normalizeMilitaryInstallations } from '../../data/militaryInstallationData.js';
import { UNNAMED_MILITARY_AREA } from '../../sources/openFreeMap.js';
import { setLocale } from '../../i18n/index.js';
import { installationTitle } from './classLabel.js';
import { formatAwarenessLabel } from '../../data/militaryAwarenessEngine.js';
// Unnamed installations get an English label in data ("Firing range",
// "Military land", "Military airfield"; OpenFreeMap polygons: "Military area").
// The data stays English; the map card title reads in the UI language.
const { records } = normalizeMilitaryInstallations({
elements: [
{ type: 'node', id: 1, lat: 1, lon: 1, tags: { military: 'range' } },
{
type: 'way',
id: 2,
center: { lat: 2, lon: 2 },
tags: { landuse: 'military' },
},
{ type: 'node', id: 3, lat: 3, lon: 3, tags: { military: 'airfield' } },
{ type: 'node', id: 4, lat: 4, lon: 4, tags: { military: 'naval_base' } },
],
});
const names = [...records.map((r) => r.name), UNNAMED_MILITARY_AREA];
test('unnamed installations keep English names in data', () => {
assert.deepEqual(names, [
'Firing range',
'Military land',
'Military airfield',
'Naval base',
'Military area',
]);
});
test('unnamed installation card title reads in the UI language', () => {
try {
for (const locale of ['es', 'ru']) {
setLocale(locale);
for (const name of names) {
const shown = installationTitle(name);
assert.ok(shown, `${locale}: "${name}" has a title`);
assert.notEqual(
shown,
name,
`${locale}: "${name}" is shown in English`,
);
}
// A mapped name is shown as it is.
assert.equal(installationTitle('Fort Bragg'), 'Fort Bragg');
}
} finally {
setLocale('en');
}
});
test('English card titles are unchanged', () => {
setLocale('en');
for (const name of names) assert.equal(installationTitle(name), name);
assert.equal(installationTitle(''), '');
});
test('the Contacts line names an unnamed installation in the UI language', () => {
try {
setLocale('en');
for (const name of names)
assert.equal(formatAwarenessLabel({ id: 'osm-1', name }), name);
for (const locale of ['es', 'ru']) {
setLocale(locale);
for (const name of names)
assert.equal(
formatAwarenessLabel({ id: 'osm-1', name }),
installationTitle(name),
);
assert.equal(
formatAwarenessLabel({ id: 'osm-2', name: 'Fort Bragg' }),
'Fort Bragg',
);
assert.equal(formatAwarenessLabel({ callsign: 'RCH123 ' }), 'RCH123');
}
} finally {
setLocale('en');
}
});
@@ -0,0 +1,85 @@
// A launch without a Launch Library status gets our 'Unknown'. The record keeps
// it English; the mission panel translates it and shows provider statuses as sent.
import assert from 'node:assert/strict';
import test from 'node:test';
import { setLocale } from '../../i18n/index.js';
import {
launchStatusAllowsOrbit,
normalizeRocketLaunches,
} from '../../data/rocketLaunches.js';
import { UNKNOWN_LAUNCH_STATUS } from './model.js';
import { createPanel } from './panel.js';
const NOW = new Date('2026-07-27T00:00:00Z');
function shownStatus(status) {
const outputs = new Map();
const state = {
_missionPanel: {
querySelector(selector) {
if (!outputs.has(selector))
outputs.set(selector, {
textContent: '',
closest: () => null,
removeAttribute() {},
});
return outputs.get(selector);
},
closest: () => null,
},
_selectedLaunchId: 'mission-1',
_launches: [
{
id: 'mission-1',
name: 'Example',
status,
payloads: [],
recoveryStages: [],
},
],
_replayTracks: new Map(),
};
createPanel({
state,
parts: {
overlays: { shortMissionLabel: (name) => name },
policyHelpers: { missionPathPresentation: () => ({}) },
replay: { syncReplayButton() {} },
},
}).renderMissionPanel();
return outputs.get('[data-mission-status]').textContent;
}
test('a launch without a status keeps the English fallback in its record', () => {
const [launch] = normalizeRocketLaunches(
{
results: [
{
id: 'no-status',
name: 'Test Flight',
net: '2026-07-20T10:00:00Z',
pad: {
name: 'Pad A',
location: { name: 'Test Range', coordinates: '12.5,45.5' },
},
},
],
},
NOW,
);
assert.equal(UNKNOWN_LAUNCH_STATUS, 'Unknown');
assert.equal(launch.status, 'Unknown');
assert.equal(launchStatusAllowsOrbit(launch.status), true);
});
test('the mission panel translates only the fallback status', (t) => {
t.after(() => setLocale('en'));
setLocale('en');
assert.equal(shownStatus(UNKNOWN_LAUNCH_STATUS), 'Unknown');
setLocale('ru');
assert.equal(shownStatus(UNKNOWN_LAUNCH_STATUS), 'Неизвестно');
assert.equal(shownStatus('Go for Launch'), 'Go for Launch');
setLocale('es');
assert.equal(shownStatus(UNKNOWN_LAUNCH_STATUS), 'Desconocido');
assert.equal(shownStatus('Launch Successful'), 'Launch Successful');
});
+8 -1
View File
@@ -1,6 +1,13 @@
import * as Cesium from 'cesium';
import { msg } from '../../i18n/index.js';
import { WINDOW_DAYS } from './policy.js';
/**
* Fallback when Launch Library sends no status. English in the record
* (launchStatusAllowsOrbit, analyst records); the mission panel translates it.
*/
export const UNKNOWN_LAUNCH_STATUS = msg('Unknown');
export function createModel({ state: layerState, services, parts, source }) {
function finiteCoordinate(value) {
const number = Number(value);
@@ -192,7 +199,7 @@ export function createModel({ state: layerState, services, parts, source }) {
launch.id || launch.slug || launch.name || `launch-${date}`,
),
name: launch.name || 'Unnamed launch',
status: launch.status?.name || 'Unknown',
status: launch.status?.name || UNKNOWN_LAUNCH_STATUS,
launchTime: Number.isFinite(date)
? new Date(date).toISOString()
: null,
+24 -11
View File
@@ -1,4 +1,5 @@
import * as Cesium from 'cesium';
import { t, msg } 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('LAUNCH SITE · {site}', {
site: shortMissionLabel(siteName, 20).toUpperCase(),
})
: t('LAUNCH SITE'),
]
: [];
return {
@@ -586,27 +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
? `LAUNCH SITE · ${shortMissionLabel(siteName, 20).toUpperCase()}`
: 'LAUNCH SITE';
? t('LAUNCH SITE · {site}', {
site: shortMissionLabel(siteName, 20).toUpperCase(),
})
: t('LAUNCH SITE');
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('T−{time} · {mission}', {
time: String(state.countdownSeconds).padStart(2, '0'),
mission,
});
detail = `${t('LAUNCH STANDBY')}\n${siteCallout}`;
} else if (mode === 'ascent') {
title =
state.elapsedSinceStart < 1
? `LIFTOFF · ${mission}`
: `${launch.trajectory.length > 1 ? 'ASCENT REPLAY' : 'ASCENT ESTIMATE'} · ${mission}`;
? t('LIFTOFF · {mission}', { mission })
: t(
launch.trajectory.length > 1
? msg('ASCENT REPLAY · {mission}')
: msg('ASCENT ESTIMATE · {mission}'),
{ mission },
);
detail = parts.policyHelpers.formatMissionEventTime(state.eventTime);
} else if (mode === 'recovery') {
title = `STAGE RE-ENTRY / RECOVERY · ${mission}`;
title = t('STAGE RE-ENTRY / RECOVERY · {mission}', { mission });
detail = parts.policyHelpers.formatMissionEventTime(state.eventTime);
} else if (mode === 'orbit') {
title = `ORBIT REPLAY · ${mission}`;
title = t('ORBIT REPLAY · {mission}', { mission });
detail = parts.policyHelpers.formatMissionEventTime(state.eventTime);
}
if (layerState._replayPaused) title = `PAUSED · ${title}`;
if (layerState._replayPaused) title = t('PAUSED · {title}', { title });
const nextText = `${title}\n${detail}`;
if (nextText !== layerState._replayVehicleOverlayText) {
layerState._replayVehicleOverlayText = nextText;
+50 -22
View File
@@ -1,8 +1,10 @@
import * as Cesium from 'cesium';
import { t } from '../../i18n/index.js';
import {
MISSION_CLOSE_VIEW_RANGE_M,
MISSION_GLOBE_VIEW_RANGE_M,
} from './policy.js';
import { UNKNOWN_LAUNCH_STATUS } from './model.js';
export function createPanel({ state: layerState, services, parts, source }) {
/**
@@ -190,7 +192,14 @@ export function createPanel({ state: layerState, services, parts, source }) {
layerState._missionPanel.querySelector('[data-mission-title]').textContent =
parts.overlays.shortMissionLabel(launch.name, 32).toUpperCase();
setMissionPanelField('[data-mission-provider]', launch.provider);
setMissionPanelField('[data-mission-status]', launch.status);
// Launch Library status names are provider data, shown as sent; only the
// fallback for a launch without one is ours to translate.
setMissionPanelField(
'[data-mission-status]',
launch.status === UNKNOWN_LAUNCH_STATUS
? t(UNKNOWN_LAUNCH_STATUS)
: launch.status,
);
setMissionPanelField(
'[data-mission-site]',
launch.launchSite && launch.launchSite !== 'Unknown launch site'
@@ -219,34 +228,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('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">+${launch.payloads.length - 5} additional payload records</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(
'[data-mission-payloads]',
).innerHTML = missionTableRows(payloadRows, 3, 'PAYLOAD DATA UNAVAILABLE');
).innerHTML = missionTableRows(
payloadRows,
3,
t('PAYLOAD DATA UNAVAILABLE'),
);
const stageRows = launch.recoveryStages.map((stage) => {
const endpoint = stage.endpoint;
const destination =
stage.destination ||
(endpoint?.accuracy === 'PAD / RTLS'
? launch.launchSite
: 'UNAVAILABLE');
: t('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('{value} KM DOWNRANGE', {
value: stage.downrangeKm.toLocaleString(),
})
: t('POSITION UNAVAILABLE');
const stageDetail = [
Number.isFinite(stage.flightNumber)
? `FLIGHT ${stage.flightNumber}`
? t('FLIGHT {count}', {
count: stage.flightNumber,
})
: null,
stage.reused ? 'REUSED' : null,
stage.reused ? t('REUSED') : null,
stage.recoveryType,
]
.filter(Boolean)
@@ -254,7 +271,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('NO STAGE RE-ENTRY / RECOVERY DATA'));
const stageSection = layerState._missionPanel.querySelector(
'[data-mission-stages-section]',
);
@@ -281,7 +298,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('{count} / 30D', {
count: layerState._launches.length,
});
if (!list) return;
const focusSnapshot = captureMissionRosterFocus(list);
layerState._missionRosterPreviewOwnership?.reset();
@@ -289,8 +309,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('NO MISSIONS AVAILABLE IN THE CURRENT 30-DAY WINDOW')}</div>`;
restoreMissionRosterFocus(
list,
focusSnapshot,
@@ -303,12 +322,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) || 'DATE UNAVAILABLE';
const provider = launch.provider || 'UNSPECIFIED OPERATOR';
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="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('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) => {
@@ -365,7 +384,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('{value} KM', {
value: Math.max(0, altitudeM / 1000).toLocaleString(undefined, {
maximumFractionDigits: 0,
}),
})
: null,
);
const speedMps = layerState._satelliteTelemetry.get(
@@ -374,7 +397,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('{value} KM/S', {
value: (speedMps / 1000).toLocaleString(undefined, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}),
})
: null,
Number.isFinite(speedMps)
? `${(speedMps * 3.6).toLocaleString(undefined, { maximumFractionDigits: 0 })} km/h`
@@ -450,16 +478,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('Selected Space Mission'),
);
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('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="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('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);
+2 -1
View File
@@ -1,4 +1,5 @@
import * as Cesium from 'cesium';
import { t } from '../../i18n/index.js';
import {
EARTH_ROTATION_RAD_PER_SEC,
PROJECTED_ASCENT_ROTATION_SEC,
@@ -521,7 +522,7 @@ export function createPaths({ state: layerState, services, parts, source }) {
return {
lat: Cesium.Math.toDegrees(lat),
lon: Cesium.Math.toDegrees(Cesium.Math.negativePiToPi(lon)),
accuracy: 'EST. DOWNRANGE',
accuracy: t('EST. DOWNRANGE'),
};
}
+7 -5
View File
@@ -1,4 +1,5 @@
import * as Cesium from 'cesium';
import { t } from '../../i18n/index.js';
import {
SATELLITE_STANDALONE_DEFAULTS,
LAUNCH_PAD_ZONE_MAX_CAMERA_HEIGHT_M,
@@ -76,14 +77,14 @@ export function createPolicyHelpers({
: 0;
return {
orbit: orbitName
? `${orbitAllowed ? '' : 'PLANNED · '}${orbitName}`
? `${orbitAllowed ? '' : t('PLANNED · ')}${orbitName}`
: null,
ascent:
suppliedTrajectoryPoints > 1
? 'SUPPLIED TRAJECTORY POINTS'
? t('SUPPLIED TRAJECTORY POINTS')
: replayAvailable
? 'RECONSTRUCTED ESTIMATE'
: 'UNAVAILABLE',
? t('RECONSTRUCTED ESTIMATE')
: t('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('UNAVAILABLE');
return `${date.toISOString().slice(0, 10)}\n${date.toISOString().slice(11, 19)} UTC`;
}
+14 -11
View File
@@ -1,4 +1,5 @@
import * as Cesium from 'cesium';
import { t } from '../../i18n/index.js';
import {
REPLAY_ASCENT_FALLBACK_SEC,
REPLAY_ASCENT_MIN_SEC,
@@ -200,10 +201,10 @@ export function createReplay({ state: layerState, services, parts, source }) {
if (speedControl) speedControl.hidden = !replayAvailable;
button.hidden = active || !replayAvailable;
button.disabled = !replayAvailable;
button.textContent = 'REPLAY ASCENT';
button.textContent = t('REPLAY ASCENT');
button.classList.remove('active');
button.setAttribute('aria-pressed', String(active));
button.title = 'Replay the estimated ascent with a following camera';
button.title = t('Replay the estimated ascent with a following camera');
if (transport) {
transport.hidden = !active;
transport.classList.toggle(
@@ -218,11 +219,11 @@ 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('Resume replay') : t('Pause replay'),
);
toggleButton.title = layerState._replayPaused
? 'Resume replay'
: 'Pause replay';
? t('Resume replay')
: t('Pause replay');
}
}
}
@@ -233,17 +234,19 @@ export function createReplay({ state: layerState, services, parts, source }) {
);
if (!transport || !layerState._replayCameraLaunchId) return;
const phase = state.countdownActive
? `T minus ${state.countdownSeconds}`
? t('T minus {seconds}', {
seconds: state.countdownSeconds,
})
: state.preCountdownActive
? 'Preparing launch site'
? t('Preparing launch site')
: state.elapsedSinceStart < 1
? 'Liftoff'
? t('Liftoff')
: state.ascending
? 'Ascent replay'
: 'Orbit replay';
? t('Ascent replay')
: t('Orbit replay');
transport.setAttribute(
'aria-label',
`${phase}${layerState._replayPaused ? ', paused' : ''}`,
`${phase}${layerState._replayPaused ? t(', paused') : ''}`,
);
}
+5 -1
View File
@@ -1,3 +1,7 @@
// A portable export (package.json): no i18n here. Failures stay English data;
// the Layers panel shows them translated.
const msg = (text) => text;
/** Read launch records and their optional active-orbit catalog with explicit cancellation. */
export function createLaunchSource({
fetchImpl = (...args) => globalThis.fetch(...args),
@@ -10,7 +14,7 @@ export function createLaunchSource({
const payload = await response.json();
signal?.throwIfAborted();
if (!Array.isArray(payload) && !Array.isArray(payload?.results))
throw new Error('Malformed launch snapshot');
throw new Error(msg('Malformed launch snapshot'));
return {
payload,
stale: response.headers?.get?.('x-gev-cache') === 'STALE-ERROR',
+100
View File
@@ -0,0 +1,100 @@
// The Local ADS-B click card follows the UI language; English keeps its
// wording (index.test.mjs pins the full English card).
import test from 'node:test';
import assert from 'node:assert/strict';
import { setLocale } from '../../i18n/index.js';
import { localAdsbCardModel, localAdsbSourceText } from './card.js';
const record = {
icao: 'abc123',
callsign: '',
altitudeFt: 12500,
groundSpeedKt: 240,
trackDeg: 90,
verticalRateFpm: -1200,
lastPositionAt: 1000,
messageCount: 48,
bands: ['1090'],
sources: ['webusb', 'feed'],
};
test('en: the local ADS-B card keeps its English wording', () => {
setLocale('en');
assert.deepEqual(localAdsbCardModel(record, 5000).details, [
'ICAO ABC123 · NO CALLSIGN',
'ALT 12,500 FT · GS 240 KT · TRK 90°',
'V/S -1,200 FPM',
'POSITION 4 S AGO · 48 MSGS',
'Heard by your receiver · 1090 MHz · browser SDR + decoder feed',
]);
assert.equal(
localAdsbSourceText(record),
'Your RTL-SDR receiver and decoder feed',
);
});
for (const locale of ['es', 'ru']) {
test(`${locale}: the local ADS-B card is in the UI language`, () => {
setLocale(locale);
try {
const text = [
...localAdsbCardModel(record, 5000).details,
localAdsbSourceText(record),
].join(' | ');
for (const english of [
'NO CALLSIGN',
'GS ',
'POSITION',
'AGO',
'MSGS',
'Heard by',
'browser SDR',
'decoder feed',
'Your RTL-SDR',
'12,500',
])
assert.ok(
!text.includes(english),
`${locale}: "${english}" in ${text}`,
);
} finally {
setLocale('en');
}
});
}
test('the class line names the aircraft class in the UI language', async () => {
const { localAdsbClassLine } = await import('./card.js');
const { AIRCRAFT_CLASS_LABELS } = await import('../../data/aircraftClass.js');
const heli = { category: 'A7' };
try {
setLocale('en');
assert.equal(
localAdsbClassLine(heli, { klass: 'helicopter', evidence: true }),
'Helicopter · A7',
);
for (const locale of ['es', 'ru']) {
setLocale(locale);
for (const [klass, label] of Object.entries(AIRCRAFT_CLASS_LABELS)) {
const line = localAdsbClassLine({}, { klass, evidence: true });
assert.ok(line, `${locale}: ${klass} has a class line`);
assert.notEqual(line, label, `${locale}: "${label}" is in English`);
}
}
} finally {
setLocale('en');
}
});
// The receiver bands follow the UI language too ("1090 МГц").
test('ru: the receiver line names the bands in Russian', async () => {
const { localAdsbReceiverLine } = await import('./card.js');
setLocale('ru');
try {
const line = localAdsbReceiverLine({ ...record, bands: ['1090', '978'] });
assert.match(line, /· 1090 МГц \+ 978 МГц UAT ·/);
assert.doesNotMatch(line, /MHz/);
} finally {
setLocale('en');
}
});
+51 -22
View File
@@ -1,10 +1,6 @@
import { AIRCRAFT_CLASS_LABELS } from '../../data/aircraftClass.js';
import {
BAND_LABELS,
HEARD_BY_RECEIVER,
LOCAL_ADSB_COLOR,
SOURCE_LABELS,
} from './policy.js';
import { formatNumber, t, tn } from '../../i18n/index.js';
import { BAND_LABELS, LOCAL_ADSB_COLOR, SOURCE_LABELS } from './policy.js';
const DASH = '—';
@@ -14,7 +10,7 @@ function rounded(value, digits = 0) {
}
function grouped(value) {
return value === null ? DASH : value.toLocaleString('en-US');
return value === null ? DASH : formatNumber(value);
}
/**
@@ -55,17 +51,37 @@ export function localAdsbIsUat(record) {
* @returns {string}
*/
export function localAdsbReceiverLine(record) {
const bands = recordBands(record)
.map((band) => BAND_LABELS[band])
.filter(Boolean);
const sources = recordSources(record)
.map((source) => SOURCE_LABELS[source])
.filter(Boolean);
return [HEARD_BY_RECEIVER, bands.join(' + '), sources.join(' + ')]
const bands = recordBands(record).map(bandLabel).filter(Boolean);
const sources = recordSources(record).map(sourceLabel).filter(Boolean);
return [t('Heard by your receiver'), bands.join(' + '), sources.join(' + ')]
.filter(Boolean)
.join(' · ');
}
/** A receiver band (policy.js BAND_LABELS) in the UI language: "1090 МГц". */
function bandLabel(band) {
switch (String(band)) {
case '1090':
return t('1090 MHz');
case '978':
return t('978 MHz UAT');
default:
return BAND_LABELS[band];
}
}
/** A receiver source (policy.js SOURCE_LABELS) in the UI language. */
function sourceLabel(source) {
switch (source) {
case 'webusb':
return t('browser SDR');
case 'feed':
return t('decoder feed');
default:
return SOURCE_LABELS[source];
}
}
/**
* Where a heard aircraft came from, for the entity context's source field.
* @param {object} record Local ADS-B record.
@@ -74,9 +90,9 @@ export function localAdsbReceiverLine(record) {
export function localAdsbSourceText(record) {
const sources = recordSources(record);
if (sources.includes('webusb') && sources.includes('feed'))
return 'Your RTL-SDR receiver and decoder feed';
if (sources.includes('feed')) return 'Your decoder feed';
return 'Your RTL-SDR receiver';
return t('Your RTL-SDR receiver and decoder feed');
if (sources.includes('feed')) return t('Your decoder feed');
return t('Your RTL-SDR receiver');
}
/**
@@ -89,7 +105,7 @@ export function localAdsbSourceText(record) {
export function localAdsbClassLine(record, aircraftClass = {}) {
if (!aircraftClass.evidence) return '';
const label = AIRCRAFT_CLASS_LABELS[aircraftClass.klass];
return [label, record?.category].filter(Boolean).join(' · ');
return [label && t(label), record?.category].filter(Boolean).join(' · ');
}
/**
@@ -129,13 +145,26 @@ export function localAdsbCardModel(
return {
title: localAdsbTitle(record),
details: [
`ICAO ${icao} · ${record?.callsign || 'NO CALLSIGN'}`,
`ICAO ${icao} · ${record?.callsign || t('NO CALLSIGN')}`,
localAdsbClassLine(record, aircraftClass || {}),
identity,
routeLine,
`ALT ${grouped(altitude)} FT · GS ${speed === null ? DASH : speed} KT · TRK ${track === null ? DASH : `${track}°`}`,
`V/S ${vertical === null ? DASH : `${vertical > 0 ? '+' : ''}${grouped(vertical)} FPM`}`,
`POSITION ${ageS === null ? DASH : `${ageS} S AGO`} · ${messages} ${messages === 1 ? 'MSG' : 'MSGS'}`,
t('ALT {altitude} FT · GS {speed} KT · TRK {track}', {
altitude: grouped(altitude),
speed: speed === null ? DASH : speed,
track: track === null ? DASH : `${track}°`,
}),
vertical === null
? t('V/S {rate}', { rate: DASH })
: t('V/S {rate} FPM', {
rate: `${vertical > 0 ? '+' : ''}${grouped(vertical)}`,
}),
[
ageS === null
? t('POSITION {age}', { age: DASH })
: t('POSITION {seconds} S AGO', { seconds: ageS }),
tn(messages, '{count} MSG', '{count} MSGS'),
].join(' · '),
localAdsbReceiverLine(record),
].filter(Boolean),
accent: LOCAL_ADSB_COLOR,
+19 -9
View File
@@ -1,13 +1,18 @@
import { localReceiverFeedName } from './feedNames.js';
import { LAYER_SOURCE } from './policy.js';
// Marks English that the Layers panel translates when it shows the status.
// Texts with a number or a feed name stay English data too; the panel shows
// them through STATUS_PIECE_PATTERNS (ui/statusText.js), piece by piece.
const msg = (text) => text;
/**
* Layers-panel status for Local ADS-B, from its two inputs: the browser
* WebUSB receiver and the server's decoder feeds. Pure; no DOM or Cesium.
*/
export const FEED_SOURCE = 'Decoder feeds';
export const COMBINED_SOURCE = 'WebUSB + decoder feeds';
export const FEED_SOURCE = msg('Decoder feeds');
export const COMBINED_SOURCE = msg('WebUSB + decoder feeds');
function plural(count, one, many) {
return count === 1 ? one : many;
@@ -48,25 +53,29 @@ function webUsbStatus(receiver, heard, feedState) {
if (receiver.status === 'error')
return { status: 'error', error: receiver.message };
if (receiver.status === 'connecting' || receiver.status === 'tuning')
return { loading: true, loadingLabel: 'opening receiver' };
return { loading: true, loadingLabel: msg('opening receiver') };
if (
feedState?.polling &&
feedState.configured === null &&
!receiver.connected
)
return { loading: true, loadingLabel: 'checking decoder feeds' };
return { loading: true, loadingLabel: msg('checking decoder feeds') };
if (!receiver.webUsbSupported)
return {
status: 'idle',
statusMessage: 'WebUSB needs desktop Chrome or Edge',
statusMessage: msg('WebUSB needs desktop Chrome or Edge'),
};
if (!receiver.connected)
return { status: 'idle', statusMessage: 'connect a receiver in Radio' };
return {
status: 'idle',
statusMessage: msg('connect a receiver in Radio'),
};
if (receiver.mode !== 'adsb')
return { status: 'idle', statusMessage: 'receiver is in FM mode' };
return { status: 'idle', statusMessage: msg('receiver is in FM mode') };
const rate = Number.isFinite(receiver.messagesPerSecond)
? `${receiver.messagesPerSecond} msg/s`
: 'listening';
: msg('listening');
// i18n-ignore: English data, shown through STATUS_PIECE_PATTERNS
return { status: 'streaming', loadingLabel: `${heard} heard · ${rate}` };
}
@@ -97,7 +106,7 @@ export function localAdsbStatus({ receiver, feedState, heard }) {
const source = usbActive ? COMBINED_SOURCE : FEED_SOURCE;
const live = feedState.feeds.filter((feed) => feed.status === 'live');
const problems = [describeFeedProblems(feedState.feeds)].filter(Boolean);
if (usbActive && receiver.status === 'error') problems.push('USB error');
if (usbActive && receiver.status === 'error') problems.push(msg('USB error'));
const rate =
usbActive &&
receiver.status !== 'error' &&
@@ -109,6 +118,7 @@ export function localAdsbStatus({ receiver, feedState, heard }) {
return {
source,
status: 'streaming',
// i18n-ignore: English data, shown through STATUS_PIECE_PATTERNS
loadingLabel: `${live.length} ${plural(live.length, 'feed', 'feeds')} live · ${heardText}${rate}`,
};
}
@@ -0,0 +1,68 @@
// The Local ADS-B row's working texts ("3 heard · 5.8 msg/s", "listening",
// "USB error", feed problems, even as the whole error) read in the UI language.
import assert from 'node:assert/strict';
import test from 'node:test';
import { setLocale } from '../../i18n/index.js';
import { LayerPanel } from '../../ui/layerPanel.js';
import { localAdsbStatus } from './status.js';
const streaming = {
webUsbSupported: true,
connected: true,
mode: 'adsb',
status: 'streaming',
messagesPerSecond: 5.8,
};
const idle = {
webUsbSupported: true,
connected: false,
mode: 'adsb',
status: 'idle',
};
function rowFor(status, count) {
return LayerPanel.prototype._buildMetaText({
id: 'local-adsb',
enabled: true,
stats: { count, lastUpdate: Date.now(), ...status },
});
}
function inLocale(locale, run) {
setLocale(locale);
try {
run();
} finally {
setLocale('en');
}
}
for (const locale of ['es', 'ru']) {
test(`${locale}: the streaming Local ADS-B row is translated`, () => {
inLocale(locale, () => {
const row = rowFor(
localAdsbStatus({ receiver: streaming, feedState: null, heard: 3 }),
3,
);
assert.doesNotMatch(row, /\bheard\b/, row);
});
});
for (const status of ['unreachable', 'invalid']) {
test(`${locale}: a lone ${status} decoder feed with nothing heard is translated`, () => {
inLocale(locale, () => {
const st = localAdsbStatus({
receiver: idle,
feedState: {
configured: true,
feeds: [{ id: 'a', label: '1090', band: '1090', status }],
},
heard: 0,
});
const row = rowFor(st, 0);
assert.doesNotMatch(row, /\b(unreachable|invalid)\b/, row);
});
});
}
}
+8 -2
View File
@@ -1,5 +1,9 @@
import { ERROR_BACKOFF_INTERVAL } from './recordPolicy.js';
// Marks English the Layers panel translates when it shows the error. Kept
// local: this module stays free of browser globals (package boundaries).
const msg = (text) => text;
/** Own military source acquisition, cancellation, freshness and error backoff. */
export function createIngestion({
feed,
@@ -42,7 +46,7 @@ export function createIngestion({
feed._lastError =
snapshot.reason ||
(snapshot.freshness === 'unknown'
? 'Source snapshot time unavailable'
? msg('Source snapshot time unavailable')
: null);
const accepted = applySnapshot(snapshot, viewer);
feed._count = accepted.count;
@@ -69,7 +73,9 @@ export function createIngestion({
setSourceLabel(feed._lastSource);
}
feed._lastError =
e?.name === 'LiveSourceError' ? e.message : 'Live data unavailable';
e?.name === 'LiveSourceError'
? e.message
: msg('Live data unavailable');
} finally {
feed._activeUpdateControllers.delete(resourceController);
}
+2 -1
View File
@@ -1,4 +1,5 @@
import { displayedKinematics } from '../../data/motionModel.js';
import { t } from '../../i18n/index.js';
import * as Cesium from 'cesium';
import { isExplicitLayerStateOrigin } from '../../data/layerState.js';
import { aircraftIncludedInNearby } from '../../data/aircraftNearbyPolicy.js';
@@ -55,7 +56,7 @@ export function createQueries({
*/
function _formatAltitude(altitudeFt) {
if (!Number.isFinite(altitudeFt)) return 'Alt unknown';
if (!Number.isFinite(altitudeFt)) return t('Alt unknown');
return `${Math.round(altitudeFt)} ft`;
}
+9 -4
View File
@@ -39,8 +39,6 @@ export class MilitaryFlightRecords {
? prevMeta.altitudeFt * 0.3048
: 0
: 3048);
const track = aircraft.courseDeg || 0;
const speedMps = aircraft.speedMps ?? 0;
const verticalRateMps = aircraft.verticalRateMps;
const callsign = aircraft.callsign;
const type = aircraft.typeCode;
@@ -148,8 +146,15 @@ export class MilitaryFlightRecords {
// altitudeFt/baro).
geoAltitudeM,
renderAltitudeM,
speedMps: stickyNumber(speedMps, prevMeta?.speedMps, null),
track: stickyNumber(track, prevMeta?.track, null),
// A poll that omits track/speed holds the last values, like the civil
// records; 0 only until one is known. A grounded row without a speed
// reads 0, so a parked plane never coasts at its landing speed.
speedMps: stickyNumber(
aircraft.speedMps,
onGround ? 0 : prevMeta?.speedMps,
0,
),
track: stickyNumber(aircraft.courseDeg, prevMeta?.track, 0),
// Analyst seam (additive): sticky like the other kinematics.
verticalRateMps: stickyNumber(
verticalRateMps,
+36
View File
@@ -114,3 +114,39 @@ test('military emergency status follows the latest poll instead of sticking', ()
);
assert.equal(next.meta.emergency, null);
});
test('military records hold the last track and speed when a poll omits them', () => {
const records = store();
records.receive(observation({ courseDeg: 270, speedMps: 200 }), context());
const missing = records.receive(
observation({ courseDeg: null, speedMps: null }),
context(),
);
assert.equal(missing.meta.track, 270);
assert.equal(missing.meta.speedMps, 200);
const north = records.receive(
observation({ courseDeg: 0, speedMps: 0 }),
context(),
);
assert.equal(north.meta.track, 0);
assert.equal(north.meta.speedMps, 0);
const unseen = store().receive(
observation({ courseDeg: undefined, speedMps: undefined }),
context(),
);
assert.equal(unseen.meta.track, 0);
assert.equal(unseen.meta.speedMps, 0);
const landing = store();
landing.receive(observation({ courseDeg: 90, speedMps: 70 }), context());
const parked = landing.receive(
observation({
onGround: true,
baroAltitudeM: null,
courseDeg: null,
speedMps: null,
}),
context(),
);
assert.equal(parked.meta.speedMps, 0);
assert.equal(parked.meta.track, 90);
});
+6 -4
View File
@@ -1,4 +1,5 @@
import * as Cesium from 'cesium';
import { t } from '../../i18n/index.js';
import { nextCockpitNearContacts } from '../../data/cockpitAirLod.js';
import { aircraftIcon, TRACKED_ICON_PX } from '../../data/aircraftIcons.js';
import { trackedModelZoomActive } from '../../data/trackedModelRegime.js';
@@ -124,6 +125,7 @@ export function createTracking({
icao24,
// Honesty cue: the contact is coasting on dead reckoning, so the
// narrated position/velocity are last-known rather than live.
// i18n-ignore: voice-context data, English for the model
status: described.stale ? 'stale (missed polls)' : 'live',
},
};
@@ -218,7 +220,7 @@ export function createTracking({
function _buildTrackedLabel(info, icao24) {
const stale =
flightState.records.missingPolls.get(icao24) || flightState.feed._backoff
? ' · STALE'
? ` · ${t('STALE')}`
: '';
const callsign =
(parts.queries._toCleanText(info?.callsign) ||
@@ -228,12 +230,12 @@ 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('Type unknown'),
);
const registration =
parts.queries._toCleanText(info?.registration) || 'Reg unknown';
parts.queries._toCleanText(info?.registration) || t('Reg unknown');
const operator =
parts.queries._toCleanText(info?.operator) || 'Operator unknown';
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;
+150
View File
@@ -0,0 +1,150 @@
// The selected-fire card of Fire Perimeters and its accessible
// label follow the UI language, like the legend of the same layer
// (perimeters/index.js). cards.test.mjs pins the English card.
import test from 'node:test';
import assert from 'node:assert/strict';
import { setLocale, t } from '../../i18n/index.js';
import { buildIncidentCard } from './cards.js';
const now = Date.UTC(2026, 9, 14);
const row = {
stableId: 'x',
name: 'Lake',
acres: 1200,
containedPct: 40,
state: 'CA',
discoveredTime: now - 2 * 3600e3,
updatedTime: now - 3600e3,
cause: 'Human',
behavior: 'Moderate',
complexity: 'Type 3 Incident',
personnel: 1500,
county: 'Plumas',
costToDate: 4200000,
complexName: 'NORTH COMPLEX',
};
test('en: the fire card keeps its English wording', () => {
setLocale('en');
const card = buildIncidentCard(row, now, { link: 'https://inciweb.example' });
assert.equal(card.title, 'FIRE · Lake');
assert.deepEqual(card.details, [
'1,200 ac · 40% contained · CA',
'part of North Complex',
'Human cause · Moderate · Type 3 Incident',
'1,500 personnel · Plumas County · $4.2M to date',
'discovered 2h ago · updated 1h ago',
'InciWeb ↗ · click card to open',
]);
assert.equal(card.accessibilityLabel, 'Open FIRE · Lake on InciWeb');
});
for (const locale of ['es', 'ru']) {
test(`${locale}: selected fire card follows the UI language`, () => {
setLocale(locale);
try {
assert.notEqual(
t('Fully contained'),
'Fully contained',
'legend is translated',
);
const card = buildIncidentCard(row, now, {
link: 'https://inciweb.example',
});
const text = [card.title, ...card.details, card.accessibilityLabel].join(
' | ',
);
for (const english of [
'FIRE',
' ac ',
'contained',
'discovered',
'updated',
'part of',
'cause',
'personnel',
'County',
'to date',
'click card to open',
'Open ',
])
assert.ok(
!text.includes(english),
`${locale}: English "${english}" in ${text}`,
);
assert.ok(
!text.includes('1,200'),
`${locale}: en-US grouping in ${text}`,
);
} finally {
setLocale('en');
}
});
}
// Known WFIGS values, the cost and the unnamed title follow the
// UI language; the row keeps the feed's English for the AI tools.
const expected = {
es: {
situation: 'causa: humana · actividad moderada · incidente de tipo 3',
cost: '4,2 M$ hasta la fecha',
unnamed: 'INCENDIO · Sin nombre',
},
ru: {
situation:
'причина: деятельность человека · умеренная активность · инцидент 3-го типа',
cost: '4,2 млн $ на сегодня',
unnamed: 'ПОЖАР · Без названия',
},
};
for (const [locale, want] of Object.entries(expected)) {
test(`${locale}: feed values, cost and unnamed title are localized`, () => {
setLocale(locale);
try {
const card = buildIncidentCard(row, now);
assert.equal(card.details[2], want.situation);
assert.ok(
card.details[3].endsWith(want.cost),
`${locale}: ${card.details[3]}`,
);
assert.equal(
buildIncidentCard({ ...row, name: '' }, now).title,
want.unnamed,
);
assert.equal(row.cause, 'Human', 'the row keeps the feed value');
} finally {
setLocale('en');
}
});
}
test('unknown WFIGS values are shown as sent', () => {
setLocale('ru');
try {
const card = buildIncidentCard(
{
...row,
cause: 'Investigated',
behavior: 'Smoldering',
complexity: 'Complex Incident',
},
now,
);
assert.equal(
card.details[2],
'причина: Investigated · Smoldering · Complex Incident',
);
} finally {
setLocale('en');
}
});
test('en: compact costs keep the dollar prefix', () => {
setLocale('en');
const cost = (costToDate) =>
buildIncidentCard({ ...row, costToDate }, now).details[3].split(' · ')[2];
assert.equal(cost(85_000), '$85K to date');
assert.equal(cost(999_600), '$1M to date');
assert.equal(cost(1_300_000_000), '$1.3B to date');
});
+100 -30
View File
@@ -1,4 +1,5 @@
/** Card model for one selected fire-perimeter incident. Pure — no Cesium types. */
import { formatNumber, t, tn } from '../../i18n/index.js';
export const PERIMETER_OVERLAY_SOURCE_ID = 'fire-perimeters';
@@ -50,25 +51,70 @@ export function perimeterAnchorDegrees(polygons) {
return { lon: lon / count, lat: lat / count };
}
/** Compact USD, e.g. $85K / $4.2M / $1.3B. Null under $1,000. */
/**
* Compact USD in the UI language: en "$85K / $4.2M / $1.3B", ru "4,2 млн $",
* es "4,2 M$". Null under $1,000.
*/
function formatCost(dollars) {
if (!Number.isFinite(dollars) || dollars < 1000) return null;
const units = [
[1e3, 'K'],
[1e6, 'M'],
[1e9, 'B'],
];
const units = [1e3, 1e6, 1e9];
let index = 0;
for (let i = units.length - 1; i >= 0; i--) {
if (dollars >= units[i][0]) {
if (dollars >= units[i]) {
index = i;
break;
}
}
// 999,500 must promote to $1M, never render as $1000K.
if (dollars >= units[index][0] * 999.5 && index < units.length - 1) index++;
const value = dollars / units[index][0];
return `$${value >= 10 ? Math.round(value) : Math.round(value * 10) / 10}${units[index][1]}`;
if (dollars >= units[index] * 999.5 && index < units.length - 1) index++;
const value = dollars / units[index];
const amount = formatNumber(
value >= 10 ? Math.round(value) : Math.round(value * 10) / 10,
);
if (index === 0) return t('${amount}K', { amount });
if (index === 1) return t('${amount}M', { amount });
return t('${amount}B', { amount });
}
/** WFIGS attr_FireCause values in the UI language; others as sent. */
function causeLabel(cause) {
switch (cause) {
case 'Human':
return t('Human', { context: 'fire cause' });
case 'Natural':
return t('Natural', { context: 'fire cause' });
case 'Undetermined':
return t('Undetermined', { context: 'fire cause' });
case 'Unknown':
return t('Unknown', { context: 'fire cause' });
default:
return cause;
}
}
/** WFIGS attr_FireBehaviorGeneral values in the UI language; others as sent. */
function behaviorLabel(behavior) {
switch (behavior) {
case 'Minimal':
return t('Minimal', { context: 'fire behavior' });
case 'Moderate':
return t('Moderate', { context: 'fire behavior' });
case 'Active':
return t('Active', { context: 'fire behavior' });
case 'Extreme':
return t('Extreme', { context: 'fire behavior' });
default:
return behavior;
}
}
/**
* WFIGS attr_IncidentComplexityLevel ("Type 1 Incident" … "Type 5 Incident")
* in the UI language; other values as sent.
*/
function complexityLabel(complexity) {
const match = /^Type ([1-5]) Incident$/.exec(complexity);
return match ? t('Type {level} Incident', { level: match[1] }) : complexity;
}
/** WFIGS complex names arrive ALL-CAPS; render them in title case. */
@@ -78,12 +124,17 @@ function titleCase(name) {
.replace(/\b[a-z]/g, (letter) => letter.toUpperCase());
}
/** Compact age, "5m" / "3h" / "2d", in the UI language ("3 h", "3 ч"). */
function formatAge(deltaMs) {
if (!Number.isFinite(deltaMs) || deltaMs < 0) return null;
const hours = Math.floor(deltaMs / 3600000);
if (hours < 1) return `${Math.max(1, Math.floor(deltaMs / 60000))}m`;
if (hours < 24) return `${hours}h`;
return `${Math.floor(hours / 24)}d`;
if (hours < 1)
return t('{count}m', {
context: 'age',
count: Math.max(1, Math.floor(deltaMs / 60000)),
});
if (hours < 24) return t('{count}h', { context: 'age', count: hours });
return t('{count}d', { context: 'age', count: Math.floor(hours / 24) });
}
/**
@@ -95,48 +146,65 @@ function formatAge(deltaMs) {
* @returns {Object} World-overlay entry without `position`.
*/
export function buildIncidentCard(row, nowMs, { link = null } = {}) {
// The row keeps WFIGS values as sent (AI tools read them); the card translates
// the known cause, behavior and complexity values and the wording around them.
const facts = [];
if (Number.isFinite(row.acres))
facts.push(`${Math.round(row.acres).toLocaleString('en-US')} ac`);
if (Number.isFinite(row.acres)) {
const acres = Math.round(row.acres);
facts.push(
tn(acres, '{acres} ac', '{acres} ac', { acres: formatNumber(acres) }),
);
}
facts.push(
Number.isFinite(row.containedPct)
? `${Math.round(row.containedPct)}% contained`
: 'containment unknown',
? t('{percent}% contained', { percent: Math.round(row.containedPct) })
: t('containment unknown'),
);
if (row.state) facts.push(row.state);
else if (row.category) facts.push(row.category);
const situation = [];
if (row.cause) situation.push(`${row.cause} cause`);
if (row.behavior) situation.push(row.behavior);
if (row.complexity) situation.push(row.complexity);
if (row.cause)
situation.push(t('{cause} cause', { cause: causeLabel(row.cause) }));
if (row.behavior) situation.push(behaviorLabel(row.behavior));
if (row.complexity) situation.push(complexityLabel(row.complexity));
const response = [];
if (Number.isFinite(row.personnel))
if (Number.isFinite(row.personnel)) {
const personnel = Math.round(row.personnel);
response.push(
`${Math.round(row.personnel).toLocaleString('en-US')} personnel`,
tn(personnel, '{personnel} personnel', '{personnel} personnel', {
personnel: formatNumber(personnel),
}),
);
if (row.county) response.push(`${row.county} County`);
}
if (row.county) response.push(t('{county} County', { county: row.county }));
if (Number.isFinite(row.costToDate)) {
const cost = formatCost(row.costToDate);
if (cost) response.push(`${cost} to date`);
if (cost) response.push(t('{cost} to date', { cost }));
}
const ages = [];
const discovered = formatAge(nowMs - row.discoveredTime);
if (row.discoveredTime != null && discovered)
ages.push(`discovered ${discovered} ago`);
ages.push(t('discovered {age} ago', { age: discovered }));
const updated = formatAge(nowMs - row.updatedTime);
if (row.updatedTime != null && updated) ages.push(`updated ${updated} ago`);
if (row.updatedTime != null && updated)
ages.push(t('updated {age} ago', { age: updated }));
const details = [facts.join(' · ')];
if (row.complexName) details.push(`part of ${titleCase(row.complexName)}`);
if (row.complexName)
details.push(
t('part of {complex}', { complex: titleCase(row.complexName) }),
);
if (situation.length) details.push(situation.join(' · '));
if (response.length) details.push(response.join(' · '));
if (ages.length) details.push(ages.join(' · '));
if (link) details.push('InciWeb ↗ · click card to open');
if (link) details.push(t('InciWeb ↗ · click card to open'));
const title = `FIRE · ${row.name || 'Unnamed incident'}`;
const title = t('FIRE · {name}', {
name: row.name || t('Unnamed incident'),
});
return {
id: `fire-perimeter-card:${row.stableId}`,
// `selected` picks the paint lane and collision protection; an explicit
@@ -145,7 +213,9 @@ export function buildIncidentCard(row, nowMs, { link = null } = {}) {
// Only a linked card is clickable — the overlay host registers hit
// rectangles solely for interactive entries.
interactive: Boolean(link),
...(link ? { accessibilityLabel: `Open ${title} on InciWeb` } : undefined),
...(link
? { accessibilityLabel: t('Open {title} on InciWeb', { title }) }
: undefined),
title,
details,
accent: containmentAccent(row.containedPct),
+8 -6
View File
@@ -10,6 +10,7 @@ import {
inciwebNodeId,
isCurrentPublication,
} from './inciweb.js';
import { msg, t } from '../../i18n/index.js';
export { normalizeFirePerimeterSnapshot } from './records.js';
export { createWfigsPerimeterSource } from './source.js';
export * from './cards.js';
@@ -364,7 +365,7 @@ export function createFirePerimetersLayer({
if (request.signal.aborted || _request !== request || !_enabled)
return false;
console.warn('[Data:FirePerimeters] Fetch error:', e);
_lastError = e?.message || 'Perimeter source unavailable';
_lastError = e?.message || msg('Perimeter source unavailable');
return false;
} finally {
if (_request === request) _request = null;
@@ -414,10 +415,10 @@ export function createFirePerimetersLayer({
getRowControls() {
const bands = [
{ label: 'Not contained or unknown', color: containmentAccent(0) },
{ label: 'Under 50% contained', color: containmentAccent(1) },
{ label: '50–99% contained', color: containmentAccent(50) },
{ label: 'Fully contained', color: containmentAccent(100) },
{ label: t('Not contained or unknown'), color: containmentAccent(0) },
{ label: t('Under 50% contained'), color: containmentAccent(1) },
{ label: t('50–99% contained'), color: containmentAccent(50) },
{ label: t('Fully contained'), color: containmentAccent(100) },
];
return {
chips: [],
@@ -428,8 +429,9 @@ export function createFirePerimetersLayer({
).length,
...(index === 0
? {
blurb:
blurb: t(
'Colour shows reported containment. Perimeters are simplified to about 100 m.',
),
}
: {}),
})),
+5 -1
View File
@@ -1,5 +1,9 @@
import { readResponseJsonCapped } from '../../sources/httpBody.js';
// A portable export (package.json): no i18n here. Failures stay English data;
// the Layers panel shows them translated.
const msg = (text) => text;
/** Request a normalized snapshot through the bounded, same-origin WFIGS proxy. */
export function createWfigsPerimeterSource({
fetchImpl = (...args) => globalThis.fetch(...args),
@@ -16,7 +20,7 @@ export function createWfigsPerimeterSource({
);
signal?.throwIfAborted();
if (!Array.isArray(payload?.rows))
throw new Error('Malformed perimeter snapshot');
throw new Error(msg('Malformed perimeter snapshot'));
return payload.rows;
},
};
+17 -10
View File
@@ -1,4 +1,5 @@
import { MUSIC_GENRES, CATEGORY_MATCHERS } from './policy.js';
import { t } from '../../i18n/index.js';
export function createCategories({
state: layerState,
@@ -69,23 +70,29 @@ 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('All', { context: 'radio tag' }) },
{ 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('Aviation / Marine'),
},
{
id: 'traffic-transit',
label: t('Traffic / Transit'),
},
{ id: 'music', label: t('Music') },
];
for (const [genre, label] of MUSIC_GENRES) {
const id = `genre:${genre}`;
if (rows.some((station) => stationMatchesRadioCategory(station, id))) {
categories.push({ id, label });
categories.push({ id, label: t(label) });
}
}
categories.push({ id: 'other', label: 'Other' });
categories.push({ id: 'other', label: t('Other') });
return categories.map((category) => ({
...category,
color: parts.model.radioCategoryColor(category.id),
+18 -4
View File
@@ -1,8 +1,22 @@
import { msg } from '../../i18n/index.js';
import {
RADIO_DIRECTORY_STALE_MS,
RADIO_DIRECTORY_FUTURE_SKEW_MS,
} from './policy.js';
// Directory errors stay English in layer state and stats; the layer row and
// the Radio panel translate them when shown.
const ERROR_REFRESH_DEGRADED = msg(
'Directory refresh degraded; showing the previous station catalog.',
);
const ERROR_COVERAGE_DEGRADED = msg('Radio directory coverage is degraded.');
const ERROR_REFRESH_FAILED = msg(
'Directory refresh failed; showing the previous station catalog.',
);
const ERROR_DIRECTORY_UNAVAILABLE = msg(
'Radio directory is temporarily unavailable.',
);
export function createIngestion({
state: layerState,
services,
@@ -136,9 +150,9 @@ export function createIngestion({
layerState._degraded = body.degraded;
layerState._stale = body.stale;
layerState._error = preservingWarmCatalog
? 'Directory refresh degraded; showing the previous station catalog.'
? ERROR_REFRESH_DEGRADED
: layerState._degraded
? 'Radio directory coverage is degraded.'
? ERROR_COVERAGE_DEGRADED
: null;
} catch (error) {
if (
@@ -153,8 +167,8 @@ export function createIngestion({
)
return;
layerState._error = layerState._stations.length
? 'Directory refresh failed; showing the previous station catalog.'
: 'Radio directory is temporarily unavailable.';
? ERROR_REFRESH_FAILED
: ERROR_DIRECTORY_UNAVAILABLE;
layerState._stale = layerState._stations.length > 0;
layerState._degraded = layerState._stations.length > 0;
} finally {
@@ -0,0 +1,102 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { setLocale, t, translateKnown } from '../../i18n/index.js';
import { createIngestion } from './ingestion.js';
/**
* Directory errors are stored in English in layer state (layerPanel and the
* Radio panel translate them when shown). Every error the ingestion can set
* must therefore have a catalog entry in each shipped language.
*/
function makeIngestion({ stations = [], body = null, failure = null } = {}) {
const layerState = {
_enabled: true,
_requestGeneration: 0,
_sessionGeneration: 0,
_abortController: null,
_loading: false,
_error: null,
_stale: false,
_degraded: false,
_stations: stations,
_updatedAt: null,
_tuningActive: false,
_cancelledTuningPresentationStation: null,
_acceptedCatalogSnapshot: { generation: null, instance: null },
};
const parts = {
presentation: { emitState() {} },
model: { radioRequestIsCurrent: () => true },
catalogModel: {
isValidRadioDirectoryStation: () => true,
freezeRadioStation: (station) => station,
createAcceptedCatalogSnapshot: () => null,
},
rendering: { reconcileStations() {}, updateSelectionEntity() {} },
};
const source = {
async getDirectory() {
if (failure) throw failure;
return body;
},
};
const { methods } = createIngestion({
state: layerState,
services: {},
parts,
source,
});
return { layerState, methods };
}
const degradedBody = () => ({
stations: [{ id: 'a' }],
updatedAt: new Date().toISOString(),
stale: false,
degraded: true,
acceptedGeneration: 1,
catalogInstance: 'instance-1',
});
const CASES = [
{
name: 'directory unavailable without a catalog',
options: { failure: new Error('network') },
expected: 'Radio directory is temporarily unavailable.',
},
{
name: 'refresh failed with a warm catalog',
options: { stations: [{ id: 'a' }], failure: new Error('network') },
expected: 'Directory refresh failed; showing the previous station catalog.',
},
{
name: 'degraded coverage without a catalog',
options: { body: degradedBody() },
expected: 'Radio directory coverage is degraded.',
},
{
name: 'degraded refresh with a warm catalog',
options: { stations: [{ id: 'a' }], body: degradedBody() },
expected:
'Directory refresh degraded; showing the previous station catalog.',
},
];
for (const { name, options, expected } of CASES) {
test(`radio directory error is English in state and translated on show: ${name}`, async () => {
const { layerState, methods } = makeIngestion(options);
await methods.update();
assert.equal(layerState._error, expected);
try {
for (const locale of ['es', 'ru']) {
setLocale(locale);
const shown = translateKnown(layerState._error);
assert.notEqual(shown, expected, `${locale}: ${name}`);
assert.equal(t(layerState._error), shown);
}
} finally {
setLocale('en');
}
});
}
+24 -2
View File
@@ -1,8 +1,30 @@
import {
RADIO_CATEGORY_COLORS,
RADIO_CLUSTER_LABELS,
RADIO_MARKER_CATEGORY_ORDER,
} from './policy.js';
import { t } from '../../i18n/index.js';
/** Short uppercase category word on a cluster badge, in the page language. */
function radioClusterLabel(categoryId) {
switch (categoryId) {
case 'news':
return t('NEWS', { context: 'radio' });
case 'talk':
return t('TALK', { context: 'radio' });
case 'weather':
return t('WEATHER', { context: 'radio' });
case 'public-safety':
return t('SAFETY', { context: 'radio' });
case 'aviation-marine':
return t('AIR / SEA', { context: 'radio' });
case 'traffic-transit':
return t('TRANSIT', { context: 'radio' });
case 'music':
return t('MUSIC', { context: 'radio' });
default:
return t('OTHER', { context: 'radio' });
}
}
export function createModel({ state: layerState, services, parts, source }) {
/** Return the shared CSS color for a canonical or detected-genre category. */
@@ -18,7 +40,7 @@ export function createModel({ state: layerState, services, parts, source }) {
function radioClusterBadgeText(categoryId = 'other', count = 0) {
const normalized = String(categoryId || 'other');
const canonical = normalized.startsWith('genre:') ? 'music' : normalized;
const label = RADIO_CLUSTER_LABELS[canonical] || RADIO_CLUSTER_LABELS.other;
const label = radioClusterLabel(canonical);
const stationCount = Math.max(0, Math.floor(Number(count) || 0));
return `${stationCount} ${label}`;
}
+16 -4
View File
@@ -1,5 +1,18 @@
import { msg } from '../../i18n/index.js';
import { RADIO_VOICE_PLAYBACK_TIMEOUT_MS } from './policy.js';
// Playback errors stay English in layer state; the Radio panel shows them
// through t().
const AUDIO_ERROR_STREAM_FAILED = msg(
'Broadcaster stream is unavailable or blocked by the browser.',
);
const AUDIO_ERROR_GESTURE_REQUIRED = msg(
'Playback requires a direct click or tap.',
);
const AUDIO_ERROR_START_FAILED = msg(
'Broadcaster stream could not be started.',
);
export function createPlayback({ state: layerState, services, parts, source }) {
function audioEventBelongsToActiveAttempt(audio) {
const attempt = layerState._activePlaybackAttempt;
@@ -75,8 +88,7 @@ export function createPlayback({ state: layerState, services, parts, source }) {
const attempt = layerState._activePlaybackAttempt;
if (tryRadioFallback(failedId, attempt?.origin, attempt?.id)) return;
layerState._audioState = 'error';
layerState._audioError =
'Broadcaster stream is unavailable or blocked by the browser.';
layerState._audioError = AUDIO_ERROR_STREAM_FAILED;
parts.presentation.emitState();
});
}
@@ -202,8 +214,8 @@ export function createPlayback({ state: layerState, services, parts, source }) {
layerState._audioState = 'error';
layerState._audioError =
error?.name === 'NotAllowedError'
? 'Playback requires a direct click or tap.'
: 'Broadcaster stream could not be started.';
? AUDIO_ERROR_GESTURE_REQUIRED
: AUDIO_ERROR_START_FAILED;
parts.presentation.emitState();
return false;
}
+29 -36
View File
@@ -62,32 +62,36 @@ export const DEFAULT_RADIO_FILTER = 'all';
export const GLOBAL_RADIO_ALTITUDE_M = 2_000_000;
// Genre labels stay English data (the radio source, shared with the AI tools,
// imports this module); categories.js translates them.
const msg = (text) => text;
export const MUSIC_GENRES = Object.freeze([
['alternative', 'Alternative'],
['ambient', 'Ambient'],
['blues', 'Blues'],
['classical', 'Classical'],
['country', 'Country'],
['dance', 'Dance'],
['electronic', 'Electronic'],
['folk', 'Folk'],
['funk', 'Funk'],
['hip hop', 'Hip-Hop'],
['house', 'House'],
['indie', 'Indie'],
['jazz', 'Jazz'],
['latin', 'Latin'],
['metal', 'Metal'],
['oldies', 'Oldies'],
['pop', 'Pop'],
['punk', 'Punk'],
['r&b', 'R&B'],
['reggae', 'Reggae'],
['rock', 'Rock'],
['soul', 'Soul'],
['techno', 'Techno'],
['trance', 'Trance'],
['world', 'World'],
['alternative', msg('Alternative')],
['ambient', msg('Ambient')],
['blues', msg('Blues')],
['classical', msg('Classical')],
['country', msg('Country')],
['dance', msg('Dance')],
['electronic', msg('Electronic')],
['folk', msg('Folk')],
['funk', msg('Funk')],
['hip hop', msg('Hip-Hop')],
['house', msg('House')],
['indie', msg('Indie')],
['jazz', msg('Jazz')],
['latin', msg('Latin')],
['metal', msg('Metal')],
['oldies', msg('Oldies')],
['pop', msg('Pop')],
['punk', msg('Punk')],
['r&b', msg('R&B')],
['reggae', msg('Reggae')],
['rock', msg('Rock')],
['soul', msg('Soul')],
['techno', msg('Techno')],
['trance', msg('Trance')],
['world', msg('World')],
]);
export const CATEGORY_MATCHERS = Object.freeze({
@@ -127,17 +131,6 @@ export const RADIO_CATEGORY_COLORS = Object.freeze({
other: '#9aa7b3',
});
export const RADIO_CLUSTER_LABELS = Object.freeze({
news: 'NEWS',
talk: 'TALK',
weather: 'WEATHER',
'public-safety': 'SAFETY',
'aviation-marine': 'AIR / SEA',
'traffic-transit': 'TRANSIT',
music: 'MUSIC',
other: 'OTHER',
});
export const RADIO_MARKER_CATEGORY_ORDER = Object.freeze([
'news',
'public-safety',
+44
View File
@@ -0,0 +1,44 @@
/**
* Recent Imagery text in the page's language: days, metres and the focused
* day's readout. The model (`model.js`) stays English and i18n-free because AI
* tool services import it outside the browser; this module is page-only.
*/
import { formatDate, formatRelativeTime, t } from '../../i18n/index.js';
import { formatQuantity } from '../../ui/unitLabels.js';
import { dayTime } from './model.js';
const SHORT_DAY = Object.freeze({
month: 'short',
day: 'numeric',
timeZone: 'UTC',
});
const LONG_DAY = Object.freeze({ ...SHORT_DAY, year: 'numeric' });
/** `Sep 18` / `18 сент.` for a `YYYY-MM-DD` day, in the page's language. */
export function displayDay(day, options = SHORT_DAY) {
const time = dayTime(day);
return time === null ? '' : formatDate(time, options);
}
/** `30 m` / `30 м`, number and unit kept together. */
export const displayMetres = (value) => formatQuantity(value, 'm');
/** The readout's words in the page's language; the model keeps them English. */
export const READOUT_WORDS = Object.freeze({
date: (day) => displayDay(day, LONG_DAY),
daysAgo: (delta) => formatRelativeTime(delta > 0 ? -delta : 0, 'day'),
// "Sentinel-2" never breaks at its hyphen: a word joiner follows it
// (JetBrains Mono has no U+2011 non-breaking hyphen).
sensor: (sensor) =>
t(sensor).replace(/(?<=[\p{L}\p{N}])-(?=[\p{L}\p{N}])/gu, '-\u2060'),
metres: displayMetres,
overview: () => t('overview'),
cloud: (cloud) => {
if (!cloud) return t('cloud unknown');
const min = Math.round(cloud.min);
const max = Math.round(cloud.max);
return min === max
? t('{percent}% scene cloud', { percent: min })
: t('{min}–{max}% scene cloud', { min, max });
},
});
+27 -16
View File
@@ -28,11 +28,12 @@ import {
parseCandidateKey,
quantizeBox,
rankLatest,
shortDay,
utcDay,
validateBox,
viewTooLargeMessage,
} from './model.js';
import { msg, t } from '../../i18n/index.js';
import { READOUT_WORDS, displayDay, displayMetres } from './display.js';
/**
* Quiet time after the last arrow key before the focused day previews, so a
@@ -46,10 +47,11 @@ export const SPLIT_PUBLISH_MS = 400;
/** The three modes, in share-link order (`m` = index). */
export const MODES = Object.freeze(['image', 'basemap', 'ab']);
const COMPARISON_IN_USE = 'Comparison in use by another scene';
const COMPARISON_UNAVAILABLE = 'Esri map unavailable · no swipe';
const COMPARISON_NEEDS_GLOBE = 'Swipe needs a globe map';
const ESRI_KEPT = 'Imagery stays on Esri · CLEAR to use Google 3D';
// Kept English in the snapshot (AI tools read it); the card translates them.
const COMPARISON_IN_USE = msg('Comparison in use by another scene');
const COMPARISON_UNAVAILABLE = msg('Esri map unavailable · no swipe');
const COMPARISON_NEEDS_GLOBE = msg('Swipe needs a globe map');
const ESRI_KEPT = msg('Imagery stays on Esri · CLEAR to use Google 3D');
/** ZOOM IN's flight time, in seconds. */
const ZOOM_FIT_SECONDS = 1.2;
const DEFAULT_SPLIT = 0.5;
@@ -630,7 +632,7 @@ export function createRecentImageryLayer({
} catch (error) {
if (abort.signal.aborted) return;
resetCatalog();
_catalogError = error?.message || 'Imagery catalog unavailable';
_catalogError = error?.message || msg('Imagery catalog unavailable');
} finally {
if (_searchAbort === abort) {
_searchAbort = null;
@@ -748,15 +750,21 @@ export function createRecentImageryLayer({
function notes() {
const out = [];
if (_truncated) out.push('Catalog truncated · a newer clear day may exist');
if (_truncated)
out.push(t('Catalog truncated · a newer clear day may exist'));
for (const error of _catalogErrors) {
const label = PRODUCTS[error.product]?.label || error.product;
out.push(`${label} catalog unavailable · ${error.message}`);
out.push(
t('{label} catalog unavailable · {detail}', {
label,
detail: error.message,
}),
);
}
const today = utcDay(now());
const viirsToday = candidateFor(`VIIRS:${today}`);
if (viirsToday?.availability === 'unknown' && _sources.viirs)
out.push('Daily overview for today may still be empty until the pass');
out.push(t('Daily overview for today may still be empty until the pass'));
return out;
}
@@ -767,7 +775,7 @@ export function createRecentImageryLayer({
key: key || null,
candidate: candidateFor(key),
label: parsed
? `${shortDay(parsed.day)} · ${PRODUCTS[parsed.product].name} · ${PRODUCTS[parsed.product].resolutionM} m`
? `${displayDay(parsed.day)} · ${t(PRODUCTS[parsed.product].name)} · ${displayMetres(PRODUCTS[parsed.product].resolutionM)}`
: null,
sourceOff,
drapable: drapable(candidateFor(key)),
@@ -1227,7 +1235,7 @@ export function createRecentImageryLayer({
_alpha = 1;
for (const slotId of SLOT_IDS) renderer.setAlpha(slotId, 1);
}
_notice = 'Box and images cleared';
_notice = msg('Box and images cleared');
syncRender();
publish([...BOX_EDGES, 'a', 'b', 'split']);
notify();
@@ -1276,7 +1284,9 @@ export function createRecentImageryLayer({
candidates,
focusIndex,
focus,
readout: focus ? formatCandidateReadout(focus, now()) : '',
readout: focus
? formatCandidateReadout(focus, now(), READOUT_WORDS)
: '',
recommended: ranked.candidate
? { key: ranked.candidate.key, reason: ranked.reason }
: null,
@@ -1329,17 +1339,18 @@ export function createRecentImageryLayer({
chips: [
{
id: 'hls',
label: 'MORE DETAIL · 30 m',
label: t('MORE DETAIL · 30 m'),
active: _sources.hls,
title: 'Sentinel-2 and Landsat via HLS, 30 m, every 2–6 days',
title: t('Sentinel-2 and Landsat via HLS, 30 m, every 2–6 days'),
params: { hls: !_sources.hls },
},
{
id: 'viirs',
label: 'DAILY OVERVIEW · 250 m',
label: t('DAILY OVERVIEW · 250 m'),
active: _sources.viirs,
title:
title: t(
'VIIRS daily mosaic, 250 m, follows broad changes; little detail under 25 km',
),
params: { viirs: !_sources.viirs },
},
],
+1 -1
View File
@@ -228,7 +228,7 @@ test('a valid box searches and previews the START HERE day alone in IMAGE mode',
assert.equal(snapshot.focus.key, S18, 'focus starts on it');
assert.equal(snapshot.preview.key, S18);
assert.equal(snapshot.preview.slot, 'a');
assert.equal(snapshot.preview.label, 'Sep 18 · Sentinel-2 · 30 m');
assert.equal(snapshot.preview.label, 'Sep 18 · Sentinel-2 · 30\u00a0m');
assert.equal(snapshot.candidates[1].preview, true);
const none = {
key: null,
+56 -25
View File
@@ -1,3 +1,7 @@
// Marks text for i18n extraction; the UI translates it with t(). Kept local
// because AI tool services import this module outside the browser.
const msg = (text) => text;
/**
* Pure domain model for the Recent Imagery layer: products, the selection box,
* per-day candidates, ranking and the keyless URL builders. No DOM, no Cesium,
@@ -29,10 +33,10 @@ const MONTHS = 'Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec'.split(' ');
*/
export const PRODUCTS = Object.freeze({
S30: Object.freeze({
label: 'HLS S30',
name: 'Sentinel-2',
label: 'HLS S30', // i18n-ignore: product code
name: msg('Sentinel-2'),
source: 'hls',
sensor: 'Sentinel-2 via HLS',
sensor: msg('Sentinel-2 via HLS'),
resolutionM: 30,
gibsLayer: 'HLS_S30_Nadir_BRDF_Adjusted_Reflectance',
maxLevel: 12,
@@ -41,10 +45,10 @@ export const PRODUCTS = Object.freeze({
overview: false,
}),
L30: Object.freeze({
label: 'HLS L30',
name: 'Landsat 8/9',
label: 'HLS L30', // i18n-ignore: product code
name: msg('Landsat 8/9'),
source: 'hls',
sensor: 'Landsat 8/9 via HLS',
sensor: msg('Landsat 8/9 via HLS'),
resolutionM: 30,
gibsLayer: 'HLS_L30_Nadir_BRDF_Adjusted_Reflectance',
maxLevel: 12,
@@ -53,8 +57,8 @@ export const PRODUCTS = Object.freeze({
overview: false,
}),
VIIRS: Object.freeze({
label: 'VIIRS',
name: 'Daily overview',
label: 'VIIRS', // i18n-ignore: product code
name: msg('Daily overview'),
source: 'viirs',
sensor: 'VIIRS NOAA-21',
resolutionM: 250,
@@ -128,11 +132,12 @@ const refuse = (reason, message) => ({ ok: false, reason, message });
*/
export function validateBox(box) {
const normalized = normalizeBox(box);
if (!normalized) return refuse('invalid', 'Box edges must be finite');
if (!normalized) return refuse('invalid', msg('Box edges must be finite'));
const { west, south, east, north } = normalized;
if (Math.abs(west) > 180 || Math.abs(east) > 180)
return refuse('invalid', 'Longitudes must be within ±180°');
if (west > east) return refuse('dateline', 'Select one side of the dateline');
return refuse('invalid', msg('Longitudes must be within ±180°'));
if (west > east)
return refuse('dateline', msg('Select one side of the dateline'));
if (
Math.abs(south) > MERCATOR_LAT_LIMIT ||
Math.abs(north) > MERCATOR_LAT_LIMIT
@@ -142,7 +147,7 @@ export function validateBox(box) {
`Imagery stops at ±${MERCATOR_LAT_LIMIT.toFixed(2)}° latitude`,
);
if (east === west || north === south)
return refuse('degenerate', 'Box must enclose an area');
return refuse('degenerate', msg('Box must enclose an area'));
const { width, height } = boxSideKm(normalized);
if (width > MAX_BOX_SIDE_KM || height > MAX_BOX_SIDE_KM)
return refuse('too-large', tooLargeMessage(Math.max(width, height)));
@@ -275,6 +280,11 @@ export function utcDay(value) {
: null;
}
/** UTC midnight of a `YYYY-MM-DD` day in epoch ms, for locale formatting; null otherwise. */
export function dayTime(day) {
return isValidDay(day) ? Date.parse(`${day}T00:00:00Z`) : null;
}
/** `Sep 18` for a `YYYY-MM-DD` day; '' otherwise. */
export function shortDay(day) {
const match = /^\d{4}-(\d{2})-(\d{2})$/.exec(String(day || ''));
@@ -492,15 +502,14 @@ export function rankLatest(
return { candidate: null, reason: null, certain };
}
function daysAgoLabel(day, now) {
/** Whole UTC days from `day` to the day of `now`; null when either is invalid. */
function daysBetween(day, now) {
const today = utcDay(now);
if (!today) return null;
const delta = Math.round(
return Math.round(
(Date.parse(`${today}T00:00:00Z`) - Date.parse(`${day}T00:00:00Z`)) /
DAY_MS,
);
if (delta <= 0) return 'today';
return delta === 1 ? 'yesterday' : `${delta} days ago`;
}
function cloudLabel(cloud) {
@@ -510,18 +519,40 @@ function cloudLabel(cloud) {
return min === max ? `${min}% scene cloud` : `${min}–${max}% scene cloud`;
}
/**
* The words of the readout. The page passes its own (translated, locale
* formatted) set; this module stays free of i18n because AI tool services
* import it outside the browser.
*/
const READOUT_WORDS = Object.freeze({
/** `Sep 18, 2026` for a valid `YYYY-MM-DD` day. */
date: (day) => {
const [y, m, d] = day.split('-').map(Number);
return `${MONTHS[m - 1]} ${d}, ${y}`;
},
/** Age in whole UTC days, 0 or less for today. */
daysAgo: (delta) =>
delta <= 0 ? 'today' : delta === 1 ? 'yesterday' : `${delta} days ago`,
sensor: (sensor) => sensor,
metres: (value) => `${value} m`,
overview: () => 'overview',
/** `{ min, max }` scene cloud in percent, or null. */
cloud: cloudLabel,
});
/**
* One-line UTC readout, e.g.
* `Sep 18, 2026 17:12Z · 3 days ago · Sentinel-2 via HLS · 30 m · 12% scene cloud`.
* @param {object} candidate
* @param {string | number | Date} [now]
* @param {Partial<typeof READOUT_WORDS>} [words] the page's localized words
* @returns {string}
*/
export function formatCandidateReadout(candidate, now = new Date()) {
export function formatCandidateReadout(candidate, now = new Date(), words) {
const product = PRODUCTS[candidate?.product];
if (!product || !candidate?.day) return '';
const [y, m, d] = candidate.day.split('-').map(Number);
let when = `${MONTHS[m - 1]} ${d}, ${y}`;
if (!product || !isValidDay(candidate?.day)) return '';
const say = words ? { ...READOUT_WORDS, ...words } : READOUT_WORDS;
let when = say.date(candidate.day);
if (candidate.timeRange?.start) {
const start = hhmm(candidate.timeRange.start);
const end = candidate.timeRange.end ? hhmm(candidate.timeRange.end) : start;
@@ -531,11 +562,11 @@ export function formatCandidateReadout(candidate, now = new Date()) {
: ` ${start}Z`;
}
const parts = [when];
const ago = daysAgoLabel(candidate.day, now);
if (ago) parts.push(ago);
parts.push(product.sensor, `${product.resolutionM} m`);
if (product.overview) parts.push('overview');
parts.push(cloudLabel(candidate.cloud));
const delta = daysBetween(candidate.day, now);
if (delta !== null) parts.push(say.daysAgo(delta));
parts.push(say.sensor(product.sensor), say.metres(product.resolutionM));
if (product.overview) parts.push(say.overview());
parts.push(say.cloud(candidate.cloud));
return parts.join(' · ');
}
+16 -7
View File
@@ -4,6 +4,7 @@ import {
satelliteClassLegend,
} from '../../data/satelliteClass.js';
import * as Cesium from 'cesium';
import { t } from '../../i18n/index.js';
import { ISS_NORAD, POINT_STYLES } from './policy.js';
export function createControls({ state: layerState, services, parts, source }) {
@@ -493,19 +494,27 @@ 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(
'Add the full Starlink broadband shell (thousands of extra points)',
);
if (loading) title = t('Loading the Starlink shell…');
else if (failed)
title = `Starlink ${layerState._denseError || 'load failed'} — click to retry`;
title = t('Starlink {detail} — click to retry', {
detail: layerState._denseError || t('load failed'),
});
else if (active)
title =
'Showing the full Starlink shell — click for the core catalog only';
title = t(
'Showing the full Starlink shell — click for the core catalog only',
);
return {
chips: [
{
id: 'catalog',
label: loading ? 'DENSE ···' : failed ? 'DENSE ✕' : 'DENSE',
label: loading
? t('DENSE ···')
: failed
? t('DENSE ✕')
: t('DENSE'),
active,
busy: loading,
disabled: loading,
+4 -1
View File
@@ -1,5 +1,6 @@
import { twoline2satrec } from 'satellite.js';
import * as Cesium from 'cesium';
import { msg } from '../../i18n/index.js';
import { CATALOG_GROUPS, ISS_NORAD, POINT_STYLES } from './policy.js';
export function createIngestion({
@@ -59,7 +60,8 @@ export function createIngestion({
// the chip still read "just now". Keep the existing (stale) catalog on
// screen and surface the outage instead — do NOT stamp _lastUpdate.
if (results.every((r) => !r.ok)) {
layerState._lastError = 'CelesTrak unreachable';
// English data (stats reach the voice tools); the panel translates it.
layerState._lastError = msg('CelesTrak unreachable');
console.warn(
'[Data:Satellites] All CelesTrak groups failed — keeping existing catalog, surfacing outage',
);
@@ -70,6 +72,7 @@ export function createIngestion({
// At least one group loaded — proceed with a fresh rebuild, but keep the
// partial outage visible at the layer control instead of presenting the
// reduced catalog as a fully healthy refresh.
// i18n-ignore: English data; the panel shows it through FEED_ERROR_PATTERNS (ui/statusText.js)
layerState._lastError = failed.length
? `${failed.length} CelesTrak group${failed.length === 1 ? '' : 's'} unavailable`
: null;
+1 -1
View File
@@ -14,7 +14,7 @@ export function createLabels({ state: layerState, services, parts, source }) {
id: String(ISS_NORAD),
position,
variant: 'label',
title: 'ISS',
title: 'ISS', // i18n-ignore: satellite name, as in the satellite catalog
accent: '#ff4444',
priority: 1000,
collisionGroup: 'ambient-label',
+3 -1
View File
@@ -37,7 +37,9 @@ export function mapAnalystRecord(raw) {
// Empty records stay class-less; the class table's fallback is VISUAL and
// must not leak onto a row that has no satellite identity.
satelliteClass:
noradId || group ? satelliteClassLabel(group, { isIss }) : null,
noradId || group
? satelliteClassLabel(group, { isIss, localize: false })
: null,
group,
};
}
+15 -3
View File
@@ -1,5 +1,7 @@
import * as Cesium from 'cesium';
import { satelliteClassLabel } from '../../data/satelliteClass.js';
import { t } from '../../i18n/index.js';
import { formatQuantity, unitLabel } from '../../ui/unitLabels.js';
import {
ISS_NORAD,
CONTEXT_REFRESH_INTERVAL_MS,
@@ -239,7 +241,10 @@ export function createTracking({ state: layerState, services, parts, source }) {
name,
operator: '',
noradId: String(noradId),
class: satelliteClassLabel(sat.group, { isIss: noradId === ISS_NORAD }),
class: satelliteClassLabel(sat.group, {
isIss: noradId === ISS_NORAD,
localize: false,
}),
altitude:
altitudeKm === null ? '' : `${altitudeKm.toLocaleString('en-US')} km`,
},
@@ -351,7 +356,12 @@ export function createTracking({ state: layerState, services, parts, source }) {
const title = sat?.name?.trim() || `SAT-${layerState._trackedNorad}`;
const altitudeM =
layerState._trackedFrameGeo?.altitude ?? fallbackAltitudeM;
const detail = `${Number.isFinite(altitudeM) ? Math.round(altitudeM / 1000) : '?'} km · NORAD ${layerState._trackedNorad}`;
// The card is display only (the voice context slot keeps its own English
// copy), so the altitude unit and the companions line follow the language.
const altitudeText = Number.isFinite(altitudeM)
? formatQuantity(Math.round(altitudeM / 1000), 'km')
: `?\u00a0${unitLabel('km')}`;
const detail = `${altitudeText} · NORAD ${layerState._trackedNorad}`;
// Class leads the detail block: it is what tells the operator WHAT they are
// looking at, and it stays readable under the IR styles that flatten the
// dot colors to a single channel (the card is painted above post-FX).
@@ -369,7 +379,9 @@ export function createTracking({ state: layerState, services, parts, source }) {
if (companions.length > 0) {
const extra = companions.length - 1;
details.push(
`DOCKED · ${companions[0]}${extra > 0 ? ` · +${extra}` : ''}`,
extra > 0
? t('DOCKED · {name} · +{extra}', { name: companions[0], extra })
: t('DOCKED · {name}', { name: companions[0] }),
);
}
const current = layerState._trackedEntity.gevLabelModel;
+24 -5
View File
@@ -18,6 +18,7 @@ import { cameraHeightAboveGround, viewCentre, whenIdle } from './view.js';
import { createGroundClick, groundClickHeightOk } from './groundClick.js';
import { createGroundCaster, nextSurfaceMode } from './groundCast.js';
import { createMeshSampler } from './meshSampler.js';
import { msg, t, translateKnown } from '../../i18n/index.js';
/** Above this camera height the ground under the camera is not worth fetching. */
const SURFACE_WARM_BELOW_M = 6000;
@@ -335,7 +336,9 @@ export function createStreetLevelLayer({
}
function unavailableReason(entry) {
return `${entry.def.name} needs the Google 3D map: choose it under MAP SOURCE`;
return t('{name} needs the Google 3D map: choose it under MAP SOURCE', {
name: translateKnown(entry.def.name),
});
}
/** One imagery source at a time: lighting a provider darkens the others. */
@@ -410,7 +413,9 @@ export function createStreetLevelLayer({
/** The hint a ground-click provider shows, or the provider's own. */
function hintFor(entry, stats) {
if (entry.def.groundClick && stats.hint && !state.groundClickReady)
return `zoom in to a street to open ${entry.def.name}`;
return t('zoom in to a street to open {name}', {
name: translateKnown(entry.def.name),
});
return stats.hint || '';
}
@@ -578,10 +583,13 @@ export function createStreetLevelLayer({
// The lifecycle polls this every second: summarise, don't snapshot.
const snapshots = providerSnapshots();
const coverage = summarizeCoverage(snapshots);
// English data: the layer panel translates these through translateKnown.
let loadingLabel = '';
const keyLabel = coverage.keyRejected ? 'KEY REJECTED' : 'KEY REQUIRED';
const keyLabel = coverage.keyRejected
? msg('KEY REJECTED')
: msg('KEY REQUIRED');
if (coverage.keyRequired) loadingLabel = keyLabel;
else if (coverage.loading) loadingLabel = 'loading coverage...';
else if (coverage.loading) loadingLabel = msg('loading coverage...');
else if (coverage.hint && state.enabled) loadingLabel = coverage.hint;
return {
source: layer.source,
@@ -732,10 +740,21 @@ export function createStreetLevelLayer({
const stillOn = entries.some(
(entry) => entry.on && providerAvailable(entry),
);
// A provider's own refusal is English data that the panel translates;
// the core's refusal is built here, for the panel only, in the language.
if (state.enabled && stillOn)
state.street.error =
lastError?.message ||
`No street-level imagery within ${NEAREST_RADIUS_M} m of ${point ? 'that point' : 'the view centre'}`;
(point
? t('No street-level imagery within {radius} m of that point', {
radius: NEAREST_RADIUS_M,
})
: t(
'No street-level imagery within {radius} m of the view centre',
{
radius: NEAREST_RADIUS_M,
},
));
state.street.loading = false;
notify();
return false;
+19
View File
@@ -6,6 +6,7 @@ import { MAPILLARY_CREDIT_HTML } from './providers/mapillary/policy.js';
import { createMapillaryProvider } from './providers/mapillary/index.js';
import { createKartaViewProvider } from './providers/kartaview/index.js';
import { fakeStreetLevelProvider as fakeProvider } from '../../testSupport/streetLevelFakes.mjs';
import { setLocale } from '../../i18n/index.js';
/**
* A stand-in Cesium viewer the layer can be enabled on: a canvas for the
@@ -690,6 +691,24 @@ test('a lookup for the click-to-open providers asks only them and reports "that
);
});
test('the nearest-image refusal is written in the language of the page', async (t) => {
const { layer } = await withStreetView(t, { nearest: () => null });
layer.setProviderEnabled('google', true);
await settle();
setLocale('es');
try {
await layer.openNearest(
{ lat: 38.58, lon: -121.49 },
{ providerIds: ['google'], frame: false },
);
const error = layer.getUIState().street.error;
assert.notEqual(error, 'No street-level imagery within 50 m of that point');
assert.match(error, /50/, 'keeps the radius');
} finally {
setLocale('en');
}
});
test('an image opened from a ground click leaves the camera where it is', async (t) => {
const { layer, viewer } = await withStreetView(t);
layer.setProviderEnabled('google', true);
+3 -2
View File
@@ -9,6 +9,7 @@ import {
whenIdle,
} from './view.js';
import { densifyLine, MESH_DENSIFY_DEG } from './groundCast.js';
import { msg } from '../../i18n/index.js';
import { MESH_SAMPLE_RADIUS_M } from './meshSampler.js';
import { tileBounds, tilesForBbox } from './tileMath.js';
@@ -703,7 +704,7 @@ export function createLineCoverage({
state.coverage.pending.get(key) !== controller
)
return;
state.coverage.lastError = error?.message || 'Coverage tile failed';
state.coverage.lastError = error?.message || msg('Coverage tile failed');
if (error?.keyRequired) state.keyRequired = true;
if (error?.keyRejected) {
// Every other tile would be refused too: stop asking.
@@ -834,7 +835,7 @@ export function createLineCoverage({
state.coverage.hint =
zoom == null && Number.isFinite(height) && messages.tooHigh
? messages.tooHigh
: 'Point the camera at the globe for street-level coverage';
: msg('Point the camera at the globe for street-level coverage');
clear();
notify();
return;
+9 -5
View File
@@ -4,6 +4,7 @@ import {
wrapDegrees,
} from './panorama.js';
import { imageSize } from './imageSize.js';
import { msg, t } from '../../i18n/index.js';
/** A photo download (tens of kilobytes to a few megabytes). */
export const IMAGE_TIMEOUT_MS = 15_000;
@@ -44,7 +45,7 @@ export function loadImage(
crossOrigin = true,
timeoutMs = IMAGE_TIMEOUT_MS,
resize = null,
failure = 'The photo could not be loaded',
failure = msg('The photo could not be loaded'),
signal = null,
} = {},
) {
@@ -602,11 +603,14 @@ export function createPhotoViewer({
root.setAttribute('role', 'group');
root.setAttribute(
'aria-label',
`${name} photo. ← and → step along the sequence. On a 360° photo, drag or use Shift+← →, ↑ ↓ to look around, and + − to zoom`,
t(
'{name} photo. ← and → step along the sequence. On a 360° photo, drag or use Shift+← →, ↑ ↓ to look around, and + − to zoom',
{ name },
),
);
picture = doc.createElement('img');
picture.className = 'sl-photo-flat';
picture.alt = `${name} street-level photo`;
picture.alt = t('{name} street-level photo', { name });
picture.draggable = false;
picture.hidden = true;
canvas = makeCanvas(doc);
@@ -624,8 +628,8 @@ export function createPhotoViewer({
});
return button;
};
prevButton = nav('Previous photo in this sequence (←)', '‹', -1);
nextButton = nav('Next photo in this sequence (→)', '›', 1);
prevButton = nav(t('Previous photo in this sequence (←)'), '‹', -1);
nextButton = nav(t('Next photo in this sequence (→)'), '›', 1);
root.append(picture, canvas, prevButton, nextButton);
host.append(root);
listen(root, 'keydown', onKeyDown);
+8 -2
View File
@@ -1,3 +1,7 @@
// Reached by the providers' source modules (no browser globals, see
// check:boundaries), so no i18n import: the text stays English data here.
const msg = (text) => text;
/** Identity and shared tuning for the provider-neutral Street Level layer. */
export const STREET_LEVEL_LAYER_ID = 'street-level';
@@ -42,5 +46,7 @@ export const FOLLOW_EYE_HEIGHT_M = 2.4;
/** Above this, a ground click is a misclick, not a street. */
export const GROUND_CLICK_MAX_HEIGHT_M = 3_000;
export const AIM_AT_GROUND_MESSAGE =
'Aim the middle of the view at a street to look for imagery there';
/** English data: the Street Level panel translates it (translateKnown). */
export const AIM_AT_GROUND_MESSAGE = msg(
'Aim the middle of the view at a street to look for imagery there',
);
@@ -1,5 +1,6 @@
import { FOLLOW_MAP_STACK_ID } from '../../policy.js';
import { createMapsLoader } from './mapsLoader.js';
import { getLocale } from '../../../../i18n/index.js';
import { createGoogleViewer } from './viewer.js';
import {
COLORS,
@@ -48,7 +49,7 @@ function settleWithin(promise, { ms, signal, loader }) {
*/
export function createGoogleProvider({
getApiKey,
loader = createMapsLoader({ getApiKey }),
loader = createMapsLoader({ getApiKey, getLanguage: getLocale }),
}) {
if (typeof getApiKey !== 'function')
throw new TypeError('Google Street View needs getApiKey');
@@ -3,16 +3,21 @@
* `gm_authFailure` (key refused) reaches every `onAuthFailure` listener.
*/
import { msg } from '../../../../i18n/index.js';
const SCRIPT_ORIGIN = 'https://maps.googleapis.com';
const READY_CALLBACK = '__gevGoogleMapsReady';
/** The page's one script load, shared by every loader on it. */
const PAGE_LOAD = Symbol.for('gev.googleMapsLoad');
/**
* @param {{getApiKey: () => string|null|undefined, document?: Document, global?: object}} options
* @param {{getApiKey: () => string|null|undefined, getLanguage?: () => string|null|undefined, document?: Document, global?: object}} options
* `getLanguage` names the UI language; Google's own controls (Terms,
* Report a problem) follow it. English adds nothing, as before.
*/
export function createMapsLoader({
getApiKey,
getLanguage,
document = globalThis.document,
global = globalThis,
}) {
@@ -41,7 +46,7 @@ export function createMapsLoader({
function load() {
const key = getApiKey?.();
if (!key)
return Promise.reject(new Error('GOOGLE_MAPS_API_KEY is not set'));
return Promise.reject(new Error(msg('GOOGLE_MAPS_API_KEY is not set')));
installAuthHandler();
if (global[PAGE_LOAD]) return global[PAGE_LOAD];
if (global.google?.maps?.importLibrary)
@@ -53,17 +58,20 @@ export function createMapsLoader({
};
const script = document.createElement('script');
script.async = true;
script.src = `${SCRIPT_ORIGIN}/maps/api/js?${new URLSearchParams({
const params = new URLSearchParams({
key,
v: 'weekly',
loading: 'async',
callback: READY_CALLBACK,
})}`;
});
const language = getLanguage?.();
if (language && language !== 'en') params.set('language', language);
script.src = `${SCRIPT_ORIGIN}/maps/api/js?${params}`;
script.onerror = () => {
if (global[PAGE_LOAD] === loading) delete global[PAGE_LOAD];
script.remove();
delete global[READY_CALLBACK];
reject(new Error('The Google Maps JavaScript API could not load'));
reject(new Error(msg('The Google Maps JavaScript API could not load')));
};
document.head.append(script);
});
@@ -142,3 +142,19 @@ test('a retried load still reports a refusal once, and the page handler once', a
assert.deepEqual(heard, ['a']);
assert.equal(before, 1);
});
test('Google controls follow the UI language; English adds no parameter', async () => {
const language = (locale) => {
const page = fakePage();
const loader = createMapsLoader({
getApiKey: () => 'k-1',
getLanguage: () => locale,
...page,
});
void loader.importLibrary('streetView').catch(() => {});
return new URL(page.scripts[0].src).searchParams.get('language');
};
assert.equal(language('es'), 'es');
assert.equal(language('ru'), 'ru');
assert.equal(language('en'), null);
});
@@ -1,10 +1,11 @@
import { COLORS as SHARED_COLORS, PROVIDER_COLORS } from '../../policy.js';
import { msg } from '../../../../i18n/index.js';
export { NEAREST_RADIUS_M } from '../../policy.js';
export const GOOGLE_PROVIDER_ID = 'google';
export const GOOGLE_NAME = 'Google Street View';
export const GOOGLE_LABEL = 'GOOGLE STREET VIEW';
export const GOOGLE_NAME = msg('Google Street View');
export const GOOGLE_LABEL = msg('GOOGLE STREET VIEW');
/** The browser Maps key; Street View also needs the Maps JavaScript API on it. */
export const GOOGLE_KEY_ID = 'google-maps';
@@ -19,8 +20,9 @@ export const GOOGLE_CREDIT_HTML =
'Street View imagery © <a href="https://www.google.com/streetview/" target="_blank" rel="noopener">Google</a>';
/** Shown when Google refuses the key (gm_authFailure). */
export const KEY_REJECTED_MESSAGE =
'Google refused GOOGLE_MAPS_API_KEY for Street View — check that the Maps JavaScript API is enabled for it and this site is an allowed referrer';
export const KEY_REJECTED_MESSAGE = msg(
'Google refused GOOGLE_MAPS_API_KEY for Street View — check that the Maps JavaScript API is enabled for it and this site is an allowed referrer',
);
/** Past these, a nearest lookup, an open or a details lookup has failed. */
export const NEAREST_TIMEOUT_MS = 10_000;
@@ -29,10 +31,11 @@ export const DETAILS_TIMEOUT_MS = 10_000;
/** Pause after a failed details lookup before a view event can retry. */
export const DETAILS_RETRY_COOLDOWN_MS = 5_000;
export const NO_ANSWER_MESSAGE =
'Street View did not answer — check the network, and that the Maps JavaScript API is enabled for GOOGLE_MAPS_API_KEY';
export const NO_ANSWER_MESSAGE = msg(
'Street View did not answer — check the network, and that the Maps JavaScript API is enabled for GOOGLE_MAPS_API_KEY',
);
export const GROUND_CLICK_HINT = 'click a street to open Street View';
export const GROUND_CLICK_HINT = msg('click a street to open Street View');
/** Deep link to a panorama on Google Maps (Maps URLs). */
export function streetViewUrl(panoId, { heading = 0, pitch = 0 } = {}) {
@@ -9,6 +9,7 @@ import {
imageDateMs,
streetViewUrl,
} from './policy.js';
import { msg } from '../../../../i18n/index.js';
const PANORAMA_OPTIONS = Object.freeze({
visible: false,
@@ -204,7 +205,7 @@ export function createGoogleViewer({ loader, getService, render } = {}) {
else if (status)
finish(
reject,
new Error('Street View has no imagery for this place'),
new Error(msg('Street View has no imagery for this place')),
);
};
const onPosition = () => {
@@ -12,6 +12,7 @@ import {
} from './decode.js';
import { createKartaViewSource } from './source.js';
import { createKartaViewViewer } from './viewer.js';
import { msg } from '../../../../i18n/index.js';
import {
ARCHIVED_MESSAGE,
COLORS,
@@ -147,7 +148,8 @@ export function createKartaViewProvider({
const cached = photos.get(photoId);
if (cached) return cached;
const photo = remember(await source.getPhoto(photoId, { signal }));
if (!photo) throw new Error('KartaView has no position for this photo');
if (!photo)
throw new Error(msg('KartaView has no position for this photo'));
return photo;
}
@@ -443,7 +445,8 @@ export function createKartaViewProvider({
.open(photoId)
.catch((error) =>
context.actions.reportError(
error?.message || 'The KartaView photo could not be loaded',
error?.message ||
msg('The KartaView photo could not be loaded'),
),
),
// Esc in the viewer clears the selected drive, as the panel says.
@@ -1,4 +1,5 @@
import { COLORS as SHARED_COLORS, PROVIDER_COLORS } from '../../policy.js';
import { msg } from '../../../../i18n/index.js';
export { NEAREST_RADIUS_M } from '../../policy.js';
@@ -47,10 +48,11 @@ export const COVERAGE_TUNING = Object.freeze({
viewRangePerHeight: 10,
viewNearM: 1_000,
});
export const TOO_HIGH_HINT = 'zoom in to see KartaView coverage';
export const NO_COVERAGE_HINT = 'no KartaView photos in view';
export const RATE_LIMITED_MESSAGE =
'KartaView is rate-limiting requests — they resume on their own';
export const TOO_HIGH_HINT = msg('zoom in to see KartaView coverage');
export const NO_COVERAGE_HINT = msg('no KartaView photos in view');
export const RATE_LIMITED_MESSAGE = msg(
'KartaView is rate-limiting requests — they resume on their own',
);
/**
* The photo API answers 429 to more than about three requests at once, so
@@ -81,8 +83,9 @@ export const IMAGE_TIMEOUT_MS = 15_000;
export const IMAGE_PROBE_TIMEOUT_MS = 8_000;
/** Nearest photos probed (at once) before giving up on archived ones; ‹ › skip this many. */
export const NEAREST_PROBES = 6;
export const ARCHIVED_MESSAGE =
'KartaView’s photos here are archived and cannot be shown';
export const ARCHIVED_MESSAGE = msg(
'KartaView’s photos here are archived and cannot be shown',
);
/** Photos asked for around a point; the API returns them nearest first. */
export const NEAREST_LIMIT = 10;
@@ -132,10 +135,12 @@ export const MASK_MAX_BYTES = 2 * 1024 * 1024;
export const MASK_KEEP_SHARE = 0.5;
/** Points along a segment are tested this many mask pixels apart. */
export const MASK_SAMPLE_PX = 2;
export const MASK_SLOW_MESSAGE =
'KartaView did not filter this area in time — move the map to ask again';
export const FILTERED_OUT_MESSAGE =
'None of KartaView’s photos here pass the imagery filter';
export const MASK_SLOW_MESSAGE = msg(
'KartaView did not filter this area in time — move the map to ask again',
);
export const FILTERED_OUT_MESSAGE = msg(
'None of KartaView’s photos here pass the imagery filter',
);
/** Under the SINCE slider while KartaView is lit. */
export const SINCE_NOTE = 'KartaView: by upload date';
@@ -21,6 +21,7 @@ import {
TILE_TIMEOUT_MS,
photoFilterParams,
} from './policy.js';
import { msg } from '../../../../i18n/index.js';
import { createLimiter } from '../../limiter.js';
export { createLimiter } from '../../limiter.js';
@@ -43,9 +44,10 @@ export class KartaViewSourceError extends Error {
}
}
const NO_ANSWER = 'KartaView did not answer in time — try again';
const UNREACHABLE = 'KartaView could not be reached — check the network';
const UNREADABLE = 'KartaView sent an answer that could not be read';
// Failure texts are English data: the Street Level panel translates them.
const NO_ANSWER = msg('KartaView did not answer in time — try again');
const UNREACHABLE = msg('KartaView could not be reached — check the network');
const UNREADABLE = msg('KartaView sent an answer that could not be read');
/** Statuses that mean a photo file is gone, not that storage hiccupped. */
const GONE_STATUSES = Object.freeze([403, 404, 409, 410]);
@@ -122,10 +124,15 @@ export function createKartaViewSource({
try {
response = await fetchImpl(url, { signal: controller.signal });
if (response.status === 429)
throw new KartaViewSourceError('KartaView is rate-limiting requests', {
status: 429,
retryAfterSec: retryAfterSeconds(response) ?? 2,
});
throw new KartaViewSourceError(
msg('KartaView is rate-limiting requests'),
{
status: 429,
retryAfterSec: retryAfterSeconds(response) ?? 2,
},
);
// English data with a number: the Street Level panel and the layer
// panel show it through the 'KartaView answered HTTP {status}' entry.
if (!response.ok)
throw new KartaViewSourceError(
`KartaView answered HTTP ${response.status}`,
@@ -133,7 +140,7 @@ export function createKartaViewSource({
);
const length = Number(response.headers?.get?.('content-length'));
const tooMuch = () =>
new KartaViewSourceError('KartaView sent too much data', {
new KartaViewSourceError(msg('KartaView sent too much data'), {
status: response.status,
});
if (length > maxBytes) throw tooMuch();
@@ -228,6 +235,7 @@ export function createKartaViewSource({
);
const code = Number(payload?.status?.apiCode);
// 600 ok, 601 empty, 602 partial: anything else is a refusal.
// Shown through 'KartaView refused the request (code {code})'.
if (Number.isFinite(code) && (code < 600 || code > 603))
throw new KartaViewSourceError(
`KartaView refused the request (code ${code})`,
@@ -241,7 +249,9 @@ export function createKartaViewSource({
if (error?.status !== 429 || attempt >= PHOTO_API_RETRIES) {
if (error?.status === 429)
throw new KartaViewSourceError(
'KartaView is rate-limiting requests — try again in a moment',
msg(
'KartaView is rate-limiting requests — try again in a moment',
),
{ status: 429, retryAfterSec: error.retryAfterSec },
);
throw error;
@@ -372,7 +382,7 @@ export function createKartaViewSource({
{ signal },
);
if (!result.data || typeof result.data !== 'object')
throw new KartaViewSourceError('KartaView has no such photo');
throw new KartaViewSourceError(msg('KartaView has no such photo'));
return result.data;
},
@@ -14,9 +14,12 @@ import {
resizedImageUrl,
} from './policy.js';
import { msg } from '../../../../i18n/index.js';
export { panoSizeFor } from '../../photoViewer.js';
const LOAD_FAILED = 'The KartaView photo could not be loaded';
/** English data: the Street Level panel translates it (translateKnown). */
const LOAD_FAILED = msg('The KartaView photo could not be loaded');
/** Load a KartaView photo (see photoViewer.js `loadImage`). */
export function loadImage(url, options = {}) {
@@ -1,5 +1,9 @@
import { COLORS as SHARED_COLORS, PROVIDER_COLORS } from '../../policy.js';
// Reached by source.js (no browser globals, see check:boundaries), so no
// i18n import: the messages stay English data, translated where shown.
const msg = (text) => text;
export { NEAREST_RADIUS_M } from '../../policy.js';
/** Identity and tuning for the Mapillary street-level provider. */
@@ -36,11 +40,13 @@ export const COVERAGE_MAX_TILES = 9;
/** Overview (z0–5) coverage points seen from orbit. */
export const COVERAGE_OVERVIEW_MAX_TILES = 16;
/** Shown when Mapillary refuses the server's token (401/403). */
export const KEY_REJECTED_MESSAGE =
'Mapillary rejected MAPILLARY_CLIENT_TOKEN — replace it in Provider Settings';
export const KEY_REJECTED_MESSAGE = msg(
'Mapillary rejected MAPILLARY_CLIENT_TOKEN — replace it in Provider Settings',
);
/** Shown while Mapillary rate-limits tile requests (429). */
export const RATE_LIMITED_MESSAGE =
'Coverage requests are being rate-limited — they resume on their own';
export const RATE_LIMITED_MESSAGE = msg(
'Coverage requests are being rate-limited — they resume on their own',
);
/**
* Street-zoom coverage reaches this far from the camera: camera height times
* the factor, never below the floor, even when a tilted view sees further.
@@ -12,6 +12,7 @@ import { createLru, createTileCache } from '../../tileCache.js';
import { decodeCoverageTileSliced, decodePictures } from './decode.js';
import { createPanoramaxSource } from './source.js';
import { createPanoramaxViewer } from './viewer.js';
import { msg } from '../../../../i18n/index.js';
import {
COLORS,
COVERAGE_LINES_BELOW_M,
@@ -447,7 +448,8 @@ export function createPanoramaxProvider({
.open(photoId)
.catch((error) =>
context.actions.reportError(
error?.message || 'The Panoramax photo could not be loaded',
error?.message ||
msg('The Panoramax photo could not be loaded'),
),
),
step,
@@ -1,4 +1,5 @@
import { COLORS as SHARED_COLORS, PROVIDER_COLORS } from '../../policy.js';
import { msg } from '../../../../i18n/index.js';
export { NEAREST_RADIUS_M } from '../../policy.js';
@@ -79,16 +80,18 @@ export const DECODE_SLICE_MS = 8;
*/
export const OVERVIEW_BINS_PER_AXIS = 96;
export const RATE_LIMITED_MESSAGE =
'Panoramax is busy — coverage resumes on its own';
export const NO_COVERAGE_HINT = 'no Panoramax photos in view';
export const RATE_LIMITED_MESSAGE = msg(
'Panoramax is busy — coverage resumes on its own',
);
export const NO_COVERAGE_HINT = msg('no Panoramax photos in view');
/** Lines in view, all hidden by the imagery filter. */
export const FILTERED_HINT = 'no Panoramax photos here match the filters';
export const UNREADABLE_TILE_MESSAGE =
'Panoramax sent a map tile that could not be read';
export const GONE_MESSAGE = 'This Panoramax photo is no longer available';
export const FILTERED_HINT = msg('no Panoramax photos here match the filters');
export const UNREADABLE_TILE_MESSAGE = msg(
'Panoramax sent a map tile that could not be read',
);
export const GONE_MESSAGE = msg('This Panoramax photo is no longer available');
/** From high up the dots are where pictures are, not sequences to click. */
export const OVERVIEW_HINT = 'zoom in to see Panoramax coverage';
export const OVERVIEW_HINT = msg('zoom in to see Panoramax coverage');
/** Cones come from z15 tiles' `pictures` layer (~110 bytes a picture). */
export const PICTURE_TILE_ZOOM = 15;
@@ -1,4 +1,5 @@
import { createLimiter } from '../../limiter.js';
import { msg } from '../../../../i18n/index.js';
import {
API_CONCURRENCY,
API_TIMEOUT_MS,
@@ -28,8 +29,10 @@ export class PanoramaxSourceError extends Error {
}
}
const NO_ANSWER = 'Panoramax did not answer in time — try again';
const UNREACHABLE = 'Panoramax could not be reached — check the connection';
const NO_ANSWER = msg('Panoramax did not answer in time — try again');
const UNREACHABLE = msg(
'Panoramax could not be reached — check the connection',
);
/**
* Seconds from a Retry-After header (delay seconds or an HTTP date), or
@@ -56,7 +59,7 @@ export function retryAfterSeconds(response, now = Date.now()) {
*/
async function readBounded(response, onChunk = () => {}) {
const tooMuch = () =>
new PanoramaxSourceError('Panoramax sent too much data', {
new PanoramaxSourceError(msg('Panoramax sent too much data'), {
status: response.status,
});
const length = Number(response.headers?.get?.('content-length'));
@@ -139,7 +142,7 @@ export function createPanoramaxSource({
BUSY_BACKOFF_BASE_SEC * 2 ** Math.min(busyStreak, 8),
);
busyStreak++;
throw new PanoramaxSourceError('Panoramax is busy', {
throw new PanoramaxSourceError(msg('Panoramax is busy'), {
status: response.status,
retryAfterSec: wait,
});
@@ -191,7 +194,9 @@ export function createPanoramaxSource({
try {
payload = JSON.parse(new TextDecoder().decode(bytes));
} catch {
throw new PanoramaxSourceError('Panoramax sent an unreadable answer');
throw new PanoramaxSourceError(
msg('Panoramax sent an unreadable answer'),
);
}
return Array.isArray(payload?.features) ? payload.features : [];
}
Loaded 100 of 440 files, more files were not shown because too many files have changed in this diff. Show more