mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
refactor(voice): isolate gemini live provider core
This commit is contained in:
commit
10916227cb
655 files changed
+45946
-26483
No files matched your search
@@ -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 },
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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 },
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Vendored
+3
-2
@@ -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 };
|
||||
|
||||
@@ -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}` : '.'),
|
||||
),
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -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'];
|
||||
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
+45
-11
@@ -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
@@ -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);
|
||||
|
||||
@@ -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
@@ -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';
|
||||
|
||||
@@ -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
@@ -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). */
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 [];
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
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() {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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
@@ -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>',
|
||||
},
|
||||
{
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
* GPU/MSDF renderer. The Canvas2D overlay (detection.js) imports these and
|
||||
* supplies the actual drawing surface.
|
||||
*/
|
||||
import { formatNumber, t } from '../i18n/index.js';
|
||||
|
||||
/** @constant {number} Max characters shown for a label's primary (id) line. */
|
||||
const MAX_PRIMARY = 18;
|
||||
@@ -30,14 +31,15 @@ export function formatFlightLevel(altitudeMeters) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a speed in knots as a short rounded label. Returns '' for
|
||||
* non-positive or non-finite speeds (declutters moored/unknown vessels).
|
||||
* Formats a speed in knots as a short rounded label in the UI language.
|
||||
* Returns '' for non-positive or non-finite speeds (declutters moored/unknown
|
||||
* vessels).
|
||||
* @param {number} knots - Speed over ground in knots.
|
||||
* @returns {string} e.g. '14 kn', or '' when not applicable.
|
||||
* @returns {string} e.g. '14 kn' (ru '14 уз'), or '' when not applicable.
|
||||
*/
|
||||
export function formatKnots(knots) {
|
||||
if (!Number.isFinite(knots) || knots <= 0) return '';
|
||||
return Math.round(knots) + ' kn';
|
||||
return t('{speed} kn', { speed: formatNumber(Math.round(knots)) });
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -192,3 +192,14 @@ test('transit bracket stroke centres have full pixel coverage at the failing fra
|
||||
for(const [x,y] of points){assert.equal(x%1,0.5);assert.equal(y%1,0.5);}
|
||||
}
|
||||
});
|
||||
|
||||
test('formatKnots writes the unit in the UI language', async () => {
|
||||
const { setLocale } = await import('../i18n/index.js');
|
||||
setLocale('ru');
|
||||
try {
|
||||
assert.equal(formatKnots(9.5), '10 уз');
|
||||
} finally {
|
||||
setLocale('en');
|
||||
}
|
||||
assert.equal(formatKnots(9.5), '10 kn');
|
||||
});
|
||||
@@ -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м');
|
||||
});
|
||||
@@ -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
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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,
|
||||
]);
|
||||
@@ -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'),
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
+1669
-573
File diff suppressed because it is too large.
Load diff
@@ -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'),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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`,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
@@ -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' };
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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), '');
|
||||
});
|
||||
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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({
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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 });
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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 });
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
@@ -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')),
|
||||
}),
|
||||
});
|
||||
@@ -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
@@ -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,
|
||||
);
|
||||
});
|
||||
@@ -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',
|
||||
]);
|
||||
});
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
@@ -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, []);
|
||||
});
|
||||
}
|
||||
@@ -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
Reference in new issue
Block a user