mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
i18n: fix review findings and polish ru/es strings
Fixes the 51 P0–P2 findings from the full review of the i18n branch and the issues found while checking every new string on screen in ru and es. - Translate errors and statuses that still reached ru/es in English: feed and tile errors, provider statuses, the ALPR chip, the traffic mode chip, voice plan step labels, fire feed values, receiver bands, scene save reasons and the Cesium attribution link. - Show preset city names in the UI language (src/ui/placeNames.js); data and everything sent to the model stay English. - Share layer names between the layer panel, HUD and voice card (src/ui/layerNames.js). - Keep long ru/es strings whole: voice card error lines, analyst titles, the voice tier status, the Nepal evidence button, the ALPR retry chip. English layout is unchanged. - Ask before a language switch drops unsaved work. - Catalog fixes: glossary terms (пуск, пресет), gender agreement and consistent "peticiones" in es, fewer calques in both languages. - Fix English text that was itself wrong (e.g. "Exit cockpit to fly to a weather", raw tool ids in voice plan steps). Catalogs: es/ru 2535 messages each, complete. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
8029747b7e
commit
00704ddc89
212 files changed
+9871
-909
No files matched your search
@@ -276,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 {
|
||||
|
||||
@@ -139,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 ||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { msg } from '../../i18n/index.js';
|
||||
import { CITY_BY_ID } from './registry.js';
|
||||
|
||||
export function createIngestion({
|
||||
@@ -188,7 +189,8 @@ export function createIngestion({
|
||||
`[Data:Bikeshare] ${cityId} status update error:`,
|
||||
error,
|
||||
);
|
||||
layerState._error = 'GBFS status update failed';
|
||||
// English data (stats reach the voice tools); the panel translates it.
|
||||
layerState._error = msg('GBFS status update failed');
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { msg } from '../../i18n/index.js';
|
||||
import { GBFS_CITY_REGISTRY } from './registry.js';
|
||||
import {
|
||||
ACTIVATION_EXIT_ALTITUDE_M,
|
||||
@@ -172,7 +173,8 @@ export function createViewport({ state: layerState, services, parts, source }) {
|
||||
} catch (error) {
|
||||
if (error?.name === 'AbortError') return;
|
||||
console.warn(`[Data:Bikeshare] ${cityId} activate error:`, error);
|
||||
layerState._error = 'GBFS fetch error';
|
||||
// English data (stats reach the voice tools); the panel translates it.
|
||||
layerState._error = msg('GBFS fetch error');
|
||||
deactivateCity(cityId);
|
||||
layerState._activeCityIds.delete(cityId);
|
||||
} finally {
|
||||
|
||||
@@ -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 (server/providers/cyclones.js:8).
|
||||
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('K-35a: 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('K-35b: 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('K-35c: 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('K-35: 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();
|
||||
});
|
||||
@@ -14,7 +14,13 @@ import {
|
||||
CYCLONE_OVERLAY_SOURCE_ID,
|
||||
cycloneStormIdFromEntryId,
|
||||
} from './labels.js';
|
||||
import { formatUtcDayMonth, t, tn, msg } from '../../i18n/index.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
|
||||
@@ -26,6 +32,15 @@ const utc = (value) =>
|
||||
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: msg('Potential tropical cyclone'),
|
||||
HU: msg('Hurricane'),
|
||||
@@ -238,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, {
|
||||
@@ -263,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;
|
||||
@@ -332,7 +347,7 @@ export function createCyclonesLayer({
|
||||
})
|
||||
: t('Track/cone unavailable'));
|
||||
const status =
|
||||
error ||
|
||||
errorText(error) ||
|
||||
(snapshot?.stale
|
||||
? t('Cached advisory · stale source')
|
||||
: loading
|
||||
@@ -435,8 +450,8 @@ export function createCyclonesLayer({
|
||||
]
|
||||
: [],
|
||||
info: storm
|
||||
? `${detail}\n${position}\n${intensity}\n${geometry}${status && status !== geometry ? '\n' + status : ''}\n${snapshot.coverage}`
|
||||
: `${detail}${status ? '\n' + status : ''}\n${snapshot?.coverage || t(COVERAGE)}`,
|
||||
? `${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.',
|
||||
),
|
||||
|
||||
@@ -18,7 +18,8 @@
|
||||
|
||||
import * as Cesium from 'cesium';
|
||||
import { roadLabel } from '../../data/routeSteps.js';
|
||||
import { t, msg, formatNumber } from '../../i18n/index.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';
|
||||
@@ -36,6 +37,36 @@ export const DIRECTIONS_MODES = Object.freeze({
|
||||
});
|
||||
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');
|
||||
@@ -471,7 +502,7 @@ export function pointerBlockedMessage(owner, which) {
|
||||
const known = POINTER_TOOL_EXITS[id];
|
||||
if (!known)
|
||||
return t('{name} is active — turn {name} off, then set {endpoint}', {
|
||||
name: id,
|
||||
name: translateKnown(pointerOwnerName(id)),
|
||||
endpoint,
|
||||
});
|
||||
return t('{name} is active — {leave}, then set {endpoint}', {
|
||||
@@ -1118,7 +1149,9 @@ export function createDirectionsLayer({ services, source }) {
|
||||
payload?.error === 'no route found'
|
||||
? t('No route found between A and B')
|
||||
: payload?.error
|
||||
? t('Routing failed: {error}', { error: payload.error })
|
||||
? t('Routing failed: {error}', {
|
||||
error: presentRoutingRefusal(payload.error),
|
||||
})
|
||||
: t('No route found between A and B');
|
||||
return;
|
||||
}
|
||||
@@ -1139,7 +1172,7 @@ export function createDirectionsLayer({ services, source }) {
|
||||
? t('Routing timed out')
|
||||
: error?.name === 'TypeError'
|
||||
? t('Routing unavailable — no response from the routing service')
|
||||
: error?.message || t('Routing unavailable');
|
||||
: presentRoutingFailure(error);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
if (_routeAbort === controller) _routeAbort = null;
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
// K-34: AIS degradation chip text and the traffic "SIMULATED — <reason>" text
|
||||
// must be in the UI language (they stayed English in es before the fix);
|
||||
// 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('K-34: 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('K-34: 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('K-34: 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',
|
||||
);
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
@@ -43,7 +47,7 @@ export function createIngestion({
|
||||
feed._retryAt = 0;
|
||||
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 +81,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);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,19 @@
|
||||
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
|
||||
@@ -19,6 +34,10 @@ export function installationClassLabel(klass) {
|
||||
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':
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
// K-50: 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 (K-50)`, () => {
|
||||
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(', ')}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
Vendored
+26
-45
@@ -1,57 +1,38 @@
|
||||
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 { t, msg } from '../../i18n/index.js';
|
||||
import { formatNumber, t, tn } from '../../i18n/index.js';
|
||||
|
||||
// installationFeedback.js composes its guidance inside the portable
|
||||
// node/browser graph (the server-side HUD summary reads the same module), so
|
||||
// its copy stays English there. getStats is the browser presentation
|
||||
// boundary: map the closed message vocabulary to the catalog, passing any
|
||||
// live provider error through verbatim (docs/TRANSLATORS.md).
|
||||
const INSTALLATION_FEEDBACK_KEYS = Object.freeze({
|
||||
'Retrying mapped sites…': msg('Retrying mapped sites…'),
|
||||
'Fetching mapped sites…': msg('Fetching mapped sites…'),
|
||||
'Zoom in to search mapped installations': msg(
|
||||
'Zoom in to search mapped installations',
|
||||
),
|
||||
'Showing cached mapped sites': msg('Showing cached mapped sites'),
|
||||
'Mapped sites not loaded': msg('Mapped sites not loaded'),
|
||||
'Mapped sites loaded': msg('Mapped sites loaded'),
|
||||
'Overpass rate-limited': msg('Overpass rate-limited'),
|
||||
'Overpass timed out': msg('Overpass timed out'),
|
||||
'Overpass could not complete the query': msg(
|
||||
'Overpass could not complete the query',
|
||||
),
|
||||
'Overpass temporarily unavailable': msg('Overpass temporarily unavailable'),
|
||||
});
|
||||
|
||||
const INSTALLATION_RETRY_IN_RE = /^(.*) — retrying in (\d+)s$/;
|
||||
const INSTALLATION_RETRY_PENDING_RE = /^(.*) — retry pending$/;
|
||||
|
||||
function localizeInstallationFeedback(message) {
|
||||
if (typeof message !== 'string' || !message) return message;
|
||||
const key = INSTALLATION_FEEDBACK_KEYS[message];
|
||||
if (key) return t(key);
|
||||
const retryIn = message.match(INSTALLATION_RETRY_IN_RE);
|
||||
if (retryIn) {
|
||||
const reasonKey = INSTALLATION_FEEDBACK_KEYS[retryIn[1]];
|
||||
return t('{reason} — retrying in {seconds}s', {
|
||||
reason: reasonKey ? t(reasonKey) : retryIn[1],
|
||||
seconds: retryIn[2],
|
||||
});
|
||||
// node/browser graph (the HUD summary in loadingFeedback.js reads the same
|
||||
// module), so it hands back English marked with msg() plus the values;
|
||||
// getStats is the browser presentation boundary that translates them.
|
||||
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 pending = message.match(INSTALLATION_RETRY_PENDING_RE);
|
||||
if (pending) {
|
||||
const reasonKey = INSTALLATION_FEEDBACK_KEYS[pending[1]];
|
||||
return t('{reason} — retry pending', {
|
||||
reason: reasonKey ? t(reasonKey) : pending[1],
|
||||
});
|
||||
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 message;
|
||||
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 }) {
|
||||
@@ -274,7 +255,7 @@ export function createControls({ state: layerState, services, parts, source }) {
|
||||
retrying: layerState.loading && Boolean(layerState.failureReason),
|
||||
failureReason: layerState.failureReason,
|
||||
statusMessage: localizeInstallationFeedback(
|
||||
installationFeedback({
|
||||
describeInstallationFeedback({
|
||||
...layerState,
|
||||
count: layerState.lastUpdate ? mappedCount : undefined,
|
||||
retrying: layerState.loading && Boolean(layerState.failureReason),
|
||||
|
||||
@@ -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(
|
||||
@@ -255,7 +256,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();
|
||||
@@ -291,7 +292,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',
|
||||
);
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { installationClassLabel } from './classLabel.js';
|
||||
import { installationClassLabel, installationTitle } from './classLabel.js';
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
MILITARY_LABEL_CAP,
|
||||
@@ -163,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',
|
||||
}),
|
||||
@@ -183,7 +183,7 @@ export function createNamedMarkers({ state, services, parts, overlayHost }) {
|
||||
]
|
||||
: [];
|
||||
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);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { installationClassLabel } from './classLabel.js';
|
||||
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';
|
||||
@@ -272,7 +272,7 @@ export function createRendering({
|
||||
entity.gevTrackedId = `installations:${record.id}`;
|
||||
entity.gevDisplayPosition = () => displayPosition;
|
||||
entity.gevLabelModel = {
|
||||
title: record.name || t('MAPPED INSTALLATION'),
|
||||
title: installationTitle(record.name) || t('MAPPED INSTALLATION'),
|
||||
details: [
|
||||
installationClassLabel(record.class),
|
||||
...(record.memberNames || []).slice(0, 3),
|
||||
|
||||
@@ -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';
|
||||
|
||||
// K-33: 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('K-33: unnamed installations keep English names in data', () => {
|
||||
assert.deepEqual(names, [
|
||||
'Firing range',
|
||||
'Military land',
|
||||
'Military airfield',
|
||||
'Naval base',
|
||||
'Military area',
|
||||
]);
|
||||
});
|
||||
|
||||
test('K-33: 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('K-33: English card titles are unchanged', () => {
|
||||
setLocale('en');
|
||||
for (const name of names) assert.equal(installationTitle(name), name);
|
||||
assert.equal(installationTitle(''), '');
|
||||
});
|
||||
|
||||
test('K-33: 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,100 @@
|
||||
// K-37: 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');
|
||||
}
|
||||
});
|
||||
|
||||
// C8: 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');
|
||||
}
|
||||
});
|
||||
@@ -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,41 @@ 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(' · ');
|
||||
}
|
||||
|
||||
// policy.js keeps these labels in English (HEARD_BY_RECEIVER, BAND_LABELS,
|
||||
// SOURCE_LABELS); the card writes them out here so that it can show them
|
||||
// translated.
|
||||
|
||||
/** 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 +94,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 +109,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 +149,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,
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { localReceiverFeedName } from './feedNames.js';
|
||||
import { LAYER_SOURCE } from './policy.js';
|
||||
|
||||
// Marks English that the Layers panel translates when it shows the hint.
|
||||
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.
|
||||
@@ -58,12 +61,15 @@ function webUsbStatus(receiver, heard, feedState) {
|
||||
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';
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
// K-37 / K-29: 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');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// C5 / C11 / E7: 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');
|
||||
});
|
||||
+102
-30
@@ -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,67 @@ function formatAge(deltaMs) {
|
||||
* @returns {Object} World-overlay entry without `position`.
|
||||
*/
|
||||
export function buildIncidentCard(row, nowMs, { link = null } = {}) {
|
||||
// Feed values stay as WFIGS sends them in the row (and in what the AI tools
|
||||
// read); the card translates the known cause, behavior and complexity values
|
||||
// and the wording around them. State, category, county and complex names
|
||||
// are shown as sent.
|
||||
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 +215,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),
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
inciwebNodeId,
|
||||
isCurrentPublication,
|
||||
} from './inciweb.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
import { msg, t } from '../../i18n/index.js';
|
||||
export { normalizeFirePerimeterSnapshot } from './records.js';
|
||||
export { createWfigsPerimeterSource } from './source.js';
|
||||
export * from './cards.js';
|
||||
@@ -365,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;
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -632,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;
|
||||
|
||||
@@ -132,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
|
||||
@@ -146,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)));
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
@@ -354,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).
|
||||
@@ -372,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;
|
||||
|
||||
@@ -18,7 +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 { t, translateKnown } from '../../i18n/index.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;
|
||||
@@ -583,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,
|
||||
@@ -737,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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -41,5 +45,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',
|
||||
);
|
||||
@@ -3,6 +3,8 @@
|
||||
* `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. */
|
||||
@@ -44,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)
|
||||
@@ -69,7 +71,7 @@ export function createMapsLoader({
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -9,6 +9,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,
|
||||
@@ -195,7 +196,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;
|
||||
}
|
||||
|
||||
@@ -491,7 +493,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'),
|
||||
),
|
||||
),
|
||||
render: context.services?.render,
|
||||
|
||||
@@ -50,8 +50,9 @@ export const COVERAGE_TUNING = Object.freeze({
|
||||
});
|
||||
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 =
|
||||
'KartaView is rate-limiting requests — they resume on their own';
|
||||
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
|
||||
@@ -82,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;
|
||||
@@ -136,10 +138,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';
|
||||
|
||||
/**
|
||||
* An HTTP or API failure; `retryAfterSec` is set while KartaView rate-limits,
|
||||
@@ -40,9 +41,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]);
|
||||
|
||||
@@ -169,10 +171,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}`,
|
||||
@@ -180,7 +187,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();
|
||||
@@ -275,6 +282,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})`,
|
||||
@@ -288,7 +296,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;
|
||||
@@ -419,7 +429,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;
|
||||
},
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
PANO_FOV_DEFAULT_DEG,
|
||||
wrapDegrees,
|
||||
} from './panorama.js';
|
||||
import { t } from '../../../../i18n/index.js';
|
||||
import { msg, t } from '../../../../i18n/index.js';
|
||||
import {
|
||||
FLAT_FULL_SIZE,
|
||||
FLAT_VIEW_SIZE,
|
||||
@@ -17,7 +17,8 @@ import {
|
||||
resizedImageUrl,
|
||||
} from './policy.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 an image within the timeout. A CORS-clean one (for WebGL) is decoded
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { msg } from '../../i18n/index.js';
|
||||
import { freshStreet } from './state.js';
|
||||
|
||||
/**
|
||||
@@ -124,7 +125,8 @@ export function createViewerHost({ state, parts }) {
|
||||
if (!state.street.host) {
|
||||
// A nearest-image lookup may have set it; nothing is loading now.
|
||||
state.street.loading = false;
|
||||
state.street.error = 'Open the Street Level panel to view imagery';
|
||||
// English data: the Street Level panel translates it (translateKnown).
|
||||
state.street.error = msg('Open the Street Level panel to view imagery');
|
||||
notify();
|
||||
return false;
|
||||
}
|
||||
@@ -152,7 +154,7 @@ export function createViewerHost({ state, parts }) {
|
||||
if (ticket && !state.street.follow) parts.follow.lookAtPosition(ticket);
|
||||
} catch (error) {
|
||||
if (current())
|
||||
state.street.error = error?.message || 'Image could not be opened';
|
||||
state.street.error = error?.message || msg('Image could not be opened');
|
||||
} finally {
|
||||
if (current()) state.street.loading = false;
|
||||
notify();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { t } from '../../i18n/index.js';
|
||||
import { msg, t } from '../../i18n/index.js';
|
||||
import {
|
||||
featureLabel,
|
||||
featureReference,
|
||||
@@ -190,7 +190,7 @@ export function createIngestion({ state, parts, source }) {
|
||||
// A stale or aborted load reports nothing: its failure belongs to a
|
||||
// lifecycle the user already left.
|
||||
if (owns() && error?.name !== 'AbortError') {
|
||||
state._error = error?.message || 'TeleGeography load failed';
|
||||
state._error = error?.message || msg('TeleGeography load failed');
|
||||
console.warn(
|
||||
'[Data:telegeography-submarine-cables]',
|
||||
state._error,
|
||||
|
||||
Vendored
+34
-15
@@ -8,15 +8,17 @@ import {
|
||||
trafficStyleProfile,
|
||||
} from '../../data/trafficPresetStyle.js';
|
||||
import { TRAFFIC_TIMING_ENABLED } from './policy.js';
|
||||
import { t, translateKnown } from '../../i18n/index.js';
|
||||
|
||||
export function createControls({ state: layerState, services, parts, source }) {
|
||||
/** One status line: feed, drawn road source, then degraded-coverage notes. */
|
||||
function roadStatusLabel(feed) {
|
||||
const source = layerState._roadSource;
|
||||
if (layerState._roadError)
|
||||
return `UNAVAILABLE · ${source} · Roads unavailable`;
|
||||
return t('UNAVAILABLE · {source} · Roads unavailable', { source });
|
||||
const notes = [feed.loadingLabel];
|
||||
if (!layerState._liveMode || feed.error) notes.push(`Roads: ${source}`);
|
||||
if (!layerState._liveMode || feed.error)
|
||||
notes.push(t('Roads: {source}', { source }));
|
||||
// An explicit TomTom or Hybrid choice without a key draws OpenStreetMap.
|
||||
if (
|
||||
!layerState._liveMode &&
|
||||
@@ -24,16 +26,24 @@ export function createControls({ state: layerState, services, parts, source }) {
|
||||
)
|
||||
notes.push(
|
||||
layerState._flowStatusUnavailable
|
||||
? `${layerState._roadMode === 'tomtom' ? 'TomTom roads' : 'Hybrid'} unavailable while the traffic service is unreachable`
|
||||
? layerState._roadMode === 'tomtom'
|
||||
? t(
|
||||
'TomTom roads unavailable while the traffic service is unreachable',
|
||||
)
|
||||
: t('Hybrid unavailable while the traffic service is unreachable')
|
||||
: layerState._roadMode === 'tomtom'
|
||||
? 'TomTom roads need a TomTom key'
|
||||
: 'Hybrid needs a TomTom key',
|
||||
? t('TomTom roads need a TomTom key')
|
||||
: t('Hybrid needs a TomTom key'),
|
||||
);
|
||||
if (layerState._surfacePending) notes.push('Local surface still loading');
|
||||
if (layerState._roadWarning) notes.push(layerState._roadWarning);
|
||||
else if (layerState._roadPartial) notes.push('Partial coverage');
|
||||
if (layerState._detailError) notes.push('Detailed roads unavailable');
|
||||
else if (layerState._detailLimited) notes.push('Reduced detail coverage');
|
||||
if (layerState._surfacePending)
|
||||
notes.push(t('Local surface still loading'));
|
||||
// The warning is a road-source message (source.js marks the known ones).
|
||||
if (layerState._roadWarning)
|
||||
notes.push(translateKnown(layerState._roadWarning));
|
||||
else if (layerState._roadPartial) notes.push(t('Partial coverage'));
|
||||
if (layerState._detailError) notes.push(t('Detailed roads unavailable'));
|
||||
else if (layerState._detailLimited)
|
||||
notes.push(t('Reduced detail coverage'));
|
||||
return notes.join(' · ');
|
||||
}
|
||||
|
||||
@@ -161,13 +171,22 @@ export function createControls({ state: layerState, services, parts, source }) {
|
||||
getRowControls() {
|
||||
const selected =
|
||||
layerState._roadMode || resolveRoadMode(null, layerState._liveMode);
|
||||
const needsKey = layerState._liveMode ? '' : ' (needs a TomTom key)';
|
||||
const live = layerState._liveMode;
|
||||
const titles = {
|
||||
tomtom: `TomTom roads only, each with its live flow${needsKey}`,
|
||||
osm: 'OpenStreetMap roads, with TomTom flow matched when available',
|
||||
hybrid: `TomTom roads first; OpenStreetMap fills the rest, simulated${needsKey}`,
|
||||
tomtom: live
|
||||
? t('TomTom roads only, each with its live flow')
|
||||
: t(
|
||||
'TomTom roads only, each with its live flow (needs a TomTom key)',
|
||||
),
|
||||
osm: t('OpenStreetMap roads, with TomTom flow matched when available'),
|
||||
hybrid: live
|
||||
? t('TomTom roads first; OpenStreetMap fills the rest, simulated')
|
||||
: t(
|
||||
'TomTom roads first; OpenStreetMap fills the rest, simulated (needs a TomTom key)',
|
||||
),
|
||||
};
|
||||
const labels = { tomtom: 'TomTom', osm: 'OSM', hybrid: 'Hybrid' };
|
||||
// TomTom and OSM are provider names and stay as they are.
|
||||
const labels = { tomtom: 'TomTom', osm: 'OSM', hybrid: t('Hybrid') };
|
||||
return {
|
||||
chips: TRAFFIC_ROAD_MODES.map((mode) => ({
|
||||
id: `roads-${mode}`,
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
FAST_FETCH_ALTITUDE,
|
||||
roadDotBudget,
|
||||
} from './policy.js';
|
||||
import { msg } from '../../i18n/index.js';
|
||||
|
||||
export function createFlow({ state: layerState, services, parts, source }) {
|
||||
const { registerDynamicCredit, TOMTOM_CREDIT } = services.credits;
|
||||
@@ -36,11 +37,13 @@ export function createFlow({ state: layerState, services, parts, source }) {
|
||||
const status = Number.isFinite(error.status)
|
||||
? error.status
|
||||
: Number(message.match(/HTTP (\d{3})/)?.[1]);
|
||||
if (status === 503) return 'TomTom key unavailable';
|
||||
if (status === 429) return 'TomTom daily budget reached';
|
||||
if (status === 502 || status === 504) return 'TomTom upstream unreachable';
|
||||
// English in state; trafficFeedPresentation translates it for the panel.
|
||||
if (status === 503) return msg('TomTom key unavailable');
|
||||
if (status === 429) return msg('TomTom daily budget reached');
|
||||
if (status === 502 || status === 504)
|
||||
return msg('TomTom upstream unreachable');
|
||||
if (Number.isFinite(status)) return `TomTom flow error (HTTP ${status})`;
|
||||
return 'TomTom flow unavailable';
|
||||
return msg('TomTom flow unavailable');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { roadSurfaceChunks } from './surface.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
import { t, translateKnown } from '../../i18n/index.js';
|
||||
import {
|
||||
flowDensityMult,
|
||||
flowBucket,
|
||||
@@ -252,7 +252,10 @@ export function createModel({ state: layerState, services, parts, source }) {
|
||||
// drops `loadingLabel` in its error branch, so the owner's SIMULATED copy
|
||||
// has to BE the error text or the steady state reverts to a bare
|
||||
// "TomTom daily budget reached" that never says what is on screen.
|
||||
const degraded = `SIMULATED — ${flowError}`;
|
||||
// The reason is ours (flow.js marks it) or carries an HTTP code.
|
||||
const degraded = t('SIMULATED — {reason}', {
|
||||
reason: translateKnown(flowError),
|
||||
});
|
||||
return { mode, error: degraded, loadingLabel: degraded };
|
||||
}
|
||||
// TomTom and Hybrid name their geometry; OpenStreetMap keeps its match copy.
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
// K-37: the traffic road-status notes follow the UI language; English keeps
|
||||
// its wording.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { setLocale } from '../../i18n/index.js';
|
||||
import { createState } from './state.js';
|
||||
import { createControls } from './controls.js';
|
||||
|
||||
function loadingLabel(overrides) {
|
||||
const state = createState({ services: {} });
|
||||
Object.assign(state, overrides);
|
||||
const controls = createControls({
|
||||
state,
|
||||
services: {},
|
||||
source: { getFlowSessionStats: () => ({}) },
|
||||
parts: { model: { trafficFeedPresentation: () => ({ loadingLabel: '' }) } },
|
||||
});
|
||||
return controls.methods.getStats().loadingLabel;
|
||||
}
|
||||
|
||||
const DEGRADED = {
|
||||
_liveMode: false,
|
||||
_roadMode: 'tomtom',
|
||||
_roadSource: 'OpenStreetMap',
|
||||
_roadPartial: true,
|
||||
_detailLimited: true,
|
||||
_surfacePending: 2,
|
||||
};
|
||||
|
||||
test('en: road status notes keep their English wording', () => {
|
||||
setLocale('en');
|
||||
assert.equal(
|
||||
loadingLabel(DEGRADED),
|
||||
' · Roads: OpenStreetMap · TomTom roads need a TomTom key · Local surface still loading · Partial coverage · Reduced detail coverage',
|
||||
);
|
||||
assert.equal(
|
||||
loadingLabel({ _roadError: 'x', _roadSource: 'OpenStreetMap' }),
|
||||
'UNAVAILABLE · OpenStreetMap · Roads unavailable',
|
||||
);
|
||||
});
|
||||
|
||||
for (const locale of ['es', 'ru']) {
|
||||
test(`${locale}: road status notes are in the UI language`, () => {
|
||||
setLocale(locale);
|
||||
try {
|
||||
const label = loadingLabel({
|
||||
...DEGRADED,
|
||||
_roadPartial: false,
|
||||
_roadWarning: 'OpenFreeMap tiles rate-limited',
|
||||
});
|
||||
for (const english of [
|
||||
'Roads:',
|
||||
'TomTom key',
|
||||
'still loading',
|
||||
'rate-limited',
|
||||
'detail coverage',
|
||||
])
|
||||
assert.ok(
|
||||
!label.includes(english),
|
||||
`${locale}: "${english}" in ${label}`,
|
||||
);
|
||||
} finally {
|
||||
setLocale('en');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function roadChips(liveMode) {
|
||||
const state = createState({ services: {} });
|
||||
state._liveMode = liveMode;
|
||||
const controls = createControls({
|
||||
state,
|
||||
services: {},
|
||||
source: { getFlowSessionStats: () => ({}) },
|
||||
parts: {},
|
||||
});
|
||||
return controls.methods.getRowControls().chips;
|
||||
}
|
||||
|
||||
test('en: road-source chips keep their English labels and titles', () => {
|
||||
setLocale('en');
|
||||
assert.deepEqual(
|
||||
roadChips(false).map((chip) => [chip.label, chip.title]),
|
||||
[
|
||||
[
|
||||
'TomTom',
|
||||
'TomTom roads only, each with its live flow (needs a TomTom key)',
|
||||
],
|
||||
['OSM', 'OpenStreetMap roads, with TomTom flow matched when available'],
|
||||
[
|
||||
'Hybrid',
|
||||
'TomTom roads first; OpenStreetMap fills the rest, simulated (needs a TomTom key)',
|
||||
],
|
||||
],
|
||||
);
|
||||
assert.equal(
|
||||
roadChips(true)[2].title,
|
||||
'TomTom roads first; OpenStreetMap fills the rest, simulated',
|
||||
);
|
||||
});
|
||||
|
||||
for (const [locale, hybrid] of [
|
||||
['ru', 'Гибрид'],
|
||||
['es', 'Híbrido'],
|
||||
]) {
|
||||
test(`${locale}: the Hybrid chip and every chip title are translated`, () => {
|
||||
setLocale(locale);
|
||||
try {
|
||||
for (const live of [false, true]) {
|
||||
const chips = roadChips(live);
|
||||
assert.deepEqual(
|
||||
chips.map((chip) => chip.label),
|
||||
['TomTom', 'OSM', hybrid],
|
||||
);
|
||||
for (const chip of chips)
|
||||
assert.doesNotMatch(
|
||||
chip.title,
|
||||
/roads only|roads, with|fills the rest|needs a/,
|
||||
`${locale}: ${chip.title}`,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
setLocale('en');
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -59,16 +59,20 @@ export class RoadRequestError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
// AI tool services import this module, so it marks its English with a local
|
||||
// msg(); the traffic panel translates the message (controls.js).
|
||||
const msg = (text) => text;
|
||||
|
||||
/** Name the road upstream without exposing raw transport errors or missing codes. */
|
||||
export function roadRequestError(status, cause) {
|
||||
const code = Number.isFinite(status) ? status : null;
|
||||
const message =
|
||||
code === 429
|
||||
? 'OpenFreeMap tiles rate-limited'
|
||||
? msg('OpenFreeMap tiles rate-limited')
|
||||
: code === 504 || cause?.name === 'TimeoutError'
|
||||
? 'OpenFreeMap tiles timed out'
|
||||
? msg('OpenFreeMap tiles timed out')
|
||||
: code === null
|
||||
? 'OpenFreeMap tiles unavailable'
|
||||
? msg('OpenFreeMap tiles unavailable')
|
||||
: `OpenFreeMap tiles unavailable (HTTP ${code})`;
|
||||
return Object.assign(new RoadRequestError(message, { status: code, cause }), {
|
||||
retryable: cause?.retryable !== false,
|
||||
|
||||
@@ -318,6 +318,44 @@ export function aggregateTransitFeedHealth(statuses, nowMs) {
|
||||
};
|
||||
}
|
||||
|
||||
// AI tools (src/tools/queries/mobility.js) import this module, so it stays
|
||||
// free of i18n: the card copy below calls the translate function `t` that the
|
||||
// caller hands in. The browser selection passes the real one from src/i18n;
|
||||
// without it the copy is English.
|
||||
const msg = (text) => text;
|
||||
|
||||
/** English {placeholder} interpolation, the default `t` of the card copy. */
|
||||
function englishText(text, values) {
|
||||
if (!values) return text;
|
||||
return text.replace(/\{([A-Za-z0-9_]+)\}/g, (literal, name) =>
|
||||
Object.hasOwn(values, name) ? String(values[name]) : literal,
|
||||
);
|
||||
}
|
||||
|
||||
/** GTFS-realtime OccupancyStatus in words. */
|
||||
function occupancyText(occupancy, t) {
|
||||
switch (occupancy) {
|
||||
case 'EMPTY':
|
||||
return t('empty', { context: 'occupancy' });
|
||||
case 'MANY_SEATS_AVAILABLE':
|
||||
return t('many seats available', { context: 'occupancy' });
|
||||
case 'FEW_SEATS_AVAILABLE':
|
||||
return t('few seats available', { context: 'occupancy' });
|
||||
case 'STANDING_ROOM_ONLY':
|
||||
return t('standing room only', { context: 'occupancy' });
|
||||
case 'CRUSHED_STANDING_ROOM_ONLY':
|
||||
return t('crushed standing room only', { context: 'occupancy' });
|
||||
case 'FULL':
|
||||
return t('full', { context: 'occupancy' });
|
||||
case 'NOT_ACCEPTING_PASSENGERS':
|
||||
return t('not accepting passengers', { context: 'occupancy' });
|
||||
case 'NOT_BOARDABLE':
|
||||
return t('not boardable', { context: 'occupancy' });
|
||||
default:
|
||||
return occupancy.toLowerCase().replaceAll('_', ' ');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the human lines of the selection card from a vehicle record.
|
||||
* @param {object} feed Registry entry.
|
||||
@@ -325,6 +363,9 @@ export function aggregateTransitFeedHealth(statuses, nowMs) {
|
||||
* @param {string} mode Resolved transit mode.
|
||||
* @param {number} nowMs
|
||||
* @param {number|null} [fetchedAtMs] Snapshot fetch time.
|
||||
* @param {object|null} [entry] Playback entry of the vehicle.
|
||||
* @param {(text: string, values?: object) => string} [t] Translates the
|
||||
* card's English text; English when omitted.
|
||||
* @returns {{title:string, details:string[]}}
|
||||
*/
|
||||
export function buildTransitSelectionCopy(
|
||||
@@ -334,44 +375,52 @@ export function buildTransitSelectionCopy(
|
||||
nowMs,
|
||||
fetchedAtMs = null,
|
||||
entry = null,
|
||||
t = englishText,
|
||||
) {
|
||||
const icon = TRANSIT_MODE_ICON[mode] || TRANSIT_MODE_ICON.unknown;
|
||||
const routeLabel = record.routeId
|
||||
? `Route ${record.routeId}`
|
||||
: record.label
|
||||
? `Vehicle ${record.label}`
|
||||
: `Vehicle ${record.id}`;
|
||||
? t('Route {route}', { route: record.routeId })
|
||||
: t('Vehicle {vehicle}', { vehicle: record.label || record.id });
|
||||
const title = `${icon} ${routeLabel}`;
|
||||
// The mode is said in words on its own line. A glyph and a route id do not
|
||||
// tell a reader that the dot on Tremont Street is a subway train, and a
|
||||
// subway drawn on a street reads as a bug until the card says what it is.
|
||||
const kindWord = TRANSIT_MODE_WORD[mode] || TRANSIT_MODE_WORD.unknown;
|
||||
const kindWord = t(TRANSIT_MODE_WORD[mode] || TRANSIT_MODE_WORD.unknown);
|
||||
const details = [`${kindWord} · ${feed.name} · ${feed.region}`];
|
||||
const motion = [];
|
||||
// What the SCREEN is doing leads, because that is what the reader can check.
|
||||
const shown = entry ? displayMotion(entry, nowMs) : null;
|
||||
if (shown?.word === 'STOPPED') motion.push('Stopped');
|
||||
else if (shown?.word === 'NO FIX') motion.push('No recent fix');
|
||||
else if (shown?.word === 'WAITING') motion.push('Waiting for update');
|
||||
else if (shown) motion.push('Moving');
|
||||
if (shown?.word === 'STOPPED')
|
||||
motion.push(t('Stopped', { context: 'vehicle motion' }));
|
||||
else if (shown?.word === 'NO FIX') motion.push(t('No recent fix'));
|
||||
else if (shown?.word === 'WAITING') motion.push(t('Waiting for update'));
|
||||
else if (shown) motion.push(t('Moving'));
|
||||
if (motion.length) details.push(motion.join(' · '));
|
||||
const reportedMotion = [];
|
||||
if (Number.isFinite(record.speedMps))
|
||||
reportedMotion.push(`reported ${Math.round(record.speedMps * 3.6)} km/h`);
|
||||
reportedMotion.push(
|
||||
t('reported {speed} km/h', { speed: Math.round(record.speedMps * 3.6) }),
|
||||
);
|
||||
if (Number.isFinite(record.bearing))
|
||||
reportedMotion.push(`reported hdg ${Math.round(record.bearing)}°`);
|
||||
reportedMotion.push(
|
||||
t('reported hdg {heading}°', { heading: Math.round(record.bearing) }),
|
||||
);
|
||||
// The operator's own claim, kept as a REPORT rather than as the live state.
|
||||
// It describes the moment of the report, which the display may not have
|
||||
// reached yet; presenting it as "now" is what put STOPPED on a moving metro.
|
||||
const state = [];
|
||||
if (record.status === 'STOPPED_AT' && record.stopId)
|
||||
state.push(`Last report: stopped at stop ${record.stopId}`);
|
||||
state.push(
|
||||
t('Last report: stopped at stop {stop}', { stop: record.stopId }),
|
||||
);
|
||||
else if (record.status === 'INCOMING_AT' && record.stopId)
|
||||
state.push(`Last report: arriving at stop ${record.stopId}`);
|
||||
state.push(
|
||||
t('Last report: arriving at stop {stop}', { stop: record.stopId }),
|
||||
);
|
||||
else if (record.status === 'IN_TRANSIT_TO' && record.stopId)
|
||||
state.push(`Last report: next stop ${record.stopId}`);
|
||||
state.push(t('Last report: next stop {stop}', { stop: record.stopId }));
|
||||
if (record.occupancy && record.occupancy !== 'NO_DATA_AVAILABLE') {
|
||||
state.push(record.occupancy.toLowerCase().replaceAll('_', ' '));
|
||||
state.push(occupancyText(record.occupancy, t));
|
||||
}
|
||||
if (reportedMotion.length) details.push(reportedMotion.join(' · '));
|
||||
if (state.length) details.push(state.join(' · '));
|
||||
@@ -379,10 +428,10 @@ export function buildTransitSelectionCopy(
|
||||
// That is a deliberate choice — a train nobody can see is not worth drawing —
|
||||
// but it has to be stated, or a metro sitting on a road looks like an error.
|
||||
if (UNDERGROUND_MODES.has(mode)) {
|
||||
details.push('Shown at street level · depth not in the feed');
|
||||
details.push(t('Shown at street level · depth not in the feed'));
|
||||
}
|
||||
details.push(
|
||||
`Vehicle ${record.id}${record.label && record.label !== record.id ? ` · ${record.label}` : ''}`,
|
||||
`${t('Vehicle {vehicle}', { vehicle: record.id })}${record.label && record.label !== record.id ? ` · ${record.label}` : ''}`,
|
||||
);
|
||||
const ageS = Math.round(
|
||||
(Number.isFinite(entry?.sample?.latestReportAgeMs)
|
||||
@@ -391,8 +440,8 @@ export function buildTransitSelectionCopy(
|
||||
);
|
||||
const reported =
|
||||
ageS < 90
|
||||
? `Reported ${ageS} s ago`
|
||||
: `Reported ${Math.round(ageS / 60)} min ago`;
|
||||
? t('Reported {seconds} s ago', { seconds: ageS })
|
||||
: t('Reported {minutes} min ago', { minutes: Math.round(ageS / 60) });
|
||||
// A time the feed did not give for THIS vehicle is labelled, so the card
|
||||
// never presents a borrowed or inferred time as the vehicle's own report.
|
||||
const ownTime =
|
||||
@@ -404,19 +453,25 @@ export function buildTransitSelectionCopy(
|
||||
const lagS = Number.isFinite(entry?.sample?.actualDelayMs)
|
||||
? Math.max(0, Math.round(entry.sample.actualDelayMs / 1000))
|
||||
: null;
|
||||
const behind = lagS === null ? '' : ` · shown ${lagS} s behind`;
|
||||
const behind =
|
||||
lagS === null
|
||||
? ''
|
||||
: ` · ${t('shown {seconds} s behind', { seconds: lagS })}`;
|
||||
details.splice(
|
||||
motion.length ? 2 : 1,
|
||||
0,
|
||||
(ownTime ? reported : `${reported} (feed time)`) + behind,
|
||||
(ownTime ? reported : t('{reported} (feed time)', { reported })) + behind,
|
||||
);
|
||||
if (entry?.track?.count) {
|
||||
const duration = Math.max(
|
||||
0,
|
||||
Math.min(entry.playT, entry.fixes.at(-1).t) - entry.fixes[0].t,
|
||||
);
|
||||
const minutes = Math.round(duration / 60000);
|
||||
details.push(
|
||||
`Available trail: ${Math.round(duration / 60000)} min${entry.track.truncated || entry.trailTruncated ? ' (limited)' : ''}`,
|
||||
entry.track.truncated || entry.trailTruncated
|
||||
? t('Available trail: {minutes} min (limited)', { minutes })
|
||||
: t('Available trail: {minutes} min', { minutes }),
|
||||
);
|
||||
}
|
||||
return { title, details };
|
||||
@@ -461,12 +516,12 @@ export const UNDERGROUND_MODES = new Set(['subway']);
|
||||
|
||||
/** The mode in plain words, for a card a person reads rather than scans. */
|
||||
export const TRANSIT_MODE_WORD = Object.freeze({
|
||||
bus: 'Bus',
|
||||
tram: 'Tram',
|
||||
subway: 'Subway',
|
||||
rail: 'Train',
|
||||
ferry: 'Ferry',
|
||||
unknown: 'Transit vehicle',
|
||||
bus: msg('Bus'),
|
||||
tram: msg('Tram'),
|
||||
subway: msg('Subway'),
|
||||
rail: msg('Train'),
|
||||
ferry: msg('Ferry'),
|
||||
unknown: msg('Transit vehicle'),
|
||||
});
|
||||
|
||||
/** Short, fixed-width mode word for the detection card's class field. */
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from './policy.js';
|
||||
import { getRegisteredTransitFeed } from '../../data/transitFeeds.js';
|
||||
import { isPointerFree } from '../../data/inputOwnership.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
||||
/**
|
||||
* Click-to-select one vehicle and a card that follows it.
|
||||
@@ -60,6 +61,7 @@ export function createSelection({ state, services, parts }) {
|
||||
now,
|
||||
entry.fetchedAt,
|
||||
entry,
|
||||
t,
|
||||
);
|
||||
// The TEXT is what the throttle is for, and it is republished only when
|
||||
// it has changed: the anchor is live through its getter, so re-sending
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
// K-37: the Transit selection card follows the UI language. The copy builder
|
||||
// stays free of i18n (AI tools import policy.js); the browser hands it t().
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { setLocale, t } from '../../i18n/index.js';
|
||||
import { buildTransitSelectionCopy } from './policy.js';
|
||||
|
||||
const feed = { name: 'Metro Transit', region: 'Minneapolis–St Paul, MN' };
|
||||
const now = 1_788_936_960_000;
|
||||
const record = {
|
||||
id: 'T1',
|
||||
label: 'T1',
|
||||
routeId: 'Blue',
|
||||
lat: 44.9,
|
||||
lon: -93.4,
|
||||
bearing: 248,
|
||||
speedMps: 11.2,
|
||||
timestamp: 1_788_936_900,
|
||||
timestampSource: 'header',
|
||||
stopId: '57458',
|
||||
status: 'STOPPED_AT',
|
||||
occupancy: 'FEW_SEATS_AVAILABLE',
|
||||
};
|
||||
|
||||
test('en: the copy is unchanged with and without the i18n t()', () => {
|
||||
setLocale('en');
|
||||
const plain = buildTransitSelectionCopy(feed, record, 'subway', now);
|
||||
assert.deepEqual(
|
||||
buildTransitSelectionCopy(feed, record, 'subway', now, null, null, t),
|
||||
plain,
|
||||
);
|
||||
assert.deepEqual(plain.details, [
|
||||
'Subway · Metro Transit · Minneapolis–St Paul, MN',
|
||||
'Reported 60 s ago (feed time)',
|
||||
'reported 40 km/h · reported hdg 248°',
|
||||
'Last report: stopped at stop 57458 · few seats available',
|
||||
'Shown at street level · depth not in the feed',
|
||||
'Vehicle T1',
|
||||
]);
|
||||
});
|
||||
|
||||
for (const locale of ['es', 'ru']) {
|
||||
test(`${locale}: the Transit card is in the UI language`, () => {
|
||||
setLocale(locale);
|
||||
try {
|
||||
const copy = buildTransitSelectionCopy(
|
||||
feed,
|
||||
record,
|
||||
'subway',
|
||||
now,
|
||||
null,
|
||||
null,
|
||||
t,
|
||||
);
|
||||
const text = [copy.title, ...copy.details].join(' | ');
|
||||
for (const english of [
|
||||
'Route',
|
||||
'Subway',
|
||||
'Reported',
|
||||
'reported',
|
||||
'Last report',
|
||||
'few seats',
|
||||
'street level',
|
||||
'Vehicle',
|
||||
'feed time',
|
||||
])
|
||||
assert.ok(
|
||||
!text.includes(english),
|
||||
`${locale}: "${english}" in ${text}`,
|
||||
);
|
||||
} finally {
|
||||
setLocale('en');
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
accentForVesselType,
|
||||
displayVesselType,
|
||||
normalizeVesselType,
|
||||
} from '../../data/vesselLabels.js';
|
||||
import { formatNumber, t } from '../../i18n/index.js';
|
||||
|
||||
@@ -127,10 +128,18 @@ export function createCards({
|
||||
return `vessel:unkeyed:${name}:${lat}:${lon}`;
|
||||
}
|
||||
|
||||
/** Uppercased, card-width-bounded AIS type (empty string when unknown). */
|
||||
/**
|
||||
* Uppercased AIS type for the card (empty string when unknown). Free feed
|
||||
* text is bounded to the card width; a translated type token is a short
|
||||
* catalog word and shows whole, never cut mid-word ("AUXILIAR DE PUERTO").
|
||||
*/
|
||||
|
||||
function vesselTypeShort(record) {
|
||||
return displayVesselType(record.type).toUpperCase().slice(0, 14);
|
||||
const feedType = normalizeVesselType(record.type);
|
||||
const shown = displayVesselType(record.type);
|
||||
return shown === feedType
|
||||
? feedType.toUpperCase().slice(0, 14)
|
||||
: shown.toUpperCase();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
// C6: a vessel with an ambient card gets no detection callout under it (the
|
||||
// card already shows name, speed and course); the speed unit of a callout
|
||||
// follows the UI language.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import aisLiveVesselsLayer, {
|
||||
_setVesselOverlayHostForTest,
|
||||
_setVesselStateForTest,
|
||||
_updateVesselCardsForTest,
|
||||
} from '../../data/aisLiveVessels.js';
|
||||
import { setLocale } from '../../i18n/index.js';
|
||||
|
||||
const record = (mmsi, name, screen, z = 0) => ({
|
||||
name,
|
||||
mmsi,
|
||||
type: 'Cargo',
|
||||
destination: '',
|
||||
speed: 9.5,
|
||||
course: 80,
|
||||
heading: 80,
|
||||
lastPositionUtc: '',
|
||||
missedRefreshes: 0,
|
||||
position: { x: 0, y: 0, z, screen },
|
||||
billboard: null,
|
||||
});
|
||||
|
||||
function detectable(cameraZ) {
|
||||
const carded = record('200', 'GOLDEN BEAR', { x: 40, y: 30 });
|
||||
const plain = record('300', 'BAY PILOT', { x: 41, y: 31 });
|
||||
const originalProjection = Cesium.SceneTransforms.worldToWindowCoordinates;
|
||||
Cesium.SceneTransforms.worldToWindowCoordinates = (_scene, position) =>
|
||||
position.screen;
|
||||
_setVesselOverlayHostForTest({
|
||||
setEntries() {},
|
||||
setVisible() {},
|
||||
clearSource() {},
|
||||
});
|
||||
_setVesselStateForTest({
|
||||
viewer: {
|
||||
scene: {
|
||||
canvas: { clientWidth: 1600, clientHeight: 900 },
|
||||
camera: { positionWC: new Cesium.Cartesian3(0, 0, cameraZ) },
|
||||
},
|
||||
},
|
||||
records: [carded, plain],
|
||||
billboardCollection: { show: true, remove() {} },
|
||||
});
|
||||
try {
|
||||
// Both share one 118 px grid cell, so only one of them gets a card.
|
||||
_updateVesselCardsForTest([carded, plain]);
|
||||
return Object.fromEntries(
|
||||
aisLiveVesselsLayer
|
||||
.getDetectableObjects()
|
||||
.map((object) => [object.id, object]),
|
||||
);
|
||||
} finally {
|
||||
Cesium.SceneTransforms.worldToWindowCoordinates = originalProjection;
|
||||
_setVesselStateForTest({ enabled: false });
|
||||
_setVesselOverlayHostForTest();
|
||||
}
|
||||
}
|
||||
|
||||
test('a vessel with a card on screen draws no detection label', () => {
|
||||
const objects = detectable(2_500);
|
||||
const hidden = Object.values(objects).filter((object) => object.hideLabel);
|
||||
assert.equal(hidden.length, 1, 'exactly the carded vessel hides its label');
|
||||
const shown = Object.values(objects).filter((object) => !object.hideLabel);
|
||||
assert.equal(shown.length, 1, 'the vessel without a card keeps its label');
|
||||
assert.equal(hidden[0].skipLabel, false, 'not treated as the tracked vessel');
|
||||
});
|
||||
|
||||
test('past the card fade the detection label comes back', () => {
|
||||
const objects = detectable(4_000_000);
|
||||
assert.ok(Object.values(objects).every((object) => !object.hideLabel));
|
||||
});
|
||||
|
||||
test('ru: the callout speed unit is translated', () => {
|
||||
setLocale('ru');
|
||||
try {
|
||||
const objects = detectable(4_000_000);
|
||||
assert.equal(objects['GOLDEN BEAR'].metric, '10 уз');
|
||||
} finally {
|
||||
setLocale('en');
|
||||
}
|
||||
assert.equal(detectable(4_000_000)['GOLDEN BEAR'].metric, '10 kn');
|
||||
});
|
||||
@@ -1,6 +1,10 @@
|
||||
import { AIS_FIRST_CONNECT_LABEL } from './recordPolicy.js';
|
||||
import { viewAreaMovedEnough } from './viewArea.js';
|
||||
|
||||
// A portable export (package.json): no i18n here. The text stays English data
|
||||
// and the layer panel shows it translated.
|
||||
const msg = (text) => text;
|
||||
|
||||
/** Own source requests and classified feed state through explicit operations. */
|
||||
export function createIngestion({
|
||||
feed,
|
||||
@@ -69,7 +73,7 @@ export function createIngestion({
|
||||
ownsAisRequest(requestController, requestSessionId) &&
|
||||
error?.name !== 'AbortError'
|
||||
) {
|
||||
markUnavailable(error?.message || 'AIS live load failed');
|
||||
markUnavailable(error?.message || msg('AIS live load failed'));
|
||||
console.warn('[Data:ais-live-vessels]', feed.error, error);
|
||||
}
|
||||
} finally {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { VESSEL_OVERLAY_SOURCE_ID } from '../../data/vesselLabels.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
import { msg, t } from '../../i18n/index.js';
|
||||
import { AIS_FIRST_CONNECT_GRACE_MS, AIS_HEALTHY_STATUSES } from './policy.js';
|
||||
|
||||
export function createLifecycle({
|
||||
@@ -82,7 +82,7 @@ export function createLifecycle({
|
||||
|
||||
function markAisUnavailable(reason) {
|
||||
settleFirstConnectPhase('unavailable');
|
||||
state.feed.error = reason || 'AIS live load failed';
|
||||
state.feed.error = reason || msg('AIS live load failed');
|
||||
state.feed.stale = state.feed.count > 0;
|
||||
}
|
||||
|
||||
@@ -129,6 +129,7 @@ export function createLifecycle({
|
||||
state.lastFocusUpdate = 0;
|
||||
state.activeFocusCount = 0;
|
||||
state.activeLabelCount = 0;
|
||||
state.cardedRecords = new Set();
|
||||
state.selectedRecord = null;
|
||||
state.trail = null;
|
||||
state.trailPositions = [];
|
||||
|
||||
@@ -4,6 +4,7 @@ export {
|
||||
AIS_FIRST_CONNECT_LABEL,
|
||||
} from './recordPolicy.js';
|
||||
import { VESSEL_LABEL_GRID_PX } from '../../data/vesselLabels.js';
|
||||
import { msg } from '../../i18n/index.js';
|
||||
|
||||
export const FOCUS_EVIDENCE_DEV = import.meta.env?.DEV === true;
|
||||
|
||||
@@ -71,12 +72,12 @@ export const DEFAULT_AIS_RUNTIME = Object.freeze({
|
||||
*/
|
||||
|
||||
export const AIS_STATUS_REASON = {
|
||||
'missing-key': 'AISSTREAM_API_KEY not set',
|
||||
unsupported: 'live feed unsupported',
|
||||
connecting: 'connecting to feed…',
|
||||
closed: 'feed disconnected',
|
||||
error: 'feed down',
|
||||
idle: 'feed idle',
|
||||
'missing-key': msg('AISSTREAM_API_KEY not set'),
|
||||
unsupported: msg('live feed unsupported'),
|
||||
connecting: msg('connecting to feed…'),
|
||||
closed: msg('feed disconnected'),
|
||||
error: msg('feed down'),
|
||||
idle: msg('feed idle'),
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -60,20 +60,22 @@ export function createQueries({
|
||||
if (status === 'auth-failed') {
|
||||
// Actionable, not a countdown: retrying cannot fix a rejected credential,
|
||||
// so the chip asks the operator to do the one thing that can.
|
||||
return 'API key rejected — check AISSTREAM_API_KEY';
|
||||
return t('API key rejected — check AISSTREAM_API_KEY');
|
||||
}
|
||||
if (status === 'stale') {
|
||||
const silentSec = Math.round(Number(payload?.silentForMs) / 1000);
|
||||
return Number.isFinite(silentSec) && silentSec > 0
|
||||
? `feed silent ${silentSec}s — no AIS data`
|
||||
: 'feed silent — no AIS data';
|
||||
? t('feed silent {seconds}s — no AIS data', { seconds: silentSec })
|
||||
: t('feed silent — no AIS data');
|
||||
}
|
||||
const attempt = Number(payload?.reconnectAttempt);
|
||||
const suffix =
|
||||
Number.isFinite(attempt) && attempt >= 1 ? ` (attempt ${attempt})` : '';
|
||||
if (Number.isFinite(attempt) && attempt >= 1)
|
||||
return status === 'down'
|
||||
? t('feed down — retrying slowly (attempt {attempt})', { attempt })
|
||||
: t('reconnecting to feed… (attempt {attempt})', { attempt });
|
||||
return status === 'down'
|
||||
? `feed down — retrying slowly${suffix}`
|
||||
: `reconnecting to feed…${suffix}`;
|
||||
? t('feed down — retrying slowly')
|
||||
: t('reconnecting to feed…');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -106,7 +108,8 @@ export function createQueries({
|
||||
typeof payload.error === 'string' && payload.error.trim()
|
||||
? payload.error.trim()
|
||||
: '';
|
||||
const reason = AIS_STATUS_REASON[status] || 'feed unavailable';
|
||||
// The reasons are English in policy.js (msg) and shown in the UI language.
|
||||
const reason = t(AIS_STATUS_REASON[status] || msg('feed unavailable'));
|
||||
return detail && !AIS_STATUS_REASON[status]
|
||||
? `${reason} (${detail})`
|
||||
: reason;
|
||||
@@ -468,7 +471,7 @@ export function createQueries({
|
||||
* @param {Object} [options={}] - Options from the detection system.
|
||||
* @param {number} [options.maxCount] - Maximum objects to return (defaults to all).
|
||||
* @param {number} [options.seed] - Seed offset for stride sampling.
|
||||
* @returns {Array<{position: Cesium.Cartesian3, id: string, type: string, skipLabel: boolean}>}
|
||||
* @returns {Array<{position: Cesium.Cartesian3, id: string, type: string, skipLabel: boolean, hideLabel: boolean}>}
|
||||
*/
|
||||
getDetectableObjects(options = {}) {
|
||||
if (
|
||||
@@ -489,6 +492,7 @@ export function createQueries({
|
||||
const start = seed % stride;
|
||||
|
||||
const selected = state.selectedRecord;
|
||||
const carded = state.cardedRecords;
|
||||
const result = [];
|
||||
for (let idx = 0; idx < records.length; idx += 1) {
|
||||
if ((idx - start) % stride !== 0) continue;
|
||||
@@ -503,6 +507,9 @@ export function createQueries({
|
||||
id: record.name || record.mmsi || 'VESSEL',
|
||||
type: 'SEA',
|
||||
skipLabel: record === selected,
|
||||
// An open ambient card already names the vessel with its speed and
|
||||
// course; a detection label under it would only cover them.
|
||||
hideLabel: carded?.has(record) === true,
|
||||
klass: record.type
|
||||
? String(record.type).toUpperCase().slice(0, 14)
|
||||
: undefined,
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { t } from '../../i18n/index.js';
|
||||
import {
|
||||
vesselTypeCss,
|
||||
vesselOverlayCohortLimit,
|
||||
VESSEL_OVERLAY_SOURCE_ID,
|
||||
applyVesselOverlayPolicy,
|
||||
VESSEL_CARD_FADE_DISTANCE_M,
|
||||
VESSEL_CARD_FADE_START_RATIO,
|
||||
} from '../../data/vesselLabels.js';
|
||||
import {
|
||||
cameraPoseSignature,
|
||||
@@ -365,6 +367,7 @@ export function createRendering({
|
||||
|
||||
if (!scene || !records.length || maxLabels <= 0) {
|
||||
state.activeLabelCount = entries.length;
|
||||
state.cardedRecords = new Set();
|
||||
publishVesselOverlayEntries(entries);
|
||||
return;
|
||||
}
|
||||
@@ -402,6 +405,9 @@ export function createRendering({
|
||||
if (screen) accepted.push({ x: screen.x, y: screen.y });
|
||||
}
|
||||
const ranked = [...cells.values()].sort((a, b) => b.score - a.score);
|
||||
// Vessels with an ambient card: the card already shows name, speed and
|
||||
// course, so the detection overlay draws no label of its own under it.
|
||||
const carded = new Set();
|
||||
for (const candidate of ranked) {
|
||||
if (entries.length >= maxLabels) break;
|
||||
if (
|
||||
@@ -414,8 +420,10 @@ export function createRendering({
|
||||
continue;
|
||||
accepted.push({ x: candidate.x, y: candidate.y });
|
||||
entries.push(components.cards.buildVesselCard(candidate.record));
|
||||
if (cardFullyShown(scene, candidate.record)) carded.add(candidate.record);
|
||||
}
|
||||
state.activeLabelCount = entries.length;
|
||||
state.cardedRecords = carded;
|
||||
publishVesselOverlayEntries(entries);
|
||||
}
|
||||
|
||||
@@ -448,7 +456,10 @@ export function createRendering({
|
||||
: '';
|
||||
return {
|
||||
...card,
|
||||
accessibilityLabel: `Focus vessel ${card.title}, MMSI ${mmsi}`,
|
||||
accessibilityLabel: t('Focus vessel {name}, MMSI {mmsi}', {
|
||||
name: card.title,
|
||||
mmsi,
|
||||
}),
|
||||
activate: () => {
|
||||
const record = state.records.byMmsi.get(mmsi);
|
||||
if (!record) return false;
|
||||
@@ -465,6 +476,22 @@ export function createRendering({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the host paints this ambient card at full strength: inside the
|
||||
* start of its distance fade (applyVesselOverlayPolicy). Farther out the
|
||||
* card fades away and the detection label stays the vessel's only text.
|
||||
*/
|
||||
function cardFullyShown(scene, record) {
|
||||
const camera = scene.camera?.positionWC;
|
||||
const visual = getVisual(record);
|
||||
const position = visual.billboard?.position || visual.position;
|
||||
if (!camera || !position) return true;
|
||||
return (
|
||||
Cesium.Cartesian3.distance(camera, position) <
|
||||
VESSEL_CARD_FADE_DISTANCE_M * VESSEL_CARD_FADE_START_RATIO
|
||||
);
|
||||
}
|
||||
|
||||
function labelPriority(record, selected) {
|
||||
if (record === selected) return 100000;
|
||||
let score = 0;
|
||||
|
||||
@@ -69,6 +69,8 @@ export function createVesselState({ source, services }) {
|
||||
/** Sprites whose animated emphasis remains outside the 1.0 deadband. */
|
||||
activeFocusCount: 0,
|
||||
activeLabelCount: 0,
|
||||
/** @type {Set<Object>} Records that have an ambient card this pass. */
|
||||
cardedRecords: new Set(),
|
||||
selectedRecord: null,
|
||||
/** @type {{setPositions: Function, clear: Function, destroy: Function}|null} Selected-vessel fading trail */
|
||||
trail: null,
|
||||
|
||||
@@ -10,6 +10,21 @@ import {
|
||||
import { formatUtcDayMonth, t, msg } from '../../i18n/index.js';
|
||||
import { formatScaleLabel } from '../../ui/unitLabels.js';
|
||||
|
||||
/**
|
||||
* A stored (English) failure in the language. HTTP failures from source.js
|
||||
* and infraredImage.js carry their status in the text, so they are matched
|
||||
* back onto a placeholder; anything else is a plain catalog key.
|
||||
*/
|
||||
function presentWeatherProblem(problem) {
|
||||
const http = /^Weather (image )?HTTP (\d+)$/.exec(problem);
|
||||
if (http) {
|
||||
return http[1]
|
||||
? t('Weather image HTTP {status}', { status: http[2] })
|
||||
: t('Weather HTTP {status}', { status: http[2] });
|
||||
}
|
||||
return t(problem);
|
||||
}
|
||||
|
||||
const STOPS = [
|
||||
[10, '#00ecec'],
|
||||
[20, '#0000f6'],
|
||||
@@ -532,7 +547,7 @@ export function createWeatherLayer({
|
||||
const diagnostic = rendering?.getDiagnostics();
|
||||
// Errors are stored in English (stats, diagnostics); shown translated.
|
||||
const problem = error || diagnostic?.error;
|
||||
const shownProblem = problem ? t(problem) : null;
|
||||
const shownProblem = problem ? presentWeatherProblem(problem) : null;
|
||||
const shownHost = hostStatus ? t(hostStatus) : null;
|
||||
const b = manifest?.bounds;
|
||||
const canvas = viewer?.scene?.canvas;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { readResponseBytesCapped } from '../../sources/httpBody.js';
|
||||
import { msg } from '../../i18n/index.js';
|
||||
import { weatherImageUrl } from './source.js';
|
||||
import { infraredAlpha } from './infraredAlpha.js';
|
||||
|
||||
@@ -49,7 +50,7 @@ export function decodeInfraredImage(blob, signal) {
|
||||
};
|
||||
image.onerror = () => {
|
||||
cleanup();
|
||||
reject(new Error('Infrared decode failed'));
|
||||
reject(new Error(msg('Infrared decode failed')));
|
||||
};
|
||||
signal.addEventListener('abort', abort, { once: true });
|
||||
image.src = url;
|
||||
|
||||
@@ -201,7 +201,8 @@ function createGlobeRendering({
|
||||
await Promise.all(
|
||||
urls.map(async (url) => {
|
||||
const response = await fetchImpl(url, { signal });
|
||||
if (!response.ok) throw new Error('Weather prefetch unavailable');
|
||||
if (!response.ok)
|
||||
throw new Error(msg('Weather prefetch unavailable'));
|
||||
await readResponseBytesCapped(response, 4 * 1024 * 1024 + 65_536);
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { readResponseJsonCapped } from '../../sources/httpBody.js';
|
||||
|
||||
// Shared with the AI tools, so no i18n import: failures stay English here and
|
||||
// the weather layer translates them where it shows them (t / translateKnown).
|
||||
const msg = (text) => text;
|
||||
|
||||
export const WEATHER_PRODUCTS = Object.freeze([
|
||||
'radar',
|
||||
'clouds',
|
||||
@@ -10,7 +14,7 @@ export const WEATHER_PRODUCTS = Object.freeze([
|
||||
/** Only bounded, explicit observations may become imagery requests. */
|
||||
export function validateWeatherSnapshot(value, product) {
|
||||
if (!value || value.schemaVersion !== 1 || value.product !== product)
|
||||
throw new Error('Malformed weather manifest');
|
||||
throw new Error(msg('Malformed weather manifest'));
|
||||
if (value.unavailable) return value;
|
||||
const { bounds, times } = value;
|
||||
if (
|
||||
@@ -40,7 +44,7 @@ export function validateWeatherSnapshot(value, product) {
|
||||
value.maxLevel !== 6 ||
|
||||
value.tilingScheme !== 'geographic'
|
||||
)
|
||||
throw new Error('Malformed weather manifest');
|
||||
throw new Error(msg('Malformed weather manifest'));
|
||||
return value;
|
||||
}
|
||||
|
||||
@@ -113,7 +117,7 @@ export function createWeatherSource({
|
||||
const abort = () => controller.abort(signal.reason);
|
||||
signal?.addEventListener('abort', abort, { once: true });
|
||||
const timer = setTimeout(
|
||||
() => controller.abort(new Error('Weather request timed out')),
|
||||
() => controller.abort(new Error(msg('Weather request timed out'))),
|
||||
timeoutMs,
|
||||
);
|
||||
try {
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
createWindInspectionMarker,
|
||||
WIND_UNITS,
|
||||
} from './inspection.js';
|
||||
import { t, msg } from '../../i18n/index.js';
|
||||
import { t, msg, translateKnown } from '../../i18n/index.js';
|
||||
import { unitLabel } from '../../ui/unitLabels.js';
|
||||
|
||||
/** Format a forecast timestamp explicitly in UTC. */
|
||||
@@ -22,6 +22,25 @@ export function formatWindValidTime(value) {
|
||||
: null;
|
||||
}
|
||||
|
||||
/*
|
||||
* Reasons the server puts in the manifest (server/providers/wind.js). They stay
|
||||
* English in data and are translated where they are shown.
|
||||
*/
|
||||
const SERVER_REASONS = new Set([msg('Wind upstream unavailable')]);
|
||||
const reasonText = (reason) =>
|
||||
SERVER_REASONS.has(reason) ? t(reason) : translateKnown(reason) || null;
|
||||
|
||||
/**
|
||||
* A wind problem in the UI language. Source errors are English data; the
|
||||
* HTTP status line carries a number, so it goes back onto a placeholder.
|
||||
*/
|
||||
function presentWindProblem(problem) {
|
||||
const http = /^Wind HTTP (\d+)$/.exec(problem);
|
||||
if (http)
|
||||
return t('{source} HTTP {status}', { source: t('Wind'), status: http[1] });
|
||||
return t(problem);
|
||||
}
|
||||
|
||||
/** Forecast issue time is source age; valid time is reported separately. */
|
||||
export function windStats(manifest) {
|
||||
const run = Date.parse(manifest?.cycle?.runIso);
|
||||
@@ -29,7 +48,8 @@ export function windStats(manifest) {
|
||||
count: manifest?.grid ? manifest.grid.nx * manifest.grid.ny : 0,
|
||||
lastUpdate: Number.isFinite(run) ? run : null,
|
||||
error:
|
||||
manifest?.reason || (manifest?.unavailable ? 'Wind unavailable' : null),
|
||||
manifest?.reason ||
|
||||
(manifest?.unavailable ? msg('Wind unavailable') : null),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -306,8 +326,8 @@ export function createWindLayer({
|
||||
? t('Loading forecast')
|
||||
: preparing
|
||||
? t('Preparing flow')
|
||||
: (error && t(error)) ||
|
||||
manifest?.reason ||
|
||||
: (error && presentWindProblem(error)) ||
|
||||
reasonText(manifest?.reason) ||
|
||||
imageryError ||
|
||||
(scalarMissing ? t('Selected field unavailable') : null) ||
|
||||
(manifest?.stale ? t('Cached forecast · stale') : null),
|
||||
@@ -387,7 +407,7 @@ export function createWindLayer({
|
||||
model: model === 'ifs' ? 'ECMWF IFS' : 'GFS',
|
||||
field: label,
|
||||
unit: unitLabel(legendUnit),
|
||||
})}${historyStatus ? `\n${historyStatus}` : ''}\n${t('Valid: {time}', { time: valid || t('Unavailable') })}${loading ? ` · ${t('loading')}` : preparing ? ` · ${t('preparing')}` : ''}\n${t('Issued: {time}', { time: run || t('Unavailable') })}${manifest?.stale ? ' · STALE' : ''}${error ? `\n${t(error)}` : ''}${scalarMissing ? `\n${t('Selected field unavailable · wind remains visible')}` : ''}${imageryError && !scalarMissing ? `\n${t('{error} · wind remains visible', { error: imageryError })}` : ''}`,
|
||||
})}${historyStatus ? `\n${historyStatus}` : ''}\n${t('Valid: {time}', { time: valid || t('Unavailable') })}${loading ? ` · ${t('loading')}` : preparing ? ` · ${t('preparing')}` : ''}\n${t('Issued: {time}', { time: run || t('Unavailable') })}${manifest?.stale ? ` · ${t('STALE')}` : ''}${error ? `\n${presentWindProblem(error)}` : ''}${scalarMissing ? `\n${t('Selected field unavailable · wind remains visible')}` : ''}${imageryError && !scalarMissing ? `\n${t('{error} · wind remains visible', { error: imageryError })}` : ''}`,
|
||||
infoTitle: t(
|
||||
'Surface wind at 10 m. Approximately 1° global grid. Curves follow the 10 m wind field, lifted 12 km for visibility; display height is not weather altitude. View lighting is for readability. Animation shows flow through one fixed forecast; it does not advance time. Color fields drape the globe basemap or the active photorealistic 3D Tiles.',
|
||||
),
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { readWindBody } from '../../sources/windBody.js';
|
||||
|
||||
// Imported by src/tools: no i18n here. The texts stay English data and the
|
||||
// wind layer and layer panel show them translated.
|
||||
const msg = (text) => text;
|
||||
|
||||
/** Create a bounded, cancellable source for the same-origin wind provider. */
|
||||
export function createWindSource({
|
||||
fetchImpl = (...args) => globalThis.fetch(...args),
|
||||
@@ -8,15 +12,15 @@ export function createWindSource({
|
||||
return {
|
||||
async getSnapshot({ signal, model = 'gfs', overlay = 'none' } = {}) {
|
||||
if (!['gfs', 'ifs'].includes(model))
|
||||
throw new Error('Unknown wind model');
|
||||
throw new Error(msg('Unknown wind model'));
|
||||
if (!['none', 'temperature', 'pressure'].includes(overlay))
|
||||
throw new Error('Unknown weather overlay');
|
||||
throw new Error(msg('Unknown weather overlay'));
|
||||
const query = `model=${model}${overlay === 'none' ? '' : `&overlay=${overlay}`}`;
|
||||
const controller = new AbortController();
|
||||
const abort = () => controller.abort(signal.reason);
|
||||
signal?.addEventListener('abort', abort, { once: true });
|
||||
const timer = setTimeout(
|
||||
() => controller.abort(new Error('Wind request timed out')),
|
||||
() => controller.abort(new Error(msg('Wind request timed out'))),
|
||||
timeoutMs,
|
||||
);
|
||||
const active = controller.signal;
|
||||
@@ -42,8 +46,8 @@ export function createWindSource({
|
||||
scalar === undefined &&
|
||||
scalarError ===
|
||||
(overlay === 'temperature'
|
||||
? 'Temperature field unavailable'
|
||||
: 'Mean sea level pressure field unavailable');
|
||||
? msg('Temperature field unavailable')
|
||||
: msg('Mean sea level pressure field unavailable'));
|
||||
const scalarValid =
|
||||
overlay === 'none'
|
||||
? scalar === undefined && scalarError === undefined
|
||||
@@ -73,7 +77,7 @@ export function createWindSource({
|
||||
`^/api/wind/grid/${model}-[\\w.-]+\\.bin\\?${query}$`,
|
||||
).test(manifest.gridUrl)
|
||||
)
|
||||
throw new Error('Malformed wind manifest');
|
||||
throw new Error(msg('Malformed wind manifest'));
|
||||
const count = grid.nx * grid.ny;
|
||||
const expectedBytes = count * (scalar ? 12 : 8);
|
||||
const gridResponse = await fetchImpl(manifest.gridUrl, {
|
||||
@@ -84,10 +88,10 @@ export function createWindSource({
|
||||
throw new Error(`Wind HTTP ${gridResponse.status}`);
|
||||
const bytes = await readWindBody(gridResponse, expectedBytes, active);
|
||||
if (bytes.byteLength !== expectedBytes)
|
||||
throw new Error('Malformed wind grid');
|
||||
throw new Error(msg('Malformed wind grid'));
|
||||
const values = new Float32Array(bytes.buffer);
|
||||
if (!values.every(Number.isFinite))
|
||||
throw new Error('Malformed wind grid');
|
||||
throw new Error(msg('Malformed wind grid'));
|
||||
return {
|
||||
...manifest,
|
||||
u: values.slice(0, count),
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { setLocale } from '../../i18n/index.js';
|
||||
import { createWindLayer } from './index.js';
|
||||
|
||||
test('stale wind forecast is marked in the UI language (K-40)', async () => {
|
||||
const rendering = Object.fromEntries(
|
||||
['attach', 'start', 'stop', 'clear', 'destroy', 'setField'].map((n) => [
|
||||
n,
|
||||
() => {},
|
||||
]),
|
||||
);
|
||||
rendering.getDiagnostics = () => ({});
|
||||
const layer = createWindLayer({
|
||||
feed: {
|
||||
getSnapshot: async () => ({
|
||||
model: 'gfs',
|
||||
grid: { nx: 2, ny: 2 },
|
||||
cycle: {
|
||||
runIso: '2026-09-14T12:00:00Z',
|
||||
validIso: '2026-09-14T18:00:00Z',
|
||||
},
|
||||
stale: true,
|
||||
reason: 'Wind upstream unavailable',
|
||||
}),
|
||||
},
|
||||
createRendering: () => rendering,
|
||||
});
|
||||
layer.init({ container: {} });
|
||||
layer.enable();
|
||||
await layer.update();
|
||||
try {
|
||||
assert.match(layer.getRowControls().info, / · STALE(\n|$)/);
|
||||
for (const [locale, word] of [
|
||||
['es', 'DESACTUALIZADO'],
|
||||
['ru', 'УСТАРЕЛ'],
|
||||
]) {
|
||||
setLocale(locale);
|
||||
const info = layer.getRowControls().info;
|
||||
assert.doesNotMatch(info, /STALE/);
|
||||
assert.match(info, new RegExp(word));
|
||||
}
|
||||
} finally {
|
||||
setLocale('en');
|
||||
layer.destroy();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
// K-38: wind source failures are English data (src/layers/wind/source.js) and
|
||||
// are shown in the UI language; the HTTP line keeps its status number.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createWindLayer } from './index.js';
|
||||
import { setLocale } from '../../i18n/index.js';
|
||||
|
||||
function harness(problem) {
|
||||
const rendering = Object.fromEntries(
|
||||
[
|
||||
'attach',
|
||||
'start',
|
||||
'stop',
|
||||
'clear',
|
||||
'destroy',
|
||||
'setField',
|
||||
'setOptions',
|
||||
].map((name) => [name, () => {}]),
|
||||
);
|
||||
rendering.getDiagnostics = () => ({});
|
||||
const layer = createWindLayer({
|
||||
feed: {
|
||||
getSnapshot: async () => {
|
||||
throw new Error(problem);
|
||||
},
|
||||
},
|
||||
createRendering: () => rendering,
|
||||
});
|
||||
layer.init({ container: {} });
|
||||
layer.enable();
|
||||
return layer;
|
||||
}
|
||||
|
||||
async function statusFor(problem) {
|
||||
const layer = harness(problem);
|
||||
await layer.update();
|
||||
return layer.getRowControls().summary.status;
|
||||
}
|
||||
|
||||
test('en: wind source failures read as before', async () => {
|
||||
assert.equal(await statusFor('Wind HTTP 503'), 'Wind HTTP 503');
|
||||
assert.equal(
|
||||
await statusFor('Wind request timed out'),
|
||||
'Wind request timed out',
|
||||
);
|
||||
});
|
||||
|
||||
for (const locale of ['es', 'ru']) {
|
||||
test(`${locale}: wind source failures are shown in the UI language`, async () => {
|
||||
setLocale(locale);
|
||||
try {
|
||||
const http = await statusFor('Wind HTTP 503');
|
||||
assert.notEqual(http, 'Wind HTTP 503');
|
||||
assert.match(http, /503/);
|
||||
for (const problem of [
|
||||
'Wind request timed out',
|
||||
'Malformed wind manifest',
|
||||
'Malformed wind grid',
|
||||
])
|
||||
assert.notEqual(await statusFor(problem), problem);
|
||||
} finally {
|
||||
setLocale('en');
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createWindLayer, windStats } from './index.js';
|
||||
import { setLocale, translateKnown } from '../../i18n/index.js';
|
||||
import { windProxy } from '../../../server/providers/wind.js';
|
||||
|
||||
function harness(feed) {
|
||||
const rendering = Object.fromEntries(
|
||||
[
|
||||
'attach',
|
||||
'start',
|
||||
'stop',
|
||||
'clear',
|
||||
'destroy',
|
||||
'setField',
|
||||
'setOptions',
|
||||
].map((name) => [name, () => {}]),
|
||||
);
|
||||
rendering.getDiagnostics = () => ({});
|
||||
const layer = createWindLayer({ feed, createRendering: () => rendering });
|
||||
layer.init({ container: {} });
|
||||
layer.enable();
|
||||
return layer;
|
||||
}
|
||||
|
||||
async function serverManifestOnUpstreamFailure() {
|
||||
let handler;
|
||||
const failing = async () => {
|
||||
throw new Error('upstream down');
|
||||
};
|
||||
windProxy({ models: { gfs: failing, ifs: failing } }).configureServer({
|
||||
middlewares: { use: (_path, fn) => (handler = fn) },
|
||||
});
|
||||
let body = '';
|
||||
await handler(
|
||||
{ method: 'GET', url: '/manifest?model=gfs&overlay=none' },
|
||||
{ writeHead() {}, end: (b) => (body = b), once() {}, removeListener() {} },
|
||||
);
|
||||
return JSON.parse(body);
|
||||
}
|
||||
|
||||
test('en: the upstream failure reason reads as before (K-17)', async () => {
|
||||
const manifest = await serverManifestOnUpstreamFailure();
|
||||
const layer = harness({ getSnapshot: async () => manifest });
|
||||
await layer.update();
|
||||
assert.equal(
|
||||
layer.getRowControls().summary.status,
|
||||
'Wind upstream unavailable',
|
||||
);
|
||||
});
|
||||
|
||||
for (const locale of ['es', 'ru']) {
|
||||
test(`${locale}: upstream failure status is shown in the UI language`, async () => {
|
||||
const manifest = await serverManifestOnUpstreamFailure();
|
||||
assert.equal(manifest.reason, 'Wind upstream unavailable');
|
||||
setLocale(locale);
|
||||
try {
|
||||
const layer = harness({ getSnapshot: async () => manifest });
|
||||
await layer.update();
|
||||
const status = layer.getRowControls().summary.status;
|
||||
const stats = translateKnown(windStats(manifest).error);
|
||||
assert.notEqual(status, 'Wind upstream unavailable');
|
||||
assert.notEqual(stats, 'Wind upstream unavailable');
|
||||
} finally {
|
||||
setLocale('en');
|
||||
}
|
||||
});
|
||||
|
||||
test(`${locale}: a stale cached forecast is reported in the UI language`, async () => {
|
||||
// Shape the server returns when a cached forecast is kept after a failure.
|
||||
const manifest = {
|
||||
model: 'gfs',
|
||||
grid: { nx: 2, ny: 2 },
|
||||
cycle: {
|
||||
runIso: '2026-09-14T12:00:00Z',
|
||||
validIso: '2026-09-14T18:00:00Z',
|
||||
},
|
||||
stale: true,
|
||||
reason: 'Wind upstream unavailable',
|
||||
};
|
||||
setLocale(locale);
|
||||
try {
|
||||
const layer = harness({ getSnapshot: async () => manifest });
|
||||
await layer.update();
|
||||
const status = layer.getRowControls().summary.status;
|
||||
assert.doesNotMatch(status, /[A-Za-z]{4,} upstream unavailable/);
|
||||
} finally {
|
||||
setLocale('en');
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user