refactor(voice): isolate gemini live provider core

This commit is contained in:
manjunath committed 2026-10-19 21:40:40 +05:30
commit 10916227cb
655 files changed
+45946 -26483

No files matched your search

+170
View File
@@ -0,0 +1,170 @@
import * as Cesium from 'cesium';
import {
feedProvenanceEnvelope,
layerSnapshot,
layerSnapshots,
} from '../data/layerSnapshot.js';
import { contextModeWord } from '../contextModePolicy.js';
export const TRACKABLE_FAMILIES = Object.freeze([
{ layerId: 'flights', kind: 'aircraft' },
{ layerId: 'military', kind: 'aircraft' },
{ layerId: 'ais-live-vessels', kind: 'vessel' },
{ layerId: 'satellites', kind: 'satellite' },
]);
const CONTEXT_MODE_RESULT_FIELDS = Object.freeze([
{ field: 'mode', emptyAs: 'off' },
{ field: 'entering', emptyAs: null },
{ field: 'priorMode', emptyAs: null },
]);
const NESTED_CONTEXT_RESULT_FIELDS = Object.freeze([
'context',
'contextRollback',
]);
/**
* Translate context-mode payloads into the public tool vocabulary while
* retaining the internal id beside each translated field.
*/
export function withContextModeVocabulary(state) {
if (!state || typeof state !== 'object') return state;
let out = state;
const mutable = () => {
if (out === state) out = { ...state };
return out;
};
for (const { field, emptyAs } of CONTEXT_MODE_RESULT_FIELDS) {
if (!(field in state)) continue;
const internal = state[field] ?? null;
const target = mutable();
target[field] = contextModeWord(internal, { emptyAs });
target[`${field}Internal`] = internal;
}
for (const field of NESTED_CONTEXT_RESULT_FIELDS) {
const nested = state[field];
if (!nested || typeof nested !== 'object') continue;
const translated = withContextModeVocabulary(nested);
if (translated !== nested) mutable()[field] = translated;
}
return out;
}
/** Read tracked entities from their canonical layer owners. */
export function collectTrackedEntities(dataManager) {
const tracked = [];
for (const family of TRACKABLE_FAMILIES) {
const module = dataManager?.layers?.get(family.layerId)?.module;
if (!module) continue;
try {
const info =
family.kind === 'vessel'
? module.getSelectedInfo?.()
: module.getTrackedInfo?.();
if (info)
tracked.push({
kind: family.kind,
layerId: family.layerId,
...info,
});
} catch {
// A not-yet-ready layer contributes no tracked observation.
}
}
return tracked;
}
/** Read the active Contacts window from its owning awareness layer. */
export function activeContactsWindow(dataManager) {
try {
const layer = dataManager?.layers?.get('military-awareness')?.module;
return (
layer?.contactsWindowFromSnapshot?.(layer.getContextSnapshot?.()) ?? null
);
} catch {
return null;
}
}
/**
* Build the model-free fast World Packet from existing application owners.
*
* This performs no network requests and intentionally mirrors the historical
* get_current_view_state payload so voice can delegate without behavior drift.
*/
export function getWorldPacket(options = {}) {
const {
viewer,
styleManager,
dataManager,
sceneDirector = null,
now: requestedNow,
} = options ?? {};
// A default parameter only covers undefined; null or NaN would age every
// feed against the epoch and report stale data as "just now".
const now = Number.isFinite(requestedNow) ? requestedNow : Date.now();
if (!viewer?.camera?.positionWC)
throw new TypeError('World Packet requires a viewer camera');
if (!dataManager || typeof dataManager.getAll !== 'function')
throw new TypeError('World Packet requires a data manager');
const cartographic = Cesium.Cartographic.fromCartesian(
viewer.camera.positionWC,
);
// Read the roster once. Voice must not rewalk disabled layers: preserving the
// current enabled-only view keeps the model's context budget bounded.
const rows = dataManager.getAll();
const enabledRows = rows.filter((layer) => layer.enabled);
const contactsWindow = activeContactsWindow(dataManager);
const contextState =
typeof styleManager?.getContextModeState === 'function'
? withContextModeVocabulary(styleManager.getContextModeState())
: null;
return {
ok: true,
action: 'get_current_view_state',
camera: {
latitude: Cesium.Math.toDegrees(cartographic.latitude),
longitude: Cesium.Math.toDegrees(cartographic.longitude),
heightM: cartographic.height,
},
style: styleManager?.activeStyle || 'normal',
context: contextState
? {
...contextState,
...(contactsWindow ? { contactsWindow } : {}),
}
: null,
cockpit:
typeof styleManager?.getCockpitState === 'function'
? styleManager.getCockpitState()
: null,
controls:
typeof styleManager?.getControlState === 'function'
? styleManager.getControlState()
: null,
scenePlayback: sceneDirector?.getPlaybackStatus?.() || null,
tracked: collectTrackedEntities(dataManager),
disabledLayerCount: rows.length - enabledRows.length,
// Preserve get_current_view_state's existing compact enabled-only shape.
layers: enabledRows.map((layer) => {
const snapshot = layerSnapshot(layer, { now });
return {
id: layer.id,
name: layer.name,
enabled: layer.enabled,
count: layer.stats?.count || 0,
error: layer.stats?.error || null,
feedState: snapshot.feedState,
source: snapshot.source,
lastUpdate: snapshot.lastUpdate,
};
}),
feedProvenance: feedProvenanceEnvelope(
layerSnapshots(rows, { now }).filter((layer) => layer.enabled),
{ now },
),
};
}
+335
View File
@@ -0,0 +1,335 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { getWorldPacket } from './worldPacket.js';
import { createGevActionRunner } from '../voice/gevActions.js';
function withoutVoicePresentation(result) {
const packet = { ...result };
delete packet.say;
delete packet.display;
delete packet.schedule;
return packet;
}
function harness() {
const position = Cesium.Cartesian3.fromDegrees(-97.74, 30.26, 500);
const viewer = {
trackedEntity: undefined,
clock: { onTick: { addEventListener: () => () => {} } },
scene: {
canvas: {
clientWidth: 1200,
clientHeight: 800,
addEventListener() {},
removeEventListener() {},
},
globe: { getHeight: () => 0 },
tweens: [],
},
camera: {
positionWC: position,
positionCartographic: Cesium.Cartographic.fromCartesian(position),
heading: 0,
pitch: Cesium.Math.toRadians(-45),
moveEnd: { addEventListener: () => () => {} },
cancelFlight() {},
lookAtTransform() {},
},
};
const rows = [
{
id: 'traffic',
name: 'Traffic',
enabled: true,
stats: {
count: 3,
source: 'fixture',
lastUpdate: null,
},
},
];
const dataManager = {
layers: new Map([['traffic', { module: null }]]),
getAll: () => rows,
};
const styleManager = {
activeStyle: 'normal',
getContextModeState: () => ({ mode: 'flights' }),
getCockpitState: () => null,
getControlState: () => ({ hud: { visible: 'auto' } }),
};
return { viewer, dataManager, styleManager };
}
test('World Packet preserves get_current_view_state parity', async () => {
const { viewer, dataManager, styleManager } = harness();
const packet = getWorldPacket({ viewer, dataManager, styleManager });
const runner = createGevActionRunner({ viewer, dataManager, styleManager });
const voice = await runner('get_current_view_state');
assert.deepEqual(withoutVoicePresentation(voice), packet);
assert.equal(packet.context.mode, 'contacts');
assert.equal(packet.context.modeInternal, 'flights');
assert.equal(packet.layers[0].feedState, 'nominal');
assert.equal(packet.feedProvenance.overall, 'nominal');
});
test('World Packet keeps stale/unavailable feed semantics distinct from empty', () => {
const { viewer, dataManager, styleManager } = harness();
dataManager.getAll = () => [
{
id: 'traffic',
name: 'Traffic',
enabled: true,
stats: {
count: 0,
source: 'fixture',
error: 'fixture unavailable',
lastUpdate: null,
},
},
];
const packet = getWorldPacket({ viewer, dataManager, styleManager });
assert.equal(packet.layers[0].count, 0);
assert.notEqual(packet.layers[0].feedState, 'nominal');
assert.notEqual(packet.feedProvenance.overall, 'nominal');
assert.match(packet.feedProvenance.note, /fixture unavailable/i);
});
// The 2026-10 main voice contract omits disabled layers from the model payload.
test('World Packet and voice preserve enabled-only payload and count disabled layers', async () => {
const { viewer, dataManager, styleManager } = harness();
const rows = dataManager.getAll();
rows.push({
id: 'earthquakes',
name: 'Earthquakes',
enabled: false,
stats: { count: 99, source: 'fixture', error: 'stale' },
});
let reads = 0;
dataManager.getAll = () => {
reads += 1;
return rows;
};
const packet = getWorldPacket({ viewer, dataManager, styleManager });
assert.equal(reads, 1, 'collect the roster once rather than repeatedly');
assert.equal(packet.disabledLayerCount, 1);
assert.deepEqual(
packet.layers.map((layer) => layer.id),
['traffic'],
);
assert.equal(packet.feedProvenance.overall, 'nominal');
const runner = createGevActionRunner({ viewer, dataManager, styleManager });
const voice = await runner('get_current_view_state');
assert.deepEqual(withoutVoicePresentation(voice), packet);
});
const PINNED_NOW = Date.UTC(2026, 9, 17, 12, 0, 0);
// Exercises every packet field: context vocabulary and Contacts window,
// cockpit, controls, scene playback, tracked families (one not ready),
// enabled and disabled layers, and an aged and an unavailable feed.
function pinnedHarness() {
const { viewer } = harness();
const rows = [
{
id: 'flights',
name: 'Flights',
enabled: true,
stats: {
count: 12,
source: 'fixture-adsb',
lastUpdate: PINNED_NOW - 90_000,
},
},
{
id: 'traffic',
name: 'Traffic',
enabled: true,
stats: {
count: 0,
source: 'fixture',
error: 'fixture unavailable',
lastUpdate: null,
},
},
{
id: 'earthquakes',
name: 'Earthquakes',
enabled: false,
stats: { count: 99, source: 'fixture', error: 'stale' },
},
];
const module = (methods) => ({ module: methods });
const dataManager = {
layers: new Map([
[
'flights',
module({
getTrackedInfo: () => ({ icao24: 'abc123', callsign: 'TEST1' }),
}),
],
[
'ais-live-vessels',
module({ getSelectedInfo: () => ({ mmsi: '366000001' }) }),
],
[
'satellites',
module({
getTrackedInfo: () => {
throw new Error('not ready');
},
}),
],
[
'military-awareness',
module({
getContextSnapshot: () => ({ snapshot: true }),
contactsWindowFromSnapshot: (snapshot) =>
snapshot ? { windowMin: 30, count: 4 } : null,
}),
],
]),
getAll: () => rows,
};
const styleManager = {
activeStyle: 'night-vision',
getContextModeState: () => ({
mode: 'flights',
entering: null,
priorMode: null,
}),
getCockpitState: () => ({ active: false }),
getControlState: () => ({ hud: { visible: 'auto' } }),
};
const sceneDirector = {
getPlaybackStatus: () => ({ playing: false, sceneId: 'fixture' }),
};
return { viewer, dataManager, styleManager, sceneDirector };
}
// Captured from main's get_current_view_state before the extraction
// (bilawalsidhu/gods-eye-view@3d8e1ef0) with Date.now pinned to PINNED_NOW,
// minus the speech envelope. Key order is part of the contract.
const PRE_EXTRACTION_PAYLOAD = {
ok: true,
action: 'get_current_view_state',
camera: {
latitude: 30.259999999999994,
longitude: -97.74,
heightM: 499.99999999901877,
},
style: 'night-vision',
context: {
mode: 'contacts',
entering: null,
priorMode: null,
modeInternal: 'flights',
enteringInternal: null,
priorModeInternal: null,
contactsWindow: { windowMin: 30, count: 4 },
},
cockpit: { active: false },
controls: { hud: { visible: 'auto' } },
scenePlayback: { playing: false, sceneId: 'fixture' },
tracked: [
{
kind: 'aircraft',
layerId: 'flights',
icao24: 'abc123',
callsign: 'TEST1',
},
{ kind: 'vessel', layerId: 'ais-live-vessels', mmsi: '366000001' },
],
disabledLayerCount: 1,
layers: [
{
id: 'flights',
name: 'Flights',
enabled: true,
count: 12,
error: null,
feedState: 'nominal',
source: 'fixture-adsb',
lastUpdate: 1792238310000,
},
{
id: 'traffic',
name: 'Traffic',
enabled: true,
count: 0,
error: 'fixture unavailable',
feedState: 'unavailable',
source: 'fixture',
lastUpdate: 0,
},
],
feedProvenance: {
overall: 'unavailable',
layers: [
{
id: 'flights',
name: 'Flights',
enabled: true,
feedState: 'nominal',
source: 'fixture-adsb',
count: 12,
lastUpdate: 1792238310000,
ageSec: 90,
ageLabel: '1m ago',
error: null,
},
{
id: 'traffic',
name: 'Traffic',
enabled: true,
feedState: 'unavailable',
source: 'fixture',
count: 0,
lastUpdate: 0,
ageSec: null,
ageLabel: null,
error: 'fixture unavailable',
},
],
note: 'Traffic is UNAVAILABLE (fixture, fixture unavailable). Narrate that provenance with any count; do not describe this as live.',
},
};
function assertPinnedPayload(actual) {
assert.deepEqual(actual, PRE_EXTRACTION_PAYLOAD);
// deepEqual ignores key order; the serialized form does not.
assert.equal(JSON.stringify(actual), JSON.stringify(PRE_EXTRACTION_PAYLOAD));
}
test('World Packet matches the pre-extraction get_current_view_state payload', () => {
const packet = getWorldPacket({ ...pinnedHarness(), now: PINNED_NOW });
assertPinnedPayload(packet);
});
test('voice get_current_view_state still returns the pre-extraction payload', async (t) => {
t.mock.method(Date, 'now', () => PINNED_NOW);
const runner = createGevActionRunner(pinnedHarness());
const voice = await runner('get_current_view_state');
assertPinnedPayload(withoutVoicePresentation(voice));
});
test('World Packet ages feeds against the clock when now is not a finite number', (t) => {
t.mock.method(Date, 'now', () => PINNED_NOW);
for (const now of [null, Number.NaN, '2026-10-17']) {
const packet = getWorldPacket({ ...pinnedHarness(), now });
const flights = packet.feedProvenance.layers.find(
(layer) => layer.id === 'flights',
);
assert.equal(flights.ageSec, 90, `now: ${String(now)}`);
assert.equal(flights.ageLabel, '1m ago', `now: ${String(now)}`);
}
});
test('World Packet rejects null options with the camera requirement', () => {
assert.throws(() => getWorldPacket(null), /requires a viewer camera/);
});
+44 -165
View File
@@ -1,6 +1,7 @@
import { isUnavailableCapability } from '../sources/capability.js';
import { defaultGeospatial } from '../search/defaults.js';
import * as Cesium from 'cesium';
import { greatCircleMeters } from '../geoDistance.js';
import {
holdContinuousRender,
releaseContinuousRender,
@@ -72,20 +73,16 @@ export async function resolveOutlineWithRetry(
delaysMs = OUTLINE_RETRY_DELAYS_MS,
isStale = () => false,
waitFn = wait,
signal,
} = {},
) {
let retriedRateLimit = false;
for (let attempt = 0; ; attempt += 1) {
if (signal?.aborted || isStale()) return undefined;
let fp;
try {
fp = await abortable(resolveOutline(), signal);
} catch (error) {
if (signal?.aborted || error?.name === 'AbortError') return undefined;
fp = await resolveOutline();
} catch {
fp = null; // hard failure — definitive, keep the honest point
}
if (signal?.aborted || isStale()) return undefined;
if (isUnavailableCapability(fp)) return fp;
if (isRateLimitedOutcome(fp)) {
// A throttle gets one deliberately spaced retry. If that retry is throttled
@@ -94,27 +91,14 @@ export async function resolveOutlineWithRetry(
const retryAfterMs = Number.isFinite(fp.retryAfterMs)
? fp.retryAfterMs
: 0;
try {
await abortable(
waitFn(Math.max(retryAfterMs, delaysMs[attempt]), signal),
signal,
);
} catch (error) {
if (signal?.aborted) return undefined;
throw error;
}
await waitFn(Math.max(retryAfterMs, delaysMs[attempt]));
if (isStale()) return undefined;
retriedRateLimit = true;
continue;
}
if (fp !== undefined) return fp;
if (attempt >= delaysMs.length) return undefined; // transient budget exhausted
try {
await abortable(waitFn(delaysMs[attempt], signal), signal);
} catch (error) {
if (signal?.aborted) return undefined;
throw error;
}
await waitFn(delaysMs[attempt]);
if (isStale()) return undefined; // board cleared/superseded mid-backoff
}
}
@@ -182,32 +166,14 @@ export function createAnnotationEngine({
activeControllers.clear();
}
function dropQueuedOutlineUpgrades(controller = null) {
for (let i = outlineQueue.length - 1; i >= 0; i -= 1) {
const task = outlineQueue[i];
if (controller && task.controller !== controller) continue;
outlineQueue.splice(i, 1);
finishOutlineTask(task);
function dropQueuedOutlineUpgrades() {
while (outlineQueue.length) {
const task = outlineQueue.shift();
if (task.anno._outlineTask === task) task.anno._outlineTask = null;
releaseController(task.controller);
}
}
function finishOutlineTask(task) {
if (task.anno._outlineTask !== task) return;
task.anno._outlineTask = null;
task.anno.pendingOutline = false;
}
function controllerIsStale(controller) {
if (
!controller.signal.aborted &&
typeof controller._isCurrent === 'function' &&
!controller._isCurrent()
)
controller.abort();
return controller.signal.aborted;
}
// A controller stays abortable (in activeControllers) while its annotate() call OR any
// progressive outline-upgrade task it spawned is still running — clear() must be able to
// abort in-flight Overpass fetches even after the tool result has already returned.
@@ -217,10 +183,7 @@ export function createAnnotationEngine({
}
function releaseController(c) {
c._retain = (c._retain || 0) - 1;
if (c._retain <= 0) {
activeControllers.delete(c);
c._cleanup?.();
}
if (c._retain <= 0) activeControllers.delete(c);
}
// World annotations carry persistent per-frame scene animation (pulsing
@@ -296,8 +259,6 @@ export function createAnnotationEngine({
* @param {boolean} [opts.clearPrevious]
* @param {boolean} [opts.persist] Keep until cleared (default true).
* @param {boolean} [opts.flyTo] Frame the first resolved annotation.
* @param {AbortSignal} [opts.signal] Cancel only this call's pending work.
* @param {Function} [opts.isCurrent] Guard commits and deferred upgrades.
* @returns {Promise<{ok, drawn, failed, ids, results}>}
*/
async function annotate(requests, opts = {}) {
@@ -310,41 +271,15 @@ export function createAnnotationEngine({
results: [],
error: 'destroyed',
};
const cancelledResult = (results = [], ids = []) => ({
ok: false,
drawn: results.filter((r) => r.ok).length,
failed: results.filter((r) => !r.ok).length,
ids,
results,
aborted: true,
cancelled: true,
});
// Check authority BEFORE clearPrevious: an already-cancelled owner cannot
// erase a manual mark or invalidate a different owner's pending operation.
if (
opts.signal?.aborted ||
(typeof opts.isCurrent === 'function' && !opts.isCurrent())
)
return cancelledResult();
const list = Array.isArray(requests) ? requests : [requests];
if (opts.clearPrevious) clear(); // bumps generation + aborts older pending work
const persist = opts.persist !== false;
// The call owns this controller through its final deferred outline task.
// Per-call cancellation: bumped/aborted by any later clear() or destroy().
const controller = new AbortController();
controller._isCurrent = opts.isCurrent;
const cancel = () => {
controller.abort(opts.signal.reason);
dropQueuedOutlineUpgrades(controller);
};
controller._cleanup = () =>
opts.signal?.removeEventListener('abort', cancel);
opts.signal?.addEventListener('abort', cancel, { once: true });
retainController(controller);
if (opts.signal?.aborted) cancel();
const myGen = generation;
const superseded = () =>
myGen !== generation || controllerIsStale(controller);
const superseded = () => myGen !== generation || controller.signal.aborted;
const results = [];
const ids = [];
@@ -358,13 +293,7 @@ export function createAnnotationEngine({
// order are all preserved exactly as the old serial loop had them.
const settled = await Promise.allSettled(
list.map((spec) =>
abortable(
resolveSpec(spec, controller.signal, opts.flyTo === true, superseded),
controller.signal,
).catch((error) => {
if (error?.name === 'AbortError') controller.abort(error);
throw error;
}),
resolveSpec(spec, controller.signal, opts.flyTo === true),
),
);
@@ -374,13 +303,7 @@ export function createAnnotationEngine({
const outcome = settled[i];
if (superseded()) break;
try {
if (outcome.status === 'rejected') {
if (outcome.reason?.name === 'AbortError') {
controller.abort(outcome.reason);
break;
}
throw outcome.reason;
}
if (outcome.status === 'rejected') throw outcome.reason; // resolution threw → same path as before
const resolved = outcome.value;
if (!resolved) {
results.push(failResult(spec, 'could not resolve location'));
@@ -527,14 +450,23 @@ export function createAnnotationEngine({
releaseController(controller);
}
// Pending work is cancelled without removing any mark already committed.
if (superseded()) return cancelledResult(results, ids);
// A clear/new-topic landed mid-flight: drop everything this call produced and
// do not touch the renderer or camera — the board now belongs to a newer call.
if (superseded()) {
return {
ok: false,
drawn: 0,
failed: results.length,
ids: [],
results,
aborted: true,
};
}
if (ids.length) {
renderer.sync(annotations);
ensureTicking();
}
if (superseded()) return cancelledResult(results, ids);
if (opts.flyTo && firstAnchor) frameAnnotation(firstAnchor);
else if (ids.length) ensureMarksVisible(ids);
@@ -549,17 +481,7 @@ export function createAnnotationEngine({
};
}
async function resolveSpec(
spec,
signal,
allowDistant = false,
isStale = () => false,
) {
const checkCurrent = () => {
isStale(); // invalid currentness aborts this call's controller
signal.throwIfAborted();
};
checkCurrent();
async function resolveSpec(spec, signal, allowDistant = false) {
const type = normalizeType(spec?.type);
// MANUAL geometry (drawTool.js): the person clicked the vertices, so there is
// nothing to resolve. Same record shapes as a resolved spec, source 'manual'.
@@ -584,7 +506,6 @@ export function createAnnotationEngine({
allowDistant,
signal,
});
checkCurrent();
if (r) resolvedPts.push(r);
else failed.push(name || 'a waypoint');
}
@@ -606,7 +527,6 @@ export function createAnnotationEngine({
signal,
placeSearch,
);
checkCurrent();
if (routed) {
return {
path: routed.geometry.map(([lon, lat]) => ({ lon, lat, height: 0 })),
@@ -644,7 +564,6 @@ export function createAnnotationEngine({
allowDistant,
signal,
});
checkCurrent();
const to = await resolveTarget({
placeSearch,
viewer,
@@ -657,7 +576,6 @@ export function createAnnotationEngine({
allowDistant,
signal,
});
checkCurrent();
if (!from || !to) {
// Name the endpoint(s) that actually failed so the partial voice path can't
// blame the wrong place (a valid origin → unresolved destination must report
@@ -730,10 +648,10 @@ export function createAnnotationEngine({
const task = outlineQueue.shift();
const stale =
task.myGen !== generation ||
controllerIsStale(task.controller) ||
task.controller.signal.aborted ||
!annotations.has(task.anno.id);
if (stale) {
finishOutlineTask(task);
if (task.anno._outlineTask === task) task.anno._outlineTask = null;
releaseController(task.controller);
continue;
}
@@ -753,11 +671,10 @@ export function createAnnotationEngine({
delaysMs: outlineRetryDelaysMs,
isStale: () =>
myGen !== generation ||
controllerIsStale(controller) ||
controller.signal.aborted ||
!annotations.has(anno.id),
signal: controller.signal,
});
if (myGen !== generation || controllerIsStale(controller)) return; // board superseded
if (myGen !== generation || controller.signal.aborted) return; // board superseded
if (!annotations.has(anno.id)) return; // mark replaced/removed while resolving
anno.pendingOutline = false;
anno.outlineUnavailable = isUnavailableCapability(fp);
@@ -826,7 +743,7 @@ export function createAnnotationEngine({
...(fp && anno.synthesized ? { approximate: true } : {}),
});
} finally {
finishOutlineTask(task);
if (anno._outlineTask === task) anno._outlineTask = null;
releaseController(controller);
activeOutlineUpgrades -= 1;
drainOutlineQueue();
@@ -1221,12 +1138,14 @@ export function createAnnotationEngine({
{
type: 'highlight',
target: 'Palace of Fine Arts, San Francisco',
// i18n-ignore: dev demo of voice-agent labels (not translated)
label: 'Palace of Fine Arts',
color: 'amber',
},
{
type: 'area',
target: 'Presidio of San Francisco',
// i18n-ignore: dev demo of voice-agent labels (not translated)
label: 'The Presidio (former Army base)',
color: 'green',
footprint: true,
@@ -1234,6 +1153,7 @@ export function createAnnotationEngine({
{
type: 'pin',
target: 'Letterman Digital Arts Center, San Francisco',
// i18n-ignore: dev demo of voice-agent labels (not translated)
label: 'ILM / Lucasfilm',
color: 'cyan',
},
@@ -1241,6 +1161,7 @@ export function createAnnotationEngine({
type: 'arrow',
target: 'Palace of Fine Arts, San Francisco',
toTarget: 'Marina District, San Francisco',
// i18n-ignore: dev demo of voice-agent labels (not translated)
label: 'next to the Marina',
},
],
@@ -1270,6 +1191,7 @@ export function createAnnotationEngine({
{
type: 'highlight',
target: 'Palace of Fine Arts, San Francisco',
// i18n-ignore: dev demo of voice-agent labels (not translated)
label: 'Palace of Fine Arts',
color: 'amber',
},
@@ -1284,6 +1206,7 @@ export function createAnnotationEngine({
type: 'arrow',
target: 'Palace of Fine Arts, San Francisco',
toTarget: 'Marina Green, San Francisco',
// i18n-ignore: dev demo of voice-agent labels (not translated)
label: 'next to the Marina',
color: 'cyan',
},
@@ -1307,6 +1230,7 @@ export function createAnnotationEngine({
{
type: 'area',
target: 'Presidio of San Francisco',
// i18n-ignore: dev demo of voice-agent labels (not translated)
label: 'The Presidio — a former Army base',
color: 'green',
footprint: true,
@@ -1321,6 +1245,7 @@ export function createAnnotationEngine({
{
type: 'pin',
target: 'Letterman Digital Arts Center, San Francisco',
// i18n-ignore: dev demo of voice-agent labels (not translated)
label: 'ILM / Lucasfilm',
color: 'red',
},
@@ -1334,6 +1259,7 @@ export function createAnnotationEngine({
{
type: 'route',
color: 'amber',
// i18n-ignore: dev demo of voice-agent labels (not translated)
label: 'Crissy Field shoreline',
points: [
{ target: 'Palace of Fine Arts, San Francisco' },
@@ -1375,46 +1301,8 @@ export function createAnnotationEngine({
return engine;
}
// Race non-cooperative adapters too: cancellation must settle owned queues even
// when an upstream promise never acknowledges AbortSignal. Late rejection is
// consumed, and listeners are removed on either outcome.
function abortable(promise, signal) {
if (!signal) return Promise.resolve(promise);
return new Promise((resolve, reject) => {
const cleanup = () => signal.removeEventListener('abort', cancel);
const cancel = () => {
cleanup();
reject(signal.reason);
};
if (signal.aborted) cancel();
else signal.addEventListener('abort', cancel, { once: true });
Promise.resolve(promise).then(
(value) => {
cleanup();
resolve(value);
},
(error) => {
cleanup();
reject(error);
},
);
});
}
function wait(ms, signal) {
return new Promise((resolve) => {
const cancel = () => {
clearTimeout(timer);
signal?.removeEventListener('abort', cancel);
resolve();
};
const timer = setTimeout(() => {
signal?.removeEventListener('abort', cancel);
resolve();
}, ms);
signal?.addEventListener('abort', cancel, { once: true });
if (signal?.aborted) cancel();
});
function wait(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/** Exact per-vertex ring equality ([[lon,lat],…], ~1m epsilon) — the geometry-identity test. */
@@ -1680,16 +1568,7 @@ async function fetchRoute(coordPairs, mode, signal, service) {
}
function greatCircleM(a, b) {
const R = 6371000;
const toRad = (deg) => (deg * Math.PI) / 180;
const dLat = toRad(b.lat - a.lat);
const dLon = toRad(b.lon - a.lon);
const la1 = toRad(a.lat);
const la2 = toRad(b.lat);
const h =
Math.sin(dLat / 2) ** 2 +
Math.cos(la1) * Math.cos(la2) * Math.sin(dLon / 2) ** 2;
return 2 * R * Math.asin(Math.min(1, Math.sqrt(h)));
return greatCircleMeters(a, b);
}
function formatDistance(m) {
-224
View File
@@ -730,227 +730,3 @@ test('only an explicit navigation request permits resolving distant annotation t
assert.deepEqual(received.map(options => options.allowDistant), [false, true]);
engine.destroy();
});
// Call ownership is narrower than the board's explicit clear/destroy generation.
function deferredAnnotation() {
let resolve;
const promise = new Promise((done) => { resolve = done; });
return { promise, resolve };
}
const cancellationAnchor = { lon: 10, lat: 20, height: 0, source: 'test' };
test('cancelled admission cannot clear existing marks or abort another owner', async (t) => {
installAnimationFrameStubs(t);
const { renderer, calls } = fakeRenderer();
const pending = deferredAnnotation();
let siblingSignal;
const engine = createAnnotationEngine({ viewer: {}, renderer, resolveTarget: ({ signal }) => {
siblingSignal = signal;
return pending.promise;
} });
t.after(() => engine.clear());
await engine.annotate([{ type: 'pin', manual: true, longitude: 1, latitude: 2, label: 'Manual' }]);
const sibling = engine.annotate([{ target: 'Sibling' }]);
const cancelled = new AbortController();
cancelled.abort();
for (const opts of [{ signal: cancelled.signal }, { isCurrent: () => false }, { isCurrent: () => undefined }]) {
const result = await engine.annotate([{ target: 'Stale' }], { ...opts, clearPrevious: true });
assert.equal(result.cancelled, true);
assert.equal(result.ok, false);
assert.equal(result.failed, 0, 'cancellation is not a missing location');
assert.equal(siblingSignal.aborted, false);
assert.equal(engine.count(), 1);
assert.equal(calls.remove, 0);
}
pending.resolve(cancellationAnchor);
assert.equal((await sibling).ok, true);
assert.equal(engine.count(), 2);
});
test('owner abort settles a non-cooperative lookup while another owner may still commit', async (t) => {
installAnimationFrameStubs(t);
const { renderer, calls } = fakeRenderer();
const late = deferredAnnotation();
const sibling = deferredAnnotation();
const signals = new Map();
const engine = createAnnotationEngine({ viewer: {}, renderer, resolveTarget: ({ target, signal }) => {
signals.set(target, signal);
return (target === 'Late' ? late : sibling).promise;
} });
t.after(() => engine.clear());
const owner = new AbortController();
const a = engine.annotate([{ target: 'Late' }], { signal: owner.signal, flyTo: true });
const b = engine.annotate([{ target: 'Sibling' }]);
owner.abort();
const cancelled = await a;
assert.equal(cancelled.cancelled, true);
assert.equal(cancelled.drawn, 0);
assert.equal(signals.get('Late').aborted, true);
assert.equal(signals.get('Sibling').aborted, false);
sibling.resolve(cancellationAnchor);
assert.equal((await b).ok, true);
late.resolve({ ...cancellationAnchor, lon: 40 });
await flushMicrotasks();
assert.equal(engine.count(), 1);
assert.equal(calls.add, 1);
});
test('currentness is rechecked before mutation and AbortError is cancellation, not resolution failure', async (t) => {
installAnimationFrameStubs(t);
const { renderer, calls } = fakeRenderer();
const late = deferredAnnotation();
let current = true;
let signal;
const engine = createAnnotationEngine({ viewer: {}, renderer, resolveTarget: (options) => {
signal = options.signal;
return late.promise;
} });
const pending = engine.annotate([{ target: 'Late' }], { isCurrent: () => current });
current = false;
late.resolve(cancellationAnchor);
assert.equal((await pending).cancelled, true);
assert.equal(signal.aborted, true);
assert.equal(calls.add, 0);
const errorEngine = createAnnotationEngine({ viewer: {}, renderer, resolveTarget: async () => {
throw new DOMException('Cancelled lookup', 'AbortError');
} });
const result = await errorEngine.annotate([{ target: 'Aborted lookup' }]);
assert.equal(result.cancelled, true);
assert.equal(result.failed, 0);
assert.deepEqual(result.results, []);
});
test('outline cancellation frees the queue, preserves committed marks and permits a later duplicate retry', async (t) => {
installAnimationFrameStubs(t);
const { renderer, calls } = fakeRenderer();
const late = deferredAnnotation();
const started = [];
const signals = new Map();
let retries = 0;
const engine = createAnnotationEngine({ viewer: {}, renderer, resolveTarget: async ({ target, signal }) => {
signals.set(target, signal);
const i = Number(target.slice(-1));
return {
...cancellationAnchor, lon: 10 + i,
resolveOutline: () => {
started.push(target);
if (target === 'Other9' || retries) return Promise.resolve(null);
return late.promise;
},
};
} });
t.after(() => engine.clear());
const owner = new AbortController();
await engine.annotate([0, 1, 2, 3].map((i) => ({ type: 'area', target: `Owned${i}` })), { signal: owner.signal });
const other = await engine.annotate([{ type: 'area', target: 'Other9' }]);
assert.equal(other.ok, true);
assert.deepEqual(started, ['Owned0', 'Owned1']);
owner.abort();
await flushMicrotasks();
assert.deepEqual(started, ['Owned0', 'Owned1', 'Other9'], 'cancelled queued outlines never start; sibling is unblocked');
assert.equal(signals.get('Owned0').aborted, true);
assert.equal(signals.get('Other9').aborted, false);
assert.equal(engine.count(), 5);
assert.equal(engine.list().some((mark) => mark.pendingOutline), false);
const updates = calls.update;
late.resolve(FP);
await flushMicrotasks();
assert.equal(calls.update, updates, 'non-cooperative late outline cannot update or deduplicate marks');
retries++;
const retry = await engine.annotate([{ type: 'area', target: 'Owned0' }]);
await flushMicrotasks();
assert.equal(retry.results[0].duplicate, true);
assert.equal(engine.count(), 5);
assert.equal(started.at(-1), 'Owned0');
});
test('caller abort listeners detach after resolution, cancellation and final deferred outline', async (t) => {
const { getEventListeners } = await import('node:events');
installAnimationFrameStubs(t);
const { renderer } = fakeRenderer();
const late = deferredAnnotation();
const engine = createAnnotationEngine({ viewer: {}, renderer, resolveTarget: async ({ target }) => ({
...cancellationAnchor,
...(target === 'Outline' ? { resolveOutline: () => late.promise } : {}),
}) });
t.after(() => engine.clear());
for (const target of ['Plain', 'Outline']) {
const owner = new AbortController();
await engine.annotate([{ type: 'area', target }], { signal: owner.signal });
assert.equal(getEventListeners(owner.signal, 'abort').length, target === 'Plain' ? 0 : 1);
if (target === 'Outline') {
late.resolve(null);
await flushMicrotasks();
assert.equal(getEventListeners(owner.signal, 'abort').length, 0);
}
}
const unresolved = deferredAnnotation();
const cancelledEngine = createAnnotationEngine({ viewer: {}, renderer, resolveTarget: () => unresolved.promise });
const owner = new AbortController();
const work = cancelledEngine.annotate([{ target: 'Never settles' }], { signal: owner.signal });
owner.abort();
await work;
assert.equal(getEventListeners(owner.signal, 'abort').length, 0);
});
test('cancelled arrow and route do not begin the next lookup after a non-cooperative late anchor', async (t) => {
installAnimationFrameStubs(t);
for (const spec of [
{ type: 'arrow', target: 'From', toTarget: 'To' },
{ type: 'route', points: [{ target: 'From' }, { target: 'To' }] },
]) {
const { renderer, calls } = fakeRenderer();
const late = deferredAnnotation();
const targets = [];
const engine = createAnnotationEngine({ viewer: {}, renderer, resolveTarget: ({ target }) => {
targets.push(target);
return late.promise;
} });
const owner = new AbortController();
const pending = engine.annotate([spec], { signal: owner.signal });
owner.abort();
assert.equal((await pending).cancelled, true);
late.resolve(cancellationAnchor);
await flushMicrotasks();
assert.deepEqual(targets, ['From']);
assert.equal(calls.add, 0);
}
});
test('aborting deferred outline backoff settles without another resolver attempt', async () => {
const owner = new AbortController();
const waiting = deferredAnnotation();
const entered = deferredAnnotation();
let attempts = 0;
const pending = resolveOutlineWithRetry(async () => { attempts++; return undefined; }, {
delaysMs: [8000],
signal: owner.signal,
waitFn(ms, signal) {
assert.equal(ms, 8000);
assert.equal(signal, owner.signal);
entered.resolve();
return waiting.promise;
},
});
await entered.promise;
owner.abort();
assert.equal(await pending, undefined);
waiting.resolve();
await flushMicrotasks();
assert.equal(attempts, 1);
});
test('AbortError in one batch item promptly settles its non-cooperative sibling', async (t) => {
installAnimationFrameStubs(t);
const { renderer, calls } = fakeRenderer();
const never = deferredAnnotation();
const engine = createAnnotationEngine({ viewer: {}, renderer, resolveTarget: async ({ target }) => {
if (target === 'Abort') throw new DOMException('Cancelled lookup', 'AbortError');
return never.promise;
} });
const result = await engine.annotate([{ target: 'Abort' }, { target: 'Never' }]);
assert.equal(result.cancelled, true);
assert.equal(result.failed, 0);
assert.equal(calls.add, 0);
});
+19 -16
View File
@@ -1,3 +1,5 @@
import { t, tn } from '../i18n/index.js';
/**
* Manual whiteboard drawing: the pure half.
*
@@ -18,6 +20,8 @@
* `drawTool.js`.
*/
import { greatCircleMeters } from '../geoDistance.js';
export const DRAW_SHAPES = Object.freeze(['area', 'line', 'pin']);
export const MIN_VERTICES = Object.freeze({ area: 3, line: 2, pin: 1 });
/**
@@ -47,14 +51,7 @@ export function createDrawSession(shape = 'area') {
/** Great-circle distance in metres between two {lon, lat} points. */
export function greatCircleM(a, b) {
const R = 6371000;
const toRad = (d) => (d * Math.PI) / 180;
const dLat = toRad(b.lat - a.lat);
const dLon = toRad(b.lon - a.lon);
const h =
Math.sin(dLat / 2) ** 2 +
Math.cos(toRad(a.lat)) * Math.cos(toRad(b.lat)) * Math.sin(dLon / 2) ** 2;
return 2 * R * Math.asin(Math.sqrt(Math.min(1, h)));
return greatCircleMeters(a, b);
}
/**
@@ -284,20 +281,26 @@ export function closeRing(pairs) {
/** One line of guidance for the person drawing, by state. */
export function drawHint(session) {
if (!session) return 'Pick a shape, then click the map.';
if (!session) return t('Pick a shape, then click the map.');
const n = session.vertices.length;
if (session.shape === 'pin')
return n
? 'Enter to place the pin, Esc to cancel.'
: 'Click where the pin goes.';
? t('Enter to place the pin, Esc to cancel.')
: t('Click where the pin goes.');
const need = MIN_VERTICES[session.shape] - n;
if (need > 0) return `Click ${need} more point${need === 1 ? '' : 's'}.`;
if (need > 0)
return tn(need, 'Click {count} more point.', 'Click {count} more points.');
if (finishReason(session) === 'degenerate') {
return session.shape === 'area'
? 'Those points are in a line — move one off it to enclose an area.'
: 'That line has no length — click somewhere further away.';
? t('Those points are in a line — move one off it to enclose an area.')
: t('That line has no length — click somewhere further away.');
}
const full =
n >= MAX_VERTICES ? ` · ${MAX_VERTICES}-point limit reached` : '';
return `${formatMeasure(session)} · double-click or Enter to finish, Backspace undoes, Esc cancels.${full}`;
n >= MAX_VERTICES
? t(' · {max}-point limit reached', { max: MAX_VERTICES })
: '';
return t(
'{measure} · double-click or Enter to finish, Backspace undoes, Esc cancels.{full}',
{ measure: formatMeasure(session), full },
);
}
+31 -6
View File
@@ -36,6 +36,7 @@ import {
pointerOwner,
releasePointer,
} from '../data/inputOwnership.js';
import { pointerOwnerName } from '../data/pointerOwnerNames.js';
import {
DRAW_SHAPES,
MAX_VERTICES,
@@ -48,6 +49,7 @@ import {
removeLastVertex,
} from './drawMode.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';
@@ -100,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
@@ -197,10 +210,13 @@ export function initDrawTool({ viewer, annotations }) {
// a limit rather than the harmless tail of a double-click.
if (reason === 'full')
setHint(
`That shape already has ${MAX_VERTICES} points — finish it or press Backspace.`,
t(
'That shape already has {count} points — finish it or press Backspace.',
{ count: MAX_VERTICES },
),
);
else if (reason === 'invalid')
setHint('That point is off the globe — click on the world.');
setHint(t('That point is off the globe — click on the world.'));
};
const onMove = (event) => {
if (!session || session.shape === 'pin' || destroyed) return;
@@ -236,11 +252,15 @@ export function initDrawTool({ viewer, annotations }) {
flyTo: false,
});
if (destroyed || attempt !== generation) return result;
if (result?.drawn === 0) setHint('That shape could not be placed.');
if (result?.drawn === 0) setHint(t('That shape could not be placed.'));
return result;
} catch (error) {
if (destroyed || attempt !== generation) return null;
setHint(`Could not place the shape: ${error?.message || error}`);
setHint(
t('Could not place the shape: {error}', {
error: error?.message || error,
}),
);
return null;
}
};
@@ -258,7 +278,7 @@ export function initDrawTool({ viewer, annotations }) {
if (session) session = createDrawSession(shape);
annotations.clear();
syncPreview();
if (!destroyed && !active) setHint('Board cleared.');
if (!destroyed && !active) setHint(t('Board cleared.'));
};
// ---- keys: only while drawing, never while typing in another field ----
@@ -324,7 +344,11 @@ export function initDrawTool({ viewer, annotations }) {
// Somebody else is using the pointer — possibly an older instance of
// this same tool that has not finished tearing down. Say so instead of
// half-starting.
setHint(`${pointerOwner()} is using the pointer — close it first.`);
setHint(
t('{owner} is using the pointer — close it first.', {
owner: translateKnown(pointerOwnerName(pointerOwner())),
}),
);
return active;
}
}
@@ -502,6 +526,7 @@ export function initDrawTool({ viewer, annotations }) {
generation += 1;
if (active) setActive(false);
destroyed = true;
unregisterUnsavedWork();
releaseSceneHandler();
releasePointer(lease);
lease = null;
+29 -1
View File
@@ -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,29 @@ const LEFT_DOUBLE_CLICK = Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK;
/* ── the tests ─────────────────────────────────────────────────────────── */
// First in the file: tools left by later tests 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 +287,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.
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);
+11 -5
View File
@@ -4,6 +4,12 @@ import {
getActiveTrackedReadoutId,
TRACKED_OVERLAY_SOURCE_ID,
} from '../data/trackedReadout.js';
import { t } from '../i18n/index.js';
/** A label whose detailed outline could not be resolved. */
function outlineUnavailableLabel(label) {
return t('{label} · Detailed outline unavailable', { label: label || '' });
}
/**
* Screen-space annotation renderer (Direction B — the "whiteboard" aesthetic).
@@ -163,7 +169,7 @@ export function createScreenAnnotationRenderer(
group.appendChild(parts.poly);
parts.label = makeCallout(
anno.outlineUnavailable
? `${anno.label || ''} · Detailed outline unavailable`
? outlineUnavailableLabel(anno.label)
: anno.label,
c,
);
@@ -181,7 +187,7 @@ export function createScreenAnnotationRenderer(
group.appendChild(parts.path);
parts.label = makeCallout(
anno.outlineUnavailable
? `${anno.label || ''} · Detailed outline unavailable`
? outlineUnavailableLabel(anno.label)
: anno.label,
c,
);
@@ -215,7 +221,7 @@ export function createScreenAnnotationRenderer(
});
parts.label = makeCallout(
anno.outlineUnavailable
? `${anno.label || ''} · Detailed outline unavailable`
? outlineUnavailableLabel(anno.label)
: anno.label,
c,
);
@@ -259,7 +265,7 @@ export function createScreenAnnotationRenderer(
group.appendChild(parts.leader);
parts.label = makeCallout(
anno.outlineUnavailable
? `${anno.label || ''} · Detailed outline unavailable`
? outlineUnavailableLabel(anno.label)
: anno.label,
c,
);
@@ -322,7 +328,7 @@ export function createScreenAnnotationRenderer(
}
rec.anno = anno;
if (anno.outlineUnavailable && rec.parts.label) {
rec.parts.label.text.textContent = `${anno.label || ''} · Detailed outline unavailable`;
rec.parts.label.text.textContent = outlineUnavailableLabel(anno.label);
sizeCallout(rec.parts.label);
}
+3 -2
View File
@@ -2,6 +2,7 @@ import { catalogControlServices } from './catalog.js';
import { StyleManager } from '../ui/composition.js';
import { flyToAustin } from '../camera.js';
import { initCockpitCloudEffects } from '../cockpitCloudEffects.js';
import { t } from '../i18n/index.js';
/** Construct the existing controls and camera presentation. */
export function createApplicationControls({
@@ -41,10 +42,10 @@ export function createApplicationControls({
// If no share link state, do default fly-to Austin
if (!styleManager.hasShareState) {
loaderStatus.textContent = 'Flying to Austin, TX...';
loaderStatus.textContent = t('Flying to Austin, TX...');
defer(flyToAustin(viewer));
} else {
loaderStatus.textContent = 'Restoring shared view...';
loaderStatus.textContent = t('Restoring shared view...');
}
return { styleManager, weatherEffects, cockpitCloudEffects };
+7
View File
@@ -541,6 +541,7 @@ export function panelRuntime(config) {
const notice = document.createElement('div');
notice.id = 'status';
notice.textContent =
// i18n-ignore: host-panel script, no app catalogs
"This panel lost its 3D graphics, likely because other God's Eye " +
"View panels in this conversation hold them. Use Open in God's " +
'Eye View above, or show it in a new conversation.';
@@ -568,13 +569,16 @@ export function panelRuntime(config) {
queued = view;
if (started) return;
started = true;
// i18n-ignore: host-panel script, no app catalogs
say("Loading God's Eye View…");
startApp(url, view).catch((error) =>
// i18n-ignore: host-panel script, no app catalogs
say(`God's Eye View could not load here: ${error?.message || error}.`),
);
setTimeout(() => {
if (!ready)
say(
// i18n-ignore: see above
"God's Eye View did not load here" +
(blocked ? ` (${blocked})` : '') +
". Use Open in God's Eye View above.",
@@ -616,6 +620,7 @@ export function panelRuntime(config) {
if (context?.displayMode) displayMode = context.displayMode;
if (context?.availableDisplayModes)
expand.hidden = !context.availableDisplayModes.includes('fullscreen');
// i18n-ignore: host-panel script, no app catalogs
expand.textContent = displayMode === 'fullscreen' ? 'Collapse' : 'Expand';
};
expand.addEventListener('click', () => {
@@ -640,6 +645,7 @@ export function panelRuntime(config) {
appCapabilities: { availableDisplayModes: ['inline', 'fullscreen'] },
appInfo: {
name: 'gods-eye-view',
// i18n-ignore: product name sent to the MCP host
title: "God's Eye View",
version: '1.0.0',
},
@@ -655,6 +661,7 @@ export function panelRuntime(config) {
},
(error) =>
say(
// i18n-ignore: host-panel script, no app catalogs
"This client did not accept the God's Eye View panel" +
(error?.message ? `: ${error.message}` : '.'),
),
+2 -2
View File
@@ -3,7 +3,7 @@ import * as credits from '../../data/dataCredits.js';
import * as sprites from '../../data/spriteOrder.js';
import * as activation from '../../cctvFocusRequest.js';
import * as overlays from '../../overlays/worldOverlay.js';
import * as locations from '../../locations.js';
import { CITY_POIS } from '../../cityPois.js';
import * as picking from '../../data/pickRegistry.js';
import * as focus from '../../data/focusDeemphasis.js';
import * as render from '../../renderGovernor.js';
@@ -18,7 +18,7 @@ export function createApplicationCctv({ surface, source }) {
sprites,
activation,
overlays,
locations,
locations: { CITY_POIS },
picking,
terrain,
ground,
+11 -5
View File
@@ -14,6 +14,7 @@ import {
governorRequestRender,
} from '../renderGovernor.js';
import { describeError } from './errors.js';
import { t } from '../i18n/index.js';
/** Construct the application globe using the caller's local configuration. */
export async function createApplicationScene({
@@ -42,7 +43,7 @@ export async function createApplicationScene({
else window.__GOOGLE_MAPS_API_KEY__ = previousKey;
});
}
loaderStatus.textContent = 'Configuring viewer...';
loaderStatus.textContent = t('Configuring viewer...');
// Provider attribution stays visible, including clean-view and recording.
const creditContainer = document.createElement('div');
creditContainer.id = 'cesium-credits';
@@ -61,8 +62,8 @@ export async function createApplicationScene({
configureCreditKeyboardAccess(document);
loaderStatus.textContent =
googleApiKey || googleTokens || cesiumToken
? 'Loading Google 3D Tiles...'
: 'Loading the keyless globe...';
? t('Loading Google 3D Tiles...')
: t('Loading the keyless globe...');
const photoreal = await loadPhotorealisticTileset(Cesium, {
googleApiKey,
googleTokens,
@@ -90,12 +91,17 @@ export async function createApplicationScene({
tileError,
);
const tileErrorDetail = describeError(tileError);
loaderStatus.textContent = `Google 3D Tiles unavailable (${tileErrorDetail}). Loading the keyless globe...`;
loaderStatus.textContent = t(
'Google 3D Tiles unavailable ({detail}). Loading the keyless globe...',
{
detail: tileErrorDetail,
},
);
}
viewer.scene.globe.show = true;
}
loaderStatus.textContent = 'Initializing systems...';
loaderStatus.textContent = t('Initializing systems...');
const mapStackController = new MapController(viewer, {
requestRender: governorRequestRender,
-8
View File
@@ -183,14 +183,6 @@ export function createApplicationTools({
dataManager,
sceneDirector,
annotations,
onSessionEvent: (event) => {
voice.onSessionEvent?.(event);
// Radio playback stops voice so both never share the speaker.
if (event?.type === 'voice-suspended' && event.reason === 'radio')
styleManager._showToast?.(
'Voice stopped for Radio. Pause or stop Radio to resume voice.',
);
},
});
defer(() => {
voiceCommands.stop({ removeUi: true });
+514
View File
@@ -0,0 +1,514 @@
/**
* @module cityPois
* @description The preset cities and their landmarks — plain data with no
* Cesium, so modules that only need the names (voice cards) can import it.
*/
/**
* Points of Interest per city.
* Each city has 5 POIs; the first is the default fly-to landmark.
*
* Field reference:
* alt — RANGE (distance from target in meters), NOT absolute altitude
* heading — optimal camera heading in degrees (0=N, 90=E, 180=S, 270=W)
* pitch — camera tilt in degrees (negative = looking down)
* buildingHeight — estimated height of landmark center above ground (meters)
*/
export const CITY_POIS = {
austin: {
name: 'Austin',
groundElevation: 150, // meters above WGS84 ellipsoid
viewBounds: {
southwest: { lat: 30.1, lng: -97.95 },
northeast: { lat: 30.52, lng: -97.55 },
},
pois: [
{
name: 'Texas State Capitol',
lat: 30.2747,
lon: -97.7403,
alt: 550,
pitch: -28,
heading: 180,
buildingHeight: 35,
},
{
name: 'Frost Bank Tower',
lat: 30.2674,
lon: -97.7434,
alt: 550,
pitch: -22,
heading: 30,
buildingHeight: 80,
},
{
name: 'Pennybacker Bridge',
lat: 30.3451,
lon: -97.7951,
alt: 500,
pitch: -25,
heading: 90,
buildingHeight: 40,
},
{
name: 'The Jenga Tower',
lat: 30.2642,
lon: -97.75,
alt: 500,
pitch: -18,
heading: 45,
buildingHeight: 60,
},
{
name: 'UT Tower',
lat: 30.2862,
lon: -97.7394,
alt: 500,
pitch: -22,
heading: 180,
buildingHeight: 50,
},
],
},
sf: {
name: 'San Francisco',
groundElevation: 15,
viewBounds: {
southwest: { lat: 37.7, lng: -122.53 },
northeast: { lat: 37.84, lng: -122.35 },
},
pois: [
{
name: 'Golden Gate Bridge',
lat: 37.8199,
lon: -122.4783,
alt: 1400,
pitch: -20,
heading: 45,
buildingHeight: 100,
},
{
name: 'Transamerica Pyramid',
lat: 37.7952,
lon: -122.4028,
alt: 500,
pitch: -25,
heading: 30,
buildingHeight: 85,
},
{
name: 'Salesforce Tower',
lat: 37.7897,
lon: -122.3972,
alt: 680,
pitch: -25,
heading: 330,
buildingHeight: 100,
},
{
name: 'Alcatraz Island',
lat: 37.8267,
lon: -122.423,
alt: 800,
pitch: -30,
heading: 0,
buildingHeight: 20,
},
{
name: 'Coit Tower',
lat: 37.8024,
lon: -122.4058,
alt: 420,
pitch: -30,
heading: 45,
buildingHeight: 30,
},
],
},
nyc: {
name: 'New York',
groundElevation: 10,
viewBounds: {
southwest: { lat: 40.477, lng: -74.259 },
northeast: { lat: 40.918, lng: -73.7 },
},
pois: [
{
name: 'Statue of Liberty',
lat: 40.6892,
lon: -74.0445,
alt: 450,
pitch: -25,
heading: 315,
buildingHeight: 45,
},
{
name: 'Empire State Building',
lat: 40.7484,
lon: -73.9857,
alt: 850,
pitch: -12,
heading: 30,
buildingHeight: 130,
buildingBounds: { height: 443, width: 130, depth: 75 },
},
{
name: 'One World Trade Center',
lat: 40.7127,
lon: -74.0134,
alt: 850,
pitch: -25,
heading: 0,
buildingHeight: 170,
},
{
name: 'Brooklyn Bridge',
lat: 40.7061,
lon: -73.9969,
alt: 850,
pitch: -25,
heading: 45,
buildingHeight: 40,
},
{
name: 'Chrysler Building',
lat: 40.7516,
lon: -73.9755,
alt: 700,
pitch: -20,
heading: 225,
buildingHeight: 100,
},
],
},
tokyo: {
name: 'Tokyo',
groundElevation: 40,
viewBounds: {
southwest: { lat: 35.52, lng: 139.55 },
northeast: { lat: 35.9, lng: 139.92 },
},
pois: [
{
name: 'Tokyo Tower',
lat: 35.6586,
lon: 139.7454,
alt: 850,
pitch: -25,
heading: 0,
buildingHeight: 110,
},
{
name: 'Tokyo Skytree',
lat: 35.7101,
lon: 139.8107,
alt: 900,
pitch: -25,
heading: 30,
buildingHeight: 200,
},
{
name: 'Imperial Palace',
lat: 35.6852,
lon: 139.7528,
alt: 900,
pitch: -35,
heading: 0,
buildingHeight: 20,
},
{
name: 'Senso-ji Temple',
lat: 35.7148,
lon: 139.7967,
alt: 400,
pitch: -30,
heading: 180,
buildingHeight: 25,
},
{
name: 'Mode Gakuen Cocoon Tower',
lat: 35.6929,
lon: 139.6925,
alt: 350,
pitch: -20,
heading: 30,
buildingHeight: 70,
},
],
},
london: {
name: 'London',
groundElevation: 15,
viewBounds: {
southwest: { lat: 51.28, lng: -0.51 },
northeast: { lat: 51.7, lng: 0.33 },
},
pois: [
{
name: 'Tower Bridge',
lat: 51.5055,
lon: -0.0754,
alt: 400,
pitch: -25,
heading: 270,
buildingHeight: 65,
},
{
name: 'The Shard',
lat: 51.5045,
lon: -0.0865,
alt: 850,
pitch: -20,
heading: 0,
buildingHeight: 100,
},
{
name: 'Big Ben / Parliament',
lat: 51.5007,
lon: -0.1246,
alt: 600,
pitch: -25,
heading: 180,
buildingHeight: 50,
},
{
name: "St. Paul's Cathedral",
lat: 51.5138,
lon: -0.0984,
alt: 400,
pitch: -30,
heading: 270,
buildingHeight: 55,
},
{
name: 'The Gherkin',
lat: 51.5145,
lon: -0.0803,
alt: 350,
pitch: -20,
heading: 30,
buildingHeight: 60,
},
],
},
paris: {
name: 'Paris',
groundElevation: 35,
viewBounds: {
southwest: { lat: 48.815, lng: 2.224 },
northeast: { lat: 48.902, lng: 2.47 },
},
pois: [
{
name: 'Eiffel Tower',
lat: 48.8584,
lon: 2.2945,
alt: 750,
pitch: -25,
heading: 315,
buildingHeight: 150,
},
{
name: 'Arc de Triomphe',
lat: 48.8738,
lon: 2.295,
alt: 400,
pitch: -28,
heading: 45,
buildingHeight: 25,
},
{
name: 'Notre-Dame',
lat: 48.853,
lon: 2.3499,
alt: 400,
pitch: -25,
heading: 225,
buildingHeight: 35,
},
{
name: 'Sacré-Cœur',
lat: 48.8867,
lon: 2.3431,
alt: 400,
pitch: -30,
heading: 180,
buildingHeight: 40,
},
{
name: 'Louvre Pyramid',
lat: 48.8606,
lon: 2.3376,
alt: 500,
pitch: -35,
heading: 0,
buildingHeight: 10,
},
],
},
dubai: {
name: 'Dubai',
groundElevation: 5,
viewBounds: {
southwest: { lat: 24.95, lng: 54.9 },
northeast: { lat: 25.35, lng: 55.55 },
},
pois: [
{
name: 'Burj Khalifa',
lat: 25.1972,
lon: 55.2744,
alt: 600,
pitch: -20,
heading: 200,
buildingHeight: 270,
},
{
name: 'Burj Al Arab',
lat: 25.1412,
lon: 55.1853,
alt: 500,
pitch: -25,
heading: 90,
buildingHeight: 100,
},
{
name: 'Palm Jumeirah',
lat: 25.1124,
lon: 55.139,
alt: 1200,
pitch: -40,
heading: 0,
buildingHeight: 20,
},
{
name: 'Dubai Frame',
lat: 25.235,
lon: 55.3003,
alt: 400,
pitch: -25,
heading: 270,
buildingHeight: 75,
},
{
name: 'Museum of the Future',
lat: 25.2197,
lon: 55.2806,
alt: 350,
pitch: -20,
heading: 30,
buildingHeight: 35,
},
],
},
dc: {
name: 'Washington DC',
groundElevation: 10,
viewBounds: {
southwest: { lat: 38.79, lng: -77.12 },
northeast: { lat: 38.995, lng: -76.91 },
},
pois: [
{
name: 'US Capitol',
lat: 38.8897,
lon: -77.0091,
alt: 550,
pitch: -25,
heading: 270,
buildingHeight: 45,
},
{
name: 'Washington Monument',
lat: 38.8895,
lon: -77.0353,
alt: 500,
pitch: -30,
heading: 0,
buildingHeight: 85,
},
{
name: 'Lincoln Memorial',
lat: 38.8893,
lon: -77.0502,
alt: 400,
pitch: -25,
heading: 90,
buildingHeight: 20,
},
{
name: 'Pentagon',
lat: 38.8711,
lon: -77.0559,
alt: 800,
pitch: -40,
heading: 0,
buildingHeight: 20,
},
{
name: 'Jefferson Memorial',
lat: 38.8814,
lon: -77.0365,
alt: 400,
pitch: -30,
heading: 0,
buildingHeight: 25,
},
],
},
tallinn: {
name: 'Tallinn',
groundElevation: 15,
viewBounds: {
southwest: { lat: 59.36, lng: 24.6 },
northeast: { lat: 59.52, lng: 24.92 },
},
pois: [
{
name: 'Viru Square',
lat: 59.4366,
lon: 24.7527,
alt: 450,
pitch: -28,
heading: 60,
buildingHeight: 25,
},
{
name: 'Old Town / Raekoja plats',
lat: 59.4372,
lon: 24.7452,
alt: 400,
pitch: -30,
heading: 180,
buildingHeight: 20,
},
{
name: 'Teatri väljak',
lat: 59.4344,
lon: 24.7514,
alt: 400,
pitch: -25,
heading: 220,
buildingHeight: 25,
},
{
name: 'Port of Tallinn',
lat: 59.4445,
lon: 24.7675,
alt: 700,
pitch: -30,
heading: 90,
buildingHeight: 20,
},
{
name: 'Ülemiste',
lat: 59.421,
lon: 24.792,
alt: 600,
pitch: -28,
heading: 45,
buildingHeight: 30,
},
],
},
};
+2 -1
View File
@@ -8,7 +8,8 @@
// say nothing about whether a destination is framed well.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { CITY_POIS, LOCATIONS } from './locations.js';
import { CITY_POIS } from './cityPois.js';
import { LOCATIONS } from './locations.js';
const DESTINATIONS = Object.entries(CITY_POIS);
const REQUIRED_POI_KEYS = ['name', 'lat', 'lon', 'alt', 'pitch', 'heading'];
+4 -17
View File
@@ -1,5 +1,6 @@
import { applicationServices } from './services/application.js';
import * as Cesium from 'cesium';
import { greatCircleMeters } from './geoDistance.js';
import {
deriveWeatherEffectProfile,
weatherAltitudeFactors,
@@ -127,23 +128,9 @@ function compileShader(gl, type, source) {
}
function greatCircleM(a, b) {
if (
![a?.latitude, a?.longitude, b?.latitude, b?.longitude].every(
Number.isFinite,
)
)
return Infinity;
const latitudeA = Cesium.Math.toRadians(a.latitude);
const latitudeB = Cesium.Math.toRadians(b.latitude);
const latitudeDelta = Cesium.Math.toRadians(b.latitude - a.latitude);
const longitudeDelta = Cesium.Math.toRadians(b.longitude - a.longitude);
const haversine =
Math.sin(latitudeDelta / 2) ** 2 +
Math.cos(latitudeA) *
Math.cos(latitudeB) *
Math.sin(longitudeDelta / 2) ** 2;
return (
6371000 * 2 * Math.atan2(Math.sqrt(haversine), Math.sqrt(1 - haversine))
return greatCircleMeters(
{ lat: a?.latitude, lon: a?.longitude },
{ lat: b?.latitude, lon: b?.longitude },
);
}
+647 -183
View File
File diff suppressed because it is too large. Load diff
+45 -11
View File
@@ -83,31 +83,65 @@ export function cockpitAltitudeDisplayFt(altitudeM, onGround) {
return Number.isFinite(altitudeM) ? altitudeM * 3.28084 : null;
}
/** The English emergency line; see formatCockpitEmergency. */
const ENGLISH_EMERGENCY = Object.freeze({
squawk: ({ squawk, label }) => `SQUAWK ${squawk} · ${label}`,
adsb: ({ priority, label }) =>
`ADS-B ${priority ? 'PRIORITY' : 'EMERGENCY'} · ${label}`,
});
/**
* HUD wording for a tracked aircraft's transponder emergency, or null.
* States what was broadcast, never a conclusion about the flight: the Mode A
* code when it names the status, else the ADS-B field it came from (an
* unrelated squawk is dropped).
* @param {{label?: string, squawk?: string|null, source?: string,
* severity?: string}|null|undefined} emergency
* The cockpit passes its own formatter so the page shows it in the active
* language; this module stays i18n-free.
* @param {{label?: string, squawk?: string|null,
* source?: string, severity?: string}|null|undefined} emergency
* @param {{squawk: Function, adsb: Function}} [format] Builds the line from
* `{ name, label, squawk }` and `{ name, label, priority }`, where
* `label` is the upper-cased `name`.
* @returns {string|null}
*/
export function formatCockpitEmergency(emergency) {
export function formatCockpitEmergency(emergency, format = ENGLISH_EMERGENCY) {
const label = String(emergency?.label || '')
.trim()
.toUpperCase();
if (!label) return null;
const name = String(emergency.label).trim();
if (emergency.source === 'squawk' && emergency.squawk)
return `SQUAWK ${emergency.squawk} · ${label}`;
const field = emergency.severity === 'priority' ? 'PRIORITY' : 'EMERGENCY';
return `ADS-B ${field} · ${label}`;
return format.squawk({ name, label, squawk: emergency.squawk });
return format.adsb({
name,
label,
priority: emergency.severity === 'priority',
});
}
/** Format the cockpit Context scope without overstating installation coverage. */
/** The English Context scope line; see formatCockpitContextScope. */
const ENGLISH_CONTEXT_SCOPE = Object.freeze({
window: ({ label, radiusKm }) =>
`${label} · ${radiusKm ?? '—'} KM AIR/SEA WINDOW`,
installations: ({ scope, coverage }) =>
`${scope} · INSTALLATIONS ${coverage}`,
});
/**
* Format the cockpit Context scope without overstating installation coverage.
* @param {string|number} subjectLabel
* @param {number} radiusM
* @param {string|null} [installationCoverage]
* @param {{window: Function, installations: Function}} [format] Builds the
* line from `{ label, radiusKm }` (radiusKm is null when unknown) and
* `{ scope, coverage }`.
* @returns {string}
*/
export function formatCockpitContextScope(
subjectLabel,
radiusM,
installationCoverage = null,
format = ENGLISH_CONTEXT_SCOPE,
) {
const normalizedLabel =
typeof subjectLabel === 'string'
@@ -117,12 +151,12 @@ export function formatCockpitContextScope(
: '—';
const radiusKm =
Number.isFinite(radiusM) && radiusM >= 0
? String(Math.round(radiusM / 1000))
: '—';
? Math.round(radiusM / 1000)
: null;
const coverage =
typeof installationCoverage === 'string' ? installationCoverage.trim() : '';
const base = `${normalizedLabel} · ${radiusKm} KM AIR/SEA WINDOW`;
return coverage ? `${base} · INSTALLATIONS ${coverage}` : base;
const base = format.window({ label: normalizedLabel, radiusKm });
return coverage ? format.installations({ scope: base, coverage }) : base;
}
/** Return seven 30-degree compass divisions centered on a heading. */
+206 -53
View File
@@ -4,7 +4,9 @@ import { afterEach } from 'node:test';
import { formatAwarenessLabel } from './data/militaryAwarenessEngine.js';
import { CockpitViewController } from './ui/cockpitController.js';
const previousDocument = globalThis.document;
afterEach(() => { globalThis.document = previousDocument; });
afterEach(() => {
globalThis.document = previousDocument;
});
// Model native focus loss on removal AND on ordinary DOM moves. Keeping an
// object reference alone is insufficient if reconciliation disconnects it.
@@ -22,23 +24,60 @@ function fixture() {
this.className = '';
this._text = '';
this.classList = {
add: (...names) => { this.className = [...new Set([...this.className.split(/\s+/).filter(Boolean), ...names])].join(' '); },
add: (...names) => {
this.className = [
...new Set([
...this.className.split(/\s+/).filter(Boolean),
...names,
]),
].join(' ');
},
};
}
get isConnected() { return this === document.body || Boolean(this.parentNode?.isConnected); }
get firstElementChild() { return this.children[0] || null; }
get nextElementSibling() { return this.parentNode?.children[this.parentNode.children.indexOf(this) + 1] || null; }
get textContent() { return this._text + this.children.map((node) => node.textContent).join(''); }
set textContent(value) { this.replaceChildren(); this._text = String(value); }
setAttribute(name, value) { this.attributes.set(name, String(value)); }
getAttribute(name) { return this.attributes.get(name) ?? null; }
contains(node) { return this === node || this.children.some((child) => child.contains(node)); }
append(...nodes) { for (const node of nodes) this.insertBefore(node, null); }
get isConnected() {
return this === document.body || Boolean(this.parentNode?.isConnected);
}
get firstElementChild() {
return this.children[0] || null;
}
get nextElementSibling() {
return (
this.parentNode?.children[this.parentNode.children.indexOf(this) + 1] ||
null
);
}
get textContent() {
return (
this._text + this.children.map((node) => node.textContent).join('')
);
}
set textContent(value) {
this.replaceChildren();
this._text = String(value);
}
setAttribute(name, value) {
this.attributes.set(name, String(value));
}
getAttribute(name) {
return this.attributes.get(name) ?? null;
}
contains(node) {
return (
this === node || this.children.some((child) => child.contains(node))
);
}
append(...nodes) {
for (const node of nodes) this.insertBefore(node, null);
}
insertBefore(node, anchor) {
assert.ok(anchor === null || anchor.parentNode === this, 'insert anchor belongs to its parent');
assert.ok(
anchor === null || anchor.parentNode === this,
'insert anchor belongs to its parent',
);
if (node === anchor) return node;
node.remove();
const index = anchor === null ? this.children.length : this.children.indexOf(anchor);
const index =
anchor === null ? this.children.length : this.children.indexOf(anchor);
this.children.splice(index, 0, node);
node.parentNode = this;
return node;
@@ -49,7 +88,10 @@ function fixture() {
document.activeElement = document.body;
focusLosses += 1;
}
this.parentNode.children.splice(this.parentNode.children.indexOf(this), 1);
this.parentNode.children.splice(
this.parentNode.children.indexOf(this),
1,
);
this.parentNode = null;
}
replaceChildren(...nodes) {
@@ -59,20 +101,32 @@ function fixture() {
}
matches(selector) {
if (selector === 'button[data-signal-layer][data-signal-id]') {
return this.tagName === 'BUTTON' && this.dataset.signalLayer != null && this.dataset.signalId != null;
return (
this.tagName === 'BUTTON' &&
this.dataset.signalLayer != null &&
this.dataset.signalId != null
);
}
return selector.startsWith('.') ? this.className.split(/\s+/).includes(selector.slice(1))
return selector.startsWith('.')
? this.className.split(/\s+/).includes(selector.slice(1))
: this.tagName === selector.toUpperCase();
}
closest(selector) {
for (let node = this; node; node = node.parentNode) if (node.matches(selector)) return node;
for (let node = this; node; node = node.parentNode)
if (node.matches(selector)) return node;
return null;
}
querySelectorAll(selector) {
return this.children.flatMap((child) => [ ...(child.matches(selector) ? [child] : []), ...child.querySelectorAll(selector) ]);
return this.children.flatMap((child) => [
...(child.matches(selector) ? [child] : []),
...child.querySelectorAll(selector),
]);
}
focus() {
assert.ok(this.isConnected, 'a focus destination must still be connected');
assert.ok(
this.isConnected,
'a focus destination must still be connected',
);
focusCalls += 1;
document.activeElement = this;
}
@@ -85,7 +139,11 @@ function fixture() {
controller.services = { formatAwarenessLabel };
controller.signalList = new Node('ol');
controller.signalToggle = new Node('button');
controller.briefTabs = [new Node('button'), new Node('button'), new Node('button')];
controller.briefTabs = [
new Node('button'),
new Node('button'),
new Node('button'),
];
controller.briefPageIndex = 0;
controller.signalItems = [];
controller.signalSignatures = new Map();
@@ -93,64 +151,131 @@ function fixture() {
const display = new Node('button');
const radio = new Node('button');
const outside = new Node('button');
document.body.append(controller.signalToggle, controller.signalList, ...controller.briefTabs, display, radio, outside);
document.body.append(
controller.signalToggle,
controller.signalList,
...controller.briefTabs,
display,
radio,
outside,
);
const selected = [];
controller.services.militaryAwarenessLayer = { focusTarget: (...args) => selected.push(args) };
const click = event => controller.handleSignalClick(event);
controller.services.militaryAwarenessLayer = {
focusTarget: (...args) => selected.push(args),
};
const click = (event) => controller.handleSignalClick(event);
const buttons = () => controller.signalList.querySelectorAll('button');
const tab = () => {
const nodes = document.body.querySelectorAll('button');
const index = nodes.indexOf(document.activeElement);
nodes[(index + 1) % nodes.length].focus();
};
return { controller, document, buttons, display, radio, outside, selected, click, tab,
losses: () => focusLosses, focusCalls: () => focusCalls };
return {
controller,
document,
buttons,
display,
radio,
outside,
selected,
click,
tab,
losses: () => focusLosses,
focusCalls: () => focusCalls,
};
}
const item = (id, overrides = {}) => ({
key: `flight:flights:${id}`, tone: 'nearby', title: `Flight ${id}`, detail: 'COMMERCIAL FLIGHT · 2 KM',
target: { layerId: 'flights', id }, timestamp: 1_700_000_000_000, ...overrides,
key: `flight:flights:${id}`,
tone: 'nearby',
title: `Flight ${id}`,
detail: 'COMMERCIAL FLIGHT · 2 KM',
target: { layerId: 'flights', id },
timestamp: 1_700_000_000_000,
...overrides,
});
const render = (f, items) => { f.controller.signalItems = items; f.controller.renderCockpitSignals(); };
const render = (f, items) => {
f.controller.signalItems = items;
f.controller.renderCockpitSignals();
};
const snapshot = (distanceM = 2000) => ({
evaluatedAt: 1_700_000_000_000,
subject: { layerId: 'flights', id: 'current', label: 'CURRENT' },
cohorts: [{ id: 'flights', count: 1, nearest: [{ id: 'nearby', label: 'NEARBY', distanceM }] }],
cohorts: [
{
id: 'flights',
count: 1,
nearest: [{ id: 'nearby', label: 'NEARBY', distanceM }],
},
],
});
test('actual Context refresh retains the focused signal button across unchanged and changed details', () => {
const f = fixture();
f.controller.updateCockpitSignals(snapshot(), 0);
const target = f.buttons()[1]; target.focus();
const target = f.buttons()[1];
target.focus();
const calls = f.focusCalls();
for (let tick = 0; tick < 8; tick += 1) f.controller.updateCockpitSignals(snapshot(2500), 0);
for (let tick = 0; tick < 8; tick += 1)
f.controller.updateCockpitSignals(snapshot(2500), 0);
assert.equal(f.buttons()[1], target);
assert.equal(f.document.activeElement, target);
assert.equal(f.losses(), 0);
assert.equal(f.focusCalls(), calls, 'refresh must preserve focus without repeatedly restoring it');
assert.match(f.controller.signalList.textContent, /2\.5 KM/);
assert.equal(
f.focusCalls(),
calls,
'refresh must preserve focus without repeatedly restoring it',
);
assert.match(f.controller.signalList.textContent, /2\.5\u00a0KM/);
});
test('signal content and accessible name update without replacing the active target', () => {
const f = fixture(); render(f, [item('a')]);
const target = f.buttons()[0]; target.focus();
render(f, [item('a', { title: 'RENAMED', tone: 'track', detail: 'CURRENT', timestamp: 1_700_000_001_000 })]);
const f = fixture();
render(f, [item('a')]);
const target = f.buttons()[0];
target.focus();
render(f, [
item('a', {
title: 'RENAMED',
tone: 'track',
detail: 'CURRENT',
timestamp: 1_700_000_001_000,
}),
]);
assert.equal(f.buttons()[0], target);
assert.equal(target.getAttribute('aria-label'), 'Select flight RENAMED');
assert.match(target.textContent, /RENAMED/);
assert.match(f.controller.signalList.textContent, /CURRENT/);
assert.equal(f.controller.signalList.children[0].className, 'track actionable');
assert.equal(
f.controller.signalList.children[0].className,
'track actionable',
);
assert.equal(f.document.activeElement, target);
assert.equal(f.losses(), 0);
});
test('signal ranking changes move surrounding rows without disconnecting the focused row', () => {
const f = fixture(); render(f, ['a', 'b', 'c', 'd'].map((id) => item(id)));
const target = f.buttons()[1]; target.focus();
const f = fixture();
render(
f,
['a', 'b', 'c', 'd'].map((id) => item(id)),
);
const target = f.buttons()[1];
target.focus();
const calls = f.focusCalls();
for (const order of [['d', 'c', 'b', 'a'], ['b', 'c', 'd', 'a'], ['a', 'd', 'c', 'b']]) {
render(f, order.map((id) => item(id)));
assert.deepEqual(f.buttons().map((button) => button.dataset.signalId), order);
for (const order of [
['d', 'c', 'b', 'a'],
['b', 'c', 'd', 'a'],
['a', 'd', 'c', 'b'],
]) {
render(
f,
order.map((id) => item(id)),
);
assert.deepEqual(
f.buttons().map((button) => button.dataset.signalId),
order,
);
assert.equal(f.document.activeElement, target);
assert.equal(f.losses(), 0);
}
@@ -158,37 +283,65 @@ test('signal ranking changes move surrounding rows without disconnecting the foc
});
test('a new pushed signal preserves existing focus and delegated selection reads the current target', () => {
const f = fixture(); render(f, [item('a'), item('b')]);
const target = f.buttons()[1]; target.focus();
f.controller.pushCockpitSignal('status', 'warning', 'INPUT UNKNOWN', 'SOURCE UNAVAILABLE');
const f = fixture();
render(f, [item('a'), item('b')]);
const target = f.buttons()[1];
target.focus();
f.controller.pushCockpitSignal(
'status',
'warning',
'INPUT UNKNOWN',
'SOURCE UNAVAILABLE',
);
assert.equal(f.document.activeElement, target);
assert.equal(f.losses(), 0);
const event = { target: target.children[0], prevented: false, preventDefault() { this.prevented = true; } };
const event = {
target: target.children[0],
prevented: false,
preventDefault() {
this.prevented = true;
},
};
f.click(event);
assert.equal(event.prevented, true);
assert.deepEqual(f.selected, [['flights', 'b', { origin: 'user' }]]);
});
test('removing the focused identity uses the existing briefing tab once and permits forward traversal', () => {
const f = fixture(); render(f, [item('a'), item('b')]);
const removed = f.buttons()[1]; removed.focus();
const f = fixture();
render(f, [item('a'), item('b')]);
const removed = f.buttons()[1];
removed.focus();
const calls = f.focusCalls();
render(f, [item('a')]);
assert.equal(removed.isConnected, false);
assert.equal(f.document.activeElement, f.controller.briefTabs[0]);
assert.equal(f.focusCalls(), calls + 1);
f.tab(); f.tab(); f.tab();
f.tab();
f.tab();
f.tab();
assert.equal(f.document.activeElement, f.display);
render(f, [item('a', { detail: 'UPDATED' })]);
assert.equal(f.document.activeElement, f.display, 'later refresh must not pull focus back');
assert.equal(
f.document.activeElement,
f.display,
'later refresh must not pull focus back',
);
f.tab();
assert.equal(f.document.activeElement, f.radio);
});
test('a changed target cannot reuse a focused action for another flight', () => {
const f = fixture(); render(f, [item('a', { key: 'shared-status-key' })]);
const removed = f.buttons()[0]; removed.focus();
render(f, [item('b', { key: 'shared-status-key', target: { layerId: 'military', id: 'b' } })]);
const f = fixture();
render(f, [item('a', { key: 'shared-status-key' })]);
const removed = f.buttons()[0];
removed.focus();
render(f, [
item('b', {
key: 'shared-status-key',
target: { layerId: 'military', id: 'b' },
}),
]);
const target = f.buttons()[0];
assert.notEqual(target, removed);
assert.equal(removed.isConnected, false);
+32 -1
View File
@@ -1,3 +1,5 @@
import { msg } from './i18n/marker.js';
const CONTEXT_DEPENDENCIES = Object.freeze({
flights: new Set([
'military-awareness',
@@ -9,6 +11,12 @@ const CONTEXT_DEPENDENCIES = Object.freeze({
'space-missions': new Set(['rocket-launches', 'satellites']),
});
const CONTEXT_COMPANIONS = new Set(['radio']);
/** 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 +344,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);
}
/**
* The layer name from a Space Missions refusal, so the UI can rebuild the
* sentence in its language.
* @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,
),
};
}
/**
+11 -1
View File
@@ -2,6 +2,8 @@
* Adds the keyboard semantics omitted by Cesium's href-less attribution links.
* Cesium retains ownership of showing and hiding its required credit lightbox.
*/
import { getLocale, t } from './i18n/index.js';
export function configureCreditKeyboardAccess(root = document) {
const expand = root?.querySelector?.(
'#cesium-credits .cesium-credit-expand-link',
@@ -11,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');
@@ -19,7 +29,7 @@ export function configureCreditKeyboardAccess(root = document) {
expand.setAttribute('aria-expanded', 'false');
close.setAttribute('role', 'button');
close.setAttribute('tabindex', '0');
close.setAttribute('aria-label', 'Close data attribution');
close.setAttribute('aria-label', t('Close data attribution'));
if (expand.dataset.gevKeyboardReady === 'true') return true;
expand.dataset.gevKeyboardReady = 'true';
+35
View File
@@ -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');
}
});
+13 -11
View File
@@ -7,6 +7,8 @@
* integer category (states index 17) and ADS-B emitter strings ("A1".."B1").
*/
import { msg } from '../i18n/marker.js';
const HELI = new Set([
'EC20',
'EC25',
@@ -438,18 +440,18 @@ export function classifyAircraft({ typeCode, category } = {}) {
return 'airliner';
}
/** Readable name of each class, for cards and readouts. */
/** 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). */
+11 -6
View File
@@ -13,18 +13,23 @@
* broadcast, which does not confirm an incident or its resolution.
*/
import { msg } from '../i18n/marker.js';
/** @constant {string} Tint for aircraft broadcasting an emergency (not a priority). */
export const EMERGENCY_TINT_CSS = '#FF4444';
const SQUAWK_KINDS = { 7500: 'unlawful', 7600: 'nordo', 7700: 'general' };
const KINDS = {
general: { label: 'General emergency', severity: 'emergency' },
unlawful: { label: 'Unlawful interference', severity: 'emergency' },
nordo: { label: 'Radio failure', severity: 'emergency' },
downed: { label: 'Downed aircraft', severity: 'emergency' },
minfuel: { label: 'Minimum fuel', severity: 'priority' },
lifeguard: { label: 'Lifeguard (medical priority)', severity: 'priority' },
general: { label: msg('General emergency'), severity: 'emergency' },
unlawful: { label: msg('Unlawful interference'), severity: 'emergency' },
nordo: { label: msg('Radio failure'), severity: 'emergency' },
downed: { label: msg('Downed aircraft'), severity: 'emergency' },
minfuel: { label: msg('Minimum fuel'), severity: 'priority' },
lifeguard: {
label: msg('Lifeguard (medical priority)'),
severity: 'priority',
},
};
/**
+4
View File
@@ -249,6 +249,7 @@ export function createAisWatchdog(options = {}) {
if (!env.hasKey) {
const actions = terminateOwned('key-absent');
status = 'missing-key';
// i18n-ignore: server detail; layers/vessels/queries.js shows a translated reason
error = 'AISSTREAM_API_KEY is not set';
reconnectAttempt = 0;
keyFingerprint = null;
@@ -257,6 +258,7 @@ export function createAisWatchdog(options = {}) {
if (env.hasTransport === false) {
const actions = terminateOwned('no-transport');
status = 'unsupported';
// i18n-ignore: server detail; layers/vessels/queries.js shows a translated reason
error = 'Node WebSocket transport is unavailable';
return actions;
}
@@ -329,6 +331,7 @@ export function createAisWatchdog(options = {}) {
if (!silenceWatchArmed) return [];
const silentFor = monoNow - silenceSinceMono;
if (silentFor >= recycleAfterMs) {
// i18n-ignore: server detail; layers/vessels/queries.js shows a translated reason
error = `AISStream delivered no data for ${Math.round(silentFor / 1000)}s`;
const actions = terminateOwned('silent');
scheduleRetry('transport');
@@ -401,6 +404,7 @@ export function createAisWatchdog(options = {}) {
function onClose(eventGeneration) {
if (!ownsGeneration(eventGeneration)) return [];
release();
// i18n-ignore: server detail; layers/vessels/queries.js shows a translated reason
if (!error) error = 'AISStream websocket closed';
scheduleRetry('transport');
return [];
+3 -10
View File
@@ -27,6 +27,7 @@
import { feedProvenanceEnvelope } from './layerSnapshot.js';
import { pointInRing } from './naturalEarthRegions.js';
import { VOICE_LAYER_MANIFEST, voiceLayer } from '../voice/layerManifest.js';
import { greatCircleKm } from '../geoDistance.js';
/**
* Layers the engine understands, with the fields queries may reference,
@@ -98,17 +99,9 @@ function toEpochMs(value) {
return Date.parse(text);
}
const EARTH_R_KM = 6371;
/** Great-circle distance in km. */
/** @deprecated Use geoDistance.greatCircleKm with `{ lat, lon }` points. */
export function haversineKm(lat1, lon1, lat2, lon2) {
const d2r = Math.PI / 180;
const dLat = (lat2 - lat1) * d2r;
const dLon = (lon2 - lon1) * d2r;
const a =
Math.sin(dLat / 2) ** 2 +
Math.cos(lat1 * d2r) * Math.cos(lat2 * d2r) * Math.sin(dLon / 2) ** 2;
return 2 * EARTH_R_KM * Math.asin(Math.min(1, Math.sqrt(a)));
return greatCircleKm({ lat: lat1, lon: lon1 }, { lat: lat2, lon: lon2 });
}
/**
+12
View File
@@ -0,0 +1,12 @@
/**
* Local equirectangular distance in km, for viewer-proximity gates. Takes the
* short way round, so points either side of 180° stay close.
*/
export function approxDistanceKm(lat1, lon1, lat2, lon2) {
const dLat = (lat2 - lat1) * 111.32;
let dLonDeg = lon2 - lon1;
dLonDeg -= 360 * Math.round(dLonDeg / 360);
const dLon =
dLonDeg * 111.32 * Math.cos((((lat1 + lat2) / 2) * Math.PI) / 180);
return Math.hypot(dLat, dLon);
}
+112 -24
View File
@@ -1,4 +1,5 @@
import * as Cesium from 'cesium';
import { t, msg } from '../i18n/index.js';
const YOUTUBE_HOSTS = new Set([
'youtube.com',
@@ -39,6 +40,55 @@ const MEDIA_PRECONNECT_ORIGINS = Object.freeze({
facebook: Object.freeze(['https://www.facebook.com']),
});
// Known English from public/events/bhote-koshi-2026/event.json, translated at
// display (whole or by " · " parts). Anything else shows as authored.
const EVENT_PACK_TEXT = new Set([
// Field reports: our labels (credits after " · " stay) and their statuses.
msg('Rasuwagadhi witness video'),
msg('Bhote Koshi valley footage'),
msg('Flood footage'),
msg('Compilation and casualty claims'),
msg('SOURCE GEOCONFIRMED · WORLD-PINNED'),
msg('SOURCE CREDITED · LOCATION UNRESOLVED'),
msg('REFERENCE ONLY · CLAIMS UNVERIFIED'),
msg('SOURCE GEOCONFIRMED'),
// Reconstruction caveat.
msg(
'2021 historical reference versus 2026 post-event imagery; changes are cumulative and include the July 2025 Rasuwagadhi flood. Schematic downstream progression, not modeled arrival time; not an official hazard model.',
),
// Evidence titles that are more than a place name.
msg('Immediate collapse viewpoint'),
msg('Collapse viewpoint'),
msg('Debris-dammed lake'),
msg('Dammed lake'),
msg('Second landslide'),
msg('Gyirong border gate'),
msg('Border gate'),
msg('Timure evidence cluster'),
msg('Mailung / Upper Trishuli bridge'),
msg('Dandagaun viewpoint restaurant'),
msg('Dandagaun viewpoint'),
msg('Bidur - Trishuli bridge'),
msg('Bidur bridge'),
msg('Devighat / Taadi Khola Bridge'),
msg('Devighat bridge'),
]);
/**
* Display text for an authored event-pack string.
* @param {unknown} value
* @returns {string}
*/
export function bhoteEventText(value) {
const text = value == null ? '' : String(value);
if (EVENT_PACK_TEXT.has(text)) return t(text);
if (!text.includes(' · ')) return text;
return text
.split(' · ')
.map((part) => (EVENT_PACK_TEXT.has(part) ? t(part) : part))
.join(' · ');
}
let _xWidgetsPromise = null;
let _facebookSdkPromise = null;
let _facebookEmbedSequence = 0;
@@ -240,7 +290,7 @@ function loadFacebookSdk(documentRef, globalRef = globalThis) {
prior?.();
} finally {
if (facebookSdkReady(globalRef.FB)) finish();
else finish(new Error('Facebook player API did not initialize.'));
else finish(new Error(msg('Facebook player API did not initialize.')));
}
};
globalRef.fbAsyncInit = ready;
@@ -256,12 +306,12 @@ function loadFacebookSdk(documentRef, globalRef = globalThis) {
}
script.addEventListener?.(
'error',
() => finish(new Error('Facebook player API could not load.')),
() => finish(new Error(msg('Facebook player API could not load.'))),
{ once: true },
);
timer =
globalRef.setTimeout?.(
() => finish(new Error('Facebook player API timed out.')),
() => finish(new Error(msg('Facebook player API timed out.'))),
EMBED_TIMEOUT_MS,
) ?? null;
}).catch((error) => {
@@ -398,7 +448,7 @@ function loadXWidgets(documentRef, globalRef = globalThis) {
};
const ready = () => {
if (xWidgetsReady(globalRef.twttr)) finish();
else finish(new Error('X embed API did not initialize.'));
else finish(new Error(msg('X embed API did not initialize.')));
};
let script = documentRef.querySelector?.(`script[src="${X_WIDGET_URL}"]`);
if (!script) {
@@ -411,7 +461,7 @@ function loadXWidgets(documentRef, globalRef = globalThis) {
}
script.addEventListener?.(
'error',
() => finish(new Error('X embed could not load.')),
() => finish(new Error(msg('X embed could not load.'))),
{
once: true,
},
@@ -440,7 +490,7 @@ function loadXWidgets(documentRef, globalRef = globalThis) {
},
);
timer = setTimeout(
() => finish(new Error('X embed timed out.')),
() => finish(new Error(msg('X embed timed out.'))),
EMBED_TIMEOUT_MS,
);
}).catch((error) => {
@@ -450,9 +500,17 @@ function loadXWidgets(documentRef, globalRef = globalThis) {
return _xWidgetsPromise;
}
// Outcomes a trimmed YouTube clip reports to the card status.
const YOUTUBE_PHASE_LABELS = Object.freeze({
unavailable: msg('UNAVAILABLE'),
timeout: msg('TIMEOUT'),
blocked: msg('BLOCKED'),
});
function providerLabel(provider) {
return (
{ youtube: 'YOUTUBE', facebook: 'FACEBOOK', x: 'X' }[provider] || 'SOURCE'
{ youtube: 'YOUTUBE', facebook: 'FACEBOOK', x: 'X' }[provider] ||
t('SOURCE')
);
}
@@ -737,7 +795,9 @@ export function createBhoteKoshiEmbeddedMedia({
globalRef.setTimeout?.(() => {
if (!session.active || session.ready) return;
session.error = new Error(
'Facebook embed unavailable. The post may be private, removed, region-restricted, or blocked.',
msg(
'Facebook embed unavailable. The post may be private, removed, region-restricted, or blocked.',
),
);
session.onError?.(session.error);
}, EMBED_TIMEOUT_MS) ?? null;
@@ -857,7 +917,9 @@ export function createBhoteKoshiEmbeddedMedia({
startAtSec,
endAtSec,
});
iframe.title = `Preloading ${providerLabel(source.provider)} media`;
iframe.title = t('Preloading {provider} media', {
provider: providerLabel(source.provider),
});
iframe.tabIndex = -1;
iframe.setAttribute('aria-hidden', 'true');
iframe.loading = 'eager';
@@ -868,7 +930,7 @@ export function createBhoteKoshiEmbeddedMedia({
}
async function mountX(current, token) {
current.status.textContent = 'LOADING X POST…';
current.status.textContent = t('LOADING X POST…');
try {
const api = await xLoader(documentRef, globalRef);
if (token !== generation || record !== current) return;
@@ -885,13 +947,17 @@ export function createBhoteKoshiEmbeddedMedia({
);
if (token !== generation || record !== current) return;
if (!widget)
throw new Error('The X post is unavailable or cannot be embedded.');
current.status.textContent = 'X POST · PROVIDER CONTROLS';
throw new Error(
msg('The X post is unavailable or cannot be embedded.'),
);
current.status.textContent = t('X POST · PROVIDER CONTROLS');
position();
} catch (error) {
if (token !== generation || record !== current) return;
current.card.classList.add('is-unavailable');
current.status.textContent = `${String(error?.message || 'X embed unavailable')} OPEN ORIGINAL.`;
current.status.textContent = t('{reason} OPEN ORIGINAL.', {
reason: t(String(error?.message || msg('X embed unavailable'))),
});
position();
}
}
@@ -904,19 +970,23 @@ export function createBhoteKoshiEmbeddedMedia({
session.shouldPlay = autoplay === true;
session.onReady = () => {
if (token !== generation || record !== current) return;
current.status.textContent = 'FACEBOOK · PROVIDER CONTROLS';
current.status.textContent = t('FACEBOOK · PROVIDER CONTROLS');
current.card.classList.remove('is-unavailable');
position();
};
session.onError = (error) => {
if (token !== generation || record !== current) return;
current.card.classList.add('is-unavailable');
current.status.textContent = `${String(error?.message || 'Facebook embed unavailable')} OPEN ORIGINAL.`;
current.status.textContent = t('{reason} OPEN ORIGINAL.', {
reason: t(String(error?.message || msg('Facebook embed unavailable'))),
});
position();
};
session.onPlaybackBlocked = () => {
if (token !== generation || record !== current) return;
current.status.textContent = 'AUTOPLAY BLOCKED · USE PROVIDER CONTROLS';
current.status.textContent = t(
'AUTOPLAY BLOCKED · USE PROVIDER CONTROLS',
);
};
if (session.ready) session.onReady();
else if (session.error) session.onError(session.error);
@@ -968,7 +1038,12 @@ export function createBhoteKoshiEmbeddedMedia({
card.dataset.provider = source.provider;
card.setAttribute(
'aria-label',
`Embedded ${providerLabel(source.provider)} media for ${observation?.title || 'witness source'}`,
t('Embedded {provider} media for {source}', {
provider: providerLabel(source.provider),
source: observation?.title
? bhoteEventText(observation.title)
: t('witness source'),
}),
);
const leader = documentRef.createElementNS(
'http://www.w3.org/2000/svg',
@@ -982,17 +1057,19 @@ export function createBhoteKoshiEmbeddedMedia({
player.className = 'bhote-embedded-callout-player';
const footer = documentRef.createElement('footer');
const title = documentRef.createElement('strong');
title.textContent = String(
observation?.shortTitle || observation?.title || 'Witness source',
title.textContent = bhoteEventText(
observation?.shortTitle || observation?.title || t('Witness source'),
).toUpperCase();
const status = documentRef.createElement('small');
status.setAttribute('role', 'status');
status.textContent = `${providerLabel(source.provider)} · EMBEDDED SOURCE`;
status.textContent = t('{provider} · EMBEDDED SOURCE', {
provider: providerLabel(source.provider),
});
const link = documentRef.createElement('a');
link.href = source.url;
link.target = '_blank';
link.rel = 'noopener noreferrer';
link.textContent = 'OPEN ORIGINAL ↗';
link.textContent = t('OPEN ORIGINAL ↗');
footer.append(title, status, link);
const body = documentRef.createElement('div');
body.className = 'bhote-embedded-callout-body';
@@ -1032,7 +1109,12 @@ export function createBhoteKoshiEmbeddedMedia({
url.searchParams.set('origin', globalRef.location.origin);
iframe.src = url.href;
}
iframe.title = `${providerLabel(source.provider)} media: ${observation?.title || 'witness source'}`;
iframe.title = t('{provider} media: {source}', {
provider: providerLabel(source.provider),
source: observation?.title
? bhoteEventText(observation.title)
: t('witness source'),
});
iframe.allow =
'autoplay; encrypted-media; picture-in-picture; fullscreen';
iframe.allowFullscreen = true;
@@ -1049,7 +1131,9 @@ export function createBhoteKoshiEmbeddedMedia({
record.youtubeSession.getState().phase,
)
) {
status.textContent = `${providerLabel(source.provider)} · PROVIDER CONTROLS`;
status.textContent = t('{provider} · PROVIDER CONTROLS', {
provider: providerLabel(source.provider),
});
}
position();
},
@@ -1067,7 +1151,11 @@ export function createBhoteKoshiEmbeddedMedia({
isCurrent: () => token === generation && record === current,
onStatus: (phase) => {
if (phase !== 'completed')
status.textContent = `YOUTUBE ${phase.toUpperCase()} · OPEN ORIGINAL`;
status.textContent = t('YOUTUBE {state} · OPEN ORIGINAL', {
state: YOUTUBE_PHASE_LABELS[phase]
? t(YOUTUBE_PHASE_LABELS[phase])
: phase.toUpperCase(),
});
},
});
}
+262 -73
View File
@@ -19,11 +19,13 @@ import {
SOURCE_PATH_BEAT_IDS,
} from './bhoteKoshiShotPaths.js';
import {
bhoteEventText,
createBhoteKoshiEmbeddedMedia,
resolveEmbeddedMediaSource,
} from './bhoteKoshiEmbeddedMedia.js';
import { acquireImageryComparison } from '../maps/imageryComparison.js';
import { createImagerySplit } from '../ui/imagerySplit.js';
import { formatDate, t, tn, msg } from '../i18n/index.js';
export const BHOTE_KOSHI_LAYER_ID = 'bhote-koshi-2026';
export const BHOTE_KOSHI_OVERLAY_SOURCE_ID = 'bhote-koshi-witnesses';
@@ -384,13 +386,90 @@ export function adjacentEvidenceIndex(progress, timeline, direction) {
return Math.max(0, Math.min(beats.length - 1, current + delta));
}
// Evidence phases from the event pack, shown as the beat meta prefix.
const EVIDENCE_PHASE_LABELS = Object.freeze({
initiation: msg('INITIATION'),
damming: msg('DAMMING'),
'secondary failure': msg('SECONDARY FAILURE'),
'border arrival': msg('BORDER ARRIVAL'),
propagation: msg('PROPAGATION'),
'downstream passage': msg('DOWNSTREAM PASSAGE'),
consequence: msg('CONSEQUENCE'),
'downstream consequence': msg('DOWNSTREAM CONSEQUENCE'),
});
/** Display label for an evidence phase; unknown phases show as authored. */
function evidencePhaseLabel(phase) {
const label = EVIDENCE_PHASE_LABELS[String(phase || '').toLowerCase()];
if (label) return t(label);
return phase ? String(phase).toUpperCase() : t('EVIDENCE');
}
// Imagery endpoint names from the event pack ("2021 HISTORICAL REFERENCE ·
// 16 OCT 2021"); unknown names and unparseable dates show as authored.
const IMAGERY_LABEL_NAMES = Object.freeze([
[/^(\d{4}) HISTORICAL REFERENCE$/, msg('{year} HISTORICAL REFERENCE')],
[/^(\d{4}) POST-EVENT$/, msg('{year} POST-EVENT')],
]);
/** Day, short month and year in the active language: 16 OCT 2021, 16 ОКТ. 2021. */
function imageryDateText(observedAt) {
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(observedAt || ''));
if (!match) return '';
const date = new Date(Date.UTC(+match[1], +match[2] - 1, +match[3]));
if (Number.isNaN(date.getTime())) return '';
const part = (options) => formatDate(date, { timeZone: 'UTC', ...options });
return `${part({ day: 'numeric' })} ${part({ month: 'short' })} ${part({ year: 'numeric' })}`.toUpperCase();
}
/**
* Localized name and date of an imagery endpoint for display.
* @param {{label?: string, observedAt?: string}} entry
* @returns {{name: string, date: string}}
*/
export function imageryEndpointLabel(entry) {
const label = String(entry?.label || '');
const split = label.lastIndexOf(' · ');
const authoredName = split >= 0 ? label.slice(0, split) : label;
const authoredDate = split >= 0 ? label.slice(split + 3) : '';
let name = authoredName;
for (const [pattern, message] of IMAGERY_LABEL_NAMES) {
const match = pattern.exec(authoredName);
if (match) name = t(message, { year: match[1] });
}
return { name, date: imageryDateText(entry?.observedAt) || authoredDate };
}
function imageryEndpointText(entry) {
const { name, date } = imageryEndpointLabel(entry);
return [name, date].filter(Boolean).join(' · ');
}
/** Name, then the date kept on one line so the year never wraps alone. */
function renderImageryEndpoint(element, entry) {
if (!element) return;
const { name, date } = imageryEndpointLabel(entry);
element.textContent = date ? `${name} · ` : name;
if (!date) return;
const dateElement = document.createElement('span');
dateElement.className = 'bhote-event-date';
dateElement.textContent = date;
element.appendChild(dateElement);
}
/** Compact, source-bounded disclosure for multi-source geolocation beats. */
export function evidenceCorroborationLabel(observation) {
const count = Math.max(
0,
Math.floor(Number(observation?.corroboration?.sourceCount) || 0),
);
return count > 1 ? `${count} GEOLOCATED SOURCE-MAP PLACEMENTS` : '';
return count > 1
? tn(
count,
'{count} GEOLOCATED SOURCE-MAP PLACEMENT',
'{count} GEOLOCATED SOURCE-MAP PLACEMENTS',
)
: '';
}
/** Interpolate headings across north using the shortest angular path. */
@@ -641,7 +720,7 @@ function drawEvidenceFallback(ctx, observation, width, height) {
}
ctx.fillStyle = WORLD_OVERLAY_STYLE.accent;
ctx.font = scaledOverlayFont(WORLD_OVERLAY_STYLE.fontLabel, 1.4);
ctx.fillText('NO PREVIEW', 20, 30);
ctx.fillText(t('NO PREVIEW'), 20, 30);
}
function ellipsizeEvidenceText(value, limit) {
@@ -650,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,
@@ -704,25 +796,78 @@ export function updateEvidenceFrame(
ctx.font = scaledOverlayFont(WORLD_OVERLAY_STYLE.fontTitle);
ctx.fillText(
ellipsizeEvidenceText(
observation.shortTitle || observation.title,
bhoteEventText(observation.shortTitle || observation.title),
layout.titleChars,
).toUpperCase(),
16,
infoY + 27,
);
ctx.fillStyle = WORLD_OVERLAY_STYLE.detail;
ctx.font = scaledOverlayFont(WORLD_OVERLAY_STYLE.fontDetail);
const publisher = String(
observation.media?.publisher || 'SOURCE LINK',
const publisher = (
observation.media?.publisher
? evidencePublisherText(observation.media.publisher)
: t('SOURCE LINK')
).toUpperCase();
ctx.fillText(
`${ellipsizeEvidenceText(publisher, 24)} / OPEN`,
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 a line that fits
* `maxWidth`. Translations run longer: first shrink the font (to 80%), then
* shorten the publisher; «/ OPEN» 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);
@@ -813,28 +958,40 @@ function collectPanelReferences(panel) {
});
}
/** Escape translated text for the panel markup template. */
function escapeMarkup(text) {
return String(text)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function createPanel(event, handlers) {
const panel = document.createElement('aside');
panel.id = 'bhote-koshi-event-panel';
panel.setAttribute('aria-label', 'Bhote Koshi flood reconstruction controls');
panel.setAttribute(
'aria-label',
t('Bhote Koshi flood reconstruction controls'),
);
panel.innerHTML = `
<div class="bhote-event-header">
<div>
<div class="bhote-event-kicker">EVENT RECONSTRUCTION · 26 AUG 2026</div>
<div class="bhote-event-title">BHOTE KOSHI OUTBURST FLOOD</div>
<div class="bhote-event-kicker">${escapeMarkup(t('EVENT RECONSTRUCTION · 26 AUG 2026'))}</div>
<div class="bhote-event-title">${escapeMarkup(t('BHOTE KOSHI OUTBURST FLOOD'))}</div>
</div>
<button class="bhote-event-icon-btn" type="button" data-action="close" title="Close event layer" aria-label="Close event layer">×</button>
<button class="bhote-event-icon-btn" type="button" data-action="close" title="${escapeMarkup(t('Close event layer'))}" aria-label="${escapeMarkup(t('Close event layer'))}">×</button>
</div>
<div class="bhote-event-status-row">
<span class="bhote-event-status observed">OBSERVED IMAGERY</span>
<span class="bhote-event-status reconstructed">SCHEMATIC CORRIDOR</span>
<span class="bhote-event-status observed">${escapeMarkup(t('OBSERVED IMAGERY'))}</span>
<span class="bhote-event-status reconstructed">${escapeMarkup(t('SCHEMATIC CORRIDOR'))}</span>
</div>
<section class="bhote-event-section" data-role="imagery-comparison">
<div class="bhote-event-section-head">
<span>2021 HISTORICAL REFERENCE / 2026 POST-EVENT</span>
<span>${escapeMarkup(t('2021 HISTORICAL REFERENCE / 2026 POST-EVENT'))}</span>
<span data-role="split-readout">50 / 50</span>
</div>
<input class="bhote-event-range" data-role="split" type="range" min="0" max="100" value="50" aria-label="Historical reference and post-event image split" />
<input class="bhote-event-range" data-role="split" type="range" min="0" max="100" value="50" aria-label="${escapeMarkup(t('Historical reference and post-event image split'))}" />
<div class="bhote-event-dates">
<span data-role="before-date"></span>
<span data-role="after-date"></span>
@@ -842,44 +999,44 @@ function createPanel(event, handlers) {
</section>
<section class="bhote-event-section">
<div class="bhote-event-section-head">
<span data-role="timeline-label">RECONSTRUCTION CLOCK</span>
<span data-role="timeline-label">${escapeMarkup(t('RECONSTRUCTION CLOCK'))}</span>
<span data-role="time"></span>
</div>
<input class="bhote-event-range flood" data-role="progress" type="range" min="0" max="1000" value="0" aria-label="Schematic downstream progression" />
<div class="bhote-event-story-nav" aria-label="Story beat navigation">
<button type="button" data-action="previous-beat" title="Previous story beat" aria-label="Previous story beat">‹</button>
<input class="bhote-event-range flood" data-role="progress" type="range" min="0" max="1000" value="0" aria-label="${escapeMarkup(t('Schematic downstream progression'))}" />
<div class="bhote-event-story-nav" aria-label="${escapeMarkup(t('Story beat navigation'))}">
<button type="button" data-action="previous-beat" title="${escapeMarkup(t('Previous story beat'))}" aria-label="${escapeMarkup(t('Previous story beat'))}">‹</button>
<div class="bhote-event-story-current">
<span data-role="beat-index">01 / 06</span>
<strong data-role="beat-title" aria-live="polite">CAUSE</strong>
<small data-role="beat-meta">CAPTURE TIME UNVERIFIED</small>
<strong data-role="beat-title" aria-live="polite">${escapeMarkup(t('CAUSE'))}</strong>
<small data-role="beat-meta">${escapeMarkup(t('CAPTURE TIME UNVERIFIED'))}</small>
</div>
<button type="button" data-action="next-beat" title="Next story beat" aria-label="Next story beat">›</button>
<button type="button" data-action="next-beat" title="${escapeMarkup(t('Next story beat'))}" aria-label="${escapeMarkup(t('Next story beat'))}">›</button>
</div>
<div class="bhote-event-actions">
<button type="button" data-action="play">▶ PLAY</button>
<button type="button" data-action="play-scene" hidden title="Play the next shot and continue through this scene">▶ PLAY SCENE</button>
<button type="button" data-action="cinematic" aria-pressed="false">◉ CINEMATIC</button>
<button type="button" data-action="story-replay">↺ FULL STORY</button>
<button type="button" data-action="open-source">↗ OPEN ORIGINAL</button>
<button type="button" data-action="corridor">⌖ LOWER GORGE</button>
<button type="button" data-action="play">${escapeMarkup(t('▶ PLAY'))}</button>
<button type="button" data-action="play-scene" hidden title="${escapeMarkup(t('Play the next shot and continue through this scene'))}">${escapeMarkup(t('▶ PLAY SCENE'))}</button>
<button type="button" data-action="cinematic" aria-pressed="false">${escapeMarkup(t('◉ CINEMATIC'))}</button>
<button type="button" data-action="story-replay">${escapeMarkup(t('↺ FULL STORY'))}</button>
<button type="button" data-action="open-source">${escapeMarkup(t('↗ OPEN ORIGINAL'))}</button>
<button type="button" data-action="corridor">${escapeMarkup(t('⌖ LOWER GORGE'))}</button>
</div>
</section>
<details class="bhote-event-evidence">
<summary>FIELD REPORTS · ${event.fieldReports.length}</summary>
<summary>${escapeMarkup(t('FIELD REPORTS · {count}', { count: event.fieldReports.length }))}</summary>
<div class="bhote-event-report-list"></div>
</details>
<div class="bhote-event-credit">
<span>GEOLOCATIONS · GEO GEORGE SHADRACH</span>
<button type="button" data-action="geolocation-map">↗ OPEN PUBLIC MAP</button>
<span>${escapeMarkup(t('GEOLOCATIONS · {credit}', { credit: 'GEO GEORGE SHADRACH' }))}</span>
<button type="button" data-action="geolocation-map">${escapeMarkup(t('↗ OPEN PUBLIC MAP'))}</button>
</div>
<p class="bhote-event-caveat"><span data-role="caveat"></span><span data-role="imagery-cloud-note"> Clouds are preserved from the source imagery.</span></p>
<p class="bhote-event-caveat"><span data-role="caveat"></span><span data-role="imagery-cloud-note"> ${escapeMarkup(t('Clouds are preserved from the source imagery.'))}</span></p>
`;
const refs = collectPanelReferences(panel);
refs.beforeDate.textContent = event.imagery.before.label;
refs.afterDate.textContent = event.imagery.after.label;
renderImageryEndpoint(refs.beforeDate, event.imagery.before);
renderImageryEndpoint(refs.afterDate, event.imagery.after);
refs.time.textContent = `~0:00 / ${elapsedLabel(1, event.reconstruction.elapsedSeconds)}`;
refs.caveat.textContent = event.reconstruction.caveat;
refs.caveat.textContent = bhoteEventText(event.reconstruction.caveat);
for (const report of event.fieldReports) {
const button = document.createElement('button');
@@ -887,8 +1044,8 @@ function createPanel(event, handlers) {
const status = document.createElement('small');
button.type = 'button';
button.className = 'bhote-event-report';
label.textContent = report.label;
status.textContent = report.status;
label.textContent = bhoteEventText(report.label);
status.textContent = bhoteEventText(report.status);
button.append(label, status);
button.addEventListener('click', () => openExternal(report.url));
refs.reportList.appendChild(button);
@@ -1205,7 +1362,7 @@ export function createBhoteKoshiEventLayer({
_embeddedMedia?.show?.({
observation: {
title: witness.title,
shortTitle: 'Rasuwagadhi witness',
shortTitle: t('Rasuwagadhi witness'),
media: { sourceUrl: witness.sourceUrl },
},
anchor,
@@ -2320,7 +2477,10 @@ export function createBhoteKoshiEventLayer({
collisionGroup: 'ambient-card',
zIndex: 60,
interactive: true,
accessibilityLabel: `Open original source for ${observation.title}; capture time unverified`,
accessibilityLabel: t(
'Open original source for {source}; capture time unverified',
{ source: bhoteEventText(observation.title) },
),
activate: () => openExternal(observation.media.sourceUrl),
minDistance: 0,
maxDistance: 220000,
@@ -2353,7 +2513,7 @@ export function createBhoteKoshiEventLayer({
position: evidencePositionGetter(index, true),
cullPosition: anchor,
variant: 'label',
title: `${String(observation.sequence).padStart(2, '0')} · ${observation.shortTitle || observation.title}`,
title: `${String(observation.sequence).padStart(2, '0')} · ${bhoteEventText(observation.shortTitle || observation.title)}`,
details: [],
accent: WORLD_OVERLAY_STYLE.accent,
priority: 80_000 - index,
@@ -2559,11 +2719,14 @@ export function createBhoteKoshiEventLayer({
id: 'bhote-koshi-split-line',
handleClass: 'bhote-koshi-split-handle',
cssProperty: '--bhote-koshi-split',
beforeTitle: event.imagery.before.label,
afterTitle: event.imagery.after.label,
ariaLabel: 'Historical reference and post-event image divider',
beforeTitle: imageryEndpointText(event.imagery.before),
afterTitle: imageryEndpointText(event.imagery.after),
ariaLabel: t('Historical reference and post-event image divider'),
formatValueText: (beforePercent, afterPercent) =>
`A historical reference ${beforePercent} percent, B post-event ${afterPercent} percent`,
t(
'A historical reference {before} percent, B post-event {after} percent',
{ before: beforePercent, after: afterPercent },
),
onChange: setSplit,
});
}
@@ -2595,7 +2758,7 @@ export function createBhoteKoshiEventLayer({
setElementProperty(
button,
'textContent',
_cinematicActive ? '■ RELEASE CAMERA' : '◉ CINEMATIC',
_cinematicActive ? t('■ RELEASE CAMERA') : t('◉ CINEMATIC'),
);
setElementAttribute(button, 'aria-pressed', String(_cinematicActive));
}
@@ -2961,13 +3124,13 @@ export function createBhoteKoshiEventLayer({
_panelRefs.progressInput,
'aria-label',
sceneDirected
? 'Seek Nepal scene clock'
: 'Schematic downstream progression',
? t('Seek Nepal scene clock')
: t('Schematic downstream progression'),
);
setElementProperty(
_panelRefs.timelineLabel,
'textContent',
sceneDirected ? 'NEPAL SCENE CLOCK' : 'RECONSTRUCTION CLOCK',
sceneDirected ? t('NEPAL SCENE CLOCK') : t('RECONSTRUCTION CLOCK'),
);
const sceneDuration = Number(_sceneContext?.sceneDurationSec) || 0;
setElementProperty(
@@ -2980,22 +3143,22 @@ export function createBhoteKoshiEventLayer({
);
const playLabel = sceneDirected
? _sceneActionPending && _sceneActionPending.type !== 'scene'
? '… PLAYING SHOT'
: '▶ PLAY SHOT'
? t('… PLAYING SHOT')
: t('▶ PLAY SHOT')
: _playing
? 'Ⅱ PAUSE'
? t('Ⅱ PAUSE')
: _progress >= 1
? _cinematicReplayArmed
? '↺ REPLAY CINEMATIC'
: '↺ REPLAY'
: '▶ PLAY';
? t('↺ REPLAY CINEMATIC')
: t('↺ REPLAY')
: t('▶ PLAY');
setElementProperty(_panelRefs.playButton, 'textContent', playLabel);
setElementProperty(
_panelRefs.playSceneButton,
'textContent',
_sceneActionPending?.type === 'scene'
? '… PLAYING SCENE'
: '▶ PLAY SCENE',
? t('… PLAYING SCENE')
: t('▶ PLAY SCENE'),
);
setElementProperty(
_panelRefs.playSceneButton,
@@ -3011,19 +3174,24 @@ export function createBhoteKoshiEventLayer({
setElementAttribute(
_panelRefs.playSceneButton,
'aria-label',
`Continue ${_sceneContext?.sceneTitle || 'current scene'} from next shot`,
t('Continue {scene} from next shot', {
scene:
_sceneContext?.sceneDisplayTitle ||
_sceneContext?.sceneTitle ||
t('current scene'),
}),
);
setElementProperty(
_panelRefs.storyReplayButton,
'textContent',
'↺ FULL STORY',
t('↺ FULL STORY'),
);
if (!sceneDirected) {
setElementProperty(_panelRefs.storyReplayButton, 'disabled', false);
setElementAttribute(
_panelRefs.storyReplayButton,
'aria-label',
'Replay the full reconstruction',
t('Replay the full reconstruction'),
);
}
const beatIndex = currentEvidenceIndex();
@@ -3047,18 +3215,26 @@ export function createBhoteKoshiEventLayer({
_panelRefs.beatTitle,
'textContent',
sceneDirected
? _sceneContext?.shotTitle || 'SCENE SHOT'
: observation?.shortTitle || observation?.title || 'EVIDENCE',
? _sceneContext?.shotDisplayTitle ||
_sceneContext?.shotTitle ||
t('SCENE SHOT')
: observation?.shortTitle || observation?.title
? bhoteEventText(observation.shortTitle || observation.title)
: t('EVIDENCE'),
);
const sourceCount = Math.max(
0,
Math.floor(Number(observation?.corroboration?.sourceCount) || 0),
);
const beatMeta = [
String(observation?.phase || 'EVIDENCE').toUpperCase(),
'TIME UNVERIFIED',
sourceCount > 1 ? `${sourceCount} SOURCES` : '',
observation?.imageryCoverage === 'outside' ? 'OUTSIDE IMAGERY SWIPE' : '',
evidencePhaseLabel(observation?.phase),
t('TIME UNVERIFIED'),
sourceCount > 1
? tn(sourceCount, '{count} SOURCE', '{count} SOURCES')
: '',
observation?.imageryCoverage === 'outside'
? t('OUTSIDE IMAGERY SWIPE')
: '',
]
.filter(Boolean)
.join(' / ');
@@ -3066,7 +3242,13 @@ export function createBhoteKoshiEventLayer({
_panelRefs.beatMeta,
'textContent',
sceneDirected
? `${String(_sceneContext?.sceneTitle || 'NEPAL FLOOD INCIDENT').toUpperCase()} / AUTHORED SHOT`
? t('{scene} / AUTHORED SHOT', {
scene: String(
_sceneContext?.sceneDisplayTitle ||
_sceneContext?.sceneTitle ||
t('NEPAL FLOOD INCIDENT'),
).toUpperCase(),
})
: beatMeta,
);
setElementProperty(
@@ -3093,18 +3275,23 @@ export function createBhoteKoshiEventLayer({
_panelRefs.playButton,
'aria-label',
sceneDirected
? `Play ${_sceneContext?.shotTitle || 'current scene shot'}`
: 'Play reconstruction',
? t('Play {shot}', {
shot:
_sceneContext?.shotDisplayTitle ||
_sceneContext?.shotTitle ||
t('current scene shot'),
})
: t('Play reconstruction'),
);
setElementAttribute(
_panelRefs.previousButton,
'aria-label',
sceneDirected ? 'Load previous scene shot' : 'Previous story beat',
sceneDirected ? t('Load previous scene shot') : t('Previous story beat'),
);
setElementAttribute(
_panelRefs.nextButton,
'aria-label',
sceneDirected ? 'Load next scene shot' : 'Next story beat',
sceneDirected ? t('Load next scene shot') : t('Next story beat'),
);
setElementProperty(
_panelRefs.openSourceButton,
@@ -3153,8 +3340,8 @@ export function createBhoteKoshiEventLayer({
if (_afterLayer) _afterLayer.show = imageryAvailable;
if (_panelRefs?.caveat) {
_panelRefs.caveat.textContent = imageryAvailable
? _event?.reconstruction?.caveat || ''
: 'SCHEMATIC CORRIDOR, NOT MODELED ARRIVAL TIME.';
? bhoteEventText(_event?.reconstruction?.caveat || '')
: t('SCHEMATIC CORRIDOR, NOT MODELED ARRIVAL TIME.');
}
if (_panelRefs?.imageryCloudNote)
_panelRefs.imageryCloudNote.hidden = !imageryAvailable;
@@ -3717,7 +3904,9 @@ export function createBhoteKoshiEventLayer({
count: _enabled ? 1 : 0,
status: _enabled ? 'nominal' : 'idle',
source: 'Vantor + GeoPera',
coverage: _enabled ? 'Rasuwa · 26 Aug 2026' : 'Event reconstruction',
coverage: _enabled
? t('Rasuwa · 26 Aug 2026')
: t('Event reconstruction'),
};
},
getPlaybackState() {
+120
View File
@@ -0,0 +1,120 @@
// The «{publisher} / OPEN» line is drawn on a canvas, so CSS cannot wrap it:
// a long translation shrinks to fit the portrait card, action word whole.
// English is drawn at full size.
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';
// The publisher on the evidence frame ("YouTube source") is pack text 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');
});
+60 -25
View File
@@ -12,6 +12,7 @@ import {
} from './dataCredits.js';
import { BHOTE_KOSHI_FLOOD_PATH } from './bhoteKoshiFloodPath.js';
import { INCIDENT_OVERVIEW_PLACES } from './bhoteKoshiIncidentPlaces.js';
import { t, msg, getLocale } from '../i18n/index.js';
export const BHOTE_KOSHI_LOCATOR_LAYER_ID = 'bhote-koshi-locator';
export const BHOTE_KOSHI_LOCATOR_OVERLAY_SOURCE_ID =
@@ -51,23 +52,42 @@ const TRIGGER_ORBIT_DURATION_SEC = 2.8;
const TRIGGER_SOURCE_URL =
'https://earthquake.usgs.gov/earthquakes/eventpage/us7000tbwb/executive';
// Descriptive pin titles translate at display (same entries as the event
// panel); place names stay Latin, anything else shows as authored.
const PLACE_TITLE_TEXT = new Map(
[
msg('Collapse viewpoint'),
msg('Dammed lake'),
msg('Second landslide'),
msg('Border gate'),
msg('Dandagaun viewpoint'),
msg('Bidur bridge'),
msg('Devighat bridge'),
].map((text) => [text.toUpperCase(), text]),
);
function placeTitle(title) {
const text = PLACE_TITLE_TEXT.get(title);
return text ? t(text).toLocaleUpperCase(getLocale()) : title;
}
const INCIDENT = Object.freeze({
lat: 28.3632333,
lon: 85.4423639,
heightM: 5200,
title: 'BHOTE KOSHI FLOOD INCIDENT',
subtitle: 'NEPAL · 26 AUG 2026',
title: msg('BHOTE KOSHI FLOOD INCIDENT'),
subtitle: msg('NEPAL · 26 AUG 2026'),
});
const TRIGGER_RECORD = Object.freeze({
lat: 28.271,
lon: 85.515,
heightM: 4200,
title: 'USGS INITIAL REPORT',
title: msg('USGS INITIAL REPORT'),
details: Object.freeze([
'26 AUG 2026 · 08:37 NPT',
'28.2710° N · 85.5150° E',
'INITIAL M4.4 · RECLASSIFIED M5.2 LANDSLIDE',
'USGS EVENT us7000tbwb · OPEN SOURCE',
msg('26 AUG 2026 · 08:37 NPT'),
msg('28.2710° N · 85.5150° E'),
msg('INITIAL M4.4 · RECLASSIFIED M5.2 LANDSLIDE'),
msg('USGS EVENT us7000tbwb · OPEN SOURCE'),
]),
});
const TRIGGER_APPROACH_CAMERA = Object.freeze({
@@ -90,11 +110,13 @@ const NEPAL = Object.freeze({
lat: 28.3949,
lon: 84.124,
heightM: 6800,
// i18n-ignore: country name, shown as is
title: 'NEPAL',
});
const NEARBY_CITIES = Object.freeze([
Object.freeze({
id: 'kathmandu',
// i18n-ignore: city name
title: 'KATHMANDU',
lat: 27.7017,
lon: 85.3206,
@@ -103,6 +125,7 @@ const NEARBY_CITIES = Object.freeze([
}),
Object.freeze({
id: 'bhaktapur',
// i18n-ignore: city name
title: 'BHAKTAPUR',
lat: 27.673,
lon: 85.43,
@@ -111,6 +134,7 @@ const NEARBY_CITIES = Object.freeze([
}),
Object.freeze({
id: 'dhulikhel',
// i18n-ignore: city name
title: 'DHULIKHEL',
lat: 27.6221,
lon: 85.5428,
@@ -394,8 +418,8 @@ export function createBhoteKoshiLocatorLayer({
position,
variant: 'card',
paintLane: 'thumbnail',
title: subject.title,
details: [subject.subtitle],
title: t(subject.title),
details: [t(subject.subtitle)],
accent: INCIDENT_ACCENT,
sourceAlpha: () => presentation().alpha,
presentationScale: () => presentation().scale,
@@ -536,8 +560,8 @@ export function createBhoteKoshiLocatorLayer({
position,
variant: 'card',
paintLane: 'thumbnail',
title: TRIGGER_RECORD.title,
details: [...TRIGGER_RECORD.details],
title: t(TRIGGER_RECORD.title),
details: TRIGGER_RECORD.details.map((line) => t(line)),
accent: INCIDENT_ACCENT,
sourceAlpha: () => presentation().alpha,
presentationScale: () => presentation().scale,
@@ -549,8 +573,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,
@@ -815,7 +840,7 @@ export function createBhoteKoshiLocatorLayer({
position,
variant: 'label',
paintLane: 'ambient-label',
title: `${String(index + 1).padStart(2, '0')} · ${place.title}`,
title: `${String(index + 1).padStart(2, '0')} · ${placeTitle(place.title)}`,
details: [],
accent: INCIDENT_ACCENT,
sourceAlpha: progress,
@@ -1121,57 +1146,67 @@ export function createBhoteKoshiLocatorLayer({
chips: [
{
id: 'nepal-context',
// i18n-ignore: country name
label: 'NEPAL',
active: _presentation === NEPAL_CONTEXT_PRESENTATION,
title:
title: t(
'Show the Nepal context callout without a border or country fill',
),
params: { presentation: NEPAL_CONTEXT_PRESENTATION },
},
{
id: 'bhote-koshi-regional',
// i18n-ignore: river name (Bhote Koshi)
label: 'BHOTE',
active: _presentation === BHOTE_KOSHI_REGIONAL_PRESENTATION,
title:
title: t(
'Introduce Nepal, draw its border, then reveal the Bhote Koshi incident',
),
params: { presentation: BHOTE_KOSHI_REGIONAL_PRESENTATION },
},
{
id: 'bhote-koshi-city-context',
label: 'CITIES',
label: t('CITIES'),
active: _presentation === BHOTE_KOSHI_CITY_CONTEXT_PRESENTATION,
title:
title: t(
'Keep the regional incident context and introduce nearby city labels',
),
params: { presentation: BHOTE_KOSHI_CITY_CONTEXT_PRESENTATION },
},
{
id: 'bhote-koshi-incident-places',
label: 'PLACES',
label: t('PLACES'),
active: _presentation === BHOTE_KOSHI_INCIDENT_PLACES_PRESENTATION,
title:
title: t(
'Introduce the reconstruction scene key places from trigger to downstream',
),
params: { presentation: BHOTE_KOSHI_INCIDENT_PLACES_PRESENTATION },
},
{
id: 'bhote-koshi-flood-path',
label: 'PATH',
label: t('PATH'),
active: _presentation === BHOTE_KOSHI_FLOOD_PATH_PRESENTATION,
title:
title: t(
'Animate the sourced cyan river-centerline route to Trishuli Bazaar',
),
params: { presentation: BHOTE_KOSHI_FLOOD_PATH_PRESENTATION },
},
{
id: 'bhote-koshi-path-overview',
label: 'OVERVIEW',
label: t('OVERVIEW'),
active: _presentation === BHOTE_KOSHI_PATH_OVERVIEW_PRESENTATION,
title: 'Keep the completed flood path and location labels visible',
title: t(
'Keep the completed flood path and location labels visible',
),
params: { presentation: BHOTE_KOSHI_PATH_OVERVIEW_PRESENTATION },
},
{
id: 'bhote-koshi-trigger-record',
label: 'RECORD',
label: t('RECORD'),
active: _presentation === BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION,
title:
title: t(
'Reveal the trigger record while making a slow camera approach',
),
params: {
presentation: BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION,
preview: true,
+408 -121
View File
@@ -1,9 +1,13 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { readFile } from 'node:fs/promises';
import { INCIDENT_OVERVIEW_HOLD_SEC, INCIDENT_OVERVIEW_PLACES } from './bhoteKoshiIncidentPlaces.js';
import {
INCIDENT_OVERVIEW_HOLD_SEC,
INCIDENT_OVERVIEW_PLACES,
} from './bhoteKoshiIncidentPlaces.js';
import { BHOTE_KOSHI_FLOOD_PATH } from './bhoteKoshiFloodPath.js';
import * as Cesium from 'cesium';
import { setLocale } from '../i18n/index.js';
import { SceneDirector } from '../scenes/director.js';
import { createPlaybackClock } from '../director/clock.js';
import { createDefaultScenePacks } from '../scenes/packs/defaults.js';
@@ -29,8 +33,12 @@ function viewerFixture() {
return {
creditDisplay: { addStaticCredit() {}, removeStaticCredit() {} },
dataSources: {
add(source) { added.push(source); },
remove(source) { removed.push(source); },
add(source) {
added.push(source);
},
remove(source) {
removed.push(source);
},
},
_test: { added, removed },
};
@@ -38,7 +46,9 @@ function viewerFixture() {
function nonRunningAnimation() {
return {
scheduleFrame() { return 1; },
scheduleFrame() {
return 1;
},
cancelFrame() {},
};
}
@@ -58,24 +68,68 @@ function overlayHostFixture() {
}
test('Bhote Koshi locator closes valid region rings once', () => {
assert.deepEqual(closeRegionRing([[80, 26], [88, 26], [88, 30]]), [
[80, 26], [88, 26], [88, 30], [80, 26],
]);
assert.deepEqual(closeRegionRing([[80, 26], [88, 26], [80, 26]]), [
[80, 26], [88, 26], [80, 26],
]);
assert.deepEqual(closeRegionRing([[80, 26], ['bad', 27]]), []);
assert.deepEqual(
closeRegionRing([
[80, 26],
[88, 26],
[88, 30],
]),
[
[80, 26],
[88, 26],
[88, 30],
[80, 26],
],
);
assert.deepEqual(
closeRegionRing([
[80, 26],
[88, 26],
[80, 26],
]),
[
[80, 26],
[88, 26],
[80, 26],
],
);
assert.deepEqual(
closeRegionRing([
[80, 26],
['bad', 27],
]),
[],
);
});
test('Bhote Koshi locator selects the largest GeoJSON country exterior', () => {
const main = [[80, 26], [88, 26], [88, 30], [80, 30], [80, 26]];
const island = [[82, 25], [83, 25], [82, 25]];
assert.equal(largestGeoJsonRing({ type: 'Polygon', coordinates: [main] }), main);
assert.equal(largestGeoJsonRing({
type: 'MultiPolygon',
coordinates: [[island], [main]],
}), main);
assert.deepEqual(largestGeoJsonRing({ type: 'Point', coordinates: [84, 28] }), []);
const main = [
[80, 26],
[88, 26],
[88, 30],
[80, 30],
[80, 26],
];
const island = [
[82, 25],
[83, 25],
[82, 25],
];
assert.equal(
largestGeoJsonRing({ type: 'Polygon', coordinates: [main] }),
main,
);
assert.equal(
largestGeoJsonRing({
type: 'MultiPolygon',
coordinates: [[island], [main]],
}),
main,
);
assert.deepEqual(
largestGeoJsonRing({ type: 'Point', coordinates: [84, 28] }),
[],
);
});
test('Bhote Koshi locator reveals a border progressively', () => {
@@ -84,7 +138,10 @@ test('Bhote Koshi locator reveals a border progressively', () => {
new Cesium.Cartesian3(10, 0, 0),
new Cesium.Cartesian3(20, 0, 0),
];
assert.deepEqual(revealPolylinePositions(positions, 0), [positions[0], positions[0]]);
assert.deepEqual(revealPolylinePositions(positions, 0), [
positions[0],
positions[0],
]);
assert.deepEqual(revealPolylinePositions(positions, 0.25), [
positions[0],
new Cesium.Cartesian3(5, 0, 0),
@@ -115,7 +172,12 @@ test('Bhote Koshi regional locator sequences Nepal, border, then the incident',
const layer = createBhoteKoshiLocatorLayer({
boundaryResolver: async () => ({
name: 'Nepal',
ring: [[80, 26], [88, 26], [88, 30], [80, 30]],
ring: [
[80, 26],
[88, 26],
[88, 30],
[80, 30],
],
}),
requestRender: (reason) => renders.push(reason),
overlayHost,
@@ -141,8 +203,12 @@ test('Bhote Koshi regional locator sequences Nepal, border, then the incident',
const publication = overlayHost._test.publications.at(-1);
assert.equal(publication.sourceId, BHOTE_KOSHI_LOCATOR_OVERLAY_SOURCE_ID);
assert.equal(publication.entries.length, 2);
const nepal = publication.entries.find(({ id }) => id === 'nepal-context-callout');
const incident = publication.entries.find(({ id }) => id === 'bhote-koshi-incident-callout');
const nepal = publication.entries.find(
({ id }) => id === 'nepal-context-callout',
);
const incident = publication.entries.find(
({ id }) => id === 'bhote-koshi-incident-callout',
);
assert.equal(nepal.variant, 'label');
assert.equal(nepal.title, 'NEPAL');
assert.equal(nepal.anchorDot, true);
@@ -175,12 +241,17 @@ test('Bhote Koshi regional locator sequences Nepal, border, then the incident',
assert.equal(incident.contentAlpha(), 1);
assert.equal(incident.presentationScale(), 1);
assert.equal(layer.getStats().status, 'nominal');
assert.deepEqual(layer.getParams(), { presentation: BHOTE_KOSHI_REGIONAL_PRESENTATION });
assert.deepEqual(layer.getParams(), {
presentation: BHOTE_KOSHI_REGIONAL_PRESENTATION,
});
assert.ok(renders.includes('bhote-koshi-locator-boundary'));
assert.equal(await layer.disable(), true);
assert.equal(viewer._test.removed.length, 1);
assert.equal(overlayHost._test.clears.at(-1), BHOTE_KOSHI_LOCATOR_OVERLAY_SOURCE_ID);
assert.equal(
overlayHost._test.clears.at(-1),
BHOTE_KOSHI_LOCATOR_OVERLAY_SOURCE_ID,
);
assert.equal(layer.getStats().status, 'idle');
});
@@ -190,7 +261,12 @@ test('Bhote Koshi locator switches between Nepal context and the regional incide
const layer = createBhoteKoshiLocatorLayer({
boundaryResolver: async () => ({
name: 'Nepal',
ring: [[80, 26], [88, 26], [88, 30], [80, 30]],
ring: [
[80, 26],
[88, 26],
[88, 30],
[80, 30],
],
}),
requestRender() {},
overlayHost,
@@ -199,37 +275,46 @@ test('Bhote Koshi locator switches between Nepal context and the regional incide
layer.setParams({ presentation: NEPAL_CONTEXT_PRESENTATION });
await layer.enable(viewer);
assert.deepEqual(viewer._test.added[0].entities.values.map((entity) => entity.id), [
'nepal-context-halo',
]);
assert.deepEqual(
viewer._test.added[0].entities.values.map((entity) => entity.id),
['nepal-context-halo'],
);
const nepalLabel = overlayHost._test.publications.at(-1).entries[0];
assert.equal(nepalLabel.variant, 'label');
assert.equal(nepalLabel.placement, 'right');
assert.equal(nepalLabel.anchorDot, true);
assert.equal(layer.getStats().coverage, 'Nepal context');
assert.deepEqual(layer.getRowControls().chips.map(({ id, active }) => ({ id, active })), [
{ id: 'nepal-context', active: true },
{ id: 'bhote-koshi-regional', active: false },
{ id: 'bhote-koshi-city-context', active: false },
{ id: 'bhote-koshi-incident-places', active: false },
{ id: 'bhote-koshi-flood-path', active: false },
{ id: 'bhote-koshi-path-overview', active: false },
{ id: 'bhote-koshi-trigger-record', active: false },
]);
assert.deepEqual(
layer.getRowControls().chips.map(({ id, active }) => ({ id, active })),
[
{ id: 'nepal-context', active: true },
{ id: 'bhote-koshi-regional', active: false },
{ id: 'bhote-koshi-city-context', active: false },
{ id: 'bhote-koshi-incident-places', active: false },
{ id: 'bhote-koshi-flood-path', active: false },
{ id: 'bhote-koshi-path-overview', active: false },
{ id: 'bhote-koshi-trigger-record', active: false },
],
);
layer.setParams({ presentation: BHOTE_KOSHI_REGIONAL_PRESENTATION });
assert.ok(viewer._test.added[0].entities.getById('nepal-border-highlight'));
assert.equal(viewer._test.added[0].entities.getById('bhote-koshi-city-kathmandu'), undefined);
assert.equal(
overlayHost._test.publications.at(-1).entries
.find(({ id }) => id === 'bhote-koshi-incident-callout').variant,
viewer._test.added[0].entities.getById('bhote-koshi-city-kathmandu'),
undefined,
);
assert.equal(
overlayHost._test.publications
.at(-1)
.entries.find(({ id }) => id === 'bhote-koshi-incident-callout').variant,
'card',
);
layer.setParams({ presentation: NEPAL_CONTEXT_PRESENTATION });
assert.deepEqual(viewer._test.added[0].entities.values.map((entity) => entity.id), [
'nepal-context-halo',
]);
assert.deepEqual(
viewer._test.added[0].entities.values.map((entity) => entity.id),
['nepal-context-halo'],
);
await layer.destroy();
});
@@ -239,16 +324,28 @@ test('Incident Corridor reveals all fifteen overview pins and keeps layout activ
let nowMs = 0;
let pulseFrame = null;
const layer = createBhoteKoshiLocatorLayer({
boundaryResolver: async () => null, requestRender() {},
boundaryResolver: async () => null,
requestRender() {},
overlayHost,
now: () => nowMs,
scheduleFrame(callback) { pulseFrame = callback; return 1; },
scheduleFrame(callback) {
pulseFrame = callback;
return 1;
},
cancelFrame() {},
});
layer.setParams({ presentation: BHOTE_KOSHI_INCIDENT_PLACES_PRESENTATION });
await layer.enable(viewer);
const source = viewer._test.added[0];
const event = JSON.parse(await readFile(new URL('../../public/events/bhote-koshi-2026/event.json', import.meta.url), 'utf8'));
const event = JSON.parse(
await readFile(
new URL(
'../../public/events/bhote-koshi-2026/event.json',
import.meta.url,
),
'utf8',
),
);
assert.equal(event.evidenceSpine.length, 16);
assert.equal(event.evidenceSpine.at(-1).id, 'charaudi');
assert.equal(source.entities.values.length, 15);
@@ -259,30 +356,100 @@ test('Incident Corridor reveals all fifteen overview pins and keeps layout activ
const id = `bhote-koshi-place-${point.id}`;
const pin = labels.find((entry) => entry.id === id);
assert.ok(pin, point.id);
assert.equal(pin.title, `${String(index + 1).padStart(2, '0')} · ${point.title}`);
assert.equal(
pin.title,
`${String(index + 1).padStart(2, '0')} · ${point.title}`,
);
assert.equal(pin.variant, 'label');
const location = Cesium.Cartographic.fromCartesian(pin.position);
assert.ok(Math.abs(Cesium.Math.toDegrees(location.latitude) - point.lat) < 1e-6);
assert.ok(Math.abs(Cesium.Math.toDegrees(location.longitude) - point.lon) < 1e-6);
assert.ok(
Math.abs(Cesium.Math.toDegrees(location.latitude) - point.lat) < 1e-6,
);
assert.ok(
Math.abs(Cesium.Math.toDegrees(location.longitude) - point.lon) < 1e-6,
);
nowMs = 349 + index * 650;
assert.equal(pin.sourceAlpha(), 0);
nowMs += 1;
assert.equal(pin.sourceAlpha(), 0);
assert.equal(source.entities.values.filter(entity => entity.id.endsWith('-halo') && entity.show.getValue()).length, 1);
assert.equal(
source.entities.values.filter(
(entity) => entity.id.endsWith('-halo') && entity.show.getValue(),
).length,
1,
);
nowMs += 520;
assert.equal(pin.sourceAlpha(), 1);
}
assert.ok(nowMs < INCIDENT_OVERVIEW_HOLD_SEC * 1000);
// A repeat halo cycle must not erase already revealed labels.
nowMs = 350 + 15 * 650;
assert.equal(source.entities.getById('bhote-koshi-place-immediate-collapse-viewpoint-halo').show.getValue(), true);
assert.equal(labels.find(({ id }) => id === 'bhote-koshi-place-devighat-taadi-khola-bridge').sourceAlpha(), 1);
assert.equal(labels.some(({ id }) => id === 'bhote-koshi-place-charaudi'), false);
assert.equal(
source.entities
.getById('bhote-koshi-place-immediate-collapse-viewpoint-halo')
.show.getValue(),
true,
);
assert.equal(
labels
.find(({ id }) => id === 'bhote-koshi-place-devighat-taadi-khola-bridge')
.sourceAlpha(),
1,
);
assert.equal(
labels.some(({ id }) => id === 'bhote-koshi-place-charaudi'),
false,
);
assert.equal(typeof pulseFrame, 'function');
pulseFrame();
assert.equal(overlayHost._test.publications.at(-1).options.moving, false);
layer.setParams({ presentation: BHOTE_KOSHI_REGIONAL_PRESENTATION });
assert.equal(source.entities.values.some(entity => entity.id.startsWith('bhote-koshi-place-')), false);
assert.equal(
source.entities.values.some((entity) =>
entity.id.startsWith('bhote-koshi-place-'),
),
false,
);
await layer.destroy();
});
test('Bhote Koshi overview pins translate their descriptive titles and keep place names', async (t) => {
t.after(() => setLocale('en'));
setLocale('ru');
const viewer = viewerFixture();
const overlayHost = overlayHostFixture();
const layer = createBhoteKoshiLocatorLayer({
boundaryResolver: async () => null,
requestRender() {},
overlayHost,
now: () => 0,
...nonRunningAnimation(),
});
layer.setParams({ presentation: BHOTE_KOSHI_FLOOD_PATH_PRESENTATION });
await layer.enable(viewer);
const titles = new Map(
overlayHost._test.publications
.at(-1)
.entries.map(({ id, title }) => [id, title]),
);
assert.equal(
titles.get('bhote-koshi-place-immediate-collapse-viewpoint'),
'01 · ВИД НА ОБРУШЕНИЕ',
);
assert.equal(
titles.get('bhote-koshi-place-gyirong-border-gate'),
'04 · ПОГРАНПЕРЕХОД',
);
assert.equal(
titles.get('bhote-koshi-place-dandagaun-viewpoint'),
'11 · СМОТРОВАЯ ПЛОЩАДКА DANDAGAUN',
);
assert.equal(
titles.get('bhote-koshi-place-bidur-trishuli-bridge'),
'14 · МОСТ BIDUR',
);
assert.equal(titles.get('bhote-koshi-place-timure-cluster'), '05 · TIMURE');
await layer.destroy();
});
@@ -303,8 +470,12 @@ test('Bhote Koshi flood path draws a solid cyan sourced route after the place sh
const source = viewer._test.added[0];
const path = source.entities.getById('bhote-koshi-flood-path');
const labels = overlayHost._test.publications.at(-1).entries;
const trigger = labels.find(({ id }) => id === 'bhote-koshi-place-immediate-collapse-viewpoint');
const bidur = labels.find(({ id }) => id === 'bhote-koshi-place-bidur-trishuli-bridge');
const trigger = labels.find(
({ id }) => id === 'bhote-koshi-place-immediate-collapse-viewpoint',
);
const bidur = labels.find(
({ id }) => id === 'bhote-koshi-place-bidur-trishuli-bridge',
);
assert.equal(source.entities.values.length, 1);
assert.equal(labels.length, 15);
@@ -315,10 +486,14 @@ test('Bhote Koshi flood path draws a solid cyan sourced route after the place sh
}
assert.equal(path.polyline.clampToGround.getValue(), true);
assert.ok(path.polyline.material instanceof Cesium.ColorMaterialProperty);
assert.ok(path.polyline.material.color.getValue().equalsEpsilon(
Cesium.Color.fromCssColorString('#20e7f2').withAlpha(0.96),
Cesium.Math.EPSILON7,
));
assert.ok(
path.polyline.material.color
.getValue()
.equalsEpsilon(
Cesium.Color.fromCssColorString('#20e7f2').withAlpha(0.96),
Cesium.Math.EPSILON7,
),
);
assert.equal(path.show.getValue(), false);
assert.equal(trigger.sourceAlpha(), 1);
assert.equal(bidur.sourceAlpha(), 1);
@@ -330,15 +505,23 @@ test('Bhote Koshi flood path draws a solid cyan sourced route after the place sh
const halfwayCount = path.polyline.positions.getValue().length;
assert.ok(Math.abs(halfwayCount - BHOTE_KOSHI_FLOOD_PATH.length / 2) <= 2);
nowMs = 4250;
assert.equal(path.polyline.positions.getValue().length, BHOTE_KOSHI_FLOOD_PATH.length);
assert.equal(
path.polyline.positions.getValue().length,
BHOTE_KOSHI_FLOOD_PATH.length,
);
assert.deepEqual(BHOTE_KOSHI_FLOOD_PATH[0], [85.48476, 28.33255]);
// More observation pins must not extend or straighten the sourced route.
assert.deepEqual(path.polyline.positions.getValue(), BHOTE_KOSHI_FLOOD_PATH.map(([lon, lat]) => (
Cesium.Cartesian3.fromDegrees(lon, lat)
)));
assert.deepEqual(
path.polyline.positions.getValue(),
BHOTE_KOSHI_FLOOD_PATH.map(([lon, lat]) =>
Cesium.Cartesian3.fromDegrees(lon, lat),
),
);
assert.deepEqual(BHOTE_KOSHI_FLOOD_PATH.at(-1), [85.14943, 27.92177]);
assert.equal(layer.getStats().coverage, 'Bhote Koshi · Flood path');
assert.deepEqual(layer.getParams(), { presentation: BHOTE_KOSHI_FLOOD_PATH_PRESENTATION });
assert.deepEqual(layer.getParams(), {
presentation: BHOTE_KOSHI_FLOOD_PATH_PRESENTATION,
});
await layer.destroy();
});
@@ -349,7 +532,12 @@ test('Bhote Koshi path-overview presentation keeps the route settled without Nep
const layer = createBhoteKoshiLocatorLayer({
boundaryResolver: async () => ({
name: 'Nepal',
ring: [[80, 26], [88, 26], [88, 30], [80, 30]],
ring: [
[80, 26],
[88, 26],
[88, 30],
[80, 30],
],
}),
requestRender() {},
overlayHost,
@@ -367,21 +555,35 @@ test('Bhote Koshi path-overview presentation keeps the route settled without Nep
assert.equal(labels.length, 15);
for (const [index, place] of INCIDENT_OVERVIEW_PLACES.entries()) {
const pin = labels.find(({ id }) => id === `bhote-koshi-place-${place.id}`);
assert.equal(pin.title, `${String(index + 1).padStart(2, '0')} · ${place.title}`);
assert.equal(
pin.title,
`${String(index + 1).padStart(2, '0')} · ${place.title}`,
);
assert.equal(pin.sourceAlpha(), 1);
}
assert.equal(path.show.getValue(), true);
assert.equal(path.polyline.positions.getValue().length, BHOTE_KOSHI_FLOOD_PATH.length);
assert.ok(Cesium.Cartesian3.equals(path.polyline.positions.getValue()[0],
Cesium.Cartesian3.fromDegrees(85.48476, 28.33255)));
assert.equal(
path.polyline.positions.getValue().length,
BHOTE_KOSHI_FLOOD_PATH.length,
);
assert.ok(
Cesium.Cartesian3.equals(
path.polyline.positions.getValue()[0],
Cesium.Cartesian3.fromDegrees(85.48476, 28.33255),
),
);
assert.equal(source.entities.getById('nepal-border-fill'), undefined);
assert.equal(source.entities.getById('nepal-border-highlight'), undefined);
assert.equal(
source.entities.getById('bhote-koshi-place-immediate-collapse-viewpoint-halo'),
source.entities.getById(
'bhote-koshi-place-immediate-collapse-viewpoint-halo',
),
undefined,
);
assert.equal(layer.getStats().coverage, 'Bhote Koshi · Path overview');
assert.deepEqual(layer.getParams(), { presentation: BHOTE_KOSHI_PATH_OVERVIEW_PRESENTATION });
assert.deepEqual(layer.getParams(), {
presentation: BHOTE_KOSHI_PATH_OVERVIEW_PRESENTATION,
});
await layer.destroy();
});
@@ -406,10 +608,10 @@ test('Bhote Koshi trigger-record presentation reveals sourced incident details',
const publication = overlayHost._test.publications.at(-1);
const record = publication.entries[0];
assert.deepEqual(source.entities.values.map(({ id }) => id), [
'bhote-koshi-flood-path',
'bhote-koshi-trigger-record-halo',
]);
assert.deepEqual(
source.entities.values.map(({ id }) => id),
['bhote-koshi-flood-path', 'bhote-koshi-trigger-record-halo'],
);
assert.equal(record.title, 'USGS INITIAL REPORT');
assert.deepEqual(record.details, [
'26 AUG 2026 · 08:37 NPT',
@@ -440,7 +642,9 @@ test('Bhote Koshi trigger-record waits for the scene camera, zooms, then orbits'
viewer.camera = {
moveStart,
moveEnd,
flyTo(options) { flights.push(options); },
flyTo(options) {
flights.push(options);
},
cancelFlight() {},
};
const layer = createBhoteKoshiLocatorLayer({
@@ -465,8 +669,12 @@ test('Bhote Koshi trigger-record waits for the scene camera, zooms, then orbits'
assert.equal(flights.length, 1);
assert.equal(flights[0].duration, 4.6);
const destination = Cesium.Cartographic.fromCartesian(flights[0].destination);
assert.ok(Math.abs(Cesium.Math.toDegrees(destination.latitude) - 28.368) < 1e-6);
assert.ok(Math.abs(Cesium.Math.toDegrees(destination.longitude) - 85.473) < 1e-6);
assert.ok(
Math.abs(Cesium.Math.toDegrees(destination.latitude) - 28.368) < 1e-6,
);
assert.ok(
Math.abs(Cesium.Math.toDegrees(destination.longitude) - 85.473) < 1e-6,
);
assert.ok(Math.abs(destination.height - 10526) < 1e-4);
flights[0].complete();
assert.equal(flights.length, 2);
@@ -488,8 +696,11 @@ for (const phase of ['timer', 'move-end', 'approach', 'orbit']) {
const moveStart = new Cesium.Event();
const moveEnd = new Cesium.Event();
viewer.camera = {
moveStart, moveEnd,
flyTo(options) { flights.push(options); },
moveStart,
moveEnd,
flyTo(options) {
flights.push(options);
},
cancelFlight() {},
};
const layer = createBhoteKoshiLocatorLayer({
@@ -501,21 +712,30 @@ for (const phase of ['timer', 'move-end', 'approach', 'orbit']) {
timers.set(++nextTimer, callback);
return nextTimer;
},
cancelDelay(id) { timers.delete(id); },
cancelDelay(id) {
timers.delete(id);
},
...nonRunningAnimation(),
});
const director = {
_clock: createPlaybackClock({ isRunning: () => false, timingForShot: () => null, onProgress() {} }),
_clock: createPlaybackClock({
isRunning: () => false,
timingForShot: () => null,
onProgress() {},
}),
_scenePacks: createDefaultScenePacks(),
dataManager: { layers: new Map([[layer.id, { module: layer }]]) },
viewer,
_sceneSeekGeneration: 0,
_loadGeneration: 0,
_cancelActiveSceneTravel: SceneDirector.prototype._cancelActiveSceneTravel,
_cancelActiveSceneTravel:
SceneDirector.prototype._cancelActiveSceneTravel,
_setSceneMediaPlayback: SceneDirector.prototype._setSceneMediaPlayback,
};
try {
layer.setParams({ presentation: BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION });
layer.setParams({
presentation: BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION,
});
await layer.enable(viewer);
if (phase === 'move-end') moveStart.raiseEvent();
if (phase === 'approach' || phase === 'orbit') callbacks[0]();
@@ -528,19 +748,38 @@ for (const phase of ['timer', 'move-end', 'approach', 'orbit']) {
callbacks[0]();
moveEnd.raiseEvent();
for (const flight of oldFlights) flight.complete();
assert.equal(flights.length, oldFlights.length, 'no post-Stop approach or orbit');
assert.equal(layer.getStats().count, 1, 'Stop preserves the visible locator');
assert.equal(
flights.length,
oldFlights.length,
'no post-Stop approach or orbit',
);
assert.equal(
layer.getStats().count,
1,
'Stop preserves the visible locator',
);
layer.setParams({ presentation: BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION });
layer.setParams({
presentation: BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION,
});
callbacks.at(-1)();
const successor = flights.at(-1);
const count = flights.length;
callbacks[0]();
for (const flight of oldFlights) { flight.cancel(); flight.complete(); }
for (const flight of oldFlights) {
flight.cancel();
flight.complete();
}
assert.equal(flights.length, count, 'old callbacks cannot add a flight');
successor.complete();
assert.equal(flights.length, count + 1, 'successor retains its own orbit');
} finally { await layer.destroy(); }
assert.equal(
flights.length,
count + 1,
'successor retains its own orbit',
);
} finally {
await layer.destroy();
}
});
}
@@ -551,7 +790,12 @@ test('Bhote Koshi locator keeps the regional context settled while nearby cities
const layer = createBhoteKoshiLocatorLayer({
boundaryResolver: async () => ({
name: 'Nepal',
ring: [[80, 26], [88, 26], [88, 30], [80, 30]],
ring: [
[80, 26],
[88, 26],
[88, 30],
[80, 30],
],
}),
requestRender() {},
overlayHost,
@@ -564,19 +808,29 @@ test('Bhote Koshi locator keeps the regional context settled while nearby cities
const source = viewer._test.added[0];
const borderFill = source.entities.getById('nepal-border-fill');
const entries = overlayHost._test.publications.at(-1).entries;
const incident = entries.find(({ id }) => id === 'bhote-koshi-incident-callout');
const kathmandu = entries.find(({ id }) => id === 'bhote-koshi-city-kathmandu');
const bhaktapur = entries.find(({ id }) => id === 'bhote-koshi-city-bhaktapur');
const dhulikhel = entries.find(({ id }) => id === 'bhote-koshi-city-dhulikhel');
const incident = entries.find(
({ id }) => id === 'bhote-koshi-incident-callout',
);
const kathmandu = entries.find(
({ id }) => id === 'bhote-koshi-city-kathmandu',
);
const bhaktapur = entries.find(
({ id }) => id === 'bhote-koshi-city-bhaktapur',
);
const dhulikhel = entries.find(
({ id }) => id === 'bhote-koshi-city-dhulikhel',
);
assert.equal(borderFill.show.getValue(), true);
assert.equal(incident.contentAlpha(), 1);
assert.equal(kathmandu.title, 'KATHMANDU');
assert.equal(bhaktapur.title, 'BHAKTAPUR');
assert.equal(dhulikhel.title, 'DHULIKHEL');
assert.ok([kathmandu, bhaktapur, dhulikhel].every(({ variant, anchorDot }) => (
variant === 'label' && anchorDot === true
)));
assert.ok(
[kathmandu, bhaktapur, dhulikhel].every(
({ variant, anchorDot }) => variant === 'label' && anchorDot === true,
),
);
assert.equal(kathmandu.sourceAlpha(), 0);
assert.equal(bhaktapur.sourceAlpha(), 0);
assert.equal(dhulikhel.sourceAlpha(), 0);
@@ -591,7 +845,9 @@ test('Bhote Koshi locator keeps the regional context settled while nearby cities
nowMs = 1300;
assert.equal(dhulikhel.sourceAlpha(), 1);
assert.equal(layer.getStats().coverage, 'Bhote Koshi · Nearby cities');
assert.deepEqual(layer.getParams(), { presentation: BHOTE_KOSHI_CITY_CONTEXT_PRESENTATION });
assert.deepEqual(layer.getParams(), {
presentation: BHOTE_KOSHI_CITY_CONTEXT_PRESENTATION,
});
await layer.destroy();
});
@@ -608,15 +864,17 @@ test('Bhote Koshi locator degrades to labels when the regional boundary is unava
});
try {
await layer.enable(viewer);
const ids = viewer._test.added[0].entities.values.map((entity) => entity.id);
const ids = viewer._test.added[0].entities.values.map(
(entity) => entity.id,
);
assert.ok(ids.includes('nepal-context-halo'));
assert.ok(ids.includes('bhote-koshi-incident-halo'));
assert.equal(ids.includes('bhote-koshi-city-kathmandu'), false);
assert.equal(ids.includes('nepal-border-highlight'), false);
assert.deepEqual(overlayHost._test.publications.at(-1).entries.map(({ id }) => id), [
'nepal-context-callout',
'bhote-koshi-incident-callout',
]);
assert.deepEqual(
overlayHost._test.publications.at(-1).entries.map(({ id }) => id),
['nepal-context-callout', 'bhote-koshi-incident-callout'],
);
assert.equal(layer.getStats().status, 'degraded');
} finally {
console.warn = originalWarn;
@@ -628,7 +886,9 @@ test('Bhote Koshi regional locator waits for its boundary before starting the fu
const viewer = viewerFixture();
const overlayHost = overlayHostFixture();
let releaseBoundary;
const boundary = new Promise((resolve) => { releaseBoundary = resolve; });
const boundary = new Promise((resolve) => {
releaseBoundary = resolve;
});
const layer = createBhoteKoshiLocatorLayer({
boundaryResolver: async () => boundary,
requestRender() {},
@@ -638,23 +898,42 @@ test('Bhote Koshi regional locator waits for its boundary before starting the fu
const enabling = layer.enable(viewer);
assert.ok(viewer._test.added[0].entities.getById('nepal-context-halo'));
assert.equal(viewer._test.added[0].entities.getById('nepal-border-highlight'), undefined);
assert.equal(viewer._test.added[0].entities.getById('bhote-koshi-incident-halo'), undefined);
assert.equal(
viewer._test.added[0].entities.getById('nepal-border-highlight'),
undefined,
);
assert.equal(
viewer._test.added[0].entities.getById('bhote-koshi-incident-halo'),
undefined,
);
assert.equal(overlayHost._test.publications.length, 1);
assert.deepEqual(overlayHost._test.publications[0].entries.map(({ id }) => id), [
'nepal-context-callout',
]);
assert.deepEqual(
overlayHost._test.publications[0].entries.map(({ id }) => id),
['nepal-context-callout'],
);
releaseBoundary({ ring: [[80, 26], [88, 26], [88, 30], [80, 30]] });
releaseBoundary({
ring: [
[80, 26],
[88, 26],
[88, 30],
[80, 30],
],
});
await enabling;
assert.ok(viewer._test.added[0].entities.getById('bhote-koshi-incident-halo'));
assert.ok(
viewer._test.added[0].entities.getById('bhote-koshi-incident-halo'),
);
assert.ok(viewer._test.added[0].entities.getById('nepal-border-highlight'));
assert.equal(viewer._test.added[0].entities.getById('bhote-koshi-city-kathmandu'), undefined);
assert.equal(
viewer._test.added[0].entities.getById('bhote-koshi-city-kathmandu'),
undefined,
);
assert.equal(overlayHost._test.publications.length, 2);
assert.deepEqual(overlayHost._test.publications.at(-1).entries.map(({ id }) => id), [
'nepal-context-callout',
'bhote-koshi-incident-callout',
]);
assert.deepEqual(
overlayHost._test.publications.at(-1).entries.map(({ id }) => id),
['nepal-context-callout', 'bhote-koshi-incident-callout'],
);
await layer.destroy();
});
@@ -662,7 +941,9 @@ test('Bhote Koshi locator cancels a stale boundary resolution and removes its su
const viewer = viewerFixture();
const controller = new AbortController();
let releaseBoundary;
const boundary = new Promise((resolve) => { releaseBoundary = resolve; });
const boundary = new Promise((resolve) => {
releaseBoundary = resolve;
});
const layer = createBhoteKoshiLocatorLayer({
boundaryResolver: async () => boundary,
requestRender() {},
@@ -670,7 +951,13 @@ test('Bhote Koshi locator cancels a stale boundary resolution and removes its su
});
const enabling = layer.enable(viewer, { signal: controller.signal });
controller.abort();
releaseBoundary({ ring: [[80, 26], [88, 26], [88, 30]] });
releaseBoundary({
ring: [
[80, 26],
[88, 26],
[88, 30],
],
});
await assert.rejects(enabling, (error) => error?.name === 'AbortError');
assert.equal(viewer._test.removed.length, 1);
});
+376
View File
@@ -0,0 +1,376 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
floridaCameraId,
floridaCameraToSource,
loadFloridaSourcesFromOpenData,
} from '../../server/providers/cctv/sources.js';
import {
FLORIDA_CCTV_URL,
FLORIDA_MAX_CATALOG_BYTES,
FLORIDA_MAX_CATALOG_PAGES,
} from '../../server/providers/cctv/constants.js';
import {
fallbackHeadingFromId,
isLikelyFloridaCoordinate,
} from '../../server/providers/cctv/normalize.js';
import { createCctvCatalog } from '../../server/providers/cctv/catalog.js';
const IMAGE_HOST = 'https://images-dis.divas.cloud/DGI/';
/** A response with a live body, plus a flag that flips when it is cancelled. */
const streamingResponse = (init = {}) => {
const state = { cancelled: false };
const body = new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode('{'));
},
cancel() {
state.cancelled = true;
},
});
return { response: new Response(body, init), state };
};
/** One FL511 feature, shaped like the live FeatureServer payload. */
const feature = (overrides = {}) => ({
attributes: {
OBJECTID_1: 6,
ID: '1220',
DESCRIPT: 'I-75 @ MM 346.5 SB',
COUNTY: 'Marion',
HIGHWAY: 'I-75',
DIRECTION: 'S',
LATITUDE: 29.107363,
LONGITUDE: -82.188334,
IMAGE: `${IMAGE_HOST}chan-9431_h.jpg`,
...overrides,
},
});
/** One catalog page of `count` distinct cameras, channels from `start`. */
const page = (start, count, extra = {}) => ({
features: Array.from({ length: count }, (_, i) =>
feature({ IMAGE: `${IMAGE_HOST}chan-${start + i}_h.jpg` }),
),
...extra,
});
const offsetOf = (url) =>
Number(new URL(String(url)).searchParams.get('resultOffset'));
/** Lift the pack cap for one test so every loaded camera comes back. */
const uncapped = (t) => {
const previous = process.env.CCTV_FLORIDA_MAX_SOURCES;
process.env.CCTV_FLORIDA_MAX_SOURCES = '4200';
t.after(() => {
if (previous === undefined) delete process.env.CCTV_FLORIDA_MAX_SOURCES;
else process.env.CCTV_FLORIDA_MAX_SOURCES = previous;
});
};
test('an FL511 feature maps to a source on a pinned HTTPS frame host', () => {
const source = floridaCameraToSource(feature());
assert.equal(source.id, 'fl-9431');
assert.equal(source.name, 'I-75 @ MM 346.5 SB');
assert.equal(source.city, 'Marion County');
assert.equal(source.cityId, 'florida');
assert.equal(source.provider, 'FL511');
assert.equal(source.lat, 29.107363);
assert.equal(source.lon, -82.188334);
assert.equal(source.headingDeg, 180);
assert.equal(
floridaCameraToSource(feature({ DIRECTION: 'E' })).headingDeg,
90,
);
assert.equal(source.headingConfidence, 'high');
assert.equal(source.feedType, 'image');
assert.equal(source.url, `${IMAGE_HOST}chan-9431_h.jpg`);
assert.equal(source.url, source.snapshotUrl);
assert.equal(source.sourceKind, 'fl511-open-data');
assert.match(source.license, /personal use only/);
assert.match(source.license, /written consent/);
});
test('NOT DIRECTIONAL takes the id-hash fallback at low confidence', () => {
const source = floridaCameraToSource(
feature({ DIRECTION: 'NOT DIRECTIONAL' }),
);
assert.equal(source.headingConfidence, 'low');
assert.equal(source.headingDeg, fallbackHeadingFromId('fl-9431'));
});
test('rows outside Florida, off-host frames and unusable rows are dropped', () => {
const dropped = [
feature({ LATITUDE: 33.749, LONGITUDE: -84.388 }), // Atlanta
feature({ LATITUDE: 0, LONGITUDE: 0 }),
feature({ LATITUDE: -82.188334, LONGITUDE: 29.107363 }), // swapped
feature({ LATITUDE: '', LONGITUDE: '' }),
feature({ IMAGE: 'https://evil.example/DGI/chan-9431_h.jpg' }),
feature({ IMAGE: 'http://images-dis.divas.cloud/DGI/chan-9431_h.jpg' }),
feature({
IMAGE: 'https://user:[email protected]/DGI/chan-9431_h.jpg',
}),
feature({ IMAGE: 'not a url' }),
{ attributes: null },
null,
];
for (const row of dropped) assert.equal(floridaCameraToSource(row), null);
});
test('camera ids come from the image channel, not the repeating ID column', () => {
// FL511 reuses ID across regions: 1220 is both an I-75 and an I-95 camera.
const marion = floridaCameraToSource(feature());
const broward = floridaCameraToSource(
feature({
DESCRIPT: 'I-95 SB before E Cypress Rd',
COUNTY: 'Broward',
LATITUDE: 26.22006,
LONGITUDE: -80.13676,
IMAGE: `${IMAGE_HOST}chan-6351_h.jpg`,
}),
);
assert.equal(marion.id, 'fl-9431');
assert.equal(broward.id, 'fl-6351');
// Any resolution suffix keys the same way.
assert.equal(floridaCameraId(`${IMAGE_HOST}chan-77_l.jpg`), 'fl-77');
// A filename without a channel falls back to a stable path slug.
assert.equal(
floridaCameraId('https://snapshots.divas.cloud/US-1%20at%20SR-5.jpg'),
'fl-us-1-20at-20sr-5',
);
assert.equal(floridaCameraId('not a url'), null);
});
test('isLikelyFloridaCoordinate spans the state and rejects the rest', () => {
const inside = [
[24.5551, -81.78], // Key West
[30.4213, -87.2169], // Pensacola
[30.99, -84.0], // Georgia line
[25.7617, -80.1918], // Miami
];
const outside = [
[33.749, -84.388], // Atlanta
[25.0443, -77.3504], // Nassau
[0, 0],
[-81.78, 24.5551], // swapped
[NaN, -81],
];
for (const [lat, lon] of inside)
assert.equal(isLikelyFloridaCoordinate(lat, lon), true);
for (const [lat, lon] of outside)
assert.equal(isLikelyFloridaCoordinate(lat, lon), false);
});
test('the loader pages until exceededTransferLimit clears and collapses repeated rows', async (t) => {
t.mock.method(console, 'log', () => {});
uncapped(t);
const calls = [];
t.mock.method(globalThis, 'fetch', async (url, init) => {
assert.ok(String(url).startsWith(FLORIDA_CCTV_URL));
calls.push([offsetOf(url), init.redirect]);
if (offsetOf(url) === 0)
return Response.json(page(0, 2000, { exceededTransferLimit: true }));
// Last page (no exceededTransferLimit): two new cameras and a repeat.
return Response.json(page(1999, 3));
});
const cameras = await loadFloridaSourcesFromOpenData();
assert.deepEqual(calls, [
[0, 'manual'],
[2000, 'manual'],
]);
assert.equal(cameras.length, 2002);
assert.equal(new Set(cameras.map((camera) => camera.id)).size, 2002);
});
test('pages shorter than requested keep paging while the server says more remain', async (t) => {
t.mock.method(console, 'log', () => {});
uncapped(t);
const offsets = [];
t.mock.method(globalThis, 'fetch', async (url) => {
const params = new URL(String(url)).searchParams;
// Offset paging is only stable over a fixed order on the object id.
assert.equal(params.get('orderByFields'), 'OBJECTID_1');
const offset = offsetOf(url);
offsets.push(offset);
// A layer whose max record count was lowered to 1,000.
return Response.json(
page(offset, offset < 2000 ? 1000 : 500, {
exceededTransferLimit: offset < 2000,
}),
);
});
const cameras = await loadFloridaSourcesFromOpenData();
assert.deepEqual(offsets, [0, 1000, 2000]);
assert.equal(cameras.length, 2500);
});
test('a service that ignores resultOffset stops at the page ceiling with a warning', async (t) => {
t.mock.method(console, 'log', () => {});
const warnings = [];
t.mock.method(console, 'warn', (...args) => warnings.push(args.join(' ')));
uncapped(t);
let calls = 0;
t.mock.method(globalThis, 'fetch', async () => {
calls += 1;
return Response.json(page(0, 2000, { exceededTransferLimit: true }));
});
const cameras = await loadFloridaSourcesFromOpenData();
assert.equal(calls, FLORIDA_MAX_CATALOG_PAGES);
assert.equal(cameras.length, 2000);
assert.ok(warnings.some((line) => /page ceiling/.test(line)));
});
test('a redirect on any page drops the whole pack and releases it', async (t) => {
t.mock.method(console, 'warn', () => {});
const redirected = streamingResponse({
status: 302,
headers: { location: 'https://evil.example/cameras' },
});
t.mock.method(globalThis, 'fetch', async (url) =>
offsetOf(url) === 0
? Response.json(page(0, 2000, { exceededTransferLimit: true }))
: redirected.response,
);
assert.deepEqual(await loadFloridaSourcesFromOpenData(), []);
assert.equal(
redirected.state.cancelled,
true,
'the redirect body is cancelled',
);
});
test('a failed later page keeps the pages already read', async (t) => {
t.mock.method(console, 'log', () => {});
t.mock.method(console, 'warn', () => {});
uncapped(t);
const failed = streamingResponse({ status: 503 });
t.mock.method(globalThis, 'fetch', async (url) =>
offsetOf(url) === 0
? Response.json(page(0, 2000, { exceededTransferLimit: true }))
: failed.response,
);
assert.equal((await loadFloridaSourcesFromOpenData()).length, 2000);
assert.equal(failed.state.cancelled, true, 'the failed body is cancelled');
// A network error on a later page keeps them too.
t.mock.restoreAll();
t.mock.method(console, 'log', () => {});
t.mock.method(console, 'warn', () => {});
t.mock.method(globalThis, 'fetch', async (url) => {
if (offsetOf(url) === 0)
return Response.json(page(0, 2000, { exceededTransferLimit: true }));
throw new Error('network down');
});
assert.equal((await loadFloridaSourcesFromOpenData()).length, 2000);
});
test('an oversized catalog page is refused', async (t) => {
t.mock.method(console, 'log', () => {});
t.mock.method(console, 'warn', () => {});
t.mock.method(
globalThis,
'fetch',
async () =>
new Response(JSON.stringify(page(0, 1)), {
headers: {
'Content-Type': 'application/json',
'content-length': String(FLORIDA_MAX_CATALOG_BYTES + 1),
},
}),
);
assert.deepEqual(await loadFloridaSourcesFromOpenData(), []);
// So is a body that only turns out too long while streaming.
t.mock.restoreAll();
t.mock.method(console, 'log', () => {});
t.mock.method(console, 'warn', () => {});
t.mock.method(globalThis, 'fetch', async () => {
const oversized = 'x'.repeat(FLORIDA_MAX_CATALOG_BYTES + 1024);
return new Response(
new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode(oversized));
controller.close();
},
}),
{ headers: { 'Content-Type': 'application/json' } },
);
});
assert.deepEqual(await loadFloridaSourcesFromOpenData(), []);
});
/**
* Serve a two-camera FL511 page to the Florida endpoint and an empty payload
* to every other pack, so one catalog refresh exercises the registration
* without reaching the network. Returns the URLs that were requested.
*/
const runCatalogWithMockedUpstreams = async (t) => {
const requested = [];
t.mock.method(console, 'log', () => {});
t.mock.method(console, 'warn', () => {});
t.mock.method(globalThis, 'fetch', async (url) => {
const href = String(url);
requested.push(href);
if (href.startsWith(FLORIDA_CCTV_URL)) return Response.json(page(0, 2));
return Response.json([]);
});
// No curated catalogs or ground-height sidecar, so only live lanes are in play.
const sources = await createCctvCatalog({ sourceRoot: '/nonexistent' })();
return { requested, sources };
};
/** Put process.env back exactly as it was. */
const restoreEnv = (saved) => {
for (const key of Object.keys(process.env)) {
if (key in saved) continue;
delete process.env[key];
}
Object.assign(process.env, saved);
};
test('with CCTV_FLORIDA_ENABLED=1 the Florida lane is wired into the catalog', async (t) => {
const saved = { ...process.env };
try {
delete process.env.CCTV_SOURCES_FILE;
delete process.env.CCTV_SOURCES_JSON;
process.env.CCTV_FLORIDA_ENABLED = '1';
const { requested, sources } = await runCatalogWithMockedUpstreams(t);
assert.ok(
requested.some((href) => href.startsWith(FLORIDA_CCTV_URL)),
'the catalog refresh invokes the Florida loader',
);
assert.deepEqual(
sources
.filter((s) => s.cityId === 'florida')
.map((s) => s.id)
.sort(),
['fl-0', 'fl-1'],
'Florida cameras reach the served catalog through the registered lane',
);
} finally {
restoreEnv(saved);
}
});
test('the Florida lane stays off unless CCTV_FLORIDA_ENABLED=1', async (t) => {
const saved = { ...process.env };
try {
delete process.env.CCTV_SOURCES_FILE;
delete process.env.CCTV_SOURCES_JSON;
delete process.env.CCTV_FLORIDA_ENABLED;
const { requested, sources } = await runCatalogWithMockedUpstreams(t);
assert.equal(
requested.some((href) => href.startsWith(FLORIDA_CCTV_URL)),
false,
'the disabled lane never reaches its upstream',
);
assert.deepEqual(
sources.filter((s) => s.cityId === 'florida'),
[],
);
} finally {
restoreEnv(saved);
}
});
+252
View File
@@ -0,0 +1,252 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import {
fallbackHeadingFromId,
isLikelyCataloniaCoordinate,
toFiniteNumber,
} from '../../server/providers/cctv/normalize.js';
import {
loadCaltransSourcesFromOpenData,
loadTallinnSourcesFromCatalog,
loadTflSourcesFromOpenData,
parseCataloniaXml,
} from '../../server/providers/cctv/sources.js';
import { cctvProxy } from '../../server/providers/cctv.js';
import { createCatalog } from '../layers/cctv/catalog.js';
import { createModel } from '../layers/cctv/model.js';
/** Values `Number` reads as 0 (or 1) although they carry no number. */
const ABSENT = [null, undefined, '', ' ', false, true];
/** Set env vars for one test and restore them afterwards. */
function withEnv(t, env) {
for (const [name, value] of Object.entries(env)) {
const previous = process.env[name];
t.after(() => {
if (previous === undefined) delete process.env[name];
else process.env[name] = previous;
});
if (value === undefined) delete process.env[name];
else process.env[name] = value;
}
}
/** Silence the loaders' progress and failure logging. */
function quiet(t) {
t.mock.method(console, 'log', () => {});
t.mock.method(console, 'warn', () => {});
}
/** The production browser model; only the catalog's pose pass is stubbed. */
function browserModel() {
const parts = {};
parts.model = createModel({
state: {},
services: { focus: {} },
parts,
source: {},
});
return parts.model;
}
/** The browser catalog over the production model. */
function browserCatalog(model, cityPois = {}) {
return createCatalog({
state: {},
services: { locations: { CITY_POIS: cityPois } },
parts: { model: { ...model, ensureCameraPose: () => {} } },
source: {},
});
}
/** GET /api/cctv/sources through the real proxy middleware. */
async function requestSources(plugin) {
let handler;
plugin.configureServer({
middlewares: {
use(_route, fn) {
handler = fn;
},
},
});
const res = {
writeHead(status, headers) {
Object.assign(this, { status, headers });
},
end(body) {
this.body = body;
},
};
await handler({ url: '/sources', method: 'GET' }, res);
assert.equal(res.status, 200);
return JSON.parse(res.body);
}
test('absent, blank and boolean values take the fallback on both sides of the source list', () => {
const { safeNumber } = browserModel();
for (const value of ABSENT) {
assert.ok(Number.isNaN(toFiniteNumber(value)), `server ${String(value)}`);
assert.equal(toFiniteNumber(value, 15), 15, `server ${String(value)}`);
assert.ok(Number.isNaN(safeNumber(value)), `browser ${String(value)}`);
assert.equal(safeNumber(value, 74), 74, `browser ${String(value)}`);
}
for (const [value, expected] of [
[0, 0],
['0', 0],
[-17, -17],
[' 12.5 ', 12.5],
]) {
assert.equal(toFiniteNumber(value, 99), expected);
assert.equal(safeNumber(value, 99), expected);
}
});
test('a source-pack camera that omits or nulls its pose gets the browser defaults, not the clamp floors', async (t) => {
quiet(t);
const pose = {
headingDeg: null,
pitchDeg: null,
fovDeg: null,
rangeM: null,
mountHeightM: null,
groundElevationM: null,
};
const at = { cityId: 'austin', lat: 30.2672, lon: -97.7431 };
withEnv(t, {
CCTV_SOURCES_FILE: path.join(tmpdir(), 'gev-no-such-pack.json'),
CCTV_SOURCES_JSON: JSON.stringify([
{ id: 'pack-pose-omitted', ...at, feedType: 'image' },
{ id: 'pack-pose-null', ...at, ...pose, feedType: 'image' },
{ id: 'pack-coords-null', lat: null, lon: null, feedType: 'image' },
{ id: 'pack-coords-blank', lat: '', lon: ' ', feedType: 'image' },
]),
});
const body = await requestSources(cctvProxy({ sourceRoot: tmpdir() }));
const model = browserModel();
const cameras = browserCatalog(model, {
austin: { name: 'Austin', groundElevation: 150 },
}).buildCatalogFromSources(body.sources);
const byId = new Map(cameras.map((camera) => [camera.id, camera]));
for (const id of ['pack-pose-omitted', 'pack-pose-null']) {
const camera = byId.get(id);
assert.ok(camera, id);
assert.equal(camera.headingDeg, model.headingFromId(id), id);
assert.equal(camera.pitchDeg, -17, id);
assert.equal(camera.fovDeg, 74, id);
assert.equal(camera.rangeM, 700, id);
assert.equal(camera.mountHeightM, 24, id);
assert.equal(camera.groundElevationM, 150, id);
}
// No position is not a camera in the Gulf of Guinea.
assert.equal(byId.has('pack-coords-null'), false);
assert.equal(byId.has('pack-coords-blank'), false);
});
test('Tallinn rows with a null heading or ground keep the low-confidence prior', (t) => {
quiet(t);
const root = mkdtempSync(path.join(tmpdir(), 'gev-tallinn-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
const file = path.join(root, 'tallinn.json');
writeFileSync(
file,
JSON.stringify([
{
id: 'tln-null-heading',
lat: 59.437,
lon: 24.753,
headingDeg: null,
groundElevationM: null,
url: 'https://ristmikud.tallinn.ee/last/cam001.jpg',
},
]),
);
withEnv(t, { CCTV_TALLINN_SOURCES_FILE: file });
const [camera] = loadTallinnSourcesFromCatalog({ sourceRoot: root });
assert.equal(camera.headingConfidence, 'low');
assert.equal(camera.headingDeg, fallbackHeadingFromId('tln-null-heading'));
assert.equal(camera.groundElevationM, 15);
});
test('TfL JamCams with null coordinates are dropped instead of placed at 0,0', async (t) => {
quiet(t);
const jamCam = (id, lat, lon) => ({
id: `JamCams_${id}`,
commonName: `JamCam ${id}`,
lat,
lon,
additionalProperties: [
{ key: 'available', value: 'true' },
{
key: 'imageUrl',
value: `https://s3-eu-west-1.amazonaws.com/jamcams.tfl.gov.uk/${id}.jpg`,
},
],
});
t.mock.method(globalThis, 'fetch', async () =>
Response.json([
jamCam('00001.00001', 51.5072, -0.1276),
jamCam('00001.00002', null, null),
jamCam('00001.00003', 51.5072, ''),
]),
);
const cameras = await loadTflSourcesFromOpenData();
assert.deepEqual(
cameras.map((camera) => camera.id),
['tfl-00001.00001'],
);
});
test('a Catalonia feature with a blank longitude is not read as the zero meridian', () => {
const feature = (coordinates) => `<gml:featureMember>
<cite:cameres fid="cameres.1">
<gml:coordinates>${coordinates}</gml:coordinates>
<cite:link>https://example.org/cam.jpg</cite:link>
</cite:cameres>
</gml:featureMember>`;
const [blank, valid] = parseCataloniaXml(
feature(',41.5') + feature('2.17,41.39'),
);
// 0 °E at 41.5 °N is inside the Catalonia box, so a zero would pass it.
assert.ok(Number.isNaN(blank.lon));
assert.equal(isLikelyCataloniaCoordinate(blank.lat, blank.lon), false);
assert.equal(isLikelyCataloniaCoordinate(valid.lat, valid.lon), true);
});
test('a Caltrans camera with a blank elevation keeps the 150 m ground prior', async (t) => {
quiet(t);
withEnv(t, { CCTV_CALTRANS_DISTRICTS: '4' });
t.mock.method(globalThis, 'fetch', async () =>
Response.json({
data: [
{
cctv: {
inService: 'true',
location: {
latitude: '37.8044',
longitude: '-122.2712',
elevation: '',
locationName: 'TV101 -- I-880 : Test Ave',
},
imageData: {
static: {
currentImageURL:
'https://cwwp2.dot.ca.gov/data/d4/cctv/image/tv101/tv101.jpg',
},
},
},
},
],
}),
);
const [camera] = await loadCaltransSourcesFromOpenData();
assert.ok(camera);
assert.equal(camera.groundElevationM, 150);
});
+13 -2
View File
@@ -263,6 +263,14 @@ export const DATA_CREDITS = [
'Traffic cameras (Calgary): contains information licensed under the ' +
'<a href="https://data.calgary.ca/stories/s/Open-Calgary-Terms-of-Use/u45n-7awa" target="_blank" rel="noopener">Open Government Licence – City of Calgary</a>',
},
{
key: 'florida-cctv',
html:
'Traffic cameras (Florida): FL511, Florida Department of Transportation. ' +
'Individual, personal use only under the ' +
'<a href="https://fl511.com/privacy#disclaimer" target="_blank" rel="noopener">FL511 terms</a>; ' +
"any other use needs FDOT's express written consent",
},
{
key: 'catalonia-cctv',
html:
@@ -305,7 +313,10 @@ export const DATA_CREDITS = [
html:
'Bikeshare availability: GBFS operator feeds ' +
'(e.g. Austin BCycle; Bike Share Toronto, operated by the Toronto Parking Authority; Bike Itaú and Tembici Bogotá, operated by Tembici; ' +
'Bicing, Barcelona City Council (B:SM); AMBici, Àrea Metropolitana de Barcelona, operated by nextbike)',
'Bicing, Barcelona City Council (B:SM); AMBici, Àrea Metropolitana de Barcelona, operated by nextbike; ' +
'BiciMAD, <a href="https://www.emtmadrid.es" target="_blank" rel="noopener">Powered by EMT de Madrid</a>; ' +
'Bizi Zaragoza; BIKI, AUVASA (Valladolid); BiciCoruña; Dbizi (Donostia-San Sebastián); ' +
'Bilbao Bizi, Bilbao City Council; BicinRivas, Rivas-Vaciamadrid City Council)',
},
{
key: 'urbansharing-gbfs',
@@ -324,7 +335,7 @@ export const DATA_CREDITS = [
{
key: 'jcdecaux-gbfs',
html:
'City bikes (Lillestrøm): Bysykkel Lillestrøm, JCDecaux — ' +
'City bikes (Lillestrøm, Valencia, Seville): Bysykkel Lillestrøm, Valenbisi, Sevici, JCDecaux — ' +
'<a href="https://developer.jcdecaux.com/files/Open-Licence-fr.pdf" target="_blank" rel="noopener">Licence Ouverte</a>',
},
{
+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');
});
+103 -4
View File
@@ -27,6 +27,13 @@ import {
createApplicationDirectionsLayer,
} from './directions.js';
import { GROUND_FLOOR_LIFT_M } from './groundFloor.js';
import { instructionFor } from './routeSteps.js';
import { setLocale } from '../i18n/index.js';
import {
directionsInstruction,
routeDistanceText,
routeDurationText,
} from '../layers/directions/index.js';
import {
claimPointer,
isPointerFree,
@@ -222,10 +229,10 @@ test('stats guide the user, show progress, and summarize a route honestly', () =
lastUpdate: 5,
});
assert.equal(ready.count, 3);
assert.equal(ready.coverage, '21 km · 25 min · Drive');
assert.equal(ready.coverage, '21\u00a0km · 25\u00a0min · Drive');
assert.equal(
ready.loadingLabel,
'21 km · 25 min · Drive',
'21\u00a0km · 25\u00a0min · Drive',
'the summary is the row detail line',
);
assert.equal(ready.loading, undefined);
@@ -249,7 +256,7 @@ test('step cards name the maneuver, its leg, and what comes next', () => {
const copy = directionsStepCopy(route.steps, 1);
assert.equal(copy.title, 'Turn right onto Kansakoulukatu');
assert.deepEqual(copy.details, [
'Step 2 of 3 · then 240 m · 39 s',
'Step 2 of 3 · then 240\u00a0m · 39\u00a0s',
'Then: Arrive at your destination',
]);
const last = directionsStepCopy(route.steps, 2);
@@ -354,7 +361,7 @@ test('the turn-by-turn list is ordered, labelled, and highlights the flown step'
);
assert.deepEqual(
list.items.map((item) => item.lead),
['8 m', '240 m', '0 m'],
['8\u00a0m', '240\u00a0m', '0\u00a0m'],
);
assert.deepEqual(
list.items.map((item) => item.params),
@@ -733,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);
@@ -773,3 +785,90 @@ test('the refusal reaches the app toast, and a successful arming says nothing',
assert.equal(toasts.length, 1, 'a successful arming is silent');
assert.equal(pointerOwner(), DIRECTIONS_POINTER_OWNER);
});
test('maneuvers are phrased on the page, in English word for word as the proxy does', () => {
const types = [
'depart',
'arrive',
'turn',
'new name',
'continue',
'end of road',
'fork',
'merge',
'on ramp',
'off ramp',
'roundabout',
'rotary',
'roundabout turn',
'exit roundabout',
'exit rotary',
'use lane',
'notification',
'something new',
];
const modifiers = [
null,
'left',
'right',
'slight left',
'slight right',
'sharp left',
'sharp right',
'straight',
'uturn',
];
const roads = [
{ name: '', ref: null },
{ name: 'Congress Avenue', ref: null },
{ name: 'Mannerheimintie', ref: 'E12' },
{ name: '', ref: 'I 35' },
];
for (const type of types)
for (const modifier of modifiers)
for (const road of roads)
for (const exit of [null, 1, 2, 3, 4, 11, 12, 13, 21, 22]) {
const step = { type, modifier, exit, ...road };
assert.equal(
directionsInstruction(step),
instructionFor(step),
JSON.stringify(step),
);
}
// An older payload without a maneuver type keeps the proxy's sentence.
assert.equal(
directionsInstruction({ instruction: 'Head out on Kaivokatu' }),
'Head out on Kaivokatu',
);
});
test('route numbers follow the page language and never break from their unit', (t) => {
t.after(() => setLocale('en'));
assert.equal(routeDistanceText(8.4), '8\u00a0m');
assert.equal(routeDistanceText(236), '240\u00a0m');
assert.equal(routeDistanceText(1500), '1.5\u00a0km');
assert.equal(routeDistanceText(2_345_000), '2345\u00a0km');
assert.equal(routeDurationText(39), '39\u00a0s');
assert.equal(routeDurationText(3900), '1\u00a0h\u00a05\u00a0min');
assert.equal(routeDurationText(7200), '2\u00a0h');
assert.equal(routeDistanceText(-1), '');
assert.equal(routeDurationText(Number.NaN), '');
setLocale('ru');
assert.equal(routeDistanceText(1500), '1,5\u00a0км');
assert.equal(routeDurationText(240), '4\u00a0мин');
assert.equal(
directionsInstruction({
type: 'turn',
modifier: 'right',
name: 'East 7th Street',
}),
'Поверните направо на East 7th Street',
);
assert.equal(
directionsInstruction({ type: 'roundabout', exit: 2, name: '' }),
'На кольце сверните на 2-й съезд',
);
const list = directionsStepList({ route });
assert.equal(list.items[1].lead, '240\u00a0м');
});
+48 -1
View File
@@ -726,7 +726,15 @@ const LABEL_ICAO = 'ae1fa4';
const LABEL_POSITION = Cesium.Cartesian3.fromDegrees(-97.71, 30.21, 10_668);
/** Seed one civil contact through the production refresh seam. */
function seedLabelContact({ callsign, registration, tracked = false }) {
function seedLabelContact({
callsign,
registration,
tracked = false,
// Per-test metadata overrides, spread last so a test can set a field to
// `undefined` to model "the feed never sent it" — a default parameter
// could not express that, it would just re-supply the default.
meta: metaOverrides = {},
}) {
_setTrackedFlightRefreshStateForTest({
icao24: LABEL_ICAO,
entity: null,
@@ -752,6 +760,7 @@ function seedLabelContact({ callsign, registration, tracked = false }) {
turnRateDps: 0,
rawLat: 30.21,
rawLon: -97.71,
...metaOverrides,
},
});
}
@@ -825,6 +834,44 @@ test('civil label chain: the cockpit descriptor exposes a trimmed registration',
assert.equal(flightsLayer.getTrackedInfo()?.registration, null);
});
test('the tracked descriptor publishes the reported vertical rate', () => {
// `mapAnalystRecord` has always published `verticalRateMps`; the tracked
// seam did not, so a consumer that needs climb/descent (a flight-phase
// classifier, the cockpit) had to reach past getTrackedInfo() into the
// raw record. Both seams now answer with the same field name and unit.
seedLabelContact({
callsign: 'SWA696',
registration: 'N123AB',
tracked: true,
meta: { verticalRate: -6.4 },
});
assert.equal(flightsLayer.getTrackedInfo()?.verticalRateMps, -6.4);
assert.equal(
mapAnalystRecord(LABEL_ICAO, { verticalRate: -6.4 }).verticalRateMps,
-6.4,
'the analyst seam and the tracked seam must agree',
);
});
test('an unreported vertical rate stays null, never a level-flight zero', () => {
// Absent is not level. `stickyNumber` seeds this field with null, so a
// contact that has never transmitted a rate must not read as 0 m/s — a
// phase classifier would call that cruise while the aircraft climbs.
for (const missing of [undefined, null, Number.NaN]) {
seedLabelContact({
callsign: 'SWA696',
registration: 'N123AB',
tracked: true,
meta: { verticalRate: missing },
});
assert.equal(
flightsLayer.getTrackedInfo()?.verticalRateMps,
null,
`vertical rate ${String(missing)} must publish as null`,
);
}
});
test('civil label chain: identity stays icao24 while the label moves', () => {
for (const fixture of [
{ callsign: 'SWA696', registration: 'N123AB' },
+94 -36
View File
@@ -2,6 +2,7 @@
import * as Cesium from 'cesium';
import { createImportImageBudget } from './geoImportImages.js';
import { createImportedPins } from './geoImportPins.js';
import { msg } from '../i18n/marker.js';
/** Largest file accepted, in bytes. */
export const MAX_IMPORT_BYTES = 50 * 1024 * 1024;
@@ -61,11 +62,17 @@ const KML_REMOVED_ELEMENTS = new Set([
'httpQuery',
]);
/** An error whose message is meant for the person who chose the file. */
/**
* An error whose message is meant for the person who chose the file. The
* message stays English; `template` and `values`, when given, let the UI show
* it in the active language.
*/
export class GeoImportError extends Error {
constructor(message) {
constructor(message, template = message, values = {}) {
super(message);
this.name = 'GeoImportError';
this.template = template;
this.values = values;
}
}
@@ -124,12 +131,14 @@ export function parseGeoJsonText(text) {
try {
value = JSON.parse(String(text).replace(/^/, ''));
} catch {
throw new GeoImportError('That file is not valid JSON.');
throw new GeoImportError(msg('That file is not valid JSON.'));
}
if (!value || typeof value !== 'object' || !GEOJSON_TYPES.has(value.type))
throw new GeoImportError('That JSON file is not GeoJSON.');
throw new GeoImportError(msg('That JSON file is not GeoJSON.'));
if (value.type === 'FeatureCollection' && !Array.isArray(value.features))
throw new GeoImportError('That FeatureCollection has no features list.');
throw new GeoImportError(
msg('That FeatureCollection has no features list.'),
);
return value;
}
@@ -164,7 +173,7 @@ export function countGeoJsonGeometry(
for (let i = 0; i < list.length; i++) {
if (list[i]?.type !== 'Feature')
throw new GeoImportError(
'Every GeoJSON feature must have type Feature.',
msg('Every GeoJSON feature must have type Feature.'),
);
stack.push(list[i]);
}
@@ -183,11 +192,11 @@ export function countGeoJsonGeometry(
if (node.type === 'GeometryCollection') {
const list = node.geometries;
if (!Array.isArray(list))
throw new GeoImportError('That GeoJSON has invalid geometries.');
throw new GeoImportError(msg('That GeoJSON has invalid geometries.'));
for (let i = 0; i < list.length; i++) {
if (!list[i] || ['Feature', 'FeatureCollection'].includes(list[i].type))
throw new GeoImportError(
'That GeoJSON contains an unsupported geometry.',
msg('That GeoJSON contains an unsupported geometry.'),
);
stack.push(list[i]);
}
@@ -216,7 +225,9 @@ export function countGeoJsonGeometry(
while (coords.length) {
const [c, remaining] = coords.pop();
if (!Array.isArray(c))
throw new GeoImportError('That GeoJSON has invalid coordinates.');
throw new GeoImportError(
msg('That GeoJSON has invalid coordinates.'),
);
if (remaining === 0) {
if (
c.length < 2 ||
@@ -226,7 +237,9 @@ export function countGeoJsonGeometry(
Math.abs(c[1]) > 90 ||
(c.length === 3 && Math.abs(c[2]) > 1_000_000)
)
throw new GeoImportError('That GeoJSON has invalid coordinates.');
throw new GeoImportError(
msg('That GeoJSON has invalid coordinates.'),
);
positions += 1;
if (positions > maxPositions) return result('positions');
} else
@@ -234,7 +247,7 @@ export function countGeoJsonGeometry(
}
} else
throw new GeoImportError(
'That GeoJSON contains an unsupported geometry.',
msg('That GeoJSON contains an unsupported geometry.'),
);
}
return result();
@@ -245,17 +258,36 @@ function overLimitError({ over, features }) {
if (over === 'entities')
return new GeoImportError(
`That file creates more than ${MAX_IMPORT_ENTITIES.toLocaleString()} map objects (the limit).`,
msg('That file creates more than {limit} map objects (the limit).'),
{ limit: MAX_IMPORT_ENTITIES.toLocaleString() },
);
if (over === 'links')
return new GeoImportError(
`That file has more than ${MAX_KML_LINKS.toLocaleString()} style definitions and image links after cleanup (the limit).`,
msg(
'That file has more than {limit} style definitions and image links after cleanup (the limit).',
),
{ limit: MAX_KML_LINKS.toLocaleString() },
);
if (over === 'features')
return new GeoImportError(
`That file has more than ${MAX_IMPORT_ENTITIES.toLocaleString()} features (the limit).`,
msg('That file has more than {limit} features (the limit).'),
{ limit: MAX_IMPORT_ENTITIES.toLocaleString() },
);
const limit = MAX_IMPORT_POSITIONS.toLocaleString();
if (features)
return new GeoImportError(
`That file has more than ${limit} points (the limit) across ${features.toLocaleString()} features.`,
msg(
'That file has more than {limit} points (the limit) across {features} features.',
),
{ limit, features: features.toLocaleString() },
);
return new GeoImportError(
`That file has more than ${MAX_IMPORT_POSITIONS.toLocaleString()} points (the limit)${features ? ` across ${features.toLocaleString()} features` : ''}.`,
`That file has more than ${limit} points (the limit).`,
msg('That file has more than {limit} points (the limit).'),
{ limit },
);
}
@@ -302,14 +334,16 @@ async function inflateRaw(bytes, maxBytes) {
total += value.length;
if (total > maxBytes)
throw new GeoImportError(
'That KMZ unpacks to more than the import limit.',
msg('That KMZ unpacks to more than the import limit.'),
);
chunks.push(value);
}
} catch (error) {
await reader.cancel().catch(() => {});
if (error instanceof GeoImportError) throw error;
throw new GeoImportError('That KMZ is damaged and could not be unpacked.');
throw new GeoImportError(
msg('That KMZ is damaged and could not be unpacked.'),
);
}
const out = new Uint8Array(total);
let at = 0;
@@ -332,7 +366,7 @@ export async function readZipEntries(
{ maxInflatedBytes = MAX_KMZ_INFLATED_BYTES } = {},
) {
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
const fail = (message = 'That KMZ is not a readable zip archive.') => {
const fail = (message = msg('That KMZ is not a readable zip archive.')) => {
throw new GeoImportError(message);
};
// End of central directory: 22 bytes plus a comment of up to 64 KiB.
@@ -352,7 +386,7 @@ export async function readZipEntries(
const dirSize = view.getUint32(eocd + 12, true);
let at = view.getUint32(eocd + 16, true);
if (count === 0xffff || at === 0xffffffff || at + dirSize > eocd)
fail('That KMZ uses a zip format this import does not read.');
fail(msg('That KMZ uses a zip format this import does not read.'));
const entries = new Map();
const names = new TextDecoder();
let inflated = 0;
@@ -370,9 +404,9 @@ export async function readZipEntries(
const name = names.decode(bytes.subarray(at + 46, at + 46 + nameLength));
at += 46 + nameLength + extraLength + commentLength;
if (name.endsWith('/')) continue;
if (flags & 0x1) fail('That KMZ is encrypted.');
if (flags & 0x1) fail(msg('That KMZ is encrypted.'));
if (compressed === 0xffffffff || size === 0xffffffff)
fail('That KMZ uses a zip format this import does not read.');
fail(msg('That KMZ uses a zip format this import does not read.'));
if (local + 30 > bytes.length || view.getUint32(local, true) !== 0x04034b50)
fail();
const start =
@@ -386,10 +420,13 @@ export async function readZipEntries(
if (method === 0) contents = data;
else if (method === 8)
contents = await inflateRaw(data, maxInflatedBytes - inflated);
else fail('That KMZ uses a compression method this import does not read.');
else
fail(
msg('That KMZ uses a compression method this import does not read.'),
);
inflated += contents.length;
if (inflated > maxInflatedBytes)
fail('That KMZ unpacks to more than the import limit.');
fail(msg('That KMZ unpacks to more than the import limit.'));
entries.set(name, contents);
}
return entries;
@@ -765,7 +802,7 @@ export function countKmlGeometry(
!Number.isFinite(value) ||
Math.abs(value) > limit
)
throw new GeoImportError('That KML has invalid geometry bounds.');
throw new GeoImportError(msg('That KML has invalid geometry bounds.'));
}
// Cesium creates entities for folders, a parent for every placemark, and
// a child for each geometry in a MultiGeometry, including nested containers.
@@ -822,7 +859,7 @@ function checkKmlPosition(tuple) {
Math.abs(numbers[1]) > 90 ||
(numbers.length === 3 && Math.abs(numbers[2]) > 1_000_000)
)
throw new GeoImportError('That KML has invalid coordinates.');
throw new GeoImportError(msg('That KML has invalid coordinates.'));
}
/**
@@ -843,7 +880,7 @@ export function gpxToGeoJson(
) {
const root = doc.documentElement;
if (root?.localName !== 'gpx')
throw new GeoImportError('That GPX file has no <gpx> document.');
throw new GeoImportError(msg('That GPX file has no <gpx> document.'));
const features = [];
let positions = 0;
const point = (el) => {
@@ -1028,7 +1065,11 @@ function parseXml(text, what) {
'application/xml',
);
if (doc.getElementsByTagName('parsererror').length)
throw new GeoImportError(`That ${what} file is not valid XML.`);
throw new GeoImportError(
`That ${what} file is not valid XML.`,
msg('That {format} file is not valid XML.'),
{ format: what },
);
return doc;
}
@@ -1066,7 +1107,9 @@ function checkLocalImages(doc, entries = new Map(), main = '') {
}
} catch {
throw new GeoImportError(
'That file has an unreadable image or exceeds the 16-megapixel image limit.',
msg(
'That file has an unreadable image or exceeds the 16-megapixel image limit.',
),
);
}
}
@@ -1086,7 +1129,7 @@ async function loadKml(doc, viewer, signal) {
}),
new Promise((_, reject) => {
const failed = () => {
reject(new GeoImportError('That KML could not be read.'));
reject(new GeoImportError(msg('That KML could not be read.')));
};
globalThis.addEventListener?.('error', failed);
removeError = () => globalThis.removeEventListener?.('error', failed);
@@ -1116,7 +1159,9 @@ async function settleGroundPins(dataSource, viewer, signal) {
if (!position) continue;
const cartographic = Cesium.Cartographic.fromCartesian(position);
if (!cartographic)
throw new GeoImportError('That file has invalid point coordinates.');
throw new GeoImportError(
msg('That file has invalid point coordinates.'),
);
pins.push({
entity,
original: position,
@@ -1192,7 +1237,9 @@ async function settleGroundPins(dataSource, viewer, signal) {
const c = points[i] && Cesium.Cartographic.fromCartesian(points[i]);
if (!c)
throw new GeoImportError(
'Ground heights are not available for that file. Try again when the map has loaded.',
msg(
'Ground heights are not available for that file. Try again when the map has loaded.',
),
);
item.groundHeight = c.height;
c.height += item.offset;
@@ -1216,7 +1263,8 @@ async function boundedImport(promise, signal) {
abort = () => reject(signal.reason);
signal?.addEventListener('abort', abort, { once: true });
timer = setTimeout(
() => reject(new GeoImportError('That file took too long to read.')),
() =>
reject(new GeoImportError(msg('That file took too long to read.'))),
60_000,
);
}),
@@ -1244,16 +1292,18 @@ export async function loadGeoFile(
: AbortSignal.timeout(60_000);
signal?.throwIfAborted();
if (!file || !Number.isFinite(file.size))
throw new GeoImportError('Nothing to import.');
throw new GeoImportError(msg('Nothing to import.'));
if (file.size > MAX_IMPORT_BYTES)
throw new GeoImportError(
`That file is larger than ${MAX_IMPORT_BYTES / 1024 / 1024} MiB.`,
msg('That file is larger than {size} MiB.'),
{ size: MAX_IMPORT_BYTES / 1024 / 1024 },
);
let bytes = new Uint8Array(await boundedImport(file.arrayBuffer(), signal));
const format = detectGeoFileFormat(file.name, bytes.subarray(0, 1024));
if (!format)
throw new GeoImportError(
'Only GeoJSON, KML, KMZ and GPX files can be imported.',
msg('Only GeoJSON, KML, KMZ and GPX files can be imported.'),
);
const stroke = Cesium.Color.fromCssColorString(color);
const urls = [];
@@ -1276,7 +1326,7 @@ export async function loadGeoFile(
const size = countGeoJsonGeometry(geojson);
if (size.over) throw overLimitError(size);
if (!size.positions)
throw new GeoImportError('That file has nothing to draw.');
throw new GeoImportError(msg('That file has nothing to draw.'));
prepareGeoJson(geojson);
bytes = null;
dataSource = await boundedImport(
@@ -1299,7 +1349,8 @@ export async function loadGeoFile(
if (format === 'kmz') {
entries = await readZipEntries(bytes);
main = kmzMainDocument(entries);
if (!main) throw new GeoImportError('That KMZ holds no KML document.');
if (!main)
throw new GeoImportError(msg('That KMZ holds no KML document.'));
text = decodeXml(entries.get(main));
resolveLocal = kmzImageResolver(entries, main, (url) => urls.push(url));
} else {
@@ -1325,6 +1376,8 @@ export async function loadGeoFile(
if (error instanceof GeoImportError) throw error;
throw new GeoImportError(
`That ${format.toUpperCase()} file could not be read.`,
msg('That {format} file could not be read.'),
{ format: format.toUpperCase() },
);
}
const entityCount = dataSource.entities.values.length;
@@ -1332,6 +1385,11 @@ export async function loadGeoFile(
revoke();
throw new GeoImportError(
`That file has ${entityCount.toLocaleString()} map objects; the limit is ${MAX_IMPORT_ENTITIES.toLocaleString()}.`,
msg('That file has {count} map objects; the limit is {limit}.'),
{
count: entityCount.toLocaleString(),
limit: MAX_IMPORT_ENTITIES.toLocaleString(),
},
);
}
const now = Cesium.JulianDate.now();
@@ -1346,7 +1404,7 @@ export async function loadGeoFile(
);
if (!drawable) {
revoke();
throw new GeoImportError('That file has nothing to draw.');
throw new GeoImportError(msg('That file has nothing to draw.'));
}
scrubImportedEntities(dataSource);
try {
@@ -1356,7 +1414,7 @@ export async function loadGeoFile(
throw error instanceof GeoImportError
? error
: new GeoImportError(
'Ground heights could not be read. Try that file again.',
msg('Ground heights could not be read. Try that file again.'),
);
}
// Keep the clock invariant explicit even though unused KML dates have
@@ -1366,7 +1424,7 @@ export async function loadGeoFile(
pins = createImportedPins(dataSource, viewer);
} catch {
revoke();
throw new GeoImportError('That file has invalid point graphics.');
throw new GeoImportError(msg('That file has invalid point graphics.'));
}
// The display name travels beside the data source (the file name, not
// anything the document says about itself).
+26
View File
@@ -257,6 +257,32 @@ test('stickyFloorCell does not stick to a far-away stale cell', () => {
assert.deepEqual(stickyFloorCell(30.2004, -97.6604, prev), { lat: 30.2, lon: -97.66 });
});
// --- Antimeridian ------------------------------------------------------------
// Raw longitude arithmetic walks a segment across 180° the long way round and
// reads a contact on the seam as half a world from its previous cell.
test('corridorFloorCells walks across the antimeridian, not the long way round', () => {
for (const [from, to, across, behind] of [
[179.999, -179.998, -179.999, 179.998],
[-179.999, 179.998, 179.999, -179.998],
]) {
const cells = corridorFloorCells([{ lat: -16.69, lon: from }, { lat: -16.69, lon: to }]);
const lons = cells.map((c) => c.lon);
assert.equal(lons[0], from);
assert.equal(lons.at(-1), to);
assert.ok(lons.includes(across), `crosses through ${across}: ${lons}`);
assert.ok(!lons.includes(behind), `never walks back through ${behind}: ${lons}`);
assert.ok(lons.every((lon) => Math.abs(lon) <= 180), `normalised: ${lons}`);
}
});
test('stickyFloorCell holds across the antimeridian', () => {
for (const [prevLon, lon] of [[180, -179.9998], [-180, 179.9998]]) {
const prev = { lat: -16.69, lon: prevLon };
assert.deepEqual(stickyFloorCell(-16.69, lon, prev), prev);
}
});
// --- F2: corridor budget fairness ------------------------------------------
// The first cut spent the budget in Map insertion order and decremented it
// BEFORE deduping, so parked contacts (whose corridor is the fix cell the poll
+51 -21
View File
@@ -1,25 +1,43 @@
/** 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';
import { msg } from '../i18n/marker.js';
// layers/installations/controls.js translates describeInstallationFeedback().
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 +45,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}`;
}
+28
View File
@@ -0,0 +1,28 @@
import { msg } from '../i18n/marker.js';
/**
* English names for installations without a mapped name. Data keeps them
* English (server, AI tools, voice context); classLabel.js and
* militaryAwarenessEngine.js translate them.
*/
/** 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,
]);
+6 -3
View File
@@ -1,3 +1,6 @@
// Restore labels are marked with msg(); ui/shareRestoration.js translates them.
// Local, not i18n/marker.js: the token-ledger tests run this file in isolation.
const msg = (text) => text;
import { normalizeIdSwitches, patchRequestedIdSwitches } from './idSwitches.js';
import reservationRows from './layerStateTokenReservations.json' with { type: 'json' };
@@ -473,19 +476,19 @@ export const SHARE_TRACKING_RESTORE_POLICIES = Object.freeze({
optionOwner: 'flights',
optionKey: 'selectedFlightsTrackingId',
expiryWindowMs: 90_000,
label: 'flight',
label: msg('flight'),
}),
military: Object.freeze({
optionOwner: 'flights',
optionKey: 'selectedMilitaryTrackingId',
expiryWindowMs: 45_000,
label: 'military flight',
label: msg('military flight'),
}),
satellites: Object.freeze({
optionOwner: 'satellites',
optionKey: 'selectedSatTrackingId',
expiryWindowMs: 300_000,
label: 'satellite',
label: msg('satellite'),
}),
});
+9
View File
@@ -128,3 +128,12 @@ test('sampler: outside the google-3d regime nothing is sampled at all', () => {
setMeshFloorPreferred(true);
assert.equal(cachedMeshFloor(cell.lat, cell.lon), null);
});
test('sampler: a cell just across the antimeridian is still near the viewer', () => {
reset();
let calls = 0;
const scene = fakeScene(() => { calls += 1; return undefined; });
sampleMeshFloorCells(scene, [{ lat: -16.8, lon: -179.999 }], { viewerLat: -16.8, viewerLon: 179.999 });
sampleMeshFloorCells(scene, [{ lat: -16.81, lon: 179.999 }], { viewerLat: -16.81, viewerLon: -179.999 });
assert.equal(calls, 2, 'about 200 m apart, so both cells are probed');
});
File diff suppressed because it is too large. Load diff
+19 -6
View File
@@ -1,5 +1,10 @@
/** 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;
export const AWARENESS_RELATIONSHIP = Object.freeze({
@@ -47,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 [
@@ -63,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 '—';
}
@@ -82,14 +95,14 @@ export function summarizeAwarenessCohort(
relationship: AWARENESS_RELATIONSHIP.UNKNOWN,
count: null,
nearest: [],
reason: 'feed unavailable',
reason: t('feed unavailable'),
};
if (stale)
return {
relationship: AWARENESS_RELATIONSHIP.UNKNOWN,
count: null,
nearest: [],
reason: 'feed stale',
reason: t('feed stale'),
};
const normalized = (Array.isArray(items) ? items : [])
.filter(
@@ -106,8 +119,8 @@ export function summarizeAwarenessCohort(
count: normalized.length,
nearest: normalized.slice(0, limit),
reason: normalized.length
? 'observed or mapped nearby context'
: 'no observed or mapped objects in current feeds',
? t('observed or mapped nearby context')
: t('no observed or mapped objects in current feeds'),
};
}
+45
View File
@@ -834,3 +834,48 @@ test('two contacts matching at the same strength resolve deterministically', ()
'and the same query resolves the same way every time',
);
});
test('the military tracked descriptor publishes the reported vertical rate', () => {
// Shape parity with the civil layer's getTrackedInfo(). The two layers store
// the rate under different record keys (`verticalRateMps` here,
// `verticalRate` there), so `resolveTrackedAircraftInfo` only hands back one
// shape if BOTH descriptors map it — a consumer that reads the field off the
// civil seam and finds it missing on the military one would silently treat
// every military contact as level flight.
const seedRate = (verticalRateMps) =>
_setTrackedMilitaryRefreshStateForTest({
icao24: 'ae7f01',
entity: { gevLabelModel: { title: 'TEST', details: [] } },
billboard: {
position: Cesium.Cartesian3.fromDegrees(-97.0, 31.0, 8_000),
color: Cesium.Color.WHITE,
show: true,
},
billboardCollection: { show: true, remove() {} },
viewer: { camera: { positionCartographic: null }, scene: {} },
meta: {
callsign: 'RCH512',
rawLat: 31,
rawLon: -97,
altitudeFt: 26_000,
speedMps: 220,
track: 95,
onGround: false,
verticalRateMps,
},
});
seedRate(12.7);
assert.equal(militaryFlightsLayer.getTrackedInfo()?.verticalRateMps, 12.7);
// Absent is not level: a contact that has never transmitted a rate must read
// as null, not as a 0 m/s a phase classifier would call cruise.
for (const missing of [undefined, null, Number.NaN]) {
seedRate(missing);
assert.equal(
militaryFlightsLayer.getTrackedInfo()?.verticalRateMps,
null,
`vertical rate ${String(missing)} must publish as null`,
);
}
});
+10 -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,10 @@ 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;
const MAX_FOOTPRINT_POINTS = 400;
@@ -44,7 +47,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) {
+5 -11
View File
@@ -18,8 +18,8 @@
import { loadBundledJson } from './bundledJson.js';
import { createRetryableLoader } from './retryableLoad.js';
import { EARTH_RADIUS_KM, greatCircleKm } from '../geoDistance.js';
const EARTH_RADIUS_KM = 6371;
const toRad = (d) => (d * Math.PI) / 180;
/** Spherical-excess ring area (km²) — same family as turf/geojson-area. */
@@ -36,15 +36,6 @@ function ringAreaKm2(ring) {
return Math.abs((sum * EARTH_RADIUS_KM * EARTH_RADIUS_KM) / 2);
}
function haversineKm(lon1, lat1, lon2, lat2) {
const dLat = toRad(lat2 - lat1);
const dLon = toRad(lon2 - lon1);
const h =
Math.sin(dLat / 2) ** 2 +
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2;
return 2 * EARTH_RADIUS_KM * Math.asin(Math.min(1, Math.sqrt(h)));
}
/**
* Normalize a query/name for matching: lowercase, strip diacritics and
* punctuation, collapse whitespace, strip a leading "the ".
@@ -157,7 +148,10 @@ function buildEntries(pack, kind) {
polygons,
areaKm2,
bbox: [minLon, minLat, maxLon, maxLat],
bboxDiagonalKm: haversineKm(minLon, minLat, maxLon, maxLat),
bboxDiagonalKm: greatCircleKm(
{ lat: minLat, lon: minLon },
{ lat: maxLat, lon: maxLon },
),
});
}
return out;
+11
View File
@@ -3,6 +3,7 @@ import { test } from 'node:test';
import assert from 'node:assert/strict';
import { statSync, readFileSync } from 'node:fs';
import { findNaturalRegion, listRegions, lookupNaturalRegionOutline, pointInRing } from './naturalEarthRegions.js';
import { greatCircleKm } from '../geoDistance.js';
const PACK_DIR = new URL('./local_data/natural_earth/', import.meta.url);
@@ -19,6 +20,16 @@ test('marquee ranges resolve with sane areas (owner acceptance: Alps + Rockies)'
assert.ok(rockies.areaKm2 >= 700_000 && rockies.areaKm2 < 1_500_000,
`Rockies area sane (got ${Math.round(rockies.areaKm2)} km²)`);
assert.ok(rockies.bboxDiagonalKm > 2000, 'Rockies span thousands of km');
const coordinates = rockies.polygons.flat();
const minLon = Math.min(...coordinates.map(([lon]) => lon));
const minLat = Math.min(...coordinates.map(([, lat]) => lat));
const maxLon = Math.max(...coordinates.map(([lon]) => lon));
const maxLat = Math.max(...coordinates.map(([, lat]) => lat));
assert.equal(
rockies.bboxDiagonalKm,
greatCircleKm({ lat: minLat, lon: minLon }, { lat: maxLat, lon: maxLon }),
'Natural Earth bbox conversion maps [lon, lat] data into { lat, lon } points',
);
});
test('aliases and articles: "the Alps", "Rockies", "Sahara Desert", "Himalaya"', async () => {
+24
View File
@@ -0,0 +1,24 @@
import { msg } from '../i18n/marker.js';
/**
* Tool name for a pointer owner. Owner ids (inputOwnership.js) never go on
* screen; callers translate the English name with translateKnown().
*/
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;
}
-6
View File
@@ -603,12 +603,6 @@ test('late media errors after replacement or Pause cannot mutate the active stat
assert.equal(radioLayer.getUIState().audioState, 'loading');
assert.equal(radioLayer.stopPlayback({ origin: 'user' }), true);
assert.deepEqual(controlEvents[1], {
action: 'play-request',
observedAudioState: 'paused',
observedStationId: stationRows[1].id,
cleanupResult: false,
}, 'manual playback announces replacement before the new attempt owns media');
assert.deepEqual(controlEvents[2], {
action: 'stop',
observedAudioState: 'stopped',
observedStationId: null,
+6 -15
View File
@@ -1,3 +1,5 @@
import { greatCircleMeters } from '../geoDistance.js';
const MAX_ARTICLES = 5;
function cleanText(value, maxLength = 180) {
@@ -153,19 +155,8 @@ export function weatherCodeLabel(code) {
/** Great-circle distance used to avoid refetching a regional brief every animation frame. */
export function regionalDistanceM(from, to) {
if (
![from?.latitude, from?.longitude, to?.latitude, to?.longitude].every(
Number.isFinite,
)
) {
return Infinity;
}
const phi1 = (from.latitude * Math.PI) / 180;
const phi2 = (to.latitude * Math.PI) / 180;
const deltaPhi = ((to.latitude - from.latitude) * Math.PI) / 180;
const deltaLambda = ((to.longitude - from.longitude) * Math.PI) / 180;
const a =
Math.sin(deltaPhi / 2) ** 2 +
Math.cos(phi1) * Math.cos(phi2) * Math.sin(deltaLambda / 2) ** 2;
return 6371000 * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return greatCircleMeters(
{ lat: from?.latitude, lon: from?.longitude },
{ lat: to?.latitude, lon: to?.longitude },
);
}
+6 -2
View File
@@ -59,9 +59,13 @@ test('new data proxies install the same routes in dev and preview servers', () =
test('Launch Library uses a 15-minute cache and optional server-side token header', () => {
assert.equal(LL2_CACHE_TTL_MS, 15 * 60_000);
assert.deepEqual(launchLibraryRequestHeaders(''), { Accept: 'application/json' });
assert.deepEqual(launchLibraryRequestHeaders(' secret '), {
const headers = {
Accept: 'application/json',
'User-Agent': 'gods-eye-view-launch-library-proxy/1.0 (+https://github.com/bilawalsidhu/gods-eye-view)',
};
assert.deepEqual(launchLibraryRequestHeaders(''), headers);
assert.deepEqual(launchLibraryRequestHeaders(' secret '), {
...headers,
Authorization: 'Token secret',
});
});
+152
View File
@@ -2,6 +2,7 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { gstime } from 'satellite.js';
import { createModel } from '../layers/launches/model.js';
import rocketLaunchesLayer, {
_setRocketMissionOverlayHostForTest,
_setSelectedRocketMissionForTest,
@@ -676,6 +677,37 @@ test('uses Launch Library 2 pad latitude/longitude fields', () => {
assert.equal(launches[0].lon, -120.611);
});
test('null or blank pad coordinates stay unknown instead of becoming 0', () => {
const base = { net: '2026-07-20T10:00:00Z' };
for (const blank of [null, '', ' ', false]) {
assert.deepEqual(
normalizeRocketLaunches([{ ...base, id: 'blank', pad: { latitude: blank, longitude: blank } }], NOW),
[],
);
}
});
test('null pad coordinates use the location fallback instead of 0,0', () => {
const base = { net: '2026-07-20T10:00:00Z' };
const [fallback] = normalizeRocketLaunches([{ ...base, id: 'fallback',
pad: { latitude: null, longitude: null, location: { coordinates: '-80.6,28.6' } } }], NOW);
assert.equal(fallback.lat, 28.6);
assert.equal(fallback.lon, -80.6);
});
test('an undisclosed landing keeps top-level position and stage numbers unknown', () => {
const base = { net: '2026-07-20T10:00:00Z' };
const [launch] = normalizeRocketLaunches([{ ...base, id: 'landing', pad: { latitude: 1, longitude: 2 },
rocket: { launcher_stage: [{ launcher_flight_number: null,
landing: { attempt: true, latitude: null, longitude: null, downrange_distance: null,
landing_location: { latitude: null, longitude: null } } }] } }], NOW);
const [stage] = launch.recoveryStages;
assert.equal(stage.lat, null);
assert.equal(stage.lon, null);
assert.equal(stage.downrangeKm, null);
assert.equal(stage.flightNumber, null);
});
test('normalizes detailed payload and stage recovery records', () => {
const launches = normalizeRocketLaunches({ results: [{
id: 'recovery-details',
@@ -728,6 +760,126 @@ test('normalizes detailed payload and stage recovery records', () => {
assert.equal(launches[0].recoveryStages[0].lon, -76.2);
});
test('finiteCoordinate normalizes finite numbers and rejects non-numeric or non-finite inputs', () => {
const model = createModel({});
assert.equal(model.finiteCoordinate(42.5), 42.5);
assert.equal(model.finiteCoordinate('28.5619'), 28.5619);
assert.equal(model.finiteCoordinate('-80.5772'), -80.5772);
assert.equal(model.finiteCoordinate(0), 0);
assert.equal(model.finiteCoordinate('0'), 0);
assert.equal(model.finiteCoordinate(undefined), null);
assert.equal(model.finiteCoordinate('invalid'), null);
assert.equal(model.finiteCoordinate('TBD'), null);
assert.equal(model.finiteCoordinate(Number.NaN), null);
assert.equal(model.finiteCoordinate(Number.POSITIVE_INFINITY), null);
assert.equal(model.finiteCoordinate(Number.NEGATIVE_INFINITY), null);
});
test('normalizes recovery stage coordinates, downrange distance, and flight numbers with non-finite fallbacks', () => {
const launches = normalizeRocketLaunches(
{
results: [
{
id: 'recovery-fallbacks',
name: 'Falcon 9 | Edge Case Stage Normalization',
net: '2026-07-20T10:00:00Z',
pad: { latitude: '28.608', longitude: '-80.604' },
rocket: {
launcher_stage: [
{
id: 88,
type: { name: 'Booster' },
launcher_flight_number: 'invalid-flight',
landing: {
attempt: true,
success: false,
downrange_distance: 'TBD',
landing_location: {
name: 'Ocean Impact',
latitude: 'N/A',
longitude: undefined,
},
},
},
{
id: 89,
type: { name: 'Expended Stage' },
landing: {
attempt: false,
type: { name: 'Expendable' },
},
},
],
},
},
],
},
NOW,
);
assert.equal(launches[0].recoveryStages.length, 2);
const booster = launches[0].recoveryStages[0];
assert.equal(booster.status, 'LOST');
assert.equal(booster.success, false);
assert.equal(booster.attempted, true);
assert.equal(booster.flightNumber, null);
assert.equal(booster.downrangeKm, null);
assert.equal(booster.lat, null);
assert.equal(booster.lon, null);
const expendable = launches[0].recoveryStages[1];
assert.equal(expendable.status, 'EXPENDABLE');
assert.equal(expendable.attempted, false);
assert.equal(expendable.success, null);
});
test('normalizes payload amounts and fractional string masses', () => {
const launches = normalizeRocketLaunches(
{
results: [
{
id: 'payload-edge-cases',
name: 'Electron | Mixed Payloads',
net: '2026-07-20T10:00:00Z',
pad: { latitude: '-39.26', longitude: '177.86' },
rocket: {
payloads: [
{
id: 601,
amount: 'not-a-number',
payload: { id: 11, name: 'Unknown Amount Payload' },
},
{
id: 602,
amount: 3,
payload: { id: 12, name: 'Triple Payload' },
},
{
id: 603,
payload: {
id: 13,
name: 'Valid Fractional Mass Payload',
mass: '12.75',
},
},
],
},
},
],
},
NOW,
);
// Blank, negative and non-numeric masses are pinned by "missing payload
// details stay unknown without inventing mass or classification".
const payloads = launches[0].payloads;
assert.equal(payloads.length, 3);
assert.equal(payloads[0].amount, 1);
assert.equal(payloads[1].amount, 3);
assert.equal(payloads[2].massKg, 12.75);
});
test('keeps payload and recovery collections empty when LL2 does not disclose them', () => {
const launches = normalizeRocketLaunches({ results: [{
id: 'undisclosed',
+12 -28
View File
@@ -10,19 +10,9 @@
* low → its destination should be nearby).
*/
const D2R = Math.PI / 180;
const R_KM = 6371;
import { EARTH_RADIUS_KM, greatCircleKm, isGeoPoint } from '../geoDistance.js';
/** Haversine great-circle distance in km. */
export function greatCircleKm(lat1, lon1, lat2, lon2) {
const p1 = lat1 * D2R;
const p2 = lat2 * D2R;
const dp = (lat2 - lat1) * D2R;
const dl = (lon2 - lon1) * D2R;
const a =
Math.sin(dp / 2) ** 2 + Math.cos(p1) * Math.cos(p2) * Math.sin(dl / 2) ** 2;
return R_KM * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}
const D2R = Math.PI / 180;
function bearingRad(lat1, lon1, lat2, lon2) {
const p1 = lat1 * D2R;
@@ -36,10 +26,11 @@ function bearingRad(lat1, lon1, lat2, lon2) {
/** Signed cross-track distance (km) of point from the great circle p1→p2. */
export function crossTrackKm(lat, lon, lat1, lon1, lat2, lon2) {
const d13 = greatCircleKm(lat1, lon1, lat, lon) / R_KM;
const d13 =
greatCircleKm({ lat: lat1, lon: lon1 }, { lat, lon }) / EARTH_RADIUS_KM;
const b13 = bearingRad(lat1, lon1, lat, lon);
const b12 = bearingRad(lat1, lon1, lat2, lon2);
return Math.asin(Math.sin(d13) * Math.sin(b13 - b12)) * R_KM;
return Math.asin(Math.sin(d13) * Math.sin(b13 - b12)) * EARTH_RADIUS_KM;
}
const NEAR_ENDPOINT_KM = 130;
@@ -65,14 +56,14 @@ export function routePlausible({
origin = null,
destination = null,
}) {
const haveO = Number.isFinite(origin?.lat) && Number.isFinite(origin?.lon);
const haveD =
Number.isFinite(destination?.lat) && Number.isFinite(destination?.lon);
const position = { lat: latDeg, lon: lonDeg };
if (!isGeoPoint(position)) return true;
const haveO = isGeoPoint(origin);
const haveD = isGeoPoint(destination);
if (!haveO && !haveD) return true; // no coordinates — cannot judge, do not hide
// (a) Geographic consistency: near an endpoint, or roughly on the path.
const near = (pt) =>
greatCircleKm(latDeg, lonDeg, pt.lat, pt.lon) < NEAR_ENDPOINT_KM;
const near = (point) => greatCircleKm(position, point) < NEAR_ENDPOINT_KM;
let geomOk = (haveO && near(origin)) || (haveD && near(destination));
if (!geomOk && haveO && haveD) {
geomOk =
@@ -99,17 +90,10 @@ export function routePlausible({
Math.abs(verticalRateMps) > VERT_TREND_MPS
) {
if (verticalRateMps > 0) {
if (
haveO &&
greatCircleKm(latDeg, lonDeg, origin.lat, origin.lon) > LOCAL_AIRPORT_KM
)
if (haveO && greatCircleKm(position, origin) > LOCAL_AIRPORT_KM)
return false; // departing — origin should be local
} else {
if (
haveD &&
greatCircleKm(latDeg, lonDeg, destination.lat, destination.lon) >
LOCAL_AIRPORT_KM
)
if (haveD && greatCircleKm(position, destination) > LOCAL_AIRPORT_KM)
return false; // arriving — destination should be local
}
}
+7 -2
View File
@@ -1,14 +1,15 @@
// src/data/routePlausible.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { routePlausible, greatCircleKm } from './routePlausible.js';
import { greatCircleKm } from '../geoDistance.js';
import { routePlausible } from './routePlausible.js';
const SFO = { lat: 37.6188, lon: -122.3754 };
const LAX = { lat: 33.9416, lon: -118.4085 };
const JFK = { lat: 40.6413, lon: -73.7781 };
test('greatCircleKm sanity: SFO→LAX ≈ 543 km', () => {
const d = greatCircleKm(SFO.lat, SFO.lon, LAX.lat, LAX.lon);
const d = greatCircleKm(SFO, LAX);
assert.ok(Math.abs(d - 543) < 15, `got ${d}`);
});
@@ -41,4 +42,8 @@ test('missing data never hides a route (cannot judge → allow)', () => {
latDeg: 51.5, lonDeg: -0.12,
origin: { lat: null, lon: null }, destination: null,
}), true);
assert.equal(routePlausible({
latDeg: 95, lonDeg: 0,
origin: SFO, destination: LAX,
}), true);
});
+42 -18
View File
@@ -17,54 +17,46 @@
* for), then the constellations users can actually name, then the grab-bag.
*/
import { t, msg } from '../i18n/index.js';
/**
* Class registry. `label` is the card/legend token, `color` the point color,
* `blurb` the plain-language gloss for the legend tooltip.
* @type {Readonly<Record<string, { label: string, color: string, blurb: string }>>}
* Class registry: the point color of each class. The card and legend text is
* in CLASS_DISPLAY_KEYS.
* @type {Readonly<Record<string, { color: string }>>}
*/
export const SATELLITE_CLASSES = Object.freeze({
station: Object.freeze({
label: 'STATION',
// Warm white — "crewed", and the brightest class in the sky. Deliberately
// NOT amber: 40-48deg amber is the app's known-military convention
// (flights.js MIL_TINT / militaryFlights.js MIL_ICON_COLOR) and must not
// leak into another domain. Slightly warm so it also separates from the
// pure white the civilian air fleet draws in.
color: '#fff6e5',
blurb: 'Crewed stations and their visiting vehicles',
}),
nav: Object.freeze({
label: 'NAV',
// Cyan — the strongest accent goes to the class users actually name
// ("how many GPS satellites are up?"). Takes over the slot the catch-all
// VISUAL group used to occupy, so this adds no new hue to the app.
color: '#4fd8ff',
blurb: 'GNSS navigation — GPS, GLONASS, Galileo',
}),
geo: Object.freeze({
label: 'GEO',
// Violet, unchanged. Already the app's "space" semantic (the detection
// overlay paints the SAT tier #bda4ff), and the geostationary belt draws
// as one clean equatorial ring at globe scale.
color: '#c89bff',
blurb: 'Geostationary belt — comms and weather, fixed over the equator',
}),
visual: Object.freeze({
label: 'VISUAL',
// Muted blue-gray. This is the catch-all bucket, so it gives up the bright
// cyan it used to hold and recedes behind the three classes that mean
// something. Still well clear of the dense shell by luminance.
color: '#9fb3c4',
blurb: 'Brightest naked-eye objects — CelesTrak visual group',
}),
comms: Object.freeze({
label: 'COMMS',
// Dim slate — thousands of these appear in DENSE mode. They must read as
// texture behind the core catalog, never compete with it. Rec.601 luma
// ~0.40 against VISUAL's ~0.69 keeps the two separable even when the
// NVG/FLIR shaders collapse the scene to a single channel.
color: '#54697f',
blurb: 'Broadband constellation shell — shown only in DENSE mode',
}),
});
@@ -77,6 +69,32 @@ export const SATELLITE_CLASS_ORDER = Object.freeze([
'comms',
]);
// What the card and legend show for each class.
const CLASS_DISPLAY_KEYS = Object.freeze({
station: Object.freeze({
label: msg('STATION'),
blurb: msg('Crewed stations and their visiting vehicles'),
}),
nav: Object.freeze({
label: msg('NAV'),
blurb: msg('GNSS navigation — GPS, GLONASS, Galileo'),
}),
geo: Object.freeze({
label: msg('GEO'),
blurb: msg(
'Geostationary belt — comms and weather, fixed over the equator',
),
}),
visual: Object.freeze({
label: msg('VISUAL'),
blurb: msg('Brightest naked-eye objects — CelesTrak visual group'),
}),
comms: Object.freeze({
label: msg('COMMS'),
blurb: msg('Broadband constellation shell — shown only in DENSE mode'),
}),
});
/**
* CelesTrak group tag → { class, subtype }. Subtype names the specific
* constellation so the card can read "NAV · GPS" rather than a bare "NAV".
@@ -134,12 +152,17 @@ export function satelliteClassColor(group) {
* The ISS is always a STATION regardless of which group ingested it — see
* satelliteClassOf, which owns that rule for every consumer.
* @param {string|undefined|null} group CelesTrak group tag.
* @param {{ isIss?: boolean }} [options] Whether this is the ISS.
* @param {{ isIss?: boolean, localize?: boolean }} [options] isIss: the ISS;
* localize: false keeps the English label (data for the AI tools).
* @returns {string} Display label.
*/
export function satelliteClassLabel(group, { isIss = false } = {}) {
export function satelliteClassLabel(
group,
{ isIss = false, localize = true } = {},
) {
const { klass, subtype } = satelliteClassOf(group, { isIss });
const base = SATELLITE_CLASSES[klass].label;
const label = CLASS_DISPLAY_KEYS[klass].label;
const base = localize ? t(label) : label;
return subtype ? `${base} · ${subtype}` : base;
}
@@ -180,11 +203,12 @@ export function satelliteClassLegend(counts) {
const count = counts?.[klass];
if (!(count > 0)) continue;
const spec = SATELLITE_CLASSES[klass];
const display = CLASS_DISPLAY_KEYS[klass];
result.push({
klass,
label: spec.label,
label: t(display.label),
color: spec.color,
blurb: spec.blurb,
blurb: t(display.blurb),
count,
});
}
+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',
);
+4 -2
View File
@@ -7,10 +7,12 @@ export function celestrakTleUrl(group) {
}
export function launchLibraryRecentUrl(end) {
const start = new Date(end.getTime() - 30 * 86400000);
// Stable minute boundaries let different installations share LL2's cache.
const roundedEnd = new Date(Math.floor(end.getTime() / 60000) * 60000);
const start = new Date(roundedEnd.getTime() - 30 * 86400000);
const url = new URL('https://ll.thespacedevs.com/2.3.0/launches/');
url.searchParams.set('net__gte', start.toISOString());
url.searchParams.set('net__lte', end.toISOString());
url.searchParams.set('net__lte', roundedEnd.toISOString());
url.searchParams.set('limit', '100');
url.searchParams.set('mode', 'detailed');
return url;
+70
View File
@@ -0,0 +1,70 @@
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('es: tracked ISS card is localized', () => {
const details = run('es');
assert.equal(details[0], 'ESTACIÓN · ISS');
assert.doesNotMatch(
details[2],
/^DOCKED/,
'companions line should be localized',
);
});
test('ru: tracked ISS card is localized', () => {
const details = run('ru');
assert.equal(details[0], 'СТАНЦИЯ · ISS');
assert.match(details[1], /км/, 'ru glossary: km → км');
assert.doesNotMatch(
details[2],
/^DOCKED/,
'companions line should be localized',
);
});
+6 -23
View File
@@ -13,31 +13,11 @@
* @module data/trafficBounds
*/
/** @const {number} Mean Earth radius in km (spherical approximation). */
const EARTH_RADIUS_KM = 6371;
import { EARTH_RADIUS_KM, greatCircleKm, isGeoPoint } from '../geoDistance.js';
const toRad = (deg) => (deg * Math.PI) / 180;
const toDeg = (rad) => (rad * 180) / Math.PI;
/**
* Great-circle distance between two lat/lon points (haversine).
*
* @param {number} lat1 - First point latitude (degrees).
* @param {number} lon1 - First point longitude (degrees).
* @param {number} lat2 - Second point latitude (degrees).
* @param {number} lon2 - Second point longitude (degrees).
* @returns {number} Distance in kilometres.
*/
export function greatCircleKm(lat1, lon1, lat2, lon2) {
const p1 = toRad(lat1);
const p2 = toRad(lat2);
const dp = toRad(lat2 - lat1);
const dl = toRad(lon2 - lon1);
const a =
Math.sin(dp / 2) ** 2 + Math.cos(p1) * Math.cos(p2) * Math.sin(dl / 2) ** 2;
return 2 * EARTH_RADIUS_KM * Math.asin(Math.min(1, Math.sqrt(a)));
}
/**
* Initial bearing (radians) from point 1 toward point 2 along the great circle.
*
@@ -111,10 +91,13 @@ export function deriveFetchCenter({
hitLon,
maxPullKm = 12,
}) {
if (!Number.isFinite(hitLat) || !Number.isFinite(hitLon)) {
if (!isGeoPoint({ lat: hitLat, lon: hitLon })) {
return { lat: nadirLat, lon: nadirLon, source: 'nadir' };
}
const distKm = greatCircleKm(nadirLat, nadirLon, hitLat, hitLon);
const distKm = greatCircleKm(
{ lat: nadirLat, lon: nadirLon },
{ lat: hitLat, lon: hitLon },
);
if (distKm <= maxPullKm) {
return { lat: hitLat, lon: hitLon, source: 'hit' };
}
+7 -7
View File
@@ -1,8 +1,8 @@
// src/data/trafficBounds.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { greatCircleKm } from '../geoDistance.js';
import {
greatCircleKm,
deriveFetchCenter,
clampBoundsAroundCenter,
} from './trafficBounds.js';
@@ -11,7 +11,7 @@ import {
const NADIR = { lat: 30.2672, lon: -97.7431 };
test('greatCircleKm sanity: Austin -> ~5 km north', () => {
const d = greatCircleKm(NADIR.lat, NADIR.lon, NADIR.lat + 0.045, NADIR.lon);
const d = greatCircleKm(NADIR, { lat: NADIR.lat + 0.045, lon: NADIR.lon });
assert.ok(Math.abs(d - 5.0) < 0.1, `got ${d}`);
});
@@ -41,14 +41,14 @@ test('oblique look within 12 km: uses the hit point verbatim', () => {
test('horizon gaze: far hit is pulled back to 12 km along the bearing', () => {
// Hit ~100 km due east of nadir.
const hit = { lat: NADIR.lat, lon: NADIR.lon + 1.041 };
assert.ok(greatCircleKm(NADIR.lat, NADIR.lon, hit.lat, hit.lon) > 90, 'precondition: far hit');
assert.ok(greatCircleKm(NADIR, hit) > 90, 'precondition: far hit');
const c = deriveFetchCenter({
nadirLat: NADIR.lat, nadirLon: NADIR.lon,
hitLat: hit.lat, hitLon: hit.lon,
maxPullKm: 12,
});
assert.equal(c.source, 'pulled');
const d = greatCircleKm(NADIR.lat, NADIR.lon, c.lat, c.lon);
const d = greatCircleKm(NADIR, c);
assert.ok(Math.abs(d - 12) < 0.05, `pulled distance ${d} km, expected ~12`);
// Due-east bearing: latitude stays ~constant, longitude moves east but well
// short of the hit.
@@ -64,12 +64,12 @@ test('pull cap is honored for other maxPullKm values', () => {
maxPullKm: 5,
});
assert.equal(c.source, 'pulled');
const d = greatCircleKm(NADIR.lat, NADIR.lon, c.lat, c.lon);
const d = greatCircleKm(NADIR, c);
assert.ok(Math.abs(d - 5) < 0.05, `pulled distance ${d} km, expected ~5`);
});
test('pickEllipsoid failure (non-finite hit): falls back to the camera nadir', () => {
for (const [hitLat, hitLon] of [[NaN, NaN], [undefined, undefined], [30.3, undefined]]) {
test('invalid ground hit falls back to the camera nadir', () => {
for (const [hitLat, hitLon] of [[NaN, NaN], [undefined, undefined], [30.3, undefined], [91, -97], [30, 181]]) {
const c = deriveFetchCenter({
nadirLat: NADIR.lat, nadirLon: NADIR.lon,
hitLat, hitLon,
+5 -8
View File
@@ -1,3 +1,5 @@
import { greatCircleKm } from '../geoDistance.js';
/**
* @module transitFeeds
* @description Registry of keyless, openly licensed GTFS-Realtime
@@ -773,7 +775,8 @@ export function getRegisteredTransitFeed(id) {
}
/**
* Great-circle distance in km.
* Great-circle distance in km. Retained for compatibility with existing
* transit consumers; new shared spatial code should use `greatCircleKm`.
* @param {number} aLat
* @param {number} aLon
* @param {number} bLat
@@ -781,13 +784,7 @@ export function getRegisteredTransitFeed(id) {
* @returns {number}
*/
export function haversineKm(aLat, aLon, bLat, bLon) {
const toRad = (deg) => (deg * Math.PI) / 180;
const dLat = toRad(bLat - aLat);
const dLon = toRad(bLon - aLon);
const h =
Math.sin(dLat / 2) ** 2 +
Math.cos(toRad(aLat)) * Math.cos(toRad(bLat)) * Math.sin(dLon / 2) ** 2;
return 2 * 6371 * Math.asin(Math.min(1, Math.sqrt(h)));
return greatCircleKm({ lat: aLat, lon: aLon }, { lat: bLat, lon: bLon });
}
/**
+37 -34
View File
@@ -4,6 +4,11 @@
* after the vessel cards moved out of their dedicated canvas renderer.
*/
import { t, msg } from '../i18n/index.js';
import { normalizeVesselType } from './vesselTypes.js';
export { normalizeVesselType };
export const VESSEL_OVERLAY_SOURCE_ID = 'ais-live-vessels';
/** Existing selector grid size; one ambient winner is retained per cell. */
export const VESSEL_LABEL_GRID_PX = 118;
@@ -13,6 +18,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
@@ -39,44 +46,40 @@ const TYPE_STYLES = [
];
const DEFAULT_STYLE = { css: '#39d5ff', accent: '57, 213, 255' };
const NUMERIC_TYPE_SPECIALS = {
30: 'FISHING',
31: 'TOWING',
32: 'TOWING',
33: 'DREDGER',
34: 'DIVE OPS',
35: 'MILITARY',
36: 'SAILING',
37: 'PLEASURE',
50: 'PILOT',
51: 'SAR',
52: 'TUG',
53: 'PORT TENDER',
54: 'ANTI-POLLUTION',
55: 'LAW ENFORCE',
58: 'MEDICAL',
};
const NUMERIC_TYPE_FAMILIES = {
4: 'HIGH-SPEED',
6: 'PASSENGER',
7: 'CARGO',
8: 'TANKER',
9: 'OTHER',
};
// Card text for the type tokens above. The tokens stay English: TYPE_STYLES
// and tests match on them.
const TYPE_DISPLAY_KEYS = Object.freeze({
FISHING: msg('FISHING'),
TOWING: msg('TOWING'),
DREDGER: msg('DREDGER'),
'DIVE OPS': msg('DIVE OPS'),
MILITARY: msg('MILITARY'),
SAILING: msg('SAILING'),
PLEASURE: msg('PLEASURE'),
PILOT: msg('PILOT'),
SAR: msg('SAR'),
TUG: msg('TUG'),
'PORT TENDER': msg('PORT TENDER'),
'ANTI-POLLUTION': msg('ANTI-POLLUTION'),
'LAW ENFORCE': msg('LAW ENFORCE'),
MEDICAL: msg('MEDICAL'),
'HIGH-SPEED': msg('HIGH-SPEED'),
PASSENGER: msg('PASSENGER'),
CARGO: msg('CARGO'),
TANKER: msg('TANKER'),
OTHER: msg('OTHER'),
});
/**
* Resolve an AIS type to display text: bare numeric ship-type codes map to
* family names ("71" → "CARGO"); text types pass through unchanged.
* Card-display form of a normalized AIS type: known tokens translate through
* the catalog; free-text types and unknown codes render unchanged.
* @param {string} type Raw AIS type.
* @returns {string}
*/
export function normalizeVesselType(type) {
const text = String(type || '').trim();
if (!text || !/^\d{1,2}$/.test(text)) return text;
const code = Number(text);
if (code <= 0) return '';
if (NUMERIC_TYPE_SPECIALS[code]) return NUMERIC_TYPE_SPECIALS[code];
return NUMERIC_TYPE_FAMILIES[Math.floor(code / 10)] || 'OTHER';
export function displayVesselType(type) {
const normalized = normalizeVesselType(type);
const key = TYPE_DISPLAY_KEYS[normalized.toUpperCase()];
return key ? t(key) : normalized;
}
/** AIS ship type → CSS hex hue for the billboard chevron. */
@@ -148,7 +151,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,
+2
View File
@@ -29,6 +29,8 @@ test('normalizeVesselType preserves text and degrades unknown codes', () => {
assert.equal(normalizeVesselType('Crude Oil Tanker'), 'Crude Oil Tanker');
assert.equal(normalizeVesselType('0'), '');
assert.equal(normalizeVesselType('25'), 'OTHER');
assert.equal(normalizeVesselType('100'), '');
assert.equal(normalizeVesselType('255'), '');
assert.equal(normalizeVesselType(undefined), '');
});
+96
View File
@@ -0,0 +1,96 @@
// 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',
);
});
+47
View File
@@ -0,0 +1,47 @@
/**
* @module vesselTypes
* @description AIS ship-type codes (ITU-R M.1371) to family names, with no
* browser or i18n dependencies so the server-side query tools can use it.
*/
const NUMERIC_TYPE_SPECIALS = {
30: 'FISHING',
31: 'TOWING',
32: 'TOWING',
33: 'DREDGER',
34: 'DIVE OPS',
35: 'MILITARY',
36: 'SAILING',
37: 'PLEASURE',
50: 'PILOT',
51: 'SAR',
52: 'TUG',
53: 'PORT TENDER',
54: 'ANTI-POLLUTION',
55: 'LAW ENFORCE',
58: 'MEDICAL',
};
const NUMERIC_TYPE_FAMILIES = {
4: 'HIGH-SPEED',
6: 'PASSENGER',
7: 'CARGO',
8: 'TANKER',
9: 'OTHER',
};
/**
* Resolve an AIS type to display text: bare numeric ship-type codes map to
* family names ("71" → "CARGO"); text types pass through unchanged. Code 0
* ("not available") and the reserved codes 100 to 255 name no type and
* resolve to "".
* @param {string} type Raw AIS type.
* @returns {string}
*/
export function normalizeVesselType(type) {
const text = String(type || '').trim();
if (!text || !/^\d+$/.test(text)) return text;
const code = Number(text);
if (code <= 0 || code >= 100) return '';
if (NUMERIC_TYPE_SPECIALS[code]) return NUMERIC_TYPE_SPECIALS[code];
return NUMERIC_TYPE_FAMILIES[Math.floor(code / 10)] || 'OTHER';
}
+3 -52
View File
@@ -70,7 +70,7 @@ test('dev-fresh passes names-only boot provenance before resolving file fallback
const dotenvResolution = source.indexOf('GOOGLE_MAPS_API_KEY_ENV="${GOOGLE_MAPS_API_KEY:-}"');
assert.ok(capture >= 0 && capture < dotenvResolution, 'parent-shell provenance must be captured first');
for (const name of [
'GOOGLE_MAPS_API_KEY', 'CESIUM_ION_TOKEN', 'OPENAI_API_KEY', 'GEMINI_API_KEY', 'AISSTREAM_API_KEY',
'GOOGLE_MAPS_API_KEY', 'CESIUM_ION_TOKEN', 'OPENAI_API_KEY', 'AISSTREAM_API_KEY',
'FIRMS_MAP_KEY', 'TOMTOM_API_KEY', 'OPENSKY_CLIENT_ID',
'OPENSKY_CLIENT_SECRET', 'LL2_API_TOKEN',
]) {
@@ -128,56 +128,7 @@ bashTest('the external-keys provenance block survives set -u keyless and joins n
const keyless = await run('bash', ['-c', probe], { env: { PATH: process.env.PATH } });
assert.equal(keyless.stdout, '');
const keyed = await run('bash', ['-c', probe], {
env: { PATH: process.env.PATH, GEMINI_API_KEY: 'gemini-fixture', FIRMS_MAP_KEY: 'x', TOMTOM_API_KEY: 'y' },
env: { PATH: process.env.PATH, FIRMS_MAP_KEY: 'x', TOMTOM_API_KEY: 'y' },
});
assert.equal(keyed.stdout, 'GEMINI_API_KEY,FIRMS_MAP_KEY,TOMTOM_API_KEY');
});
bashTest('dev-fresh keeps voice available when only a Gemini key is configured', async () => {
const script = await fs.readFile(new URL('../scripts/dev-fresh.sh', import.meta.url), 'utf8');
const start = script.indexOf('[[ -n "${OPENAI_API_KEY}" ]] && echo "OpenAI key');
const end = script.indexOf('[[ -n "${AISSTREAM_API_KEY}" ]] && echo', start);
assert.ok(start >= 0 && end > start, 'voice startup status block not found');
const block = script.slice(start, end);
const { execFile } = await import('node:child_process');
const { promisify } = await import('node:util');
const run = promisify(execFile);
for (const [openai, gemini, available] of [
['', '', false], ['openai-fixture', '', true], ['', 'gemini-fixture', true],
]) {
const { stdout } = await run('bash', ['-c', `set -u\n${block}`], {
env: { PATH: process.env.PATH, OPENAI_API_KEY: openai, GEMINI_API_KEY: gemini },
});
if (available) {
assert.match(stdout, /Voice control: available/);
assert.doesNotMatch(stdout, /GEV MIC disabled/);
} else {
assert.match(stdout, /add an OpenAI or Gemini key/);
}
assert.doesNotMatch(stdout, /openai-fixture|gemini-fixture/);
}
});
bashTest('dev-fresh preserves exported Gemini key and model with independent dotenv fallbacks', async () => {
const script = await fs.readFile(new URL('../scripts/dev-fresh.sh', import.meta.url), 'utf8');
const assignments = script.split('\n').filter((line) => /^GEMINI_(API_KEY|LIVE_MODEL)=/.test(line));
assert.equal(assignments.length, 2);
assert.match(script, /put_env_if_set GEMINI_API_KEY "\$\{GEMINI_API_KEY\}"/);
assert.match(script, /put_env_if_set GEMINI_LIVE_MODEL "\$\{GEMINI_LIVE_MODEL\}"/);
const { execFile } = await import('node:child_process');
const { promisify } = await import('node:util');
const run = promisify(execFile);
const probe = [
'set -u',
'read_dotenv_value() { printf "file-%s" "$1"; }',
...assignments,
'printf "%s|%s" "$GEMINI_API_KEY" "$GEMINI_LIVE_MODEL"',
].join('\n');
const inherited = await run('bash', ['-c', probe], {
env: { PATH: process.env.PATH, GEMINI_API_KEY: 'shell-key', GEMINI_LIVE_MODEL: 'shell-model' },
});
assert.equal(inherited.stdout, 'shell-key|shell-model');
const fallback = await run('bash', ['-c', probe], { env: { PATH: process.env.PATH } });
assert.equal(fallback.stdout, 'file-GEMINI_API_KEY|file-GEMINI_LIVE_MODEL');
assert.equal(keyed.stdout, 'FIRMS_MAP_KEY,TOMTOM_API_KEY');
});
+1
View File
@@ -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) => {
+7 -4
View File
@@ -1,5 +1,8 @@
import { msg } from '../../i18n/marker.js';
import { BUNDLE_SOURCE } from './bundle.js';
// Statuses stay English; scenes/sharing.js translates them for the dialog.
/** Describe an import without fetching assets, applying state or exposing source configuration. */
export function describeSceneShare(
{ project, assets },
@@ -16,11 +19,11 @@ export function describeSceneShare(
status:
pack.source.adapter === BUNDLE_SOURCE
? assets.has(pack.source.path)
? 'Included in bundle'
: 'Missing bundle file — reimport its bundle'
? msg('Included in bundle')
: msg('Missing bundle file — reimport its bundle')
: sources.has(pack.source.adapter)
? 'Source configured; file checked when loaded'
: 'Source unavailable',
? msg('Source configured; file checked when loaded')
: msg('Source unavailable'),
})),
);
return {
+12 -4
View File
@@ -72,19 +72,27 @@ export function sceneSeekState(scene, progress, durationForShot, holdForShot) {
let shotIndex = scene.shots.length - 1;
for (let index = 0; index < scene.shots.length; index += 1) {
const endElapsedSec = startElapsedSec + durations[index];
if (targetSec < endElapsedSec || index === scene.shots.length - 1) {
// Compare in progress units: a shot's startProgress (start / total)
// times total can round below its start and select the previous shot.
if (
normalized < endElapsedSec / totalSec ||
index === scene.shots.length - 1
) {
shotIndex = index;
break;
}
startElapsedSec = endElapsedSec;
}
// Keep the elapsed time inside the selected shot, so it agrees with
// shotIndex when targetSec rounded below the shot's start.
const elapsedSec = Math.max(targetSec, startElapsedSec);
const shot = scene.shots[shotIndex];
const flightDurationSec = shot.durationSec || DEFAULT_SHOT_DURATION_SEC;
const holdDurationSec = holdForShot(scene, shot);
const shotDurationSec = Math.max(0.001, flightDurationSec + holdDurationSec);
const shotElapsedSec = Math.max(
0,
Math.min(shotDurationSec, targetSec - startElapsedSec),
Math.min(shotDurationSec, elapsedSec - startElapsedSec),
);
const cameraProgress = clamp01(
shotElapsedSec / Math.max(0.001, flightDurationSec),
@@ -99,8 +107,8 @@ export function sceneSeekState(scene, progress, durationForShot, holdForShot) {
);
const move = resolveCameraMove(scene, shot);
return {
sceneProgress: totalSec > 0 ? targetSec / totalSec : 0,
sceneElapsedSec: targetSec,
sceneProgress: totalSec > 0 ? elapsedSec / totalSec : 0,
sceneElapsedSec: elapsedSec,
sceneDurationSec: totalSec,
shotIndex,
shot,
+36
View File
@@ -49,3 +49,39 @@ test('camera seeking preserves cubic easing and shortest-angle orientation', ()
);
assert.deepEqual(cameraAtProgress(null, camera(20, 350), 0), camera(20, 350));
});
test('seeking to a shot start lands in that shot for every shot count and duration', () => {
const misses = [];
for (let count = 2; count <= 12; count += 1) {
for (let flightTenths = 1; flightTenths <= 100; flightTenths += 1) {
for (let holdTenths = 0; holdTenths <= 50; holdTenths += 1) {
const shots = Array.from({ length: count }, (_, index) => ({
id: `s${index}`,
// Every third shot is longer, so boundaries are not all equal steps.
durationSec: flightTenths / 10 + (index % 3 === 2 ? 1.7 : 0),
holdSec: holdTenths / 10,
camera: camera(20, 0),
}));
const mixed = { shots };
for (const [index, shot] of shots.entries()) {
const { startProgress, startElapsedSec } = sceneTimingForShot(
mixed,
shot,
duration,
);
const seek = sceneSeekState(mixed, startProgress, duration, hold);
// One or two ulps earlier still belongs to the previous shot.
const before = startProgress * (1 - Number.EPSILON);
const previous = sceneSeekState(mixed, before, duration, hold);
if (
seek.shotIndex !== index ||
seek.sceneElapsedSec < startElapsedSec ||
previous.shotIndex !== Math.max(0, index - 1)
)
misses.push(`${count}x${flightTenths}/${holdTenths}#${index}`);
}
}
}
}
assert.deepEqual(misses.slice(0, 5), []);
});
+36 -13
View File
@@ -21,6 +21,8 @@ import { createSurfaceKeyboard } from './ui/surfaceKeyboard.js';
// Choosing a mission is deliberately NOT durable suppression: picking a mission
// is enthusiasm, not "never show me this again".
import { t, msg } from './i18n/index.js';
/** Durable suppression. Written ONLY by the "Don't show this again" checkbox. */
export const FIRST_RUN_STORAGE_KEY = 'gev:first-run-mission:v1';
/** Per-session dismissal. Written by every close path; scoped to sessionStorage. */
@@ -34,10 +36,10 @@ export const FIRST_RUN_SESSION_KEY = 'gev:first-run-mission-session:v1';
*/
export const ENVIRONMENTAL_LABEL_CHOICE = 'ENVIRONMENTAL';
const ENVIRONMENTAL_LABELS = Object.freeze({
ENVIRONMENTAL: Object.freeze({ title: 'ENVIRONMENTAL' }),
EARTH_WATCH: Object.freeze({ title: 'EARTH WATCH' }),
ACTIVE_EVENTS: Object.freeze({ title: 'ACTIVE EVENTS' }),
const ENVIRONMENTAL_LABEL_KEYS = Object.freeze({
ENVIRONMENTAL: msg('ENVIRONMENTAL'),
EARTH_WATCH: msg('EARTH WATCH'),
ACTIVE_EVENTS: msg('ACTIVE EVENTS'),
});
/**
@@ -45,7 +47,9 @@ const ENVIRONMENTAL_LABELS = Object.freeze({
* @returns {{title: string}} The label set the constant above selects.
*/
export function environmentalLabel(choice = ENVIRONMENTAL_LABEL_CHOICE) {
return ENVIRONMENTAL_LABELS[choice] || ENVIRONMENTAL_LABELS.ENVIRONMENTAL;
const key =
ENVIRONMENTAL_LABEL_KEYS[choice] || ENVIRONMENTAL_LABEL_KEYS.ENVIRONMENTAL;
return { title: t(key) };
}
/*
@@ -92,12 +96,12 @@ export const FIRST_RUN_MISSIONS = Object.freeze({
contacts: Object.freeze({
kind: 'context',
contextMode: 'contacts',
busyText: 'Starting live contacts…',
busyKey: msg('Starting live contacts…'),
}),
'space-missions': Object.freeze({
kind: 'context',
contextMode: 'space-missions',
busyText: 'Opening space missions…',
busyKey: msg('Opening space missions…'),
}),
environmental: Object.freeze({
kind: 'globe',
@@ -115,7 +119,7 @@ export const FIRST_RUN_MISSIONS = Object.freeze({
// before a launch. LEDGERED post-launch. Until it lands, keyless visitors
// are judged on the layer row, which tells them the truth.
layerIds: Object.freeze(['earthquakes', 'local-firms']),
busyText: 'Scanning active events…',
busyKey: msg('Scanning active events…'),
}),
explore: Object.freeze({ kind: 'none' }),
});
@@ -355,12 +359,25 @@ export function initFirstRunExperience({
}
// The tile name is owner-switchable from one constant, so paint it from the
// module rather than trusting the markup to have been edited to match.
// module rather than trusting the markup to match. The description and the
// tile subcopy are data-i18n-runtime in welcome.html and translated here.
const environmentalTitle = root.querySelector(
'[data-first-run-environmental-title]',
);
if (environmentalTitle)
environmentalTitle.textContent = environmentalLabel().title;
const environmentalSub = root.querySelector(
'[data-first-run-environmental-title] + small',
);
if (environmentalSub)
environmentalSub.textContent = t(
'Live earthquakes and active fires, from USGS and NASA',
);
const description = root.querySelector('#first-run-description');
if (description)
description.textContent = t(
'It feels like a forbidden cockpit—then you realize the sources are public and the data is real.',
);
const status = root.querySelector('[data-first-run-status]');
const suppressBox = root.querySelector('[data-first-run-suppress]');
@@ -443,7 +460,9 @@ export function initFirstRunExperience({
button.setAttribute('aria-disabled', String(next));
if (!status) return;
if (next)
status.textContent = FIRST_RUN_MISSIONS[choice]?.busyText || 'Working…';
status.textContent = t(
FIRST_RUN_MISSIONS[choice]?.busyKey || msg('Working…'),
);
else if (status.dataset.sticky !== 'true')
status.textContent = defaultStatus;
};
@@ -493,7 +512,10 @@ export function initFirstRunExperience({
Array.isArray(failed) && failed.length ? ` (${failed.join(', ')})` : '';
if (status) {
status.dataset.sticky = 'true';
status.textContent = `Could not open that mission${detail}. Retry or explore manually.`;
status.textContent = t(
'Could not open that mission{detail}. Retry or explore manually.',
{ detail },
);
}
setBusy(false);
};
@@ -509,8 +531,9 @@ export function initFirstRunExperience({
if (box) box.checked = !wanted;
if (!status) return;
status.dataset.sticky = 'true';
status.textContent =
'This browser is blocking storage, so that could not be saved.';
status.textContent = t(
'This browser is blocking storage, so that could not be saved.',
);
};
const keyboard = createSurfaceKeyboard({
+3 -4
View File
@@ -836,7 +836,7 @@ test('markup, startup ordering and accessibility remain pinned', () => {
// passing edit — changing it needs the owner, not a nicer-sounding rewrite.
assert.ok(
html.includes(
'<p id="first-run-description">It feels like a forbidden cockpit' +
'<p id="first-run-description" data-i18n-runtime>It feels like a forbidden cockpit' +
'—then you realize the sources are public and the data is real.</p>',
),
'the owner-authored first-run line must ship exactly as written',
@@ -995,11 +995,10 @@ test('the voice TOOL SCHEMA matches the pinned contract — the mission mapping
// list descriptions; the missions still ride existing tools.
// Street Level and GNSS extend the manifest; broadcast caveats describe tracked and analyst aircraft.
// Air quality (ECCC AQHI) extends the manifest.
// control_radio separates "turn on" (enable) from explicit playback.
assert.equal(block.length, 30773, 'serialized tool schema length drifted');
assert.equal(block.length, 30655, 'serialized tool schema length drifted');
assert.equal(
crypto.createHash('sha256').update(block).digest('hex'),
'c91af86fbfd53a40b1e5d5838e959c97ef8f475591959768775693f371971c8d',
'3b1d5e77c10a92f6543a4f9d1ff1870101a0306b65e5b8a6113111c81eda371e',
'the first-run missions must ride EXISTING tools: no schema edit, no cache bust',
);
const instructions = fs.readFileSync(
+115
View File
@@ -0,0 +1,115 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { EventEmitter } from 'node:events';
import { PassThrough } from 'node:stream';
import { localProviderPlugins } from '../server/providers/local.js';
import { standaloneVoiceTools } from '../server/standalone/voiceTools.js';
import {
KEY_SETUP_KEYS,
keySetupStatus,
validateKeySetupUpdates,
upsertDotenvValues,
} from './keySetupCore.mjs';
test('local hosts opt into Gemini explicitly and retain the default plugin order', (t) => {
t.mock.method(globalThis, 'fetch', () => {
throw Error('construction must not fetch');
});
const defaults = localProviderPlugins().map((plugin) => plugin.name);
assert.ok(!defaults.includes('gemini-live-proxy'));
const enabled = localProviderPlugins({ gemini: {} }).map(
(plugin) => plugin.name,
);
assert.equal(
enabled.filter((name) => name === 'gemini-live-proxy').length,
1,
);
assert.deepEqual(
enabled.filter((name) => name !== 'gemini-live-proxy'),
defaults,
);
});
for (const hook of ['configureServer', 'configurePreviewServer']) {
test(`opted-in ${hook} locks the standalone catalog into the Gemini token`, async () => {
const tools = standaloneVoiceTools();
const calls = [];
const plugin = localProviderPlugins({
gemini: {
tools,
resolveApiKey: () => 'fixture-key',
resolveModel: () => 'gemini-3.8-live',
resolveHost: () => '',
resolveAllowedHosts: () => '',
resolveRateLimit: () => '0',
fetchImpl: async (url, options) => {
calls.push({ url, options });
return Response.json({ name: 'auth_tokens/fixture' });
},
},
}).find((entry) => entry.name === 'gemini-live-proxy');
let handler;
plugin[hook]({
middlewares: {
use(route, value) {
assert.equal(route, '/api/gemini/token');
handler = value;
},
},
});
const req = new PassThrough();
req.method = 'POST';
req.headers = {
host: 'localhost:5173',
origin: 'http://localhost:5173',
'content-type': 'application/json',
};
req.socket = { remoteAddress: '127.0.0.1' };
const res = new EventEmitter();
res.setHeader = () => {};
res.end = (body) => {
res.body = JSON.parse(body);
res.writableEnded = true;
};
req.end('{}');
await handler(req, res);
assert.equal(res.statusCode, 200);
assert.equal(calls.length, 1);
const locked = JSON.parse(calls[0].options.body).bidiGenerateContentSetup
.tools[0].functionDeclarations;
assert.deepEqual(
locked.map(({ name }) => name),
tools.map(({ name }) => name),
);
assert.deepEqual(
locked.map(({ parametersJsonSchema }) => parametersJsonSchema),
tools.map(({ parameters }) => parameters),
);
assert.ok(locked.every(({ behavior }) => behavior === 'BLOCKING'));
assert.deepEqual(res.body.config.tools[0].functionDeclarations, locked);
assert.ok(!JSON.stringify(res.body).includes('fixture-key'));
});
}
test('Gemini key setup uses the existing server-only status and save contract', () => {
const entry = KEY_SETUP_KEYS.find(({ id }) => id === 'gemini');
assert.deepEqual(entry.envVars, ['GEMINI_API_KEY']);
assert.ok(!entry.clientExposed);
assert.equal(
keySetupStatus({}).keys.find(({ id }) => id === 'gemini').set,
false,
);
const env = { GEMINI_API_KEY: 'fixture-private-key' };
const status = keySetupStatus(env);
assert.equal(status.keys.find(({ id }) => id === 'gemini').set, true);
assert.ok(!JSON.stringify(status).includes(env.GEMINI_API_KEY));
assert.equal(validateKeySetupUpdates(env).ok, true);
assert.match(
upsertDotenvValues('GEMINI_API_KEY=old\n', env),
/^GEMINI_API_KEY=fixture-private-key\n$/,
);
assert.equal(
validateKeySetupUpdates({ VITE_GEMINI_API_KEY: 'fixture-private-key' }).ok,
false,
);
});
+418 -180
View File
@@ -1,12 +1,14 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import http from 'node:http';
import { once } from 'node:events';
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { createServer, preview } from 'vite';
import { hostCheckPlugin, resolveAllowedHosts } from '../build/allowedHosts.js';
import { standaloneVoiceTools } from '../server/standalone/voiceTools.js';
import test from 'node:test';
import assert from 'node:assert/strict';
import http from 'node:http';
import { EventEmitter, once } from 'node:events';
import { PassThrough } from 'node:stream';
import { geminiLiveProxy } from '../server/providers/gemini.js';
import { createGeminiTokenHandler } from '../server/providers/gemini/realtime.js';
import {
@@ -15,7 +17,6 @@ import {
createGeminiLiveConfig,
} from '../server/providers/gemini/config.js';
import { GEV_REALTIME_TOOLS } from '../server/providers/openai/tools.js';
import { standaloneVoiceTools } from '../server/standalone/voiceTools.js';
import { PROXY_SIGNALS } from './localRequestGate.mjs';
const API_KEY = 'private-gemini-test-key-never-return';
@@ -108,6 +109,82 @@ test('dev and preview install the same bounded Gemini token endpoint', () => {
}
});
test('mounted token handler completes synchronous resolver failures without exposing details', async (t) => {
for (const resolver of [
'resolveHost',
'resolveAllowedHosts',
'resolveApiKey',
'resolveModel',
'resolveRateLimit',
null,
]) {
await t.test(resolver || 'healthy control', async () => {
const req = Object.assign(new PassThrough(), {
method: 'POST',
headers: {
host: '127.0.0.1',
origin: 'http://127.0.0.1',
'content-type': 'application/json',
},
socket: { remoteAddress: '127.0.0.1' },
});
const writes = [];
const headers = new Map();
const res = Object.assign(new EventEmitter(), {
setHeader: (name, value) => headers.set(name.toLowerCase(), value),
end(body) {
this.writableEnded = true;
writes.push(JSON.parse(body));
},
});
let calls = 0;
let handler;
geminiLiveProxy({
realtime: {
resolveHost: () => '',
resolveAllowedHosts: () => '',
resolveApiKey: () => API_KEY,
resolveModel: () => 'gemini-3.8-live',
resolveRateLimit: () => '0',
fetchImpl: async () => {
calls++;
return Response.json({ name: TOKEN });
},
...(resolver && {
[resolver]: () => {
throw new Error(`Synthetic resolver failure: ${API_KEY}`);
},
}),
},
}).configureServer({
middlewares: {
use(_path, installed) {
handler = installed;
},
},
});
const pending = handler(req, res);
req.end('{}');
await assert.doesNotReject(pending);
assert.equal(writes.length, 1);
assert.equal(res.statusCode, resolver ? 503 : 200);
assert.equal(calls, resolver ? 0 : 1);
assert.equal(headers.get('cache-control'), 'no-store');
if (resolver) {
assert.deepEqual(writes[0], {
error: 'Gemini voice could not connect. Try again.',
});
assert.equal(headers.get('connection'), 'close');
} else assert.equal(writes[0].token, TOKEN);
assert.equal(req.listenerCount('aborted'), 0);
assert.equal(req.listenerCount('data'), 0);
assert.equal(req.listenerCount('end'), 0);
assert.equal(res.listenerCount('close'), 0);
req.destroy();
});
}
});
test('mint locks canonical action schemas and server instructions with no permanent credential in browser data', async (t) => {
const f = await fixture(t, {
annotationGuidance: 'Keep existing annotations.',
@@ -170,67 +247,7 @@ test('mint locks canonical action schemas and server instructions with no perman
});
});
test('standalone mints lock the full voice query catalog in both credential and browser setup', async (t) => {
const tools = standaloneVoiceTools();
const declarations = tools.map(({ name, description, parameters }) => ({
name,
description,
parametersJsonSchema: parameters,
behavior: 'BLOCKING',
}));
for (const name of ['search_places', 'get_weather', 'get_wind', 'plan_route'])
assert.ok(
tools.some((tool) => tool.name === name),
name,
);
assert.ok(tools.length > GEV_REALTIME_TOOLS.length);
for (const inputMode of ['open-mic', 'push-to-talk']) {
const f = await fixture(t, { tools });
const response = await f.send({
body: JSON.stringify(inputMode === 'open-mic' ? {} : { inputMode }),
});
assert.equal(response.status, 200);
const data = await response.json();
assert.deepEqual(data.config.tools, [
{ functionDeclarations: declarations },
]);
const request = JSON.parse(f.calls[0][1].body);
assert.deepEqual(request.bidiGenerateContentSetup, {
model: 'models/gemini-3.8-live',
...data.config,
});
assert.equal(request.fieldMask, undefined);
assert.equal(JSON.stringify(data).includes(API_KEY), false);
}
});
test('Gemini endpoint instructions advertise only the server-owned provider chooser', async (t) => {
for (const options of [
{},
{ providerDropdown: false },
{ providerDropdown: true },
]) {
const f = await fixture(t, options);
const response = await f.send();
assert.equal(response.status, 200);
const data = await response.json();
const instructions = data.config.systemInstruction.parts[0].text;
assert.match(instructions, /I can’t switch voice providers directly/);
if (options.providerDropdown === true)
assert.match(
instructions,
/Hover over the Voice panel and use the provider dropdown/,
);
else assert.doesNotMatch(instructions, /hover|provider dropdown/i);
const upstream = JSON.parse(f.calls[0][1].body);
assert.equal(
upstream.bidiGenerateContentSetup.systemInstruction.parts[0].text,
instructions,
);
}
});
test('browser token requests cannot enable provider chooser guidance', async (t) => {
test('browser token requests cannot override server capabilities', async (t) => {
for (const body of [
{ providerDropdown: true },
{ inputMode: 'push-to-talk', providerDropdown: true },
@@ -455,119 +472,6 @@ test('a positive fraction applies a real cap and only explicit zero disables it'
assert.equal(unlimited.calls.length, 31);
});
test('real Vite dev and preview reject hostile hosts before the Gemini middleware', async (t) => {
for (const mode of ['dev', 'preview']) {
await t.test(mode, async (t) => {
const root = await mkdtemp(path.join(tmpdir(), 'gev-gemini-vite-test-'));
await mkdir(path.join(root, 'dist'));
await writeFile(path.join(root, 'index.html'), '<p>isolated fixture</p>');
await writeFile(
path.join(root, 'dist/index.html'),
'<p>isolated fixture</p>',
);
let credentialReads = 0;
let upstreamCalls = 0;
const allowedHosts = resolveAllowedHosts('gev.local');
const config = {
configFile: false,
envFile: false,
root,
logLevel: 'silent',
publicDir: false,
optimizeDeps: { noDiscovery: true, include: [] },
// Deliberately put the provider first: enforce: pre must order the gate.
plugins: [
geminiLiveProxy({
realtime: {
resolveApiKey: () => {
credentialReads++;
return '';
},
resolveHost: () => '127.0.0.1',
resolveAllowedHosts: () => 'gev.local',
fetchImpl: async () => {
upstreamCalls++;
throw new Error('No upstream calls are permitted');
},
},
}),
hostCheckPlugin(),
],
server: {
host: '127.0.0.1',
port: 0,
allowedHosts,
hmr: false,
watch: null,
},
preview: { host: '127.0.0.1', port: 0, allowedHosts },
};
let server;
try {
server =
mode === 'dev' ? await createServer(config) : await preview(config);
if (mode === 'dev') await server.listen();
const origin = `http://127.0.0.1:${server.httpServer.address().port}`;
const request = (host, extraHeaders = {}) =>
new Promise((resolve, reject) => {
const req = http.request(
`${origin}/api/gemini/token`,
{
method: 'POST',
headers: {
Host: host,
Origin: `http://${host}`,
...extraHeaders,
},
},
(res) => {
const chunks = [];
res.on('data', (chunk) => chunks.push(chunk));
res.on('end', () =>
resolve({
status: res.statusCode,
body: Buffer.concat(chunks).toString(),
}),
);
res.on('error', reject);
},
);
req.on('error', reject);
req.end();
});
for (const host of ['attacker.example:4173', 'arbitrary.local:4173']) {
const response = await request(host);
assert.equal(response.status, 403);
assert.match(response.body, /This host is not allowed/);
}
assert.equal(credentialReads, 0);
const sameSite = await request('gev.local:4173', {
'Sec-Fetch-Site': 'same-site',
});
assert.equal(sameSite.status, 403);
assert.equal(credentialReads, 0);
for (const host of [
'gev.local:4173',
'192.168.1.20:4173',
'demo.localhost:4173',
]) {
const response = await request(host);
assert.equal(response.status, 503);
assert.match(response.body, /Add a Gemini API key/);
}
assert.equal(credentialReads, 3);
assert.equal(upstreamCalls, 0);
} finally {
try {
if (server) await server.close();
} finally {
await rm(root, { recursive: true, force: true });
}
}
});
}
});
test('client bodies cannot override model or locked settings and stay byte bounded', async (t) => {
const f = await fixture(t);
for (const body of [
@@ -740,6 +644,86 @@ test('request disconnect cancels its pending mint and cannot publish a late toke
await new Promise((resolve) => setImmediate(resolve));
});
test('disconnect at mint resolution settles without publishing and discards the response body', async () => {
for (const timing of ['before-resolution', 'after-resolution', 'connected']) {
const req = Object.assign(new PassThrough(), {
method: 'POST',
headers: {
host: '127.0.0.1',
origin: 'http://127.0.0.1',
'content-type': 'application/json',
},
socket: { remoteAddress: '127.0.0.1' },
});
const writes = [];
const res = Object.assign(new EventEmitter(), {
setHeader() {},
end(body) {
this.writableEnded = true;
writes.push(body);
},
});
let started;
let release;
let cancelled = 0;
const ready = new Promise((resolve) => {
started = resolve;
});
const handler = createGeminiTokenHandler({
resolveApiKey: () => API_KEY,
resolveModel: () => 'gemini-3.8-live',
resolveHost: () => '',
resolveAllowedHosts: () => '',
resolveRateLimit: () => '0',
fetchImpl: () =>
new Promise((resolve) => {
release = resolve;
started();
}),
});
const pending = handler(req, res);
req.end('{}');
await ready;
const disconnect = () => {
req.aborted = true;
req.emit('aborted');
};
if (timing === 'before-resolution') {
disconnect();
await pending;
}
release(
new Response(
new ReadableStream({
start(controller) {
controller.enqueue(
new TextEncoder().encode(JSON.stringify({ name: TOKEN })),
);
if (timing === 'connected') controller.close();
},
cancel() {
cancelled++;
},
}),
),
);
if (timing === 'after-resolution') queueMicrotask(disconnect);
await pending;
await new Promise((resolve) => setImmediate(resolve));
if (timing === 'connected') {
assert.equal(res.statusCode, 200);
assert.equal(writes.length, 1);
assert.equal(JSON.parse(writes[0]).token, TOKEN);
assert.equal(cancelled, 0);
} else {
assert.equal(writes.length, 0, timing);
assert.equal(cancelled, 1, timing);
}
assert.equal(req.listenerCount('aborted'), 0, timing);
assert.equal(res.listenerCount('close'), 0, timing);
}
});
test('timeout also bounds a stalled upstream response body', async (t) => {
let cancelled = false;
const f = await fixture(t, {
@@ -787,7 +771,261 @@ test('a partial token request cannot hold the handler beyond its deadline', asyn
req.write('{');
});
assert.equal(response.statusCode, 504);
response.resume();
const chunks = [];
response.on('data', (chunk) => chunks.push(chunk));
await once(response, 'end');
assert.deepEqual(JSON.parse(Buffer.concat(chunks)), {
error: 'Gemini voice could not connect. Try again.',
});
assert.equal(f.calls.length, 0);
});
test(
'deadline responds before an abort-ignoring mint settles and cancels its late body',
{ timeout: 2000 },
async (t) => {
let release;
let signal;
let cancelled = false;
const f = await fixture(t, {
timeoutMs: 30,
fetchImpl: (_url, options) => {
signal = options.signal;
return new Promise((resolve) => {
release = resolve;
});
},
});
const response = await f.send();
assert.equal(response.status, 504);
assert.equal(signal.aborted, true);
assert.deepEqual(await response.json(), {
error: 'Gemini voice could not connect. Try again.',
});
release(
new Response(
new ReadableStream({
start(controller) {
controller.enqueue(
new TextEncoder().encode(JSON.stringify({ name: TOKEN })),
);
},
cancel() {
cancelled = true;
},
}),
),
);
await new Promise((resolve) => setImmediate(resolve));
assert.equal(cancelled, true);
},
);
test('wrong-destination and redirected successful responses discard their bodies', async (t) => {
for (const property of ['url', 'redirected']) {
let cancelled = false;
const f = await fixture(t, {
fetchImpl: async () => {
const response = new Response(
new ReadableStream({
cancel() {
cancelled = true;
},
}),
);
Object.defineProperty(response, property, {
value: property === 'url' ? 'https://attacker.example/token' : true,
});
return response;
},
});
const response = await f.send();
assert.equal(response.status, 502);
assert.deepEqual(await response.json(), {
error: 'Gemini voice could not connect. Try again.',
});
assert.equal(cancelled, true);
}
});
test(
'a chunked overflow delivers fixed 413 while its upload is still open',
{ timeout: 2000 },
async (t) => {
const f = await fixture(t);
const response = await new Promise((resolve, reject) => {
const req = http.request(
f.origin,
{
method: 'POST',
headers: {
Origin: f.origin,
'Content-Type': 'application/json',
'Transfer-Encoding': 'chunked',
},
},
resolve,
);
req.on('error', reject);
t.after(() => req.destroy());
req.write(' '.repeat(1024));
req.write(' ');
});
assert.equal(response.statusCode, 413);
const chunks = [];
response.on('data', (chunk) => chunks.push(chunk));
await once(response, 'end');
assert.deepEqual(JSON.parse(Buffer.concat(chunks)), {
error: 'Token request is too large',
});
assert.equal(f.calls.length, 0);
assert.equal((await f.send({ body: ' '.repeat(1024) })).status, 200);
},
);
test('standalone mints lock the full voice query catalog in both credential and browser setup', async (t) => {
const tools = standaloneVoiceTools();
const declarations = tools.map(({ name, description, parameters }) => ({
name,
description,
parametersJsonSchema: parameters,
behavior: 'BLOCKING',
}));
for (const name of ['search_places', 'get_weather', 'get_wind', 'plan_route'])
assert.ok(
tools.some((tool) => tool.name === name),
name,
);
assert.ok(tools.length > GEV_REALTIME_TOOLS.length);
for (const inputMode of ['open-mic', 'push-to-talk']) {
const f = await fixture(t, { tools });
const response = await f.send({
body: JSON.stringify(inputMode === 'open-mic' ? {} : { inputMode }),
});
assert.equal(response.status, 200);
const data = await response.json();
assert.deepEqual(data.config.tools, [
{ functionDeclarations: declarations },
]);
const request = JSON.parse(f.calls[0][1].body);
assert.deepEqual(request.bidiGenerateContentSetup, {
model: 'models/gemini-3.8-live',
...data.config,
});
assert.equal(request.fieldMask, undefined);
assert.equal(JSON.stringify(data).includes(API_KEY), false);
}
});
test('real Vite dev and preview reject hostile hosts before the Gemini middleware', async (t) => {
for (const mode of ['dev', 'preview']) {
await t.test(mode, async (t) => {
const root = await mkdtemp(path.join(tmpdir(), 'gev-gemini-vite-test-'));
await mkdir(path.join(root, 'dist'));
await writeFile(path.join(root, 'index.html'), '<p>isolated fixture</p>');
await writeFile(
path.join(root, 'dist/index.html'),
'<p>isolated fixture</p>',
);
let credentialReads = 0;
let upstreamCalls = 0;
const allowedHosts = resolveAllowedHosts('gev.local');
const config = {
configFile: false,
envFile: false,
root,
logLevel: 'silent',
publicDir: false,
optimizeDeps: { noDiscovery: true, include: [] },
// Deliberately put the provider first: enforce: pre must order the gate.
plugins: [
geminiLiveProxy({
realtime: {
resolveApiKey: () => {
credentialReads++;
return '';
},
resolveHost: () => '127.0.0.1',
resolveAllowedHosts: () => 'gev.local',
fetchImpl: async () => {
upstreamCalls++;
throw new Error('No upstream calls are permitted');
},
},
}),
hostCheckPlugin(),
],
server: {
host: '127.0.0.1',
port: 0,
allowedHosts,
hmr: false,
watch: null,
},
preview: { host: '127.0.0.1', port: 0, allowedHosts },
};
let server;
try {
server =
mode === 'dev' ? await createServer(config) : await preview(config);
if (mode === 'dev') await server.listen();
const origin = `http://127.0.0.1:${server.httpServer.address().port}`;
const request = (host, extraHeaders = {}) =>
new Promise((resolve, reject) => {
const req = http.request(
`${origin}/api/gemini/token`,
{
method: 'POST',
headers: {
Host: host,
Origin: `http://${host}`,
...extraHeaders,
},
},
(res) => {
const chunks = [];
res.on('data', (chunk) => chunks.push(chunk));
res.on('end', () =>
resolve({
status: res.statusCode,
body: Buffer.concat(chunks).toString(),
}),
);
res.on('error', reject);
},
);
req.on('error', reject);
req.end();
});
for (const host of ['attacker.example:4173', 'arbitrary.local:4173']) {
const response = await request(host);
assert.equal(response.status, 403);
assert.match(response.body, /This host is not allowed/);
}
assert.equal(credentialReads, 0);
const sameSite = await request('gev.local:4173', {
'Sec-Fetch-Site': 'same-site',
});
assert.equal(sameSite.status, 403);
assert.equal(credentialReads, 0);
for (const host of [
'gev.local:4173',
'192.168.1.20:4173',
'demo.localhost:4173',
]) {
const response = await request(host);
assert.equal(response.status, 503);
assert.match(response.body, /Add a Gemini API key/);
}
assert.equal(credentialReads, 3);
assert.equal(upstreamCalls, 0);
} finally {
try {
if (server) await server.close();
} finally {
await rm(root, { recursive: true, force: true });
}
}
});
}
});
+70
View File
@@ -0,0 +1,70 @@
/**
* Canonical geographic point used by shared spatial utilities.
*
* @typedef {{lat: number, lon: number}} GeoPoint
*/
/** IUGG mean spherical Earth radius, in kilometres. */
export const EARTH_RADIUS_KM = 6371.0088;
const DEGREES_TO_RADIANS = Math.PI / 180;
/**
* Whether a value is a canonical, finite WGS84-style point in degrees.
* Longitudes are not implicitly wrapped because an out-of-range value usually
* indicates a broken coordinate contract at the caller.
*
* @param {*} point
* @returns {point is GeoPoint}
*/
export function isGeoPoint(point) {
return (
Number.isFinite(point?.lat) &&
point.lat >= -90 &&
point.lat <= 90 &&
Number.isFinite(point?.lon) &&
point.lon >= -180 &&
point.lon <= 180
);
}
/**
* Great-circle distance in kilometres between two `{ lat, lon }` points.
*
* `{ lat, lon }` is the canonical shared coordinate representation. Invalid,
* missing, non-finite, or out-of-range coordinates return `Infinity`, which
* keeps invalid points out of threshold and nearest-neighbour selections.
*
* @param {GeoPoint} from
* @param {GeoPoint} to
* @returns {number}
*/
export function greatCircleKm(from, to) {
if (!isGeoPoint(from) || !isGeoPoint(to)) return Number.POSITIVE_INFINITY;
const latitude1 = from.lat * DEGREES_TO_RADIANS;
const latitude2 = to.lat * DEGREES_TO_RADIANS;
const latitudeDelta = (to.lat - from.lat) * DEGREES_TO_RADIANS;
const longitudeDelta = (to.lon - from.lon) * DEGREES_TO_RADIANS;
const haversine =
Math.sin(latitudeDelta / 2) ** 2 +
Math.cos(latitude1) *
Math.cos(latitude2) *
Math.sin(longitudeDelta / 2) ** 2;
const bounded = Math.min(1, Math.max(0, haversine));
return (
2 * EARTH_RADIUS_KM * Math.atan2(Math.sqrt(bounded), Math.sqrt(1 - bounded))
);
}
/**
* Great-circle distance in metres, with the same point and invalid-input
* contract as {@link greatCircleKm}.
*
* @param {GeoPoint} from
* @param {GeoPoint} to
* @returns {number}
*/
export function greatCircleMeters(from, to) {
return greatCircleKm(from, to) * 1000;
}
+78
View File
@@ -0,0 +1,78 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
EARTH_RADIUS_KM,
greatCircleKm,
greatCircleMeters,
isGeoPoint,
} from './geoDistance.js';
const AUSTIN = Object.freeze({ lat: 30.2672, lon: -97.7431 });
const HOUSTON = Object.freeze({ lat: 29.7604, lon: -95.3698 });
test('great-circle identity and symmetry', () => {
assert.equal(greatCircleKm(AUSTIN, AUSTIN), 0);
assert.equal(greatCircleKm(AUSTIN, HOUSTON), greatCircleKm(HOUSTON, AUSTIN));
});
test('great-circle one-degree calibration and canonical units', () => {
const kilometres = greatCircleKm({ lat: 0, lon: 0 }, { lat: 1, lon: 0 });
assert.ok(Math.abs(kilometres - 111.195) < 0.001);
assert.equal(
greatCircleMeters({ lat: 0, lon: 0 }, { lat: 1, lon: 0 }),
kilometres * 1000,
);
});
test('great-circle handles dateline crossing and antipodal points', () => {
const acrossDateline = greatCircleKm(
{ lat: 0, lon: 179 },
{ lat: 0, lon: -179 },
);
assert.ok(Math.abs(acrossDateline - 222.39) < 0.01);
assert.ok(
Math.abs(
greatCircleKm({ lat: 0, lon: 0 }, { lat: 0, lon: 180 }) -
Math.PI * EARTH_RADIUS_KM,
) < 1e-9,
);
});
test('canonical points require explicit latitude and longitude keys', () => {
assert.equal(isGeoPoint({ lat: 10, lon: 80 }), true);
assert.equal(isGeoPoint({ latitude: 10, longitude: 80 }), false);
assert.equal(isGeoPoint([10, 80]), false);
assert.equal(
greatCircleKm(
{ latitude: 10, longitude: 80 },
{ latitude: 20, longitude: 70 },
),
Infinity,
);
});
test('latitude-before-longitude regression rejects a swapped Austin point', () => {
assert.ok(Number.isFinite(greatCircleKm(AUSTIN, HOUSTON)));
assert.equal(
greatCircleKm(
{ lat: AUSTIN.lon, lon: AUSTIN.lat },
{ lat: HOUSTON.lon, lon: HOUSTON.lat },
),
Infinity,
);
});
test('invalid, missing, non-finite, and out-of-range points return Infinity', () => {
for (const point of [
null,
{},
{ lat: 0 },
{ lat: NaN, lon: 0 },
{ lat: 0, lon: Infinity },
{ lat: 90.0001, lon: 0 },
{ lat: 0, lon: -180.0001 },
]) {
assert.equal(greatCircleKm(point, AUSTIN), Infinity);
assert.equal(greatCircleMeters(AUSTIN, point), Infinity);
}
});
+127 -26
View File
@@ -15,9 +15,11 @@ import { applicationServices } from './services/application.js';
*/
import * as Cesium from 'cesium';
import { greatCircleKm } from './geoDistance.js';
import { forward as toMGRS } from 'mgrs';
import { CITY_POIS } from './locations.js';
import { CITY_POIS } from './cityPois.js';
import { composeLocalityTag } from './hudLocality.js';
import { hudQuantity, LOCALIZED_LOCALITY } from './hudLocalityFormat.js';
import {
ellipsoidalToMslDisplayM,
ensureGeoidReady,
@@ -27,7 +29,7 @@ import { getBasemapLabelContext } from './voice/gevActions.js';
import {
hudSummaryMatchesProvenance,
hudSummaryLayerContext,
hudTelemetryProvenanceTag,
hudTelemetryProvenance,
isHudSummaryUnconfigured,
} from './hudSummaryResponse.js';
import {
@@ -35,6 +37,8 @@ import {
DEFAULT_HUD_LAYOUT,
normalizeHudLayout,
} from './hudLayouts.js';
import { formatNumber, msg, t } from './i18n/index.js';
import { localizeLayerName } from './ui/layerPanel.js';
/** Color palettes keyed by shader mode; applied as CSS custom properties. */
const HUD_COLORS = {
@@ -60,6 +64,79 @@ const HUD_COLORS = {
},
};
/**
* Mode names per style: the same English as STYLE_STATUS_LABELS
* (ui/visualPresets.js), so both translate alike.
*/
const MODE_LABELS = Object.freeze({
normal: msg('NORMAL'),
retro: msg('CRT'),
surveillance: msg('NVG'),
thermal: msg('FLIR'),
anime: msg('ANIME'),
noir: msg('NOIR'),
snow: msg('SNOW'),
});
/** Region ids from _regionLabel() and the name shown for each. */
const REGION_LABELS = Object.freeze({
ARCTIC: msg('ARCTIC'),
ANTARCTIC: msg('ANTARCTIC'),
'NORTH AMERICA': msg('NORTH AMERICA'),
'SOUTH AMERICA': msg('SOUTH AMERICA'),
EUROPE: msg('EUROPE'),
AFRICA: msg('AFRICA'),
ASIA: msg('ASIA'),
OCEANIA: msg('OCEANIA'),
'NORTHERN OCEANIC GRID': msg('NORTHERN OCEANIC GRID'),
'SOUTHERN OCEANIC GRID': msg('SOUTHERN OCEANIC GRID'),
});
/** Feed-state ids from hudTelemetryProvenance() and the word shown for each. */
const FEED_STATE_LABELS = Object.freeze({
loading: msg('LOADING'),
degraded: msg('DEGRADED'),
stale: msg('STALE'),
partial: msg('PARTIAL'),
fallback: msg('FALLBACK'),
unavailable: msg('UNAVAILABLE'),
});
/**
* The summary's feed-state suffix in the active language, e.g.
* `UNAVAILABLE: LIVE AIS VESSELS`. State ids and layer names stay English in
* layer data; only this line translates them.
*/
function localizedProvenanceTag(provenance) {
if (!provenance) return null;
const state = FEED_STATE_LABELS[provenance.state]
? t(FEED_STATE_LABELS[provenance.state])
: provenance.state.toUpperCase();
if (!provenance.names.length) return state;
return t('{state} {layers}', {
state,
layers: provenance.names
.map((name) => localizeLayerName(name).toUpperCase())
.join('/'),
});
}
/** The name shown for a _viewBand() id. */
function viewBandLabel(band) {
switch (band) {
case 'STREET':
return t('STREET', { context: 'view band' });
case 'CITY':
return t('CITY', { context: 'view band' });
case 'METRO':
return t('METRO', { context: 'view band' });
case 'REGIONAL':
return t('REGIONAL', { context: 'view band' });
default:
return t('GLOBAL', { context: 'view band' });
}
}
/** Shader modes that automatically show the HUD overlay. */
const MILITARY_STYLES = new Set(['retro', 'surveillance', 'thermal']);
@@ -185,6 +262,7 @@ export class IntelHUD {
this._el = document.getElementById('intel-hud');
if (!this._el) return;
// i18n-ignore: sensor codes and a classification banner; the rest uses t()
this._el.innerHTML = `
<div class="hud-sonar" aria-hidden="true"></div>
@@ -199,17 +277,17 @@ export class IntelHUD {
<div class="hud-content">
<div class="hud-classification">TOP SECRET // SI-TK // NOFORN</div>
<div class="hud-system">${this._missionId} ${this._sensorId}</div>
<div class="hud-mode" id="hud-mode">NORMAL</div>
<div class="hud-mode" id="hud-mode">${t(MODE_LABELS.normal)}</div>
<div class="hud-summary-wrap">
<div class="hud-summary-label">SUMMARY</div>
<div class="hud-summary" id="hud-summary">Awaiting telemetry...</div>
<div class="hud-summary-label">${t('SUMMARY')}</div>
<div class="hud-summary" id="hud-summary">${t('Awaiting telemetry...')}</div>
</div>
</div>
</div>
<div class="hud-corner hud-top-right">
<div class="hud-content" style="text-align:right">
<div class="hud-rec"><span id="hud-rec-dot">●</span> REC <span id="hud-timestamp">2026-01-01 00:00:00Z</span></div>
<div class="hud-rec"><span id="hud-rec-dot">●</span> ${t('REC')} <span id="hud-timestamp">2026-01-01 00:00:00Z</span></div>
<div class="hud-orbital">ORB: ${this._orbitNum} PASS: DESC-${this._passNum}</div>
</div>
<div class="hud-bracket">┐</div>
@@ -227,7 +305,7 @@ export class IntelHUD {
<div class="hud-content" style="text-align:right">
<div id="hud-gsd">GSD: --m NIIRS: --</div>
<div id="hud-alt">ALT: --m SUN: --° EL</div>
<div id="hud-ais-vessel" class="hud-ais-vessel">AIS: --</div>
<div id="hud-ais-vessel" class="hud-ais-vessel">${t('AIS: --')}</div>
</div>
<div class="hud-bracket">┘</div>
</div>
@@ -355,9 +433,11 @@ export class IntelHUD {
const formatted = this._formatMGRS(mgrsStr);
mgrsLabel = formatted;
const el = document.getElementById('hud-mgrs');
// i18n-ignore: sensor codes
if (el) el.textContent = `MGRS: ${formatted}`;
} catch {
const el = document.getElementById('hud-mgrs');
// i18n-ignore: sensor codes
if (el) el.textContent = 'MGRS: ---';
}
@@ -366,6 +446,7 @@ export class IntelHUD {
if (llEl) llEl.textContent = `${latDMS} ${lonDMS}`;
const bottomEl = document.getElementById('hud-bottom-line');
if (bottomEl) {
// i18n-ignore: sensor codes
bottomEl.textContent = `MGRS: ${mgrsLabel} LAT: ${latDMS} LON: ${lonDMS}`;
}
@@ -381,6 +462,7 @@ export class IntelHUD {
);
const gsdEl = document.getElementById('hud-gsd');
if (gsdEl)
// i18n-ignore: sensor codes
gsdEl.textContent = `GSD: ${gsd.toFixed(2)}m NIIRS: ${niirs.toFixed(1)}`;
// Altitude — reported as height above MEAN SEA LEVEL. `altM` is the raw
@@ -393,6 +475,7 @@ export class IntelHUD {
const altMslM = ellipsoidalToMslDisplayM(altM, geoidN);
const sunEl = this._estimateSunElevation(latDeg, lonDeg);
if (altEl)
// i18n-ignore: sensor codes
altEl.textContent = `ALT: ${Math.round(altMslM)}m SUN: ${sunEl.toFixed(1)}° EL`;
// Collection timestamp
@@ -402,6 +485,7 @@ export class IntelHUD {
const h = String(now.getUTCHours()).padStart(2, '0');
const m = String(now.getUTCMinutes()).padStart(2, '0');
const s = String(now.getUTCSeconds()).padStart(2, '0');
// i18n-ignore: sensor codes
collEl.textContent = `COLL: ${h}:${m}:${s}Z`;
}
@@ -410,6 +494,7 @@ export class IntelHUD {
const pitchDeg = Cesium.Math.toDegrees(camera.pitch);
const ona = Math.max(0, 90 + pitchDeg);
const onaEl = document.getElementById('hud-ona');
// i18n-ignore: sensor codes
if (onaEl) onaEl.textContent = `ONA: ${ona.toFixed(1)}°`;
// `altM` stays the raw ellipsoidal camera height the sensor model reads
@@ -599,13 +684,7 @@ export class IntelHUD {
* @returns {number} Distance in kilometers.
*/
_haversineKm(lat1, lon1, lat2, lon2) {
const toRad = (deg) => Cesium.Math.toRadians(deg);
const dLat = toRad(lat2 - lat1);
const dLon = toRad(lon2 - lon1);
const a =
Math.sin(dLat / 2) ** 2 +
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2;
return 6371 * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return greatCircleKm({ lat: lat1, lon: lon1 }, { lat: lat2, lon: lon2 });
}
/**
@@ -634,10 +713,10 @@ export class IntelHUD {
*/
_composeSummary() {
const m = this._latestMetrics;
if (!m) return 'Awaiting telemetry...';
if (!m) return t('Awaiting telemetry...');
const modeEl = document.getElementById('hud-mode');
const modeLabel = modeEl?.textContent || 'NORMAL';
const modeLabel = modeEl?.textContent || t(MODE_LABELS.normal);
const region = this._regionLabel(m.latDeg, m.lonDeg);
const nearest = this._nearestKnownPoint(m.latDeg, m.lonDeg);
const band = this._viewBand(m.altM);
@@ -649,20 +728,41 @@ export class IntelHUD {
// together, so they must never disagree. The view band above deliberately
// keeps the ellipsoidal height: its thresholds were tuned against it.
const altDisplayM = Number.isFinite(m.altMslM) ? m.altMslM : m.altM;
// Display only: the AI summary request carries its own context, so this
// line is built in the active language.
const altTag =
altDisplayM >= 1000
? `${(altDisplayM / 1000).toFixed(1)}KM`
: `${Math.round(altDisplayM)}M`;
? hudQuantity(altDisplayM / 1000, 'km', 1)
: hudQuantity(Math.round(altDisplayM), 'm');
const winTag = window
? `${Math.max(1, Math.round(window.widthKm))}x${Math.max(1, Math.round(window.heightKm))}KM`
: 'N/A';
? `${hudQuantity(Math.max(1, Math.round(window.widthKm)), '')}x${hudQuantity(Math.max(1, Math.round(window.heightKm)), 'km')}`
: t('N/A');
// NEAR the nearest catalogued POI at metro range; otherwise the lat/lon sector.
const localityTag = composeLocalityTag(nearest, m.latDeg, m.lonDeg);
const localityTag = composeLocalityTag(
nearest,
m.latDeg,
m.lonDeg,
LOCALIZED_LOCALITY,
);
const provenance = hudTelemetryProvenanceTag(
this._dataManager?.getAll?.() || [],
const provenance = localizedProvenanceTag(
hudTelemetryProvenance(this._dataManager?.getAll?.() || []),
);
// ALT, SUN and ONA are sensor codes, as in the corner readouts.
const line = t(
'{mode} {band} {locality} | {region} | ALT {alt} | WINDOW {window} | SUN {sun}° | ONA {ona}° | {zone}',
{
mode: modeLabel,
band: viewBandLabel(band),
locality: localityTag,
region: REGION_LABELS[region] ? t(REGION_LABELS[region]) : region,
alt: altTag,
window: winTag,
sun: formatNumber(Math.round(m.sunEl)),
ona: formatNumber(Math.round(m.ona)),
zone: localTag,
},
);
const line = `${modeLabel} ${band} ${localityTag} | ${region} | ALT ${altTag} | WINDOW ${winTag} | SUN ${m.sunEl.toFixed(0)}° | ONA ${m.ona.toFixed(0)}° | ${localTag}`;
return provenance ? `${line} | ${provenance}` : line;
}
@@ -818,8 +918,9 @@ export class IntelHUD {
// Update mode label
const modeEl = document.getElementById('hud-mode');
if (modeEl) {
const modeNames = { surveillance: 'NVG', thermal: 'FLIR', retro: 'CRT' };
modeEl.textContent = modeNames[styleName] || styleName.toUpperCase();
modeEl.textContent = MODE_LABELS[styleName]
? t(MODE_LABELS[styleName])
: styleName.toUpperCase();
}
// Update color scheme
const colors = HUD_COLORS[styleName] || HUD_COLORS._default;
+5 -3
View File
@@ -22,10 +22,12 @@ test('HUD sliders and location search have descriptive explicit names', () => {
});
test('the first-run checkbox keeps its native visible label', () => {
assert.match(html, /<label\b[^>]*class="first-run-suppress"[^>]*>\s*<input type="checkbox" data-first-run-suppress \/>\s*<span>Don't show this again<\/span>\s*<\/label>/);
// The span may carry a data-i18n extraction attribute; the native wrapping
// label contract is what this guards.
assert.match(html, /<label\b[^>]*class="first-run-suppress"[^>]*>\s*<input type="checkbox" data-first-run-suppress \/>\s*<span\b[^>]*>Don't show this again<\/span>\s*<\/label>/);
});
test('generated style sliders use the visible parameter label as their name', () => {
assert.match(parameters, /label\.textContent\s*=\s*metadata\.label;/);
assert.match(parameters, /slider\.setAttribute\(['"]aria-label['"],\s*metadata\.label\)/);
assert.match(parameters, /label\.textContent\s*=\s*t\(metadata\.label\);/);
assert.match(parameters, /slider\.setAttribute\(['"]aria-label['"],\s*t\(metadata\.label\)\)/);
});
+23 -3
View File
@@ -23,6 +23,14 @@
*/
export const NEAR_POI_MAX_KM = 150;
/** The English locality line; the HUD passes hudLocalityFormat.js instead. */
const ENGLISH_LOCALITY = Object.freeze({
near: ({ poi, city, distKm }) =>
`NEAR ${poi.toUpperCase()} (${city.toUpperCase()}) ${Math.round(distKm)}KM`,
sector: ({ latDeg, lonDeg }) =>
`SECTOR ${coordinateTag(latDeg, 'N', 'S')} ${coordinateTag(lonDeg, 'E', 'W')}`,
});
/**
* Format one hemisphere-tagged coordinate, e.g. `21.33N` / `157.80W`.
* @param {number} value Signed decimal degrees.
@@ -42,13 +50,25 @@ function coordinateTag(value, positive, negative) {
* catalogue is empty.
* @param {number} latDeg Camera latitude in decimal degrees.
* @param {number} lonDeg Camera longitude in decimal degrees.
* @param {{near: Function, sector: Function}} [format] Builds the line from
* `{ poi, city, distKm }` or `{ latDeg, lonDeg }`; names arrive as
* catalogued (`Tokyo`), not upper-cased.
* @returns {string} `NEAR <POI> (<CITY>) <N>KM` within the bound (inclusive),
* otherwise `SECTOR <lat> <lon>`.
*/
export function composeLocalityTag(nearest, latDeg, lonDeg) {
export function composeLocalityTag(
nearest,
latDeg,
lonDeg,
format = ENGLISH_LOCALITY,
) {
const distKm = Number(nearest?.distKm);
if (nearest && Number.isFinite(distKm) && distKm <= NEAR_POI_MAX_KM) {
return `NEAR ${String(nearest.poi).toUpperCase()} (${String(nearest.city).toUpperCase()}) ${Math.round(distKm)}KM`;
return format.near({
poi: String(nearest.poi),
city: String(nearest.city),
distKm,
});
}
return `SECTOR ${coordinateTag(latDeg, 'N', 'S')} ${coordinateTag(lonDeg, 'E', 'W')}`;
return format.sector({ latDeg, lonDeg });
}
+70 -9
View File
@@ -6,6 +6,8 @@ import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { composeLocalityTag, NEAR_POI_MAX_KM } from './hudLocality.js';
import { LOCALIZED_LOCALITY } from './hudLocalityFormat.js';
import { setLocale } from './i18n/index.js';
const ALCATRAZ = { poi: 'Alcatraz Island', city: 'San Francisco' };
const LINCOLN = { poi: 'Lincoln Memorial', city: 'Washington DC' };
@@ -17,7 +19,7 @@ test('the NEAR bound is metro scale, not continental', () => {
test('a landmark under the camera reads NEAR', () => {
assert.equal(
composeLocalityTag({ ...ALCATRAZ, distKm: 2.4 }, 37.8267, -122.4230),
composeLocalityTag({ ...ALCATRAZ, distKm: 2.4 }, 37.8267, -122.423),
'NEAR ALCATRAZ ISLAND (SAN FRANCISCO) 2KM',
);
assert.equal(
@@ -40,20 +42,42 @@ test('the field failures now fall through to the SECTOR readout', () => {
});
test('the boundary is pinned on both sides, inclusive at the bound', () => {
const at = composeLocalityTag({ ...LINCOLN, distKm: NEAR_POI_MAX_KM }, 40, -78);
assert.match(at, /^NEAR LINCOLN MEMORIAL/, 'exactly at the bound still reads NEAR');
const at = composeLocalityTag(
{ ...LINCOLN, distKm: NEAR_POI_MAX_KM },
40,
-78,
);
assert.match(
at,
/^NEAR LINCOLN MEMORIAL/,
'exactly at the bound still reads NEAR',
);
const just_under = composeLocalityTag({ ...LINCOLN, distKm: NEAR_POI_MAX_KM - 0.1 }, 40, -78);
const just_under = composeLocalityTag(
{ ...LINCOLN, distKm: NEAR_POI_MAX_KM - 0.1 },
40,
-78,
);
assert.match(just_under, /^NEAR LINCOLN MEMORIAL/);
const just_over = composeLocalityTag({ ...LINCOLN, distKm: NEAR_POI_MAX_KM + 0.1 }, 40, -78);
const just_over = composeLocalityTag(
{ ...LINCOLN, distKm: NEAR_POI_MAX_KM + 0.1 },
40,
-78,
);
assert.match(just_over, /^SECTOR /, 'one step past the bound falls through');
});
test('southern and western hemispheres carry the right suffixes', () => {
// Rio and Honolulu — the two the fallback already handled correctly in the field.
assert.equal(composeLocalityTag(null, -22.9068, -43.1729), 'SECTOR 22.91S 43.17W');
assert.equal(composeLocalityTag(null, 21.3069, -157.8583), 'SECTOR 21.31N 157.86W');
assert.equal(
composeLocalityTag(null, -22.9068, -43.1729),
'SECTOR 22.91S 43.17W',
);
assert.equal(
composeLocalityTag(null, 21.3069, -157.8583),
'SECTOR 21.31N 157.86W',
);
});
// The tests above all pass against a hud.js that still computes the tag inline —
@@ -72,7 +96,9 @@ test('hud.js actually composes its summary through this helper', () => {
'hud.js must import composeLocalityTag from ./hudLocality.js',
);
assert.equal(
has(/const localityTag = composeLocalityTag\(nearest, m\.latDeg, m\.lonDeg\);/),
has(
/const localityTag = composeLocalityTag\(\s*nearest,\s*m\.latDeg,\s*m\.lonDeg,\s*LOCALIZED_LOCALITY,?\s*\);/,
),
true,
'_composeSummary must build its locality tag through composeLocalityTag()',
);
@@ -91,6 +117,41 @@ test('hud.js actually composes its summary through this helper', () => {
test('a missing or malformed nearest POI never crashes the summary', () => {
assert.match(composeLocalityTag(null, 0, 0), /^SECTOR /);
assert.match(composeLocalityTag(undefined, 0, 0), /^SECTOR /);
assert.match(composeLocalityTag({ ...LINCOLN, distKm: NaN }, 10, 10), /^SECTOR /);
assert.match(
composeLocalityTag({ ...LINCOLN, distKm: NaN }, 10, 10),
/^SECTOR /,
);
assert.match(composeLocalityTag({ ...LINCOLN }, 10, 10), /^SECTOR /);
});
test('a formatter builds the line from the same parts', () => {
const format = {
near: ({ poi, city, distKm }) => `near:${poi}|${city}|${distKm}`,
sector: ({ latDeg, lonDeg }) => `sector:${latDeg}|${lonDeg}`,
};
assert.equal(
composeLocalityTag({ ...ALCATRAZ, distKm: 2.4 }, 37.8, -122.4, format),
'near:Alcatraz Island|San Francisco|2.4',
);
assert.equal(
composeLocalityTag(null, 21.3, -157.8, format),
'sector:21.3|-157.8',
);
});
test('the HUD formatter translates the catalogued city, then upper-cases it', () => {
const tokyo = { poi: 'Tokyo Tower', city: 'Tokyo', distKm: 2 };
assert.equal(
composeLocalityTag(tokyo, 35.66, 139.75, LOCALIZED_LOCALITY),
'NEAR TOKYO TOWER (TOKYO) 2KM',
);
setLocale('ru');
try {
assert.match(
composeLocalityTag(tokyo, 35.66, 139.75, LOCALIZED_LOCALITY),
/\(ТОКИО\)/,
);
} finally {
setLocale('en');
}
});
+41
View File
@@ -0,0 +1,41 @@
/**
* @module hudLocalityFormat
* @description The HUD summary's locality line in the active language — the
* formatter `hud.js` hands to `composeLocalityTag`. Kept apart from `hud.js`
* (which imports `mgrs`, not loadable under plain Node) so it is unit-testable.
*/
import { formatNumber, getLocale, t } from './i18n/index.js';
import { unitLabel } from './ui/unitLabels.js';
import { localizePlaceName } from './ui/placeNames.js';
/** An upper-case HUD quantity such as `13KM` / `13КМ`, in the active language. */
export function hudQuantity(value, unit, digits = 0) {
const number = formatNumber(value, {
useGrouping: false,
minimumFractionDigits: digits,
maximumFractionDigits: digits,
});
return `${number}${unitLabel(unit).toUpperCase()}`;
}
/** One hemisphere-tagged coordinate, e.g. `21.33N` / `21,33С`. */
function hudCoordinate(value, positive, negative) {
return `${hudQuantity(Math.abs(value), '', 2)}${value >= 0 ? positive : negative}`;
}
/** The summary's locality line in the active language (see hudLocality.js). */
export const LOCALIZED_LOCALITY = Object.freeze({
// Translate the catalogued name (`Tokyo`), then upper-case it in the page's
// language: the place-name table knows `Tokyo`, not `TOKYO`.
near: ({ poi, city, distKm }) =>
t('NEAR {poi} ({city}) {distance}', {
poi: poi.toLocaleUpperCase(getLocale()),
city: localizePlaceName(city).toLocaleUpperCase(getLocale()),
distance: hudQuantity(Math.round(distKm), 'km'),
}),
sector: ({ latDeg, lonDeg }) =>
t('SECTOR {lat} {lon}', {
lat: hudCoordinate(latDeg, t('N'), t('S')),
lon: hudCoordinate(lonDeg, t('E'), t('W')),
}),
});
+7 -5
View File
@@ -83,12 +83,14 @@ export function hudSummaryLayerContext(layers = [], options) {
}
/**
* Deterministic telemetry suffix when an enabled feed is not nominal.
* The feed state and up to two layer names for the HUD telemetry suffix when
* an enabled feed is not nominal. Values stay English (`state` is a feed-state
* id, `names` are `layer.name`); the HUD translates them for display.
* @param {Array<object>} [layers]
* @param {{ now?: number }} [options]
* @returns {string|null}
* @returns {{ state: string, names: string[] }|null}
*/
export function hudTelemetryProvenanceTag(layers = [], options) {
export function hudTelemetryProvenance(layers = [], options) {
const snapshots = layerSnapshots(layers, options).filter((s) => s.enabled);
const envelope = feedProvenanceEnvelope(snapshots, options);
if (!envelope.overall || envelope.overall === 'nominal') return null;
@@ -96,9 +98,9 @@ export function hudTelemetryProvenanceTag(layers = [], options) {
.filter(
(s) => s.feedState && s.feedState !== 'nominal' && s.feedState !== 'off',
)
.map((s) => String(s.name || s.id || 'LAYER').toUpperCase())
.map((s) => String(s.name || s.id || 'LAYER'))
.slice(0, 2);
return `${envelope.overall.toUpperCase()}${names.length ? ` ${names.join('/')}` : ''}`;
return { state: envelope.overall, names };
}
/** Require the supplied non-nominal state before showing an AI summary. */
+111 -39
View File
@@ -5,7 +5,7 @@ import {
HUD_SUMMARY_INSTRUCTIONS,
HUD_SUMMARY_UNCONFIGURED_CODE,
hudSummaryLayerContext,
hudTelemetryProvenanceTag,
hudTelemetryProvenance,
isHudSummaryUnconfigured,
keylessHudSummaryResponse,
} from './hudSummaryResponse.js';
@@ -30,7 +30,10 @@ function installOpenAiRoutes() {
return routes;
}
function invokeRoute(handler, { method = 'GET', url = '/', remoteAddress = '127.0.0.1' } = {}) {
function invokeRoute(
handler,
{ method = 'GET', url = '/', remoteAddress = '127.0.0.1' } = {},
) {
return new Promise((resolve, reject) => {
const headers = new Map();
const req = {
@@ -71,33 +74,54 @@ test('builds an HTTP-success capability response only for a blank key', () => {
test('recognizes only the exact deliberate no-key fallback response', () => {
assert.equal(isHudSummaryUnconfigured(200, UNCONFIGURED_PAYLOAD), true);
assert.equal(isHudSummaryUnconfigured(503, UNCONFIGURED_PAYLOAD), false);
assert.equal(isHudSummaryUnconfigured(200, {
...UNCONFIGURED_PAYLOAD,
error: 'provider failed',
}), false);
assert.equal(isHudSummaryUnconfigured(200, {
...UNCONFIGURED_PAYLOAD,
summary: 'Unexpected provider output',
}), false);
assert.equal(isHudSummaryUnconfigured(200, {
...UNCONFIGURED_PAYLOAD,
configured: true,
}), false);
assert.equal(isHudSummaryUnconfigured(200, {
...UNCONFIGURED_PAYLOAD,
unexpected: true,
}), false);
assert.equal(isHudSummaryUnconfigured(200, {
code: HUD_SUMMARY_UNCONFIGURED_CODE,
}), false);
assert.equal(
isHudSummaryUnconfigured(200, {
...UNCONFIGURED_PAYLOAD,
error: 'provider failed',
}),
false,
);
assert.equal(
isHudSummaryUnconfigured(200, {
...UNCONFIGURED_PAYLOAD,
summary: 'Unexpected provider output',
}),
false,
);
assert.equal(
isHudSummaryUnconfigured(200, {
...UNCONFIGURED_PAYLOAD,
configured: true,
}),
false,
);
assert.equal(
isHudSummaryUnconfigured(200, {
...UNCONFIGURED_PAYLOAD,
unexpected: true,
}),
false,
);
assert.equal(
isHudSummaryUnconfigured(200, {
code: HUD_SUMMARY_UNCONFIGURED_CODE,
}),
false,
);
});
test('does not hide real provider and HTTP failures', () => {
assert.equal(isHudSummaryUnconfigured(502, {
code: HUD_SUMMARY_UNCONFIGURED_CODE,
}), false);
assert.equal(
isHudSummaryUnconfigured(502, {
code: HUD_SUMMARY_UNCONFIGURED_CODE,
}),
false,
);
assert.equal(isHudSummaryUnconfigured(503, UNCONFIGURED_PAYLOAD), false);
assert.equal(isHudSummaryUnconfigured(200, { error: 'provider failed' }), false);
assert.equal(
isHudSummaryUnconfigured(200, { error: 'provider failed' }),
false,
);
});
test('the installed keyless HUD route stays successful after the voice quota is exhausted', async () => {
@@ -121,14 +145,18 @@ test('the installed keyless HUD route stays successful after the voice quota is
for (let attempt = 0; attempt < 2; attempt += 1) {
const response = await invokeRoute(hud, { method: 'POST' });
assert.equal(response.statusCode, 200);
assert.equal(response.headers['content-type'], 'application/json; charset=utf-8');
assert.equal(
response.headers['content-type'],
'application/json; charset=utf-8',
);
assert.equal(response.headers['cache-control'], 'no-store');
assert.deepEqual(response.body, UNCONFIGURED_PAYLOAD);
}
} finally {
if (previousKey === undefined) delete process.env.OPENAI_API_KEY;
else process.env.OPENAI_API_KEY = previousKey;
if (previousLimit === undefined) delete process.env.GEV_RATELIMIT_OPENAI_PER_MIN;
if (previousLimit === undefined)
delete process.env.GEV_RATELIMIT_OPENAI_PER_MIN;
else process.env.GEV_RATELIMIT_OPENAI_PER_MIN = previousLimit;
}
});
@@ -140,7 +168,12 @@ test('HUD summary context and telemetry tag carry chip-identical feed-state', ()
id: 'flights',
name: 'Live Flights',
enabled: true,
stats: { stale: true, count: 12, lastUpdate: now - 240_000, source: 'OpenSky Network' },
stats: {
stale: true,
count: 12,
lastUpdate: now - 240_000,
source: 'OpenSky Network',
},
},
{
id: 'earthquakes',
@@ -153,27 +186,66 @@ test('HUD summary context and telemetry tag carry chip-identical feed-state', ()
assert.deepEqual(context.enabledLayerLabels, ['Live Flights']);
assert.equal(context.enabledLayers[0].feedState, 'stale');
assert.equal(context.feedProvenance.overall, 'stale');
assert.equal(hudTelemetryProvenanceTag(layers, { now }), 'STALE LIVE FLIGHTS');
assert.equal(hudTelemetryProvenanceTag([{
id: 'flights', name: 'Live Flights', enabled: true, stats: { count: 4, lastUpdate: now },
}], { now }), null);
assert.deepEqual(hudTelemetryProvenance(layers, { now }), {
state: 'stale',
names: ['Live Flights'],
});
assert.equal(
hudTelemetryProvenance(
[
{
id: 'flights',
name: 'Live Flights',
enabled: true,
stats: { count: 4, lastUpdate: now },
},
],
{ now },
),
null,
);
});
test('HUD summary instructions require non-nominal feedState in the five words', () => {
assert.match(HUD_SUMMARY_INSTRUCTIONS, /five words MUST include that feedState token/);
assert.match(HUD_SUMMARY_INSTRUCTIONS, /STALE, DEGRADED, FALLBACK, LOADING, or UNAVAILABLE/);
assert.match(
HUD_SUMMARY_INSTRUCTIONS,
/five words MUST include that feedState token/,
);
assert.match(
HUD_SUMMARY_INSTRUCTIONS,
/STALE, DEGRADED, FALLBACK, LOADING, or UNAVAILABLE/,
);
assert.match(HUD_SUMMARY_INSTRUCTIONS, /feedProvenance/);
});
test('the HUD proxy uses the shared provenance instructions', () => {
const local = readFileSync(new URL('../server/providers/openai/hud-summary.js', import.meta.url), 'utf8');
const local = readFileSync(
new URL('../server/providers/openai/hud-summary.js', import.meta.url),
'utf8',
);
assert.match(local, /HUD_SUMMARY_INSTRUCTIONS/);
assert.doesNotMatch(local, /enabled-layer text labels/);
});
test('AI summaries missing a non-nominal provenance token fall back deterministically', async () => {
const { hudSummaryMatchesProvenance } = await import('./hudSummaryResponse.js');
assert.equal(hudSummaryMatchesProvenance('Austin flights operating normally today', { overall: 'stale' }), false);
assert.equal(hudSummaryMatchesProvenance('Austin stale flights over downtown', { overall: 'stale' }), true);
assert.equal(hudSummaryMatchesProvenance('Austin flights operating normally today', { overall: 'nominal' }), true);
const { hudSummaryMatchesProvenance } =
await import('./hudSummaryResponse.js');
assert.equal(
hudSummaryMatchesProvenance('Austin flights operating normally today', {
overall: 'stale',
}),
false,
);
assert.equal(
hudSummaryMatchesProvenance('Austin stale flights over downtown', {
overall: 'stale',
}),
true,
);
assert.equal(
hudSummaryMatchesProvenance('Austin flights operating normally today', {
overall: 'nominal',
}),
true,
);
});
+280
View File
@@ -0,0 +1,280 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
checkCatalog,
extractMessages,
findUnmarked,
readCatalog,
shippedLocales,
unmarkedMarkup,
unmarkedScript,
} from '../../scripts/i18n.mjs';
import { LOCALES } from './locale.js';
const messages = extractMessages();
test('every catalog file is a shipped locale and every non-English locale has a catalog', () => {
assert.deepEqual(
shippedLocales().sort(),
LOCALES.filter((locale) => locale !== 'en').sort(),
);
});
for (const locale of shippedLocales()) {
test(`${locale} catalog matches the code (npm run i18n)`, () => {
const { missing, stale, errors } = checkCatalog(
locale,
readCatalog(locale),
messages,
);
assert.deepEqual(
{ missing, stale, errors },
{ missing: [], stale: [], errors: [] },
);
});
}
test('every UI string is marked for translation or kept on purpose (npm run i18n)', () => {
assert.deepEqual(
findUnmarked().map(({ where, text }) => `${where} ${text}`),
[],
);
});
test('checkCatalog reports missing, stale, placeholder and plural problems', () => {
const fixture = new Map([
['Saved {name}', { plural: false }],
['{count} keys', { plural: true, one: '{count} key' }],
['Untranslated', { plural: false }],
]);
const result = checkCatalog(
'ru',
{
'Saved {name}': 'Сохранено {nmae}',
'{count} keys': {
one: '{count} ключ',
few: '{count} ключа',
many: '{count} ключей',
},
Removed: 'Удалено',
},
fixture,
);
assert.deepEqual(result.missing, ['Untranslated']);
assert.deepEqual(result.stale, ['Removed']);
assert.equal(result.errors.length, 2);
});
test('checkCatalog reports a translation left in English unless it is allowed', () => {
const fixture = new Map([
['Bloom', { plural: false }],
['HDG', { plural: false }],
['voice|READY', { plural: false }],
['{state} · {source}', { plural: false }],
]);
const catalog = {
Bloom: 'Bloom',
HDG: 'HDG',
'voice|READY': 'READY',
'{state} · {source}': '{state} · {source}',
};
const errors = (allowed) =>
checkCatalog('es', catalog, fixture, new Set(allowed)).errors;
assert.deepEqual(
errors([]).map((error) => error.split(' is still English')[0]),
['"Bloom"', '"HDG"', '"voice|READY"'],
);
assert.deepEqual(errors(['Bloom', 'HDG', 'voice|READY']), []);
});
test('unmarkedMarkup finds text and attributes without data-i18n*', () => {
const texts = (html) => unmarkedMarkup('f.html', html).map((f) => f.text);
assert.deepEqual(texts('<span>Close</span>'), ['Close']);
assert.deepEqual(texts('<span data-i18n>Close</span>'), []);
assert.deepEqual(texts('<button title="Pin it">x</button>'), [
'title="Pin it"',
]);
assert.deepEqual(
texts('<button title="Pin it" data-i18n-title>x</button>'),
[],
);
assert.deepEqual(
texts('<select translate="no"><option>0.9 dB</option></select>'),
[],
);
assert.deepEqual(texts('<b data-i18n-runtime><i>OFF</i></b>'), []);
assert.deepEqual(
texts('<span class="material-symbols-outlined">public</span>'),
[],
);
assert.deepEqual(texts('<!-- Note --><span>— 42 —</span>'), []);
});
test('unmarkedScript finds literals that reach the page without t/tn/msg', () => {
const texts = (js) => unmarkedScript('f.js', js).map((f) => f.text);
assert.deepEqual(texts("el.textContent = 'Saved';"), ['Saved']);
assert.deepEqual(texts("el.textContent = t('Saved');"), []);
assert.deepEqual(texts("el.textContent = on ? 'On air' : 'Off air';"), [
'On air',
'Off air',
]);
assert.deepEqual(texts("el.setAttribute('aria-label', `Open ${name}`);"), [
'Open',
]);
assert.deepEqual(
texts("const row = { label: msg('Wind'), id: 'wind' };"),
[],
);
assert.deepEqual(texts("const row = { label: 'Wind' };"), ['Wind']);
assert.deepEqual(texts("el.textContent = labelFor('flights');"), []);
assert.deepEqual(texts("el.className = 'is-active';"), []);
assert.deepEqual(texts("el.title = mode === 'Auto mode' ? a : b;"), []);
assert.deepEqual(texts("// el.textContent = 'Old copy';"), []);
assert.deepEqual(
texts("// i18n-ignore: product name\nel.textContent = 'OpenSky';"),
[],
);
});
test('unmarkedScript sees toasts behind optional calls and private helpers', () => {
const texts = (js) => unmarkedScript('f.js', js).map((f) => f.text);
assert.deepEqual(texts("showToast?.('North up');"), ['North up']);
assert.deepEqual(texts("this._showToast('Search failed');"), [
'Search failed',
]);
assert.deepEqual(texts("this.showToast('Link copied');"), ['Link copied']);
assert.deepEqual(texts("this.actions.showToast?.('Tilted view');"), [
'Tilted view',
]);
assert.deepEqual(texts("showToast?.(t('North up'));"), []);
assert.deepEqual(texts("this._showToast(t('Search failed'));"), []);
});
test('unmarkedScript sees status text assigned to layer errors and labels', () => {
const texts = (js) => unmarkedScript('f.js', js).map((f) => f.text);
assert.deepEqual(texts("layerState._error = 'live feed unavailable';"), [
'live feed unavailable',
]);
assert.deepEqual(
texts("this._error = err?.message || 'TeleGeography load failed';"),
['TeleGeography load failed'],
);
assert.deepEqual(texts("_error = 'Route request failed';"), [
'Route request failed',
]);
assert.deepEqual(texts("error ??= 'Feed closed';"), ['Feed closed']);
assert.deepEqual(texts('stats.error = `${name} fetch error`;'), [
'fetch error',
]);
assert.deepEqual(texts("loadingLabel = 'loading coverage...';"), [
'loading coverage...',
]);
assert.deepEqual(texts("state._loadingLabel = 'Loading cables';"), [
'Loading cables',
]);
assert.deepEqual(texts("stats.statusMessage = 'Add a key in Settings';"), [
'Add a key in Settings',
]);
assert.deepEqual(texts("this.status = 'Scene not saved';"), [
'Scene not saved',
]);
assert.deepEqual(
texts("layerState._error = msg('live feed unavailable');"),
[],
);
assert.deepEqual(texts("stats.error = t('GBFS fetch error');"), []);
// Machine values, comparisons and local scratch variables are not UI text.
assert.deepEqual(texts("status = 'missing-key';"), []);
assert.deepEqual(texts("if (error === 'Feed closed') stop();"), []);
assert.deepEqual(texts("const error = log() || 'Unknown shader error';"), []);
assert.deepEqual(texts('stats.error = new Error(`HTTP ${code}`);'), []);
});
test('unmarkedScript sees human-readable status, loadingLabel and statusMessage fields', () => {
const texts = (js) => unmarkedScript('f.js', js).map((f) => f.text);
assert.deepEqual(
texts("return { status: 'Scene not saved — storage unavailable' };"),
['Scene not saved — storage unavailable'],
);
assert.deepEqual(
texts("return { status: ok ? 'Ready' : 'Source unavailable' };"),
['Ready', 'Source unavailable'],
);
assert.deepEqual(texts("return { loadingLabel: 'Loading feeds' };"), [
'Loading feeds',
]);
assert.deepEqual(texts("return { statusMessage: 'connect a receiver' };"), [
'connect a receiver',
]);
assert.deepEqual(texts("return { status: 'streaming', code: 'ok' };"), []);
assert.deepEqual(texts("return { status: 'auth-failed' };"), []);
assert.deepEqual(texts("return { status: msg('Source unavailable') };"), []);
});
// The Layers panel shows `stats.error || stats.lastError`, so a
// lastError is status text like error.
test('unmarkedScript sees status text assigned to lastError', () => {
const texts = (js) => unmarkedScript('f.js', js).map((f) => f.text);
assert.deepEqual(
texts(
"state.coverage.lastError = error?.message || 'Coverage tile failed';",
),
['Coverage tile failed'],
);
assert.deepEqual(texts("layerState._lastError = 'CelesTrak unreachable';"), [
'CelesTrak unreachable',
]);
assert.deepEqual(
texts(
"state.coverage.lastError = error?.message || msg('Coverage tile failed');",
),
[],
);
assert.deepEqual(texts('this._lastError = message;'), []);
});
test('unmarkedScript skips default values and ternary branches, not later assignments', () => {
const texts = (js) => unmarkedScript('f.js', js).map((f) => f.text);
// A default value in a parameter list or destructuring is a local's.
assert.deepEqual(texts("function f(error = 'Feed went away') {}"), []);
assert.deepEqual(texts("const f = (status = 'Not ready', x) => x;"), []);
assert.deepEqual(
texts("function f(\n status = 'Not ready',\n x,\n) {}"),
[],
);
assert.deepEqual(texts("const { status = 'Ready now' } = options;"), []);
assert.deepEqual(
texts("const {\n error = 'No data yet',\n} = options;"),
[],
);
assert.deepEqual(
texts("for (const [id, status = 'Waiting'] of rows) {}"),
[],
);
// A name right after `?` is a ternary branch, not a `status:` property.
assert.deepEqual(texts("const s = ok ? status : 'Source unavailable';"), []);
assert.deepEqual(texts("const s = ok\n ? title\n : 'Untitled view';"), []);
// …while the property itself still is.
assert.deepEqual(texts("return { title: x ? title : 'Untitled view' };"), [
'Untitled view',
]);
// A later assignment is checked even when the name is a declared local:
// a layer keeps its status in `let error` (wind, AIS watchdog) and builds
// `loadingLabel` in a `let` before returning it (FIRMS).
assert.deepEqual(
texts(
"function f() {\n let error = null;\n if (x) error = 'Feed closed';\n}",
),
['Feed closed'],
);
assert.deepEqual(
texts(
"let loadingLabel = '';\nif (x) {\n loadingLabel = 'KEY REQUIRED';\n}",
),
['KEY REQUIRED'],
);
assert.deepEqual(texts("let a = 1,\n error = 'Feed closed';"), [
'Feed closed',
]);
});
+62
View File
@@ -0,0 +1,62 @@
// 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');
}
});
+252
View File
@@ -0,0 +1,252 @@
import { test, afterEach } from 'node:test';
import assert from 'node:assert/strict';
import {
applyDocumentTranslations,
formatDate,
formatNumber,
msg,
persistLocaleAndReload,
setLocale,
t,
tn,
} from './index.js';
import {
LOCALE_STORAGE_KEY,
applyDocumentLanguage,
normalizeLocale,
resolveLocale,
writeStoredLocale,
} from './locale.js';
afterEach(() => setLocale('en'));
function memoryStorage(initial = {}) {
const data = new Map(Object.entries(initial));
return {
getItem: (key) => (data.has(key) ? data.get(key) : null),
setItem: (key, value) => data.set(key, String(value)),
data,
};
}
test('normalizeLocale folds regional variants and rejects unshipped tags', () => {
assert.equal(normalizeLocale('ru-RU'), 'ru');
assert.equal(normalizeLocale(' RU '), 'ru');
assert.equal(normalizeLocale('en_GB'), 'en');
assert.equal(normalizeLocale('xx'), null);
assert.equal(normalizeLocale(undefined), null);
});
test('resolveLocale: ?lang= wins, then the stored choice, then browser languages, then English', () => {
const storage = memoryStorage({ [LOCALE_STORAGE_KEY]: 'ru' });
assert.equal(
resolveLocale({
location: { search: '?lang=en' },
storage,
languages: ['ru'],
}),
'en',
);
assert.equal(
resolveLocale({ location: { search: '' }, storage, languages: ['en'] }),
'ru',
);
assert.equal(
resolveLocale({
location: { search: '?lang=xx' },
storage: memoryStorage(),
languages: ['de-DE', 'ru-RU'],
}),
'ru',
);
assert.equal(
resolveLocale({
location: { search: '' },
storage: memoryStorage(),
languages: ['de'],
}),
'en',
);
});
test('a throwing storage never escapes', () => {
const storage = {
getItem() {
throw new Error('blocked');
},
setItem() {
throw new Error('blocked');
},
};
assert.equal(
resolveLocale({ location: { search: '' }, storage, languages: [] }),
'en',
);
assert.equal(writeStoredLocale('ru', storage), false);
});
test('applyDocumentLanguage sets lang and dir', () => {
const doc = { documentElement: {} };
applyDocumentLanguage(doc, 'ru-RU');
assert.deepEqual(doc.documentElement, { lang: 'ru', dir: 'ltr' });
});
test('t() returns English text in English and translates in Russian', () => {
assert.equal(t('POWERED UP'), 'POWERED UP');
setLocale('ru');
assert.notEqual(t('POWERED UP'), 'POWERED UP');
});
test('t() interpolates named values and keeps unknown names literal', () => {
assert.equal(
t('Save failed: {detail}', { detail: 'disk full' }),
'Save failed: disk full',
);
assert.equal(
t('Hello {who} {missing}', { who: 'pilot' }),
'Hello pilot {missing}',
);
});
test('t() falls back to English for text without a translation', () => {
setLocale('ru');
assert.equal(t('No such message {n}', { n: 1 }), 'No such message 1');
});
test('tn() picks English and Russian plural forms', () => {
const label = (count) =>
tn(
count,
'POWER UP · {count} KEY WAITING',
'POWER UP · {count} KEYS WAITING',
);
assert.equal(label(1), 'POWER UP · 1 KEY WAITING');
assert.equal(label(3), 'POWER UP · 3 KEYS WAITING');
setLocale('ru');
assert.match(label(1), / 1 КЛЮЧ(?![А-ЯЁ])/);
assert.match(label(3), / 3 КЛЮЧА(?![А-ЯЁ])/);
assert.match(label(5), / 5 КЛЮЧЕЙ(?![А-ЯЁ])/);
assert.match(label(21), / 21 КЛЮЧ(?![А-ЯЁ])/);
});
test('t() context separates identical English with different meanings', () => {
assert.equal(t('CLEAR', { context: 'action' }), 'CLEAR');
setLocale('ru');
assert.notEqual(t('CLEAR', { context: 'action' }), t('CLEAR'));
});
test('msg() returns its text unchanged', () => {
assert.equal(msg('Live Flights'), 'Live Flights');
});
test('Intl formatters follow the active locale', () => {
const date = new Date(Date.UTC(2026, 0, 15));
const options = {
year: 'numeric',
month: 'long',
day: 'numeric',
timeZone: 'UTC',
};
assert.equal(formatDate(date, options), 'January 15, 2026');
assert.equal(formatNumber(1234.5), '1,234.5');
setLocale('ru');
assert.equal(formatDate(date, options), '15 января 2026 г.');
assert.equal(formatNumber(1234.5), '1 234,5');
});
function fakeElement({ text = '', attributes = {} } = {}) {
const attrs = new Map(Object.entries(attributes));
return {
textContent: text,
getAttribute: (name) => (attrs.has(name) ? attrs.get(name) : null),
setAttribute: (name, value) => attrs.set(name, value),
attrs,
};
}
test('applyDocumentTranslations translates opted-in text and attributes in place', () => {
const label = fakeElement({ text: '\n POWERED\n UP ' });
const pin = fakeElement({ attributes: { title: 'POWERED UP' } });
const unknown = fakeElement({ text: 'No such message' });
const doc = {
querySelectorAll(selector) {
if (selector === '[data-i18n]') return [label, unknown];
if (selector === '[data-i18n-title]') return [pin];
return [];
},
};
assert.equal(
applyDocumentTranslations(doc),
0,
'English leaves markup alone',
);
setLocale('ru');
assert.equal(applyDocumentTranslations(doc), 2);
assert.equal(label.textContent, t('POWERED UP'));
assert.equal(pin.attrs.get('title'), t('POWERED UP'));
assert.equal(unknown.textContent, 'No such message');
});
test('persistLocaleAndReload keeps the choice in ?lang when storage is blocked', () => {
const calls = [];
const location = {
href: 'https://gev.local/?x=1#scene=a',
assign: (url) => calls.push(['assign', url]),
reload: () => calls.push(['reload']),
};
const history = {
replaceState: (_s, _t, url) => calls.push(['replace', url]),
};
const storage = {
setItem() {
throw new Error('blocked');
},
};
assert.equal(
persistLocaleAndReload('ru', { location, history, storage }),
true,
);
assert.deepEqual(calls, [
['replace', 'https://gev.local/?x=1&lang=ru#scene=a'],
['reload'],
]);
});
test('persistLocaleAndReload stores the choice, keeps the hash, strips ?lang', () => {
const storage = memoryStorage();
const calls = [];
const location = {
href: 'https://gev.local/?lang=en&x=1#scene=a',
assign: (url) => calls.push(['assign', url]),
reload: () => calls.push(['reload']),
};
const history = {
replaceState: (_s, _t, url) => calls.push(['replace', url]),
};
assert.equal(
persistLocaleAndReload('ru', { location, history, storage }),
true,
);
assert.equal(storage.data.get(LOCALE_STORAGE_KEY), 'ru');
assert.deepEqual(calls, [
['replace', 'https://gev.local/?x=1#scene=a'],
['reload'],
]);
});
test('tn() shows a caller-formatted count and still picks the form by number', () => {
const label = (n) =>
tn(n, '{count} map object', '{count} map objects', {
count: formatNumber(n),
});
assert.equal(label(12345), '12,345 map objects');
assert.equal(tn(12345, '{count} file', '{count} files'), '12345 files');
setLocale('ru');
try {
assert.equal(label(12345), '12 345 объектов на карте');
assert.equal(label(1), '1 объект на карте');
} finally {
setLocale('en');
}
});
+330
View File
@@ -0,0 +1,330 @@
// God's Eye View i18n: the English source text is the message key.
//
// t('Save keys') → 'Сохранить ключи'
// t('Cleared {name}', { name }) → named interpolation
// tn(count, '{count} camera', '{count} cameras') → plural by Intl.PluralRules
// t('CLEAR', { context: 'action' }) → same English, own entry
// msg('Live Flights') → marks a string kept in data
// for a later t(); returns it as is
//
// 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 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,
normalizeLocale,
writeStoredLocale,
} from './locale.js';
import es from './locales/es.json' with { type: 'json' };
import ru from './locales/ru.json' with { type: 'json' };
const CATALOGS = Object.freeze({ es, ru });
let currentLocale = DEFAULT_LOCALE;
/** The catalog for a locale; null for English and unknown codes. */
export function getCatalog(locale) {
return CATALOGS[normalizeLocale(locale)] ?? null;
}
/** Active locale. */
export function getLocale() {
return currentLocale;
}
/**
* Switch the active locale. Pure state change: call applyDocumentLanguage /
* applyDocumentTranslations to reflect it in the DOM.
* @returns {string} The locale actually applied (normalized).
*/
export function setLocale(locale) {
const normalized = normalizeLocale(locale) || DEFAULT_LOCALE;
if (normalized !== currentLocale) {
currentLocale = normalized;
formatterCache.clear();
}
return currentLocale;
}
/** Development-only warning; silent under node:test and in production. */
function warnMissing(text) {
if (import.meta.env?.DEV !== true) return;
console.warn(
`[i18n] no ${currentLocale} translation for ${JSON.stringify(text)}`,
);
}
const PLACEHOLDER_PATTERN = /\{([A-Za-z0-9_]+)\}/g;
/** Named {placeholder} interpolation. Unknown names stay literal so a gap
* stays visible instead of silently vanishing. */
function interpolate(pattern, values) {
if (!values) return pattern;
return pattern.replace(PLACEHOLDER_PATTERN, (literal, name) =>
Object.hasOwn(values, name) ? String(values[name]) : literal,
);
}
/** 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;
const entry = Object.hasOwn(catalog, text) ? catalog[text] : undefined;
if (filled(entry) || (entry && typeof entry === 'object')) return entry;
warnMissing(text);
return undefined;
}
/**
* Translate English source text into the active locale.
* @param {string} text English text, e.g. 'Hold Space to talk'
* @param {object} [values] Named interpolation values. `context` tells
* apart identical English with different meanings: t('CLEAR', { context:
* 'action' }) is the catalog entry "action|CLEAR"; English is unchanged.
* @returns {string}
*/
export function t(text, values) {
const entry = lookup(values?.context ? `${values.context}|${text}` : text);
return interpolate(typeof entry === 'string' ? entry : text, values);
}
/**
* Translate a count-dependent message. The catalog entry for `other` holds
* one form per Intl.PluralRules category of the locale ({ one, few, many, other }
* for Russian). `{count}` is always available to the pattern: the raw number,
* unless `values.count` gives its display text, e.g. `formatNumber(n)`.
* @param {number} count
* @param {string} one English singular, e.g. '{count} camera'
* @param {string} other English plural, e.g. '{count} cameras'
* @param {object} [values] Extra interpolation values; a `count` here is
* shown in place of the number (the plural form still follows `count`)
* @returns {string}
*/
export function tn(count, one, other, values) {
const entry = lookup(other);
const category = pluralRules(currentLocale).select(count);
let pattern;
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;
}
return interpolate(pattern, { count, ...values });
}
/**
* Translate text that may be ours or may come from a provider: a catalog
* entry is used when there is one, anything else shows as given, without a
* missing-translation warning.
*/
export function translateKnown(text) {
const catalog = CATALOGS[currentLocale];
if (!catalog || typeof text !== 'string') return 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(). */
export function msg(text) {
return text;
}
const pluralRulesCache = new Map();
function pluralRules(locale) {
let rules = pluralRulesCache.get(locale);
if (!rules) {
rules = new Intl.PluralRules(locale);
pluralRulesCache.set(locale, rules);
}
return rules;
}
/*
* Intl formatters are memoized per (kind, locale, options): the constructors
* are expensive and panels format on refresh paths. The cache is cleared
* whenever the active locale changes.
*/
const formatterCache = new Map();
function cachedFormatter(kind, Constructor, options) {
const cacheKey = `${kind}|${currentLocale}|${options ? JSON.stringify(options) : ''}`;
let formatter = formatterCache.get(cacheKey);
if (!formatter) {
formatter = new Constructor(currentLocale, options);
formatterCache.set(cacheKey, formatter);
}
return formatter;
}
/** Locale-aware number formatting (Intl.NumberFormat). */
export function formatNumber(value, options) {
return cachedFormatter('number', Intl.NumberFormat, options).format(value);
}
/** Locale-aware date/time formatting (Intl.DateTimeFormat). */
export function formatDate(value, options) {
return cachedFormatter('date', Intl.DateTimeFormat, options).format(value);
}
const UTC_DAY_MONTH = Object.freeze({
day: '2-digit',
month: '2-digit',
timeZone: 'UTC',
});
/**
* The UTC day and month of a time, for "MM-DD HH:MM UTC" readouts: "10-13" in
* English, "13.10" in Russian, "13/10" in Spanish, never read as a
* range. Takes an ISO string (its date part is used as written) or a time.
* @param {string|number|Date} value
* @returns {string} '' when the value is not a time.
*/
export function formatUtcDayMonth(value) {
const written =
typeof value === 'string' ? /^(\d{4})-(\d{2})-(\d{2})/.exec(value) : null;
const time = written
? Date.UTC(Number(written[1]), Number(written[2]) - 1, Number(written[3]))
: new Date(value).getTime();
if (!Number.isFinite(time)) return '';
if (currentLocale === 'en') return new Date(time).toISOString().slice(5, 10);
return cachedFormatter('date', Intl.DateTimeFormat, UTC_DAY_MONTH).format(
time,
);
}
/**
* Locale-aware relative time (Intl.RelativeTimeFormat), e.g.
* formatRelativeTime(-5, 'minute') → '5 minutes ago' / '5 минут назад'.
*/
export function formatRelativeTime(value, unit = 'second', options) {
return cachedFormatter('relative', Intl.RelativeTimeFormat, {
numeric: 'auto',
...options,
}).format(value, unit);
}
/** Collapse markup whitespace so a message reads the same in HTML and JS. */
export function normalizeMarkupText(text) {
return String(text ?? '')
.replace(/\s+/g, ' ')
.trim();
}
/**
* Markup opt-in: the element's own English text or attribute is the source.
* <span data-i18n>Normal</span>
* <button title="Pin" data-i18n-title>…</button>
* Also data-i18n-aria-label and data-i18n-placeholder.
*/
const DOCUMENT_TRANSLATION_TARGETS = Object.freeze([
['data-i18n', null],
['data-i18n-title', 'title'],
['data-i18n-aria-label', 'aria-label'],
['data-i18n-placeholder', 'placeholder'],
]);
/**
* Translate every opted-in element in `doc`. Runs once at boot; switching
* locale reloads the page.
* @param {Document|Element} doc
* @returns {number} Count of element/attribute writes performed.
*/
export function applyDocumentTranslations(doc) {
if (typeof doc?.querySelectorAll !== 'function') return 0;
if (!CATALOGS[currentLocale]) return 0;
let applied = 0;
for (const [marker, attribute] of DOCUMENT_TRANSLATION_TARGETS) {
for (const element of doc.querySelectorAll(`[${marker}]`)) {
const source = attribute
? element.getAttribute(attribute)
: normalizeMarkupText(element.textContent);
if (!source) continue;
const text = t(source);
if (text === source) continue;
if (attribute) element.setAttribute(attribute, text);
else element.textContent = text;
applied += 1;
}
}
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 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(
locale,
{
location = globalThis.location,
history = globalThis.history,
storage,
} = {},
) {
const normalized = normalizeLocale(locale) || DEFAULT_LOCALE;
const stored = writeStoredLocale(normalized, storage);
if (!location || typeof location.assign !== 'function') return false;
let target = location.href;
try {
// Without storage the choice only survives in the URL, so keep it there.
const url = new URL(location.href);
if (stored) url.searchParams.delete('lang');
else url.searchParams.set('lang', normalized);
target = url.href;
} catch {
// Keep the current href.
}
// location.assign() to the identical URL does not navigate, so strip ?lang
// in place and force a real reload instead.
try {
history?.replaceState?.(null, '', target);
} catch {
// replaceState is best-effort; the reload below still applies.
}
if (typeof location.reload === 'function') {
location.reload();
} else {
location.assign(target);
}
return true;
}
+60
View File
@@ -0,0 +1,60 @@
// KartaView texts are English data (msg() in providers/kartaview/) shown
// through translateKnown, so each needs an entry in every 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');
}
}
});
+65
View File
@@ -0,0 +1,65 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { setLocale, translateKnown } from './index.js';
// Layers keep these status texts as English data, and the panels show them
// through translateKnown, so each needs an entry in every catalog.
const SITES = [
['../layers/streetLevel/index.js', 'loading coverage...'],
['../layers/firms/ingestion.js', 'live feed unavailable'],
['../layers/bikeshare/controls.js', 'GBFS fetch error'],
[
'../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/fields.js', 'Temperature field unavailable'],
['../layers/wind/fields.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 not found in ${file}`);
setLocale(locale);
try {
assert.notEqual(translateKnown(text), text);
} finally {
setLocale('en');
}
});
}
}
+88
View File
@@ -0,0 +1,88 @@
// Which locale the page runs in: ?lang= → stored choice → browser languages →
// English. Only shipped locales are ever selected.
/** Durable preference key. Matches the repo convention gev:<name>:v1. */
export const LOCALE_STORAGE_KEY = 'gev:locale:v1';
/** Source language and fallback. */
export const DEFAULT_LOCALE = 'en';
/** Shipped locales; order does not matter. Adding one: docs/TRANSLATORS.md. */
export const LOCALES = Object.freeze(['en', 'es', 'ru']);
/** Each locale named in its own language, for the selector. */
export const LOCALE_NAMES = Object.freeze({
en: 'English',
es: 'Español',
ru: 'Русский',
});
/** Right-to-left locales. */
const RTL_LOCALES = Object.freeze([]);
/**
* Map any language tag to a shipped locale: 'ru-RU' and 'RU' → 'ru'.
* @returns {string|null}
*/
export function normalizeLocale(candidate) {
if (typeof candidate !== 'string') return null;
const primary = candidate.trim().toLowerCase().split(/[-_]/)[0];
return LOCALES.includes(primary) ? primary : null;
}
function queryLocale(location) {
try {
return normalizeLocale(new URLSearchParams(location?.search).get('lang'));
} catch {
return null;
}
}
function readStoredLocale(storage) {
try {
const store = storage !== undefined ? storage : globalThis.localStorage;
return normalizeLocale(store?.getItem?.(LOCALE_STORAGE_KEY));
} catch {
return null;
}
}
/** Store the choice; false when storage is unavailable. */
export function writeStoredLocale(locale, storage) {
const normalized = normalizeLocale(locale) || DEFAULT_LOCALE;
try {
const store = storage !== undefined ? storage : globalThis.localStorage;
if (typeof store?.setItem !== 'function') return false;
store.setItem(LOCALE_STORAGE_KEY, normalized);
return true;
} catch {
return false;
}
}
/** Resolve the boot locale. Every input is injectable for tests. */
export function resolveLocale({
location = globalThis.location,
storage,
languages = globalThis.navigator?.languages,
} = {}) {
const fromQuery = queryLocale(location);
if (fromQuery) return fromQuery;
const stored = readStoredLocale(storage);
if (stored) return stored;
for (const candidate of Array.isArray(languages) ? languages : []) {
const normalized = normalizeLocale(candidate);
if (normalized) return normalized;
}
return DEFAULT_LOCALE;
}
/** Set <html lang> and <html dir>. */
export function applyDocumentLanguage(doc, locale = DEFAULT_LOCALE) {
const root = doc?.documentElement;
if (!root) return false;
const normalized = normalizeLocale(locale) || DEFAULT_LOCALE;
root.lang = normalized;
root.dir = RTL_LOCALES.includes(normalized) ? 'rtl' : 'ltr';
return true;
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+7
View File
@@ -0,0 +1,7 @@
// msg() for modules the server, tools or other portable code also import:
// marks English that stays in data for `npm run i18n`, without loading the
// catalogs (src/i18n/index.js). The UI shows it later through t().
// Browser-only modules import msg from src/i18n/index.js instead.
/** Mark English text that is stored in data and translated later by t(). */
export const msg = (text) => text;
+89
View File
@@ -0,0 +1,89 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
checkCatalog,
extractMessages,
missingTemplate,
} from '../../scripts/i18n.mjs';
// A plural entry with forms left in English is reported like a string entry.
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)}`,
);
});
}
// The `--missing` template, pasted without edits, must fail entry by entry,
// and its plural entries carry every form the language needs.
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, []);
});
}
+137
View File
@@ -0,0 +1,137 @@
{
"es": [
"1090 MHz",
"978 MHz UAT",
"AIS: --",
"AIS: {name}",
"ANIME",
"ASIA",
"AUTO",
"Ambient",
"Anime",
"Austin",
"BRG {value}°",
"BRG —",
"Blues",
"CAL {value}",
"CAL · {badge}",
"CCTV",
"CRS {value}°",
"CRT",
"Country",
"DEST ---°",
"DEST {bearing}",
"Dance",
"ENE",
"ERROR",
"ESE",
"Error: {message}",
"FLIR",
"FLT",
"FM MHZ",
"FOV",
"FOV {value}°",
"FT",
"Folk",
"Funk",
"GEO",
"GOOGLE STREET VIEW",
"Global",
"Global · 60°S–60°N",
"Google Street View",
"HDG",
"HDG {value}°",
"Hip-Hop",
"House",
"Indie",
"Ironbow",
"Jazz",
"LOCAL",
"Landsat 8/9",
"MIC",
"MIL",
"MINI",
"MM",
"MONITOR",
"MSG/S",
"Metal",
"NAV",
"NE",
"NNE",
"NOIR",
"NORMAL",
"NVG",
"Noir",
"Normal",
"POS: {time}Z",
"Plan",
"Pop",
"Punk",
"R&B",
"RADIO",
"RANGE",
"REC",
"Radio",
"Reggae",
"Rock",
"SAR",
"SE",
"SECTOR {lat} {lon}",
"SIG",
"SQUAWK {code} · {status}",
"SRC {value}",
"SSE",
"STD",
"San Francisco",
"Sector",
"Sonar",
"Soul",
"Techno",
"Trance",
"USB {rate} msg/s",
"VISUAL",
"VS",
"WHOT/BHOT",
"WX",
"audio {state}",
"dB",
"fire confidence|nominal",
"ft",
"hPa",
"installation class|BASE",
"km",
"km/h",
"min",
"mini",
"mph",
"nominal",
"panel",
"view band|GLOBAL",
"voice|ERROR",
"{rate} msg/s",
"{speed} kn",
"{type} SPD: {speed} HDG: {heading}",
"{value} KM",
"{value} KM/H",
"{value} KM/S",
"ΔE",
"ΔN",
"▲ {frp} MW"
],
"ru": [
"AIS: --",
"AIS: {name}",
"CCTV",
"CRT",
"FLIR",
"GEO",
"Ironbow",
"Landsat 8/9",
"NAV",
"NVG",
"R&B",
"REC",
"VS",
"WHOT/BHOT"
]
}
Loaded 100 of 655 files, more files were not shown because too many files have changed in this diff. Show more