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
@@ -36,6 +36,7 @@ import {
|
||||
pointerOwner,
|
||||
releasePointer,
|
||||
} from '../data/inputOwnership.js';
|
||||
import { pointerOwnerName } from '../data/pointerOwnerNames.js';
|
||||
import {
|
||||
DRAW_SHAPES,
|
||||
MAX_VERTICES,
|
||||
@@ -48,7 +49,7 @@ import {
|
||||
removeLastVertex,
|
||||
} from './drawMode.js';
|
||||
|
||||
import { t } from '../i18n/index.js';
|
||||
import { msg, registerUnsavedWork, t, translateKnown } from '../i18n/index.js';
|
||||
/** The id this tool claims the pointer under. */
|
||||
export const DRAW_POINTER_OWNER = 'draw';
|
||||
const PREVIEW_DATA_SOURCE_NAME = 'gev-draw-preview';
|
||||
@@ -101,6 +102,17 @@ export function initDrawTool({ viewer, annotations }) {
|
||||
target.addEventListener(type, listener, options);
|
||||
domListeners.push([target, type, listener, options]);
|
||||
};
|
||||
// The board lives only in memory: switching language (a reload) asks first
|
||||
// while it holds a lasting mark or a shape is half drawn.
|
||||
const unregisterUnsavedWork = registerUnsavedWork(() => {
|
||||
if (destroyed) return null;
|
||||
const lasting = (annotations.list?.() || []).some(
|
||||
(mark) => mark && mark.ttlMs == null && !mark.expiring,
|
||||
);
|
||||
return lasting || session?.vertices?.length > 0
|
||||
? msg('drawings on the map')
|
||||
: null;
|
||||
});
|
||||
|
||||
const dataSource = new Cesium.CustomDataSource(PREVIEW_DATA_SOURCE_NAME);
|
||||
// `add()` resolves on the NEXT tick, so an init-then-immediate-destroy would
|
||||
@@ -334,7 +346,7 @@ export function initDrawTool({ viewer, annotations }) {
|
||||
// half-starting.
|
||||
setHint(
|
||||
t('{owner} is using the pointer — close it first.', {
|
||||
owner: pointerOwner(),
|
||||
owner: translateKnown(pointerOwnerName(pointerOwner())),
|
||||
}),
|
||||
);
|
||||
return active;
|
||||
@@ -514,6 +526,7 @@ export function initDrawTool({ viewer, annotations }) {
|
||||
generation += 1;
|
||||
if (active) setActive(false);
|
||||
destroyed = true;
|
||||
unregisterUnsavedWork();
|
||||
releaseSceneHandler();
|
||||
releasePointer(lease);
|
||||
lease = null;
|
||||
|
||||
@@ -6,6 +6,7 @@ import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import { initDrawTool } from './drawTool.js';
|
||||
import { describeUnsavedWork } from '../i18n/index.js';
|
||||
import {
|
||||
isPointerFree,
|
||||
pointerOwner,
|
||||
@@ -240,6 +241,30 @@ const LEFT_DOUBLE_CLICK = Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK;
|
||||
|
||||
/* ── the tests ─────────────────────────────────────────────────────────── */
|
||||
|
||||
// K-27. First in the file: the tools other tests leave behind would answer the
|
||||
// same registry.
|
||||
test('a lasting mark or a half-drawn shape makes the language switch ask first', async () => {
|
||||
const h = harness();
|
||||
try {
|
||||
let marks = [];
|
||||
h.annotations.list = () => marks;
|
||||
assert.deepEqual(describeUnsavedWork(), [], 'an empty board loses nothing');
|
||||
marks = [{ id: 'a', ttlMs: 8000, expiring: false }];
|
||||
assert.deepEqual(describeUnsavedWork(), [], 'a fading mark is not work');
|
||||
marks = [{ id: 'b', ttlMs: null, expiring: false }];
|
||||
assert.deepEqual(describeUnsavedWork(), ['drawings on the map']);
|
||||
marks = [];
|
||||
h.element('draw-toggle').emit('click');
|
||||
h.tool.addVertex(-97.75, 30.26);
|
||||
assert.deepEqual(describeUnsavedWork(), ['drawings on the map'], 'a shape in progress');
|
||||
marks = [{ id: 'c', ttlMs: null, expiring: false }];
|
||||
await h.tool.destroy();
|
||||
assert.deepEqual(describeUnsavedWork(), [], 'a destroyed tool unregisters');
|
||||
} finally {
|
||||
h.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('turning Draw on takes a lease and turning it off gives it back', async () => {
|
||||
const h = harness();
|
||||
try {
|
||||
@@ -263,7 +288,11 @@ test('Draw refuses to start when something else holds the pointer', async () =>
|
||||
h.element('draw-toggle').emit('click');
|
||||
assert.equal(h.tool.active, false, 'it must not half-start');
|
||||
assert.equal(pointerOwner(), 'directions', 'and must not steal the claim');
|
||||
assert.match(h.element('draw-hint').textContent, /directions/);
|
||||
// Named by its tool, not by the internal owner id (E3/E12).
|
||||
assert.equal(
|
||||
h.element('draw-hint').textContent,
|
||||
'Directions is using the pointer — close it first.',
|
||||
);
|
||||
assert.equal(h.tool.diagnostics().sceneHandler, false, 'no handler was bound');
|
||||
|
||||
releasePointer(other);
|
||||
|
||||
@@ -569,15 +569,20 @@ export function panelRuntime(config) {
|
||||
queued = view;
|
||||
if (started) return;
|
||||
started = true;
|
||||
// i18n-ignore: this script runs as source text in the host panel, without the app catalogs
|
||||
say("Loading God's Eye View…");
|
||||
startApp(url, view).catch((error) =>
|
||||
// i18n-ignore: this script runs as source text in the host panel, without the app catalogs
|
||||
say(`God's Eye View could not load here: ${error?.message || error}.`),
|
||||
);
|
||||
setTimeout(() => {
|
||||
// i18n-ignore: this script runs as source text in the host panel
|
||||
if (!ready)
|
||||
say(
|
||||
// i18n-ignore: see above
|
||||
"God's Eye View did not load here" +
|
||||
(blocked ? ` (${blocked})` : '') +
|
||||
// i18n-ignore: see above
|
||||
". Use Open in God's Eye View above.",
|
||||
);
|
||||
}, config.loadTimeoutMs);
|
||||
@@ -658,6 +663,7 @@ export function panelRuntime(config) {
|
||||
},
|
||||
(error) =>
|
||||
say(
|
||||
// i18n-ignore: this script runs as source text in the host panel, without the app catalogs
|
||||
"This client did not accept the God's Eye View panel" +
|
||||
(error?.message ? `: ${error.message}` : '.'),
|
||||
),
|
||||
|
||||
@@ -9,6 +9,16 @@ const CONTEXT_DEPENDENCIES = Object.freeze({
|
||||
'space-missions': new Set(['rocket-launches', 'satellites']),
|
||||
});
|
||||
const CONTEXT_COMPANIONS = new Set(['radio']);
|
||||
// This module is shared with voice actions and scene policy, so the refusal
|
||||
// reason stays English data; the Context shell translates it with t() at the
|
||||
// toast (src/ui/contextLayerChanges.js).
|
||||
const msg = (text) => text;
|
||||
/** The Space Missions refusal, with the blocked layer's name as {layer}. */
|
||||
export const SPACE_MISSIONS_LAYER_BLOCK_REASON = msg(
|
||||
'Space Missions isolates replay data. Exit the mode to enable {layer}.',
|
||||
);
|
||||
const [BLOCK_REASON_PREFIX, BLOCK_REASON_SUFFIX] =
|
||||
SPACE_MISSIONS_LAYER_BLOCK_REASON.split('{layer}');
|
||||
/** Return whether an origin represents a direct user choice on this route. */
|
||||
export function isExplicitUserIntentOrigin(origin, layerId = null) {
|
||||
return origin === 'user' || origin === 'voice';
|
||||
@@ -336,7 +346,30 @@ export function contextLayerEnableBlockReason({
|
||||
return null;
|
||||
}
|
||||
const label = String(layerName || change.layerId || 'that layer');
|
||||
return `Space Missions isolates replay data. Exit the mode to enable ${label}.`;
|
||||
return SPACE_MISSIONS_LAYER_BLOCK_REASON.replace('{layer}', label);
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a Space Missions refusal back into its template and layer name so
|
||||
* the presentation can translate the sentence and localize the name.
|
||||
* @param {string} reason
|
||||
* @returns {{layer: string}|null} The blocked layer's name, or null when the
|
||||
* reason is not a Space Missions refusal.
|
||||
*/
|
||||
export function parseContextLayerBlockReason(reason) {
|
||||
if (
|
||||
typeof reason !== 'string' ||
|
||||
!reason.startsWith(BLOCK_REASON_PREFIX) ||
|
||||
!reason.endsWith(BLOCK_REASON_SUFFIX) ||
|
||||
reason.length <= BLOCK_REASON_PREFIX.length + BLOCK_REASON_SUFFIX.length
|
||||
)
|
||||
return null;
|
||||
return {
|
||||
layer: reason.slice(
|
||||
BLOCK_REASON_PREFIX.length,
|
||||
reason.length - BLOCK_REASON_SUFFIX.length,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Adds the keyboard semantics omitted by Cesium's href-less attribution links.
|
||||
* Cesium retains ownership of showing and hiding its required credit lightbox.
|
||||
*/
|
||||
import { t } from './i18n/index.js';
|
||||
import { getLocale, t } from './i18n/index.js';
|
||||
|
||||
export function configureCreditKeyboardAccess(root = document) {
|
||||
const expand = root?.querySelector?.(
|
||||
@@ -13,6 +13,14 @@ export function configureCreditKeyboardAccess(root = document) {
|
||||
if (!expand || !lightbox || !close) return false;
|
||||
const overlay = lightbox.parentElement;
|
||||
|
||||
// Cesium writes these two texts in English once, when it builds the credit
|
||||
// display, and never rewrites them; English keeps Cesium's own text.
|
||||
if (getLocale() !== 'en') {
|
||||
expand.textContent = t('Data attribution');
|
||||
const title = lightbox.querySelector?.('.cesium-credit-lightbox-title');
|
||||
if (title) title.textContent = t('Data provided by:');
|
||||
}
|
||||
|
||||
if (!lightbox.id) lightbox.id = 'cesium-credit-lightbox';
|
||||
expand.setAttribute('role', 'button');
|
||||
expand.setAttribute('tabindex', '0');
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { configureCreditKeyboardAccess } from './creditKeyboard.js';
|
||||
import { setLocale } from './i18n/index.js';
|
||||
|
||||
function fakeElement() {
|
||||
const attributes = new Map();
|
||||
@@ -186,3 +187,37 @@ test('credit keyboard setup is idempotent and safely declines incomplete markup'
|
||||
assert.equal(f.overlay.listenerCount('click'), 1);
|
||||
assert.equal(configureCreditKeyboardAccess({ querySelector: () => null }), false);
|
||||
});
|
||||
|
||||
function textFixture() {
|
||||
const f = fixture();
|
||||
const title = fakeElement();
|
||||
title.textContent = 'Data provided by:';
|
||||
f.expand.textContent = 'Data attribution';
|
||||
f.lightbox.querySelector = (selector) =>
|
||||
selector === '.cesium-credit-lightbox-title' ? title : f.close;
|
||||
return { ...f, title };
|
||||
}
|
||||
|
||||
test('English keeps Cesium attribution texts', () => {
|
||||
setLocale('en');
|
||||
const f = textFixture();
|
||||
configureCreditKeyboardAccess(f.root);
|
||||
assert.equal(f.expand.textContent, 'Data attribution');
|
||||
assert.equal(f.title.textContent, 'Data provided by:');
|
||||
});
|
||||
|
||||
for (const [locale, link, title] of [
|
||||
['ru', 'Источники данных', 'Данные предоставлены:'],
|
||||
['es', 'Atribución de datos', 'Datos proporcionados por:'],
|
||||
])
|
||||
test(`${locale}: Cesium attribution link and lightbox title are translated`, () => {
|
||||
setLocale(locale);
|
||||
try {
|
||||
const f = textFixture();
|
||||
configureCreditKeyboardAccess(f.root);
|
||||
assert.equal(f.expand.textContent, link);
|
||||
assert.equal(f.title.textContent, title);
|
||||
} finally {
|
||||
setLocale('en');
|
||||
}
|
||||
});
|
||||
+15
-11
@@ -438,18 +438,22 @@ export function classifyAircraft({ typeCode, category } = {}) {
|
||||
return 'airliner';
|
||||
}
|
||||
|
||||
/** Readable name of each class, for cards and readouts. */
|
||||
// Portable flight records import this module, so it has no i18n: msg() only
|
||||
// marks the labels, and the card translates them where it shows them.
|
||||
const msg = (text) => text;
|
||||
|
||||
/** Readable name of each class, for cards and readouts (English data). */
|
||||
export const AIRCRAFT_CLASS_LABELS = Object.freeze({
|
||||
light: 'Light aircraft',
|
||||
glider: 'Glider',
|
||||
turboprop: 'Turboprop',
|
||||
airliner: 'Airliner',
|
||||
widebody: 'Widebody',
|
||||
quadjet: 'Four-engine jet',
|
||||
helicopter: 'Helicopter',
|
||||
fastjet: 'Fast jet',
|
||||
bizjet: 'Business jet',
|
||||
uav: 'Drone',
|
||||
light: msg('Light aircraft'),
|
||||
glider: msg('Glider'),
|
||||
turboprop: msg('Turboprop'),
|
||||
airliner: msg('Airliner'),
|
||||
widebody: msg('Widebody'),
|
||||
quadjet: msg('Four-engine jet'),
|
||||
helicopter: msg('Helicopter'),
|
||||
fastjet: msg('Fast jet'),
|
||||
bizjet: msg('Business jet'),
|
||||
uav: msg('Drone'),
|
||||
});
|
||||
|
||||
/** Billboard scale multipliers (skylight GLYPH_SCALE, + fastjet). */
|
||||
|
||||
@@ -729,6 +729,19 @@ function ellipsizeEvidenceText(value, limit) {
|
||||
return `${text.slice(0, Math.max(1, limit - 3)).trimEnd()}...`;
|
||||
}
|
||||
|
||||
// The pack names the kind of source in `media.publisher` (event.json); the
|
||||
// frame shows it in the UI language. Any other publisher shows as given.
|
||||
const EVIDENCE_PUBLISHER_TEXT = new Set([
|
||||
msg('YouTube source'),
|
||||
msg('Facebook source'),
|
||||
]);
|
||||
|
||||
/** An evidence item's publisher as its frame shows it. */
|
||||
function evidencePublisherText(publisher) {
|
||||
const text = String(publisher);
|
||||
return EVIDENCE_PUBLISHER_TEXT.has(text) ? t(text) : bhoteEventText(text);
|
||||
}
|
||||
|
||||
/** Repaint one stable frame with a poster or the current source-video frame. */
|
||||
export function updateEvidenceFrame(
|
||||
canvas,
|
||||
@@ -790,20 +803,73 @@ export function updateEvidenceFrame(
|
||||
infoY + 27,
|
||||
);
|
||||
ctx.fillStyle = WORLD_OVERLAY_STYLE.detail;
|
||||
ctx.font = scaledOverlayFont(WORLD_OVERLAY_STYLE.fontDetail);
|
||||
const publisher = String(
|
||||
observation.media?.publisher || t('SOURCE LINK'),
|
||||
const publisher = (
|
||||
observation.media?.publisher
|
||||
? evidencePublisherText(observation.media.publisher)
|
||||
: t('SOURCE LINK')
|
||||
).toUpperCase();
|
||||
ctx.fillText(
|
||||
t('{publisher} / OPEN', {
|
||||
publisher: ellipsizeEvidenceText(publisher, 24),
|
||||
}),
|
||||
fitEvidenceSourceLine(
|
||||
ctx,
|
||||
publisher,
|
||||
scaledOverlayFont(WORLD_OVERLAY_STYLE.fontDetail),
|
||||
canvas.width - 32,
|
||||
),
|
||||
16,
|
||||
infoY + 53,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
// The smallest the source line's font may shrink to before the publisher is
|
||||
// shortened instead.
|
||||
const EVIDENCE_SOURCE_MIN_FONT_SCALE = 0.8;
|
||||
|
||||
/**
|
||||
* Set the font for the «{publisher} / OPEN» line and return the line so it
|
||||
* fits `maxWidth`: a translation («ИСТОЧНИК YOUTUBE / ОТКРЫТЬ») is longer than
|
||||
* the English and the portrait card cut its action word. A line that fits is
|
||||
* drawn exactly as before; one that does not first shrinks its font (to 80%),
|
||||
* then shortens the publisher — the «/ OPEN» action always stays whole.
|
||||
* @param {CanvasRenderingContext2D} ctx
|
||||
* @param {string} publisher Display publisher, already upper-cased.
|
||||
* @param {string} font CSS font with a px size.
|
||||
* @param {number} maxWidth
|
||||
* @returns {string}
|
||||
*/
|
||||
function fitEvidenceSourceLine(ctx, publisher, font, maxWidth) {
|
||||
const line = (limit) =>
|
||||
t('{publisher} / OPEN', {
|
||||
publisher: ellipsizeEvidenceText(publisher, limit),
|
||||
});
|
||||
let limit = 24;
|
||||
let text = line(limit);
|
||||
ctx.font = font;
|
||||
if (typeof ctx.measureText !== 'function' || !(maxWidth > 0)) return text;
|
||||
const width = () => Number(ctx.measureText(text)?.width) || 0;
|
||||
if (width() <= maxWidth) return text;
|
||||
const size = /(\d+(?:\.\d+)?)px/.exec(font);
|
||||
if (size) {
|
||||
const base = Number(size[1]);
|
||||
const floor = base * EVIDENCE_SOURCE_MIN_FONT_SCALE;
|
||||
// Width scales with the font size; step down until it fits or bottoms out.
|
||||
let next = Math.max(
|
||||
floor,
|
||||
Math.floor(((base * maxWidth) / width()) * 10) / 10,
|
||||
);
|
||||
for (;;) {
|
||||
ctx.font = font.replace(size[0], `${next}px`);
|
||||
if (width() <= maxWidth || next <= floor) break;
|
||||
next = Math.max(floor, next - 0.5);
|
||||
}
|
||||
}
|
||||
while (width() > maxWidth && limit > 4) {
|
||||
limit = Math.min(limit, publisher.length) - 1;
|
||||
text = line(limit);
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
/** Compose source imagery and caveats into one stable renderer-owned frame. */
|
||||
export function createEvidenceFrame(observation, poster = null, options = {}) {
|
||||
const canvas = evidenceFrameCanvas(observation);
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
// Proposal 5 / E4: the evidence frame's «{publisher} / OPEN» line is drawn on
|
||||
// a canvas, so CSS cannot widen it; on the portrait card the Russian
|
||||
// «ИСТОЧНИК YOUTUBE / ОТКРЫТЬ» lost its last letter. The line is measured and
|
||||
// shrunk to fit, keeping the action word whole; English draws as before.
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import test from 'node:test';
|
||||
|
||||
import { setLocale } from '../i18n/index.js';
|
||||
import { evidenceCardLayout, updateEvidenceFrame } from './bhoteKoshiEvent.js';
|
||||
|
||||
const eventUrl = new URL(
|
||||
'../../public/events/bhote-koshi-2026/event.json',
|
||||
import.meta.url,
|
||||
);
|
||||
|
||||
// JetBrains Mono advances every glyph by 0.6 em.
|
||||
const fontPx = (font) => Number(/(\d+(?:\.\d+)?)px/.exec(font)[1]);
|
||||
const measure = (text, font) => text.length * 0.6 * fontPx(font);
|
||||
|
||||
function drawSourceLine(observation, publisher) {
|
||||
const layout = evidenceCardLayout(observation);
|
||||
const drawn = [];
|
||||
const context = {
|
||||
font: '',
|
||||
beginPath() {},
|
||||
clearRect() {},
|
||||
moveTo() {},
|
||||
lineTo() {},
|
||||
stroke() {},
|
||||
fillRect() {},
|
||||
drawImage() {},
|
||||
measureText(text) {
|
||||
return { width: measure(String(text), this.font) };
|
||||
},
|
||||
fillText(value, x) {
|
||||
drawn.push({ text: String(value), font: this.font, x });
|
||||
},
|
||||
};
|
||||
const canvas = {
|
||||
width: layout.canvasWidth,
|
||||
height: layout.canvasHeight,
|
||||
_bhoteEvidenceLayout: layout,
|
||||
getContext: () => context,
|
||||
};
|
||||
const subject = publisher
|
||||
? { ...observation, media: { ...observation.media, publisher } }
|
||||
: observation;
|
||||
assert.equal(updateEvidenceFrame(canvas, subject), true);
|
||||
const line = drawn.find((entry) => entry.text.includes(' / '));
|
||||
assert.ok(line, 'source line drawn');
|
||||
return { ...line, maxWidth: layout.canvasWidth - 32 };
|
||||
}
|
||||
|
||||
async function portraitObservations() {
|
||||
const event = JSON.parse(await readFile(eventUrl, 'utf8'));
|
||||
const portrait = event.evidenceSpine.filter(
|
||||
(o) => evidenceCardLayout(o).kind === 'portrait',
|
||||
);
|
||||
const youtube = portrait.find((o) => o.media?.publisher === 'YouTube source');
|
||||
const facebook = portrait.find(
|
||||
(o) => o.media?.publisher === 'Facebook source',
|
||||
);
|
||||
assert.ok(youtube, 'a portrait YouTube card exists');
|
||||
return [youtube, facebook].filter(Boolean);
|
||||
}
|
||||
|
||||
test('English source lines draw unchanged on every card', async () => {
|
||||
setLocale('en');
|
||||
const event = JSON.parse(await readFile(eventUrl, 'utf8'));
|
||||
for (const observation of event.evidenceSpine) {
|
||||
const line = drawSourceLine(observation);
|
||||
assert.equal(fontPx(line.font), 21, `${observation.id}: font kept`);
|
||||
assert.match(
|
||||
line.text,
|
||||
/^(YOUTUBE|FACEBOOK) SOURCE \/ OPEN$|^SOURCE LINK \/ OPEN$/,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('ru/es source lines fit the portrait card with the action word whole', async () => {
|
||||
const observations = await portraitObservations();
|
||||
try {
|
||||
for (const [locale, action] of [
|
||||
['ru', '/ ОТКРЫТЬ'],
|
||||
['es', '/ ABRIR'],
|
||||
]) {
|
||||
setLocale(locale);
|
||||
for (const observation of observations) {
|
||||
const line = drawSourceLine(observation);
|
||||
assert.ok(line.text.endsWith(action), `${locale}: ${line.text}`);
|
||||
assert.ok(
|
||||
measure(line.text, line.font) <= line.maxWidth,
|
||||
`${locale}: «${line.text}» at ${line.font} overflows`,
|
||||
);
|
||||
assert.ok(fontPx(line.font) >= 21 * 0.8, 'font stays readable');
|
||||
}
|
||||
}
|
||||
setLocale('ru');
|
||||
const ru = drawSourceLine(observations[0]);
|
||||
assert.equal(ru.text, 'ИСТОЧНИК YOUTUBE / ОТКРЫТЬ', 'publisher kept whole');
|
||||
} finally {
|
||||
setLocale('en');
|
||||
}
|
||||
});
|
||||
|
||||
test('a publisher too long even at the smallest font is shortened, not the action', async () => {
|
||||
const [observation] = await portraitObservations();
|
||||
try {
|
||||
setLocale('ru');
|
||||
const line = drawSourceLine(
|
||||
observation,
|
||||
'A very long regional broadcaster',
|
||||
);
|
||||
assert.ok(line.text.endsWith('... / ОТКРЫТЬ'), line.text);
|
||||
assert.ok(measure(line.text, line.font) <= line.maxWidth, line.text);
|
||||
assert.equal(fontPx(line.font), 21 * 0.8);
|
||||
} finally {
|
||||
setLocale('en');
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import test from 'node:test';
|
||||
|
||||
// K-30: the publisher on the evidence frame ("YouTube source") is pack text
|
||||
// and is shown in the UI language; English stays "YOUTUBE SOURCE / OPEN".
|
||||
import { setLocale } from '../i18n/index.js';
|
||||
import { createEvidenceFrame } from './bhoteKoshiEvent.js';
|
||||
|
||||
const eventUrl = new URL(
|
||||
'../../public/events/bhote-koshi-2026/event.json',
|
||||
import.meta.url,
|
||||
);
|
||||
|
||||
function captureFrameText(observation) {
|
||||
const originalDocument = globalThis.document;
|
||||
const text = [];
|
||||
globalThis.document = {
|
||||
createElement() {
|
||||
const context = {
|
||||
beginPath() {},
|
||||
clearRect() {},
|
||||
moveTo() {},
|
||||
lineTo() {},
|
||||
stroke() {},
|
||||
fillRect() {},
|
||||
fillText(value) {
|
||||
text.push(String(value));
|
||||
},
|
||||
drawImage() {},
|
||||
};
|
||||
return { width: 0, height: 0, getContext: () => context };
|
||||
},
|
||||
};
|
||||
try {
|
||||
assert.ok(createEvidenceFrame(observation));
|
||||
} finally {
|
||||
globalThis.document = originalDocument;
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
test('evidence card source line is fully in the UI language (es, ru)', async () => {
|
||||
const event = JSON.parse(await readFile(eventUrl, 'utf8'));
|
||||
const youtube = event.evidenceSpine.find(
|
||||
(o) => o.media?.publisher === 'YouTube source',
|
||||
);
|
||||
const facebook = event.evidenceSpine.find(
|
||||
(o) => o.media?.publisher === 'Facebook source',
|
||||
);
|
||||
assert.ok(youtube && facebook);
|
||||
try {
|
||||
for (const locale of ['es', 'ru']) {
|
||||
setLocale(locale);
|
||||
for (const observation of [youtube, facebook]) {
|
||||
const lines = captureFrameText(observation);
|
||||
const sourceLine = lines.find((line) => line.includes(' / '));
|
||||
assert.ok(sourceLine, 'source line drawn');
|
||||
assert.doesNotMatch(
|
||||
sourceLine,
|
||||
/SOURCE/,
|
||||
`${locale}: English "SOURCE" left in "${sourceLine}"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setLocale('en');
|
||||
}
|
||||
});
|
||||
|
||||
test('evidence card source line keeps its English wording', async () => {
|
||||
const event = JSON.parse(await readFile(eventUrl, 'utf8'));
|
||||
const youtube = event.evidenceSpine.find(
|
||||
(o) => o.media?.publisher === 'YouTube source',
|
||||
);
|
||||
setLocale('en');
|
||||
const sourceLine = captureFrameText(youtube).find((line) =>
|
||||
line.includes(' / '),
|
||||
);
|
||||
assert.equal(sourceLine, 'YOUTUBE SOURCE / OPEN');
|
||||
});
|
||||
@@ -574,8 +574,9 @@ export function createBhoteKoshiLocatorLayer({
|
||||
protected: true,
|
||||
active: true,
|
||||
interactive: true,
|
||||
accessibilityLabel:
|
||||
accessibilityLabel: t(
|
||||
'Open the USGS initial report for the 26 August 2026 Nepal landslide',
|
||||
),
|
||||
activate: () => {
|
||||
const opened = window.open(
|
||||
TRIGGER_SOURCE_URL,
|
||||
|
||||
@@ -1450,6 +1450,9 @@ function _drawOverlay(frame) {
|
||||
if (bracketAlpha > 0) protectedVisibleCount++;
|
||||
continue;
|
||||
}
|
||||
// The source shows its own card for this object (e.g. an ambient vessel
|
||||
// card): keep the bracket, draw no callout over the card.
|
||||
if (obj.hideLabel) continue;
|
||||
|
||||
const primary = String(obj.id || '');
|
||||
const micro = String(obj.metric || '');
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
* GPU/MSDF renderer. The Canvas2D overlay (detection.js) imports these and
|
||||
* supplies the actual drawing surface.
|
||||
*/
|
||||
import { formatNumber, t } from '../i18n/index.js';
|
||||
|
||||
/** @constant {number} Max characters shown for a label's primary (id) line. */
|
||||
const MAX_PRIMARY = 18;
|
||||
@@ -30,14 +31,15 @@ export function formatFlightLevel(altitudeMeters) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a speed in knots as a short rounded label. Returns '' for
|
||||
* non-positive or non-finite speeds (declutters moored/unknown vessels).
|
||||
* Formats a speed in knots as a short rounded label in the UI language.
|
||||
* Returns '' for non-positive or non-finite speeds (declutters moored/unknown
|
||||
* vessels).
|
||||
* @param {number} knots - Speed over ground in knots.
|
||||
* @returns {string} e.g. '14 kn', or '' when not applicable.
|
||||
* @returns {string} e.g. '14 kn' (ru '14 уз'), or '' when not applicable.
|
||||
*/
|
||||
export function formatKnots(knots) {
|
||||
if (!Number.isFinite(knots) || knots <= 0) return '';
|
||||
return Math.round(knots) + ' kn';
|
||||
return t('{speed} kn', { speed: formatNumber(Math.round(knots)) });
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -192,3 +192,14 @@ test('transit bracket stroke centres have full pixel coverage at the failing fra
|
||||
for(const [x,y] of points){assert.equal(x%1,0.5);assert.equal(y%1,0.5);}
|
||||
}
|
||||
});
|
||||
|
||||
test('formatKnots writes the unit in the UI language', async () => {
|
||||
const { setLocale } = await import('../i18n/index.js');
|
||||
setLocale('ru');
|
||||
try {
|
||||
assert.equal(formatKnots(9.5), '10 уз');
|
||||
} finally {
|
||||
setLocale('en');
|
||||
}
|
||||
assert.equal(formatKnots(9.5), '10 kn');
|
||||
});
|
||||
@@ -955,6 +955,52 @@ test('a transit vehicle is a contact: box, route, mode and operator on the share
|
||||
}
|
||||
});
|
||||
|
||||
test('hideLabel keeps the bracket but paints no callout over the source card', () => {
|
||||
// C6: an ambient vessel card already names the vessel with its speed; the
|
||||
// callout drawn beside it covered that card's speed and course.
|
||||
const env = installEnvironment();
|
||||
const objects = [
|
||||
{
|
||||
position: new Cesium.Cartesian3(0, 0, 6_356_752),
|
||||
sourceId: '200',
|
||||
id: 'GOLDEN BEAR',
|
||||
type: 'VEH',
|
||||
metric: '10 kn',
|
||||
hideLabel: true,
|
||||
},
|
||||
{
|
||||
position: new Cesium.Cartesian3(0.5, 0.5, 6_356_752),
|
||||
sourceId: '300',
|
||||
id: 'BAY PILOT',
|
||||
type: 'VEH',
|
||||
metric: '4 kn',
|
||||
},
|
||||
];
|
||||
try {
|
||||
initWorldOverlay(env.viewer);
|
||||
initDetection(
|
||||
env.viewer,
|
||||
[{ id: 'transit', getDetectableObjects: () => objects }],
|
||||
() => {},
|
||||
);
|
||||
setMode('DENSE');
|
||||
env.advance(250);
|
||||
env.postRender.raise();
|
||||
env.advance(250);
|
||||
env.postRender.raise();
|
||||
const painted = env.ctx.calls
|
||||
.filter(([name]) => name === 'fillText')
|
||||
.map(([, text]) => String(text));
|
||||
assert.ok(painted.includes('BAY PILOT'), `painted ${JSON.stringify(painted)}`);
|
||||
assert.ok(
|
||||
!painted.some((text) => text.includes('GOLDEN BEAR')),
|
||||
`no callout under the card; painted ${JSON.stringify(painted)}`,
|
||||
);
|
||||
} finally {
|
||||
env.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('transit contacts take the vehicle tier, not the civil-air one', () => {
|
||||
assert.equal(resolveTier({ type: 'VEH' }), 'vehicle');
|
||||
assert.equal(resolveTier({ type: 'AIR' }), 'civil');
|
||||
|
||||
@@ -740,6 +740,11 @@ test('a refused arming names the tool holding the pointer and how to leave it',
|
||||
pointerBlockedMessage('measure', 'a'),
|
||||
'measure is active — turn measure off, then set A',
|
||||
);
|
||||
// A registered owner without an exit is named by its tool, not its id.
|
||||
assert.equal(
|
||||
pointerBlockedMessage('recent-imagery-box', 'a'),
|
||||
'Recent imagery is active — turn Recent imagery off, then set A',
|
||||
);
|
||||
// Nothing to say when nothing is in the way, or when we are the holder.
|
||||
assert.equal(pointerBlockedMessage(null, 'a'), null);
|
||||
assert.equal(pointerBlockedMessage('', 'a'), null);
|
||||
|
||||
@@ -1,25 +1,45 @@
|
||||
/** Explain mapped-site availability without claiming an unobserved overload. */
|
||||
export function installationFeedback(stats = {}, now = Date.now()) {
|
||||
const reasons = {
|
||||
rate_limited: 'Overpass rate-limited',
|
||||
timeout: 'Overpass timed out',
|
||||
query_failed: 'Overpass could not complete the query',
|
||||
tiles_unavailable: 'Map tiles temporarily unavailable',
|
||||
names_unavailable: 'Mapped names temporarily unavailable',
|
||||
};
|
||||
const reason =
|
||||
reasons[stats.failureReason] || 'Overpass temporarily unavailable';
|
||||
// Portable node/browser module (the HUD summary in loadingFeedback.js reads
|
||||
// it too), so it marks its copy with a local msg() instead of importing i18n;
|
||||
// installations/controls.js translates describeInstallationFeedback().
|
||||
const msg = (text) => text;
|
||||
|
||||
const REASONS = Object.freeze({
|
||||
rate_limited: msg('Overpass rate-limited'),
|
||||
timeout: msg('Overpass timed out'),
|
||||
query_failed: msg('Overpass could not complete the query'),
|
||||
tiles_unavailable: msg('Map tiles temporarily unavailable'),
|
||||
names_unavailable: msg('Mapped names temporarily unavailable'),
|
||||
});
|
||||
const FALLBACK_REASON = msg('Overpass temporarily unavailable');
|
||||
|
||||
/**
|
||||
* Mapped-site availability as a message to render, without claiming an
|
||||
* unobserved overload. Either `{ text, reason?, seconds? }`, where `text` is
|
||||
* English (`{reason}` / `{seconds}` placeholders, `reason` English too), or
|
||||
* `{ count, km }` for a found-sites count (`km` is null for the view).
|
||||
* @param {object} [stats]
|
||||
* @param {number} [now]
|
||||
* @returns {{text: string, reason?: string, seconds?: number}|{count: number, km: ?number}}
|
||||
*/
|
||||
export function describeInstallationFeedback(stats = {}, now = Date.now()) {
|
||||
const reason = REASONS[stats.failureReason] || FALLBACK_REASON;
|
||||
if (stats.loading)
|
||||
return stats.retrying ? 'Retrying mapped sites…' : 'Fetching mapped sites…';
|
||||
return {
|
||||
text: stats.retrying
|
||||
? msg('Retrying mapped sites…')
|
||||
: msg('Fetching mapped sites…'),
|
||||
};
|
||||
if (stats.retryAt > 0) {
|
||||
const seconds = Math.max(0, Math.ceil((stats.retryAt - now) / 1000));
|
||||
return `${reason} — ${seconds ? `retrying in ${seconds}s` : 'retry pending'}`;
|
||||
return seconds
|
||||
? { text: msg('{reason} — retrying in {seconds}s'), reason, seconds }
|
||||
: { text: msg('{reason} — retry pending'), reason };
|
||||
}
|
||||
if (stats.status === 'unavailable') return reason;
|
||||
if (stats.status === 'unavailable') return { text: reason };
|
||||
if (stats.status === 'zoom-in')
|
||||
return 'Zoom in to search mapped installations';
|
||||
if (stats.stale) return 'Showing cached mapped sites';
|
||||
if (stats.status === 'idle') return 'Mapped sites not loaded';
|
||||
return { text: msg('Zoom in to search mapped installations') };
|
||||
if (stats.stale) return { text: msg('Showing cached mapped sites') };
|
||||
if (stats.status === 'idle') return { text: msg('Mapped sites not loaded') };
|
||||
// With a count, say what was found and where; an empty area is not "loaded".
|
||||
if (Number.isFinite(stats.count)) {
|
||||
const km =
|
||||
@@ -27,9 +47,21 @@ export function installationFeedback(stats = {}, now = Date.now()) {
|
||||
Number.isFinite(stats.coverage.radiusM)
|
||||
? Math.round(stats.coverage.radiusM / 1000)
|
||||
: null;
|
||||
const where = km ? ` within ${km} km of the contact` : ' in view';
|
||||
if (stats.count === 0) return `No mapped sites${where}`;
|
||||
return `${stats.count} mapped site${stats.count === 1 ? '' : 's'}${where}`;
|
||||
return { count: stats.count, km: km || null };
|
||||
}
|
||||
return 'Mapped sites loaded';
|
||||
return { text: msg('Mapped sites loaded') };
|
||||
}
|
||||
|
||||
/** Explain mapped-site availability in English. */
|
||||
export function installationFeedback(stats = {}, now = Date.now()) {
|
||||
const feedback = describeInstallationFeedback(stats, now);
|
||||
if (feedback.text !== undefined)
|
||||
return feedback.text
|
||||
.replace('{reason}', feedback.reason)
|
||||
.replace('{seconds}', feedback.seconds);
|
||||
const where = feedback.km
|
||||
? ` within ${feedback.km} km of the contact`
|
||||
: ' in view';
|
||||
if (feedback.count === 0) return `No mapped sites${where}`;
|
||||
return `${feedback.count} mapped site${feedback.count === 1 ? '' : 's'}${where}`;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* The English names the data gives a military installation that has no
|
||||
* mapped name. They stay English in data — the server provider, the AI tools
|
||||
* and the voice context read the records — and the display translates them
|
||||
* (installationTitle in src/layers/installations/classLabel.js and
|
||||
* formatAwarenessLabel in src/data/militaryAwarenessEngine.js).
|
||||
*
|
||||
* The server, tools and source modules load this file, so it marks the names
|
||||
* with a local msg() instead of importing i18n.
|
||||
*/
|
||||
const msg = (text) => text;
|
||||
|
||||
/** Name of an unnamed feature, by installation class. */
|
||||
export const UNNAMED_INSTALLATION_LABEL_BY_CLASS = Object.freeze({
|
||||
airfield: msg('Military airfield'),
|
||||
naval_base: msg('Naval base'),
|
||||
range: msg('Firing range'),
|
||||
military_land: msg('Military land'),
|
||||
});
|
||||
|
||||
/** Name of an unnamed feature without a usable class. */
|
||||
export const UNNAMED_INSTALLATION_FALLBACK = msg('Mapped installation');
|
||||
|
||||
/** Name of a military polygon the names pack has not named. */
|
||||
export const UNNAMED_MILITARY_AREA = msg('Military area');
|
||||
|
||||
/** Every name above, for the display to translate. */
|
||||
export const UNNAMED_PLACE_NAMES = Object.freeze([
|
||||
...Object.values(UNNAMED_INSTALLATION_LABEL_BY_CLASS),
|
||||
UNNAMED_INSTALLATION_FALLBACK,
|
||||
UNNAMED_MILITARY_AREA,
|
||||
]);
|
||||
@@ -1,6 +1,9 @@
|
||||
/** Pure proximity helpers for Global Context. No capability modeling. */
|
||||
|
||||
import { t } from '../i18n/index.js';
|
||||
import { UNNAMED_PLACE_NAMES } from './installationNames.js';
|
||||
|
||||
const UNNAMED_NAMES = new Set(UNNAMED_PLACE_NAMES);
|
||||
|
||||
export const AWARENESS_RADIUS_M = 250000;
|
||||
export const AWARENESS_MAX_FLIGHT_SEARCH_RADIUS_M = 16000000;
|
||||
@@ -49,7 +52,11 @@ export function formatAwarenessDistance(meters) {
|
||||
: `${(meters / 1000).toFixed(meters < 10000 ? 1 : 0)} km`;
|
||||
}
|
||||
|
||||
/** Return a stable visible label without leaking missing values into UI text. */
|
||||
/**
|
||||
* Return a stable visible label without leaking missing values into UI text.
|
||||
* The English name the data gives an unnamed installation ("Military area")
|
||||
* is shown in the UI language; mapped names and callsigns show as they are.
|
||||
*/
|
||||
export function formatAwarenessLabel(value) {
|
||||
if (value && typeof value === 'object') {
|
||||
for (const candidate of [
|
||||
@@ -65,7 +72,11 @@ export function formatAwarenessLabel(value) {
|
||||
}
|
||||
return '—';
|
||||
}
|
||||
if (typeof value === 'string') return value.trim() || '—';
|
||||
if (typeof value === 'string') {
|
||||
const text = value.trim();
|
||||
if (!text) return '—';
|
||||
return UNNAMED_NAMES.has(text) ? t(text) : text;
|
||||
}
|
||||
if (Number.isFinite(value)) return String(value);
|
||||
return '—';
|
||||
}
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
import {
|
||||
UNNAMED_INSTALLATION_FALLBACK,
|
||||
UNNAMED_INSTALLATION_LABEL_BY_CLASS,
|
||||
} from './installationNames.js';
|
||||
|
||||
/**
|
||||
* Normalize the deliberately small, allow-listed installation context returned
|
||||
* by `/api/military-installations`. These are mapped features, not assertions
|
||||
@@ -22,12 +27,17 @@ const CLASS_BY_MILITARY_TAG = {
|
||||
* nouns. The OSM id is not lost — it stays on `id` and on `sources[].id`, where
|
||||
* attribution and the details panel read it.
|
||||
*/
|
||||
const LABEL_BY_CLASS = {
|
||||
airfield: 'Military airfield',
|
||||
naval_base: 'Naval base',
|
||||
range: 'Firing range',
|
||||
military_land: 'Military land',
|
||||
};
|
||||
// The labels stay English in data (the server provider and AI tools read the
|
||||
// records too); the map card and Contacts translate them (installationNames.js).
|
||||
const LABEL_BY_CLASS = UNNAMED_INSTALLATION_LABEL_BY_CLASS;
|
||||
const UNNAMED_FALLBACK = UNNAMED_INSTALLATION_FALLBACK;
|
||||
|
||||
/** Every name humanizeInstallationClass gives a record of a class
|
||||
* CLASS_BY_MILITARY_TAG can produce, for the display to translate. */
|
||||
export const UNNAMED_INSTALLATION_NAMES = Object.freeze([
|
||||
...Object.values(LABEL_BY_CLASS),
|
||||
UNNAMED_FALLBACK,
|
||||
]);
|
||||
|
||||
const MAX_FOOTPRINT_POINTS = 400;
|
||||
|
||||
@@ -44,7 +54,7 @@ export function humanizeInstallationClass(klass) {
|
||||
const words = key.replaceAll('_', ' ').trim();
|
||||
return words
|
||||
? `${words[0].toUpperCase()}${words.slice(1)}`
|
||||
: 'Mapped installation';
|
||||
: UNNAMED_FALLBACK;
|
||||
}
|
||||
|
||||
function finiteLatitude(value) {
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* The tool name to show for a pointer owner.
|
||||
*
|
||||
* Pointer-owner ids (src/data/inputOwnership.js) are stable internal
|
||||
* identifiers — 'draw', 'recent-imagery-box' — and never belong on screen.
|
||||
* This maps each to the English name of the tool, which the caller translates
|
||||
* with translateKnown() where it is shown. Pure and stateless, so any package
|
||||
* can carry it without a second copy of the pointer claim.
|
||||
*/
|
||||
|
||||
// English marker for the i18n extractor; this module imports no i18n.
|
||||
const msg = (text) => text;
|
||||
|
||||
const POINTER_OWNER_NAMES = Object.freeze({
|
||||
draw: msg('Draw'),
|
||||
directions: msg('Directions'),
|
||||
'recent-imagery-box': msg('Recent imagery'),
|
||||
});
|
||||
|
||||
/**
|
||||
* English display name of a pointer owner. An owner without a registered name
|
||||
* falls back to its id.
|
||||
* @param {string|null} owner Pointer-owner id, as `pointerOwner()` returns it.
|
||||
* @returns {string}
|
||||
*/
|
||||
export function pointerOwnerName(owner) {
|
||||
const id = typeof owner === 'string' ? owner.trim() : '';
|
||||
if (!id) return '';
|
||||
return Object.hasOwn(POINTER_OWNER_NAMES, id) ? POINTER_OWNER_NAMES[id] : id;
|
||||
}
|
||||
@@ -19,8 +19,10 @@ import {
|
||||
import satellitesLayer from './satellites.js';
|
||||
import { createTrackedOverlayEntry } from './trackedReadout.js';
|
||||
|
||||
const L1 = '1 25544U 98067A 08264.51782528 -.00002182 00000-0 -11606-4 0 2927';
|
||||
const L2 = '2 25544 51.6416 247.4627 0006703 130.5360 325.0288 15.72125391563537';
|
||||
const L1 =
|
||||
'1 25544U 98067A 08264.51782528 -.00002182 00000-0 -11606-4 0 2927';
|
||||
const L2 =
|
||||
'2 25544 51.6416 247.4627 0006703 130.5360 325.0288 15.72125391563537';
|
||||
|
||||
test('partial and dense catalogs do not make an early false missing decision', async () => {
|
||||
_setSatelliteTrackingRefreshOutcomeForTest({
|
||||
@@ -32,31 +34,45 @@ test('partial and dense catalogs do not make an early false missing decision', a
|
||||
assert.match(partial.reason, /partial CelesTrak catalog/);
|
||||
|
||||
let releaseDense;
|
||||
const densePromise = new Promise((resolve) => { releaseDense = resolve; });
|
||||
const densePromise = new Promise((resolve) => {
|
||||
releaseDense = resolve;
|
||||
});
|
||||
_setSatelliteTrackingRefreshOutcomeForTest({
|
||||
status: 'accepted',
|
||||
catalog: 'dense',
|
||||
densePromise,
|
||||
});
|
||||
let settled = false;
|
||||
const resolution = satellitesLayer.resolveTrackingRestoreTarget(987654)
|
||||
.then((result) => { settled = true; return result; });
|
||||
const resolution = satellitesLayer
|
||||
.resolveTrackingRestoreTarget(987654)
|
||||
.then((result) => {
|
||||
settled = true;
|
||||
return result;
|
||||
});
|
||||
await Promise.resolve();
|
||||
assert.equal(settled, false, 'dense absence must wait for the dense catalog');
|
||||
releaseDense({ status: 'ready' });
|
||||
assert.equal((await resolution).status, 'missing');
|
||||
_setSatelliteTrackingRefreshOutcomeForTest({ status: 'accepted', catalog: 'core' });
|
||||
_setSatelliteTrackingRefreshOutcomeForTest({
|
||||
status: 'accepted',
|
||||
catalog: 'core',
|
||||
});
|
||||
});
|
||||
|
||||
test('satellite first update aborts every parallel catalog request', async () => {
|
||||
const realFetch = globalThis.fetch;
|
||||
const observedSignals = [];
|
||||
globalThis.fetch = (_url, options = {}) => new Promise((_resolve, reject) => {
|
||||
observedSignals.push(options.signal);
|
||||
options.signal?.addEventListener('abort', () => {
|
||||
reject(new DOMException('aborted', 'AbortError'));
|
||||
}, { once: true });
|
||||
});
|
||||
globalThis.fetch = (_url, options = {}) =>
|
||||
new Promise((_resolve, reject) => {
|
||||
observedSignals.push(options.signal);
|
||||
options.signal?.addEventListener(
|
||||
'abort',
|
||||
() => {
|
||||
reject(new DOMException('aborted', 'AbortError'));
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
});
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const work = satellitesLayer.update({}, { signal: controller.signal });
|
||||
@@ -64,7 +80,10 @@ test('satellite first update aborts every parallel catalog request', async () =>
|
||||
assert.ok(observedSignals.length > 1);
|
||||
controller.abort();
|
||||
await assert.rejects(work, { name: 'AbortError' });
|
||||
assert.equal(observedSignals.every((signal) => signal?.aborted), true);
|
||||
assert.equal(
|
||||
observedSignals.every((signal) => signal?.aborted),
|
||||
true,
|
||||
);
|
||||
} finally {
|
||||
globalThis.fetch = realFetch;
|
||||
}
|
||||
@@ -94,7 +113,9 @@ test('selected satellite params survive delayed arrival and yield to newer expli
|
||||
point,
|
||||
});
|
||||
try {
|
||||
assert.doesNotThrow(() => satellitesLayer.setParams({ selectedSatTrackingId: 25544 }));
|
||||
assert.doesNotThrow(() =>
|
||||
satellitesLayer.setParams({ selectedSatTrackingId: 25544 }),
|
||||
);
|
||||
assert.equal(satellitesLayer.getParams().selectedSatTrackingId, 25544);
|
||||
assert.equal(viewer.trackedEntity?.gevTrackedId, 'satellites:25544');
|
||||
|
||||
@@ -120,7 +141,11 @@ test('selected satellite params survive delayed arrival and yield to newer expli
|
||||
);
|
||||
assert.equal(_pendingSatelliteTrackingRestoreForTest(), 99999);
|
||||
assert.equal(satellitesLayer.trackById(25544, { origin: 'user' }), true);
|
||||
assert.equal(_pendingSatelliteTrackingRestoreForTest(), null, 'new user selection cancels stale restore');
|
||||
assert.equal(
|
||||
_pendingSatelliteTrackingRestoreForTest(),
|
||||
null,
|
||||
'new user selection cancels stale restore',
|
||||
);
|
||||
_applyPendingSatelliteTrackingRestoreForTest();
|
||||
assert.equal(satellitesLayer.getParams().selectedSatTrackingId, 25544);
|
||||
|
||||
@@ -132,7 +157,11 @@ test('selected satellite params survive delayed arrival and yield to newer expli
|
||||
);
|
||||
assert.equal(_pendingSatelliteTrackingRestoreForTest(), 25544);
|
||||
satellitesLayer.stopTracking({ origin: 'user' });
|
||||
assert.equal(_pendingSatelliteTrackingRestoreForTest(), null, 'explicit clear cancels stale restore');
|
||||
assert.equal(
|
||||
_pendingSatelliteTrackingRestoreForTest(),
|
||||
null,
|
||||
'explicit clear cancels stale restore',
|
||||
);
|
||||
_setSatelliteLabelLifecycleStateForTest({
|
||||
viewer,
|
||||
satrec: twoline2satrec(L1, L2),
|
||||
@@ -187,8 +216,8 @@ test('a tracked docked cluster consolidates its companions onto one card', () =>
|
||||
viewer,
|
||||
now: () => nowMs,
|
||||
neighbours: [
|
||||
{ noradId: 55555, name: 'PROGRESS-MS 34', point: near(120) }, // berthed
|
||||
{ noradId: 55556, name: 'SOYUZ-MS 12', point: near(-90) }, // berthed
|
||||
{ noradId: 55555, name: 'PROGRESS-MS 34', point: near(120) }, // berthed
|
||||
{ noradId: 55556, name: 'SOYUZ-MS 12', point: near(-90) }, // berthed
|
||||
{ noradId: 99999, name: 'UNRELATED SAT', point: near(50_000) }, // 50 km away
|
||||
],
|
||||
});
|
||||
@@ -203,10 +232,17 @@ test('a tracked docked cluster consolidates its companions onto one card', () =>
|
||||
const details = entity.gevLabelModel.details;
|
||||
// Class leads the detail block; the altitude line follows it, and the
|
||||
// consolidated companions stay last.
|
||||
assert.equal(details[0], 'STATION · ISS', 'the class line names what this is');
|
||||
assert.equal(
|
||||
details[0],
|
||||
'STATION · ISS',
|
||||
'the class line names what this is',
|
||||
);
|
||||
assert.match(details[1], /NORAD 25544$/, 'the altitude line is unchanged');
|
||||
assert.equal(details[2], 'DOCKED · PROGRESS-MS 34 · +1',
|
||||
'companions are consolidated, named, and counted on the tracked card');
|
||||
assert.equal(
|
||||
details[2],
|
||||
'DOCKED · PROGRESS-MS 34 · +1',
|
||||
'companions are consolidated, named, and counted on the tracked card',
|
||||
);
|
||||
|
||||
// Scoping is encoded in that string: THREE neighbours were seeded but only two
|
||||
// are counted (the named one plus "+1"). The satellite 50 km away is not part
|
||||
@@ -222,10 +258,7 @@ test('satellite pre-render refreshes the tracked altitude on each propagated fra
|
||||
camera: null,
|
||||
scene: { frameState: { frameNumber: 1 } },
|
||||
};
|
||||
const epochs = [
|
||||
Date.UTC(2008, 8, 20, 12, 30),
|
||||
Date.UTC(2008, 8, 20, 13, 0),
|
||||
];
|
||||
const epochs = [Date.UTC(2008, 8, 20, 12, 30), Date.UTC(2008, 8, 20, 13, 0)];
|
||||
let epochIndex = 0;
|
||||
_setTrackedSatelliteRefreshStateForTest({
|
||||
noradId: 25544,
|
||||
@@ -240,14 +273,18 @@ test('satellite pre-render refreshes the tracked altitude on each propagated fra
|
||||
_runSatellitePreRenderForTest();
|
||||
assert.equal(entity.gevLabelModel.title, 'ISS (ZARYA)');
|
||||
assert.equal(entity.gevLabelModel.details[0], 'STATION · ISS');
|
||||
assert.equal(entity.gevLabelModel.details[1], '353 km · NORAD 25544');
|
||||
// formatQuantity keeps the number and its unit together with a no-break space.
|
||||
assert.equal(entity.gevLabelModel.details[1], '353\u00a0km · NORAD 25544');
|
||||
|
||||
epochIndex = 1;
|
||||
viewer.scene.frameState.frameNumber = 2;
|
||||
_runSatellitePreRenderForTest();
|
||||
assert.equal(entity.gevLabelModel.details[1], '366 km · NORAD 25544');
|
||||
assert.equal(entity.gevLabelModel.details[0], 'STATION · ISS',
|
||||
'the class line survives an altitude-only republish');
|
||||
assert.equal(entity.gevLabelModel.details[1], '366\u00a0km · NORAD 25544');
|
||||
assert.equal(
|
||||
entity.gevLabelModel.details[0],
|
||||
'STATION · ISS',
|
||||
'the class line survives an altitude-only republish',
|
||||
);
|
||||
});
|
||||
|
||||
test('ISS ambient and tracked lifecycle uses cached host entries with no native labels or duplicate text', () => {
|
||||
@@ -288,34 +325,59 @@ test('ISS ambient and tracked lifecycle uses cached host entries with no native
|
||||
assert.deepEqual(ambientPublication[3], ISS_OVERLAY_SOURCE_OPTIONS);
|
||||
const ambient = ambientPublication[2][0];
|
||||
assert.equal(ambient.title, 'ISS');
|
||||
assert.equal(ambient.position(), point.position, 'ISS getter reads the existing point cache');
|
||||
assert.equal(point.label, undefined, 'ISS point primitive carries no label graphic');
|
||||
assert.equal(
|
||||
ambient.position(),
|
||||
point.position,
|
||||
'ISS getter reads the existing point cache',
|
||||
);
|
||||
assert.equal(
|
||||
point.label,
|
||||
undefined,
|
||||
'ISS point primitive carries no label graphic',
|
||||
);
|
||||
|
||||
const trackedEntity = _trackIssForTest();
|
||||
assert.ok(trackedEntity);
|
||||
assert.equal(viewer.entities.values.length, 1, 'runtime guard requires a real tracked entity');
|
||||
assert.equal(
|
||||
viewer.entities.values.length,
|
||||
1,
|
||||
'runtime guard requires a real tracked entity',
|
||||
);
|
||||
assert.equal(trackedEntity.label, undefined);
|
||||
assert.ok(trackedEntity.point, 'tracked dot remains on the entity for camera framing');
|
||||
assert.ok(
|
||||
trackedEntity.point,
|
||||
'tracked dot remains on the entity for camera framing',
|
||||
);
|
||||
const trackedEntry = createTrackedOverlayEntry(trackedEntity);
|
||||
assert.ok(trackedEntry);
|
||||
assert.equal(trackedEntry.id, 'satellites:25544');
|
||||
assert.equal(trackedEntry.protected, true);
|
||||
assert.equal(trackedEntry.paintLane, 'tracked');
|
||||
assert.equal(
|
||||
calls.filter(([type, sourceId]) => type === 'entries' && sourceId === 'satellites-iss').length,
|
||||
calls.filter(
|
||||
([type, sourceId]) =>
|
||||
type === 'entries' && sourceId === 'satellites-iss',
|
||||
).length,
|
||||
2,
|
||||
'tracking may refresh before ownership changes but must not publish a second ISS entry',
|
||||
);
|
||||
assert.deepEqual(calls.slice(-2), [
|
||||
['clear', 'satellites-iss'],
|
||||
['visible', 'satellites-iss', false],
|
||||
], 'tracked ISS suppresses ambient text so the tracked card is the only text surface');
|
||||
assert.deepEqual(
|
||||
calls.slice(-2),
|
||||
[
|
||||
['clear', 'satellites-iss'],
|
||||
['visible', 'satellites-iss', false],
|
||||
],
|
||||
'tracked ISS suppresses ambient text so the tracked card is the only text surface',
|
||||
);
|
||||
|
||||
_clearSatelliteLabelLifecycleForTest();
|
||||
cleaned = true;
|
||||
assert.equal(viewer.entities.values.length, 0);
|
||||
assert.equal(
|
||||
calls.filter(([type, sourceId]) => type === 'entries' && sourceId === 'satellites-iss').length,
|
||||
calls.filter(
|
||||
([type, sourceId]) =>
|
||||
type === 'entries' && sourceId === 'satellites-iss',
|
||||
).length,
|
||||
3,
|
||||
'untracking republishes exactly one persistent ISS ambient entry before teardown',
|
||||
);
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import * as Cesium from 'cesium';
|
||||
import { twoline2satrec } from 'satellite.js';
|
||||
import {
|
||||
_runSatellitePreRenderForTest,
|
||||
_setTrackedSatelliteRefreshStateForTest,
|
||||
_trackedFrameCartesianForTest,
|
||||
} from './satellites.js';
|
||||
import { setLocale } from '../i18n/index.js';
|
||||
|
||||
const L1 =
|
||||
'1 25544U 98067A 08264.51782528 -.00002182 00000-0 -11606-4 0 2927';
|
||||
const L2 =
|
||||
'2 25544 51.6416 247.4627 0006703 130.5360 325.0288 15.72125391563537';
|
||||
|
||||
function run(locale) {
|
||||
setLocale(locale);
|
||||
const entity = { gevLabelModel: { title: 'OLD', details: ['? km'] } };
|
||||
const point = { position: new Cesium.Cartesian3() };
|
||||
const viewer = { camera: null, scene: { frameState: { frameNumber: 1 } } };
|
||||
let nowMs = Date.UTC(2008, 8, 20, 12, 30);
|
||||
const near = (metres) => ({
|
||||
get position() {
|
||||
const tracked = _trackedFrameCartesianForTest();
|
||||
return new Cesium.Cartesian3(tracked.x + metres, tracked.y, tracked.z);
|
||||
},
|
||||
});
|
||||
_setTrackedSatelliteRefreshStateForTest({
|
||||
noradId: 25544,
|
||||
name: 'ISS (ZARYA)',
|
||||
satrec: twoline2satrec(L1, L2),
|
||||
entity,
|
||||
point,
|
||||
viewer,
|
||||
now: () => nowMs,
|
||||
neighbours: [
|
||||
{ noradId: 55555, name: 'PROGRESS-MS 34', point: near(120) },
|
||||
{ noradId: 55556, name: 'SOYUZ-MS 12', point: near(-90) },
|
||||
],
|
||||
});
|
||||
_runSatellitePreRenderForTest();
|
||||
nowMs += 2000;
|
||||
viewer.scene.frameState.frameNumber = 2;
|
||||
_runSatellitePreRenderForTest();
|
||||
const details = entity.gevLabelModel.details;
|
||||
setLocale('en');
|
||||
return details;
|
||||
}
|
||||
|
||||
test('K-32 es: tracked ISS card', () => {
|
||||
const details = run('es');
|
||||
console.log('es details:', JSON.stringify(details));
|
||||
assert.equal(details[0], 'ESTACIÓN · ISS');
|
||||
assert.doesNotMatch(
|
||||
details[2],
|
||||
/^DOCKED/,
|
||||
'companions line should be localized',
|
||||
);
|
||||
});
|
||||
|
||||
test('K-32 ru: tracked ISS card', () => {
|
||||
const details = run('ru');
|
||||
console.log('ru details:', JSON.stringify(details));
|
||||
assert.equal(details[0], 'СТАНЦИЯ · ISS');
|
||||
assert.match(details[1], /км/, 'ru glossary: km → км');
|
||||
assert.doesNotMatch(
|
||||
details[2],
|
||||
/^DOCKED/,
|
||||
'companions line should be localized',
|
||||
);
|
||||
});
|
||||
@@ -15,6 +15,8 @@ export const VESSEL_DEFAULT_LABEL_LIMIT = 900;
|
||||
export const VESSEL_OVERLAY_MAX_COHORT = VESSEL_DEFAULT_LABEL_LIMIT;
|
||||
/** Existing ambient vessel-card distance fade reaches zero at 5000 km. */
|
||||
export const VESSEL_CARD_FADE_DISTANCE_M = 5_000_000;
|
||||
/** Share of VESSEL_CARD_FADE_DISTANCE_M at which ambient cards begin to fade. */
|
||||
export const VESSEL_CARD_FADE_START_RATIO = 0.7;
|
||||
|
||||
/**
|
||||
* AIS type family → chevron hue + card accent. Single source of truth for
|
||||
@@ -187,7 +189,7 @@ export function applyVesselOverlayPolicy(
|
||||
verticalOnly: !selected,
|
||||
viewportMargin: 4,
|
||||
maxDistance: selected ? Number.POSITIVE_INFINITY : fadeDistance,
|
||||
distanceFadeStartRatio: 0.7,
|
||||
distanceFadeStartRatio: VESSEL_CARD_FADE_START_RATIO,
|
||||
edgeFade: 'keyhole',
|
||||
horizonCull: true,
|
||||
terrainOcclusion: false,
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
// K-12: a translated vessel type is never cut mid-word on the card.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import { buildVesselCard, buildSelectedVesselCard } from './aisLiveVessels.js';
|
||||
import { displayVesselType } from './vesselLabels.js';
|
||||
import { setLocale } from '../i18n/index.js';
|
||||
|
||||
const POS = new Cesium.Cartesian3(1, 2, 3);
|
||||
const rec = (type) => ({
|
||||
name: 'X',
|
||||
mmsi: '1',
|
||||
type,
|
||||
destination: '',
|
||||
speed: 5,
|
||||
course: 10,
|
||||
heading: 10,
|
||||
lastPositionUtc: '',
|
||||
missedRefreshes: 0,
|
||||
position: POS,
|
||||
billboard: null,
|
||||
});
|
||||
// Every AIS ShipType code with its own display token.
|
||||
const CODES = [
|
||||
'30',
|
||||
'31',
|
||||
'33',
|
||||
'34',
|
||||
'35',
|
||||
'36',
|
||||
'37',
|
||||
'50',
|
||||
'51',
|
||||
'52',
|
||||
'53',
|
||||
'54',
|
||||
'55',
|
||||
'58',
|
||||
'40',
|
||||
'60',
|
||||
'70',
|
||||
'80',
|
||||
'90',
|
||||
];
|
||||
|
||||
// The card label is acceptable when it is the whole translation, or when it
|
||||
// was shortened at a word boundary and marked with an ellipsis.
|
||||
function assertNotCutMidWord(label, full, where) {
|
||||
if (label === full) return;
|
||||
const head = label.replace(/(…|\.\.\.)$/, '');
|
||||
assert.notEqual(
|
||||
head,
|
||||
label,
|
||||
`${where}: "${label}" cut from "${full}" without an ellipsis`,
|
||||
);
|
||||
assert.ok(
|
||||
full.startsWith(head),
|
||||
`${where}: "${label}" is not a prefix of "${full}"`,
|
||||
);
|
||||
assert.match(
|
||||
full.slice(head.length),
|
||||
/^[\s-]/,
|
||||
`${where}: "${label}" cuts "${full}" mid-word`,
|
||||
);
|
||||
}
|
||||
|
||||
for (const locale of ['es', 'ru']) {
|
||||
test(`${locale}: vessel type on cards is never cut mid-word`, () => {
|
||||
setLocale(locale);
|
||||
try {
|
||||
for (const code of CODES) {
|
||||
const full = displayVesselType(code).toUpperCase();
|
||||
const ambient = buildVesselCard(rec(code)).details[0].split(' · ')[0];
|
||||
const selected = buildSelectedVesselCard(rec(code)).details[0].split(
|
||||
' · ',
|
||||
)[0];
|
||||
assertNotCutMidWord(ambient, full, `${locale} ${code} ambient`);
|
||||
assertNotCutMidWord(selected, full, `${locale} ${code} selected`);
|
||||
}
|
||||
} finally {
|
||||
setLocale('en');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('en: free feed text is still bounded to the card width', () => {
|
||||
setLocale('en');
|
||||
assert.equal(
|
||||
buildVesselCard(rec('Crude Oil Tanker')).details[0].split(' · ')[0],
|
||||
'CRUDE OIL TANK',
|
||||
);
|
||||
assert.equal(
|
||||
buildSelectedVesselCard(rec('54')).details[0].split(' · ')[0],
|
||||
'ANTI-POLLUTION',
|
||||
);
|
||||
});
|
||||
@@ -15,6 +15,7 @@ export class SceneDocumentError extends Error {
|
||||
super(`${path}: ${reason}`);
|
||||
this.name = 'SceneDocumentError';
|
||||
this.path = path;
|
||||
this.reason = reason;
|
||||
}
|
||||
}
|
||||
export const fail = (path, reason) => {
|
||||
|
||||
+2
-1
@@ -38,6 +38,7 @@ import {
|
||||
import { formatNumber, msg, t } from './i18n/index.js';
|
||||
import { unitLabel } from './ui/unitLabels.js';
|
||||
import { localizeLayerName } from './ui/layerPanel.js';
|
||||
import { localizePlaceName } from './ui/placeNames.js';
|
||||
|
||||
/** Color palettes keyed by shader mode; applied as CSS custom properties. */
|
||||
const HUD_COLORS = {
|
||||
@@ -157,7 +158,7 @@ const LOCALIZED_LOCALITY = Object.freeze({
|
||||
near: ({ poi, city, distKm }) =>
|
||||
t('NEAR {poi} ({city}) {distance}', {
|
||||
poi,
|
||||
city,
|
||||
city: localizePlaceName(city),
|
||||
distance: hudQuantity(Math.round(distKm), 'km'),
|
||||
}),
|
||||
sector: ({ latDeg, lonDeg }) =>
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
// K-21: an empty or blank translation must not count as done.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
checkCatalog,
|
||||
extractMessages,
|
||||
readCatalog,
|
||||
} from '../../scripts/i18n.mjs';
|
||||
import { getCatalog, setLocale, t, tn, translateKnown } from './index.js';
|
||||
|
||||
const messages = extractMessages();
|
||||
const EMPTY = 'DISCONNECT';
|
||||
const BLANK = 'CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR';
|
||||
|
||||
for (const locale of ['es', 'ru']) {
|
||||
test(`${locale}: checkCatalog rejects an empty or blank translation`, () => {
|
||||
const catalog = { ...readCatalog(locale), [EMPTY]: '', [BLANK]: ' ' };
|
||||
const { errors } = checkCatalog(locale, catalog, messages);
|
||||
assert.ok(
|
||||
errors.some((e) => e.includes(`"${EMPTY}"`)),
|
||||
'empty string passes the check',
|
||||
);
|
||||
assert.ok(
|
||||
errors.some((e) => e.includes(`"${BLANK}"`)),
|
||||
'blank string passes the check',
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
test('t() and translateKnown() fall back to English for an empty entry', () => {
|
||||
const es = getCatalog('es');
|
||||
const saved = es[EMPTY];
|
||||
es[EMPTY] = '';
|
||||
setLocale('es');
|
||||
try {
|
||||
assert.equal(t(EMPTY), EMPTY);
|
||||
assert.equal(translateKnown(EMPTY), EMPTY);
|
||||
} finally {
|
||||
es[EMPTY] = saved;
|
||||
setLocale('en');
|
||||
}
|
||||
});
|
||||
|
||||
test('tn() skips an empty plural form', () => {
|
||||
const es = getCatalog('es');
|
||||
const KEY = '{count} INPUTS UNKNOWN';
|
||||
const saved = es[KEY];
|
||||
setLocale('es');
|
||||
try {
|
||||
es[KEY] = { ...saved, one: ' ' };
|
||||
assert.equal(
|
||||
tn(1, '{count} INPUT UNKNOWN', KEY),
|
||||
saved.other.replace('{count}', '1'),
|
||||
);
|
||||
es[KEY] = { one: '', many: '', other: '' };
|
||||
assert.equal(tn(1, '{count} INPUT UNKNOWN', KEY), '1 INPUT UNKNOWN');
|
||||
assert.equal(tn(3, '{count} INPUT UNKNOWN', KEY), '3 INPUTS UNKNOWN');
|
||||
} finally {
|
||||
es[KEY] = saved;
|
||||
setLocale('en');
|
||||
}
|
||||
});
|
||||
+46
-8
@@ -9,7 +9,7 @@
|
||||
//
|
||||
// English needs no catalog. Every other locale is one flat JSON file,
|
||||
// locales/<code>.json, keyed by the English text (for tn, by the English
|
||||
// plural form). A missing entry falls back to English.
|
||||
// plural form). A missing or empty entry falls back to English.
|
||||
// `npm run i18n` keeps the catalogs in step with the code. Guide: docs/I18N.md.
|
||||
import {
|
||||
DEFAULT_LOCALE,
|
||||
@@ -66,10 +66,14 @@ function interpolate(pattern, values) {
|
||||
);
|
||||
}
|
||||
|
||||
/** A catalog string with something to show; an empty one counts as missing. */
|
||||
const filled = (value) => typeof value === 'string' && value.trim() !== '';
|
||||
|
||||
function lookup(text) {
|
||||
const catalog = CATALOGS[currentLocale];
|
||||
if (!catalog) return undefined;
|
||||
if (Object.hasOwn(catalog, text)) return catalog[text];
|
||||
const entry = Object.hasOwn(catalog, text) ? catalog[text] : undefined;
|
||||
if (filled(entry) || (entry && typeof entry === 'object')) return entry;
|
||||
warnMissing(text);
|
||||
return undefined;
|
||||
}
|
||||
@@ -101,8 +105,9 @@ export function tn(count, one, other, values) {
|
||||
const entry = lookup(other);
|
||||
const category = pluralRules(currentLocale).select(count);
|
||||
let pattern;
|
||||
if (entry && typeof entry === 'object') {
|
||||
pattern = entry[category] ?? entry.other ?? other;
|
||||
const forms = entry && typeof entry === 'object' ? entry : null;
|
||||
if (forms && (filled(forms[category]) || filled(forms.other))) {
|
||||
pattern = filled(forms[category]) ? forms[category] : forms.other;
|
||||
} else {
|
||||
pattern = pluralRules(DEFAULT_LOCALE).select(count) === 'one' ? one : other;
|
||||
}
|
||||
@@ -117,8 +122,8 @@ export function tn(count, one, other, values) {
|
||||
export function translateKnown(text) {
|
||||
const catalog = CATALOGS[currentLocale];
|
||||
if (!catalog || typeof text !== 'string') return text;
|
||||
const entry = catalog[text];
|
||||
return typeof entry === 'string' ? entry : text;
|
||||
const entry = Object.hasOwn(catalog, text) ? catalog[text] : undefined;
|
||||
return filled(entry) ? entry : text;
|
||||
}
|
||||
|
||||
/** Mark English text that is stored in data and translated later by t(). */
|
||||
@@ -247,10 +252,43 @@ export function applyDocumentTranslations(doc) {
|
||||
return applied;
|
||||
}
|
||||
|
||||
const unsavedWorkChecks = new Set();
|
||||
|
||||
/**
|
||||
* Name work that switching language would lose: the switch reloads the page,
|
||||
* and the language switch asks first while any check reports something.
|
||||
* @param {() => string|null|undefined} check Returns a msg() label (e.g.
|
||||
* msg('unsaved CCTV calibration')) while there is such work, else nothing.
|
||||
* @returns {() => void} Removes the check.
|
||||
*/
|
||||
export function registerUnsavedWork(check) {
|
||||
if (typeof check !== 'function') return () => {};
|
||||
unsavedWorkChecks.add(check);
|
||||
return () => unsavedWorkChecks.delete(check);
|
||||
}
|
||||
|
||||
/**
|
||||
* What a reload would lose right now, translated, without repeats.
|
||||
* @returns {string[]}
|
||||
*/
|
||||
export function describeUnsavedWork() {
|
||||
const labels = new Set();
|
||||
for (const check of unsavedWorkChecks) {
|
||||
try {
|
||||
const label = check();
|
||||
if (typeof label === 'string' && label) labels.add(t(label));
|
||||
} catch {
|
||||
// A failing check must not block the switch.
|
||||
}
|
||||
}
|
||||
return [...labels];
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist a locale choice, then reload preserving window.location.hash exactly
|
||||
* and stripping ?lang from the search string, so the override is one-shot.
|
||||
* Where storage is blocked, ?lang carries the choice instead.
|
||||
* and stripping ?lang from the search string, so the stored choice applies.
|
||||
* Where storage is blocked, ?lang carries the choice instead (share links
|
||||
* drop it: ShareLinkManager.copyLink).
|
||||
* @returns {boolean} true when a reload was triggered.
|
||||
*/
|
||||
export function persistLocaleAndReload(
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
// K-194: the KartaView provider's failure and state texts are English data
|
||||
// (msg() in kartaview/{policy,source,viewer,index}.js) that the Street Level
|
||||
// panel and the chip tooltip show through translateKnown, so every one needs
|
||||
// an entry in each language's catalog.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { setLocale, translateKnown } from './index.js';
|
||||
|
||||
const MESSAGES = [
|
||||
// kartaview/policy.js
|
||||
'KartaView is rate-limiting requests — they resume on their own',
|
||||
'KartaView’s photos here are archived and cannot be shown',
|
||||
'KartaView did not filter this area in time — move the map to ask again',
|
||||
'None of KartaView’s photos here pass the imagery filter',
|
||||
// kartaview/source.js
|
||||
'KartaView did not answer in time — try again',
|
||||
'KartaView could not be reached — check the network',
|
||||
'KartaView sent an answer that could not be read',
|
||||
'KartaView is rate-limiting requests',
|
||||
'KartaView is rate-limiting requests — try again in a moment',
|
||||
'KartaView sent too much data',
|
||||
'KartaView has no such photo',
|
||||
// kartaview/viewer.js, index.js
|
||||
'The KartaView photo could not be loaded',
|
||||
'KartaView has no position for this photo',
|
||||
];
|
||||
|
||||
for (const locale of ['es', 'ru']) {
|
||||
test(`KartaView provider messages are translated in ${locale}`, () => {
|
||||
setLocale(locale);
|
||||
try {
|
||||
const untranslated = MESSAGES.filter((m) => translateKnown(m) === m);
|
||||
assert.deepEqual(untranslated, []);
|
||||
} finally {
|
||||
setLocale('en');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// kartaview/source.js refusals with a number in them: English data, shown
|
||||
// through placeholder entries by the Street Level panel and the layer panel.
|
||||
test('KartaView HTTP and refusal codes keep their number in every language', async () => {
|
||||
const { translateStreetLevelError } =
|
||||
await import('../ui/streetLevelPresentation.js');
|
||||
const http = 'KartaView answered HTTP 503';
|
||||
const refused = 'KartaView refused the request (code 610)';
|
||||
assert.equal(translateStreetLevelError(http), http);
|
||||
assert.equal(translateStreetLevelError(refused), refused);
|
||||
for (const locale of ['es', 'ru']) {
|
||||
setLocale(locale);
|
||||
try {
|
||||
const shownHttp = translateStreetLevelError(http);
|
||||
const shownRefused = translateStreetLevelError(refused);
|
||||
assert.notEqual(shownHttp, http);
|
||||
assert.match(shownHttp, /503/);
|
||||
assert.notEqual(shownRefused, refused);
|
||||
assert.match(shownRefused, /610/);
|
||||
} finally {
|
||||
setLocale('en');
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { setLocale, translateKnown } from './index.js';
|
||||
|
||||
// Status and error texts the layers write straight into their state
|
||||
// (loadingLabel / _error / street.error) as English data; the layer panel and
|
||||
// the Street Level panel show them through translateKnown, so each needs a
|
||||
// catalog entry in every language (K-36).
|
||||
const SITES = [
|
||||
['../layers/streetLevel/index.js', 'loading coverage...'],
|
||||
['../layers/firms/ingestion.js', 'live feed unavailable'],
|
||||
['../layers/bikeshare/viewport.js', 'GBFS fetch error'],
|
||||
['../layers/bikeshare/ingestion.js', 'GBFS status update failed'],
|
||||
[
|
||||
'../layers/streetLevel/viewerHost.js',
|
||||
'Open the Street Level panel to view imagery',
|
||||
],
|
||||
['../layers/streetLevel/viewerHost.js', 'Image could not be opened'],
|
||||
['../layers/satellites/ingestion.js', 'CelesTrak unreachable'],
|
||||
[
|
||||
'../layers/streetLevel/providers/mapillary/policy.js',
|
||||
'Coverage requests are being rate-limited — they resume on their own',
|
||||
],
|
||||
['../layers/alpr/index.js', 'Camera source temporarily unavailable'],
|
||||
['../layers/perimeters/index.js', 'Perimeter source unavailable'],
|
||||
['../layers/recentImagery/index.js', 'Imagery catalog unavailable'],
|
||||
['../layers/installations/ingestion.js', 'Installation context unavailable'],
|
||||
['../layers/earthquakes/index.js', 'Earthquake source unavailable'],
|
||||
['../layers/submarineCables/ingestion.js', 'TeleGeography load failed'],
|
||||
['../layers/vessels/ingestion.js', 'AIS live load failed'],
|
||||
['../layers/vessels/lifecycle.js', 'AIS live load failed'],
|
||||
['../layers/wind/index.js', 'Wind unavailable'],
|
||||
['../layers/wind/source.js', 'Wind request timed out'],
|
||||
['../layers/wind/source.js', 'Malformed wind manifest'],
|
||||
['../layers/wind/source.js', 'Malformed wind grid'],
|
||||
['../layers/wind/source.js', 'Temperature field unavailable'],
|
||||
['../layers/wind/source.js', 'Mean sea level pressure field unavailable'],
|
||||
];
|
||||
|
||||
// A fallback that reaches the panel must be marked, or `npm run i18n` cannot
|
||||
// see it and the catalog entry would be pruned as unused.
|
||||
for (const [file, text] of SITES) {
|
||||
test(`"${text}" in ${file} is marked`, () => {
|
||||
const source = readFileSync(new URL(file, import.meta.url), 'utf8');
|
||||
assert.ok(
|
||||
new RegExp(
|
||||
String.raw`\b(?:msg|t)\(\s*'${text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}'`,
|
||||
).test(source),
|
||||
`${text} is not marked in ${file}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
for (const locale of ['es', 'ru']) {
|
||||
for (const [file, text] of SITES) {
|
||||
test(`${locale}: "${text}" from ${file} is translated`, () => {
|
||||
const source = readFileSync(new URL(file, import.meta.url), 'utf8');
|
||||
assert.ok(source.includes(`'${text}'`), `literal still in ${file}`);
|
||||
setLocale(locale);
|
||||
try {
|
||||
assert.notEqual(translateKnown(text), text);
|
||||
} finally {
|
||||
setLocale('en');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
+558
-5
@@ -10,7 +10,7 @@
|
||||
"LOAD FAILED": "CARGA FALLIDA",
|
||||
"LOAD CANCELLED": "CARGA CANCELADA",
|
||||
"syncing road network": "sincronizando la red vial",
|
||||
"retrying in {seconds}s": "reintentando en {seconds} s",
|
||||
"retrying in {seconds}s": "reintento en {seconds} s",
|
||||
"retry pending": "reintento pendiente",
|
||||
"Clear selected data layers": "Limpiar las capas de datos seleccionadas",
|
||||
"Turn off all selected data layers": "Desactivar todas las capas de datos seleccionadas",
|
||||
@@ -812,7 +812,7 @@
|
||||
"Check microphone permission and network access, then try again.": "Revisa el permiso del micrófono y el acceso a la red, y vuelve a intentarlo.",
|
||||
"AI AGENT": "AGENTE DE IA",
|
||||
"VOICE CONTROL": "CONTROL DE VOZ",
|
||||
"{tier} applies next session": "{tier} se aplica en la próxima sesión",
|
||||
"{tier} applies next session": "{tier} desde la próxima sesión",
|
||||
"Voice model tier — applies next session": "Nivel del modelo de voz — se aplica en la próxima sesión",
|
||||
"Estimated session cost": "Costo estimado de la sesión",
|
||||
"Voice control — activate to toggle voice; hold Space to speak": "Control de voz — púlsalo para activar o desactivar la voz; mantén pulsado Space para hablar",
|
||||
@@ -989,7 +989,7 @@
|
||||
"Coverage colours": "Colores de cobertura",
|
||||
"That shape already has {count} points — finish it or press Backspace.": "Esa forma ya tiene {count} puntos — termínala o pulsa Backspace.",
|
||||
"Could not place the shape: {error}": "No se pudo colocar la forma: {error}",
|
||||
"{owner} is using the pointer — close it first.": "{owner} está usando el puntero — ciérralo primero.",
|
||||
"{owner} is using the pointer — close it first.": "Puntero en uso: {owner}. Ciérralo primero.",
|
||||
"{label} · Detailed outline unavailable": "{label} · Contorno detallado no disponible",
|
||||
"Witness source": "Fuente de testigo",
|
||||
"{provider} · EMBEDDED SOURCE": "{provider} · FUENTE INSERTADA",
|
||||
@@ -1758,7 +1758,7 @@
|
||||
"Mark {targets} +{more}": "Marcar {targets} +{more}",
|
||||
"Mark {targets}": "Marcar: {targets}",
|
||||
"Fly to {place}": "Volar a {place}",
|
||||
"Nearest aircraft to {place}": "Aeronaves más cercanas a {place}",
|
||||
"Nearest aircraft to {place}": "Aeronave más cercana a {place}",
|
||||
"Read the view": "Consultar la vista",
|
||||
"Check view state": "Comprobar el estado de la vista",
|
||||
"Frame {target}": "Encuadrar {target}",
|
||||
@@ -2208,5 +2208,558 @@
|
||||
"Show your own GeoJSON, KML, KMZ or GPX files on the globe (or drop them onto it). File bytes stay on this device; overlays can appear in map screenshots shared with voice.": "Muestra en el globo tus propios archivos GeoJSON, KML, KMZ o GPX (o suéltalos sobre él). Los archivos no salen de este dispositivo; las capas pueden aparecer en capturas del mapa compartidas con la voz.",
|
||||
"Import": "Importar",
|
||||
"Choose GeoJSON, KML, KMZ or GPX files": "Elige archivos GeoJSON, KML, KMZ o GPX",
|
||||
"Imported files": "Archivos importados"
|
||||
"Imported files": "Archivos importados",
|
||||
"DOCKED · {name}": "ACOPLE · {name}",
|
||||
"DOCKED · {name} · +{extra}": "ACOPLE · {name} · +{extra}",
|
||||
"loading coverage...": "cargando cobertura...",
|
||||
"live feed unavailable": "fuente en vivo no disponible",
|
||||
"GBFS fetch error": "error al obtener datos GBFS",
|
||||
"GBFS status update failed": "falló la actualización del estado GBFS",
|
||||
"Open the Street Level panel to view imagery": "Abre el panel \"A pie de calle\" para ver las imágenes",
|
||||
"Image could not be opened": "No se pudo abrir la imagen",
|
||||
"Mapillary rejected MAPILLARY_CLIENT_TOKEN — replace it in Provider Settings": "Mapillary rechazó MAPILLARY_CLIENT_TOKEN — sustitúyelo en Ajustes de proveedores",
|
||||
"Coverage requests are being rate-limited — they resume on their own": "Las peticiones de cobertura están limitadas por frecuencia — se reanudan solas",
|
||||
"No street-level imagery within {radius} m of that point": "No hay imágenes a pie de calle a menos de {radius} m de ese punto",
|
||||
"No street-level imagery within {radius} m of the view centre": "No hay imágenes a pie de calle a menos de {radius} m del centro de la vista",
|
||||
"Aim the middle of the view at a street to look for imagery there": "Apunta el centro de la vista a una calle para buscar imágenes allí",
|
||||
"Street View has no imagery for this place": "Street View no tiene imágenes de este lugar",
|
||||
"GOOGLE_MAPS_API_KEY is not set": "GOOGLE_MAPS_API_KEY no está configurada",
|
||||
"The Google Maps JavaScript API could not load": "No se pudo cargar la API de Maps JavaScript de Google",
|
||||
"Weather request timed out": "La petición del clima agotó el tiempo de espera",
|
||||
"Malformed weather manifest": "Manifiesto del clima mal formado",
|
||||
"Weather HTTP {status}": "Clima: HTTP {status}",
|
||||
"Weather image HTTP {status}": "Imagen del clima: HTTP {status}",
|
||||
"Weather prefetch unavailable": "Precarga del clima no disponible",
|
||||
"Infrared decode failed": "Falló la decodificación del infrarrojo",
|
||||
"rate limited": "límite de peticiones superado",
|
||||
"routing service is rate limited": "el servicio de rutas superó el límite de peticiones",
|
||||
"route leg too long": "tramo de la ruta demasiado largo",
|
||||
"route too long": "ruta demasiado larga",
|
||||
"Routing is rate limited — try again in a moment": "Se alcanzó el límite de peticiones de rutas — inténtalo de nuevo en un momento",
|
||||
"{error} — try again in a moment": "{error} — inténtalo de nuevo en un momento",
|
||||
"Routing unavailable (HTTP {status})": "Rutas no disponibles (HTTP {status})",
|
||||
"CelesTrak unreachable": "CelesTrak inaccesible",
|
||||
"KartaView is rate-limiting requests — they resume on their own": "KartaView está limitando las peticiones — se reanudan solas",
|
||||
"KartaView’s photos here are archived and cannot be shown": "Las fotos de KartaView de aquí están archivadas y no se pueden mostrar",
|
||||
"KartaView did not filter this area in time — move the map to ask again": "KartaView no filtró esta zona a tiempo — mueve el mapa para pedirlo de nuevo",
|
||||
"None of KartaView’s photos here pass the imagery filter": "Ninguna foto de KartaView de aquí pasa el filtro de imágenes",
|
||||
"KartaView did not answer in time — try again": "KartaView no respondió a tiempo — inténtalo de nuevo",
|
||||
"KartaView could not be reached — check the network": "No se pudo conectar con KartaView — comprueba la red",
|
||||
"KartaView sent an answer that could not be read": "KartaView envió una respuesta que no se pudo leer",
|
||||
"KartaView is rate-limiting requests": "KartaView está limitando las peticiones",
|
||||
"KartaView is rate-limiting requests — try again in a moment": "KartaView está limitando las peticiones — inténtalo de nuevo en un momento",
|
||||
"KartaView sent too much data": "KartaView envió demasiados datos",
|
||||
"KartaView has no such photo": "KartaView no tiene esa foto",
|
||||
"The KartaView photo could not be loaded": "No se pudo cargar la foto de KartaView",
|
||||
"KartaView has no position for this photo": "KartaView no tiene la posición de esta foto",
|
||||
"Map tiles temporarily unavailable": "Teselas del mapa temporalmente no disponibles",
|
||||
"Mapped names temporarily unavailable": "Nombres mapeados temporalmente no disponibles",
|
||||
"No mapped sites in view": "No hay sitios mapeados a la vista",
|
||||
"No mapped sites within {km} km of the contact": "No hay sitios mapeados a menos de {km} km del contacto",
|
||||
"{count} mapped sites in view": {
|
||||
"one": "{count} sitio mapeado a la vista",
|
||||
"many": "{count} de sitios mapeados a la vista",
|
||||
"other": "{count} sitios mapeados a la vista"
|
||||
},
|
||||
"{count} mapped sites within {km} km of the contact": {
|
||||
"one": "{count} sitio mapeado a menos de {km} km del contacto",
|
||||
"many": "{count} de sitios mapeados a menos de {km} km del contacto",
|
||||
"other": "{count} sitios mapeados a menos de {km} km del contacto"
|
||||
},
|
||||
"Too many mapped sites in view to list them all": "Hay demasiados sitios mapeados a la vista para listarlos todos",
|
||||
"Installation context unavailable": "Contexto de instalaciones no disponible",
|
||||
"Focus vessel {name}, MMSI {mmsi}": "Enfocar el buque {name}, MMSI {mmsi}",
|
||||
"Open the USGS initial report for the 26 August 2026 Nepal landslide": "Abrir el informe inicial del USGS sobre el deslizamiento del 26 de agosto de 2026 en Nepal",
|
||||
"YouTube source": "Fuente de YouTube",
|
||||
"Facebook source": "Fuente de Facebook",
|
||||
"Military airfield": "Aeródromo militar",
|
||||
"Naval base": "Base naval",
|
||||
"Firing range": "Campo de tiro",
|
||||
"Military land": "Terreno militar",
|
||||
"Mapped installation": "Instalación mapeada",
|
||||
"Military area": "Zona militar",
|
||||
"SIMULATED — {reason}": "SIMULADO — {reason}",
|
||||
"TomTom key unavailable": "Clave de TomTom no disponible",
|
||||
"TomTom daily budget reached": "Se alcanzó el límite diario de TomTom",
|
||||
"TomTom upstream unreachable": "Servidor de TomTom inaccesible",
|
||||
"TomTom flow unavailable": "Flujo de TomTom no disponible",
|
||||
"API key rejected — check AISSTREAM_API_KEY": "Clave de API rechazada — revisa AISSTREAM_API_KEY",
|
||||
"feed silent {seconds}s — no AIS data": "fuente en silencio {seconds} s — sin datos AIS",
|
||||
"feed silent — no AIS data": "fuente en silencio — sin datos AIS",
|
||||
"feed down — retrying slowly (attempt {attempt})": "fuente caída — reintentando despacio (intento {attempt})",
|
||||
"feed down — retrying slowly": "fuente caída — reintentando despacio",
|
||||
"reconnecting to feed… (attempt {attempt})": "reconectando con la fuente… (intento {attempt})",
|
||||
"reconnecting to feed…": "reconectando con la fuente…",
|
||||
"AISSTREAM_API_KEY not set": "AISSTREAM_API_KEY no está configurada",
|
||||
"live feed unsupported": "fuente en vivo no compatible",
|
||||
"connecting to feed…": "conectando con la fuente…",
|
||||
"feed disconnected": "fuente desconectada",
|
||||
"feed down": "fuente caída",
|
||||
"feed idle": "fuente inactiva",
|
||||
"AIS live load failed": "Falló la carga de AIS en vivo",
|
||||
"age|{count}m": "{count} min",
|
||||
"{acres} ac": {
|
||||
"one": "{acres} acre",
|
||||
"many": "{acres} de acres",
|
||||
"other": "{acres} acres"
|
||||
},
|
||||
"{percent}% contained": "contenido al {percent}%",
|
||||
"containment unknown": "contención desconocida",
|
||||
"{cause} cause": "causa: {cause}",
|
||||
"{personnel} personnel": {
|
||||
"one": "{personnel} efectivo",
|
||||
"many": "{personnel} de efectivos",
|
||||
"other": "{personnel} efectivos"
|
||||
},
|
||||
"{county} County": "condado de {county}",
|
||||
"{cost} to date": "{cost} hasta la fecha",
|
||||
"discovered {age} ago": "detectado hace {age}",
|
||||
"updated {age} ago": "actualizado hace {age}",
|
||||
"part of {complex}": "parte de {complex}",
|
||||
"InciWeb ↗ · click card to open": "InciWeb ↗ · haz clic en la tarjeta para abrir",
|
||||
"FIRE · {name}": "INCENDIO · {name}",
|
||||
"Unnamed incident": "Sin nombre",
|
||||
"Open {title} on InciWeb": "Abrir {title} en InciWeb",
|
||||
"Vehicle {vehicle}": "Vehículo {vehicle}",
|
||||
"Bus": "Autobús",
|
||||
"Tram": "Tranvía",
|
||||
"Subway": "Metro",
|
||||
"Train": "Tren",
|
||||
"Ferry": "Ferri",
|
||||
"Transit vehicle": "Vehículo de transporte público",
|
||||
"No recent fix": "Sin posición reciente",
|
||||
"Waiting for update": "Esperando actualización",
|
||||
"Moving": "En movimiento",
|
||||
"reported {speed} km/h": "velocidad notificada: {speed} km/h",
|
||||
"reported hdg {heading}°": "rumbo notificado: {heading}°",
|
||||
"Last report: stopped at stop {stop}": "Último informe: detenido en la parada {stop}",
|
||||
"Last report: arriving at stop {stop}": "Último informe: llegando a la parada {stop}",
|
||||
"Last report: next stop {stop}": "Último informe: próxima parada {stop}",
|
||||
"occupancy|empty": "vacío",
|
||||
"occupancy|many seats available": "muchos asientos libres",
|
||||
"occupancy|few seats available": "pocos asientos libres",
|
||||
"occupancy|standing room only": "solo de pie",
|
||||
"occupancy|crushed standing room only": "abarrotado, solo de pie",
|
||||
"occupancy|full": "lleno",
|
||||
"occupancy|not accepting passengers": "no admite pasajeros",
|
||||
"occupancy|not boardable": "no se puede subir",
|
||||
"Shown at street level · depth not in the feed": "Se muestra a nivel de calle · la fuente no indica la profundidad",
|
||||
"Reported {seconds} s ago": "Notificado hace {seconds} s",
|
||||
"Reported {minutes} min ago": "Notificado hace {minutes} min",
|
||||
"{reported} (feed time)": "{reported} (hora de la fuente)",
|
||||
"shown {seconds} s behind": "se muestra con {seconds} s de retraso",
|
||||
"Available trail: {minutes} min": "Rastro disponible: {minutes} min",
|
||||
"Available trail: {minutes} min (limited)": "Rastro disponible: {minutes} min (limitado)",
|
||||
"UNAVAILABLE · {source} · Roads unavailable": "NO DISPONIBLE · {source} · Vías no disponibles",
|
||||
"Roads: {source}": "Vías: {source}",
|
||||
"TomTom roads unavailable while the traffic service is unreachable": "Vías de TomTom no disponibles mientras el servicio de tráfico esté inaccesible",
|
||||
"Hybrid unavailable while the traffic service is unreachable": "Modo híbrido no disponible mientras el servicio de tráfico esté inaccesible",
|
||||
"TomTom roads need a TomTom key": "Las vías de TomTom necesitan una clave de TomTom",
|
||||
"Hybrid needs a TomTom key": "El modo híbrido necesita una clave de TomTom",
|
||||
"Local surface still loading": "La superficie bajo las vías aún se está cargando",
|
||||
"Partial coverage": "Cobertura parcial",
|
||||
"Detailed roads unavailable": "Vías detalladas no disponibles",
|
||||
"Reduced detail coverage": "Cobertura con menos detalle",
|
||||
"OpenFreeMap tiles rate-limited": "Teselas de OpenFreeMap con límite de peticiones",
|
||||
"OpenFreeMap tiles timed out": "Las teselas de OpenFreeMap agotaron el tiempo de espera",
|
||||
"OpenFreeMap tiles unavailable": "Teselas de OpenFreeMap no disponibles",
|
||||
"Heard by your receiver": "Captado por tu receptor",
|
||||
"browser SDR": "SDR del navegador",
|
||||
"decoder feed": "fuente del decodificador",
|
||||
"Your RTL-SDR receiver and decoder feed": "Tu receptor RTL-SDR y la fuente del decodificador",
|
||||
"Your decoder feed": "Tu fuente del decodificador",
|
||||
"Your RTL-SDR receiver": "Tu receptor RTL-SDR",
|
||||
"NO CALLSIGN": "SIN INDICATIVO",
|
||||
"ALT {altitude} FT · GS {speed} KT · TRK {track}": "ALT {altitude} FT · VEL. SUELO {speed} KN · TRK {track}",
|
||||
"V/S {rate}": "VEL. VERT. {rate}",
|
||||
"V/S {rate} FPM": "VEL. VERT. {rate} FT/MIN",
|
||||
"POSITION {age}": "POSICIÓN {age}",
|
||||
"POSITION {seconds} S AGO": "POSICIÓN HACE {seconds} S",
|
||||
"{count} MSGS": {
|
||||
"one": "{count} MENSAJE",
|
||||
"many": "{count} DE MENSAJES",
|
||||
"other": "{count} MENSAJES"
|
||||
},
|
||||
"installation class|BARRACKS": "CUARTEL",
|
||||
"installation class|TRAINING AREA": "ZONA DE INSTRUCCIÓN",
|
||||
"Seeking up…": "Buscando hacia arriba…",
|
||||
"Seeking down…": "Buscando hacia abajo…",
|
||||
"Scanning {mhz} MHz…": "Escaneando {mhz} MHz…",
|
||||
"Found {mhz} MHz · {snr} dB": "Encontrado {mhz} MHz · {snr} dB",
|
||||
"Broadcaster stream is unavailable or blocked by the browser.": "El flujo de la emisora no está disponible o el navegador lo ha bloqueado.",
|
||||
"Playback requires a direct click or tap.": "La reproducción requiere un clic o un toque directo.",
|
||||
"Broadcaster stream could not be started.": "No se pudo iniciar el flujo de la emisora.",
|
||||
"Directory refresh degraded; showing the previous station catalog.": "La actualización del directorio fue parcial; se muestra el catálogo de emisoras anterior.",
|
||||
"Radio directory coverage is degraded.": "El directorio de radio funciona con interrupciones.",
|
||||
"Directory refresh failed; showing the previous station catalog.": "La actualización del directorio falló; se muestra el catálogo de emisoras anterior.",
|
||||
"Radio directory is temporarily unavailable.": "El directorio de radio no está disponible temporalmente.",
|
||||
"Decoder feeds: {feeds}": "Fuentes del decodificador: {feeds}",
|
||||
" · read while Local ADS-B is on": " · se leen mientras ADS-B local está activo",
|
||||
"idle": "en espera",
|
||||
"running": "activo",
|
||||
"suspended": "suspendido",
|
||||
"closed": "cerrado",
|
||||
"Local FM stopped: internet radio started": "FM local detenida: se inició la radio por Internet",
|
||||
"ALPR cameras": "cámaras ALPR",
|
||||
"OVERPASS RATE-LIMITED": "OVERPASS CON LÍMITE DE PETICIONES",
|
||||
"OVERPASS TIMED OUT": "OVERPASS AGOTÓ EL TIEMPO DE ESPERA",
|
||||
"OVERPASS COULD NOT COMPLETE THE QUERY": "OVERPASS NO PUDO COMPLETAR LA CONSULTA",
|
||||
"MAP TILES TEMPORARILY UNAVAILABLE": "TESELAS DEL MAPA TEMPORALMENTE NO DISPONIBLES",
|
||||
"MAPPED NAMES TEMPORARILY UNAVAILABLE": "NOMBRES MAPEADOS TEMPORALMENTE NO DISPONIBLES",
|
||||
"Shared flight follow expired": "El seguimiento del vuelo compartido ha caducado",
|
||||
"Shared military flight follow expired": "El seguimiento del vuelo militar compartido ha caducado",
|
||||
"Shared satellite follow expired": "El seguimiento del satélite compartido ha caducado",
|
||||
"Shared flight could not be restored — feed unavailable": "No se pudo restaurar el vuelo compartido — fuente no disponible",
|
||||
"Shared military flight could not be restored — feed unavailable": "No se pudo restaurar el vuelo militar compartido — fuente no disponible",
|
||||
"Shared satellite could not be restored — feed unavailable": "No se pudo restaurar el satélite compartido — fuente no disponible",
|
||||
"Shared flight is unavailable": "El vuelo compartido no está disponible",
|
||||
"Shared military flight is unavailable": "El vuelo militar compartido no está disponible",
|
||||
"Shared satellite is unavailable": "El satélite compartido no está disponible",
|
||||
"Scene not saved — browser storage unavailable": "Escena no guardada — almacenamiento del navegador no disponible",
|
||||
"Scene not saved — existing saved project could not be read. Export edits or import a valid file.": "Escena no guardada — no se pudo leer el proyecto guardado. Exporta los cambios o importa un archivo válido.",
|
||||
"Saved project could not be read; storage preserved. Import a valid file to resume saving.": "No se pudo leer el proyecto guardado; el almacenamiento se conserva. Importa un archivo válido para volver a guardar.",
|
||||
"Scene not saved — {reason}": "Escena no guardada — {reason}",
|
||||
"Stopped": "Detenido",
|
||||
"Stopped (Esc)": "Detenido (Esc)",
|
||||
"Camera ownership changed": "Cambió el control de la cámara",
|
||||
"Scene layer turned off": "Capa de la escena desactivada",
|
||||
"Seeking scene clock": "Desplazando el reloj de la escena",
|
||||
"Importing project": "Importando proyecto",
|
||||
"Camera move interrupted": "Movimiento de cámara interrumpido",
|
||||
"Scene media timed out": "El contenido multimedia de la escena agotó el tiempo de espera",
|
||||
"Space Missions isolates replay data. Exit the mode to enable {layer}.": "El modo Misiones espaciales aísla los datos de repetición. Sal del modo para activar {layer}.",
|
||||
"Tilted view": "Vista inclinada",
|
||||
"Straight-down view": "Vista cenital",
|
||||
"North up": "Norte arriba",
|
||||
"Exit cockpit to fly to a camera": "Sal de la cabina para volar a una cámara",
|
||||
"Exit cockpit to fly to a location": "Sal de la cabina para volar a una ubicación",
|
||||
"Exit cockpit to fly to a photo": "Sal de la cabina para volar a una foto",
|
||||
"Exit cockpit to fly to a shared view": "Sal de la cabina para volar a una vista compartida",
|
||||
"Exit cockpit to fly to a target": "Sal de la cabina para volar a un objetivo",
|
||||
"Exit cockpit to fly to a vessel": "Sal de la cabina para volar a un buque",
|
||||
"Exit cockpit to fly to a fire": "Sal de la cabina para volar a un incendio",
|
||||
"Exit cockpit to fly to a route": "Sal de la cabina para volar a una ruta",
|
||||
"Exit cockpit to fly to a file": "Sal de la cabina para volar a un archivo",
|
||||
"Exit cockpit to fly to a scene": "Sal de la cabina para volar a una escena",
|
||||
"Exit cockpit to fly to a {noun}": "Sal de la cabina para volar a {noun}",
|
||||
"Go to expanded Radio section": "Ir a la sección de Radio expandida",
|
||||
"Expand Radio section in Context": "Expandir la sección de Radio en Contexto",
|
||||
"Double-click the header to snap the panel back": "Haz doble clic en el encabezado para devolver el panel a su sitio",
|
||||
"New scene name": "Nombre de la nueva escena",
|
||||
"Delete scene \"{scene}\" and all shots?": "¿Eliminar la escena \"{scene}\" y todos sus planos?",
|
||||
"Detailed outline unavailable": "Contorno detallado no disponible",
|
||||
"Provider Settings does not answer proxied requests": "Ajustes de proveedores no responde a peticiones a través de un proxy",
|
||||
"Provider Settings is disabled while sharing is enabled": "El panel \"Ajustes de proveedores\" está desactivado mientras el uso compartido esté activado",
|
||||
"Provider Settings answers only the machine running the server": "Ajustes de proveedores solo responde al equipo que ejecuta el servidor",
|
||||
"Provider Settings answers only local hostnames": "Ajustes de proveedores solo responde a nombres de host locales",
|
||||
"Provider Settings requires an exact local Origin": "Ajustes de proveedores requiere un Origin local exacto",
|
||||
"Unrecognized Origin refused": "Origin no reconocido: petición rechazada",
|
||||
"Cross-origin requests are refused": "Se rechazan las peticiones de otro origen",
|
||||
"Content-Type must be application/json": "Content-Type debe ser application/json",
|
||||
"Method not allowed": "Método no permitido",
|
||||
"Request too large": "Petición demasiado grande",
|
||||
"Invalid JSON": "JSON no válido",
|
||||
"Body must be a JSON object of {ENV_VAR: value}": "El cuerpo debe ser un objeto JSON de {ENV_VAR: value}",
|
||||
"No keys provided": "No se envió ninguna clave",
|
||||
"At most {limit} keys per save": "Como máximo {limit} claves por guardado",
|
||||
"Unknown key: {name}": "Clave desconocida: {name}",
|
||||
"{name} must be a string": "{name}: el valor debe ser una cadena de texto",
|
||||
"{name} is empty": "{name}: el valor está vacío",
|
||||
"{name} is longer than any real key ({limit} max)": "{name}: el valor es más largo que cualquier clave real (máximo {limit})",
|
||||
"{name} may only contain printable characters with no spaces": "{name}: solo se admiten caracteres imprimibles, sin espacios",
|
||||
"{name} contains a character that is not valid in a key (#, quotes, $, \\, or backtick)": "{name}: hay un carácter no válido en una clave (#, comillas, $, \\ o acento grave)",
|
||||
"{name} is configured outside Provider Settings and can only be changed where it was set": "{name} se configuró fuera de Ajustes de proveedores y solo se puede cambiar donde se definió",
|
||||
"The key was not saved: could not restrict the credential file to your account; nothing was saved": "La clave no se guardó: no se pudo restringir el archivo de credenciales a tu cuenta; no se guardó nada",
|
||||
"The key was not saved: the existing configuration could not be read, so nothing was changed": "La clave no se guardó: no se pudo leer la configuración existente, así que no se cambió nada",
|
||||
"Could not write the {store} store": "No se pudo escribir en el almacén {store}",
|
||||
"Could not check ChatGPT sign-in. Try again.": "No se pudo comprobar el inicio de sesión de ChatGPT. Inténtalo de nuevo.",
|
||||
"Cloud voice will use OPENAI_API_KEY on the next session.": "La voz en la nube usará OPENAI_API_KEY en la próxima sesión.",
|
||||
"Checking local ChatGPT OAuth sign-in…": "Comprobando el inicio de sesión OAuth local de ChatGPT…",
|
||||
"Could not check ChatGPT sign-in.": "No se pudo comprobar el inicio de sesión de ChatGPT.",
|
||||
"ChatGPT OAuth selected for cloud voice. Your API key stays saved and available.": "OAuth de ChatGPT seleccionado para la voz en la nube. Tu clave de API sigue guardada y disponible.",
|
||||
"ChatGPT sign-in expired. Opening sign-in again…": "El inicio de sesión de ChatGPT caducó. Abriéndolo de nuevo…",
|
||||
"Opening ChatGPT sign-in in your browser…": "Abriendo el inicio de sesión de ChatGPT en tu navegador…",
|
||||
"Could not start ChatGPT sign-in on this machine.": "No se pudo abrir el inicio de sesión de ChatGPT en este equipo.",
|
||||
"Finish ChatGPT sign-in in the browser. Waiting for it to complete…": "Termina de iniciar sesión en ChatGPT en el navegador. Esperando a que se complete…",
|
||||
"ChatGPT sign-in timed out. Click {button} to try again.": "Se agotó el tiempo para iniciar sesión en ChatGPT. Haz clic en \"{button}\" para intentarlo de nuevo.",
|
||||
"ChatGPT sign-in complete. OAuth will be used for the next cloud voice session.": "Sesión de ChatGPT iniciada. Se usará OAuth en la próxima sesión de voz en la nube.",
|
||||
"OAuth check failed: {detail}": "Error al comprobar OAuth: {detail}",
|
||||
"unsaved CCTV calibration": "la calibración de CCTV sin guardar",
|
||||
"the open cockpit": "la cabina abierta",
|
||||
"Switching language reloads the page. This will be lost: {items}. Switch anyway?": "Cambiar de idioma recarga la página. Se perderá lo siguiente: {items}. ¿Cambiar de todos modos?",
|
||||
"Wind upstream unavailable": "Origen de datos de viento no disponible",
|
||||
"WebUSB needs desktop Chrome or Edge": "WebUSB requiere Chrome o Edge de escritorio",
|
||||
"connect a receiver in Radio": "conecta un receptor en Radio",
|
||||
"receiver is in FM mode": "el receptor está en modo FM",
|
||||
"Cyclone advisories unavailable": "Avisos de ciclones no disponibles",
|
||||
"Cyclone data unavailable": "Datos de ciclones no disponibles",
|
||||
"Drag to size the box": "Arrastra para dar tamaño al recuadro",
|
||||
"Press on the ground, not the sky": "Pulsa sobre el suelo, no sobre el cielo",
|
||||
"Point the camera at the ground to use the view": "Apunta la cámara al suelo para usar la vista",
|
||||
"That point cannot anchor a box": "Ese punto no puede anclar un recuadro",
|
||||
"Box edges must be finite": "Coordenadas del recuadro no válidas",
|
||||
"Longitudes must be within ±180°": "Las longitudes deben estar dentro de ±180°",
|
||||
"Select one side of the dateline": "No cruces la línea de cambio de fecha",
|
||||
"Box must enclose an area": "El recuadro debe abarcar un área",
|
||||
"Imagery stops at ±{limit}° latitude": "Las imágenes llegan hasta ±{limit}° de latitud",
|
||||
"{owner} is using the pointer — close it first": "Puntero en uso: {owner}. Ciérralo primero",
|
||||
"OpenSky OAuth client missing/invalid": "Cliente OAuth de OpenSky ausente o no válido",
|
||||
"OpenSky OAuth rejected credentials": "OpenSky OAuth rechazó las credenciales",
|
||||
"OpenSky username/password rejected": "OpenSky rechazó el usuario o la contraseña",
|
||||
"OpenSky auth missing": "Falta la autenticación de OpenSky",
|
||||
"OpenSky auth required": "OpenSky requiere autenticación",
|
||||
"OpenSky auth invalid": "Autenticación de OpenSky no válida",
|
||||
"OpenSky auth failed": "Falló la autenticación de OpenSky",
|
||||
"Source snapshot {minutes} min old": "La instantánea de la fuente tiene {minutes} min",
|
||||
"{source} rate limited (anonymous)": "{source}: límite de peticiones (anónimo)",
|
||||
"{source} rate limited": "{source}: límite de peticiones",
|
||||
"{source} HTTP {status}": "{source}: HTTP {status}",
|
||||
"{source} network error": "{source}: error de red",
|
||||
"Malformed {source} aircraft rows": "{source}: filas de aeronaves mal formadas",
|
||||
"Malformed {source} response": "{source}: respuesta mal formada",
|
||||
"Source snapshot time unavailable": "Hora de la instantánea de la fuente no disponible",
|
||||
"Live data unavailable": "Datos en vivo no disponibles",
|
||||
"{n} aircraft": {
|
||||
"one": "{n} aeronave",
|
||||
"many": "{n} de aeronaves",
|
||||
"other": "{n} aeronaves"
|
||||
},
|
||||
"{n} military aircraft": {
|
||||
"one": "{n} aeronave militar",
|
||||
"many": "{n} de aeronaves militares",
|
||||
"other": "{n} aeronaves militares"
|
||||
},
|
||||
"{n} ships": {
|
||||
"one": "{n} buque",
|
||||
"many": "{n} de buques",
|
||||
"other": "{n} buques"
|
||||
},
|
||||
"{n} satellites": {
|
||||
"one": "{n} satélite",
|
||||
"many": "{n} de satélites",
|
||||
"other": "{n} satélites"
|
||||
},
|
||||
"{n} launches": {
|
||||
"one": "{n} lanzamiento",
|
||||
"many": "{n} de lanzamientos",
|
||||
"other": "{n} lanzamientos"
|
||||
},
|
||||
"{n} earthquakes": {
|
||||
"one": "{n} terremoto",
|
||||
"many": "{n} de terremotos",
|
||||
"other": "{n} terremotos"
|
||||
},
|
||||
"{n} fires": {
|
||||
"one": "{n} incendio",
|
||||
"many": "{n} de incendios",
|
||||
"other": "{n} incendios"
|
||||
},
|
||||
"{n} fire perimeters": {
|
||||
"one": "{n} perímetro de incendio",
|
||||
"many": "{n} de perímetros de incendio",
|
||||
"other": "{n} perímetros de incendio"
|
||||
},
|
||||
"{n} cyclones": {
|
||||
"one": "{n} ciclón",
|
||||
"many": "{n} de ciclones",
|
||||
"other": "{n} ciclones"
|
||||
},
|
||||
"{n} buses": {
|
||||
"one": "{n} autobús",
|
||||
"many": "{n} de autobuses",
|
||||
"other": "{n} autobuses"
|
||||
},
|
||||
"{n} bike share": {
|
||||
"one": "{n} estación de bicicletas compartidas",
|
||||
"many": "{n} de estaciones de bicicletas compartidas",
|
||||
"other": "{n} estaciones de bicicletas compartidas"
|
||||
},
|
||||
"{n} flock cameras": {
|
||||
"one": "{n} cámara ALPR",
|
||||
"many": "{n} de cámaras ALPR",
|
||||
"other": "{n} cámaras ALPR"
|
||||
},
|
||||
"{n} military bases": {
|
||||
"one": "{n} base militar",
|
||||
"many": "{n} de bases militares",
|
||||
"other": "{n} bases militares"
|
||||
},
|
||||
"{n} datacenters": {
|
||||
"one": "{n} centro de datos",
|
||||
"many": "{n} de centros de datos",
|
||||
"other": "{n} centros de datos"
|
||||
},
|
||||
"{n} dams": {
|
||||
"one": "{n} presa",
|
||||
"many": "{n} de presas",
|
||||
"other": "{n} presas"
|
||||
},
|
||||
"{n} results": {
|
||||
"one": "{n} resultado",
|
||||
"many": "{n} de resultados",
|
||||
"other": "{n} resultados"
|
||||
},
|
||||
"anywhere in the loaded data": "en todos los datos cargados",
|
||||
"in view": "a la vista",
|
||||
"within {radius} of the view centre": "en un radio de {radius} del centro de la vista",
|
||||
"within {radius} of {place} (Contacts window)": "en un radio de {radius} de {place} (ventana de Contactos)",
|
||||
"within {radius} of {place}": "en un radio de {radius} de {place}",
|
||||
"within {radius}": "en un radio de {radius}",
|
||||
"over {region}": "en {region}",
|
||||
"At least {items}": "Al menos {items}",
|
||||
"{items} {scope}": "{items} {scope}",
|
||||
"last 24 h": "últimas 24 h",
|
||||
"past 30 d and upcoming": "últimos 30 d y próximos",
|
||||
"the flights layer loads by viewport": "la capa de vuelos se carga según la vista",
|
||||
"distance is ground distance, not overhead": "la distancia es sobre el terreno hasta el punto subsatélite, no hasta el satélite",
|
||||
"the count is a lower bound because at least one layer reached its retained limit.": "el recuento es un mínimo porque al menos una capa alcanzó su límite de retención.",
|
||||
"{label} loading": "{label}: cargando",
|
||||
"{label} error": "{label}: error",
|
||||
"counted the first {returned} loaded records": "se contaron los primeros {returned} registros cargados",
|
||||
"counted {returned} of {total} loaded records": "se contaron {returned} de {total} registros cargados",
|
||||
"{layers} just turned on — still loading, counts will rise": "{layers}: recién activado — aún se está cargando, los recuentos subirán",
|
||||
"Contacts panel snapshot at {time} — the same retained cohort supplies the card, speech, and follow-up memory": "Instantánea del panel Contactos ({time}) — el mismo conjunto retenido alimenta la tarjeta, la voz y la memoria de seguimiento",
|
||||
"Contacts panel snapshot — the same retained cohort supplies the card, speech, and follow-up memory": "Instantánea del panel Contactos — el mismo conjunto retenido alimenta la tarjeta, la voz y la memoria de seguimiento",
|
||||
"Not answered: {layers}": "Sin respuesta: {layers}",
|
||||
"The flights layer is not enabled": "La capa de vuelos no está activada",
|
||||
"The military layer is not enabled": "La capa de vuelos militares no está activada",
|
||||
"The satellites layer is not enabled": "La capa de satélites no está activada",
|
||||
"The ais-live-vessels layer is not enabled": "La capa de buques AIS no está activada",
|
||||
"Could not enable the requested layer": "No se pudo activar la capa solicitada",
|
||||
"Could not disable the requested layer": "No se pudo desactivar la capa solicitada",
|
||||
"Radio layer unavailable": "La capa de radio no está disponible",
|
||||
"Camera navigation is unavailable in the current view": "La navegación de la cámara no está disponible en la vista actual",
|
||||
"Camera navigation policy unavailable": "La política de navegación de la cámara no está disponible",
|
||||
"Nearest-aircraft selection supports Flights or Military Flights only": "La selección de la aeronave más cercana solo funciona con vuelos o vuelos militares",
|
||||
"Nearest-aircraft selection needs a preset, place name, or latitude and longitude": "Para seleccionar la aeronave más cercana se necesita un preajuste, un nombre de lugar o la latitud y la longitud",
|
||||
"Could not arrive at the requested place": "No se pudo llegar al lugar solicitado",
|
||||
"The nearest airborne aircraft could not be selected": "No se pudo seleccionar la aeronave en vuelo más cercana",
|
||||
"No flights within {radius} of the current view": "No hay vuelos en un radio de {radius} de la vista actual",
|
||||
"No military within {radius} of the current view": "No hay vuelos militares en un radio de {radius} de la vista actual",
|
||||
"No satellites within {radius} of the current view": "No hay satélites en un radio de {radius} de la vista actual",
|
||||
"No vessels within {radius} of the current view": "No hay buques en un radio de {radius} de la vista actual",
|
||||
"{layer} is enabled on the {source} feed, but no airborne aircraft is loaded in the {place} view yet": "La capa {layer} está activada con la fuente {source}, pero aún no hay aeronaves en vuelo cargadas en la vista de {place}",
|
||||
"{layer} is enabled, but no airborne aircraft is loaded in the {place} view yet": "La capa {layer} está activada, pero aún no hay aeronaves en vuelo cargadas en la vista de {place}",
|
||||
"{layer} is enabled, but its aircraft feed is unavailable": "La capa {layer} está activada, pero su fuente de aeronaves no está disponible",
|
||||
"{layer} is enabled, but its destination refresh did not complete": "La capa {layer} está activada, pero la actualización en el destino no terminó",
|
||||
"{layer} is enabled, but {source} is unavailable": "La capa {layer} está activada, pero {source} no está disponible",
|
||||
"Could not arrive at {place}": "No se pudo llegar a {place}",
|
||||
"{name} is no longer loaded": "{name} ya no está cargado",
|
||||
"Unknown target layer: {target}": "Capa de destino desconocida: {target}",
|
||||
"Layer": "Capa",
|
||||
"there": "allí",
|
||||
"Frame flights": "Encuadrar aeronaves",
|
||||
"Frame military": "Encuadrar aeronaves militares",
|
||||
"Frame vessels": "Encuadrar buques",
|
||||
"Fly to Location": "Volar al lugar",
|
||||
"Nearest aircraft to Here": "Aeronave más cercana aquí",
|
||||
"Estimate is incomplete — a response was still in flight when the session ended, so its usage was never reported.": "La estimación está incompleta: una respuesta seguía en curso cuando terminó la sesión, así que su uso nunca se informó.",
|
||||
"STANDARD": "ESTÁNDAR",
|
||||
"vehicle motion|Stopped": "Detenido",
|
||||
"ACQUIRING FRAME": "OBTENIENDO FOTOGRAMA",
|
||||
"Camera source temporarily unavailable": "Fuente de cámaras no disponible temporalmente",
|
||||
"Perimeter source unavailable": "Fuente de perímetros no disponible",
|
||||
"Imagery catalog unavailable": "Catálogo de imágenes no disponible",
|
||||
"Earthquake source unavailable": "Fuente de terremotos no disponible",
|
||||
"TeleGeography load failed": "Falló la carga de TeleGeography",
|
||||
"Wind request timed out": "La petición del viento agotó el tiempo de espera",
|
||||
"Malformed wind manifest": "Manifiesto del viento mal formado",
|
||||
"Malformed wind grid": "Malla del viento mal formada",
|
||||
"Unknown wind model": "Modelo de viento desconocido",
|
||||
"Unknown weather overlay": "Capa de clima desconocida",
|
||||
"Temperature field unavailable": "Campo de temperatura no disponible",
|
||||
"Mean sea level pressure field unavailable": "Campo de presión a nivel del mar no disponible",
|
||||
"KartaView answered HTTP {status}": "KartaView respondió HTTP {status}",
|
||||
"KartaView refused the request (code {code})": "KartaView rechazó la petición (código {code})",
|
||||
"Stopped by voice": "Detenido por voz",
|
||||
"drawings on the map": "los dibujos del mapa",
|
||||
"imported map files": "los archivos importados en el mapa",
|
||||
"the active voice session": "la sesión de voz activa",
|
||||
"Light aircraft": "Avioneta",
|
||||
"Glider": "Planeador",
|
||||
"Turboprop": "Turbohélice",
|
||||
"Airliner": "Avión comercial",
|
||||
"Widebody": "Avión de fuselaje ancho",
|
||||
"Four-engine jet": "Reactor cuatrimotor",
|
||||
"Helicopter": "Helicóptero",
|
||||
"Fast jet": "Avión de alto rendimiento",
|
||||
"Business jet": "Reactor ejecutivo",
|
||||
"Drone": "Dron",
|
||||
"fire cause|Human": "humana",
|
||||
"fire cause|Natural": "natural",
|
||||
"fire cause|Undetermined": "indeterminada",
|
||||
"fire cause|Unknown": "desconocida",
|
||||
"fire behavior|Minimal": "actividad mínima",
|
||||
"fire behavior|Moderate": "actividad moderada",
|
||||
"fire behavior|Active": "actividad alta",
|
||||
"fire behavior|Extreme": "actividad extrema",
|
||||
"Type {level} Incident": "incidente de tipo {level}",
|
||||
"${amount}K": "{amount} mil $",
|
||||
"${amount}M": "{amount} M$",
|
||||
"${amount}B": "{amount} mil M$",
|
||||
"{speed} kn": "{speed} kn",
|
||||
"1090 MHz": "1090 MHz",
|
||||
"978 MHz UAT": "978 MHz UAT",
|
||||
"feed status|live": "en vivo",
|
||||
"feed status|stale": "desactualizada",
|
||||
"feed status|unreachable": "inaccesible",
|
||||
"feed status|invalid": "no válida",
|
||||
"Vector tile metadata unavailable": "Metadatos de teselas vectoriales no disponibles",
|
||||
"Vector tiles unavailable": "Teselas vectoriales no disponibles",
|
||||
"Vector tiles unavailable (HTTP {status})": "Teselas vectoriales no disponibles (HTTP {status})",
|
||||
"Vector tile request timed out": "Las teselas vectoriales agotaron el tiempo de espera",
|
||||
"Zoom in for vector tile coverage": "Acércate para cargar las teselas vectoriales",
|
||||
"Camera coverage unavailable": "Cobertura de cámaras no disponible",
|
||||
"Hybrid": "Híbrido",
|
||||
"TomTom roads only, each with its live flow": "Solo vías de TomTom, cada una con su flujo en vivo",
|
||||
"TomTom roads only, each with its live flow (needs a TomTom key)": "Solo vías de TomTom, cada una con su flujo en vivo (necesita una clave de TomTom)",
|
||||
"OpenStreetMap roads, with TomTom flow matched when available": "Vías de OpenStreetMap, con el flujo de TomTom emparejado cuando está disponible",
|
||||
"TomTom roads first; OpenStreetMap fills the rest, simulated": "Primero las vías de TomTom; OpenStreetMap completa el resto con flujo simulado",
|
||||
"TomTom roads first; OpenStreetMap fills the rest, simulated (needs a TomTom key)": "Primero las vías de TomTom; OpenStreetMap completa el resto con flujo simulado (necesita una clave de TomTom)",
|
||||
"Austin": "Austin",
|
||||
"San Francisco": "San Francisco",
|
||||
"New York": "Nueva York",
|
||||
"Tokyo": "Tokio",
|
||||
"London": "Londres",
|
||||
"Paris": "París",
|
||||
"Dubai": "Dubái",
|
||||
"Washington DC": "Washington D. C.",
|
||||
"Tallinn": "Tallin",
|
||||
"Los Angeles": "Los Ángeles",
|
||||
"Data attribution": "Atribución de datos",
|
||||
"Data provided by:": "Datos proporcionados por:",
|
||||
"Exit cockpit to view weather coverage": "Sal de la cabina para ver la cobertura de la capa meteorológica",
|
||||
"Exit cockpit to fly to a cyclone": "Sal de la cabina para volar a un ciclón",
|
||||
"the title of scene {scene} is empty or longer than {max} characters": "el título de la escena {scene} está vacío o supera los {max} caracteres",
|
||||
"the title of shot {shot} in scene {scene} is empty or longer than {max} characters": "el título del plano {shot} de la escena {scene} está vacío o supera los {max} caracteres",
|
||||
"the project is larger than 5 MiB": "el proyecto pesa más de 5 MiB",
|
||||
"the project has too many shots": "el proyecto tiene demasiados planos",
|
||||
"the scene data failed validation": "los datos de la escena no superaron la validación",
|
||||
"Layer on": "Capa: activada",
|
||||
"Layer off": "Capa: desactivada",
|
||||
"Zoom in": "Acercar",
|
||||
"Zoom out": "Alejar",
|
||||
"Zoom out to globe": "Alejar hasta el globo",
|
||||
"Open data layers": "Abrir las capas de datos",
|
||||
"Open or close a panel": "Abrir o cerrar un panel",
|
||||
"Change context mode": "Cambiar el modo de contexto",
|
||||
"Control cockpit": "Controlar la cabina",
|
||||
"Change visual style": "Cambiar el estilo visual",
|
||||
"Adjust HUD": "Ajustar el HUD",
|
||||
"Adjust cyber sonar": "Ajustar el sonar Ciber",
|
||||
"Adjust detection overlay": "Ajustar la superposición de detección",
|
||||
"Change map stack": "Cambiar el conjunto de mapas",
|
||||
"Adjust post-processing": "Ajustar el posprocesado",
|
||||
"Control scene": "Controlar la escena",
|
||||
"Control CCTV": "Controlar las cámaras CCTV",
|
||||
"Control radio": "Controlar la radio",
|
||||
"Track contact": "Rastrear el contacto",
|
||||
"Stop tracking": "Detener el rastreo",
|
||||
"Clear marks": "Borrar las marcas",
|
||||
"Move camera": "Mover la cámara",
|
||||
"Fly route": "Recorrer la ruta",
|
||||
"Analyst query": "Consulta analítica",
|
||||
"Next ISS pass": "Próximo paso de la EEI",
|
||||
"Next satellite pass": "Próximo paso del satélite",
|
||||
"Action": "Acción"
|
||||
}
|
||||
+578
-4
@@ -857,7 +857,7 @@
|
||||
"Voice session could not be started.": "Не удалось запустить голосовую сессию.",
|
||||
"CONNECTING": "ПОДКЛЮЧЕНИЕ",
|
||||
"ERROR": "ОШИБКА",
|
||||
"{tier} applies next session": "{tier} вступит в силу со следующей сессии",
|
||||
"{tier} applies next session": "{tier} — со следующей сессии",
|
||||
"Session ended — cost cap {cost}": "Сессия завершена — лимит стоимости {cost}",
|
||||
"Next session: {pendingId} — this session stays on {modelId}": "Следующая сессия: {pendingId} — текущая остаётся на {modelId}",
|
||||
"Voice model: {pendingId} — click to switch to {target}; applies next session": "Голосовая модель: {pendingId} — нажмите, чтобы переключиться на {target}; вступит в силу со следующей сессии",
|
||||
@@ -1001,7 +1001,7 @@
|
||||
"Coverage colours": "Цвета покрытия",
|
||||
"That shape already has {count} points — finish it or press Backspace.": "В этой фигуре уже точек: {count} — завершите её или нажмите Backspace.",
|
||||
"Could not place the shape: {error}": "Не удалось разместить фигуру: {error}",
|
||||
"{owner} is using the pointer — close it first.": "Указатель занят инструментом {owner} — сначала закройте его.",
|
||||
"{owner} is using the pointer — close it first.": "Указатель занят: «{owner}». Закройте его.",
|
||||
"{label} · Detailed outline unavailable": "{label} · Подробный контур недоступен",
|
||||
"Witness source": "Свидетельство очевидца",
|
||||
"{provider} · EMBEDDED SOURCE": "{provider} · ВСТРОЕННЫЙ ИСТОЧНИК",
|
||||
@@ -1790,7 +1790,7 @@
|
||||
"Mark {targets} +{more}": "Отметить {targets} +{more}",
|
||||
"Mark {targets}": "Отметить {targets}",
|
||||
"Fly to {place}": "Перелёт: {place}",
|
||||
"Nearest aircraft to {place}": "Ближайшие самолёты: {place}",
|
||||
"Nearest aircraft to {place}": "Ближайший самолёт: {place}",
|
||||
"Read the view": "Осмотреть видимую область",
|
||||
"Check view state": "Проверить состояние вида",
|
||||
"Frame {target}": "Показать в кадре: {target}",
|
||||
@@ -2244,5 +2244,579 @@
|
||||
"Show your own GeoJSON, KML, KMZ or GPX files on the globe (or drop them onto it). File bytes stay on this device; overlays can appear in map screenshots shared with voice.": "Показать на глобусе свои файлы GeoJSON, KML, KMZ или GPX (или перетащите их на глобус). Содержимое файлов остаётся на этом устройстве; наложения могут попасть в снимки карты, которые передаются голосовому ассистенту.",
|
||||
"Import": "Импорт",
|
||||
"Choose GeoJSON, KML, KMZ or GPX files": "Выберите файлы GeoJSON, KML, KMZ или GPX",
|
||||
"Imported files": "Импортированные файлы"
|
||||
"Imported files": "Импортированные файлы",
|
||||
"DOCKED · {name}": "СТЫКОВКА · {name}",
|
||||
"DOCKED · {name} · +{extra}": "СТЫКОВКА · {name} · +{extra}",
|
||||
"loading coverage...": "загрузка покрытия...",
|
||||
"live feed unavailable": "поток в реальном времени недоступен",
|
||||
"GBFS fetch error": "ошибка загрузки GBFS",
|
||||
"GBFS status update failed": "не удалось обновить статус GBFS",
|
||||
"Open the Street Level panel to view imagery": "Откройте панель «Уличные снимки», чтобы их посмотреть",
|
||||
"Image could not be opened": "Не удалось открыть снимок",
|
||||
"Mapillary rejected MAPILLARY_CLIENT_TOKEN — replace it in Provider Settings": "Mapillary отклонил MAPILLARY_CLIENT_TOKEN — замените его в «Настройках провайдеров»",
|
||||
"Coverage requests are being rate-limited — they resume on their own": "Запросы покрытия ограничены по частоте — они возобновятся сами",
|
||||
"No street-level imagery within {radius} m of that point": "В радиусе {radius} м от этой точки уличных снимков нет",
|
||||
"No street-level imagery within {radius} m of the view centre": "В радиусе {radius} м от центра вида уличных снимков нет",
|
||||
"Aim the middle of the view at a street to look for imagery there": "Наведите центр вида на улицу, чтобы поискать там снимки",
|
||||
"Street View has no imagery for this place": "В Просмотре улиц нет снимков этого места",
|
||||
"GOOGLE_MAPS_API_KEY is not set": "GOOGLE_MAPS_API_KEY не задан",
|
||||
"The Google Maps JavaScript API could not load": "Не удалось загрузить Google Maps JavaScript API",
|
||||
"Weather request timed out": "Истекло время ожидания запроса погоды",
|
||||
"Malformed weather manifest": "Некорректный манифест погоды",
|
||||
"Weather HTTP {status}": "Погода: HTTP {status}",
|
||||
"Weather image HTTP {status}": "Снимок погоды: HTTP {status}",
|
||||
"Weather prefetch unavailable": "Предзагрузка погоды недоступна",
|
||||
"Infrared decode failed": "Не удалось декодировать инфракрасный снимок",
|
||||
"rate limited": "превышен лимит запросов",
|
||||
"routing service is rate limited": "сервис маршрутизации превысил лимит запросов",
|
||||
"route leg too long": "участок маршрута слишком длинный",
|
||||
"route too long": "маршрут слишком длинный",
|
||||
"Routing is rate limited — try again in a moment": "Построение маршрута: превышен лимит запросов — попробуйте ещё раз чуть позже",
|
||||
"{error} — try again in a moment": "{error} — попробуйте ещё раз чуть позже",
|
||||
"Routing unavailable (HTTP {status})": "Построение маршрута недоступно (HTTP {status})",
|
||||
"CelesTrak unreachable": "CelesTrak недоступен",
|
||||
"KartaView is rate-limiting requests — they resume on their own": "KartaView ограничивает частоту запросов — они возобновятся сами",
|
||||
"KartaView’s photos here are archived and cannot be shown": "Фотографии KartaView здесь в архиве, показать их нельзя",
|
||||
"KartaView did not filter this area in time — move the map to ask again": "KartaView не успел отфильтровать эту область — сдвиньте карту, чтобы запросить снова",
|
||||
"None of KartaView’s photos here pass the imagery filter": "Ни одна фотография KartaView здесь не проходит фильтр снимков",
|
||||
"KartaView did not answer in time — try again": "KartaView не ответил вовремя — попробуйте снова",
|
||||
"KartaView could not be reached — check the network": "Не удалось связаться с KartaView — проверьте сеть",
|
||||
"KartaView sent an answer that could not be read": "KartaView прислал ответ, который не удалось прочитать",
|
||||
"KartaView is rate-limiting requests": "KartaView ограничивает частоту запросов",
|
||||
"KartaView is rate-limiting requests — try again in a moment": "KartaView ограничивает частоту запросов — попробуйте ещё раз чуть позже",
|
||||
"KartaView sent too much data": "KartaView прислал слишком много данных",
|
||||
"KartaView has no such photo": "У KartaView нет такой фотографии",
|
||||
"The KartaView photo could not be loaded": "Не удалось загрузить фотографию KartaView",
|
||||
"KartaView has no position for this photo": "У KartaView нет координат этой фотографии",
|
||||
"Map tiles temporarily unavailable": "Тайлы карты временно недоступны",
|
||||
"Mapped names temporarily unavailable": "Названия объектов временно недоступны",
|
||||
"No mapped sites in view": "В кадре нет объектов",
|
||||
"No mapped sites within {km} km of the contact": "Нет объектов в радиусе {km} км от контакта",
|
||||
"{count} mapped sites in view": {
|
||||
"one": "{count} объект в кадре",
|
||||
"few": "{count} объекта в кадре",
|
||||
"many": "{count} объектов в кадре",
|
||||
"other": "{count} объекта в кадре"
|
||||
},
|
||||
"{count} mapped sites within {km} km of the contact": {
|
||||
"one": "{count} объект в радиусе {km} км от контакта",
|
||||
"few": "{count} объекта в радиусе {km} км от контакта",
|
||||
"many": "{count} объектов в радиусе {km} км от контакта",
|
||||
"other": "{count} объекта в радиусе {km} км от контакта"
|
||||
},
|
||||
"Too many mapped sites in view to list them all": "В кадре слишком много объектов, чтобы перечислить все",
|
||||
"Installation context unavailable": "Контекст объектов недоступен",
|
||||
"Focus vessel {name}, MMSI {mmsi}": "Фокус на судне {name}, MMSI {mmsi}",
|
||||
"Open the USGS initial report for the 26 August 2026 Nepal landslide": "Открыть первичное сообщение USGS об оползне в Непале 26 августа 2026 года",
|
||||
"YouTube source": "Источник YouTube",
|
||||
"Facebook source": "Источник Facebook",
|
||||
"Military airfield": "Военный аэродром",
|
||||
"Naval base": "Военно-морская база",
|
||||
"Firing range": "Стрельбище",
|
||||
"Military land": "Военная территория",
|
||||
"Mapped installation": "Объект на карте",
|
||||
"Military area": "Военная зона",
|
||||
"SIMULATED — {reason}": "СИМУЛЯЦИЯ — {reason}",
|
||||
"TomTom key unavailable": "Ключ TomTom недоступен",
|
||||
"TomTom daily budget reached": "Исчерпан дневной лимит TomTom",
|
||||
"TomTom upstream unreachable": "Сервер TomTom не отвечает",
|
||||
"TomTom flow unavailable": "Поток TomTom недоступен",
|
||||
"API key rejected — check AISSTREAM_API_KEY": "Ключ API отклонён — проверьте AISSTREAM_API_KEY",
|
||||
"feed silent {seconds}s — no AIS data": "нет данных AIS от потока {seconds} с",
|
||||
"feed silent — no AIS data": "нет данных AIS от потока",
|
||||
"feed down — retrying slowly (attempt {attempt})": "поток недоступен — повторяем реже (попытка {attempt})",
|
||||
"feed down — retrying slowly": "поток недоступен — повторяем реже",
|
||||
"reconnecting to feed… (attempt {attempt})": "переподключение к потоку… (попытка {attempt})",
|
||||
"reconnecting to feed…": "переподключение к потоку…",
|
||||
"AISSTREAM_API_KEY not set": "AISSTREAM_API_KEY не задан",
|
||||
"live feed unsupported": "поток в реальном времени не поддерживается",
|
||||
"connecting to feed…": "подключение к потоку…",
|
||||
"feed disconnected": "поток отключён",
|
||||
"feed down": "поток недоступен",
|
||||
"feed idle": "поток простаивает",
|
||||
"AIS live load failed": "Не удалось загрузить AIS в реальном времени",
|
||||
"age|{count}m": "{count} мин",
|
||||
"{acres} ac": {
|
||||
"one": "{acres} акр",
|
||||
"few": "{acres} акра",
|
||||
"many": "{acres} акров",
|
||||
"other": "{acres} акра"
|
||||
},
|
||||
"{percent}% contained": "локализован на {percent}%",
|
||||
"containment unknown": "локализация неизвестна",
|
||||
"{cause} cause": "причина: {cause}",
|
||||
"{personnel} personnel": {
|
||||
"one": "{personnel} человек",
|
||||
"few": "{personnel} человека",
|
||||
"many": "{personnel} человек",
|
||||
"other": "{personnel} человека"
|
||||
},
|
||||
"{county} County": "округ {county}",
|
||||
"{cost} to date": "{cost} на сегодня",
|
||||
"discovered {age} ago": "обнаружен {age} назад",
|
||||
"updated {age} ago": "обновлён {age} назад",
|
||||
"part of {complex}": "входит в {complex}",
|
||||
"InciWeb ↗ · click card to open": "InciWeb ↗ · нажмите на карточку, чтобы открыть",
|
||||
"FIRE · {name}": "ПОЖАР · {name}",
|
||||
"Unnamed incident": "Без названия",
|
||||
"Open {title} on InciWeb": "Открыть {title} в InciWeb",
|
||||
"Vehicle {vehicle}": "Борт {vehicle}",
|
||||
"Bus": "Автобус",
|
||||
"Tram": "Трамвай",
|
||||
"Subway": "Метро",
|
||||
"Train": "Поезд",
|
||||
"Ferry": "Паром",
|
||||
"Transit vehicle": "Общественный транспорт",
|
||||
"No recent fix": "Нет свежих координат",
|
||||
"Waiting for update": "Ожидание обновления",
|
||||
"Moving": "В движении",
|
||||
"reported {speed} km/h": "по отчёту {speed} км/ч",
|
||||
"reported hdg {heading}°": "по отчёту курс {heading}°",
|
||||
"Last report: stopped at stop {stop}": "Последний отчёт: стоит на остановке {stop}",
|
||||
"Last report: arriving at stop {stop}": "Последний отчёт: прибывает на остановку {stop}",
|
||||
"Last report: next stop {stop}": "Последний отчёт: следующая остановка {stop}",
|
||||
"occupancy|empty": "пусто",
|
||||
"occupancy|many seats available": "много свободных мест",
|
||||
"occupancy|few seats available": "мало свободных мест",
|
||||
"occupancy|standing room only": "только стоячие места",
|
||||
"occupancy|crushed standing room only": "переполнен, только стоя",
|
||||
"occupancy|full": "мест нет",
|
||||
"occupancy|not accepting passengers": "посадки нет",
|
||||
"occupancy|not boardable": "посадка невозможна",
|
||||
"Shown at street level · depth not in the feed": "Показан на уровне улицы · глубины в потоке нет",
|
||||
"Reported {seconds} s ago": "Отчёт {seconds} с назад",
|
||||
"Reported {minutes} min ago": "Отчёт {minutes} мин назад",
|
||||
"{reported} (feed time)": "{reported} (время потока)",
|
||||
"shown {seconds} s behind": "показ отстаёт на {seconds} с",
|
||||
"Available trail: {minutes} min": "Доступный след: {minutes} мин",
|
||||
"Available trail: {minutes} min (limited)": "Доступный след: {minutes} мин (ограничен)",
|
||||
"UNAVAILABLE · {source} · Roads unavailable": "НЕДОСТУПНО · {source} · Дороги недоступны",
|
||||
"Roads: {source}": "Дороги: {source}",
|
||||
"TomTom roads unavailable while the traffic service is unreachable": "Дороги TomTom недоступны, пока сервис трафика не отвечает",
|
||||
"Hybrid unavailable while the traffic service is unreachable": "Гибридный режим недоступен, пока сервис трафика не отвечает",
|
||||
"TomTom roads need a TomTom key": "Для дорог TomTom нужен ключ TomTom",
|
||||
"Hybrid needs a TomTom key": "Для гибридного режима нужен ключ TomTom",
|
||||
"Local surface still loading": "Поверхность под дорогами ещё загружается",
|
||||
"Partial coverage": "Частичное покрытие",
|
||||
"Detailed roads unavailable": "Нет детальных данных о дорогах",
|
||||
"Reduced detail coverage": "Покрытие с меньшей детализацией",
|
||||
"OpenFreeMap tiles rate-limited": "Тайлы OpenFreeMap: превышен лимит запросов",
|
||||
"OpenFreeMap tiles timed out": "Тайлы OpenFreeMap: истекло время ожидания",
|
||||
"OpenFreeMap tiles unavailable": "Тайлы OpenFreeMap недоступны",
|
||||
"Heard by your receiver": "Принято вашим приёмником",
|
||||
"browser SDR": "SDR в браузере",
|
||||
"decoder feed": "поток декодера",
|
||||
"Your RTL-SDR receiver and decoder feed": "Ваш приёмник RTL-SDR и поток декодера",
|
||||
"Your decoder feed": "Ваш поток декодера",
|
||||
"Your RTL-SDR receiver": "Ваш приёмник RTL-SDR",
|
||||
"NO CALLSIGN": "БЕЗ ПОЗЫВНОГО",
|
||||
"ALT {altitude} FT · GS {speed} KT · TRK {track}": "ВЫС. {altitude} ФТ · ПУТ. СКОР. {speed} УЗ · ПУТЬ {track}",
|
||||
"V/S {rate}": "ВЕРТ. СКОР. {rate}",
|
||||
"V/S {rate} FPM": "ВЕРТ. СКОР. {rate} ФТ/МИН",
|
||||
"POSITION {age}": "ПОЗИЦИЯ {age}",
|
||||
"POSITION {seconds} S AGO": "ПОЗИЦИЯ {seconds} С НАЗАД",
|
||||
"{count} MSGS": {
|
||||
"one": "{count} СООБЩЕНИЕ",
|
||||
"few": "{count} СООБЩЕНИЯ",
|
||||
"many": "{count} СООБЩЕНИЙ",
|
||||
"other": "{count} СООБЩЕНИЯ"
|
||||
},
|
||||
"installation class|BARRACKS": "КАЗАРМЫ",
|
||||
"installation class|TRAINING AREA": "УЧЕБНЫЙ ПОЛИГОН",
|
||||
"Seeking up…": "Поиск вверх…",
|
||||
"Seeking down…": "Поиск вниз…",
|
||||
"Scanning {mhz} MHz…": "Сканирование {mhz} МГц…",
|
||||
"Found {mhz} MHz · {snr} dB": "Найдено {mhz} МГц · {snr} дБ",
|
||||
"Broadcaster stream is unavailable or blocked by the browser.": "Поток вещателя недоступен или заблокирован браузером.",
|
||||
"Playback requires a direct click or tap.": "Чтобы начать воспроизведение, нажмите «Пуск».",
|
||||
"Broadcaster stream could not be started.": "Не удалось запустить поток вещателя.",
|
||||
"Directory refresh degraded; showing the previous station catalog.": "Обновление каталога прошло с перебоями; показан предыдущий каталог станций.",
|
||||
"Radio directory coverage is degraded.": "Каталог радиостанций работает с перебоями.",
|
||||
"Directory refresh failed; showing the previous station catalog.": "Не удалось обновить каталог; показан предыдущий каталог станций.",
|
||||
"Radio directory is temporarily unavailable.": "Каталог радиостанций временно недоступен.",
|
||||
"Decoder feeds: {feeds}": "Потоки декодера: {feeds}",
|
||||
" · read while Local ADS-B is on": " · читаются при включённом слое «Локальный ADS-B»",
|
||||
"idle": "ожидание",
|
||||
"running": "работает",
|
||||
"suspended": "приостановлено",
|
||||
"closed": "закрыто",
|
||||
"Local FM stopped: internet radio started": "Локальный FM остановлен: запущено интернет-радио",
|
||||
"ALPR cameras": "камеры ALPR",
|
||||
"OVERPASS RATE-LIMITED": "OVERPASS: ПРЕВЫШЕН ЛИМИТ ЗАПРОСОВ",
|
||||
"OVERPASS TIMED OUT": "OVERPASS: ИСТЕКЛО ВРЕМЯ ОЖИДАНИЯ",
|
||||
"OVERPASS COULD NOT COMPLETE THE QUERY": "OVERPASS: НЕ УДАЛОСЬ ЗАВЕРШИТЬ ЗАПРОС",
|
||||
"MAP TILES TEMPORARILY UNAVAILABLE": "ТАЙЛЫ КАРТЫ ВРЕМЕННО НЕДОСТУПНЫ",
|
||||
"MAPPED NAMES TEMPORARILY UNAVAILABLE": "НАЗВАНИЯ ОБЪЕКТОВ ВРЕМЕННО НЕДОСТУПНЫ",
|
||||
"Shared flight follow expired": "Срок отслеживания рейса по ссылке истёк",
|
||||
"Shared military flight follow expired": "Срок отслеживания военного рейса по ссылке истёк",
|
||||
"Shared satellite follow expired": "Срок отслеживания спутника по ссылке истёк",
|
||||
"Shared flight could not be restored — feed unavailable": "Не удалось восстановить рейс по ссылке — поток данных недоступен",
|
||||
"Shared military flight could not be restored — feed unavailable": "Не удалось восстановить военный рейс по ссылке — поток данных недоступен",
|
||||
"Shared satellite could not be restored — feed unavailable": "Не удалось восстановить спутник по ссылке — поток данных недоступен",
|
||||
"Shared flight is unavailable": "Рейс по ссылке недоступен",
|
||||
"Shared military flight is unavailable": "Военный рейс по ссылке недоступен",
|
||||
"Shared satellite is unavailable": "Спутник по ссылке недоступен",
|
||||
"Scene not saved — browser storage unavailable": "Сцена не сохранена — хранилище браузера недоступно",
|
||||
"Scene not saved — existing saved project could not be read. Export edits or import a valid file.": "Сцена не сохранена — не удалось прочитать сохранённый проект. Экспортируйте правки или импортируйте корректный файл.",
|
||||
"Saved project could not be read; storage preserved. Import a valid file to resume saving.": "Не удалось прочитать сохранённый проект; хранилище не тронуто. Импортируйте корректный файл, чтобы снова сохранять.",
|
||||
"Scene not saved — {reason}": "Сцена не сохранена — {reason}",
|
||||
"Stopped": "Остановлено",
|
||||
"Stopped (Esc)": "Остановлено (Esc)",
|
||||
"Camera ownership changed": "Управление камерой перехвачено",
|
||||
"Scene layer turned off": "Слой сцены выключен",
|
||||
"Seeking scene clock": "Перемотка сцены",
|
||||
"Importing project": "Импорт проекта",
|
||||
"Camera move interrupted": "Перелёт камеры прерван",
|
||||
"Scene media timed out": "Истекло время ожидания медиа сцены",
|
||||
"Space Missions isolates replay data. Exit the mode to enable {layer}.": "Режим «Космические миссии» изолирует данные воспроизведения. Выйдите из режима, чтобы включить слой «{layer}».",
|
||||
"Tilted view": "Вид с наклоном",
|
||||
"Straight-down view": "Вид сверху",
|
||||
"North up": "Север вверху",
|
||||
"Exit cockpit to fly to a camera": "Выйдите из кабины, чтобы перелететь к камере",
|
||||
"Exit cockpit to fly to a location": "Выйдите из кабины, чтобы перелететь к месту",
|
||||
"Exit cockpit to fly to a photo": "Выйдите из кабины, чтобы перелететь к фото",
|
||||
"Exit cockpit to fly to a shared view": "Выйдите из кабины, чтобы перелететь к виду по ссылке",
|
||||
"Exit cockpit to fly to a target": "Выйдите из кабины, чтобы перелететь к цели",
|
||||
"Exit cockpit to fly to a vessel": "Выйдите из кабины, чтобы перелететь к судну",
|
||||
"Exit cockpit to fly to a fire": "Выйдите из кабины, чтобы перелететь к пожару",
|
||||
"Exit cockpit to fly to a route": "Выйдите из кабины, чтобы перелететь к маршруту",
|
||||
"Exit cockpit to fly to a file": "Выйдите из кабины, чтобы перелететь к файлу",
|
||||
"Exit cockpit to fly to a scene": "Выйдите из кабины, чтобы перелететь к сцене",
|
||||
"Exit cockpit to fly to a {noun}": "Выйдите из кабины, чтобы перелететь: {noun}",
|
||||
"Go to expanded Radio section": "Перейти к развёрнутому разделу радио",
|
||||
"Expand Radio section in Context": "Развернуть раздел «Радио» в панели «Контекст»",
|
||||
"Double-click the header to snap the panel back": "Двойной щелчок по заголовку — вернуть панель на место",
|
||||
"New scene name": "Название новой сцены",
|
||||
"Delete scene \"{scene}\" and all shots?": "Удалить сцену «{scene}» и все кадры?",
|
||||
"Detailed outline unavailable": "Подробный контур недоступен",
|
||||
"Provider Settings does not answer proxied requests": "«Настройки провайдеров» не отвечают на запросы через прокси",
|
||||
"Provider Settings is disabled while sharing is enabled": "«Настройки провайдеров» отключены, пока включён общий доступ",
|
||||
"Provider Settings answers only the machine running the server": "«Настройки провайдеров» отвечают только компьютеру, на котором запущен сервер",
|
||||
"Provider Settings answers only local hostnames": "«Настройки провайдеров» отвечают только на локальные имена хостов",
|
||||
"Provider Settings requires an exact local Origin": "«Настройкам провайдеров» нужен точный локальный Origin",
|
||||
"Unrecognized Origin refused": "Нераспознанный Origin отклонён",
|
||||
"Cross-origin requests are refused": "Запросы с другого источника отклоняются",
|
||||
"Content-Type must be application/json": "Content-Type должен быть application/json",
|
||||
"Method not allowed": "Метод не разрешён",
|
||||
"Request too large": "Слишком большой запрос",
|
||||
"Invalid JSON": "Некорректный JSON",
|
||||
"Body must be a JSON object of {ENV_VAR: value}": "Тело запроса должно быть JSON-объектом вида {ENV_VAR: value}",
|
||||
"No keys provided": "Ключи не переданы",
|
||||
"At most {limit} keys per save": "Ключей за одно сохранение — не больше {limit}",
|
||||
"Unknown key: {name}": "Неизвестный ключ: {name}",
|
||||
"{name} must be a string": "{name}: значение должно быть строкой",
|
||||
"{name} is empty": "{name}: пустое значение",
|
||||
"{name} is longer than any real key ({limit} max)": "{name}: значение длиннее любого настоящего ключа (максимум {limit})",
|
||||
"{name} may only contain printable characters with no spaces": "{name}: допустимы только печатные символы без пробелов",
|
||||
"{name} contains a character that is not valid in a key (#, quotes, $, \\, or backtick)": "{name}: недопустимый для ключа символ (#, кавычки, $, \\ или обратный апостроф)",
|
||||
"{name} is configured outside Provider Settings and can only be changed where it was set": "{name}: задан вне «Настроек провайдеров», изменить его можно только там, где его задали",
|
||||
"The key was not saved: could not restrict the credential file to your account; nothing was saved": "Ключ не сохранён: не удалось закрыть файл учётных данных от всех, кроме вашей учётной записи; ничего не сохранено",
|
||||
"The key was not saved: the existing configuration could not be read, so nothing was changed": "Ключ не сохранён: не удалось прочитать текущую конфигурацию, поэтому ничего не изменено",
|
||||
"Could not write the {store} store": "Не удалось записать хранилище {store}",
|
||||
"Could not check ChatGPT sign-in. Try again.": "Не удалось проверить вход в ChatGPT. Попробуйте ещё раз.",
|
||||
"Cloud voice will use OPENAI_API_KEY on the next session.": "Следующая облачная голосовая сессия будет использовать OPENAI_API_KEY.",
|
||||
"Checking local ChatGPT OAuth sign-in…": "Проверка локального входа в ChatGPT через OAuth…",
|
||||
"Could not check ChatGPT sign-in.": "Не удалось проверить вход в ChatGPT.",
|
||||
"ChatGPT OAuth selected for cloud voice. Your API key stays saved and available.": "Для облачного голоса выбран ChatGPT OAuth. Ваш ключ API остаётся сохранённым и доступным.",
|
||||
"ChatGPT sign-in expired. Opening sign-in again…": "Сеанс ChatGPT истёк. Открываем вход заново…",
|
||||
"Opening ChatGPT sign-in in your browser…": "Открываем вход в ChatGPT в браузере…",
|
||||
"Could not start ChatGPT sign-in on this machine.": "Не удалось начать вход в ChatGPT на этом компьютере.",
|
||||
"Finish ChatGPT sign-in in the browser. Waiting for it to complete…": "Завершите вход в ChatGPT в браузере. Ожидание завершения…",
|
||||
"ChatGPT sign-in timed out. Click {button} to try again.": "Время на вход в ChatGPT истекло. Нажмите «{button}», чтобы попробовать ещё раз.",
|
||||
"ChatGPT sign-in complete. OAuth will be used for the next cloud voice session.": "Вход в ChatGPT выполнен. В следующей облачной голосовой сессии будет использоваться OAuth.",
|
||||
"OAuth check failed: {detail}": "Не удалось проверить OAuth: {detail}",
|
||||
"unsaved CCTV calibration": "несохранённая калибровка CCTV",
|
||||
"the open cockpit": "открытая кабина",
|
||||
"Switching language reloads the page. This will be lost: {items}. Switch anyway?": "Смена языка перезагрузит страницу. Будет потеряно: {items}. Всё равно сменить язык?",
|
||||
"Wind upstream unavailable": "Источник данных о ветре недоступен",
|
||||
"WebUSB needs desktop Chrome or Edge": "Для WebUSB нужен настольный Chrome или Edge",
|
||||
"connect a receiver in Radio": "подключите приёмник в разделе «Радио»",
|
||||
"receiver is in FM mode": "приёмник в режиме FM",
|
||||
"Cyclone advisories unavailable": "Сводки о циклонах недоступны",
|
||||
"Cyclone data unavailable": "Данные о циклонах недоступны",
|
||||
"Drag to size the box": "Потяните, чтобы задать размер области",
|
||||
"Press on the ground, not the sky": "Нажмите на землю, а не на небо",
|
||||
"Point the camera at the ground to use the view": "Наведите камеру на землю, чтобы использовать вид",
|
||||
"That point cannot anchor a box": "От этой точки нельзя построить область",
|
||||
"Box edges must be finite": "Некорректные координаты области",
|
||||
"Longitudes must be within ±180°": "Долгота должна быть в пределах ±180°",
|
||||
"Select one side of the dateline": "Не пересекайте линию перемены дат",
|
||||
"Box must enclose an area": "Область должна иметь площадь",
|
||||
"Imagery stops at ±{limit}° latitude": "Снимки есть только до ±{limit}° широты",
|
||||
"{owner} is using the pointer — close it first": "Указатель занят: «{owner}». Закройте его",
|
||||
"OpenSky OAuth client missing/invalid": "Клиент OAuth OpenSky отсутствует или недействителен",
|
||||
"OpenSky OAuth rejected credentials": "OpenSky OAuth отклонил учётные данные",
|
||||
"OpenSky username/password rejected": "OpenSky отклонил имя пользователя или пароль",
|
||||
"OpenSky auth missing": "Нет данных для входа в OpenSky",
|
||||
"OpenSky auth required": "OpenSky требует входа",
|
||||
"OpenSky auth invalid": "Неверные данные для входа в OpenSky",
|
||||
"OpenSky auth failed": "Не удалось войти в OpenSky",
|
||||
"Source snapshot {minutes} min old": "Возраст снимка источника: {minutes} мин",
|
||||
"{source} rate limited (anonymous)": "{source}: лимит запросов (анонимно)",
|
||||
"{source} rate limited": "{source}: лимит запросов",
|
||||
"{source} HTTP {status}": "{source}: HTTP {status}",
|
||||
"{source} network error": "{source}: ошибка сети",
|
||||
"Malformed {source} aircraft rows": "{source}: некорректные записи о самолётах",
|
||||
"Malformed {source} response": "{source}: некорректный ответ",
|
||||
"Source snapshot time unavailable": "Время снимка источника неизвестно",
|
||||
"Live data unavailable": "Данные в реальном времени недоступны",
|
||||
"{n} aircraft": {
|
||||
"one": "{n} самолёт",
|
||||
"few": "{n} самолёта",
|
||||
"many": "{n} самолётов",
|
||||
"other": "{n} самолёта"
|
||||
},
|
||||
"{n} military aircraft": {
|
||||
"one": "{n} военный самолёт",
|
||||
"few": "{n} военных самолёта",
|
||||
"many": "{n} военных самолётов",
|
||||
"other": "{n} военного самолёта"
|
||||
},
|
||||
"{n} ships": {
|
||||
"one": "{n} судно",
|
||||
"few": "{n} судна",
|
||||
"many": "{n} судов",
|
||||
"other": "{n} судна"
|
||||
},
|
||||
"{n} satellites": {
|
||||
"one": "{n} спутник",
|
||||
"few": "{n} спутника",
|
||||
"many": "{n} спутников",
|
||||
"other": "{n} спутника"
|
||||
},
|
||||
"{n} launches": {
|
||||
"one": "{n} пуск",
|
||||
"few": "{n} пуска",
|
||||
"many": "{n} пусков",
|
||||
"other": "{n} пуска"
|
||||
},
|
||||
"{n} earthquakes": {
|
||||
"one": "{n} землетрясение",
|
||||
"few": "{n} землетрясения",
|
||||
"many": "{n} землетрясений",
|
||||
"other": "{n} землетрясения"
|
||||
},
|
||||
"{n} fires": {
|
||||
"one": "{n} пожар",
|
||||
"few": "{n} пожара",
|
||||
"many": "{n} пожаров",
|
||||
"other": "{n} пожара"
|
||||
},
|
||||
"{n} fire perimeters": {
|
||||
"one": "{n} контур пожара",
|
||||
"few": "{n} контура пожаров",
|
||||
"many": "{n} контуров пожаров",
|
||||
"other": "{n} контура пожаров"
|
||||
},
|
||||
"{n} cyclones": {
|
||||
"one": "{n} циклон",
|
||||
"few": "{n} циклона",
|
||||
"many": "{n} циклонов",
|
||||
"other": "{n} циклона"
|
||||
},
|
||||
"{n} buses": {
|
||||
"one": "{n} автобус",
|
||||
"few": "{n} автобуса",
|
||||
"many": "{n} автобусов",
|
||||
"other": "{n} автобуса"
|
||||
},
|
||||
"{n} bike share": {
|
||||
"one": "{n} станция проката велосипедов",
|
||||
"few": "{n} станции проката велосипедов",
|
||||
"many": "{n} станций проката велосипедов",
|
||||
"other": "{n} станции проката велосипедов"
|
||||
},
|
||||
"{n} flock cameras": {
|
||||
"one": "{n} камера ALPR",
|
||||
"few": "{n} камеры ALPR",
|
||||
"many": "{n} камер ALPR",
|
||||
"other": "{n} камеры ALPR"
|
||||
},
|
||||
"{n} military bases": {
|
||||
"one": "{n} военная база",
|
||||
"few": "{n} военные базы",
|
||||
"many": "{n} военных баз",
|
||||
"other": "{n} военной базы"
|
||||
},
|
||||
"{n} datacenters": {
|
||||
"one": "{n} дата-центр",
|
||||
"few": "{n} дата-центра",
|
||||
"many": "{n} дата-центров",
|
||||
"other": "{n} дата-центра"
|
||||
},
|
||||
"{n} dams": {
|
||||
"one": "{n} плотина",
|
||||
"few": "{n} плотины",
|
||||
"many": "{n} плотин",
|
||||
"other": "{n} плотины"
|
||||
},
|
||||
"{n} results": {
|
||||
"one": "{n} результат",
|
||||
"few": "{n} результата",
|
||||
"many": "{n} результатов",
|
||||
"other": "{n} результата"
|
||||
},
|
||||
"anywhere in the loaded data": "во всех загруженных данных",
|
||||
"in view": "в кадре",
|
||||
"within {radius} of the view centre": "в радиусе {radius} от центра вида",
|
||||
"within {radius} of {place} (Contacts window)": "в радиусе {radius} — {place} (окно «Контактов»)",
|
||||
"within {radius} of {place}": "в радиусе {radius} — {place}",
|
||||
"within {radius}": "в радиусе {radius}",
|
||||
"over {region}": "в регионе {region}",
|
||||
"At least {items}": "{items} или больше",
|
||||
"{items} {scope}": "{items} {scope}",
|
||||
"last 24 h": "за последние 24 ч",
|
||||
"past 30 d and upcoming": "за последние 30 дн. и предстоящие",
|
||||
"the flights layer loads by viewport": "слой рейсов загружается по области вида",
|
||||
"distance is ground distance, not overhead": "расстояние — по поверхности земли до подспутниковой точки, а не до самого аппарата",
|
||||
"the count is a lower bound because at least one layer reached its retained limit.": "это нижняя граница: хотя бы один слой достиг предела хранимых записей.",
|
||||
"{label} loading": "{label}: загрузка",
|
||||
"{label} error": "{label}: ошибка",
|
||||
"counted the first {returned} loaded records": "учтены только первые загруженные записи: {returned}",
|
||||
"counted {returned} of {total} loaded records": "учтено загруженных записей: {returned} из {total}",
|
||||
"{layers} just turned on — still loading, counts will rise": "{layers}: только что включено — данные ещё загружаются, значения вырастут",
|
||||
"Contacts panel snapshot at {time} — the same retained cohort supplies the card, speech, and follow-up memory": "Снимок панели «Контакты» ({time}) — та же сохранённая выборка используется для карточки, речи и памяти уточняющих вопросов",
|
||||
"Contacts panel snapshot — the same retained cohort supplies the card, speech, and follow-up memory": "Снимок панели «Контакты» — та же сохранённая выборка используется для карточки, речи и памяти уточняющих вопросов",
|
||||
"Not answered: {layers}": "Нет ответа: {layers}",
|
||||
"The flights layer is not enabled": "Слой рейсов не включён",
|
||||
"The military layer is not enabled": "Слой военных рейсов не включён",
|
||||
"The satellites layer is not enabled": "Слой спутников не включён",
|
||||
"The ais-live-vessels layer is not enabled": "Слой судов AIS не включён",
|
||||
"Could not enable the requested layer": "Не удалось включить запрошенный слой",
|
||||
"Could not disable the requested layer": "Не удалось выключить запрошенный слой",
|
||||
"Radio layer unavailable": "Слой радио недоступен",
|
||||
"Camera navigation is unavailable in the current view": "Управление камерой недоступно в текущем виде",
|
||||
"Camera navigation policy unavailable": "Правила навигации камеры недоступны",
|
||||
"Nearest-aircraft selection supports Flights or Military Flights only": "Выбор ближайшего самолёта работает только для рейсов и военных рейсов",
|
||||
"Nearest-aircraft selection needs a preset, place name, or latitude and longitude": "Для выбора ближайшего самолёта нужен пресет, название места или широта и долгота",
|
||||
"Could not arrive at the requested place": "Не удалось долететь до запрошенного места",
|
||||
"The nearest airborne aircraft could not be selected": "Не удалось выбрать ближайший самолёт в воздухе",
|
||||
"No flights within {radius} of the current view": "В радиусе {radius} от текущего вида нет рейсов",
|
||||
"No military within {radius} of the current view": "В радиусе {radius} от текущего вида нет военных рейсов",
|
||||
"No satellites within {radius} of the current view": "В радиусе {radius} от текущего вида нет спутников",
|
||||
"No vessels within {radius} of the current view": "В радиусе {radius} от текущего вида нет судов",
|
||||
"{layer} is enabled on the {source} feed, but no airborne aircraft is loaded in the {place} view yet": "{layer}: включено (поток {source}), но в виде «{place}» пока не загружено ни одного самолёта в воздухе",
|
||||
"{layer} is enabled, but no airborne aircraft is loaded in the {place} view yet": "{layer}: включено, но в виде «{place}» пока не загружено ни одного самолёта в воздухе",
|
||||
"{layer} is enabled, but its aircraft feed is unavailable": "{layer}: включено, но поток данных о самолётах недоступен",
|
||||
"{layer} is enabled, but its destination refresh did not complete": "{layer}: включено, но обновление в точке назначения не завершилось",
|
||||
"{layer} is enabled, but {source} is unavailable": "{layer}: включено, но источник {source} недоступен",
|
||||
"Could not arrive at {place}": "Не удалось долететь: {place}",
|
||||
"{name} is no longer loaded": "{name}: объект больше не загружен",
|
||||
"Unknown target layer: {target}": "Неизвестный слой цели: {target}",
|
||||
"Layer": "Слой",
|
||||
"there": "там",
|
||||
"Frame flights": "Показать самолёты в кадре",
|
||||
"Frame military": "Показать военные самолёты в кадре",
|
||||
"Frame vessels": "Показать суда в кадре",
|
||||
"Fly to Location": "Перелёт к месту",
|
||||
"Nearest aircraft to Here": "Ближайший самолёт здесь",
|
||||
"Estimate is incomplete — a response was still in flight when the session ended, so its usage was never reported.": "Оценка неполная: к моменту завершения сессии один из ответов ещё не был получен, поэтому его использование не учтено.",
|
||||
"STANDARD": "СТАНДАРТ",
|
||||
"vehicle motion|Stopped": "Стоит",
|
||||
"ACQUIRING FRAME": "ПОЛУЧЕНИЕ КАДРА",
|
||||
"Camera source temporarily unavailable": "Источник камер временно недоступен",
|
||||
"Perimeter source unavailable": "Источник контуров пожаров недоступен",
|
||||
"Imagery catalog unavailable": "Каталог снимков недоступен",
|
||||
"Earthquake source unavailable": "Источник данных о землетрясениях недоступен",
|
||||
"TeleGeography load failed": "Не удалось загрузить данные TeleGeography",
|
||||
"Wind request timed out": "Истекло время ожидания запроса ветра",
|
||||
"Malformed wind manifest": "Некорректный манифест ветра",
|
||||
"Malformed wind grid": "Некорректная сетка ветра",
|
||||
"Unknown wind model": "Неизвестная модель ветра",
|
||||
"Unknown weather overlay": "Неизвестный погодный слой",
|
||||
"Temperature field unavailable": "Поле температуры недоступно",
|
||||
"Mean sea level pressure field unavailable": "Поле давления на уровне моря недоступно",
|
||||
"KartaView answered HTTP {status}": "KartaView ответил: HTTP {status}",
|
||||
"KartaView refused the request (code {code})": "KartaView отклонил запрос (код {code})",
|
||||
"Stopped by voice": "Остановлено голосовой командой",
|
||||
"drawings on the map": "рисунки на карте",
|
||||
"imported map files": "импортированные файлы на карте",
|
||||
"the active voice session": "активная голосовая сессия",
|
||||
"Light aircraft": "Лёгкий самолёт",
|
||||
"Glider": "Планёр",
|
||||
"Turboprop": "Турбовинтовой самолёт",
|
||||
"Airliner": "Авиалайнер",
|
||||
"Widebody": "Широкофюзеляжный самолёт",
|
||||
"Four-engine jet": "Четырёхмоторный реактивный самолёт",
|
||||
"Helicopter": "Вертолёт",
|
||||
"Fast jet": "Высокоманёвренный самолёт",
|
||||
"Business jet": "Бизнес-джет",
|
||||
"Drone": "Беспилотник",
|
||||
"fire cause|Human": "деятельность человека",
|
||||
"fire cause|Natural": "природная",
|
||||
"fire cause|Undetermined": "не установлена",
|
||||
"fire cause|Unknown": "неизвестна",
|
||||
"fire behavior|Minimal": "минимальная активность",
|
||||
"fire behavior|Moderate": "умеренная активность",
|
||||
"fire behavior|Active": "высокая активность",
|
||||
"fire behavior|Extreme": "экстремальная активность",
|
||||
"Type {level} Incident": "инцидент {level}-го типа",
|
||||
"${amount}K": "{amount} тыс. $",
|
||||
"${amount}M": "{amount} млн $",
|
||||
"${amount}B": "{amount} млрд $",
|
||||
"{speed} kn": "{speed} уз",
|
||||
"1090 MHz": "1090 МГц",
|
||||
"978 MHz UAT": "978 МГц UAT",
|
||||
"feed status|live": "онлайн",
|
||||
"feed status|stale": "устарел",
|
||||
"feed status|unreachable": "недоступен",
|
||||
"feed status|invalid": "некорректный",
|
||||
"Vector tile metadata unavailable": "Метаданные векторных тайлов недоступны",
|
||||
"Vector tiles unavailable": "Векторные тайлы недоступны",
|
||||
"Vector tiles unavailable (HTTP {status})": "Векторные тайлы недоступны (HTTP {status})",
|
||||
"Vector tile request timed out": "Векторные тайлы: истекло время ожидания",
|
||||
"Zoom in for vector tile coverage": "Приблизьте карту, чтобы загрузить векторные тайлы",
|
||||
"Camera coverage unavailable": "Покрытие камер недоступно",
|
||||
"Hybrid": "Гибрид",
|
||||
"TomTom roads only, each with its live flow": "Только дороги TomTom, у каждой — свой поток онлайн",
|
||||
"TomTom roads only, each with its live flow (needs a TomTom key)": "Только дороги TomTom, у каждой — свой поток онлайн (нужен ключ TomTom)",
|
||||
"OpenStreetMap roads, with TomTom flow matched when available": "Дороги OpenStreetMap; поток TomTom сопоставляется, где он есть",
|
||||
"TomTom roads first; OpenStreetMap fills the rest, simulated": "Сначала дороги TomTom; остальное дополняет OpenStreetMap с симуляцией потока",
|
||||
"TomTom roads first; OpenStreetMap fills the rest, simulated (needs a TomTom key)": "Сначала дороги TomTom; остальное дополняет OpenStreetMap с симуляцией потока (нужен ключ TomTom)",
|
||||
"Austin": "Остин",
|
||||
"San Francisco": "Сан-Франциско",
|
||||
"New York": "Нью-Йорк",
|
||||
"Tokyo": "Токио",
|
||||
"London": "Лондон",
|
||||
"Paris": "Париж",
|
||||
"Dubai": "Дубай",
|
||||
"Washington DC": "Вашингтон",
|
||||
"Tallinn": "Таллин",
|
||||
"Los Angeles": "Лос-Анджелес",
|
||||
"Data attribution": "Источники данных",
|
||||
"Data provided by:": "Данные предоставлены:",
|
||||
"Exit cockpit to view weather coverage": "Выйдите из кабины, чтобы показать зону покрытия погодного слоя",
|
||||
"Exit cockpit to fly to a cyclone": "Выйдите из кабины, чтобы перелететь к циклону",
|
||||
"the title of scene {scene} is empty or longer than {max} characters": "название сцены {scene} пустое или длиннее {max} символов",
|
||||
"the title of shot {shot} in scene {scene} is empty or longer than {max} characters": "название кадра {shot} в сцене {scene} пустое или длиннее {max} символов",
|
||||
"the project is larger than 5 MiB": "проект больше 5 МиБ",
|
||||
"the project has too many shots": "в проекте слишком много кадров",
|
||||
"the scene data failed validation": "данные сцены не прошли проверку",
|
||||
"Layer on": "Слой: вкл.",
|
||||
"Layer off": "Слой: выкл.",
|
||||
"Zoom in": "Приблизить",
|
||||
"Zoom out": "Отдалить",
|
||||
"Zoom out to globe": "Отдалить до глобуса",
|
||||
"Open data layers": "Открыть слои данных",
|
||||
"Open or close a panel": "Открыть или закрыть панель",
|
||||
"Change context mode": "Сменить режим контекста",
|
||||
"Control cockpit": "Управлять кабиной",
|
||||
"Change visual style": "Сменить визуальный стиль",
|
||||
"Adjust HUD": "Настроить HUD",
|
||||
"Adjust cyber sonar": "Настроить кибер-сонар",
|
||||
"Adjust detection overlay": "Настроить слой детекции",
|
||||
"Change map stack": "Сменить набор карт",
|
||||
"Adjust post-processing": "Настроить постобработку",
|
||||
"Control scene": "Управлять сценой",
|
||||
"Control CCTV": "Управлять камерами CCTV",
|
||||
"Control radio": "Управлять радио",
|
||||
"Track contact": "Отслеживать контакт",
|
||||
"Stop tracking": "Остановить отслеживание",
|
||||
"Clear marks": "Убрать отметки",
|
||||
"Move camera": "Переместить камеру",
|
||||
"Fly route": "Пролететь по маршруту",
|
||||
"Analyst query": "Аналитический запрос",
|
||||
"Next ISS pass": "Следующий пролёт МКС",
|
||||
"Next satellite pass": "Следующий пролёт спутника",
|
||||
"Action": "Действие"
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
checkCatalog,
|
||||
extractMessages,
|
||||
missingTemplate,
|
||||
} from '../../scripts/i18n.mjs';
|
||||
|
||||
// A plural entry whose every form is still the English text must be reported
|
||||
// the same way a string entry is ("is still English"). Today the plural
|
||||
// branch of checkCatalog never compares forms with the English.
|
||||
const fixture = new Map([
|
||||
['{count} cameras', { plural: true, one: '{count} camera' }],
|
||||
['Save keys', { plural: false }],
|
||||
]);
|
||||
|
||||
test('string entry left in English is reported (control)', () => {
|
||||
const { errors } = checkCatalog(
|
||||
'de',
|
||||
{
|
||||
'Save keys': 'Save keys',
|
||||
'{count} cameras': { one: 'x {count}', other: 'y {count}' },
|
||||
},
|
||||
fixture,
|
||||
new Set(),
|
||||
);
|
||||
assert.equal(errors.length, 1);
|
||||
assert.match(errors[0], /is still English/);
|
||||
});
|
||||
|
||||
for (const [locale, entry] of [
|
||||
['de', { one: '{count} camera', other: '{count} cameras' }],
|
||||
['ja', { other: '{count} cameras' }],
|
||||
['zh-CN', { other: '{count} cameras' }],
|
||||
[
|
||||
'es',
|
||||
{
|
||||
one: '{count} camera',
|
||||
many: '{count} cámaras',
|
||||
other: '{count} cameras',
|
||||
},
|
||||
],
|
||||
[
|
||||
'ru',
|
||||
{
|
||||
one: '{count} camera',
|
||||
few: '{count} камеры',
|
||||
many: '{count} камер',
|
||||
other: '{count} cameras',
|
||||
},
|
||||
],
|
||||
]) {
|
||||
test(`${locale}: plural entry with English forms is reported`, () => {
|
||||
const { errors } = checkCatalog(
|
||||
locale,
|
||||
{ 'Save keys': 'Schlüssel', '{count} cameras': entry },
|
||||
fixture,
|
||||
new Set(),
|
||||
);
|
||||
assert.ok(
|
||||
errors.some((e) => /is still English/.test(e)),
|
||||
`expected an "is still English" error, got ${JSON.stringify(errors)}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// `npm run i18n -- --missing <code>` prints a template; pasted without edits,
|
||||
// none of its entries may pass as a translation (K-21, K-52), and a plural
|
||||
// entry carries every form the language needs (K-97).
|
||||
const messages = extractMessages();
|
||||
for (const locale of ['de', 'ja', 'es', 'ru']) {
|
||||
test(`${locale}: an untouched --missing template fails the check entry by entry`, () => {
|
||||
const template = missingTemplate(locale, {}, messages);
|
||||
assert.equal(Object.keys(template).length, messages.size);
|
||||
const categories = new Intl.PluralRules(locale).resolvedOptions()
|
||||
.pluralCategories;
|
||||
for (const [text, info] of messages)
|
||||
if (info.plural)
|
||||
assert.deepEqual(Object.keys(template[text]), categories, text);
|
||||
const { missing, errors } = checkCatalog(
|
||||
locale,
|
||||
template,
|
||||
messages,
|
||||
new Set(),
|
||||
);
|
||||
assert.deepEqual(missing, []);
|
||||
const accepted = [...messages.keys()].filter(
|
||||
(text) => !errors.some((error) => error.startsWith(`"${text}" `)),
|
||||
);
|
||||
assert.deepEqual(accepted, []);
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
{
|
||||
"es": [
|
||||
"1090 MHz",
|
||||
"978 MHz UAT",
|
||||
"AIS: --",
|
||||
"AIS: {name}",
|
||||
"ANIME",
|
||||
@@ -7,6 +9,7 @@
|
||||
"AUTO",
|
||||
"Ambient",
|
||||
"Anime",
|
||||
"Austin",
|
||||
"BRG {value}°",
|
||||
"BRG —",
|
||||
"Blues",
|
||||
@@ -79,6 +82,7 @@
|
||||
"SRC {value}",
|
||||
"SSE",
|
||||
"STD",
|
||||
"San Francisco",
|
||||
"Sector",
|
||||
"Sonar",
|
||||
"Soul",
|
||||
@@ -103,6 +107,7 @@
|
||||
"panel",
|
||||
"view band|GLOBAL",
|
||||
"voice|ERROR",
|
||||
"{speed} kn",
|
||||
"{type} SPD: {speed} HDG: {heading}",
|
||||
"{value} KM",
|
||||
"{value} KM/H",
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
// K-23: scripts/i18n.mjs must skip index entries that are gone from the
|
||||
// working tree (plain `mv`/`rm` without `git mv`/`git rm`).
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import {
|
||||
mkdtempSync,
|
||||
mkdirSync,
|
||||
writeFileSync,
|
||||
copyFileSync,
|
||||
renameSync,
|
||||
rmSync,
|
||||
} from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
|
||||
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
function sandbox() {
|
||||
const root = mkdtempSync(path.join(tmpdir(), 'k23-'));
|
||||
mkdirSync(path.join(root, 'scripts'));
|
||||
mkdirSync(path.join(root, 'src/i18n/locales'), { recursive: true });
|
||||
copyFileSync(
|
||||
path.join(HERE, '../../scripts/i18n.mjs'),
|
||||
path.join(root, 'scripts/i18n.mjs'),
|
||||
);
|
||||
writeFileSync(path.join(root, 'src/a.js'), "export const x = t('Hello');\n");
|
||||
writeFileSync(path.join(root, 'index.html'), '<p data-i18n>Hello</p>\n');
|
||||
const git = (...args) =>
|
||||
execFileSync('git', args, { cwd: root, encoding: 'utf8' });
|
||||
git('init', '-q');
|
||||
git('add', '-A');
|
||||
git('-c', 'user.email=k@23', '-c', 'user.name=k23', 'commit', '-qm', 'init');
|
||||
return root;
|
||||
}
|
||||
|
||||
test('extractMessages survives a module renamed with plain mv', async () => {
|
||||
const root = sandbox();
|
||||
try {
|
||||
renameSync(path.join(root, 'src/a.js'), path.join(root, 'src/b.js'));
|
||||
const mod = await import(
|
||||
pathToFileURL(path.join(root, 'scripts/i18n.mjs')).href
|
||||
);
|
||||
const messages = mod.extractMessages();
|
||||
assert.ok(messages.has('Hello'));
|
||||
assert.doesNotThrow(() => mod.findUnmarked());
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
+126
-20
@@ -54,6 +54,74 @@ export function collectKeyUpdates(fields) {
|
||||
return updates;
|
||||
}
|
||||
|
||||
/**
|
||||
* Why /api/setup/keys refused a save, by the `code` the server sends with its
|
||||
* English `error` (src/keySetupCore.mjs, server/standalone/key-setup.js). The
|
||||
* English here is the server's own sentence, so the default locale reads the
|
||||
* same as before.
|
||||
*/
|
||||
const SAVE_REFUSALS = Object.freeze({
|
||||
proxied: msg('Provider Settings does not answer proxied requests'),
|
||||
sharing: msg('Provider Settings is disabled while sharing is enabled'),
|
||||
'not-loopback': msg(
|
||||
'Provider Settings answers only the machine running the server',
|
||||
),
|
||||
'not-local-host': msg('Provider Settings answers only local hostnames'),
|
||||
'origin-required': msg('Provider Settings requires an exact local Origin'),
|
||||
'origin-unrecognized': msg('Unrecognized Origin refused'),
|
||||
'cross-origin': msg('Cross-origin requests are refused'),
|
||||
'content-type': msg('Content-Type must be application/json'),
|
||||
method: msg('Method not allowed'),
|
||||
'too-large': msg('Request too large'),
|
||||
'invalid-json': msg('Invalid JSON'),
|
||||
'not-object': msg('Body must be a JSON object of {ENV_VAR: value}'),
|
||||
'no-keys': msg('No keys provided'),
|
||||
'too-many': msg('At most {limit} keys per save'),
|
||||
'unknown-key': msg('Unknown key: {name}'),
|
||||
'not-string': msg('{name} must be a string'),
|
||||
empty: msg('{name} is empty'),
|
||||
'too-long': msg('{name} is longer than any real key ({limit} max)'),
|
||||
'not-printable': msg(
|
||||
'{name} may only contain printable characters with no spaces',
|
||||
),
|
||||
'invalid-char': msg(
|
||||
'{name} contains a character that is not valid in a key (#, quotes, $, \\, or backtick)',
|
||||
),
|
||||
external: msg(
|
||||
'{name} is configured outside Provider Settings and can only be changed where it was set',
|
||||
),
|
||||
'harden-failed': msg(
|
||||
'The key was not saved: could not restrict the credential file to your account; nothing was saved',
|
||||
),
|
||||
'store-unreadable': msg(
|
||||
'The key was not saved: the existing configuration could not be read, so nothing was changed',
|
||||
),
|
||||
'write-failed': msg('Could not write the {store} store'),
|
||||
});
|
||||
|
||||
/**
|
||||
* The status line for a refused save — pure, exported for tests. A known
|
||||
* `code` reads in the active language; anything else shows the server's text.
|
||||
* @param {{code?: string, params?: object, error?: string}} payload
|
||||
* @param {number} status HTTP status of the response.
|
||||
* @returns {string}
|
||||
*/
|
||||
export function saveRefusalText(payload, status) {
|
||||
const pattern = Object.hasOwn(SAVE_REFUSALS, payload?.code)
|
||||
? SAVE_REFUSALS[payload.code]
|
||||
: null;
|
||||
if (pattern) {
|
||||
const values = {};
|
||||
for (const name of ['name', 'limit', 'store']) {
|
||||
const value = payload.params?.[name];
|
||||
if (typeof value === 'string' || typeof value === 'number')
|
||||
values[name] = value;
|
||||
}
|
||||
return t(pattern, values);
|
||||
}
|
||||
return payload?.error || t('Save failed ({status}).', { status });
|
||||
}
|
||||
|
||||
function sleepWithSignal(ms, signal) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (signal?.aborted) {
|
||||
@@ -72,6 +140,36 @@ function sleepWithSignal(ms, signal) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* An OAuth check error. A server-sent reason is kept as-is and later wrapped
|
||||
* in «OAuth check failed: …»; when there is none, the error carries our own
|
||||
* already-translated sentence and is flagged so the toast shows it unwrapped
|
||||
* (the wrapper would only repeat it).
|
||||
* @param {unknown} serverError
|
||||
* @param {string} fallback Already-translated fallback sentence.
|
||||
* @returns {Error}
|
||||
*/
|
||||
function oauthCheckError(serverError, fallback) {
|
||||
if (serverError) return new Error(String(serverError));
|
||||
const error = new Error(fallback);
|
||||
error.oauthFallback = true;
|
||||
return error;
|
||||
}
|
||||
|
||||
/**
|
||||
* Status text for a failed OAuth check — pure, exported for tests. A fallback
|
||||
* sentence of our own already says what failed and is shown alone; any other
|
||||
* reason (server text, network error) gets the «OAuth check failed» wrapper.
|
||||
* @param {unknown} error
|
||||
* @returns {string}
|
||||
*/
|
||||
export function oauthCheckFailureText(error) {
|
||||
if (error?.oauthFallback && error.message) return error.message;
|
||||
return t('OAuth check failed: {detail}', {
|
||||
detail: error?.message || error,
|
||||
});
|
||||
}
|
||||
|
||||
export async function waitForChatGptOAuth({
|
||||
fetchImpl,
|
||||
signal,
|
||||
@@ -90,8 +188,9 @@ export async function waitForChatGptOAuth({
|
||||
if (signal?.aborted) return false;
|
||||
if (response.ok && payload.available) return true;
|
||||
if (!response.ok || payload.loginFailed) {
|
||||
throw new Error(
|
||||
payload.error || 'Could not check ChatGPT sign-in. Try again.',
|
||||
throw oauthCheckError(
|
||||
payload.error,
|
||||
t('Could not check ChatGPT sign-in. Try again.'),
|
||||
);
|
||||
}
|
||||
if (now() >= deadline) break;
|
||||
@@ -415,11 +514,7 @@ export async function initKeySetup({
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (disposed) return;
|
||||
if (!response.ok || !payload.ok) {
|
||||
// payload.error is a server-authored message (API value) — shown as-is.
|
||||
say(
|
||||
payload.error ||
|
||||
t('Save failed ({status}).', { status: response.status }),
|
||||
);
|
||||
say(saveRefusalText(payload, response.status));
|
||||
return;
|
||||
}
|
||||
for (const input of root.querySelectorAll('input[data-env-var]'))
|
||||
@@ -496,12 +591,12 @@ export async function initKeySetup({
|
||||
if (current === 'oauth') {
|
||||
writeStoredCloudVoiceAuthMode('api-key');
|
||||
syncCloudVoiceAuth();
|
||||
say('Cloud voice will use OPENAI_API_KEY on the next session.');
|
||||
say(t('Cloud voice will use OPENAI_API_KEY on the next session.'));
|
||||
return;
|
||||
}
|
||||
oauthBusy = true;
|
||||
syncCloudVoiceAuth();
|
||||
say('Checking local ChatGPT OAuth sign-in…');
|
||||
say(t('Checking local ChatGPT OAuth sign-in…'));
|
||||
try {
|
||||
const response = await doFetch('/api/realtime/oauth-status', {
|
||||
cache: 'no-store',
|
||||
@@ -510,22 +605,25 @@ export async function initKeySetup({
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (lifetime.signal.aborted) return;
|
||||
if (!response.ok)
|
||||
throw new Error(
|
||||
payload.error || 'Could not check ChatGPT sign-in.',
|
||||
throw oauthCheckError(
|
||||
payload.error,
|
||||
t('Could not check ChatGPT sign-in.'),
|
||||
);
|
||||
if (payload.available) {
|
||||
writeStoredCloudVoiceAuthMode('oauth');
|
||||
syncCloudVoiceAuth();
|
||||
say(
|
||||
'ChatGPT OAuth selected for cloud voice. Your API key stays saved and available.',
|
||||
t(
|
||||
'ChatGPT OAuth selected for cloud voice. Your API key stays saved and available.',
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
say(
|
||||
payload.code === 'CODEX_OAUTH_REAUTH_REQUIRED'
|
||||
? 'ChatGPT sign-in expired. Opening sign-in again…'
|
||||
: 'Opening ChatGPT sign-in in your browser…',
|
||||
? t('ChatGPT sign-in expired. Opening sign-in again…')
|
||||
: t('Opening ChatGPT sign-in in your browser…'),
|
||||
);
|
||||
const loginResponse = await doFetch('/api/realtime/oauth-login', {
|
||||
method: 'POST',
|
||||
@@ -538,7 +636,7 @@ export async function initKeySetup({
|
||||
say(
|
||||
loginPayload.error ||
|
||||
payload.error ||
|
||||
'Could not start ChatGPT sign-in on this machine.',
|
||||
t('Could not start ChatGPT sign-in on this machine.'),
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -546,13 +644,17 @@ export async function initKeySetup({
|
||||
writeStoredCloudVoiceAuthMode('oauth');
|
||||
syncCloudVoiceAuth();
|
||||
say(
|
||||
'ChatGPT OAuth selected for cloud voice. Your API key stays saved and available.',
|
||||
t(
|
||||
'ChatGPT OAuth selected for cloud voice. Your API key stays saved and available.',
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
say(
|
||||
'Finish ChatGPT sign-in in the browser. Waiting for it to complete…',
|
||||
t(
|
||||
'Finish ChatGPT sign-in in the browser. Waiting for it to complete…',
|
||||
),
|
||||
);
|
||||
const available = await waitForChatGptOAuth({
|
||||
fetchImpl: doFetch,
|
||||
@@ -561,18 +663,22 @@ export async function initKeySetup({
|
||||
if (lifetime.signal.aborted) return;
|
||||
if (!available) {
|
||||
say(
|
||||
'ChatGPT sign-in timed out. Click USE CHATGPT OAUTH to try again.',
|
||||
t('ChatGPT sign-in timed out. Click {button} to try again.', {
|
||||
button: t('USE CHATGPT OAUTH'),
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
writeStoredCloudVoiceAuthMode('oauth');
|
||||
syncCloudVoiceAuth();
|
||||
say(
|
||||
'ChatGPT sign-in complete. OAuth will be used for the next cloud voice session.',
|
||||
t(
|
||||
'ChatGPT sign-in complete. OAuth will be used for the next cloud voice session.',
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
if (lifetime.signal.aborted) return;
|
||||
say(`OAuth check failed: ${error?.message || error}`);
|
||||
say(oauthCheckFailureText(error));
|
||||
} finally {
|
||||
oauthBusy = false;
|
||||
if (!disposed) syncCloudVoiceAuth();
|
||||
|
||||
+49
-6
@@ -241,7 +241,9 @@ export function isSharingEnabled(env = {}) {
|
||||
* preflight this server never answers, closing the simple-request CSRF
|
||||
* write primitive.
|
||||
*
|
||||
* @returns {{ok: true} | {ok: false, status: number, error: string}}
|
||||
* A refusal also carries `code`, which the panel turns into its own localized
|
||||
* sentence (keySetup.js); `error` stays the English for logs and API callers.
|
||||
* @returns {{ok: true} | {ok: false, status: number, error: string, code: string}}
|
||||
*/
|
||||
export function admitKeySetupRequest({
|
||||
method,
|
||||
@@ -262,6 +264,7 @@ export function admitKeySetupRequest({
|
||||
ok: false,
|
||||
status: 403,
|
||||
error: 'Provider Settings does not answer proxied requests',
|
||||
code: 'proxied',
|
||||
};
|
||||
}
|
||||
// Every sharing signal the launcher recognizes (scripts/pinokio-preflight.mjs)
|
||||
@@ -279,6 +282,7 @@ export function admitKeySetupRequest({
|
||||
ok: false,
|
||||
status: 403,
|
||||
error: 'Provider Settings is disabled while sharing is enabled',
|
||||
code: 'sharing',
|
||||
};
|
||||
}
|
||||
if (!LOOPBACK_ADDRESSES.has(String(remoteAddress || ''))) {
|
||||
@@ -286,6 +290,7 @@ export function admitKeySetupRequest({
|
||||
ok: false,
|
||||
status: 403,
|
||||
error: 'Provider Settings answers only the machine running the server',
|
||||
code: 'not-loopback',
|
||||
};
|
||||
}
|
||||
const authority = localAuthority(hostHeader, protocol);
|
||||
@@ -294,6 +299,7 @@ export function admitKeySetupRequest({
|
||||
ok: false,
|
||||
status: 403,
|
||||
error: 'Provider Settings answers only local hostnames',
|
||||
code: 'not-local-host',
|
||||
};
|
||||
}
|
||||
if (
|
||||
@@ -304,6 +310,7 @@ export function admitKeySetupRequest({
|
||||
ok: false,
|
||||
status: 403,
|
||||
error: 'Provider Settings requires an exact local Origin',
|
||||
code: 'origin-required',
|
||||
};
|
||||
}
|
||||
if (origin !== undefined && origin !== null && origin !== '') {
|
||||
@@ -311,7 +318,12 @@ export function admitKeySetupRequest({
|
||||
try {
|
||||
parsedOrigin = new URL(String(origin));
|
||||
} catch {
|
||||
return { ok: false, status: 403, error: 'Unrecognized Origin refused' };
|
||||
return {
|
||||
ok: false,
|
||||
status: 403,
|
||||
error: 'Unrecognized Origin refused',
|
||||
code: 'origin-unrecognized',
|
||||
};
|
||||
}
|
||||
const exactOrigin =
|
||||
parsedOrigin.username === '' &&
|
||||
@@ -325,6 +337,7 @@ export function admitKeySetupRequest({
|
||||
ok: false,
|
||||
status: 403,
|
||||
error: 'Cross-origin requests are refused',
|
||||
code: 'cross-origin',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -338,6 +351,7 @@ export function admitKeySetupRequest({
|
||||
ok: false,
|
||||
status: 415,
|
||||
error: 'Content-Type must be application/json',
|
||||
code: 'content-type',
|
||||
};
|
||||
}
|
||||
return { ok: true };
|
||||
@@ -447,6 +461,8 @@ export function keySetupStatus(env = {}) {
|
||||
* below safe to write without quoting rules. A `null` value means REMOVE:
|
||||
* the writer comments the assignment back out, returning the file to its
|
||||
* template state for that key.
|
||||
* A refusal carries `code` and `params` for the panel's localized sentence
|
||||
* (keySetup.js) besides the English `error`.
|
||||
* @param {unknown} body Parsed JSON from the request.
|
||||
*/
|
||||
export function validateKeySetupUpdates(body) {
|
||||
@@ -454,38 +470,63 @@ export function validateKeySetupUpdates(body) {
|
||||
return {
|
||||
ok: false,
|
||||
error: 'Body must be a JSON object of {ENV_VAR: value}',
|
||||
code: 'not-object',
|
||||
};
|
||||
}
|
||||
const entries = Object.entries(body);
|
||||
if (entries.length === 0) return { ok: false, error: 'No keys provided' };
|
||||
if (entries.length === 0)
|
||||
return { ok: false, error: 'No keys provided', code: 'no-keys' };
|
||||
if (entries.length > KEY_SETUP_UPDATE_LIMIT) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `At most ${KEY_SETUP_UPDATE_LIMIT} keys per save`,
|
||||
code: 'too-many',
|
||||
params: { limit: KEY_SETUP_UPDATE_LIMIT },
|
||||
};
|
||||
}
|
||||
const known = knownKeySetupEnvVars();
|
||||
const updates = {};
|
||||
for (const [name, raw] of entries) {
|
||||
if (!known.has(name)) return { ok: false, error: `Unknown key: ${name}` };
|
||||
if (!known.has(name))
|
||||
return {
|
||||
ok: false,
|
||||
error: `Unknown key: ${name}`,
|
||||
code: 'unknown-key',
|
||||
params: { name },
|
||||
};
|
||||
if (raw === null) {
|
||||
updates[name] = null;
|
||||
continue;
|
||||
}
|
||||
if (typeof raw !== 'string')
|
||||
return { ok: false, error: `${name} must be a string` };
|
||||
return {
|
||||
ok: false,
|
||||
error: `${name} must be a string`,
|
||||
code: 'not-string',
|
||||
params: { name },
|
||||
};
|
||||
const value = raw.trim();
|
||||
if (!value) return { ok: false, error: `${name} is empty` };
|
||||
if (!value)
|
||||
return {
|
||||
ok: false,
|
||||
error: `${name} is empty`,
|
||||
code: 'empty',
|
||||
params: { name },
|
||||
};
|
||||
if (value.length > KEY_SETUP_VALUE_LIMIT) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `${name} is longer than any real key (${KEY_SETUP_VALUE_LIMIT} max)`,
|
||||
code: 'too-long',
|
||||
params: { name, limit: KEY_SETUP_VALUE_LIMIT },
|
||||
};
|
||||
}
|
||||
if (!/^[\x21-\x7e]+$/.test(value)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `${name} may only contain printable characters with no spaces`,
|
||||
code: 'not-printable',
|
||||
params: { name },
|
||||
};
|
||||
}
|
||||
// Reject the dotenv metacharacters that would round-trip WRONG when written
|
||||
@@ -497,6 +538,8 @@ export function validateKeySetupUpdates(body) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `${name} contains a character that is not valid in a key (#, quotes, $, \\, or backtick)`,
|
||||
code: 'invalid-char',
|
||||
params: { name },
|
||||
};
|
||||
}
|
||||
updates[name] = value;
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
// K-25: a refused key save reads in the panel's language. The server sends a
|
||||
// `code` (and `params`) beside its English `error`; the panel builds the
|
||||
// sentence through t(), so English stays the server's own text and es/ru get
|
||||
// the catalog's.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
admitKeySetupRequest,
|
||||
KEY_SETUP_UPDATE_LIMIT,
|
||||
validateKeySetupUpdates,
|
||||
} from './keySetupCore.mjs';
|
||||
import { collectKeyUpdates, saveRefusalText } from './keySetup.js';
|
||||
import { setLocale } from './i18n/index.js';
|
||||
import { extractMessages } from '../scripts/i18n.mjs';
|
||||
|
||||
const local = {
|
||||
method: 'POST',
|
||||
remoteAddress: '127.0.0.1',
|
||||
hostHeader: 'localhost:4173',
|
||||
origin: 'http://localhost:4173',
|
||||
contentType: 'application/json',
|
||||
};
|
||||
|
||||
// Every refusal the core can return, plus the ones only the endpoint sends
|
||||
// (server/standalone/key-setup.js), with the English the server writes.
|
||||
const refusals = () => [
|
||||
validateKeySetupUpdates(
|
||||
collectKeyUpdates([{ envVar: 'OPENAI_API_KEY', value: '"sk-abc123"' }]),
|
||||
),
|
||||
validateKeySetupUpdates(
|
||||
collectKeyUpdates([{ envVar: 'OPENAI_API_KEY', value: 'sk abc123' }]),
|
||||
),
|
||||
validateKeySetupUpdates({ OPENAI_API_KEY: 'x'.repeat(600) }),
|
||||
validateKeySetupUpdates({ OPENAI_API_KEY: ' ' }),
|
||||
validateKeySetupUpdates({ OPENAI_API_KEY: 42 }),
|
||||
validateKeySetupUpdates({ PATH: '/usr/bin' }),
|
||||
validateKeySetupUpdates({}),
|
||||
validateKeySetupUpdates([]),
|
||||
validateKeySetupUpdates(
|
||||
Object.fromEntries(
|
||||
Array.from({ length: KEY_SETUP_UPDATE_LIMIT + 1 }, (_, i) => [
|
||||
`K${i}`,
|
||||
'v',
|
||||
]),
|
||||
),
|
||||
),
|
||||
admitKeySetupRequest({ ...local, proxyHeaders: { 'x-forwarded-for': '1' } }),
|
||||
admitKeySetupRequest({
|
||||
...local,
|
||||
env: { PINOKIO_SHARE_CLOUDFLARE: 'true' },
|
||||
}),
|
||||
admitKeySetupRequest({ ...local, remoteAddress: '192.168.1.20' }),
|
||||
admitKeySetupRequest({ ...local, hostHeader: 'workstation.local:4173' }),
|
||||
admitKeySetupRequest({ ...local, origin: undefined }),
|
||||
admitKeySetupRequest({ ...local, origin: 'not a url' }),
|
||||
admitKeySetupRequest({ ...local, origin: 'https://evil.example' }),
|
||||
admitKeySetupRequest({ ...local, contentType: 'text/plain' }),
|
||||
{ error: 'Method not allowed', code: 'method' },
|
||||
{ error: 'Request too large', code: 'too-large' },
|
||||
{ error: 'Invalid JSON', code: 'invalid-json' },
|
||||
{
|
||||
error:
|
||||
'OPENAI_API_KEY is configured outside Provider Settings and can only be changed where it was set',
|
||||
code: 'external',
|
||||
params: { name: 'OPENAI_API_KEY' },
|
||||
},
|
||||
{
|
||||
error:
|
||||
'The key was not saved: could not restrict the credential file to your account; nothing was saved',
|
||||
code: 'harden-failed',
|
||||
},
|
||||
{
|
||||
error:
|
||||
'The key was not saved: the existing configuration could not be read, so nothing was changed',
|
||||
code: 'store-unreadable',
|
||||
},
|
||||
{
|
||||
error: 'Could not write the env-file store',
|
||||
code: 'write-failed',
|
||||
params: { store: 'env-file' },
|
||||
},
|
||||
];
|
||||
|
||||
test('every refusal carries a code', () => {
|
||||
for (const refusal of refusals()) {
|
||||
assert.equal(refusal.ok ?? false, false);
|
||||
assert.equal(typeof refusal.code, 'string', refusal.error);
|
||||
}
|
||||
});
|
||||
|
||||
test('in English the panel shows exactly the server sentence', () => {
|
||||
setLocale('en');
|
||||
for (const refusal of refusals())
|
||||
assert.equal(saveRefusalText(refusal, 400), refusal.error);
|
||||
});
|
||||
|
||||
test('an unknown code or a bare error falls back as before', () => {
|
||||
setLocale('en');
|
||||
assert.equal(
|
||||
saveRefusalText({ error: 'Something new', code: 'new' }, 400),
|
||||
'Something new',
|
||||
);
|
||||
assert.equal(saveRefusalText({}, 502), 'Save failed (502).');
|
||||
});
|
||||
|
||||
test('the refusal sentences are catalog messages', () => {
|
||||
const messages = extractMessages();
|
||||
setLocale('en');
|
||||
for (const refusal of refusals()) {
|
||||
const pattern = saveRefusalText(
|
||||
{
|
||||
code: refusal.code,
|
||||
params: { name: '{name}', limit: '{limit}', store: '{store}' },
|
||||
},
|
||||
400,
|
||||
);
|
||||
assert.ok(messages.has(pattern), pattern);
|
||||
}
|
||||
});
|
||||
|
||||
for (const locale of ['es', 'ru']) {
|
||||
test(`${locale}: a refused save reads in the panel language`, () => {
|
||||
setLocale(locale);
|
||||
try {
|
||||
for (const refusal of refusals()) {
|
||||
const text = saveRefusalText(refusal, 400);
|
||||
assert.notEqual(text, refusal.error, `${locale}: ${refusal.error}`);
|
||||
if (refusal.params?.name) assert.ok(text.includes(refusal.params.name));
|
||||
}
|
||||
} finally {
|
||||
setLocale('en');
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
// C9: a failed ChatGPT OAuth check whose only reason is our own fallback
|
||||
// sentence shows that sentence alone — «OAuth check failed: Could not check
|
||||
// ChatGPT sign-in. Try again.» repeated itself. A server-sent reason keeps the
|
||||
// wrapper.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { oauthCheckFailureText, waitForChatGptOAuth } from './keySetup.js';
|
||||
import { setLocale } from './i18n/index.js';
|
||||
|
||||
const failWith = (payload, ok = true) =>
|
||||
waitForChatGptOAuth({
|
||||
fetchImpl: async () => ({ ok, json: async () => payload }),
|
||||
sleep: () => assert.fail('failed login must stop polling'),
|
||||
}).then(
|
||||
() => assert.fail('expected a rejection'),
|
||||
(error) => error,
|
||||
);
|
||||
|
||||
test('a fallback-only OAuth failure is shown without the wrapper in every locale', async (t) => {
|
||||
t.after(() => setLocale('en'));
|
||||
const expected = {
|
||||
en: 'Could not check ChatGPT sign-in. Try again.',
|
||||
ru: 'Не удалось проверить вход в ChatGPT. Попробуйте ещё раз.',
|
||||
es: 'No se pudo comprobar el inicio de sesión de ChatGPT. Inténtalo de nuevo.',
|
||||
};
|
||||
for (const [locale, text] of Object.entries(expected)) {
|
||||
setLocale(locale);
|
||||
const loginFailed = await failWith({ available: false, loginFailed: true });
|
||||
assert.equal(oauthCheckFailureText(loginFailed), text);
|
||||
const httpFailed = await failWith({}, false);
|
||||
assert.equal(oauthCheckFailureText(httpFailed), text);
|
||||
}
|
||||
});
|
||||
|
||||
test('a server-sent or network OAuth failure keeps the «OAuth check failed» wrapper', async (t) => {
|
||||
t.after(() => setLocale('en'));
|
||||
setLocale('en');
|
||||
const server = await failWith({
|
||||
loginFailed: true,
|
||||
error: 'Sign-in is local only.',
|
||||
});
|
||||
assert.equal(
|
||||
oauthCheckFailureText(server),
|
||||
'OAuth check failed: Sign-in is local only.',
|
||||
);
|
||||
assert.equal(
|
||||
oauthCheckFailureText(new TypeError('Failed to fetch')),
|
||||
'OAuth check failed: Failed to fetch',
|
||||
);
|
||||
setLocale('ru');
|
||||
assert.match(
|
||||
oauthCheckFailureText(server),
|
||||
/^Не удалось проверить OAuth: Sign-in is local only\.$/,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
// K-26: every status line of the ChatGPT OAuth flow in Provider Settings goes
|
||||
// through t(), and the i18n check treats say() as a place UI text lands.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
import {
|
||||
extractMessages,
|
||||
readCatalog,
|
||||
shippedLocales,
|
||||
unmarkedScript,
|
||||
} from '../scripts/i18n.mjs';
|
||||
|
||||
// Status-line texts of the ChatGPT OAuth flow in Provider Settings
|
||||
// (src/keySetup.js, handler of [data-cloud-voice-auth-toggle]).
|
||||
const OAUTH_STATUS_TEXTS = [
|
||||
'Cloud voice will use OPENAI_API_KEY on the next session.',
|
||||
'Checking local ChatGPT OAuth sign-in…',
|
||||
'ChatGPT OAuth selected for cloud voice. Your API key stays saved and available.',
|
||||
'ChatGPT sign-in expired. Opening sign-in again…',
|
||||
'Opening ChatGPT sign-in in your browser…',
|
||||
'Finish ChatGPT sign-in in the browser. Waiting for it to complete…',
|
||||
'ChatGPT sign-in timed out. Click {button} to try again.',
|
||||
'OAuth check failed: {detail}',
|
||||
'Could not check ChatGPT sign-in.',
|
||||
'Could not check ChatGPT sign-in. Try again.',
|
||||
'Could not start ChatGPT sign-in on this machine.',
|
||||
'ChatGPT sign-in complete. OAuth will be used for the next cloud voice session.',
|
||||
];
|
||||
|
||||
test('K-26: OAuth status-line texts are extractable messages with es/ru entries', () => {
|
||||
const messages = extractMessages();
|
||||
const missing = OAUTH_STATUS_TEXTS.filter((text) => !messages.has(text));
|
||||
assert.deepEqual(missing, [], 'texts not wrapped in t() in src/keySetup.js');
|
||||
for (const locale of shippedLocales()) {
|
||||
const catalog = readCatalog(locale);
|
||||
const untranslated = OAUTH_STATUS_TEXTS.filter((text) => !catalog[text]);
|
||||
assert.deepEqual(untranslated, [], `${locale}.json lacks entries`);
|
||||
}
|
||||
});
|
||||
|
||||
test('K-26: the i18n check sees an unmarked literal passed to say()', () => {
|
||||
const sample =
|
||||
"const say = (x) => { el.textContent = x; };\nsay('Checking local ChatGPT OAuth sign-in…');\n";
|
||||
const found = unmarkedScript('sample.js', sample);
|
||||
assert.ok(
|
||||
found.some((f) => f.text.includes('Checking local ChatGPT OAuth')),
|
||||
'say(...) is not among JS_SINKS in scripts/i18n.mjs',
|
||||
);
|
||||
});
|
||||
|
||||
test('K-26: src/keySetup.js has no unmarked say() literals once say is a sink', () => {
|
||||
const source = readFileSync(
|
||||
new URL('./keySetup.js', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
const literal = /\bsay\(\s*['"`]/g;
|
||||
const hits = [...source.matchAll(literal)].map(
|
||||
(m) => source.slice(0, m.index).split('\n').length,
|
||||
);
|
||||
assert.deepEqual(
|
||||
hits,
|
||||
[],
|
||||
`say() called with a bare literal at lines ${hits.join(', ')}`,
|
||||
);
|
||||
});
|
||||
@@ -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'),
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
Loaded 100 of 212 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user