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:
German KhokhlovandClaude Opus 5.5 committed 2026-10-15 23:49:52 +04:00
1 parent 8029747b7e
commit 00704ddc89
212 files changed
+9871 -909

No files matched your search

+15 -11
View File
@@ -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). */
+72 -6
View File
@@ -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);
+121
View File
@@ -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');
});
+2 -1
View File
@@ -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,
+3
View File
@@ -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 || '');
+6 -4
View File
@@ -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)) });
}
/**
+11
View File
@@ -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');
});
+46
View File
@@ -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');
+5
View File
@@ -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);
+53 -21
View File
@@ -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}`;
}
+32
View File
@@ -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,
]);
+13 -2
View File
@@ -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 '—';
}
+17 -7
View File
@@ -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) {
+30
View File
@@ -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;
}
+101 -39
View File
@@ -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',
);
+72
View File
@@ -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',
);
});
+3 -1
View File
@@ -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,
+96
View File
@@ -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',
);
});